Yuki Takei 2 недель назад
Родитель
Сommit
1a40341434

+ 1 - 1
.kiro/specs/suggest-path/spec.json

@@ -3,7 +3,7 @@
   "created_at": "2026-02-10T12:00:00Z",
   "updated_at": "2026-03-23T00:00:00Z",
   "language": "en",
-  "phase": "implementation-complete",
+  "phase": "refactoring",
   "approvals": {
     "requirements": {
       "generated": true,

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

@@ -103,25 +103,25 @@
 
 See `gap-analysis.md` for detailed rationale.
 
-- [ ] 8. Simplify service layer abstractions
-- [ ] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
+- [x] 8. Simplify service layer abstractions
+- [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
   - Remove the `GenerateSuggestionsDeps` type and `deps` parameter from `generateSuggestions()`
   - Import `analyzeContent`, `evaluateCandidates`, `generateCategorySuggestion`, `resolveParentGrant` directly
   - Accept `searchService` as a direct argument (the only true external dependency that cannot be imported)
   - Rewrite `generate-suggestions.spec.ts` to use `vi.mock()` instead of injected mock deps
   - Simplify the route handler in `routes/apiv3/index.ts` to pass `searchService` directly instead of wiring 5 callbacks
 
-- [ ] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
+- [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
   - Replace `options: RetrieveSearchCandidatesOptions` with a direct `searchService: SearchService` parameter
   - Keep `scoreThreshold` as a module-level constant (no caller overrides it)
   - Update `retrieve-search-candidates.spec.ts` accordingly
   - Update the call site in `generate-suggestions.ts` (no more lambda wrapper needed)
 
-- [ ] 8.3 Add JSDoc to `call-llm-for-json.ts`
+- [x] 8.3 Add JSDoc to `call-llm-for-json.ts`
   - Add a brief JSDoc comment explaining this utility's purpose: shared LLM client initialization, JSON parsing, and response validation
   - Document that it is consumed by `analyzeContent` and `evaluateCandidates`
 
-- [ ] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
+- [x] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
   - Update `SearchService` interface in `suggest-path-types.ts`: change `userGroups: unknown` to `userGroups: ObjectIdLike[]`
   - Propagate the type change to `retrieveSearchCandidates` and `generateSuggestions` signatures
   - Import `ObjectIdLike` from `@growi/core` (or the appropriate subpath)

+ 3 - 1
apps/app/src/features/ai-tools/suggest-path/interfaces/suggest-path-types.ts

@@ -1,5 +1,7 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
 export const SuggestionType = {
   MEMO: 'memo',
   SEARCH: 'search',
@@ -54,7 +56,7 @@ export type SearchService = {
     keyword: string,
     nqName: string | null,
     user: IUserHasId,
-    userGroups: unknown,
+    userGroups: ObjectIdLike[],
     opts: Record<string, unknown>,
   ): Promise<[{ data: SearchResultItem[] }, unknown]>;
 };

+ 2 - 23
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.spec.ts

@@ -18,21 +18,6 @@ vi.mock('../../services/generate-suggestions', () => ({
   generateSuggestions: mocks.generateSuggestionsMock,
 }));
 
-// Mock modules imported by the handler for dependency injection
-vi.mock('../../services/analyze-content', () => ({ analyzeContent: vi.fn() }));
-vi.mock('../../services/evaluate-candidates', () => ({
-  evaluateCandidates: vi.fn(),
-}));
-vi.mock('../../services/generate-category-suggestion', () => ({
-  generateCategorySuggestion: vi.fn(),
-}));
-vi.mock('../../services/retrieve-search-candidates', () => ({
-  retrieveSearchCandidates: vi.fn(),
-}));
-vi.mock('../../services/resolve-parent-grant', () => ({
-  resolveParentGrant: vi.fn(),
-}));
-
 vi.mock('~/server/middlewares/login-required', () => ({
   default: mocks.loginRequiredFactoryMock,
 }));
@@ -110,7 +95,7 @@ describe('suggestPathHandlersFactory', () => {
       return { req, res };
     };
 
-    it('should call generateSuggestions with user, body, userGroups, and deps', async () => {
+    it('should call generateSuggestions with user, body, userGroups, and searchService', async () => {
       const suggestions = [
         {
           type: 'memo',
@@ -133,13 +118,7 @@ describe('suggestPathHandlersFactory', () => {
         { _id: 'user123', username: 'alice' },
         'Some page content',
         ['group1', 'extGroup1'],
-        expect.objectContaining({
-          analyzeContent: expect.any(Function),
-          retrieveSearchCandidates: expect.any(Function),
-          evaluateCandidates: expect.any(Function),
-          generateCategorySuggestion: expect.any(Function),
-          resolveParentGrant: expect.any(Function),
-        }),
+        mockSearchService,
       );
     });
 

+ 1 - 15
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts

@@ -16,12 +16,7 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import loggerFactory from '~/utils/logger';
 
 import type { SearchService } from '../../../interfaces/suggest-path-types';
-import { analyzeContent } from '../../services/analyze-content';
-import { evaluateCandidates } from '../../services/evaluate-candidates';
-import { generateCategorySuggestion } from '../../services/generate-category-suggestion';
 import { generateSuggestions } from '../../services/generate-suggestions';
-import { resolveParentGrant } from '../../services/resolve-parent-grant';
-import { retrieveSearchCandidates } from '../../services/retrieve-search-candidates';
 
 const logger = loggerFactory('growi:features:suggest-path:routes');
 
@@ -88,16 +83,7 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           user,
           req.body.body,
           userGroups,
-          {
-            analyzeContent,
-            retrieveSearchCandidates: (keywords, u, groups) =>
-              retrieveSearchCandidates(keywords, u, groups, {
-                searchService: typedSearchService,
-              }),
-            evaluateCandidates,
-            generateCategorySuggestion,
-            resolveParentGrant,
-          },
+          typedSearchService,
         );
         return res.apiv3({ suggestions });
       } catch (err) {

+ 5 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/call-llm-for-json.ts

@@ -5,6 +5,11 @@ import {
 } from '~/features/openai/server/services/client-delegator';
 import { configManager } from '~/server/service/config-manager';
 
+/**
+ * Shared utility for making LLM calls that return JSON responses.
+ * Handles OpenAI client initialization, JSON parsing, and response validation.
+ * Consumed by `analyzeContent` (1st AI call) and `evaluateCandidates` (2nd AI call).
+ */
 export const callLlmForJson = async <T>(
   systemPrompt: string,
   userMessage: string,

+ 99 - 85
apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.spec.ts

@@ -5,11 +5,17 @@ import type {
   EvaluatedSuggestion,
   PathSuggestion,
   SearchCandidate,
+  SearchService,
 } from '../../interfaces/suggest-path-types';
 
 const mocks = vi.hoisted(() => {
   return {
     generateMemoSuggestionMock: vi.fn(),
+    analyzeContentMock: vi.fn(),
+    retrieveSearchCandidatesMock: vi.fn(),
+    evaluateCandidatesMock: vi.fn(),
+    generateCategorySuggestionMock: vi.fn(),
+    resolveParentGrantMock: vi.fn(),
     loggerErrorMock: vi.fn(),
   };
 });
@@ -18,6 +24,26 @@ vi.mock('./generate-memo-suggestion', () => ({
   generateMemoSuggestion: mocks.generateMemoSuggestionMock,
 }));
 
+vi.mock('./analyze-content', () => ({
+  analyzeContent: mocks.analyzeContentMock,
+}));
+
+vi.mock('./retrieve-search-candidates', () => ({
+  retrieveSearchCandidates: mocks.retrieveSearchCandidatesMock,
+}));
+
+vi.mock('./evaluate-candidates', () => ({
+  evaluateCandidates: mocks.evaluateCandidatesMock,
+}));
+
+vi.mock('./generate-category-suggestion', () => ({
+  generateCategorySuggestion: mocks.generateCategorySuggestionMock,
+}));
+
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: mocks.resolveParentGrantMock,
+}));
+
 vi.mock('~/utils/logger', () => ({
   default: () => ({
     error: mocks.loggerErrorMock,
@@ -29,7 +55,14 @@ const mockUser = {
   username: 'alice',
 } as unknown as IUserHasId;
 
-const mockUserGroups = ['group1', 'group2'];
+const mockUserGroups = [
+  'group1',
+  'group2',
+] as unknown as import('~/server/interfaces/mongoose-utils').ObjectIdLike[];
+
+const mockSearchService = {
+  searchKeyword: vi.fn(),
+} as unknown as SearchService;
 
 const memoSuggestion: PathSuggestion = {
   type: 'memo',
@@ -76,37 +109,9 @@ const categorySuggestion: PathSuggestion = {
 };
 
 describe('generateSuggestions', () => {
-  const createMockDeps = () => ({
-    analyzeContent: vi.fn<(body: string) => Promise<ContentAnalysis>>(),
-    retrieveSearchCandidates:
-      vi.fn<
-        (
-          keywords: string[],
-          user: IUserHasId,
-          userGroups: unknown,
-        ) => Promise<SearchCandidate[]>
-      >(),
-    evaluateCandidates:
-      vi.fn<
-        (
-          body: string,
-          analysis: ContentAnalysis,
-          candidates: SearchCandidate[],
-        ) => Promise<EvaluatedSuggestion[]>
-      >(),
-    generateCategorySuggestion:
-      vi.fn<
-        (candidates: SearchCandidate[]) => Promise<PathSuggestion | null>
-      >(),
-    resolveParentGrant: vi.fn<(path: string) => Promise<number>>(),
-  });
-
-  let mockDeps: ReturnType<typeof createMockDeps>;
-
   beforeEach(() => {
     vi.resetAllMocks();
     mocks.generateMemoSuggestionMock.mockResolvedValue(memoSuggestion);
-    mockDeps = createMockDeps();
   });
 
   const callGenerateSuggestions = async () => {
@@ -115,17 +120,19 @@ describe('generateSuggestions', () => {
       mockUser,
       'Some page content',
       mockUserGroups,
-      mockDeps,
+      mockSearchService,
     );
   };
 
   describe('successful full pipeline', () => {
     beforeEach(() => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockResolvedValue(mockEvaluated);
-      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
-      mockDeps.resolveParentGrant.mockResolvedValue(1);
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
     });
 
     it('should return memo + search + category suggestions when all succeed', async () => {
@@ -164,15 +171,15 @@ describe('generateSuggestions', () => {
     });
 
     it('should resolve grant for each evaluated suggestion path', async () => {
-      mockDeps.resolveParentGrant
+      mocks.resolveParentGrantMock
         .mockResolvedValueOnce(1)
         .mockResolvedValueOnce(4);
 
       const result = await callGenerateSuggestions();
 
-      expect(mockDeps.resolveParentGrant).toHaveBeenCalledTimes(2);
-      expect(mockDeps.resolveParentGrant).toHaveBeenCalledWith('/tech/React/');
-      expect(mockDeps.resolveParentGrant).toHaveBeenCalledWith(
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledTimes(2);
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/tech/React/');
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith(
         '/tech/React/performance/',
       );
       expect(result[1].grant).toBe(1);
@@ -182,23 +189,26 @@ describe('generateSuggestions', () => {
     it('should pass correct arguments to analyzeContent', async () => {
       await callGenerateSuggestions();
 
-      expect(mockDeps.analyzeContent).toHaveBeenCalledWith('Some page content');
+      expect(mocks.analyzeContentMock).toHaveBeenCalledWith(
+        'Some page content',
+      );
     });
 
-    it('should pass keywords from content analysis to retrieveSearchCandidates', async () => {
+    it('should pass keywords, user, userGroups, and searchService to retrieveSearchCandidates', async () => {
       await callGenerateSuggestions();
 
-      expect(mockDeps.retrieveSearchCandidates).toHaveBeenCalledWith(
+      expect(mocks.retrieveSearchCandidatesMock).toHaveBeenCalledWith(
         ['React', 'hooks'],
         mockUser,
         mockUserGroups,
+        mockSearchService,
       );
     });
 
     it('should pass body, analysis, and candidates to evaluateCandidates', async () => {
       await callGenerateSuggestions();
 
-      expect(mockDeps.evaluateCandidates).toHaveBeenCalledWith(
+      expect(mocks.evaluateCandidatesMock).toHaveBeenCalledWith(
         'Some page content',
         mockAnalysis,
         mockCandidates,
@@ -208,7 +218,7 @@ describe('generateSuggestions', () => {
     it('should pass candidates to generateCategorySuggestion', async () => {
       await callGenerateSuggestions();
 
-      expect(mockDeps.generateCategorySuggestion).toHaveBeenCalledWith(
+      expect(mocks.generateCategorySuggestionMock).toHaveBeenCalledWith(
         mockCandidates,
       );
     });
@@ -216,20 +226,20 @@ describe('generateSuggestions', () => {
 
   describe('graceful degradation', () => {
     it('should fall back to memo only when content analysis fails', async () => {
-      mockDeps.analyzeContent.mockRejectedValue(
+      mocks.analyzeContentMock.mockRejectedValue(
         new Error('AI service unavailable'),
       );
 
       const result = await callGenerateSuggestions();
 
       expect(result).toEqual([memoSuggestion]);
-      expect(mockDeps.retrieveSearchCandidates).not.toHaveBeenCalled();
-      expect(mockDeps.evaluateCandidates).not.toHaveBeenCalled();
-      expect(mockDeps.generateCategorySuggestion).not.toHaveBeenCalled();
+      expect(mocks.retrieveSearchCandidatesMock).not.toHaveBeenCalled();
+      expect(mocks.evaluateCandidatesMock).not.toHaveBeenCalled();
+      expect(mocks.generateCategorySuggestionMock).not.toHaveBeenCalled();
     });
 
     it('should log error when content analysis fails', async () => {
-      mockDeps.analyzeContent.mockRejectedValue(
+      mocks.analyzeContentMock.mockRejectedValue(
         new Error('AI service unavailable'),
       );
 
@@ -239,8 +249,8 @@ describe('generateSuggestions', () => {
     });
 
     it('should fall back to memo only when search candidate retrieval fails', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockRejectedValue(
         new Error('Search service down'),
       );
 
@@ -251,12 +261,14 @@ describe('generateSuggestions', () => {
     });
 
     it('should return memo + category when candidate evaluation fails', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockRejectedValue(
         new Error('AI evaluation failed'),
       );
-      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
 
       const result = await callGenerateSuggestions();
 
@@ -265,11 +277,11 @@ describe('generateSuggestions', () => {
     });
 
     it('should return memo + search when category generation fails', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockResolvedValue(mockEvaluated);
-      mockDeps.resolveParentGrant.mockResolvedValue(1);
-      mockDeps.generateCategorySuggestion.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockRejectedValue(
         new Error('Category failed'),
       );
 
@@ -283,8 +295,8 @@ describe('generateSuggestions', () => {
     });
 
     it('should return memo only when both search pipeline and category fail', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockRejectedValue(
         new Error('Search down'),
       );
 
@@ -294,22 +306,22 @@ describe('generateSuggestions', () => {
     });
 
     it('should skip search suggestions when no candidates pass threshold (empty array)', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue([]);
-      mockDeps.generateCategorySuggestion.mockResolvedValue(null);
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue([]);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
 
       const result = await callGenerateSuggestions();
 
       expect(result).toEqual([memoSuggestion]);
-      expect(mockDeps.evaluateCandidates).not.toHaveBeenCalled();
+      expect(mocks.evaluateCandidatesMock).not.toHaveBeenCalled();
     });
 
     it('should omit category when generateCategorySuggestion returns null', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockResolvedValue(mockEvaluated);
-      mockDeps.resolveParentGrant.mockResolvedValue(1);
-      mockDeps.generateCategorySuggestion.mockResolvedValue(null);
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
 
       const result = await callGenerateSuggestions();
 
@@ -324,11 +336,11 @@ describe('generateSuggestions', () => {
         keywords: ['meeting', 'minutes'],
         informationType: 'flow',
       };
-      mockDeps.analyzeContent.mockResolvedValue(flowAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockResolvedValue([mockEvaluated[0]]);
-      mockDeps.resolveParentGrant.mockResolvedValue(1);
-      mockDeps.generateCategorySuggestion.mockResolvedValue(null);
+      mocks.analyzeContentMock.mockResolvedValue(flowAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue([mockEvaluated[0]]);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
 
       const result = await callGenerateSuggestions();
 
@@ -339,12 +351,14 @@ describe('generateSuggestions', () => {
 
   describe('parallel execution', () => {
     it('should run evaluate pipeline and category generation independently', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockRejectedValue(
         new Error('Evaluate failed'),
       );
-      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
 
       const result = await callGenerateSuggestions();
 
@@ -352,11 +366,11 @@ describe('generateSuggestions', () => {
     });
 
     it('should return search suggestions even when category fails', async () => {
-      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
-      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
-      mockDeps.evaluateCandidates.mockResolvedValue(mockEvaluated);
-      mockDeps.resolveParentGrant.mockResolvedValue(1);
-      mockDeps.generateCategorySuggestion.mockRejectedValue(
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockRejectedValue(
         new Error('Category failed'),
       );
 

+ 15 - 26
apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.ts

@@ -1,50 +1,38 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
 import loggerFactory from '~/utils/logger';
 
 import type {
   ContentAnalysis,
-  EvaluatedSuggestion,
   PathSuggestion,
   SearchCandidate,
+  SearchService,
 } from '../../interfaces/suggest-path-types';
 import { SuggestionType } from '../../interfaces/suggest-path-types';
+import { analyzeContent } from './analyze-content';
+import { evaluateCandidates } from './evaluate-candidates';
+import { generateCategorySuggestion } from './generate-category-suggestion';
 import { generateMemoSuggestion } from './generate-memo-suggestion';
+import { resolveParentGrant } from './resolve-parent-grant';
+import { retrieveSearchCandidates } from './retrieve-search-candidates';
 
 const logger = loggerFactory(
   'growi:features:suggest-path:generate-suggestions',
 );
 
-export type GenerateSuggestionsDeps = {
-  analyzeContent: (body: string) => Promise<ContentAnalysis>;
-  retrieveSearchCandidates: (
-    keywords: string[],
-    user: IUserHasId,
-    userGroups: unknown,
-  ) => Promise<SearchCandidate[]>;
-  evaluateCandidates: (
-    body: string,
-    analysis: ContentAnalysis,
-    candidates: SearchCandidate[],
-  ) => Promise<EvaluatedSuggestion[]>;
-  generateCategorySuggestion: (
-    candidates: SearchCandidate[],
-  ) => Promise<PathSuggestion | null>;
-  resolveParentGrant: (path: string) => Promise<number>;
-};
-
 export const generateSuggestions = async (
   user: IUserHasId,
   body: string,
-  userGroups: unknown,
-  deps: GenerateSuggestionsDeps,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
 ): Promise<PathSuggestion[]> => {
   const memoSuggestion = await generateMemoSuggestion(user);
 
   // 1st AI call: Content analysis (keyword extraction + flow/stock classification)
   let analysis: ContentAnalysis;
   try {
-    analysis = await deps.analyzeContent(body);
+    analysis = await analyzeContent(body);
   } catch (err) {
     logger.error('Content analysis failed, falling back to memo only:', err);
     return [memoSuggestion];
@@ -53,10 +41,11 @@ export const generateSuggestions = async (
   // Retrieve search candidates (single ES query, shared by evaluate and category)
   let candidates: SearchCandidate[];
   try {
-    candidates = await deps.retrieveSearchCandidates(
+    candidates = await retrieveSearchCandidates(
       analysis.keywords,
       user,
       userGroups,
+      searchService,
     );
   } catch (err) {
     logger.error(
@@ -71,14 +60,14 @@ export const generateSuggestions = async (
     // Evaluate pipeline: evaluate → grant resolution (skip if no candidates)
     candidates.length > 0
       ? (async (): Promise<PathSuggestion[]> => {
-          const evaluated = await deps.evaluateCandidates(
+          const evaluated = await evaluateCandidates(
             body,
             analysis,
             candidates,
           );
           return Promise.all(
             evaluated.map(async (s): Promise<PathSuggestion> => {
-              const grant = await deps.resolveParentGrant(s.path);
+              const grant = await resolveParentGrant(s.path);
               return {
                 type: SuggestionType.SEARCH,
                 path: s.path,
@@ -92,7 +81,7 @@ export const generateSuggestions = async (
         })()
       : Promise.resolve([]),
     // Category generation (uses same candidates, no extra ES query)
-    deps.generateCategorySuggestion(candidates),
+    generateCategorySuggestion(candidates),
   ]);
 
   const suggestions: PathSuggestion[] = [memoSuggestion];

+ 79 - 49
apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.spec.ts

@@ -47,7 +47,7 @@ describe('retrieveSearchCandidates', () => {
         ['React', 'hooks'],
         mockUser,
         [],
-        { searchService, scoreThreshold: 5 },
+        searchService,
       );
 
       expect(result).toHaveLength(3);
@@ -63,10 +63,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result).toHaveLength(1);
       expect(result[0]).toEqual({
@@ -78,50 +80,56 @@ describe('retrieveSearchCandidates', () => {
   });
 
   describe('threshold filtering', () => {
-    it('should include candidates above the threshold', async () => {
+    it('should include candidates above the default threshold (5.0)', async () => {
       const searchResult = createSearchResult([
         { path: '/tech/React/hooks', score: 15 },
         { path: '/tech/React/state', score: 3 },
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 10,
-      });
+      );
 
       expect(result).toHaveLength(1);
       expect(result[0].pagePath).toBe('/tech/React/hooks');
     });
 
-    it('should exclude candidates below the threshold', async () => {
+    it('should exclude candidates below the default threshold (5.0)', async () => {
       const searchResult = createSearchResult([
         { path: '/tech/React/hooks', score: 3 },
         { path: '/tech/Vue/basics', score: 2 },
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 10,
-      });
+      );
 
       expect(result).toHaveLength(0);
     });
 
-    it('should include candidates at exactly the threshold', async () => {
+    it('should include candidates at exactly the default threshold (5.0)', async () => {
       const searchResult = createSearchResult([
-        { path: '/tech/React/hooks', score: 10 },
+        { path: '/tech/React/hooks', score: 5 },
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 10,
-      });
+      );
 
       expect(result).toHaveLength(1);
-      expect(result[0].score).toBe(10);
+      expect(result[0].score).toBe(5);
     });
 
     it('should filter mixed results correctly', async () => {
@@ -133,15 +141,18 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 10,
-      });
+      );
 
-      expect(result).toHaveLength(2);
+      expect(result).toHaveLength(3);
       expect(result.map((c) => c.pagePath)).toEqual([
         '/tech/React/hooks',
         '/tech/React/state',
+        '/guides/intro',
       ]);
     });
   });
@@ -155,7 +166,7 @@ describe('retrieveSearchCandidates', () => {
         ['nonexistent'],
         mockUser,
         [],
-        { searchService, scoreThreshold: 5 },
+        searchService,
       );
 
       expect(result).toEqual([]);
@@ -168,10 +179,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result).toEqual([]);
     });
@@ -190,10 +203,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('Using React hooks');
     });
@@ -210,10 +225,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('React hooks guide');
     });
@@ -230,10 +247,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('Reactのフックについて');
     });
@@ -244,10 +263,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('');
     });
@@ -264,10 +285,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('React hooks ... custom hooks pattern');
     });
@@ -284,10 +307,12 @@ describe('retrieveSearchCandidates', () => {
       ]);
       const searchService = createMockSearchService(searchResult);
 
-      const result = await retrieveSearchCandidates(['React'], mockUser, [], {
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(result[0].snippet).toBe('React hooks');
     });
@@ -302,7 +327,7 @@ describe('retrieveSearchCandidates', () => {
         ['React', 'hooks', 'useState'],
         mockUser,
         [],
-        { searchService, scoreThreshold: 5 },
+        searchService,
       );
 
       expect(searchService.searchKeyword).toHaveBeenCalledWith(
@@ -317,12 +342,17 @@ describe('retrieveSearchCandidates', () => {
     it('should pass user and userGroups to searchKeyword', async () => {
       const searchResult = createSearchResult([]);
       const searchService = createMockSearchService(searchResult);
-      const mockUserGroups = ['group1', 'group2'];
+      const mockUserGroups = [
+        'group1',
+        'group2',
+      ] as unknown as import('~/server/interfaces/mongoose-utils').ObjectIdLike[];
 
-      await retrieveSearchCandidates(['React'], mockUser, mockUserGroups, {
+      await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        mockUserGroups,
         searchService,
-        scoreThreshold: 5,
-      });
+      );
 
       expect(searchService.searchKeyword).toHaveBeenCalledWith(
         expect.any(String),

+ 6 - 10
apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.ts

@@ -1,19 +1,16 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
 import type {
   SearchCandidate,
   SearchResultItem,
   SearchService,
 } from '../../interfaces/suggest-path-types';
 
-const DEFAULT_SCORE_THRESHOLD = 5.0;
+const SCORE_THRESHOLD = 5.0;
 const SEARCH_RESULT_LIMIT = 20;
 
-export type RetrieveSearchCandidatesOptions = {
-  searchService: SearchService;
-  scoreThreshold?: number;
-};
-
 // Elasticsearch highlights use <em class='highlighted-keyword'> and </em>
 const ES_HIGHLIGHT_TAG_REGEX = /<\/?em[^>]*>/g;
 
@@ -39,10 +36,9 @@ function extractSnippet(item: SearchResultItem): string {
 export const retrieveSearchCandidates = async (
   keywords: string[],
   user: IUserHasId,
-  userGroups: unknown,
-  options: RetrieveSearchCandidatesOptions,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
 ): Promise<SearchCandidate[]> => {
-  const { searchService, scoreThreshold = DEFAULT_SCORE_THRESHOLD } = options;
   const keyword = keywords.join(' ');
 
   const [searchResult] = await searchService.searchKeyword(
@@ -54,7 +50,7 @@ export const retrieveSearchCandidates = async (
   );
 
   return searchResult.data
-    .filter((item) => item._score >= scoreThreshold)
+    .filter((item) => item._score >= SCORE_THRESHOLD)
     .map((item) => ({
       pagePath: item._source.path,
       snippet: extractSnippet(item),