diff --git a/__tests__/feeds.ts b/__tests__/feeds.ts index ee24a9b79e..acd528729c 100644 --- a/__tests__/feeds.ts +++ b/__tests__/feeds.ts @@ -671,8 +671,8 @@ describe('query feed', () => { }; const QUERY = ` - query Feed($ranking: Ranking, $first: Int, $after: String, $version: Int, $unreadOnly: Boolean, $supportedTypes: [String!]) { - feed(ranking: $ranking, first: $first, after: $after, version: $version, unreadOnly: $unreadOnly, supportedTypes: $supportedTypes) { + query Feed($ranking: Ranking, $first: Int, $after: String, $version: Int, $unreadOnly: Boolean, $supportedTypes: [String!], $noAi: Boolean) { + feed(ranking: $ranking, first: $first, after: $after, version: $version, unreadOnly: $unreadOnly, supportedTypes: $supportedTypes, noAi: $noAi) { ${feedFields()} } } @@ -689,6 +689,45 @@ describe('query feed', () => { expect(res.data).toMatchSnapshot(); }); + it('should include no-ai blocked tags and title words in the v2 feed config', async () => { + loggedUser = '1'; + await saveFeedFixtures(); + nock('http://localhost:6002') + .post('/config') + .reply(200, { + user_id: '1', + config: { + providers: {}, + }, + }); + nock('http://localhost:6000') + .post('/feed.json', (body) => { + expect(body.blocked_tags).toEqual( + expect.arrayContaining(['golang', 'ai', 'openai']), + ); + expect(body.blocked_title_words).toEqual( + expect.arrayContaining(['Claude', 'Elon Musk']), + ); + + return true; + }) + .reply(200, { + data: [{ post_id: 'p1' }, { post_id: 'p4' }], + cursor: 'b', + }); + + const res = await client.query(QUERY, { + variables: { + ...variables, + noAi: true, + version: 20, + }, + }); + + expect(res.errors).toBeFalsy(); + expect(res.data.feed.edges.length).toEqual(2); + }); + describe('youtube content', () => { beforeEach(async () => { await saveFixtures(con, YouTubePost, videoPostsFixture); diff --git a/src/common/feedGenerator.ts b/src/common/feedGenerator.ts index 247f11ecc9..c22a170eb7 100644 --- a/src/common/feedGenerator.ts +++ b/src/common/feedGenerator.ts @@ -705,6 +705,7 @@ export const anonymousFeedBuilder = ( whereNotTags(filters.blockedTags!, builder, alias), ); } + return newBuilder; }; diff --git a/src/common/noAiFilter.ts b/src/common/noAiFilter.ts new file mode 100644 index 0000000000..08b546b22e --- /dev/null +++ b/src/common/noAiFilter.ts @@ -0,0 +1,169 @@ +export const NO_AI_BLOCKED_TAGS = [ + 'ai', + 'machine-learning', + 'data-science', + 'llm', + 'deep-learning', + 'nlp', + 'genai', + 'openai', + 'chatgpt', + 'computer-vision', + 'ai-agents', + 'neural-networks', + 'pytorch', + 'tensorflow', + 'rag', + 'reinforcement-learning', + 'transformers', + 'prompt-engineering', + 'claude', + 'gpt', + 'vector-search', + 'anthropic', + 'google-gemini', + 'conversational-ai', + 'langchain', + 'mlops', + 'ai-coding', + 'ai-safety', + 'ai-security', + 'embeddings', + 'scikit', + 'aiops', + 'ai-assisted-development', + 'keras', + 'prompt-injection', + 'claude-code', + 'llama', + 'responsible-ai', + 'agentic-ai', + 'stable-diffusion', + 'huggingface', + 'vertex-ai', + 'ollama', + 'unsupervised-learning', + 'bert', + 'text-to-speech', + 'ai-governance', + 'ai-infrastructure', + 'text-generation', + 'midjourney', + 'vibe-coding', + 'diffusion-models', + 'deepseek', + 'mistral-ai', + 'grok', + 'explainable-ai', + 'ai-regulation', + 'google-ai', + 'vllm', + 'spring-ai', + 'ai-inference', + 'voice-ai', + 'stability-ai', + 'perplexity', + 'weaviate', + 'brain-computer-interface', + 'microsoft-copilot', + 'generative-art', + 'azure-openai', + 'dalle', + 'cohere', + 'edge-ai', + 'sora', + 'amazon-q', + 'llmops', + 'ai-engineering', + 'pinecone', + 'gemma', + 'local-ai', + 'azure-ai', + 'windsurf', + 'gpt-store', + 'codewhisperer', + 'duet-ai', +] as const; + +export const NO_AI_BLOCKED_WORDS = [ + 'ChatGPT', + 'GPT', + 'GPT-4', + 'GPT-5', + 'OpenAI', + 'Claude', + 'Claude Code', + 'Anthropic', + 'Gemini', + 'Copilot', + 'DeepSeek', + 'Mistral', + 'LLaMA', + 'Llama', + 'Ollama', + 'Qwen', + 'Mixtral', + 'Groq', + 'Cohere', + 'Codex', + 'DALL-E', + 'Sora', + 'Midjourney', + 'Stable Diffusion', + 'Gemma', + 'MiniMax', + 'Cursor', + 'Windsurf', + 'Devin', + 'Tabnine', + 'Codeium', + 'Supermaven', + 'LangChain', + 'LangSmith', + 'HuggingFace', + 'Hugging Face', + 'vLLM', + 'Perplexity', + 'Pinecone', + 'Weaviate', + 'LLM', + 'GenAI', + 'AGI', + 'NLP', + 'RAG', + 'AI agent', + 'AI agents', + 'agentic', + 'vibe coding', + 'vibe-coding', + 'machine learning', + 'deep learning', + 'neural network', + 'fine-tuning', + 'fine-tune', + 'embeddings', + 'transformer', + 'diffusion model', + 'prompt engineering', + 'prompt injection', + 'inference', + 'AI coding', + 'AI code', + 'AI-generated', + 'AI-powered', + 'AI-driven', + 'AI-assisted', + 'AI tool', + 'AI tools', + 'AI model', + 'AI models', + 'AI safety', + 'AI security', + 'AI governance', + 'AI regulation', + 'Dario Amodei', + 'Sam Altman', + 'Jensen Huang', + 'Elon Musk', + 'xAI', +] as const; diff --git a/src/integrations/feed/generators.ts b/src/integrations/feed/generators.ts index 07761a52d4..fc300a38c1 100644 --- a/src/integrations/feed/generators.ts +++ b/src/integrations/feed/generators.ts @@ -2,6 +2,7 @@ import { baseFeedConfig, DynamicConfig, FeedConfigGenerator, + FeedConfigGeneratorResult, FeedConfigName, FeedResponse, FeedVersion, @@ -47,6 +48,23 @@ export class FeedGenerator { extraMetadata, ); } + + withConfigTransform( + transform: ( + result: FeedConfigGeneratorResult, + ) => FeedConfigGeneratorResult | Promise, + ): FeedGenerator { + const baseConfig = this.config; + + return new FeedGenerator( + this.client, + { + generate: async (ctx, opts) => + transform(await baseConfig.generate(ctx, opts)), + }, + this.feedId, + ); + } } const garmFeedService = new GarmrService({ diff --git a/src/schema/feeds.ts b/src/schema/feeds.ts index 70e7c8005e..061fe0334a 100644 --- a/src/schema/feeds.ts +++ b/src/schema/feeds.ts @@ -87,6 +87,7 @@ import { randomUUID } from 'crypto'; import { SourceMemberRoles } from '../roles'; import { ContentPreferenceKeyword } from '../entity/contentPreference/ContentPreferenceKeyword'; import { briefingPostIdsMaxItems } from '../common/brief'; +import { NO_AI_BLOCKED_TAGS, NO_AI_BLOCKED_WORDS } from '../common/noAiFilter'; interface GQLTagsCategory { id: string; @@ -394,6 +395,11 @@ export const typeDefs = /* GraphQL */ ` Array of supported post types """ supportedTypes: [String!] + + """ + Exclude AI-related content from the feed + """ + noAi: Boolean = false ): PostConnection! @auth """ @@ -1120,6 +1126,7 @@ interface AnonymousFeedArgs extends FeedArgs { interface ConfiguredFeedArgs extends FeedArgs { unreadOnly: boolean; version: number; + noAi?: boolean; } interface SourceFeedArgs extends FeedArgs { @@ -1364,6 +1371,27 @@ const anonymousFeedResolverV1: IFieldResolver< { allowPrivatePosts: false }, ); +const mergeUniqueStrings = ( + current: string[] | undefined, + additions: readonly string[], +): string[] => Array.from(new Set([...(current ?? []), ...additions])); + +const wrapGeneratorWithNoAi = (generator: FeedGenerator): FeedGenerator => + generator.withConfigTransform((result) => ({ + ...result, + config: { + ...result.config, + blocked_tags: mergeUniqueStrings( + result.config.blocked_tags, + NO_AI_BLOCKED_TAGS, + ), + blocked_title_words: mergeUniqueStrings( + result.config.blocked_title_words, + NO_AI_BLOCKED_WORDS, + ), + }, + })); + const feedResolverV1: IFieldResolver = feedResolver( ( @@ -1387,10 +1415,8 @@ const feedResolverV1: IFieldResolver = feedPageGenerator, applyFeedPaging, { - fetchQueryParams: async (ctx, args) => { - const feedId = args.feedId || ctx.userId; - return feedToFilters(ctx.con, feedId, ctx.userId); - }, + fetchQueryParams: async (ctx, args) => + feedToFilters(ctx.con, args.feedId || ctx.userId, ctx.userId), allowPrivatePosts: false, }, ); @@ -1567,22 +1593,26 @@ export const resolvers: IResolvers = { }, feed: (source, args: ConfiguredFeedArgs, ctx: Context, info) => { if (args.version >= 2 && args.ranking === Ranking.POPULARITY) { + const generator = versionToFeedGenerator(args.version); + return feedResolverCursor( source, { ...(args as FeedArgs), - generator: versionToFeedGenerator(args.version), + generator: args.noAi ? wrapGeneratorWithNoAi(generator) : generator, }, ctx, info, ); } if (args.version >= 2 && args.ranking === Ranking.TIME) { + const generator = versionToTimeFeedGenerator(args.version); + return feedResolverCursor( source, { ...(args as FeedArgs), - generator: versionToTimeFeedGenerator(args.version), + generator: args.noAi ? wrapGeneratorWithNoAi(generator) : generator, }, ctx, info,