Browse Source

feat(suggest-path): add AI-based candidate evaluation and path proposal (2nd AI call)

Implement CandidateEvaluator that delegates to GROWI AI for evaluating
search candidates and proposing optimal save locations using three
structural patterns (parent/subdirectory/sibling), with flow/stock
alignment as a ranking factor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 month ago
parent
commit
7a4a320034

+ 1 - 1
.kiro/specs/suggest-path/tasks.md

@@ -56,7 +56,7 @@
   - Include unit tests for: multi-result retrieval, threshold filtering (candidates above/below/at threshold), empty result handling, and correct candidate structure
   - _Requirements: 3.1, 3.2, 3.5, 5.3_
 
-- [ ] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
+- [x] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
   - Implement candidate evaluation that delegates to GROWI AI for a single AI call evaluating search candidates for content-destination fit
   - Evaluate each candidate's suitability by passing the content body, the content analysis results (keywords and informationType from the 1st AI call), and each candidate's path and search snippet
   - For each suitable candidate, propose a save location using one of three structural patterns relative to the matching page: (a) parent directory, (b) subdirectory under the matching page, (c) sibling directory alongside the matching page

+ 500 - 0
apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.spec.ts

@@ -0,0 +1,500 @@
+import { evaluateCandidates } from './evaluate-candidates';
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  SearchCandidate,
+} from './suggest-path-types';
+
+const mocks = vi.hoisted(() => {
+  return {
+    chatCompletionMock: vi.fn(),
+    getClientMock: vi.fn(),
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+  };
+});
+
+vi.mock('~/features/openai/server/services/client-delegator', () => ({
+  getClient: mocks.getClientMock,
+  isStreamResponse: (result: unknown) => {
+    return (
+      result != null &&
+      typeof result === 'object' &&
+      Symbol.asyncIterator in (result as Record<symbol, unknown>)
+    );
+  },
+}));
+
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: mocks.configManagerMock,
+}));
+
+const stockAnalysis: ContentAnalysis = {
+  keywords: ['React', 'hooks', 'useState'],
+  informationType: 'stock',
+};
+
+const flowAnalysis: ContentAnalysis = {
+  keywords: ['sprint', 'retrospective'],
+  informationType: 'flow',
+};
+
+const sampleCandidates: SearchCandidate[] = [
+  {
+    pagePath: '/tech/React/hooks',
+    snippet: 'React hooks guide for state management',
+    score: 15,
+  },
+  {
+    pagePath: '/tech/React/state',
+    snippet: 'Managing state in React applications',
+    score: 12,
+  },
+];
+
+function mockAiResponse(suggestions: EvaluatedSuggestion[]) {
+  mocks.chatCompletionMock.mockResolvedValue({
+    choices: [
+      {
+        message: {
+          content: JSON.stringify(suggestions),
+        },
+      },
+    ],
+  });
+}
+
+describe('evaluateCandidates', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+      if (key === 'openai:serviceType') return 'openai';
+      return undefined;
+    });
+    mocks.getClientMock.mockReturnValue({
+      chatCompletion: mocks.chatCompletionMock,
+    });
+  });
+
+  describe('path pattern selection across all three patterns', () => {
+    it('should return parent directory pattern suggestion', async () => {
+      const parentSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near related pages',
+        description:
+          'This directory contains React documentation including hooks and state management.',
+      };
+      mockAiResponse([parentSuggestion]);
+
+      const result = await evaluateCandidates(
+        'A guide to React hooks',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+
+    it('should return subdirectory pattern suggestion', async () => {
+      const subdirSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/hooks/advanced/',
+        label: 'Save near related pages',
+        description:
+          'Advanced hooks content fits under the existing hooks documentation.',
+      };
+      mockAiResponse([subdirSuggestion]);
+
+      const result = await evaluateCandidates(
+        'Advanced React hooks patterns',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/hooks/advanced/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+
+    it('should return sibling directory pattern suggestion', async () => {
+      const siblingSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/performance/',
+        label: 'New section for performance topics',
+        description:
+          'A new section alongside existing React documentation for performance content.',
+      };
+      mockAiResponse([siblingSuggestion]);
+
+      const result = await evaluateCandidates(
+        'React performance optimization',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/performance/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+  });
+
+  describe('sibling path generation at correct hierarchy level', () => {
+    it('should generate sibling paths at the same level as the candidate page', async () => {
+      const candidates: SearchCandidate[] = [
+        {
+          pagePath: '/docs/frontend/React/basics',
+          snippet: 'React basics introduction',
+          score: 10,
+        },
+      ];
+      const siblingSuggestion: EvaluatedSuggestion = {
+        path: '/docs/frontend/React/advanced/',
+        label: 'New section for advanced topics',
+        description: 'Sibling section at the same level as the basics page.',
+      };
+      mockAiResponse([siblingSuggestion]);
+
+      const result = await evaluateCandidates(
+        'Advanced React patterns',
+        stockAnalysis,
+        candidates,
+      );
+
+      // Sibling path should be at the same depth as the candidate
+      const candidateDepth = '/docs/frontend/React/basics'
+        .split('/')
+        .filter(Boolean).length;
+      const resultDepth = result[0].path
+        .replace(/\/$/, '')
+        .split('/')
+        .filter(Boolean).length;
+      expect(resultDepth).toBe(candidateDepth);
+    });
+  });
+
+  describe('AI-generated description quality', () => {
+    it('should include non-empty descriptions for each suggestion', async () => {
+      const suggestions: EvaluatedSuggestion[] = [
+        {
+          path: '/tech/React/',
+          label: 'Save near related pages',
+          description:
+            'Contains documentation about React hooks and state management patterns.',
+        },
+        {
+          path: '/tech/React/hooks/custom/',
+          label: 'Save under hooks section',
+          description:
+            'Custom hooks content fits naturally under the existing hooks documentation.',
+        },
+      ];
+      mockAiResponse(suggestions);
+
+      const result = await evaluateCandidates(
+        'Custom React hooks',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(2);
+      for (const suggestion of result) {
+        expect(suggestion.description).toBeTruthy();
+        expect(suggestion.description.length).toBeGreaterThan(0);
+      }
+    });
+  });
+
+  describe('ranking order', () => {
+    it('should preserve AI-determined ranking order in results', async () => {
+      const rankedSuggestions: EvaluatedSuggestion[] = [
+        {
+          path: '/tech/React/hooks/',
+          label: 'Best match',
+          description: 'Closest content-destination fit.',
+        },
+        {
+          path: '/tech/React/',
+          label: 'Good match',
+          description: 'Broader category match.',
+        },
+      ];
+      mockAiResponse(rankedSuggestions);
+
+      const result = await evaluateCandidates(
+        'React hooks guide',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(2);
+      expect(result[0].path).toBe('/tech/React/hooks/');
+      expect(result[1].path).toBe('/tech/React/');
+    });
+  });
+
+  describe('flow/stock alignment consideration', () => {
+    it('should pass informationType to AI for ranking consideration', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/meetings/2025/',
+        label: 'Save near meeting notes',
+        description: 'Flow content fits well in the meetings area.',
+      };
+      mockAiResponse([suggestion]);
+
+      await evaluateCandidates(
+        'Sprint retrospective notes from today',
+        flowAnalysis,
+        [
+          {
+            pagePath: '/meetings/2025/01',
+            snippet: 'January meeting',
+            score: 10,
+          },
+        ],
+      );
+
+      // Verify the AI receives informationType in the prompt
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('flow'),
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should pass stock informationType to AI for ranking consideration', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near documentation',
+        description: 'Stock content aligns with reference documentation.',
+      };
+      mockAiResponse([suggestion]);
+
+      await evaluateCandidates(
+        'React hooks documentation',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('stock'),
+            }),
+          ]),
+        }),
+      );
+    });
+  });
+
+  describe('AI invocation details', () => {
+    it('should pass content body to AI', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates(
+        'My custom React hooks article',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('My custom React hooks article'),
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should pass candidate paths and snippets to AI, not full page bodies', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates(
+        'React hooks guide',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      const call = mocks.chatCompletionMock.mock.calls[0][0];
+      const userMessage = call.messages.find(
+        (m: { role: string }) => m.role === 'user',
+      );
+      expect(userMessage.content).toContain('/tech/React/hooks');
+      expect(userMessage.content).toContain(
+        'React hooks guide for state management',
+      );
+    });
+
+    it('should include a system prompt with evaluation instructions', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates('test content', stockAnalysis, sampleCandidates);
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'system',
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should not use streaming mode', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates('test content', stockAnalysis, sampleCandidates);
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.not.objectContaining({
+          stream: true,
+        }),
+      );
+    });
+  });
+
+  describe('empty and edge cases', () => {
+    it('should return empty array when AI evaluates no candidates as suitable', async () => {
+      mockAiResponse([]);
+
+      const result = await evaluateCandidates(
+        'Unrelated content',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toEqual([]);
+    });
+
+    it('should handle single candidate input', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near related pages',
+        description: 'Single candidate evaluation.',
+      };
+      mockAiResponse([suggestion]);
+
+      const result = await evaluateCandidates('React content', stockAnalysis, [
+        sampleCandidates[0],
+      ]);
+
+      expect(result).toHaveLength(1);
+    });
+  });
+
+  describe('failure propagation', () => {
+    it('should throw when chatCompletion rejects', async () => {
+      mocks.chatCompletionMock.mockRejectedValue(new Error('API error'));
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow('API error');
+    });
+
+    it('should throw when AI returns invalid JSON', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: 'not valid json' } }],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when AI returns non-array JSON', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                path: '/test/',
+                label: 'test',
+                description: 'test',
+              }),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when choices array is empty', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when message content is null', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: null } }],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw on streaming response', async () => {
+      const streamMock = {
+        [Symbol.asyncIterator]: () => ({}),
+      };
+      mocks.chatCompletionMock.mockResolvedValue(streamMock);
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when suggestion item is missing required fields', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify([{ path: '/tech/' }]),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when suggestion path does not end with trailing slash', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify([
+                { path: '/tech/React', label: 'test', description: 'test' },
+              ]),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+  });
+});

+ 138 - 0
apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.ts

@@ -0,0 +1,138 @@
+import type { OpenaiServiceType } from '~/features/openai/interfaces/ai';
+import { instructionsForInformationTypes } from '~/features/openai/server/services/assistant/instructions/commons';
+import {
+  getClient,
+  isStreamResponse,
+} from '~/features/openai/server/services/client-delegator';
+import { configManager } from '~/server/service/config-manager';
+
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  SearchCandidate,
+} from './suggest-path-types';
+
+const SYSTEM_PROMPT = [
+  'You are a page save location evaluator for a wiki system. ',
+  'Given content to be saved, its analysis (keywords and information type), and a list of search candidate pages, ',
+  "evaluate each candidate's suitability as a save location and propose optimal directory paths.\n\n",
+  '## Path Proposal Patterns\n',
+  'For each suitable candidate, propose a save location using ONE of three structural patterns:\n',
+  '(a) **Parent directory**: The parent directory of the matching page (e.g., candidate `/tech/React/hooks` → propose `/tech/React/`)\n',
+  '(b) **Subdirectory**: A subdirectory under the matching page (e.g., candidate `/tech/React/hooks` → propose `/tech/React/hooks/advanced/`)\n',
+  '(c) **Sibling directory**: A new directory alongside the matching page at the SAME hierarchy level ',
+  '(e.g., candidate `/tech/React/hooks` → propose `/tech/React/performance/`). ',
+  'The generated path MUST be at the same depth as the candidate page.\n\n',
+  '## Flow/Stock Information Type\n',
+  instructionsForInformationTypes,
+  '\n\n',
+  'Use flow/stock alignment between the content and candidate locations as a RANKING FACTOR, not a hard filter.\n\n',
+  '## Output Format\n',
+  'Return a JSON array of suggestion objects, ranked by content-destination fit (best first).\n',
+  'Each object must have:\n',
+  '- "path": Directory path with trailing slash (e.g., "/tech/React/")\n',
+  '- "label": Short display label for the suggestion\n',
+  '- "description": Explanation of why this location is suitable, considering content relevance and flow/stock alignment\n\n',
+  'Return an empty array `[]` if no candidates are suitable.\n',
+  'Return only the JSON array, no other text.',
+].join('');
+
+function buildUserMessage(
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): string {
+  const candidateList = candidates
+    .map(
+      (c, i) =>
+        `${i + 1}. Path: ${c.pagePath}\n   Snippet: ${c.snippet}\n   Score: ${c.score}`,
+    )
+    .join('\n');
+
+  return [
+    '## Content to Save\n',
+    body,
+    '\n\n## Content Analysis\n',
+    `Keywords: ${analysis.keywords.join(', ')}\n`,
+    `Information Type: ${analysis.informationType}\n`,
+    '\n## Search Candidates\n',
+    candidateList,
+  ].join('');
+}
+
+const isValidEvaluatedSuggestion = (
+  item: unknown,
+): item is EvaluatedSuggestion => {
+  if (item == null || typeof item !== 'object') {
+    return false;
+  }
+
+  const obj = item as Record<string, unknown>;
+
+  if (typeof obj.path !== 'string' || !obj.path.endsWith('/')) {
+    return false;
+  }
+
+  if (typeof obj.label !== 'string' || obj.label.length === 0) {
+    return false;
+  }
+
+  if (typeof obj.description !== 'string' || obj.description.length === 0) {
+    return false;
+  }
+
+  return true;
+};
+
+export const evaluateCandidates = async (
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): Promise<EvaluatedSuggestion[]> => {
+  const openaiServiceType = configManager.getConfig(
+    'openai:serviceType',
+  ) as OpenaiServiceType;
+  const client = getClient({ openaiServiceType });
+
+  const userMessage = buildUserMessage(body, analysis, candidates);
+
+  const completion = await client.chatCompletion({
+    model: 'gpt-4.1-nano',
+    messages: [
+      { role: 'system', content: SYSTEM_PROMPT },
+      { role: 'user', content: userMessage },
+    ],
+  });
+
+  if (isStreamResponse(completion)) {
+    throw new Error('Unexpected streaming response from chatCompletion');
+  }
+
+  const choice = completion.choices[0];
+  if (choice == null) {
+    throw new Error('No choices returned from chatCompletion');
+  }
+
+  const content = choice.message.content;
+  if (content == null) {
+    throw new Error('No content returned from chatCompletion');
+  }
+
+  const parsed: unknown = JSON.parse(content);
+
+  if (!Array.isArray(parsed)) {
+    throw new Error(
+      'Invalid candidate evaluation response: expected JSON array',
+    );
+  }
+
+  for (const item of parsed) {
+    if (!isValidEvaluatedSuggestion(item)) {
+      throw new Error(
+        'Invalid suggestion in evaluation response: each item must have path (ending with /), label, and description',
+      );
+    }
+  }
+
+  return parsed as EvaluatedSuggestion[];
+};

+ 6 - 0
apps/app/src/server/routes/apiv3/ai-tools/suggest-path-types.ts

@@ -28,6 +28,12 @@ export type SearchCandidate = {
   score: number;
 };
 
+export type EvaluatedSuggestion = {
+  path: string;
+  label: string;
+  description: string;
+};
+
 export type SuggestPathResponse = {
   suggestions: PathSuggestion[];
 };