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

refactor(suggest-path): address code review findings

- Remove dead code: extract-keywords.ts and generateSearchSuggestion
- Consolidate SearchService type into suggest-path-types.ts
- Add JSON.parse error handling with descriptive messages in AI response parsers
- Move router instantiation inside factory function
- Add recursion depth guard (MAX_ANCESTOR_DEPTH=50) to resolveParentGrant
- Add body max length validation (100,000 chars)
- Reduce double casts with runtime assertion for searchService
- Remove unreachable empty-keywords check in generateSuggestions
- Document grant resolution asymmetry in generateMemoSuggestion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t пре 1 месец
родитељ
комит
542d2372cf
19 измењених фајлова са 133 додато и 746 уклоњено
  1. 15 2
      apps/app/src/server/routes/apiv3/ai-tools/analyze-content.spec.ts
  2. 8 1
      apps/app/src/server/routes/apiv3/ai-tools/analyze-content.ts
  3. 13 2
      apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.spec.ts
  4. 8 1
      apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.ts
  5. 0 178
      apps/app/src/server/routes/apiv3/ai-tools/extract-keywords.spec.ts
  6. 0 51
      apps/app/src/server/routes/apiv3/ai-tools/extract-keywords.ts
  7. 1 2
      apps/app/src/server/routes/apiv3/ai-tools/generate-category-suggestion.ts
  8. 4 0
      apps/app/src/server/routes/apiv3/ai-tools/generate-memo-suggestion.ts
  9. 0 376
      apps/app/src/server/routes/apiv3/ai-tools/generate-search-suggestion.spec.ts
  10. 0 90
      apps/app/src/server/routes/apiv3/ai-tools/generate-search-suggestion.ts
  11. 0 12
      apps/app/src/server/routes/apiv3/ai-tools/generate-suggestions.spec.ts
  12. 0 4
      apps/app/src/server/routes/apiv3/ai-tools/generate-suggestions.ts
  13. 1 2
      apps/app/src/server/routes/apiv3/ai-tools/index.ts
  14. 17 0
      apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.spec.ts
  15. 8 1
      apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.ts
  16. 5 19
      apps/app/src/server/routes/apiv3/ai-tools/retrieve-search-candidates.ts
  17. 17 0
      apps/app/src/server/routes/apiv3/ai-tools/suggest-path-integration.spec.ts
  18. 20 0
      apps/app/src/server/routes/apiv3/ai-tools/suggest-path-types.ts
  19. 16 5
      apps/app/src/server/routes/apiv3/ai-tools/suggest-path.ts

+ 15 - 2
apps/app/src/server/routes/apiv3/ai-tools/analyze-content.spec.ts

@@ -262,12 +262,25 @@ describe('analyzeContent', () => {
       await expect(analyzeContent('test')).rejects.toThrow('API error');
     });
 
-    it('should throw when AI returns invalid JSON', async () => {
+    it('should throw with descriptive message when AI returns invalid JSON', async () => {
       mocks.chatCompletionMock.mockResolvedValue({
         choices: [{ message: { content: 'not valid json' } }],
       });
 
-      await expect(analyzeContent('test')).rejects.toThrow();
+      await expect(analyzeContent('test')).rejects.toThrow(
+        /Failed to parse LLM response as JSON/,
+      );
+    });
+
+    it('should include truncated response in error message when AI returns invalid JSON', async () => {
+      const longInvalidJson = 'x'.repeat(300);
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: longInvalidJson } }],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow(
+        /Failed to parse LLM response as JSON/,
+      );
     });
 
     it('should throw when AI returns JSON without keywords field', async () => {

+ 8 - 1
apps/app/src/server/routes/apiv3/ai-tools/analyze-content.ts

@@ -73,7 +73,14 @@ export const analyzeContent = async (
     throw new Error('No content returned from chatCompletion');
   }
 
-  const parsed: unknown = JSON.parse(content);
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(content);
+  } catch {
+    throw new Error(
+      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
+    );
+  }
 
   if (!isValidContentAnalysis(parsed)) {
     throw new Error(

+ 13 - 2
apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.spec.ts

@@ -402,14 +402,25 @@ describe('evaluateCandidates', () => {
       ).rejects.toThrow('API error');
     });
 
-    it('should throw when AI returns invalid JSON', async () => {
+    it('should throw with descriptive message when AI returns invalid JSON', async () => {
       mocks.chatCompletionMock.mockResolvedValue({
         choices: [{ message: { content: 'not valid json' } }],
       });
 
       await expect(
         evaluateCandidates('test', stockAnalysis, sampleCandidates),
-      ).rejects.toThrow();
+      ).rejects.toThrow(/Failed to parse LLM response as JSON/);
+    });
+
+    it('should include truncated response in error message when AI returns invalid JSON', async () => {
+      const longInvalidJson = 'x'.repeat(300);
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: longInvalidJson } }],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow(/Failed to parse LLM response as JSON/);
     });
 
     it('should throw when AI returns non-array JSON', async () => {

+ 8 - 1
apps/app/src/server/routes/apiv3/ai-tools/evaluate-candidates.ts

@@ -130,7 +130,14 @@ export const evaluateCandidates = async (
     throw new Error('No content returned from chatCompletion');
   }
 
-  const parsed: unknown = JSON.parse(content);
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(content);
+  } catch {
+    throw new Error(
+      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
+    );
+  }
 
   if (!Array.isArray(parsed)) {
     throw new Error(

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

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

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

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

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

@@ -1,8 +1,7 @@
 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 type { PathSuggestion, SearchService } from './suggest-path-types';
 import { SuggestionType } from './suggest-path-types';
 
 const CATEGORY_LABEL = 'Save under category';

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

@@ -16,6 +16,8 @@ export const generateMemoSuggestion = async (user: {
   const disableUserPages = configManager.getConfig('security:disableUserPages');
 
   if (disableUserPages) {
+    // When user pages are disabled, memo falls back to /memo/<username>/
+    // which may have inherited grant from an ancestor page — resolve dynamically
     const path = `/memo/${user.username}/`;
     const grant = await resolveParentGrant(path);
     return {
@@ -27,6 +29,8 @@ export const generateMemoSuggestion = async (user: {
     };
   }
 
+  // When user pages are enabled, memo is saved under the user's homepage
+  // which is always owner-only by convention — no need to resolve
   return {
     type: SuggestionType.MEMO,
     path: `${userHomepagePath(user)}/memo/`,

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

@@ -1,376 +0,0 @@
-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();
-    });
-  });
-});

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

@@ -1,90 +0,0 @@
-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,
-  };
-};

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

@@ -244,18 +244,6 @@ describe('generateSuggestions', () => {
       expect(mocks.loggerErrorMock).toHaveBeenCalled();
     });
 
-    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();
-    });
-
     it('should return memo + category when search candidate retrieval fails', async () => {
       mockDeps.analyzeContent.mockResolvedValue(mockAnalysis);
       mockDeps.retrieveSearchCandidates.mockRejectedValue(

+ 0 - 4
apps/app/src/server/routes/apiv3/ai-tools/generate-suggestions.ts

@@ -52,10 +52,6 @@ export const generateSuggestions = async (
     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

+ 1 - 2
apps/app/src/server/routes/apiv3/ai-tools/index.ts

@@ -4,9 +4,8 @@ import type Crowi from '~/server/crowi';
 
 import { suggestPathHandlersFactory } from './suggest-path';
 
-const router = express.Router();
-
 export const factory = (crowi: Crowi): express.Router => {
+  const router = express.Router();
   router.post('/suggest-path', suggestPathHandlersFactory(crowi));
   return router;
 };

+ 17 - 0
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.spec.ts

@@ -138,6 +138,23 @@ describe('resolveParentGrant', () => {
     });
   });
 
+  describe('recursion depth guard', () => {
+    it('should return GRANT_OWNER when path exceeds maximum depth without finding ancestor', async () => {
+      // Create a deeply nested path that exceeds the max depth guard
+      const deepSegments = Array.from({ length: 60 }, (_, i) => `level${i}`);
+      const deepPath = `/${deepSegments.join('/')}/`;
+
+      mocks.findOneMock.mockImplementation(() => ({
+        lean: vi.fn().mockResolvedValue(null),
+      }));
+
+      const result = await resolveParentGrant(deepPath);
+      expect(result).toBe(GRANT_OWNER);
+      // Should not recurse more than MAX_DEPTH times (50)
+      expect(mocks.findOneMock.mock.calls.length).toBeLessThanOrEqual(51);
+    });
+  });
+
   describe('path normalization', () => {
     it('should strip trailing slash for database lookup', async () => {
       mocks.leanMock.mockResolvedValue({ grant: GRANT_PUBLIC });

+ 8 - 1
apps/app/src/server/routes/apiv3/ai-tools/resolve-parent-grant.ts

@@ -3,10 +3,17 @@ import mongoose, { type Model } from 'mongoose';
 
 type PageWithGrant = { grant: number };
 
+const MAX_ANCESTOR_DEPTH = 50;
+
 const findGrantInAncestors = async (
   Page: Model<PageWithGrant>,
   path: string,
+  depth: number = 0,
 ): Promise<number | null> => {
+  if (depth >= MAX_ANCESTOR_DEPTH) {
+    return null;
+  }
+
   const page = await Page.findOne({ path }).lean();
 
   if (page != null) {
@@ -18,7 +25,7 @@ const findGrantInAncestors = async (
   }
 
   const parentPath = path.slice(0, path.lastIndexOf('/')) || '/';
-  return findGrantInAncestors(Page, parentPath);
+  return findGrantInAncestors(Page, parentPath, depth + 1);
 };
 
 export const resolveParentGrant = async (dirPath: string): Promise<number> => {

+ 5 - 19
apps/app/src/server/routes/apiv3/ai-tools/retrieve-search-candidates.ts

@@ -1,28 +1,14 @@
 import type { IUserHasId } from '@growi/core/dist/interfaces';
 
-import type { SearchCandidate } from './suggest-path-types';
+import type {
+  SearchCandidate,
+  SearchResultItem,
+  SearchService,
+} from './suggest-path-types';
 
 const DEFAULT_SCORE_THRESHOLD = 5.0;
 const SEARCH_RESULT_LIMIT = 20;
 
-type SearchResultItem = {
-  _score: number;
-  _source: {
-    path: string;
-  };
-  _highlight?: Record<string, string[]>;
-};
-
-export type SearchService = {
-  searchKeyword(
-    keyword: string,
-    nqName: string | null,
-    user: IUserHasId,
-    userGroups: unknown,
-    opts: Record<string, unknown>,
-  ): Promise<[{ data: SearchResultItem[] }, unknown]>;
-};
-
 export type RetrieveSearchCandidatesOptions = {
   searchService: SearchService;
   scoreThreshold?: number;

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

@@ -261,6 +261,23 @@ describe('POST /suggest-path integration', () => {
       it('should return 400 when body field is empty string', async () => {
         await request(app).post('/suggest-path').send({ body: '' }).expect(400);
       });
+
+      it('should return 400 when body exceeds maximum length', async () => {
+        const oversizedBody = 'x'.repeat(100_001);
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: oversizedBody })
+          .expect(400);
+      });
+
+      it('should accept body at the maximum length boundary', async () => {
+        const maxBody = 'x'.repeat(100_000);
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: maxBody });
+        // Should not be rejected by validation (may be 200 or other non-400 status)
+        expect(response.status).not.toBe(400);
+      });
     });
 
     describe('AI service gating', () => {

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

@@ -1,3 +1,5 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
 export const SuggestionType = {
   MEMO: 'memo',
   SEARCH: 'search',
@@ -38,3 +40,21 @@ export type EvaluatedSuggestion = {
 export type SuggestPathResponse = {
   suggestions: PathSuggestion[];
 };
+
+export type SearchResultItem = {
+  _score: number;
+  _source: {
+    path: string;
+  };
+  _highlight?: Record<string, string[]>;
+};
+
+export type SearchService = {
+  searchKeyword(
+    keyword: string,
+    nqName: string | null,
+    user: IUserHasId,
+    userGroups: unknown,
+    opts: Record<string, unknown>,
+  ): Promise<[{ data: SearchResultItem[] }, unknown]>;
+};

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

@@ -18,11 +18,10 @@ import loggerFactory from '~/utils/logger';
 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';
+import type { SearchService } from './suggest-path-types';
 
 const logger = loggerFactory('growi:routes:apiv3:ai-tools:suggest-path');
 
@@ -38,12 +37,16 @@ type SuggestPathReq = Request<
   user?: IUserHasId;
 };
 
+const MAX_BODY_LENGTH = 100_000;
+
 const validator = [
   body('body')
     .isString()
     .withMessage('body must be a string')
     .notEmpty()
-    .withMessage('body must not be empty'),
+    .withMessage('body must not be empty')
+    .isLength({ max: MAX_BODY_LENGTH })
+    .withMessage(`body must not exceed ${MAX_BODY_LENGTH} characters`),
 ];
 
 export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
@@ -66,6 +69,14 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
 
       try {
         const { searchService } = crowi;
+        assert(
+          searchService != null &&
+            typeof (searchService as Record<string, unknown>).searchKeyword ===
+              'function',
+          'searchService must have searchKeyword method',
+        );
+        const typedSearchService = searchService as unknown as SearchService;
+
         const userGroups = [
           ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
           ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
@@ -81,7 +92,7 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
             analyzeContent,
             retrieveSearchCandidates: (keywords, u, groups) =>
               retrieveSearchCandidates(keywords, u, groups, {
-                searchService: searchService as unknown as SearchService,
+                searchService: typedSearchService,
               }),
             evaluateCandidates,
             generateCategorySuggestion: (keywords, u, groups) =>
@@ -89,7 +100,7 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
                 keywords,
                 u,
                 groups,
-                searchService as unknown as CategorySearchService,
+                typedSearchService,
               ),
             resolveParentGrant,
           },