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

feat(ai-tools): implement category-based path suggestion for suggest-path

Add generateCategorySuggestion that extracts top-level path segments
from search results to suggest broad category directories for saving
content, complementing the finer-grained search-based suggestion.

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

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

@@ -43,7 +43,7 @@
   - Include unit tests for successful extraction, empty results, and failure scenarios
   - _Requirements: 5.1, 5.2_
 
-- [ ] 4. Search and category suggestion generators
+- [x] 4. Search and category suggestion generators
 - [x] 4.1 (P) Implement search-based path suggestion
   - Implement a function that accepts extracted keywords and searches for related existing pages using the search service
   - Select the most relevant result and extract its parent directory as the suggested save location
@@ -53,7 +53,7 @@
   - Include unit tests for result selection, parent directory extraction, description generation, grant resolution, and empty-result handling
   - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 5.2, 6.3, 6.5_
 
-- [ ] 4.2 (P) Implement category-based path suggestion
+- [x] 4.2 (P) Implement category-based path suggestion
   - Implement a function that accepts extracted keywords and searches for matching pages scoped to top-level directories
   - Extract the top-level path segment from the most relevant result as the suggested category directory
   - Generate a description from the top-level segment name — purely mechanical, no AI

+ 312 - 0
apps/app/src/server/routes/apiv3/ai-tools/generate-category-suggestion.spec.ts

@@ -0,0 +1,312 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import {
+  extractTopLevelSegment,
+  generateCategoryDescription,
+  generateCategorySuggestion,
+} from './generate-category-suggestion';
+
+const mocks = vi.hoisted(() => {
+  return {
+    resolveParentGrantMock: vi.fn(),
+  };
+});
+
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: mocks.resolveParentGrantMock,
+}));
+
+const GRANT_PUBLIC = 1;
+const GRANT_OWNER = 4;
+
+function createSearchResult(pages: { path: string; score: number }[]) {
+  return {
+    data: pages.map((p) => ({
+      _id: `id-${p.path}`,
+      _score: p.score,
+      _source: { path: p.path },
+    })),
+    meta: { total: pages.length, hitsCount: pages.length },
+  };
+}
+
+function createMockSearchService(
+  result: ReturnType<typeof createSearchResult>,
+) {
+  return {
+    searchKeyword: vi.fn().mockResolvedValue([result, 'DEFAULT']),
+  };
+}
+
+const mockUser = { _id: 'user1', username: 'alice' } as unknown as IUserHasId;
+
+describe('extractTopLevelSegment', () => {
+  it('should extract top-level segment from nested path', () => {
+    expect(extractTopLevelSegment('/tech-notes/React/hooks')).toBe(
+      '/tech-notes/',
+    );
+  });
+
+  it('should extract top-level segment from two-level path', () => {
+    expect(extractTopLevelSegment('/tech-notes/React')).toBe('/tech-notes/');
+  });
+
+  it('should extract top-level segment from single-level path', () => {
+    expect(extractTopLevelSegment('/tech-notes')).toBe('/tech-notes/');
+  });
+
+  it('should return root for root path', () => {
+    expect(extractTopLevelSegment('/')).toBe('/');
+  });
+});
+
+describe('generateCategoryDescription', () => {
+  it('should generate description from segment name', () => {
+    expect(generateCategoryDescription('tech-notes')).toBe(
+      'Top-level category: tech-notes',
+    );
+  });
+
+  it('should handle single word segment', () => {
+    expect(generateCategoryDescription('guides')).toBe(
+      'Top-level category: guides',
+    );
+  });
+});
+
+describe('generateCategorySuggestion', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+  });
+
+  describe('when search returns results', () => {
+    it('should return a suggestion with type "category"', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React', 'hooks'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.type).toBe('category');
+    });
+
+    it('should extract top-level segment from top result path', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/guides/TypeScript/basics', score: 8 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toBe('/tech-notes/');
+    });
+
+    it('should return path with trailing slash', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toMatch(/\/$/);
+    });
+
+    it('should extract top-level even from deeply nested path', async () => {
+      const searchResult = createSearchResult([
+        { path: '/guides/a/b/c/d', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['keyword'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toBe('/guides/');
+    });
+
+    it('should generate description from top-level segment name', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.description).toBe('Top-level category: tech-notes');
+    });
+
+    it('should have label "Save under category"', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.label).toBe('Save under category');
+    });
+
+    it('should resolve grant from top-level directory', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/tech-notes/');
+      expect(result?.grant).toBe(GRANT_PUBLIC);
+    });
+
+    it('should return GRANT_OWNER when parent page not found', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const searchResult = createSearchResult([
+        { path: '/nonexistent/page', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['keyword'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.grant).toBe(GRANT_OWNER);
+    });
+
+    it('should join keywords with spaces for search query', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      await generateCategorySuggestion(
+        ['React', 'hooks', 'useState'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(searchService.searchKeyword).toHaveBeenCalledWith(
+        'React hooks useState',
+        null,
+        mockUser,
+        [],
+        expect.objectContaining({ limit: expect.any(Number) }),
+      );
+    });
+
+    it('should pass user and userGroups to searchKeyword', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+      const mockUserGroups = ['group1', 'group2'];
+
+      await generateCategorySuggestion(
+        ['React'],
+        mockUser,
+        mockUserGroups,
+        searchService,
+      );
+
+      expect(searchService.searchKeyword).toHaveBeenCalledWith(
+        expect.any(String),
+        null,
+        mockUser,
+        mockUserGroups,
+        expect.any(Object),
+      );
+    });
+  });
+
+  describe('when top result is a single-segment page', () => {
+    it('should return the page path as category', async () => {
+      const searchResult = createSearchResult([
+        { path: '/engineering', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['keyword'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.path).toBe('/engineering/');
+      expect(result?.description).toBe('Top-level category: engineering');
+    });
+  });
+
+  describe('when search returns no results', () => {
+    it('should return null', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateCategorySuggestion(
+        ['nonexistent'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toBeNull();
+    });
+
+    it('should not call resolveParentGrant', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      await generateCategorySuggestion(
+        ['nonexistent'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
+    });
+  });
+});

+ 63 - 0
apps/app/src/server/routes/apiv3/ai-tools/generate-category-suggestion.ts

@@ -0,0 +1,63 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { SearchService } from './generate-search-suggestion';
+import { resolveParentGrant } from './resolve-parent-grant';
+import type { PathSuggestion } from './suggest-path-types';
+import { SuggestionType } from './suggest-path-types';
+
+const CATEGORY_LABEL = 'Save under category';
+const SEARCH_RESULT_LIMIT = 10;
+
+export function extractTopLevelSegment(pagePath: string): string {
+  const segments = pagePath.split('/').filter(Boolean);
+  if (segments.length === 0) {
+    return '/';
+  }
+  return `/${segments[0]}/`;
+}
+
+export function generateCategoryDescription(topLevelSegment: string): string {
+  return `Top-level category: ${topLevelSegment}`;
+}
+
+export const generateCategorySuggestion = async (
+  keywords: string[],
+  user: IUserHasId,
+  userGroups: unknown,
+  searchService: SearchService,
+): Promise<PathSuggestion | null> => {
+  const keyword = keywords.join(' ');
+
+  const [searchResult] = await searchService.searchKeyword(
+    keyword,
+    null,
+    user,
+    userGroups,
+    { limit: SEARCH_RESULT_LIMIT },
+  );
+
+  const results = searchResult.data;
+  if (results.length === 0) {
+    return null;
+  }
+
+  const topResult = results[0];
+  const topLevelPath = extractTopLevelSegment(topResult._source.path);
+
+  // Extract segment name (strip leading/trailing slashes)
+  const segmentName = topLevelPath.replace(/^\/|\/$/g, '');
+  if (segmentName === '') {
+    return null;
+  }
+
+  const description = generateCategoryDescription(segmentName);
+  const grant = await resolveParentGrant(topLevelPath);
+
+  return {
+    type: SuggestionType.CATEGORY,
+    path: topLevelPath,
+    label: CATEGORY_LABEL,
+    description,
+    grant,
+  };
+};