Kaynağa Gözat

feat(ai-tools): wire Phase 2 orchestration for suggest-path

Add generateSuggestions orchestration function that unifies all
suggestion generators with graceful degradation: memo is always
generated first, then keyword extraction triggers parallel search
and category generation. Any Phase 2 failure falls back to memo-only.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
VANELLOPE\tomoyuki-t 1 ay önce
ebeveyn
işleme
fad7f7199c

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

@@ -63,7 +63,7 @@
   - _Requirements: 4.1, 4.2, 4.3, 4.4, 5.2, 6.4, 6.5_
   - _Requirements: 4.1, 4.2, 4.3, 4.4, 5.2, 6.4, 6.5_
 
 
 - [ ] 5. Phase 2 orchestration and integration
 - [ ] 5. Phase 2 orchestration and integration
-- [ ] 5.1 Wire suggestion generators into unified orchestration with graceful degradation
+- [x] 5.1 Wire suggestion generators into unified orchestration with graceful degradation
   - Implement the orchestration function that invokes all suggestion generators: memo (always), then keyword extraction followed by search and category generators in parallel
   - Implement the orchestration function that invokes all suggestion generators: memo (always), then keyword extraction followed by search and category generators in parallel
   - On keyword extraction or search service failure, fall back to memo-only response while logging the error
   - On keyword extraction or search service failure, fall back to memo-only response while logging the error
   - Collect non-null suggestions into the response array, ensuring memo is always present
   - Collect non-null suggestions into the response array, ensuring memo is always present

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

@@ -0,0 +1,247 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { PathSuggestion } from './suggest-path-types';
+
+const mocks = vi.hoisted(() => {
+  return {
+    generateMemoSuggestionMock: vi.fn(),
+    generateSearchSuggestionMock: vi.fn(),
+    generateCategorySuggestionMock: vi.fn(),
+    loggerErrorMock: vi.fn(),
+  };
+});
+
+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,
+  }),
+}));
+
+const mockUser = {
+  _id: 'user123',
+  username: 'alice',
+} as unknown as IUserHasId;
+
+const mockUserGroups = ['group1', 'group2'];
+
+const memoSuggestion: PathSuggestion = {
+  type: 'memo',
+  path: '/user/alice/memo/',
+  label: 'Save as memo',
+  description: 'Save to your personal memo area',
+  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 categorySuggestion: PathSuggestion = {
+  type: 'category',
+  path: '/tech-notes/',
+  label: 'Save under category',
+  description: 'Top-level category: tech-notes',
+  grant: 1,
+};
+
+describe('generateSuggestions', () => {
+  const mockSearchService = {
+    searchKeyword: vi.fn(),
+  };
+
+  const mockExtractKeywords = vi.fn();
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.generateMemoSuggestionMock.mockResolvedValue(memoSuggestion);
+  });
+
+  const callGenerateSuggestions = async () => {
+    const { generateSuggestions } = await import('./generate-suggestions');
+    return generateSuggestions(mockUser, 'Some page content', mockUserGroups, {
+      searchService: mockSearchService,
+      extractKeywords: mockExtractKeywords,
+    });
+  };
+
+  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,
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([
+        memoSuggestion,
+        searchSuggestion,
+        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);
+
+      await callGenerateSuggestions();
+
+      expect(mocks.generateSearchSuggestionMock).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);
+
+      await callGenerateSuggestions();
+
+      expect(mocks.generateCategorySuggestionMock).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,
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+    });
+
+    it('should omit category suggestion when category returns null', async () => {
+      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
+      mocks.generateSearchSuggestionMock.mockResolvedValue(searchSuggestion);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion, searchSuggestion]);
+    });
+
+    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);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+    });
+  });
+
+  describe('graceful degradation', () => {
+    it('should fall back to memo only when keyword extraction fails', async () => {
+      mockExtractKeywords.mockRejectedValue(
+        new Error('AI service unavailable'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+      expect(mocks.generateSearchSuggestionMock).not.toHaveBeenCalled();
+      expect(mocks.generateCategorySuggestionMock).not.toHaveBeenCalled();
+    });
+
+    it('should log error when keyword extraction fails', async () => {
+      const error = new Error('AI service unavailable');
+      mockExtractKeywords.mockRejectedValue(error);
+
+      await callGenerateSuggestions();
+
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+    });
+
+    it('should fall back to memo only when keyword extraction returns empty array', async () => {
+      mockExtractKeywords.mockResolvedValue([]);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+      expect(mocks.generateSearchSuggestionMock).not.toHaveBeenCalled();
+      expect(mocks.generateCategorySuggestionMock).not.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'),
+      );
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
+
+      const result = await callGenerateSuggestions();
+
+      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'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+    });
+
+    it('should log error when search or category generator throws', async () => {
+      mockExtractKeywords.mockResolvedValue(['React', 'hooks']);
+      mocks.generateSearchSuggestionMock.mockRejectedValue(
+        new Error('Search service down'),
+      );
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+
+      await callGenerateSuggestions();
+
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+    });
+  });
+});

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

@@ -0,0 +1,61 @@
+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';
+
+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[]>;
+};
+
+export const generateSuggestions = async (
+  user: IUserHasId,
+  body: string,
+  userGroups: unknown,
+  deps: GenerateSuggestionsDeps,
+): Promise<PathSuggestion[]> => {
+  const memoSuggestion = await generateMemoSuggestion(user);
+
+  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;
+  } catch (err) {
+    logger.error(
+      'Phase 2 suggestion generation failed, falling back to memo only:',
+      err,
+    );
+    return [memoSuggestion];
+  }
+};

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

@@ -55,6 +55,27 @@ vi.mock('~/server/service/config-manager', () => ({
   },
   },
 }));
 }));
 
 
+// Mock user group relations — needed for user group resolution in handler
+vi.mock('~/server/models/user-group-relation', () => ({
+  default: {
+    findAllUserGroupIdsRelatedToUser: vi.fn().mockResolvedValue([]),
+  },
+}));
+
+vi.mock(
+  '~/features/external-user-group/server/models/external-user-group-relation',
+  () => ({
+    default: {
+      findAllUserGroupIdsRelatedToUser: vi.fn().mockResolvedValue([]),
+    },
+  }),
+);
+
+// Mock extractKeywords — return empty array so Phase 2 falls back to memo-only
+vi.mock('./extract-keywords', () => ({
+  extractKeywords: vi.fn().mockResolvedValue([]),
+}));
+
 describe('POST /suggest-path — Phase 1 integration', () => {
 describe('POST /suggest-path — Phase 1 integration', () => {
   let app: express.Application;
   let app: express.Application;
 
 
@@ -82,7 +103,9 @@ describe('POST /suggest-path — Phase 1 integration', () => {
 
 
     // Import and mount the handler factory with real middleware chain
     // Import and mount the handler factory with real middleware chain
     const { suggestPathHandlersFactory } = await import('./suggest-path');
     const { suggestPathHandlersFactory } = await import('./suggest-path');
-    const mockCrowi = {} as Crowi;
+    const mockCrowi = {
+      searchService: { searchKeyword: vi.fn() },
+    } as unknown as Crowi;
     app.post('/suggest-path', suggestPathHandlersFactory(mockCrowi));
     app.post('/suggest-path', suggestPathHandlersFactory(mockCrowi));
   });
   });
 
 

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

@@ -6,14 +6,21 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 
 
 const mocks = vi.hoisted(() => {
 const mocks = vi.hoisted(() => {
   return {
   return {
-    generateMemoSuggestionMock: vi.fn(),
+    generateSuggestionsMock: vi.fn(),
+    extractKeywordsMock: vi.fn(),
     loginRequiredFactoryMock: vi.fn(),
     loginRequiredFactoryMock: vi.fn(),
     certifyAiServiceMock: vi.fn(),
     certifyAiServiceMock: vi.fn(),
+    findAllUserGroupIdsMock: vi.fn(),
+    findAllExternalUserGroupIdsMock: vi.fn(),
   };
   };
 });
 });
 
 
-vi.mock('./generate-memo-suggestion', () => ({
-  generateMemoSuggestion: mocks.generateMemoSuggestionMock,
+vi.mock('./generate-suggestions', () => ({
+  generateSuggestions: mocks.generateSuggestionsMock,
+}));
+
+vi.mock('./extract-keywords', () => ({
+  extractKeywords: mocks.extractKeywordsMock,
 }));
 }));
 
 
 vi.mock('~/server/middlewares/login-required', () => ({
 vi.mock('~/server/middlewares/login-required', () => ({
@@ -35,12 +42,32 @@ vi.mock('~/server/middlewares/apiv3-form-validator', () => ({
   apiV3FormValidator: vi.fn(),
   apiV3FormValidator: vi.fn(),
 }));
 }));
 
 
+vi.mock('~/server/models/user-group-relation', () => ({
+  default: {
+    findAllUserGroupIdsRelatedToUser: mocks.findAllUserGroupIdsMock,
+  },
+}));
+
+vi.mock(
+  '~/features/external-user-group/server/models/external-user-group-relation',
+  () => ({
+    default: {
+      findAllUserGroupIdsRelatedToUser: mocks.findAllExternalUserGroupIdsMock,
+    },
+  }),
+);
+
 describe('suggestPathHandlersFactory', () => {
 describe('suggestPathHandlersFactory', () => {
-  const mockCrowi = {} as unknown as Crowi;
+  const mockSearchService = { searchKeyword: vi.fn() };
+  const mockCrowi = {
+    searchService: mockSearchService,
+  } as unknown as Crowi;
 
 
   beforeEach(() => {
   beforeEach(() => {
     vi.resetAllMocks();
     vi.resetAllMocks();
     mocks.loginRequiredFactoryMock.mockReturnValue(vi.fn());
     mocks.loginRequiredFactoryMock.mockReturnValue(vi.fn());
+    mocks.findAllUserGroupIdsMock.mockResolvedValue(['group1']);
+    mocks.findAllExternalUserGroupIdsMock.mockResolvedValue(['extGroup1']);
   });
   });
 
 
   describe('middleware chain', () => {
   describe('middleware chain', () => {
@@ -73,15 +100,17 @@ describe('suggestPathHandlersFactory', () => {
       return { req, res };
       return { req, res };
     };
     };
 
 
-    it('should call generateMemoSuggestion with the authenticated user', async () => {
-      const memoSuggestion = {
-        type: 'memo',
-        path: '/user/alice/memo/',
-        label: 'Save as memo',
-        description: 'Save to your personal memo area',
-        grant: 4,
-      };
-      mocks.generateMemoSuggestionMock.mockReturnValue(memoSuggestion);
+    it('should call generateSuggestions with user, body, userGroups, and deps', async () => {
+      const suggestions = [
+        {
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        },
+      ];
+      mocks.generateSuggestionsMock.mockResolvedValue(suggestions);
 
 
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const handlers = suggestPathHandlersFactory(mockCrowi);
       const handlers = suggestPathHandlersFactory(mockCrowi);
@@ -90,18 +119,28 @@ describe('suggestPathHandlersFactory', () => {
       const { req, res } = createMockReqRes();
       const { req, res } = createMockReqRes();
       await handler(req, res, vi.fn());
       await handler(req, res, vi.fn());
 
 
-      expect(mocks.generateMemoSuggestionMock).toHaveBeenCalledWith(req.user);
+      expect(mocks.generateSuggestionsMock).toHaveBeenCalledWith(
+        { _id: 'user123', username: 'alice' },
+        'Some page content',
+        ['group1', 'extGroup1'],
+        {
+          searchService: mockSearchService,
+          extractKeywords: mocks.extractKeywordsMock,
+        },
+      );
     });
     });
 
 
     it('should return suggestions array via res.apiv3', async () => {
     it('should return suggestions array via res.apiv3', async () => {
-      const memoSuggestion = {
-        type: 'memo',
-        path: '/user/alice/memo/',
-        label: 'Save as memo',
-        description: 'Save to your personal memo area',
-        grant: 4,
-      };
-      mocks.generateMemoSuggestionMock.mockReturnValue(memoSuggestion);
+      const suggestions = [
+        {
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        },
+      ];
+      mocks.generateSuggestionsMock.mockResolvedValue(suggestions);
 
 
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const handlers = suggestPathHandlersFactory(mockCrowi);
       const handlers = suggestPathHandlersFactory(mockCrowi);
@@ -110,15 +149,13 @@ describe('suggestPathHandlersFactory', () => {
       const { req, res } = createMockReqRes();
       const { req, res } = createMockReqRes();
       await handler(req, res, vi.fn());
       await handler(req, res, vi.fn());
 
 
-      expect(res.apiv3).toHaveBeenCalledWith({
-        suggestions: [memoSuggestion],
-      });
+      expect(res.apiv3).toHaveBeenCalledWith({ suggestions });
     });
     });
 
 
-    it('should return error when generateMemoSuggestion throws', async () => {
-      mocks.generateMemoSuggestionMock.mockImplementation(() => {
-        throw new Error('Unexpected error');
-      });
+    it('should return error when generateSuggestions throws', async () => {
+      mocks.generateSuggestionsMock.mockRejectedValue(
+        new Error('Unexpected error'),
+      );
 
 
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const { suggestPathHandlersFactory } = await import('./suggest-path');
       const handlers = suggestPathHandlersFactory(mockCrowi);
       const handlers = suggestPathHandlersFactory(mockCrowi);
@@ -133,5 +170,21 @@ describe('suggestPathHandlersFactory', () => {
       const errorCall = apiv3ErrMock.mock.calls[0];
       const errorCall = apiv3ErrMock.mock.calls[0];
       expect(errorCall[0].message).not.toContain('Unexpected error');
       expect(errorCall[0].message).not.toContain('Unexpected error');
     });
     });
+
+    it('should combine internal and external user groups', async () => {
+      mocks.findAllUserGroupIdsMock.mockResolvedValue(['g1', 'g2']);
+      mocks.findAllExternalUserGroupIdsMock.mockResolvedValue(['eg1']);
+      mocks.generateSuggestionsMock.mockResolvedValue([]);
+
+      const { suggestPathHandlersFactory } = await import('./suggest-path');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      const call = mocks.generateSuggestionsMock.mock.calls[0];
+      expect(call[2]).toEqual(['g1', 'g2', 'eg1']);
+    });
   });
   });
 });
 });

+ 19 - 3
apps/app/src/server/routes/apiv3/ai-tools/suggest-path.ts

@@ -5,15 +5,18 @@ import { ErrorV3 } from '@growi/core/dist/models';
 import type { Request, RequestHandler } from 'express';
 import type { Request, RequestHandler } from 'express';
 import { body } from 'express-validator';
 import { body } from 'express-validator';
 
 
+import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import { certifyAiService } from '~/features/openai/server/routes/middlewares/certify-ai-service';
 import { certifyAiService } from '~/features/openai/server/routes/middlewares/certify-ai-service';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
 import loginRequiredFactory from '~/server/middlewares/login-required';
 import loginRequiredFactory from '~/server/middlewares/login-required';
+import UserGroupRelation from '~/server/models/user-group-relation';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { generateMemoSuggestion } from './generate-memo-suggestion';
+import { extractKeywords } from './extract-keywords';
+import { generateSuggestions } from './generate-suggestions';
 
 
 const logger = loggerFactory('growi:routes:apiv3:ai-tools:suggest-path');
 const logger = loggerFactory('growi:routes:apiv3:ai-tools:suggest-path');
 
 
@@ -56,8 +59,21 @@ export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
       );
       );
 
 
       try {
       try {
-        const memoSuggestion = await generateMemoSuggestion(user);
-        return res.apiv3({ suggestions: [memoSuggestion] });
+        const { searchService } = crowi;
+        const userGroups = [
+          ...(await UserGroupRelation.findAllUserGroupIdsRelatedToUser(user)),
+          ...(await ExternalUserGroupRelation.findAllUserGroupIdsRelatedToUser(
+            user,
+          )),
+        ];
+
+        const suggestions = await generateSuggestions(
+          user,
+          req.body.body,
+          userGroups,
+          { searchService, extractKeywords },
+        );
+        return res.apiv3({ suggestions });
       } catch (err) {
       } catch (err) {
         logger.error(err);
         logger.error(err);
         return res.apiv3Err(
         return res.apiv3Err(