Răsfoiți Sursa

feat(ai-tools): implement keyword extraction for suggest-path

Add extractKeywords function that delegates content keyword extraction
to the existing GROWI AI (OpenAI/Azure OpenAI) feature module via
chatCompletion. Returns 3-5 keywords as a JSON array, prioritizing
proper nouns and technical terms. Throws on failure so the caller
can handle fallback logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 lună în urmă
părinte
comite
5bf7ff3b01

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

@@ -36,7 +36,7 @@
   - Include unit tests for grant lookup with existing page, missing page, and various grant values
   - _Requirements: 7.1, 7.2, 2.4_
 
-- [ ] 3. (P) Implement content keyword extraction via GROWI AI
+- [x] 3. (P) Implement content keyword extraction via GROWI AI
   - Implement a function that accepts content body and delegates keyword extraction to the existing AI feature module
   - Return 3-5 keywords prioritizing proper nouns and technical terms, avoiding generic words
   - On extraction failure, throw an error so the caller can handle fallback logic

+ 178 - 0
apps/app/src/server/routes/apiv3/ai-tools/extract-keywords.spec.ts

@@ -0,0 +1,178 @@
+import { extractKeywords } from './extract-keywords';
+
+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,
+}));
+
+describe('extractKeywords', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+      if (key === 'openai:serviceType') return 'openai';
+      return undefined;
+    });
+    mocks.getClientMock.mockReturnValue({
+      chatCompletion: mocks.chatCompletionMock,
+    });
+  });
+
+  describe('successful extraction', () => {
+    it('should return an array of keywords from AI response', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '["React", "hooks", "useState"]' } }],
+      });
+
+      const result = await extractKeywords(
+        'A guide to React hooks and useState',
+      );
+
+      expect(result).toEqual(['React', 'hooks', 'useState']);
+    });
+
+    it('should return 3-5 keywords', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content:
+                '["TypeScript", "generics", "type inference", "mapped types", "conditional types"]',
+            },
+          },
+        ],
+      });
+
+      const result = await extractKeywords(
+        'TypeScript generics and advanced types',
+      );
+
+      expect(result.length).toBeGreaterThanOrEqual(1);
+      expect(result.length).toBeLessThanOrEqual(5);
+    });
+
+    it('should pass content body to chatCompletion as user message', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '["MongoDB"]' } }],
+      });
+
+      await extractKeywords('MongoDB aggregation pipeline');
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: 'MongoDB aggregation pipeline',
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should use a system prompt instructing keyword extraction', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '["Next.js"]' } }],
+      });
+
+      await extractKeywords('Next.js routing');
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'system',
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should not use streaming mode', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '["keyword"]' } }],
+      });
+
+      await extractKeywords('test content');
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.not.objectContaining({
+          stream: true,
+        }),
+      );
+    });
+  });
+
+  describe('empty results', () => {
+    it('should return empty array when AI returns empty JSON array', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '[]' } }],
+      });
+
+      const result = await extractKeywords('...');
+
+      expect(result).toEqual([]);
+    });
+
+    it('should return empty array when AI returns null content', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: null } }],
+      });
+
+      const result = await extractKeywords('...');
+
+      expect(result).toEqual([]);
+    });
+  });
+
+  describe('failure scenarios', () => {
+    it('should throw when chatCompletion rejects', async () => {
+      mocks.chatCompletionMock.mockRejectedValue(new Error('API error'));
+
+      await expect(extractKeywords('test')).rejects.toThrow('API error');
+    });
+
+    it('should throw when AI returns invalid JSON', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: 'not valid json' } }],
+      });
+
+      await expect(extractKeywords('test')).rejects.toThrow();
+    });
+
+    it('should throw when AI returns non-array JSON', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: '{"key": "value"}' } }],
+      });
+
+      await expect(extractKeywords('test')).rejects.toThrow();
+    });
+
+    it('should throw when choices array is empty', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [],
+      });
+
+      await expect(extractKeywords('test')).rejects.toThrow();
+    });
+  });
+});

+ 51 - 0
apps/app/src/server/routes/apiv3/ai-tools/extract-keywords.ts

@@ -0,0 +1,51 @@
+import type { OpenaiServiceType } from '~/features/openai/interfaces/ai';
+import {
+  getClient,
+  isStreamResponse,
+} from '~/features/openai/server/services/client-delegator';
+import { configManager } from '~/server/service/config-manager';
+
+const SYSTEM_PROMPT = [
+  'Extract 3 to 5 search keywords from the following content.',
+  'Prioritize proper nouns and technical terms.',
+  'Avoid generic or common words.',
+  'Return the result as a JSON array of strings.',
+  'Example: ["React", "useState", "hooks"]',
+  'Return only the JSON array, no other text.',
+].join('');
+
+export const extractKeywords = async (body: string): Promise<string[]> => {
+  const openaiServiceType = configManager.getConfig(
+    'openai:serviceType',
+  ) as OpenaiServiceType;
+  const client = getClient({ openaiServiceType });
+
+  const completion = await client.chatCompletion({
+    model: 'gpt-4.1-nano',
+    messages: [
+      { role: 'system', content: SYSTEM_PROMPT },
+      { role: 'user', content: body },
+    ],
+  });
+
+  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) {
+    return [];
+  }
+
+  const parsed: unknown = JSON.parse(content);
+  if (!Array.isArray(parsed)) {
+    throw new Error('Expected JSON array from keyword extraction');
+  }
+
+  return parsed as string[];
+};