Преглед изворни кода

feat(suggest-path): rewrite orchestration for revised Phase 2 pipeline

Rewrite generateSuggestions to implement the revised Phase 2 pipeline:
analyzeContent (1st AI call) → retrieveSearchCandidates + evaluateCandidates
(2nd AI call) in parallel with generateCategorySuggestion, with independent
error handling via Promise.allSettled.

- Add informationType field to PathSuggestion type
- Resolve grant individually for each evaluated suggestion path
- Map informationType from content analysis onto search-type suggestions
- Graceful degradation: analysis failure → memo-only, search pipeline
  failure → memo + category, category failure → memo + search
- Update route handler with dependency injection for new pipeline
- Update all related tests (unit, handler, integration)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t пре 1 месец
родитељ
комит
84a4ae69e8

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

@@ -79,7 +79,7 @@
   - _Requirements: 4.1, 4.2, 4.3, 4.4_
 
 - [ ] 7. Phase 2 revised orchestration and integration
-- [ ] 7.1 Rewrite orchestration for revised Phase 2 pipeline
+- [x] 7.1 Rewrite orchestration for revised Phase 2 pipeline
   - Rewrite the orchestration function to implement the revised Phase 2 pipeline: always generate memo suggestion first as guaranteed fallback, then invoke content analysis (1st AI call), pass keywords to search candidate retrieval, pass candidates to candidate evaluation (2nd AI call), and run category generation in parallel with the search-evaluate pipeline
   - After candidate evaluation returns, resolve grant for each proposed path via grant resolver
   - Map the informationType from content analysis onto each search-type suggestion in the final response, and add informationType as an optional field on the suggestion type

+ 253 - 110
apps/app/src/server/routes/apiv3/ai-tools/generate-suggestions.spec.ts

@@ -1,12 +1,15 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 
-import type { PathSuggestion } from './suggest-path-types';
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  PathSuggestion,
+  SearchCandidate,
+} from './suggest-path-types';
 
 const mocks = vi.hoisted(() => {
   return {
     generateMemoSuggestionMock: vi.fn(),
-    generateSearchSuggestionMock: vi.fn(),
-    generateCategorySuggestionMock: vi.fn(),
     loggerErrorMock: vi.fn(),
   };
 });
@@ -15,14 +18,6 @@ vi.mock('./generate-memo-suggestion', () => ({
   generateMemoSuggestion: mocks.generateMemoSuggestionMock,
 }));
 
-vi.mock('./generate-search-suggestion', () => ({
-  generateSearchSuggestion: mocks.generateSearchSuggestionMock,
-}));
-
-vi.mock('./generate-category-suggestion', () => ({
-  generateCategorySuggestion: mocks.generateCategorySuggestionMock,
-}));
-
 vi.mock('~/utils/logger', () => ({
   default: () => ({
     error: mocks.loggerErrorMock,
@@ -44,175 +39,275 @@ const memoSuggestion: PathSuggestion = {
   grant: 4,
 };
 
-const searchSuggestion: PathSuggestion = {
-  type: 'search',
-  path: '/tech-notes/React/',
-  label: 'Save near related pages',
-  description: 'Related pages under this directory: hooks, state',
-  grant: 1,
+const mockAnalysis: ContentAnalysis = {
+  keywords: ['React', 'hooks'],
+  informationType: 'stock',
 };
 
+const mockCandidates: SearchCandidate[] = [
+  {
+    pagePath: '/tech/React/hooks',
+    snippet: 'React hooks overview',
+    score: 10.5,
+  },
+  { pagePath: '/tech/React/state', snippet: 'State management', score: 8.2 },
+];
+
+const mockEvaluated: EvaluatedSuggestion[] = [
+  {
+    path: '/tech/React/',
+    label: 'Save near related pages',
+    description:
+      'This area contains React documentation. Your stock content fits well here.',
+  },
+  {
+    path: '/tech/React/performance/',
+    label: 'New section for performance topics',
+    description: 'A new sibling section alongside existing React pages.',
+  },
+];
+
 const categorySuggestion: PathSuggestion = {
   type: 'category',
-  path: '/tech-notes/',
+  path: '/tech/',
   label: 'Save under category',
-  description: 'Top-level category: tech-notes',
+  description: 'Top-level category: tech',
   grant: 1,
 };
 
 describe('generateSuggestions', () => {
-  const mockSearchService = {
-    searchKeyword: vi.fn(),
-  };
+  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<
+        (
+          keywords: string[],
+          user: IUserHasId,
+          userGroups: unknown,
+        ) => Promise<PathSuggestion | null>
+      >(),
+    resolveParentGrant: vi.fn<(path: string) => Promise<number>>(),
+  });
 
-  const mockExtractKeywords = vi.fn();
+  let mockDeps: ReturnType<typeof createMockDeps>;
 
   beforeEach(() => {
     vi.resetAllMocks();
     mocks.generateMemoSuggestionMock.mockResolvedValue(memoSuggestion);
+    mockDeps = createMockDeps();
   });
 
   const callGenerateSuggestions = async () => {
     const { generateSuggestions } = await import('./generate-suggestions');
-    return generateSuggestions(mockUser, 'Some page content', mockUserGroups, {
-      searchService: mockSearchService,
-      extractKeywords: mockExtractKeywords,
-    });
+    return generateSuggestions(
+      mockUser,
+      'Some page content',
+      mockUserGroups,
+      mockDeps,
+    );
   };
 
-  describe('successful multi-suggestion response', () => {
-    it('should return memo, search, and category suggestions when all succeed', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(searchSuggestion);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(
-        categorySuggestion,
-      );
+  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);
+    });
 
+    it('should return memo + search + category suggestions when all succeed', async () => {
       const result = await callGenerateSuggestions();
 
-      expect(result).toEqual([
-        memoSuggestion,
-        searchSuggestion,
-        categorySuggestion,
-      ]);
+      expect(result).toHaveLength(4); // memo + 2 search + 1 category
+      expect(result[0]).toEqual(memoSuggestion);
+      expect(result[1]).toMatchObject({ type: 'search', path: '/tech/React/' });
+      expect(result[2]).toMatchObject({
+        type: 'search',
+        path: '/tech/React/performance/',
+      });
+      expect(result[3]).toEqual(categorySuggestion);
     });
 
     it('should always include memo as the first suggestion', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(searchSuggestion);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(
-        categorySuggestion,
-      );
-
       const result = await callGenerateSuggestions();
 
       expect(result[0]).toEqual(memoSuggestion);
     });
 
-    it('should pass keywords, user, userGroups, and searchService to search generator', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(null);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+    it('should map informationType from content analysis to search-type suggestions', async () => {
+      const result = await callGenerateSuggestions();
+
+      const searchSuggestions = result.filter((s) => s.type === 'search');
+      for (const s of searchSuggestions) {
+        expect(s.informationType).toBe('stock');
+      }
+    });
+
+    it('should not include informationType on memo or category suggestions', async () => {
+      const result = await callGenerateSuggestions();
+
+      expect(result[0].informationType).toBeUndefined(); // memo
+      expect(result[3].informationType).toBeUndefined(); // category
+    });
+
+    it('should resolve grant for each evaluated suggestion path', async () => {
+      mockDeps.resolveParentGrant
+        .mockResolvedValueOnce(1)
+        .mockResolvedValueOnce(4);
+
+      const result = await callGenerateSuggestions();
+
+      expect(mockDeps.resolveParentGrant).toHaveBeenCalledTimes(2);
+      expect(mockDeps.resolveParentGrant).toHaveBeenCalledWith('/tech/React/');
+      expect(mockDeps.resolveParentGrant).toHaveBeenCalledWith(
+        '/tech/React/performance/',
+      );
+      expect(result[1].grant).toBe(1);
+      expect(result[2].grant).toBe(4);
+    });
 
+    it('should pass correct arguments to analyzeContent', async () => {
       await callGenerateSuggestions();
 
-      expect(mocks.generateSearchSuggestionMock).toHaveBeenCalledWith(
+      expect(mockDeps.analyzeContent).toHaveBeenCalledWith('Some page content');
+    });
+
+    it('should pass keywords from content analysis to retrieveSearchCandidates', async () => {
+      await callGenerateSuggestions();
+
+      expect(mockDeps.retrieveSearchCandidates).toHaveBeenCalledWith(
         ['React', 'hooks'],
         mockUser,
         mockUserGroups,
-        mockSearchService,
       );
     });
 
-    it('should pass keywords, user, userGroups, and searchService to category generator', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(null);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+    it('should pass body, analysis, and candidates to evaluateCandidates', async () => {
+      await callGenerateSuggestions();
 
+      expect(mockDeps.evaluateCandidates).toHaveBeenCalledWith(
+        'Some page content',
+        mockAnalysis,
+        mockCandidates,
+      );
+    });
+
+    it('should pass keywords from content analysis to generateCategorySuggestion', async () => {
       await callGenerateSuggestions();
 
-      expect(mocks.generateCategorySuggestionMock).toHaveBeenCalledWith(
+      expect(mockDeps.generateCategorySuggestion).toHaveBeenCalledWith(
         ['React', 'hooks'],
         mockUser,
         mockUserGroups,
-        mockSearchService,
       );
     });
   });
 
-  describe('partial results', () => {
-    it('should omit search suggestion when search returns null', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(null);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(
-        categorySuggestion,
+  describe('graceful degradation', () => {
+    it('should fall back to memo only when content analysis fails', async () => {
+      mockDeps.analyzeContent.mockRejectedValue(
+        new Error('AI service unavailable'),
       );
 
       const result = await callGenerateSuggestions();
 
-      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+      expect(result).toEqual([memoSuggestion]);
+      expect(mockDeps.retrieveSearchCandidates).not.toHaveBeenCalled();
+      expect(mockDeps.evaluateCandidates).not.toHaveBeenCalled();
+      expect(mockDeps.generateCategorySuggestion).not.toHaveBeenCalled();
     });
 
-    it('should omit category suggestion when category returns null', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(searchSuggestion);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+    it('should log error when content analysis fails', async () => {
+      mockDeps.analyzeContent.mockRejectedValue(
+        new Error('AI service unavailable'),
+      );
 
-      const result = await callGenerateSuggestions();
+      await callGenerateSuggestions();
 
-      expect(result).toEqual([memoSuggestion, searchSuggestion]);
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
     });
 
-    it('should return memo only when both search and category return null', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(null);
-      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+    it('should fall back to memo only when content analysis returns empty keywords', async () => {
+      mockDeps.analyzeContent.mockResolvedValue({
+        keywords: [],
+        informationType: 'stock',
+      });
 
       const result = await callGenerateSuggestions();
 
       expect(result).toEqual([memoSuggestion]);
+      expect(mockDeps.retrieveSearchCandidates).not.toHaveBeenCalled();
     });
-  });
 
-  describe('graceful degradation', () => {
-    it('should fall back to memo only when keyword extraction fails', async () => {
-      mockExtractKeywords.mockRejectedValue(
-        new Error('AI service unavailable'),
+    it('should return memo + category when search candidate retrieval fails', async () => {
+      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
+      mockDeps.retrieveSearchCandidates.mockRejectedValue(
+        new Error('Search service down'),
       );
+      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
 
       const result = await callGenerateSuggestions();
 
-      expect(result).toEqual([memoSuggestion]);
-      expect(mocks.generateSearchSuggestionMock).not.toHaveBeenCalled();
-      expect(mocks.generateCategorySuggestionMock).not.toHaveBeenCalled();
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
     });
 
-    it('should log error when keyword extraction fails', async () => {
-      const error = new Error('AI service unavailable');
-      mockExtractKeywords.mockRejectedValue(error);
+    it('should return memo + category when candidate evaluation fails', async () => {
+      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
+      mockDeps.retrieveSearchCandidates.mockResolvedValue(mockCandidates);
+      mockDeps.evaluateCandidates.mockRejectedValue(
+        new Error('AI evaluation failed'),
+      );
+      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
 
-      await callGenerateSuggestions();
+      const result = await callGenerateSuggestions();
 
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
       expect(mocks.loggerErrorMock).toHaveBeenCalled();
     });
 
-    it('should fall back to memo only when keyword extraction returns empty array', async () => {
-      mockExtractKeywords.mockResolvedValue([]);
+    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(
+        new Error('Category failed'),
+      );
 
       const result = await callGenerateSuggestions();
 
-      expect(result).toEqual([memoSuggestion]);
-      expect(mocks.generateSearchSuggestionMock).not.toHaveBeenCalled();
-      expect(mocks.generateCategorySuggestionMock).not.toHaveBeenCalled();
+      expect(result).toHaveLength(3); // memo + 2 search (no category)
+      expect(result[0]).toEqual(memoSuggestion);
+      expect(result[1]).toMatchObject({ type: 'search' });
+      expect(result[2]).toMatchObject({ type: 'search' });
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
     });
 
-    it('should fall back to memo only when search generator throws', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockRejectedValue(
-        new Error('Search service down'),
+    it('should return memo only when both search pipeline and category fail', async () => {
+      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
+      mockDeps.retrieveSearchCandidates.mockRejectedValue(
+        new Error('Search down'),
       );
-      mocks.generateCategorySuggestionMock.mockResolvedValue(
-        categorySuggestion,
+      mockDeps.generateCategorySuggestion.mockRejectedValue(
+        new Error('Category failed'),
       );
 
       const result = await callGenerateSuggestions();
@@ -220,28 +315,76 @@ describe('generateSuggestions', () => {
       expect(result).toEqual([memoSuggestion]);
     });
 
-    it('should fall back to memo only when category generator throws', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockResolvedValue(searchSuggestion);
-      mocks.generateCategorySuggestionMock.mockRejectedValue(
-        new Error('Category generation failed'),
+    it('should skip search suggestions when no candidates pass threshold (empty array)', async () => {
+      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
+      mockDeps.retrieveSearchCandidates.mockResolvedValue([]);
+      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+      expect(mockDeps.evaluateCandidates).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);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toHaveLength(3); // memo + 2 search, no category
+      expect(result.every((s) => s.type !== 'category')).toBe(true);
+    });
+  });
+
+  describe('informationType mapping', () => {
+    it('should map flow informationType to search-type suggestions', async () => {
+      const flowAnalysis: ContentAnalysis = {
+        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);
+
+      const result = await callGenerateSuggestions();
+
+      const searchSuggestion = result.find((s) => s.type === 'search');
+      expect(searchSuggestion?.informationType).toBe('flow');
+    });
+  });
+
+  describe('parallel execution', () => {
+    it('should run search-evaluate pipeline and category generation independently', async () => {
+      mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
+      mockDeps.retrieveSearchCandidates.mockRejectedValue(
+        new Error('Search down'),
       );
+      mockDeps.generateCategorySuggestion.mockResolvedValue(categorySuggestion);
 
       const result = await callGenerateSuggestions();
 
-      expect(result).toEqual([memoSuggestion]);
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
     });
 
-    it('should log error when search or category generator throws', async () => {
-      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
-      mocks.generateSearchSuggestionMock.mockRejectedValue(
-        new Error('Search service down'),
+    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(
+        new Error('Category failed'),
       );
-      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
 
-      await callGenerateSuggestions();
+      const result = await callGenerateSuggestions();
 
-      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+      const searchSuggestions = result.filter((s) => s.type === 'search');
+      expect(searchSuggestions).toHaveLength(2);
     });
   });
 });

+ 83 - 35
apps/app/src/server/routes/apiv3/ai-tools/generate-suggestions.ts

@@ -2,23 +2,37 @@ import type { IUserHasId } from '@growi/core/dist/interfaces';
 
 import loggerFactory from '~/utils/logger';
 
-import { generateCategorySuggestion } from './generate-category-suggestion';
 import { generateMemoSuggestion } from './generate-memo-suggestion';
-import type { SearchService } from './generate-search-suggestion';
-import { generateSearchSuggestion } from './generate-search-suggestion';
-import type { PathSuggestion } from './suggest-path-types';
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  PathSuggestion,
+  SearchCandidate,
+} from './suggest-path-types';
+import { SuggestionType } from './suggest-path-types';
 
 const logger = loggerFactory(
   'growi:routes:apiv3:ai-tools:generate-suggestions',
 );
 
-// Accept unknown for searchService to bridge between the real SearchService class
-// (which returns ISearchResult<unknown>) and the local SearchService interface
-// (which expects SearchResultItem[]). The cast is safe because Elasticsearch results
-// always contain _score and _source.path fields.
 export type GenerateSuggestionsDeps = {
-  searchService: unknown;
-  extractKeywords: (body: string) => Promise<string[]>;
+  analyzeContent: (body: string) => Promise<ContentAnalysis>;
+  retrieveSearchCandidates: (
+    keywords: string[],
+    user: IUserHasId,
+    userGroups: unknown,
+  ) => Promise<SearchCandidate[]>;
+  evaluateCandidates: (
+    body: string,
+    analysis: ContentAnalysis,
+    candidates: SearchCandidate[],
+  ) => Promise<EvaluatedSuggestion[]>;
+  generateCategorySuggestion: (
+    keywords: string[],
+    user: IUserHasId,
+    userGroups: unknown,
+  ) => Promise<PathSuggestion | null>;
+  resolveParentGrant: (path: string) => Promise<number>;
 };
 
 export const generateSuggestions = async (
@@ -29,33 +43,67 @@ export const generateSuggestions = async (
 ): Promise<PathSuggestion[]> => {
   const memoSuggestion = await generateMemoSuggestion(user);
 
+  // 1st AI call: Content analysis (keyword extraction + flow/stock classification)
+  let analysis: ContentAnalysis;
   try {
-    const keywords = await deps.extractKeywords(body);
-
-    if (keywords.length === 0) {
-      return [memoSuggestion];
-    }
-
-    const searchService = deps.searchService as SearchService;
-    const [searchSuggestion, categorySuggestion] = await Promise.all([
-      generateSearchSuggestion(keywords, user, userGroups, searchService),
-      generateCategorySuggestion(keywords, user, userGroups, searchService),
-    ]);
-
-    const suggestions: PathSuggestion[] = [memoSuggestion];
-    if (searchSuggestion != null) {
-      suggestions.push(searchSuggestion);
-    }
-    if (categorySuggestion != null) {
-      suggestions.push(categorySuggestion);
-    }
-
-    return suggestions;
+    analysis = await deps.analyzeContent(body);
   } catch (err) {
-    logger.error(
-      'Phase 2 suggestion generation failed, falling back to memo only:',
-      err,
-    );
+    logger.error('Content analysis failed, falling back to memo only:', err);
     return [memoSuggestion];
   }
+
+  if (analysis.keywords.length === 0) {
+    return [memoSuggestion];
+  }
+
+  // Run search-evaluate pipeline and category generation in parallel
+  const [searchResult, categoryResult] = await Promise.allSettled([
+    // Search-evaluate pipeline: search → evaluate → grant resolution
+    (async (): Promise<PathSuggestion[]> => {
+      const candidates = await deps.retrieveSearchCandidates(
+        analysis.keywords,
+        user,
+        userGroups,
+      );
+      if (candidates.length === 0) {
+        return [];
+      }
+      const evaluated = await deps.evaluateCandidates(
+        body,
+        analysis,
+        candidates,
+      );
+      return Promise.all(
+        evaluated.map(async (s): Promise<PathSuggestion> => {
+          const grant = await deps.resolveParentGrant(s.path);
+          return {
+            type: SuggestionType.SEARCH,
+            path: s.path,
+            label: s.label,
+            description: s.description,
+            grant,
+            informationType: analysis.informationType,
+          };
+        }),
+      );
+    })(),
+    // Category generation (parallel, independent)
+    deps.generateCategorySuggestion(analysis.keywords, user, userGroups),
+  ]);
+
+  const suggestions: PathSuggestion[] = [memoSuggestion];
+
+  if (searchResult.status === 'fulfilled') {
+    suggestions.push(...searchResult.value);
+  } else {
+    logger.error('Search-evaluate pipeline failed:', searchResult.reason);
+  }
+
+  if (categoryResult.status === 'fulfilled' && categoryResult.value != null) {
+    suggestions.push(categoryResult.value);
+  } else if (categoryResult.status === 'rejected') {
+    logger.error('Category generation failed:', categoryResult.reason);
+  }
+
+  return suggestions;
 };

+ 115 - 33
apps/app/src/server/routes/apiv3/ai-tools/suggest-path-integration.spec.ts

@@ -12,8 +12,17 @@ const testState = vi.hoisted(() => ({
   openaiServiceType: 'openai' as string | null,
   disableUserPages: false,
   // Phase 2 controls
-  extractedKeywords: [] as string[],
-  extractKeywordsError: null as Error | null,
+  contentAnalysis: null as {
+    keywords: string[];
+    informationType: 'flow' | 'stock';
+  } | null,
+  contentAnalysisError: null as Error | null,
+  evaluatedSuggestions: [] as Array<{
+    path: string;
+    label: string;
+    description: string;
+  }>,
+  evaluateCandidatesError: null as Error | null,
   parentGrant: 1,
 }));
 
@@ -75,13 +84,26 @@ vi.mock(
   }),
 );
 
-// Mock extractKeywords — configurable per test via testState
-vi.mock('./extract-keywords', () => ({
-  extractKeywords: vi.fn().mockImplementation(() => {
-    if (testState.extractKeywordsError != null) {
-      return Promise.reject(testState.extractKeywordsError);
+// Mock analyzeContent — configurable per test via testState
+vi.mock('./analyze-content', () => ({
+  analyzeContent: vi.fn().mockImplementation(() => {
+    if (testState.contentAnalysisError != null) {
+      return Promise.reject(testState.contentAnalysisError);
     }
-    return Promise.resolve(testState.extractedKeywords);
+    if (testState.contentAnalysis == null) {
+      return Promise.resolve({ keywords: [], informationType: 'stock' });
+    }
+    return Promise.resolve(testState.contentAnalysis);
+  }),
+}));
+
+// Mock evaluateCandidates — configurable per test via testState
+vi.mock('./evaluate-candidates', () => ({
+  evaluateCandidates: vi.fn().mockImplementation(() => {
+    if (testState.evaluateCandidatesError != null) {
+      return Promise.reject(testState.evaluateCandidatesError);
+    }
+    return Promise.resolve(testState.evaluatedSuggestions);
   }),
 }));
 
@@ -102,8 +124,10 @@ describe('POST /suggest-path integration', () => {
     testState.aiEnabled = true;
     testState.openaiServiceType = 'openai';
     testState.disableUserPages = false;
-    testState.extractedKeywords = [];
-    testState.extractKeywordsError = null;
+    testState.contentAnalysis = null;
+    testState.contentAnalysisError = null;
+    testState.evaluatedSuggestions = [];
+    testState.evaluateCandidatesError = null;
     testState.parentGrant = 1;
 
     mockSearchKeyword = vi.fn().mockResolvedValue([{ data: [] }, undefined]);
@@ -223,14 +247,39 @@ describe('POST /suggest-path integration', () => {
 
   describe('Phase 2 — multi-suggestion response', () => {
     const searchResults = [
-      { _score: 10, _source: { path: '/tech-notes/React/hooks-guide' } },
-      { _score: 8, _source: { path: '/tech-notes/React/state-management' } },
-      { _score: 5, _source: { path: '/tech-notes/React/best-practices' } },
+      {
+        _score: 10,
+        _source: { path: '/tech-notes/React/hooks-guide' },
+        _highlight: { body: ['React hooks overview'] },
+      },
+      {
+        _score: 8,
+        _source: { path: '/tech-notes/React/state-management' },
+        _highlight: { body: ['State management'] },
+      },
+      {
+        _score: 5,
+        _source: { path: '/tech-notes/React/best-practices' },
+        _highlight: { body: ['Best practices'] },
+      },
+    ];
+
+    const evaluatedResults = [
+      {
+        path: '/tech-notes/React/',
+        label: 'Save near related pages',
+        description:
+          'This area contains React documentation. Your stock content fits well here.',
+      },
     ];
 
     describe('complete flow with all suggestion types', () => {
-      it('should return memo, search, and category suggestions when keywords extracted and search results found', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+      it('should return memo, search, and category suggestions when analysis succeeds and search results found', async () => {
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -241,14 +290,21 @@ describe('POST /suggest-path integration', () => {
           .send({ body: 'Content about React hooks and state management' })
           .expect(200);
 
-        expect(response.body.suggestions).toHaveLength(3);
+        expect(response.body.suggestions.length).toBeGreaterThanOrEqual(3);
         expect(response.body.suggestions[0].type).toBe('memo');
         expect(response.body.suggestions[1].type).toBe('search');
-        expect(response.body.suggestions[2].type).toBe('category');
+        // Category is the last suggestion
+        const lastSuggestion =
+          response.body.suggestions[response.body.suggestions.length - 1];
+        expect(lastSuggestion.type).toBe('category');
       });
 
       it('should return correct memo suggestion alongside Phase 2 suggestions', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -268,8 +324,12 @@ describe('POST /suggest-path integration', () => {
         });
       });
 
-      it('should return search suggestion with parent directory path and related page titles in description', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+      it('should return search suggestion with AI-evaluated path and description', async () => {
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -285,13 +345,18 @@ describe('POST /suggest-path integration', () => {
         expect(searchSuggestion.path).toBe('/tech-notes/React/');
         expect(searchSuggestion.label).toBe('Save near related pages');
         expect(searchSuggestion.description).toBe(
-          'Related pages under this directory: hooks-guide, state-management, best-practices',
+          'This area contains React documentation. Your stock content fits well here.',
         );
         expect(searchSuggestion.grant).toBe(1);
+        expect(searchSuggestion.informationType).toBe('stock');
       });
 
       it('should return category suggestion with top-level segment path and category name in description', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -302,7 +367,8 @@ describe('POST /suggest-path integration', () => {
           .send({ body: 'Content about React hooks' })
           .expect(200);
 
-        const categorySuggestion = response.body.suggestions[2];
+        const categorySuggestion =
+          response.body.suggestions[response.body.suggestions.length - 1];
         expect(categorySuggestion.type).toBe('category');
         expect(categorySuggestion.path).toBe('/tech-notes/');
         expect(categorySuggestion.label).toBe('Save under category');
@@ -315,7 +381,11 @@ describe('POST /suggest-path integration', () => {
 
     describe('response structure verification', () => {
       it('should have trailing slashes on all suggestion paths', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -332,7 +402,11 @@ describe('POST /suggest-path integration', () => {
       });
 
       it('should include all required fields in every suggestion', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -358,7 +432,11 @@ describe('POST /suggest-path integration', () => {
       });
 
       it('should include grant values as numbers for all suggestion types', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = evaluatedResults;
         mockSearchKeyword.mockResolvedValue([
           { data: searchResults },
           undefined,
@@ -376,8 +454,8 @@ describe('POST /suggest-path integration', () => {
     });
 
     describe('graceful degradation', () => {
-      it('should return memo-only when keyword extraction fails', async () => {
-        testState.extractKeywordsError = new Error('AI service unavailable');
+      it('should return memo-only when content analysis fails', async () => {
+        testState.contentAnalysisError = new Error('AI service unavailable');
 
         const response = await request(app)
           .post('/suggest-path')
@@ -388,8 +466,8 @@ describe('POST /suggest-path integration', () => {
         expect(response.body.suggestions[0].type).toBe('memo');
       });
 
-      it('should return memo-only when keyword extraction returns empty array', async () => {
-        // testState.extractedKeywords is [] by default
+      it('should return memo-only when content analysis returns empty keywords', async () => {
+        // testState.contentAnalysis is null by default → returns { keywords: [], informationType: 'stock' }
 
         const response = await request(app)
           .post('/suggest-path')
@@ -400,8 +478,12 @@ describe('POST /suggest-path integration', () => {
         expect(response.body.suggestions[0].type).toBe('memo');
       });
 
-      it('should omit search and category suggestions when search returns no results', async () => {
-        testState.extractedKeywords = ['React', 'hooks'];
+      it('should omit search suggestions when search returns no results above threshold', async () => {
+        testState.contentAnalysis = {
+          keywords: ['React', 'hooks'],
+          informationType: 'stock',
+        };
+        testState.evaluatedSuggestions = [];
         mockSearchKeyword.mockResolvedValue([{ data: [] }, undefined]);
 
         const response = await request(app)
@@ -414,7 +496,7 @@ describe('POST /suggest-path integration', () => {
       });
 
       it('should return correct memo structure even when Phase 2 degrades', async () => {
-        testState.extractKeywordsError = new Error('AI service unavailable');
+        testState.contentAnalysisError = new Error('AI service unavailable');
 
         const response = await request(app)
           .post('/suggest-path')

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

@@ -13,6 +13,7 @@ export type PathSuggestion = {
   label: string;
   description: string;
   grant: number;
+  informationType?: InformationType;
 };
 
 export type InformationType = 'flow' | 'stock';

+ 16 - 7
apps/app/src/server/routes/apiv3/ai-tools/suggest-path.spec.ts

@@ -7,7 +7,6 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 const mocks = vi.hoisted(() => {
   return {
     generateSuggestionsMock: vi.fn(),
-    extractKeywordsMock: vi.fn(),
     loginRequiredFactoryMock: vi.fn(),
     certifyAiServiceMock: vi.fn(),
     findAllUserGroupIdsMock: vi.fn(),
@@ -19,9 +18,16 @@ vi.mock('./generate-suggestions', () => ({
   generateSuggestions: mocks.generateSuggestionsMock,
 }));
 
-vi.mock('./extract-keywords', () => ({
-  extractKeywords: mocks.extractKeywordsMock,
+// Mock modules imported by the handler for dependency injection
+vi.mock('./analyze-content', () => ({ analyzeContent: vi.fn() }));
+vi.mock('./evaluate-candidates', () => ({ evaluateCandidates: vi.fn() }));
+vi.mock('./generate-category-suggestion', () => ({
+  generateCategorySuggestion: vi.fn(),
 }));
+vi.mock('./retrieve-search-candidates', () => ({
+  retrieveSearchCandidates: vi.fn(),
+}));
+vi.mock('./resolve-parent-grant', () => ({ resolveParentGrant: vi.fn() }));
 
 vi.mock('~/server/middlewares/login-required', () => ({
   default: mocks.loginRequiredFactoryMock,
@@ -123,10 +129,13 @@ describe('suggestPathHandlersFactory', () => {
         { _id: 'user123', username: 'alice' },
         'Some page content',
         ['group1', 'extGroup1'],
-        {
-          searchService: mockSearchService,
-          extractKeywords: mocks.extractKeywordsMock,
-        },
+        expect.objectContaining({
+          analyzeContent: expect.any(Function),
+          retrieveSearchCandidates: expect.any(Function),
+          evaluateCandidates: expect.any(Function),
+          generateCategorySuggestion: expect.any(Function),
+          resolveParentGrant: expect.any(Function),
+        }),
       );
     });
 

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

@@ -15,8 +15,14 @@ import UserGroupRelation from '~/server/models/user-group-relation';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 
-import { extractKeywords } from './extract-keywords';
+import { analyzeContent } from './analyze-content';
+import { evaluateCandidates } from './evaluate-candidates';
+import { generateCategorySuggestion } from './generate-category-suggestion';
+import type { SearchService as CategorySearchService } from './generate-search-suggestion';
 import { generateSuggestions } from './generate-suggestions';
+import { resolveParentGrant } from './resolve-parent-grant';
+import type { SearchService } from './retrieve-search-candidates';
+import { retrieveSearchCandidates } from './retrieve-search-candidates';
 
 const logger = loggerFactory('growi:routes:apiv3:ai-tools:suggest-path');
 
@@ -71,7 +77,22 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
           user,
           req.body.body,
           userGroups,
-          { searchService, extractKeywords },
+          {
+            analyzeContent,
+            retrieveSearchCandidates: (keywords, u, groups) =>
+              retrieveSearchCandidates(keywords, u, groups, {
+                searchService: searchService as unknown as SearchService,
+              }),
+            evaluateCandidates,
+            generateCategorySuggestion: (keywords, u, groups) =>
+              generateCategorySuggestion(
+                keywords,
+                u,
+                groups,
+                searchService as unknown as CategorySearchService,
+              ),
+            resolveParentGrant,
+          },
         );
         return res.apiv3({ suggestions });
       } catch (err) {