Jelajahi Sumber

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

Add generateSearchSuggestion that searches related pages by keywords,
extracts the parent directory of the top result, and returns a path
suggestion with grant constraint and description listing related pages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 bulan lalu
induk
melakukan
b56c301a6e

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

@@ -44,7 +44,7 @@
   - _Requirements: 5.1, 5.2_
 
 - [ ] 4. Search and category suggestion generators
-- [ ] 4.1 (P) Implement search-based path suggestion
+- [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
   - Generate a description by listing titles of up to 3 top-scoring related pages found under the suggested directory — purely mechanical, no AI

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

@@ -0,0 +1,376 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import {
+  extractPageTitle,
+  extractParentDirectory,
+  generateSearchDescription,
+  generateSearchSuggestion,
+} from './generate-search-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('extractParentDirectory', () => {
+  it('should extract parent from nested path', () => {
+    expect(extractParentDirectory('/tech-notes/React/hooks')).toBe(
+      '/tech-notes/React/',
+    );
+  });
+
+  it('should extract parent from two-level path', () => {
+    expect(extractParentDirectory('/tech-notes/React')).toBe('/tech-notes/');
+  });
+
+  it('should return root for top-level page', () => {
+    expect(extractParentDirectory('/top-level')).toBe('/');
+  });
+
+  it('should extract parent from deeply nested path', () => {
+    expect(extractParentDirectory('/a/b/c/d')).toBe('/a/b/c/');
+  });
+});
+
+describe('extractPageTitle', () => {
+  it('should extract last segment as title', () => {
+    expect(extractPageTitle('/tech-notes/React/hooks')).toBe('hooks');
+  });
+
+  it('should extract title from top-level page', () => {
+    expect(extractPageTitle('/top-level')).toBe('top-level');
+  });
+
+  it('should return empty string for root path', () => {
+    expect(extractPageTitle('/')).toBe('');
+  });
+});
+
+describe('generateSearchDescription', () => {
+  it('should list page titles', () => {
+    expect(generateSearchDescription(['hooks', 'state', 'context'])).toBe(
+      'Related pages under this directory: hooks, state, context',
+    );
+  });
+
+  it('should handle single title', () => {
+    expect(generateSearchDescription(['hooks'])).toBe(
+      'Related pages under this directory: hooks',
+    );
+  });
+
+  it('should return empty string for no titles', () => {
+    expect(generateSearchDescription([])).toBe('');
+  });
+});
+
+describe('generateSearchSuggestion', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+  });
+
+  describe('when search returns results', () => {
+    it('should return a suggestion with type "search"', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React', 'hooks'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).not.toBeNull();
+      expect(result?.type).toBe('search');
+    });
+
+    it('should extract parent directory from top result path', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/tech-notes/React/state', score: 8 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toBe('/tech-notes/React/');
+    });
+
+    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 generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toMatch(/\/$/);
+    });
+
+    it('should return root when page is at top level', async () => {
+      const searchResult = createSearchResult([
+        { path: '/top-level-page', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['keyword'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.path).toBe('/');
+    });
+
+    it('should include titles of up to 3 related pages in description', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/tech-notes/React/state', score: 8 },
+        { path: '/tech-notes/React/context', score: 6 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.description).toBe(
+        'Related pages under this directory: hooks, state, context',
+      );
+    });
+
+    it('should include only 1 title when 1 result', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.description).toBe(
+        'Related pages under this directory: hooks',
+      );
+    });
+
+    it('should only include titles of pages under the parent directory', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/guides/TypeScript/basics', score: 8 },
+        { path: '/tech-notes/React/state', score: 6 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.description).toBe(
+        'Related pages under this directory: hooks, state',
+      );
+    });
+
+    it('should limit description titles to 3 even when more pages match', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/tech-notes/React/state', score: 9 },
+        { path: '/tech-notes/React/context', score: 8 },
+        { path: '/tech-notes/React/refs', score: 7 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.description).toBe(
+        'Related pages under this directory: hooks, state, context',
+      );
+    });
+
+    it('should resolve grant from parent directory', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith(
+        '/tech-notes/React/',
+      );
+      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 generateSearchSuggestion(
+        ['keyword'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.grant).toBe(GRANT_OWNER);
+    });
+
+    it('should have label "Save near related pages"', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result?.label).toBe('Save near related pages');
+    });
+
+    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 generateSearchSuggestion(
+        ['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 generateSearchSuggestion(
+        ['React'],
+        mockUser,
+        mockUserGroups,
+        searchService,
+      );
+
+      expect(searchService.searchKeyword).toHaveBeenCalledWith(
+        expect.any(String),
+        null,
+        mockUser,
+        mockUserGroups,
+        expect.any(Object),
+      );
+    });
+  });
+
+  describe('when search returns no results', () => {
+    it('should return null', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await generateSearchSuggestion(
+        ['nonexistent'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toBeNull();
+    });
+
+    it('should not call resolveParentGrant', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      await generateSearchSuggestion(
+        ['nonexistent'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
+    });
+  });
+});

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

@@ -0,0 +1,90 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import { resolveParentGrant } from './resolve-parent-grant';
+import type { PathSuggestion } from './suggest-path-types';
+import { SuggestionType } from './suggest-path-types';
+
+const SEARCH_LABEL = 'Save near related pages';
+const SEARCH_RESULT_LIMIT = 10;
+const MAX_DESCRIPTION_TITLES = 3;
+
+type SearchResultItem = {
+  _score: number;
+  _source: {
+    path: string;
+  };
+};
+
+export type SearchService = {
+  searchKeyword(
+    keyword: string,
+    nqName: string | null,
+    user: IUserHasId,
+    userGroups: unknown,
+    opts: Record<string, unknown>,
+  ): Promise<[{ data: SearchResultItem[] }, unknown]>;
+};
+
+export function extractParentDirectory(pagePath: string): string {
+  const segments = pagePath.split('/').filter(Boolean);
+  if (segments.length <= 1) {
+    return '/';
+  }
+  segments.pop();
+  return `/${segments.join('/')}/`;
+}
+
+export function extractPageTitle(pagePath: string): string {
+  const segments = pagePath.split('/').filter(Boolean);
+  return segments[segments.length - 1] ?? '';
+}
+
+export function generateSearchDescription(pageTitles: string[]): string {
+  if (pageTitles.length === 0) {
+    return '';
+  }
+  return `Related pages under this directory: ${pageTitles.join(', ')}`;
+}
+
+export const generateSearchSuggestion = 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 parentDir = extractParentDirectory(topResult._source.path);
+
+  // Filter to pages under the parent directory and extract titles
+  const titles = results
+    .filter((r) => r._source.path.startsWith(parentDir))
+    .slice(0, MAX_DESCRIPTION_TITLES)
+    .map((r) => extractPageTitle(r._source.path))
+    .filter(Boolean);
+
+  const description = generateSearchDescription(titles);
+  const grant = await resolveParentGrant(parentDir);
+
+  return {
+    type: SuggestionType.SEARCH,
+    path: parentDir,
+    label: SEARCH_LABEL,
+    description,
+    grant,
+  };
+};