From db84129a17192d6a9047d20ff578eb8f85099ba5 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 20:46:44 +0100 Subject: [PATCH] feat: add count_posts aggregation tool to AI SDK and MCP server --- src/main/engine/MCPServer.ts | 39 +++++++ src/main/engine/PostEngine.ts | 121 +++++++++++++++++++++ src/main/engine/ai/blog-tools.ts | 32 ++++++ tests/engine/MCPServer.integration.test.ts | 1 + tests/engine/MCPServer.test.ts | 35 ++++++ tests/engine/PostEngine.test.ts | 102 +++++++++++++++++ tests/engine/ai-sdk-phase2.test.ts | 1 + tests/engine/blog-tools.test.ts | 69 +++++++++++- 8 files changed, 398 insertions(+), 2 deletions(-) diff --git a/src/main/engine/MCPServer.ts b/src/main/engine/MCPServer.ts index 9a659cf..09d3ed0 100644 --- a/src/main/engine/MCPServer.ts +++ b/src/main/engine/MCPServer.ts @@ -74,6 +74,7 @@ interface PostEngineContract { getLinkedBy: (postId: string) => Promise>; getLinksTo: (postId: string) => Promise>; getPostsFiltered: (filter: PostFilter) => Promise; + getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record[]; totalPosts: number }>; } interface MediaEngineContract { @@ -567,6 +568,44 @@ export class MCPServer { } return { content }; }); + + // ── count_posts ── + server.registerTool('count_posts', { + title: 'Count Posts', + description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without full post data — ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.', + inputSchema: { + groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'), + year: z.number().optional().describe('Filter to posts in this year'), + month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'), + status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'), + category: z.string().optional().describe('Filter by category'), + tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'), + }, + annotations: { readOnlyHint: true, openWorldHint: false }, + }, async (args) => { + if (args.month && !args.year) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ error: 'month requires year. Example: year: 2025, month: 3' }) }], + isError: true, + }; + } + + const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {}; + if (args.year !== undefined) filter.year = args.year; + if (args.month !== undefined) filter.month = args.month; + if (args.status) filter.status = args.status; + if (args.category) filter.category = args.category; + if (args.tags) filter.tags = args.tags; + + const result = await this.deps.postEngine.getPostCounts( + args.groupBy, + Object.keys(filter).length > 0 ? filter : undefined, + ); + + return { + content: [{ type: 'text' as const, text: JSON.stringify(result) }], + }; + }); } private registerProposalTools(server: McpServer): void { diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index b4c96c9..1437f11 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -984,6 +984,127 @@ export class PostEngine extends EventEmitter { } } + /** + * Server-side aggregation: count posts grouped by one or more dimensions. + * Returns flat groups with counts — avoids transferring full post data for analytics. + */ + async getPostCounts( + groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, + filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }, + ): Promise<{ groups: Record[]; totalPosts: number }> { + const client = getDatabase().getLocalClient(); + if (!client) return { groups: [], totalPosts: 0 }; + + // Build SELECT expressions and GROUP BY columns + const selectExprs: string[] = []; + const groupByCols: string[] = []; + const joins: string[] = []; + + for (const dim of groupBy) { + switch (dim) { + case 'year': + selectExprs.push("CAST(strftime('%Y', posts.created_at) AS INTEGER) AS g_year"); + groupByCols.push('g_year'); + break; + case 'month': + selectExprs.push("CAST(strftime('%m', posts.created_at) AS INTEGER) AS g_month"); + groupByCols.push('g_month'); + break; + case 'tag': + selectExprs.push('t.value AS g_tag'); + joins.push('JOIN json_each(posts.tags) AS t'); + groupByCols.push('g_tag'); + break; + case 'category': + selectExprs.push('c.value AS g_category'); + joins.push('JOIN json_each(posts.categories) AS c'); + groupByCols.push('g_category'); + break; + case 'status': + selectExprs.push('posts.status AS g_status'); + groupByCols.push('g_status'); + break; + } + } + + selectExprs.push('COUNT(*) AS cnt'); + + // Build WHERE conditions + const conditions: string[] = ['posts.project_id = ?']; + const args: (string | number)[] = [this.currentProjectId]; + + if (filter?.year !== undefined) { + const start = `${filter.year}-01-01`; + const end = `${filter.year + 1}-01-01`; + conditions.push('posts.created_at >= ?'); + args.push(start); + conditions.push('posts.created_at < ?'); + args.push(end); + } + if (filter?.month !== undefined && filter?.year !== undefined) { + const start = `${filter.year}-${String(filter.month).padStart(2, '0')}-01`; + const endMonth = filter.month === 12 ? 1 : filter.month + 1; + const endYear = filter.month === 12 ? filter.year + 1 : filter.year; + const end = `${endYear}-${String(endMonth).padStart(2, '0')}-01`; + conditions.push('posts.created_at >= ?'); + args.push(start); + conditions.push('posts.created_at < ?'); + args.push(end); + } + if (filter?.status) { + conditions.push('posts.status = ?'); + args.push(filter.status); + } + if (filter?.category) { + conditions.push( + `EXISTS (SELECT 1 FROM json_each(posts.categories) AS fc WHERE fc.value = ?)`, + ); + args.push(filter.category); + } + if (filter?.tags && filter.tags.length > 0) { + for (const tag of filter.tags) { + conditions.push( + `EXISTS (SELECT 1 FROM json_each(posts.tags) AS ft WHERE ft.value = ?)`, + ); + args.push(tag); + } + } + + const sql = ` + SELECT ${selectExprs.join(', ')} + FROM posts + ${joins.join(' ')} + WHERE ${conditions.join(' AND ')} + GROUP BY ${groupByCols.join(', ')} + ORDER BY cnt DESC + `; + + try { + const result = await client.execute({ sql, args }); + + // Map dimension aliases back to clean names + const dimMap: Record = { + g_year: 'year', g_month: 'month', g_tag: 'tag', + g_category: 'category', g_status: 'status', + }; + + const groups: Record[] = result.rows.map((row: any) => { + const group: Record = {}; + for (const col of groupByCols) { + group[dimMap[col]] = row[col]; + } + group.count = Number(row.cnt); + return group; + }); + + const totalPosts = groups.reduce((sum, g) => sum + (g.count as number), 0); + return { groups, totalPosts }; + } catch (error) { + console.error('getPostCounts failed:', error); + return { groups: [], totalPosts: 0 }; + } + } + async getAvailableTags(): Promise { const allPosts = await this.getAllPostsUnpaginated(); const tags = new Set(); diff --git a/src/main/engine/ai/blog-tools.ts b/src/main/engine/ai/blog-tools.ts index e40ed2d..eb08b4c 100644 --- a/src/main/engine/ai/blog-tools.ts +++ b/src/main/engine/ai/blog-tools.ts @@ -39,6 +39,7 @@ export interface BlogToolDeps { categoryCount: number; }>; getDashboardStats: () => Promise<{ totalPosts: number; draftCount: number; publishedCount: number; archivedCount: number }>; + getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record[]; totalPosts: number }>; }; mediaEngine: { getMedia: (id: string) => Promise; @@ -444,6 +445,37 @@ export function createBlogTools(deps: BlogToolDeps) { }, }), + count_posts: tool({ + description: 'Count posts grouped by one or more dimensions (year, month, tag, category, status). Returns aggregated counts without transferring full post data. Ideal for analytics, heat maps, and distribution overviews. Example: groupBy=["month","tag"] with year=2004 returns post counts per month per tag.', + inputSchema: z.object({ + groupBy: z.array(z.enum(['year', 'month', 'tag', 'category', 'status'])).describe('Dimensions to group by (1-3 recommended)'), + year: z.number().optional().describe('Filter to posts in this year'), + month: z.number().optional().describe('Filter to posts in this month (1-12). Requires year.'), + status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'), + category: z.string().optional().describe('Filter by category'), + tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'), + }), + execute: async ({ groupBy, year, month, status, category, tags }) => { + if (month !== undefined && year === undefined) { + return { success: false, error: 'month requires year. Example: year: 2025, month: 3' }; + } + const filter: { year?: number; month?: number; status?: string; category?: string; tags?: string[] } = {}; + if (year !== undefined) filter.year = year; + if (month !== undefined) filter.month = month; + if (status) filter.status = status; + if (category) filter.category = category; + if (tags && tags.length > 0) filter.tags = tags; + + const result = await postEngine.getPostCounts(groupBy, Object.keys(filter).length > 0 ? filter : undefined); + return { + success: true, + groupCount: result.groups.length, + totalPosts: result.totalPosts, + groups: result.groups, + }; + }, + }), + list_categories: tool({ description: 'List all categories used across blog posts, with the count of posts in each category.', inputSchema: z.object({}), diff --git a/tests/engine/MCPServer.integration.test.ts b/tests/engine/MCPServer.integration.test.ts index 9375a0d..dc5d01a 100644 --- a/tests/engine/MCPServer.integration.test.ts +++ b/tests/engine/MCPServer.integration.test.ts @@ -22,6 +22,7 @@ function createMockDeps(): MCPServerDependencies { getLinkedBy: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]), + getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }), searchPostsFiltered: vi.fn().mockResolvedValue({ posts: [], total: 0 }), }), getMediaEngine: () => ({ diff --git a/tests/engine/MCPServer.test.ts b/tests/engine/MCPServer.test.ts index 8f0572c..f7fbcd3 100644 --- a/tests/engine/MCPServer.test.ts +++ b/tests/engine/MCPServer.test.ts @@ -25,6 +25,7 @@ function createMockPostEngine() { getLinkedBy: vi.fn().mockResolvedValue([]), getLinksTo: vi.fn().mockResolvedValue([]), getPostsFiltered: vi.fn().mockResolvedValue([]), + getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }), }; } @@ -190,6 +191,11 @@ describe('MCPServer', () => { expect(hasRegistered(mcpServer, '_registeredTools', 'propose_post_metadata')).toBe(true); }); + it('registers count_posts tool', () => { + const mcpServer = server.createMcpServer(); + expect(hasRegistered(mcpServer, '_registeredTools', 'count_posts')).toBe(true); + }); + it('registers accept_proposal tool', () => { const mcpServer = server.createMcpServer(); expect(hasRegistered(mcpServer, '_registeredTools', 'accept_proposal')).toBe(true); @@ -840,6 +846,35 @@ describe('MCPServer', () => { ); }); + // ── count_posts ────────────────────────────────────────────────── + + it('count_posts calls getPostCounts with correct args', async () => { + mockPostEngine.getPostCounts.mockResolvedValue({ + groups: [ + { month: 1, tag: 'Politik', count: 12 }, + { month: 2, tag: 'Politik', count: 5 }, + ], + totalPosts: 300, + }); + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'count_posts'); + const result = await tool.handler({ groupBy: ['month', 'tag'], year: 2004 }, {}) as { content: Array<{ text: string }> }; + expect(mockPostEngine.getPostCounts).toHaveBeenCalledWith( + ['month', 'tag'], + { year: 2004 }, + ); + const parsed = JSON.parse(result.content[0].text); + expect(parsed.totalPosts).toBe(300); + expect(parsed.groups).toHaveLength(2); + }); + + it('count_posts returns error when month without year', async () => { + const mcpServer = server.createMcpServer(); + const tool = getTool(mcpServer, 'count_posts'); + const result = await tool.handler({ groupBy: ['tag'], month: 6 }, {}) as { content: Array<{ text: string }>; isError?: boolean }; + expect(result.isError).toBe(true); + }); + it('draft_post creates a draft and stores proposal', async () => { const createdPost = { id: 'new-post', title: 'Draft Title', status: 'draft' }; mockPostEngine.createPost.mockResolvedValue(createdPost); diff --git a/tests/engine/PostEngine.test.ts b/tests/engine/PostEngine.test.ts index 9c78cc9..cbfffcf 100644 --- a/tests/engine/PostEngine.test.ts +++ b/tests/engine/PostEngine.test.ts @@ -2495,6 +2495,108 @@ Published snapshot content`); }); }); + describe('getPostCounts', () => { + it('should return empty groups when no posts exist', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + const result = await postEngine.getPostCounts(['year']); + expect(result).toEqual({ groups: [], totalPosts: 0 }); + }); + + it('should group by year', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { g_year: 2024, cnt: 15 }, + { g_year: 2023, cnt: 10 }, + ], + }); + + const result = await postEngine.getPostCounts(['year']); + expect(result.groups).toEqual([ + { year: 2024, count: 15 }, + { year: 2023, count: 10 }, + ]); + expect(result.totalPosts).toBe(25); + }); + + it('should group by month and tag with year filter', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { g_month: 1, g_tag: 'Politik', cnt: 12 }, + { g_month: 1, g_tag: 'Medien', cnt: 8 }, + { g_month: 2, g_tag: 'Politik', cnt: 5 }, + ], + }); + + const result = await postEngine.getPostCounts(['month', 'tag'], { year: 2004 }); + expect(result.groups).toEqual([ + { month: 1, tag: 'Politik', count: 12 }, + { month: 1, tag: 'Medien', count: 8 }, + { month: 2, tag: 'Politik', count: 5 }, + ]); + expect(result.totalPosts).toBe(25); + }); + + it('should group by category and status', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ + rows: [ + { g_category: 'article', g_status: 'published', cnt: 20 }, + { g_category: 'wiki', g_status: 'draft', cnt: 3 }, + ], + }); + + const result = await postEngine.getPostCounts(['category', 'status']); + expect(result.groups).toEqual([ + { category: 'article', status: 'published', count: 20 }, + { category: 'wiki', status: 'draft', count: 3 }, + ]); + }); + + it('should include year and month filters in SQL WHERE', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.getPostCounts(['tag'], { year: 2004, month: 6 }); + + const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('json_each'); + expect(sql).toContain('group by'); + expect(sql).toContain('created_at >='); + expect(sql).toContain('created_at <'); + }); + + it('should include status filter in SQL WHERE', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.getPostCounts(['year'], { status: 'published' }); + + const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain("status = ?"); + }); + + it('should include category filter in SQL WHERE', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.getPostCounts(['month'], { year: 2024, category: 'tech' }); + + const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('json_each'); + expect(sql).toContain('categories'); + }); + + it('should include tags filter in SQL WHERE', async () => { + mockLocalClient.execute.mockResolvedValueOnce({ rows: [] }); + + await postEngine.getPostCounts(['year'], { tags: ['js', 'react'] }); + + const call = mockLocalClient.execute.mock.calls[0]?.[0] as { sql?: string; args?: any[] } | undefined; + const sql = call?.sql?.toLowerCase() ?? ''; + expect(sql).toContain('json_each'); + }); + }); + describe('getTagsWithCounts', () => { it('should return empty array when no posts have tags', async () => { vi.mocked(mockLocalDb.select).mockImplementation(() => { diff --git a/tests/engine/ai-sdk-phase2.test.ts b/tests/engine/ai-sdk-phase2.test.ts index eb635a0..8f395a9 100644 --- a/tests/engine/ai-sdk-phase2.test.ts +++ b/tests/engine/ai-sdk-phase2.test.ts @@ -54,6 +54,7 @@ function createMockBlogToolDeps(): BlogToolDeps { getAllPosts: vi.fn(), getPostsFiltered: vi.fn(), searchPostsFiltered: vi.fn(), + getPostCounts: vi.fn(), getCategoriesWithCounts: vi.fn().mockResolvedValue([]), getTagsWithCounts: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]), diff --git a/tests/engine/blog-tools.test.ts b/tests/engine/blog-tools.test.ts index e8fa53d..2d0f47d 100644 --- a/tests/engine/blog-tools.test.ts +++ b/tests/engine/blog-tools.test.ts @@ -17,6 +17,7 @@ function createMockDeps(): BlogToolDeps { getAllPosts: vi.fn(), getPostsFiltered: vi.fn(), searchPostsFiltered: vi.fn(), + getPostCounts: vi.fn(), getCategoriesWithCounts: vi.fn().mockResolvedValue([]), getTagsWithCounts: vi.fn().mockResolvedValue([]), getLinkedBy: vi.fn().mockResolvedValue([]), @@ -71,9 +72,9 @@ describe('Blog Tools — createBlogTools', () => { tools = createBlogTools(deps); }); - it('returns all 16 tools', () => { + it('returns all 17 tools', () => { const names = Object.keys(tools); - expect(names).toHaveLength(16); + expect(names).toHaveLength(17); expect(names).toContain('check_term'); expect(names).toContain('search_posts'); expect(names).toContain('read_post'); @@ -84,6 +85,7 @@ describe('Blog Tools — createBlogTools', () => { expect(names).toContain('update_media_metadata'); expect(names).toContain('list_tags'); expect(names).toContain('list_categories'); + expect(names).toContain('count_posts'); expect(names).toContain('get_blog_stats'); expect(names).toContain('view_image'); expect(names).toContain('get_post_backlinks'); @@ -429,6 +431,69 @@ describe('Blog Tools — update_media_metadata', () => { }); }); +// --------------------------------------------------------------------------- +// count_posts +// --------------------------------------------------------------------------- + +describe('Blog Tools — count_posts', () => { + let deps: BlogToolDeps; + let tools: ReturnType; + + beforeEach(() => { + deps = createMockDeps(); + tools = createBlogTools(deps); + }); + + it('calls getPostCounts with groupBy and filters', async () => { + vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({ + groups: [ + { month: 1, tag: 'Politik', count: 12 }, + { month: 2, tag: 'Politik', count: 5 }, + ], + totalPosts: 200, + }); + + const result = await tools.count_posts.execute!( + { groupBy: ['month', 'tag'], year: 2004 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith( + ['month', 'tag'], + { year: 2004 }, + ); + expect(result).toMatchObject({ + success: true, + totalPosts: 200, + groupCount: 2, + }); + expect((result as any).groups).toHaveLength(2); + }); + + it('passes all optional filters', async () => { + vi.mocked(deps.postEngine.getPostCounts).mockResolvedValueOnce({ + groups: [], + totalPosts: 0, + }); + + await tools.count_posts.execute!( + { groupBy: ['status'], year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(deps.postEngine.getPostCounts).toHaveBeenCalledWith( + ['status'], + { year: 2024, month: 6, status: 'published', category: 'tech', tags: ['js'] }, + ); + }); + + it('returns error when month without year', async () => { + const result = await tools.count_posts.execute!( + { groupBy: ['tag'], month: 3 }, + { toolCallId: 'tc1', messages: [], abortSignal: new AbortController().signal }, + ); + expect(result).toMatchObject({ success: false, error: expect.stringContaining('month requires year') }); + }); +}); + // --------------------------------------------------------------------------- // list_tags / list_categories // ---------------------------------------------------------------------------