Просмотр исходного кода

Merge pull request #10777 from growilabs/feat/178240-suggest-path-spec

feat(ai): Suggest path to save
mergify[bot] 1 неделя назад
Родитель
Сommit
1e82c7718e
27 измененных файлов с 4715 добавлено и 2 удалено
  1. 359 0
      .kiro/specs/suggest-path/design.md
  2. 77 0
      .kiro/specs/suggest-path/requirements.md
  3. 145 0
      .kiro/specs/suggest-path/research.md
  4. 23 0
      .kiro/specs/suggest-path/spec.json
  5. 181 0
      .kiro/specs/suggest-path/tasks.md
  6. 10 0
      apps/app/src/features/ai-tools/server/routes/apiv3/index.ts
  7. 62 0
      apps/app/src/features/ai-tools/suggest-path/interfaces/suggest-path-types.ts
  8. 909 0
      apps/app/src/features/ai-tools/suggest-path/server/integration-tests/suggest-path-integration.spec.ts
  9. 182 0
      apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.spec.ts
  10. 98 0
      apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts
  11. 390 0
      apps/app/src/features/ai-tools/suggest-path/server/services/analyze-content.spec.ts
  12. 51 0
      apps/app/src/features/ai-tools/suggest-path/server/services/analyze-content.ts
  13. 60 0
      apps/app/src/features/ai-tools/suggest-path/server/services/call-llm-for-json.ts
  14. 511 0
      apps/app/src/features/ai-tools/suggest-path/server/services/evaluate-candidates.spec.ts
  15. 115 0
      apps/app/src/features/ai-tools/suggest-path/server/services/evaluate-candidates.ts
  16. 170 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-category-suggestion.spec.ts
  17. 37 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-category-suggestion.ts
  18. 136 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-memo-suggestion.spec.ts
  19. 41 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-memo-suggestion.ts
  20. 383 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.spec.ts
  21. 102 0
      apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.ts
  22. 199 0
      apps/app/src/features/ai-tools/suggest-path/server/services/resolve-parent-grant.spec.ts
  23. 43 0
      apps/app/src/features/ai-tools/suggest-path/server/services/resolve-parent-grant.ts
  24. 366 0
      apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.spec.ts
  25. 59 0
      apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.ts
  26. 3 0
      apps/app/src/server/routes/apiv3/index.js
  27. 3 2
      packages/core/vite.config.ts

+ 359 - 0
.kiro/specs/suggest-path/design.md

@@ -0,0 +1,359 @@
+# Design Document
+
+## Overview
+
+**Purpose**: AI-powered path suggestion API that helps AI clients (e.g., Claude via MCP) determine optimal save locations for page content in GROWI. The system analyzes content, searches for related pages, evaluates candidates, and returns directory path suggestions with metadata.
+
+**Users**: AI clients (Claude via MCP) call this endpoint on behalf of GROWI users during the "save to GROWI" workflow.
+
+### Goals
+
+- Single POST endpoint returning path suggestions with metadata (type, path, label, description, grant)
+- Memo path: guaranteed fallback with fixed metadata
+- Search-based suggestions: AI-powered with flow/stock classification, multi-candidate evaluation, and intelligent path proposals (including new paths)
+- Independent access control via separate `ai-tools` namespace from `/page`
+
+### Design Principles
+
+- **Client LLM independence**: Heavy reasoning (content analysis, candidate evaluation, path proposal, description generation) is centralized in GROWI AI on the server side. The API response includes structured data fields (`informationType`, `type`, `grant`) alongside natural language (`description`) so that even less capable LLM clients can make correct decisions.
+
+### Non-Goals
+
+- Page creation/saving (existing `POST /_api/v3/page` handles this)
+- Page title suggestion (Claude handles this via user dialogue)
+- Client-side "enter manually" option (Agent Skill responsibility)
+
+## Architecture
+
+### Boundary Map
+
+```mermaid
+graph TB
+    subgraph Client
+        MCP[MCP Server]
+    end
+
+    subgraph GROWI_API[GROWI API]
+        Router[ai-tools Router]
+        Handler[suggest-path Handler]
+        MemoGen[Memo Suggestion]
+        Analyzer[Content Analyzer - 1st AI Call]
+        Retriever[Search Candidate Retriever]
+        Evaluator[Candidate Evaluator - 2nd AI Call]
+        CategoryGen[Category Suggestion - Under Review]
+    end
+
+    subgraph Existing[Existing Services]
+        SearchSvc[Search Service]
+        GrantSvc[Page Grant Service]
+        AIFeature[GROWI AI - OpenAI Feature]
+    end
+
+    subgraph Data
+        ES[Elasticsearch]
+        Mongo[MongoDB - Pages]
+    end
+
+    MCP -->|POST suggest-path| Router
+    Router --> Handler
+    Handler --> MemoGen
+    Handler --> Analyzer
+    Analyzer --> AIFeature
+    Handler --> Retriever
+    Retriever --> SearchSvc
+    Handler --> Evaluator
+    Evaluator --> AIFeature
+    Handler --> CategoryGen
+    CategoryGen --> SearchSvc
+    SearchSvc --> ES
+    Evaluator --> GrantSvc
+    CategoryGen --> GrantSvc
+    GrantSvc --> Mongo
+```
+
+**Integration notes**:
+
+- Layered handler following existing GROWI route conventions
+- Domain boundaries: Route layer owns the endpoint, delegates to existing services (search, grant, AI) without modifying them
+- Existing patterns preserved: Handler factory pattern, middleware chain, `res.apiv3()` response format
+
+### Code Organization
+
+All suggest-path code resides in `features/ai-tools/suggest-path/` following the project's feature-based architecture pattern.
+
+```text
+apps/app/src/features/ai-tools/
+├── server/routes/apiv3/
+│   └── index.ts                              # Aggregation router for ai-tools namespace
+└── suggest-path/
+    ├── interfaces/
+    │   └── suggest-path-types.ts              # Shared types (PathSuggestion, ContentAnalysis, etc.)
+    └── server/
+        ├── routes/apiv3/
+        │   ├── index.ts                       # Route factory, handler + middleware chain
+        │   └── index.spec.ts
+        ├── services/
+        │   ├── generate-suggestions.ts        # Orchestrator
+        │   ├── generate-memo-suggestion.ts
+        │   ├── analyze-content.ts             # AI call #1: keyword extraction + flow/stock
+        │   ├── retrieve-search-candidates.ts  # ES search with score filtering
+        │   ├── evaluate-candidates.ts         # AI call #2: candidate evaluation + path proposal
+        │   ├── call-llm-for-json.ts           # Shared LLM call utility
+        │   ├── generate-category-suggestion.ts # Under review
+        │   ├── resolve-parent-grant.ts
+        │   └── *.spec.ts                      # Co-located tests
+        └── integration-tests/
+            └── suggest-path-integration.spec.ts
+```
+
+**Key decisions**:
+
+- **No barrel export**: Consumers import directly from subpaths (following `features/openai/` convention)
+- **Aggregation router retained**: The `ai-tools` router at `features/ai-tools/server/routes/apiv3/` imports the suggest-path route factory. This allows future ai-tools features to register under the same namespace
+- **R4 (CategorySuggestionGenerator)**: Under review — may be merged into AI evaluation approach post-discussion
+
+### Implementation Paradigm
+
+All components are pure functions with immutable data. No classes — no component currently meets class adoption criteria (shared dependency management or singleton state).
+
+### Request Flow
+
+```mermaid
+sequenceDiagram
+    participant Client as MCP Client
+    participant Handler as Orchestrator
+    participant AI1 as Content Analyzer
+    participant Search as Search Service
+    participant AI2 as Candidate Evaluator
+    participant Grant as Grant Resolver
+    participant CatGen as Category Generator
+
+    Client->>Handler: POST with body content
+    Handler->>Handler: Generate memo suggestion
+
+    Handler->>AI1: Analyze content body
+    Note over AI1: 1st AI Call
+    AI1-->>Handler: keywords + informationType
+
+    par Search and evaluate
+        Handler->>Search: Search by keywords
+        Search-->>Handler: Raw results with scores
+        Handler->>Handler: Filter by score threshold
+        Handler->>AI2: body + analysis + candidates
+        Note over AI2: 2nd AI Call
+        AI2-->>Handler: Evaluated suggestions with paths and descriptions
+        loop For each evaluated suggestion
+            Handler->>Grant: Resolve grant for proposed path
+            Grant-->>Handler: Grant value
+        end
+    and Category suggestion
+        Handler->>CatGen: Generate from keywords
+        CatGen->>Search: Scoped keyword search
+        Search-->>CatGen: Top-level pages
+        CatGen->>Grant: Resolve parent grant
+        Grant-->>CatGen: Grant value
+        CatGen-->>Handler: Category suggestion or null
+    end
+
+    Handler-->>Client: 200 suggestions array
+```
+
+**Key decisions**:
+
+- Content analysis and candidate evaluation are structurally sequential — Elasticsearch sits between them
+- Search-evaluate flow and category generation run in parallel
+- If content analysis fails → memo-only response
+- If candidate evaluation fails → memo + category (if available)
+- Category generator runs independently (under review)
+
+## Component Interfaces
+
+### Orchestrator
+
+```typescript
+function generateSuggestions(
+  user: IUserHasId,
+  body: string,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<PathSuggestion[]>;
+```
+
+- **No DI pattern**: Imports service functions directly; only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported)
+- **Invariant**: Returns array with at least one suggestion (memo type), regardless of failures
+- **informationType mapping**: Attaches `ContentAnalysis.informationType` to each search-type suggestion (Req 13.1)
+
+### Content Analyzer (1st AI Call)
+
+```typescript
+type ContentAnalysis = {
+  keywords: string[];            // 1-5 keywords, proper nouns prioritized
+  informationType: 'flow' | 'stock';
+};
+
+function analyzeContent(body: string): Promise<ContentAnalysis>;
+```
+
+### Search Candidate Retriever
+
+```typescript
+type SearchCandidate = {
+  pagePath: string;
+  snippet: string;
+  score: number;
+};
+
+function retrieveSearchCandidates(
+  keywords: string[],
+  user: IUserHasId,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<SearchCandidate[]>;
+```
+
+- `searchService` is a direct positional argument (not wrapped in an options object)
+- Score threshold is a module-level constant (`SCORE_THRESHOLD = 5.0`)
+- Filters by ES score threshold; returns empty array if no results pass
+
+### Candidate Evaluator (2nd AI Call)
+
+```typescript
+type EvaluatedSuggestion = {
+  path: string;        // Proposed directory path with trailing /
+  label: string;
+  description: string; // AI-generated rationale
+};
+
+function evaluateCandidates(
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): Promise<EvaluatedSuggestion[]>;
+```
+
+- Proposes paths using 3 structural patterns: (a) parent directory, (b) subdirectory, (c) sibling (may generate new paths at same hierarchy level)
+- Flow/stock alignment is a ranking factor, not a hard filter
+- Grant resolution performed by orchestrator after this returns
+
+### Category Suggestion Generator
+
+```typescript
+function generateCategorySuggestion(
+  candidates: SearchCandidate[],
+): Promise<PathSuggestion | null>;
+```
+
+- Under review — may be merged into AI evaluation approach post-discussion
+- Returns `null` when no matching top-level pages are found
+
+### Grant Resolver
+
+```typescript
+function resolveParentGrant(dirPath: string): Promise<number>;
+```
+
+- Traverses upward through ancestors for new paths (sibling pattern)
+- Returns `GRANT_OWNER` (4) as safe default if no ancestor found
+
+## Data Contracts
+
+### API Contract
+
+| Method | Endpoint | Request | Response | Errors |
+|--------|----------|---------|----------|--------|
+| POST | `/_api/v3/ai-tools/suggest-path` | `SuggestPathRequest` | `SuggestPathResponse` | 400, 401, 403, 500 |
+
+### Request / Response Types
+
+```typescript
+// Request
+interface SuggestPathRequest {
+  body: string; // Page content for analysis (required, non-empty)
+}
+
+// Response
+type SuggestionType = 'memo' | 'search' | 'category';
+type InformationType = 'flow' | 'stock';
+
+interface PathSuggestion {
+  type: SuggestionType;
+  path: string;                        // Directory path with trailing '/'
+  label: string;
+  description: string;                 // Fixed for memo, AI-generated for search
+  grant: number;                       // Parent page grant (PageGrant value)
+  informationType?: InformationType;   // Search-based only
+}
+
+interface SuggestPathResponse {
+  suggestions: PathSuggestion[];       // Always ≥1 element (memo)
+}
+```
+
+**Invariants**: `path` ends with `/`, `grant` is a valid PageGrant value (1, 2, 4, or 5)
+
+### Response Example
+
+```json
+{
+  "suggestions": [
+    {
+      "type": "memo",
+      "path": "/user/alice/memo/",
+      "label": "Save as memo",
+      "description": "Save to your personal memo area",
+      "grant": 4
+    },
+    {
+      "type": "search",
+      "path": "/tech-notes/React/state-management/",
+      "label": "Save near related pages",
+      "description": "This area contains pages about React state management. Your stock content fits well alongside this existing reference material.",
+      "grant": 1,
+      "informationType": "stock"
+    },
+    {
+      "type": "category",
+      "path": "/tech-notes/",
+      "label": "Save under category",
+      "description": "Top-level category: tech-notes",
+      "grant": 1
+    }
+  ]
+}
+```
+
+## Error Handling & Graceful Degradation
+
+### User Errors (4xx)
+
+| Error | Status | Requirement |
+|-------|--------|-------------|
+| Missing or empty `body` | 400 | 9.1 |
+| No authentication | 401 | 8.2 |
+| AI service not enabled | 403 | 1.4 |
+
+### Graceful Degradation (returns 200)
+
+| Failure | Fallback |
+|---------|----------|
+| Content analysis (1st AI call) | Memo only (skips entire search pipeline) |
+| Search service | Memo + category (if available) |
+| Candidate evaluation (2nd AI call) | Memo + category (if available) |
+| Category generation | Memo + search-based (if available) |
+
+Each component fails independently. Memo is always generated first as guaranteed fallback.
+
+## Security Considerations
+
+- **Authentication**: All requests require valid API token or login session (standard middleware)
+- **Authorization**: Search results are permission-scoped via `searchKeyword()` user/group parameters
+- **Input safety**: Content body is passed to GROWI AI, not directly to Elasticsearch — no NoSQL injection risk
+- **AI prompt injection**: System prompt and user content are separated to minimize prompt injection risk
+- **Information leakage**: Error responses use generic messages (Req 9.2)
+
+## Performance Considerations
+
+- Content analysis and candidate evaluation are sequential (ES sits between) — 2 AI roundtrips minimum
+- Search-evaluate pipeline and category generation run in parallel to minimize total latency
+- ES snippets (not full page bodies) are passed to AI to manage context budget
+- Score threshold filtering reduces the number of candidates passed to the 2nd AI call

+ 77 - 0
.kiro/specs/suggest-path/requirements.md

@@ -0,0 +1,77 @@
+# Requirements Document
+
+## Introduction
+
+The suggest-path feature provides an AI-powered API endpoint for GROWI that suggests optimal page save locations. When an AI client (e.g., Claude via MCP) sends page content, the endpoint analyzes it and returns directory path suggestions with metadata including descriptions and grant (permission) constraints.
+
+The feature was delivered in two phases:
+
+- **Phase 1 (MVP)**: Personal memo path suggestion — endpoint, authentication, and response structure.
+- **Phase 2 (Full)**: AI-powered search-based path suggestions with flow/stock information classification, multi-candidate evaluation, and intelligent path proposal (including new paths).
+
+### Phase 2 Revision History
+
+Phase 2 was revised based on reviewer feedback: (1) flow/stock information classification, (2) multi-candidate AI evaluation instead of top-1 selection, (3) three-pattern path proposals (parent/subdirectory/sibling), (4) AI-generated descriptions.
+
+## Out of Scope
+
+- **Page creation/saving**: Uses existing `POST /_api/v3/page`. This feature only suggests *where* to save.
+- **Page title determination**: Handled via AI client-user dialogue.
+
+## Requirements
+
+### Requirement 1: Path Suggestion API Endpoint
+
+**Summary**: POST endpoint at `/_api/v3/ai-tools/suggest-path` accepts a `body` field and returns an array of path suggestions. Each suggestion includes `type`, `path` (directory with trailing `/`), `label`, `description`, and `grant`. Endpoint is under a separate namespace from `/_api/v3/page/` for independent access control.
+
+### Requirement 2: Memo Path Suggestion (Phase 1)
+
+**Summary**: Always includes a `memo` type suggestion as guaranteed fallback. Path is `/user/{username}/memo/` when user pages are enabled, or `/memo/{username}/` when disabled. Grant is `4` (owner only). Description is fixed text.
+
+### Requirement 3: Search-Based Path Suggestion (Phase 2)
+
+**Summary**: Searches for related pages using extracted keywords, filters by Elasticsearch score threshold, then passes all passing candidates to AI-based evaluation (Req 11). Includes parent page's grant. Omitted if no candidates pass the threshold.
+
+### Requirement 4: Category-Based Path Suggestion (Phase 2) — Under Review
+
+**Summary**: Extracts top-level path segment from keyword-matched pages as a `category` type suggestion. Includes parent grant. Omitted if no match found.
+
+> **Note**: May overlap with the AI-based evaluation approach (Reqs 11, 12). Whether to retain, merge, or remove will be determined after reviewer discussion.
+
+### Requirement 5: Content Analysis via GROWI AI (Phase 2)
+
+**Summary**: Single AI call performs keyword extraction (1-5 keywords, proper nouns prioritized) and flow/stock information type classification. Keywords (not raw content) are used for search. On failure, falls back to memo-only response.
+
+### Requirement 6: Suggestion Description Generation
+
+**Summary**: Each suggestion includes a `description` field. Memo uses fixed text. Search-based suggestions use AI-generated descriptions from candidate evaluation (Req 11).
+
+### Requirement 7: Grant Constraint Information
+
+**Summary**: Each suggestion includes a `grant` field representing the parent page's grant value — the upper bound of settable permissions for child pages (a constraint, not a recommendation).
+
+### Requirement 8: Authentication and Authorization
+
+**Summary**: Requires valid API token or login session. Returns authentication error if missing. Uses authenticated user's identity for user-specific suggestions.
+
+### Requirement 9: Input Validation and Error Handling
+
+**Summary**: Returns validation error for missing/empty `body`. Internal errors return appropriate responses without exposing system details.
+
+### Requirement 10: Flow/Stock Information Type Awareness (Phase 2)
+
+**Summary**: Candidate evaluation considers flow/stock alignment between content and candidate locations. Flow = time-bound (date-based paths, meeting terms). Stock = reference (topic-based paths). Used as a ranking factor, not a hard filter.
+
+### Requirement 11: AI-Based Candidate Evaluation and Ranking (Phase 2)
+
+**Summary**: GROWI AI evaluates each candidate's suitability using content body, candidate path, and snippet. Ranks by content-destination fit considering relevance and flow/stock alignment. Generates description per suggestion. Falls back to memo-only on failure.
+
+### Requirement 12: Path Proposal Patterns (Phase 2)
+
+**Summary**: Three structural patterns relative to each matching page: (a) parent directory, (b) subdirectory, (c) sibling directory. Sibling pattern generates new directory names at the same hierarchy level as the candidate. AI determines the most appropriate pattern.
+
+### Requirement 13: Client LLM Independence (Phase 2)
+
+**Summary**: Response includes both structured metadata (`informationType`, `type`, `grant`) and natural language (`description`) so any LLM client can use it regardless of reasoning capability. All reasoning-intensive operations are server-side.
+
+**Design Rationale**: MCP clients are powered by varying LLM models. Heavy reasoning is centralized in GROWI AI to prevent quality degradation with less capable clients.

+ 145 - 0
.kiro/specs/suggest-path/research.md

@@ -0,0 +1,145 @@
+# Research & Design Decisions
+
+## Summary
+
+- **Feature**: `suggest-path`
+- **Discovery Scope**: Extension (new endpoint added to existing API infrastructure)
+- **Key Findings**:
+  - GROWI uses a handler factory pattern (`(crowi: Crowi) => RequestHandler[]`) for API routes
+  - The `ai-tools` namespace does not exist yet; closest is `/openai` under `features/openai/`
+  - Grant parent-child constraints are enforced by `page-grant.ts` — GRANT_OWNER children must share the same owner
+  - `searchService.searchKeyword()` accepts keyword string and returns scored results with page metadata
+  - User home path utilities exist in `@growi/core` (`userHomepagePath`, `isUsersHomepage`)
+
+## Research Log
+
+### GROWI API Route Patterns
+
+- **Context**: Need to understand how to add a new route namespace
+- **Sources Consulted**: `apps/app/src/server/routes/apiv3/index.js`, `page/create-page.ts`, `features/openai/server/routes/index.ts`
+- **Findings**:
+  - Three router types: standard, admin, auth. New endpoints go on standard router
+  - Route registration: `router.use('/namespace', require('./namespace')(crowi))` or factory import
+  - Handler factory pattern: exports `(crowi: Crowi) => RequestHandler[]` returning middleware chain
+  - Middleware ordering: `accessTokenParser` → `loginRequiredStrictly` → validators → `apiV3FormValidator` → handler
+  - Response helpers: `res.apiv3(data)` for success, `res.apiv3Err(error, status)` for errors
+  - Feature-based routes use dynamic import pattern (see openai routes)
+- **Implications**: suggest-path follows the handler factory pattern. Route factory in `features/ai-tools/suggest-path/server/routes/apiv3/`, aggregation router in `features/ai-tools/server/routes/apiv3/`
+
+### OpenAI Feature Structure
+
+- **Context**: Understanding existing AI feature patterns for alignment
+- **Sources Consulted**: `features/openai/server/routes/index.ts`, `middlewares/certify-ai-service.ts`
+- **Findings**:
+  - AI routes gate on `aiEnabled` config via `certifyAiService` middleware
+  - Dynamic imports used for route handlers
+  - Dedicated middleware directory for AI-specific checks
+  - Routes organized under `features/openai/` not `routes/apiv3/`
+- **Implications**: suggest-path gates on AI-enabled config via `certifyAiService`. Code lives under `features/ai-tools/suggest-path/` with an aggregation router at `features/ai-tools/server/routes/apiv3/`.
+
+### Grant System Constraints
+
+- **Context**: Need to return accurate grant constraints for suggested paths
+- **Sources Consulted**: `@growi/core` PageGrant enum, `apps/app/src/server/service/page-grant.ts`
+- **Findings**:
+  - PageGrant values: PUBLIC(1), RESTRICTED(2), SPECIFIED(3-deprecated), OWNER(4), USER_GROUP(5)
+  - Parent constrains child: OWNER parent → child must be OWNER by same user; USER_GROUP parent → child cannot be PUBLIC
+  - `calcApplicableGrantData(page, user)` returns allowed grant types for a page
+  - For memo path (`/user/{username}/memo/`), the user homepage `/user/{username}` is GRANT_OWNER(4) by default → memo path grant is fixed at 4
+- **Implications**: Phase 1 memo grant is trivially 4. Phase 2 needs to look up actual parent page grant via Page model
+
+### Search Service Integration
+
+- **Context**: Phase 2 requires keyword-based search for related pages
+- **Sources Consulted**: `apps/app/src/server/service/search.ts`
+- **Findings**:
+  - `searchKeyword(keyword, nqName, user, userGroups, searchOpts)` → `[ISearchResult, delegatorName]`
+  - Results include `_id`, `_score`, `_source`, `_highlight`
+  - Supports `prefix:` queries for path-scoped search
+  - User groups needed for permission-scoped search results
+- **Implications**: Phase 2 uses `searchKeyword` with extracted keywords. Category search uses `prefix:/` to scope to top-level. Need `getUserRelatedGroups()` for permission-correct results.
+
+### User Home Path Utilities
+
+- **Context**: Memo path generation needs user home path
+- **Sources Consulted**: `@growi/core` `page-path-utils/index.ts`
+- **Findings**:
+  - `userHomepagePath(user)` → `/user/{username}`
+  - `isUsersHomepage(path)` → boolean check
+  - `getUsernameByPath(path)` → extract username from path
+- **Implications**: Use `userHomepagePath(req.user)` + `/memo/` for memo suggestion path
+
+## Architecture Pattern Evaluation
+
+| Option | Description | Strengths | Risks / Limitations | Notes |
+|--------|-------------|-----------|---------------------|-------|
+| Route under `features/ai-tools/` | Feature-based directory with aggregation router | Clean separation, follows features pattern and `ai-tools` naming | — | **Selected** — aligns with project architecture and independent access control |
+| Route under `features/openai/` | Extend existing AI feature module | Reuses AI infrastructure, minimal setup | Provider-specific name, harder to separate for independent access control | Rejected in review — namespace should be provider-agnostic |
+| Route under `routes/apiv3/page/` | Add to existing page routes | Close to page creation | Cannot gate independently for access control | Rejected in review — yuki requested separation |
+
+## Design Decisions
+
+### Decision: Route Namespace Placement
+
+- **Context**: Endpoint needs independent access control
+- **Alternatives Considered**:
+  1. `/openai/suggest-path` — groups with AI features but provider-specific
+  2. `/page/suggest-path` — close to page creation but cannot gate independently
+  3. `/ai-tools/suggest-path` — new provider-agnostic namespace
+- **Selected Approach**: `/_api/v3/ai-tools/suggest-path` under `features/ai-tools/suggest-path/`
+- **Rationale**: Provider-agnostic, enables independent access control, follows features directory pattern
+- **Trade-offs**: Aggregation router at `features/ai-tools/server/routes/apiv3/` allows future ai-tools features under the same namespace
+
+### Decision: Phase 1 Handler Simplicity
+
+- **Context**: Phase 1 (MVP) only returns memo path — very simple logic
+- **Alternatives Considered**:
+  1. Full service layer from the start (SuggestionService class)
+  2. Inline logic in handler, extract to service when Phase 2 arrives
+- **Selected Approach**: Inline logic in handler for Phase 1, extract to service for Phase 2
+- **Rationale**: Avoid over-engineering. Phase 1 is ~10 lines of logic. Service abstraction added when needed
+- **Trade-offs**: Phase 2 will require refactoring handler → service extraction
+- **Follow-up**: Define service interface in design for Phase 2 readiness
+
+### Decision: GROWI AI Keyword Extraction Approach
+
+- **Context**: Phase 2 needs keyword extraction from content body
+- **Alternatives Considered**:
+  1. New dedicated keyword extraction service
+  2. Extend existing OpenAI feature module
+  3. Client-side keyword extraction (fallback option)
+- **Selected Approach**: Leverage existing `features/openai/` infrastructure for keyword extraction
+- **Rationale**: GROWI already has OpenAI integration. Keyword extraction is a new capability within the existing AI feature
+- **Trade-offs**: Couples suggest-path to OpenAI feature availability. Mitigated by fallback to memo-only response
+- **Follow-up**: Detailed keyword extraction implementation is out of scope for this spec (separate design)
+
+## Risks & Mitigations
+
+- **Large content body performance**: Sending full content for AI keyword extraction may be slow. Mitigation: fallback to memo-only if extraction fails
+- **Search service dependency**: Depends on Elasticsearch being available. Mitigation: graceful degradation — return memo suggestion if search fails
+
+## Post-Implementation Discoveries
+
+### Lesson: Avoid Testability-Motivated DI in Feature Services
+
+- **Context**: Initial Phase 2 implementation used a `GenerateSuggestionsDeps` pattern — a `deps` parameter containing 5 callback functions injected into the orchestrator for testability
+- **Problem**: The pattern was inconsistent with the rest of the codebase (other modules use `vi.mock()` for testing), added route handler boilerplate (10 lines wiring callbacks), and forced unnecessary abstractions like `RetrieveSearchCandidatesOptions`
+- **Resolution**: Removed `deps` pattern; service functions are imported directly. Only `searchService` is passed as a parameter (the sole external dependency that cannot be statically imported). Tests use `vi.mock()` — consistent with `generate-memo-suggestion` and other modules
+- **Guideline**: In this codebase, prefer `vi.mock()` over DI patterns for feature-specific service layers. Reserve DI for true cross-cutting concerns or when the dependency is a runtime-varying service instance (like `searchService`)
+
+### Lesson: Type Propagation from Legacy Code
+
+- **Context**: `searchService.searchKeyword()` in `src/server/service/search.ts` has untyped parameters (legacy JS-to-TS migration), so the suggest-path code initially used `userGroups: unknown` as a safe catch-all
+- **Resolution**: Traced the actual type from `findAllUserGroupIdsRelatedToUser()` which returns `ObjectIdLike[]` (from `@growi/core`), and propagated it through the `SearchService` interface and all service functions
+- **Guideline**: When integrating with legacy untyped services, trace the actual runtime type from the call site rather than defaulting to `unknown`
+
+## References
+
+- [GROWI Search Internals](https://dev.growi.org/69842ea0cb3a20a69b0a1985) — Search feature internal architecture
+- `apps/app/src/server/routes/apiv3/index.js` — Route registration entry point
+- `apps/app/src/server/routes/apiv3/page/create-page.ts` — Reference handler pattern
+- `apps/app/src/features/openai/server/routes/index.ts` — AI feature route pattern
+- `packages/core/src/interfaces/page.ts` — PageGrant enum definition
+- `apps/app/src/server/service/page-grant.ts` — Grant validation logic
+- `apps/app/src/server/service/search.ts` — Search service interface
+- `packages/core/src/utils/page-path-utils/index.ts` — User path utilities

+ 23 - 0
.kiro/specs/suggest-path/spec.json

@@ -0,0 +1,23 @@
+{
+  "feature_name": "suggest-path",
+  "created_at": "2026-02-10T12:00:00Z",
+  "updated_at": "2026-03-23T00:00:00Z",
+  "language": "en",
+  "phase": "implementation-complete",
+  "approvals": {
+    "requirements": {
+      "generated": true,
+      "approved": true
+    },
+    "design": {
+      "generated": true,
+      "approved": true
+    },
+    "tasks": {
+      "generated": true,
+      "approved": true
+    }
+  },
+  "ready_for_implementation": true,
+  "cleanup_completed": true
+}

+ 181 - 0
.kiro/specs/suggest-path/tasks.md

@@ -0,0 +1,181 @@
+# Implementation Plan
+
+## Phase 1 (MVP) — Implemented
+
+- [x] 1. Phase 1 MVP — Shared types, memo path suggestion, and endpoint registration
+- [x] 1.1 Define suggestion types and implement memo path generation
+  - Define the suggestion response types used across both phases: suggestion type discriminator, individual suggestion structure with type/path/label/description/grant fields, and the response wrapper
+  - Implement memo path generation: when user pages are enabled (default), generate path under the user's home directory with owner-only grant; when user pages are disabled, generate path under an alternative namespace with hardcoded owner-only grant (actual parent grant resolution deferred to Phase 2 task 2)
+  - Enforce directory path format with trailing slash for all generated paths
+  - Generate fixed descriptive text for memo suggestions
+  - Include unit tests covering both user-pages-enabled and user-pages-disabled paths, verifying correct path format, grant value, and description
+  - _Requirements: 1.2, 1.3, 2.1, 2.2, 2.3, 2.4, 2.5, 6.1, 6.2_
+
+- [x] 1.2 Register route endpoint with authentication and validation
+  - Create the route under a new namespace separate from the page API, following the existing handler factory pattern
+  - Apply the standard middleware chain: access token parsing, strict login requirement, AI service gating, request body validation
+  - Implement the handler to invoke memo suggestion generation for the authenticated user and return the suggestions array using the standard API response format
+  - Return appropriate error responses for authentication failures, validation failures, and AI-disabled states without exposing internal system details
+  - Register the new namespace route in the central API router
+  - _Requirements: 1.1, 1.4, 8.1, 8.2, 8.3, 9.1, 9.2_
+
+- [x] 1.3 Phase 1 integration verification
+  - Verify the complete request-response cycle for the memo suggestion endpoint with valid authentication
+  - Verify authentication enforcement: unauthenticated requests receive appropriate error responses
+  - Verify input validation: requests with missing or empty body field receive validation errors
+  - Verify AI service gating: requests when AI is disabled receive appropriate error responses
+  - Verify response structure: correct fields, trailing slash on path, correct grant value
+  - _Requirements: 1.1, 1.2, 1.3, 1.4, 2.1, 8.1, 8.2, 9.1, 9.2_
+
+## Phase 2 — Revised
+
+- [x] 2. (P) Enhance grant resolver for ancestor path traversal
+  - Enhance the existing grant resolution to support paths that may not yet exist in GROWI, as required by the sibling pattern where new directory names are generated
+  - When the direct parent page exists, return its grant value as the upper bound for child page permissions
+  - When the direct parent page is not found, traverse upward through ancestor paths to find the nearest existing page's grant
+  - When no ancestor page is found at any level, return owner-only grant as a safe default
+  - Include unit tests for: direct parent found, ancestor found at various depths, no ancestor found (safe default), root-level paths, paths with trailing slashes
+  - _Requirements: 7.1, 7.2_
+
+- [x] 3. (P) Content analysis via GROWI AI (1st AI call)
+  - Implement content analysis that delegates to GROWI AI for a single AI call performing both keyword extraction and flow/stock information type classification
+  - Extract 1-5 keywords from the content, prioritizing proper nouns and technical terms over generic words
+  - Classify the content as either flow information (time-bound: meeting notes, diaries, reports) or stock information (reference: documentation, knowledge base articles)
+  - Reference the existing flow/stock classification guidance as a prompt reference, without treating it as the sole classification criterion
+  - On analysis failure or inability to produce usable keywords, throw an error so the caller can handle fallback logic
+  - Include unit tests for: successful keyword extraction with quality verification, correct flow/stock classification for representative content samples, edge cases (very short content, ambiguous content), and failure propagation
+  - _Requirements: 5.1, 5.2, 5.4_
+
+- [x] 4. (P) Search candidate retrieval with score threshold filtering
+  - Implement search candidate retrieval that searches for related pages using extracted keywords via the existing search service
+  - Use extracted keywords (not raw content body) for search operations
+  - Filter search results using an Elasticsearch score threshold to retain only sufficiently relevant candidates
+  - Return an array of candidates with page path, snippet, and score for downstream AI evaluation
+  - Return an empty array if no results pass the threshold, allowing the caller to omit search-based suggestions
+  - The score threshold value is configurable and will be tuned with real data during implementation
+  - Include unit tests for: multi-result retrieval, threshold filtering (candidates above/below/at threshold), empty result handling, and correct candidate structure
+  - _Requirements: 3.1, 3.2, 3.5, 5.3_
+
+- [x] 5. (P) AI-based candidate evaluation and path proposal (2nd AI call)
+  - Implement candidate evaluation that delegates to GROWI AI for a single AI call evaluating search candidates for content-destination fit
+  - Evaluate each candidate's suitability by passing the content body, the content analysis results (keywords and informationType from the 1st AI call), and each candidate's path and search snippet
+  - For each suitable candidate, propose a save location using one of three structural patterns relative to the matching page: (a) parent directory, (b) subdirectory under the matching page, (c) sibling directory alongside the matching page
+  - When the sibling pattern is selected, generate an appropriate new directory name based on the content being saved; the generated path must be at the same hierarchy level as the matching search candidate page
+  - Generate a description for each suggestion explaining why the location is suitable, considering content relevance and flow/stock alignment
+  - Rank suggestions by content-destination fit, using flow/stock information type alignment as a ranking factor rather than a hard filter
+  - Pass candidate paths and ES snippets to the AI context, not full page bodies, to manage AI context budget
+  - On evaluation failure, throw an error so the caller can handle fallback logic
+  - Include unit tests for: path pattern selection across all three patterns, sibling path generation at correct hierarchy level, AI-generated description quality, ranking order, flow/stock alignment consideration, and failure propagation
+  - _Requirements: 3.3, 6.3, 10.1, 10.2, 10.3, 10.4, 11.1, 11.2, 11.3, 12.1, 12.2, 12.3, 12.4_
+
+- [x] 6. (P) Category-based path suggestion (under review — prior implementation retained)
+  - This component has an existing implementation from the prior Phase 2 design; it is retained as-is pending reviewer discussion on whether to keep, merge, or remove
+  - Search for matching pages scoped to top-level directories using extracted keywords
+  - 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 using mechanical text, not AI
+  - Resolve the parent page's grant value via grant resolution
+  - Return null when no matching top-level pages are found, so this suggestion type is omitted from the response
+  - Include unit tests for: top-level segment extraction, description generation, grant resolution, and empty result handling
+  - _Requirements: 4.1, 4.2, 4.3, 4.4_
+
+- [x] 7. Phase 2 revised orchestration and integration
+- [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
+  - Ensure the response includes both structured metadata (informationType, type, grant) and natural language context (description) for client LLM independence
+  - Ensure all reasoning-intensive operations (keyword extraction, flow/stock classification, candidate evaluation, path proposal, description generation) are performed server-side
+  - Handle graceful degradation at each failure point: content analysis failure skips the entire search pipeline (memo-only), candidate evaluation failure falls back to memo + category (if available), category failure is independent and does not affect the search pipeline
+  - Ensure the response always contains at least one suggestion (memo type)
+  - Update the route handler to use the revised orchestration function with injected dependencies
+  - Include unit tests for: full pipeline success with all suggestion types, partial failures at each stage with correct degradation, informationType mapping to PathSuggestion, dependency injection, and parallel execution of category vs search-evaluate pipeline
+  - _Requirements: 1.1, 1.2, 1.3, 3.3, 3.4, 5.3, 5.5, 8.3, 9.2, 11.4, 13.1, 13.2, 13.3_
+
+- [x] 7.2 Phase 2 integration verification
+  - Verify the complete revised flow end-to-end: content body → content analysis (keywords + informationType) → search candidate retrieval (with score threshold) → candidate evaluation (path proposals + descriptions) → grant resolution → unified response with all suggestion types
+  - Verify informationType field is present in search-based suggestions and absent in memo and category suggestions
+  - Verify path proposal patterns work correctly: parent directory, subdirectory, and sibling with generated new paths at the correct hierarchy level
+  - Verify graceful degradation at each failure point: content analysis failure → memo-only, search returns empty → search suggestions omitted, candidate evaluation failure → memo + category, category failure → memo + search, all Phase 2 failures → memo-only
+  - Verify response structure across all suggestion types: correct fields, AI-generated descriptions for search type, fixed description for memo, mechanical description for category, valid grant values, and trailing slashes on all paths
+  - _Requirements: 3.1, 3.2, 3.3, 3.4, 3.5, 5.1, 5.4, 5.5, 6.1, 6.3, 10.1, 11.1, 11.4, 12.1, 13.1, 13.2_
+
+## Post-Implementation Refactoring (from code review)
+
+See `gap-analysis.md` for detailed rationale.
+
+- [x] 8. Simplify service layer abstractions
+- [x] 8.1 Remove `GenerateSuggestionsDeps` DI pattern from `generate-suggestions.ts`
+  - Remove the `GenerateSuggestionsDeps` type and `deps` parameter from `generateSuggestions()`
+  - Import `analyzeContent`, `evaluateCandidates`, `generateCategorySuggestion`, `resolveParentGrant` directly
+  - Accept `searchService` as a direct argument (the only true external dependency that cannot be imported)
+  - Rewrite `generate-suggestions.spec.ts` to use `vi.mock()` instead of injected mock deps
+  - Simplify the route handler in `routes/apiv3/index.ts` to pass `searchService` directly instead of wiring 5 callbacks
+
+- [x] 8.2 Remove `RetrieveSearchCandidatesOptions` from `retrieve-search-candidates.ts`
+  - Replace `options: RetrieveSearchCandidatesOptions` with a direct `searchService: SearchService` parameter
+  - Keep `scoreThreshold` as a module-level constant (no caller overrides it)
+  - Update `retrieve-search-candidates.spec.ts` accordingly
+  - Update the call site in `generate-suggestions.ts` (no more lambda wrapper needed)
+
+- [x] 8.3 Add JSDoc to `call-llm-for-json.ts`
+  - Add a brief JSDoc comment explaining this utility's purpose: shared LLM client initialization, JSON parsing, and response validation
+  - Document that it is consumed by `analyzeContent` and `evaluateCandidates`
+
+- [x] 8.4 Narrow `userGroups: unknown` to `ObjectIdLike[]`
+  - Update `SearchService` interface in `suggest-path-types.ts`: change `userGroups: unknown` to `userGroups: ObjectIdLike[]`
+  - Propagate the type change to `retrieveSearchCandidates` and `generateSuggestions` signatures
+  - Import `ObjectIdLike` from `@growi/core` (or the appropriate subpath)
+  - Update test files to use correctly typed mock values
+
+## Requirements Coverage
+
+| Requirement | Task(s) |
+|-------------|---------|
+| 1.1 | 1.2, 1.3, 7.1 |
+| 1.2 | 1.1, 1.3, 7.1 |
+| 1.3 | 1.1, 1.3, 7.1 |
+| 1.4 | 1.2, 1.3 |
+| 2.1 | 1.1, 1.3 |
+| 2.2 | 1.1 |
+| 2.3 | 1.1 |
+| 2.4 | 1.1 |
+| 2.5 | 1.1 |
+| 3.1 | 4, 7.2 |
+| 3.2 | 4, 7.2 |
+| 3.3 | 5, 7.1, 7.2 |
+| 3.4 | 7.1, 7.2 |
+| 3.5 | 4, 7.2 |
+| 4.1 | 6 |
+| 4.2 | 6 |
+| 4.3 | 6 |
+| 4.4 | 6 |
+| 5.1 | 3, 7.2 |
+| 5.2 | 3 |
+| 5.3 | 4, 7.1 |
+| 5.4 | 3, 7.2 |
+| 5.5 | 7.1, 7.2 |
+| 6.1 | 1.1, 7.2 |
+| 6.2 | 1.1 |
+| 6.3 | 5, 7.2 |
+| 7.1 | 2 |
+| 7.2 | 2 |
+| 8.1 | 1.2, 1.3 |
+| 8.2 | 1.2, 1.3 |
+| 8.3 | 1.2, 7.1 |
+| 9.1 | 1.2, 1.3 |
+| 9.2 | 1.2, 7.1 |
+| 10.1 | 5, 7.2 |
+| 10.2 | 5 |
+| 10.3 | 5 |
+| 10.4 | 5 |
+| 11.1 | 5, 7.2 |
+| 11.2 | 5 |
+| 11.3 | 5 |
+| 11.4 | 7.1, 7.2 |
+| 12.1 | 5, 7.2 |
+| 12.2 | 5 |
+| 12.3 | 5 |
+| 12.4 | 5 |
+| 13.1 | 7.1, 7.2 |
+| 13.2 | 7.1, 7.2 |
+| 13.3 | 7.1 |

+ 10 - 0
apps/app/src/features/ai-tools/server/routes/apiv3/index.ts

@@ -0,0 +1,10 @@
+import express from 'express';
+
+import { suggestPathHandlersFactory } from '~/features/ai-tools/suggest-path/server/routes/apiv3';
+import type Crowi from '~/server/crowi';
+
+export const factory = (crowi: Crowi): express.Router => {
+  const router = express.Router();
+  router.post('/suggest-path', suggestPathHandlersFactory(crowi));
+  return router;
+};

+ 62 - 0
apps/app/src/features/ai-tools/suggest-path/interfaces/suggest-path-types.ts

@@ -0,0 +1,62 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+export const SuggestionType = {
+  MEMO: 'memo',
+  SEARCH: 'search',
+  CATEGORY: 'category',
+} as const;
+
+export type SuggestionType =
+  (typeof SuggestionType)[keyof typeof SuggestionType];
+
+export type PathSuggestion = {
+  type: SuggestionType;
+  path: string;
+  label: string;
+  description: string;
+  grant: number;
+  informationType?: InformationType;
+};
+
+export type InformationType = 'flow' | 'stock';
+
+export type ContentAnalysis = {
+  keywords: string[];
+  informationType: InformationType;
+};
+
+export type SearchCandidate = {
+  pagePath: string;
+  snippet: string;
+  score: number;
+};
+
+export type EvaluatedSuggestion = {
+  path: string;
+  label: string;
+  description: string;
+};
+
+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: ObjectIdLike[],
+    opts: Record<string, unknown>,
+  ): Promise<[{ data: SearchResultItem[] }, unknown]>;
+};

+ 909 - 0
apps/app/src/features/ai-tools/suggest-path/server/integration-tests/suggest-path-integration.spec.ts

@@ -0,0 +1,909 @@
+import type { NextFunction, Request, Response } from 'express';
+import express from 'express';
+import request from 'supertest';
+
+import type { ContentAnalysis } from '~/features/ai-tools/suggest-path/interfaces/suggest-path-types';
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+// Mutable test state — controls mock behavior per test
+const testState = vi.hoisted(() => ({
+  authenticateUser: true,
+  aiEnabled: true,
+  openaiServiceType: 'openai' as string | null,
+  disableUserPages: false,
+  // Phase 2 - content analysis
+  contentAnalysis: null as {
+    keywords: string[];
+    informationType: 'flow' | 'stock';
+  } | null,
+  contentAnalysisError: null as Error | null,
+  // Phase 2 - search candidates
+  searchCandidates: [] as Array<{
+    pagePath: string;
+    snippet: string;
+    score: number;
+  }>,
+  searchCandidatesError: null as Error | null,
+  // Phase 2 - candidate evaluation
+  evaluatedSuggestions: [] as Array<{
+    path: string;
+    label: string;
+    description: string;
+  }>,
+  evaluateCandidatesError: null as Error | null,
+  // Phase 2 - category
+  categorySuggestion: null as {
+    type: string;
+    path: string;
+    label: string;
+    description: string;
+    grant: number;
+  } | null,
+  categorySuggestionError: null as Error | null,
+  // Phase 2 - grant
+  parentGrant: 1,
+}));
+
+const mockUser = {
+  _id: 'user123',
+  username: 'alice',
+  status: 2, // STATUS_ACTIVE
+};
+
+// Mock access token parser — always passthrough
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser:
+    () => (_req: Request, _res: Response, next: NextFunction) =>
+      next(),
+}));
+
+// Mock login required — conditional authentication based on testState
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: () => (req: Request, res: Response, next: NextFunction) => {
+    if (!testState.authenticateUser) {
+      return res.sendStatus(403);
+    }
+    Object.assign(req, { user: mockUser });
+    next();
+  },
+}));
+
+// Mock config manager — certifyAiService and generateMemoSuggestion read from this
+vi.mock('~/server/service/config-manager', () => ({
+  configManager: {
+    getConfig: (key: string) => {
+      switch (key) {
+        case 'app:aiEnabled':
+          return testState.aiEnabled;
+        case 'openai:serviceType':
+          return testState.openaiServiceType;
+        case 'security:disableUserPages':
+          return testState.disableUserPages;
+        default:
+          return undefined;
+      }
+    },
+  },
+}));
+
+// 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 analyzeContent — configurable per test via testState
+vi.mock('../services/analyze-content', () => ({
+  analyzeContent: vi.fn().mockImplementation(() => {
+    if (testState.contentAnalysisError != null) {
+      return Promise.reject(testState.contentAnalysisError);
+    }
+    if (testState.contentAnalysis == null) {
+      return Promise.resolve({ keywords: [], informationType: 'stock' });
+    }
+    return Promise.resolve(testState.contentAnalysis);
+  }),
+}));
+
+// Mock retrieveSearchCandidates — configurable per test via testState
+vi.mock('../services/retrieve-search-candidates', () => ({
+  retrieveSearchCandidates: vi.fn().mockImplementation(() => {
+    if (testState.searchCandidatesError != null) {
+      return Promise.reject(testState.searchCandidatesError);
+    }
+    return Promise.resolve(testState.searchCandidates);
+  }),
+}));
+
+// Mock evaluateCandidates — configurable per test via testState
+vi.mock('../services/evaluate-candidates', () => ({
+  evaluateCandidates: vi.fn().mockImplementation(() => {
+    if (testState.evaluateCandidatesError != null) {
+      return Promise.reject(testState.evaluateCandidatesError);
+    }
+    return Promise.resolve(testState.evaluatedSuggestions);
+  }),
+}));
+
+// Mock generateCategorySuggestion — configurable per test via testState
+vi.mock('../services/generate-category-suggestion', () => ({
+  generateCategorySuggestion: vi.fn().mockImplementation(() => {
+    if (testState.categorySuggestionError != null) {
+      return Promise.reject(testState.categorySuggestionError);
+    }
+    return Promise.resolve(testState.categorySuggestion);
+  }),
+}));
+
+// Mock resolveParentGrant — returns configurable grant value via testState
+vi.mock('../services/resolve-parent-grant', () => ({
+  resolveParentGrant: vi.fn().mockImplementation(() => {
+    return Promise.resolve(testState.parentGrant);
+  }),
+}));
+
+describe('POST /suggest-path integration', () => {
+  let app: express.Application;
+
+  beforeEach(async () => {
+    // Reset test state to defaults
+    testState.authenticateUser = true;
+    testState.aiEnabled = true;
+    testState.openaiServiceType = 'openai';
+    testState.disableUserPages = false;
+    testState.contentAnalysis = null;
+    testState.contentAnalysisError = null;
+    testState.searchCandidates = [];
+    testState.searchCandidatesError = null;
+    testState.evaluatedSuggestions = [];
+    testState.evaluateCandidatesError = null;
+    testState.categorySuggestion = null;
+    testState.categorySuggestionError = null;
+    testState.parentGrant = 1;
+
+    // Setup express app with ApiV3Response methods
+    app = express();
+    app.use(express.json());
+    app.use((_req: Request, res: Response, next: NextFunction) => {
+      const apiRes = res as ApiV3Response;
+      apiRes.apiv3 = function (obj = {}, status = 200) {
+        this.status(status).json(obj);
+      };
+      apiRes.apiv3Err = function (_err, status = 400) {
+        const errors = Array.isArray(_err) ? _err : [_err];
+        this.status(status).json({ errors });
+      };
+      next();
+    });
+
+    // Import and mount the handler factory with real middleware chain
+    const { suggestPathHandlersFactory } = await import('../routes/apiv3');
+    const mockCrowi = {
+      searchService: { searchKeyword: vi.fn() },
+    } as unknown as Crowi;
+    app.post('/suggest-path', suggestPathHandlersFactory(mockCrowi));
+  });
+
+  describe('Phase 1 — memo-only', () => {
+    describe('valid request with authentication', () => {
+      it('should return 200 with suggestions array containing one memo suggestion', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toBeDefined();
+        expect(Array.isArray(response.body.suggestions)).toBe(true);
+        expect(response.body.suggestions).toHaveLength(1);
+      });
+
+      it('should return memo suggestion with all required fields and correct values', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
+
+        const suggestion = response.body.suggestions[0];
+        expect(suggestion).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
+
+      it('should return path with trailing slash', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
+
+        expect(response.body.suggestions[0].path).toMatch(/\/$/);
+      });
+
+      it('should return grant value of 4 (GRANT_OWNER)', async () => {
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(200);
+
+        expect(response.body.suggestions[0].grant).toBe(4);
+      });
+    });
+
+    describe('authentication enforcement', () => {
+      it('should return 403 when user is not authenticated', async () => {
+        testState.authenticateUser = false;
+
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
+    });
+
+    describe('input validation', () => {
+      it('should return 400 when body field is missing', async () => {
+        await request(app).post('/suggest-path').send({}).expect(400);
+      });
+
+      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', () => {
+      it('should return 403 when AI is not enabled', async () => {
+        testState.aiEnabled = false;
+
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
+
+      it('should return 403 when openai service type is not configured', async () => {
+        testState.openaiServiceType = null;
+
+        await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Some page content' })
+          .expect(403);
+      });
+    });
+  });
+
+  describe('Phase 2 — revised pipeline verification', () => {
+    // Common fixture data
+    const stockAnalysis = {
+      keywords: ['React', 'hooks'],
+      informationType: 'stock' as const,
+    };
+
+    const flowAnalysis = {
+      keywords: ['meeting', 'standup'],
+      informationType: 'flow' as const,
+    };
+
+    const searchCandidates = [
+      {
+        pagePath: '/tech-notes/React/hooks-guide',
+        snippet: 'React hooks overview',
+        score: 10,
+      },
+      {
+        pagePath: '/tech-notes/React/state-management',
+        snippet: 'State management',
+        score: 8,
+      },
+    ];
+
+    const singleEvaluated = [
+      {
+        path: '/tech-notes/React/',
+        label: 'Save near related pages',
+        description:
+          'This area contains React documentation. Your stock content fits well here.',
+      },
+    ];
+
+    const categorySuggestionFixture = {
+      type: 'category',
+      path: '/tech-notes/',
+      label: 'Save under category',
+      description: 'Top-level category: tech-notes',
+      grant: 1,
+    };
+
+    // Helper: set up full pipeline success with optional overrides
+    const setupFullPipeline = (overrides?: {
+      analysis?: ContentAnalysis;
+      candidates?: typeof searchCandidates;
+      evaluated?: typeof singleEvaluated;
+      category?: typeof categorySuggestionFixture | null;
+    }) => {
+      testState.contentAnalysis = overrides?.analysis ?? stockAnalysis;
+      testState.searchCandidates = overrides?.candidates ?? searchCandidates;
+      testState.evaluatedSuggestions = overrides?.evaluated ?? singleEvaluated;
+      testState.categorySuggestion =
+        overrides?.category !== undefined
+          ? overrides.category
+          : categorySuggestionFixture;
+    };
+
+    describe('complete revised flow end-to-end', () => {
+      it('should return memo, search, and category suggestions when all succeed', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks and state management' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(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');
+      });
+
+      it('should return correct memo suggestion alongside Phase 2 suggestions', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions[0]).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
+
+      it('should return search suggestion with AI-evaluated path and description', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions[1];
+        expect(searchSuggestion.type).toBe('search');
+        expect(searchSuggestion.path).toBe('/tech-notes/React/');
+        expect(searchSuggestion.label).toBe('Save near related pages');
+        expect(searchSuggestion.description).toBe(
+          '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 correct structure', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions[2]).toEqual(categorySuggestionFixture);
+      });
+
+      it('should return multiple search suggestions for multi-candidate evaluation', async () => {
+        const multiEvaluated = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Save near related pages',
+            description:
+              'React documentation area with existing hooks content.',
+          },
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'New section for performance',
+            description: 'New sibling alongside existing React pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: multiEvaluated });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React performance' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(4); // memo + 2 search + category
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestions).toHaveLength(2);
+        expect(searchSuggestions[0].path).toBe('/tech-notes/React/');
+        expect(searchSuggestions[1].path).toBe(
+          '/tech-notes/React/performance/',
+        );
+      });
+
+      it('should omit search suggestions when evaluator finds no suitable candidates', async () => {
+        setupFullPipeline({ evaluated: [] });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2); // memo + category
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
+      });
+    });
+
+    describe('informationType verification', () => {
+      it('should include informationType in search-based suggestions', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.informationType).toBe('stock');
+      });
+
+      it('should not include informationType in memo suggestion', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const memoSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'memo',
+        );
+        expect(memoSuggestion).not.toHaveProperty('informationType');
+      });
+
+      it('should not include informationType in category suggestion', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const categorySuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'category',
+        );
+        expect(categorySuggestion).not.toHaveProperty('informationType');
+      });
+
+      it('should map flow informationType when content is classified as flow', async () => {
+        setupFullPipeline({ analysis: flowAnalysis });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Meeting notes from standup' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.informationType).toBe('flow');
+      });
+    });
+
+    describe('path proposal patterns', () => {
+      it('should support parent directory pattern', async () => {
+        const parentPattern = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Parent directory',
+            description:
+              'Save in the parent directory of matching React pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: parentPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'React hooks content' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe('/tech-notes/React/');
+        expect(searchSuggestion.path).toMatch(/\/$/);
+      });
+
+      it('should support subdirectory pattern', async () => {
+        const subdirPattern = [
+          {
+            path: '/tech-notes/React/hooks-guide/advanced/',
+            label: 'Subdirectory of matching page',
+            description: 'Save under the hooks guide as a sub-topic.',
+          },
+        ];
+        setupFullPipeline({ evaluated: subdirPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Advanced React hooks patterns' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe(
+          '/tech-notes/React/hooks-guide/advanced/',
+        );
+      });
+
+      it('should support sibling pattern with new path at correct hierarchy level', async () => {
+        // Matching candidate was at /tech-notes/React/hooks-guide (depth 3)
+        // Sibling should also be at depth 3: /tech-notes/React/performance/
+        const siblingPattern = [
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'New section for performance',
+            description:
+              'A new sibling section alongside existing React documentation.',
+          },
+        ];
+        setupFullPipeline({ evaluated: siblingPattern, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'React performance optimization tips' })
+          .expect(200);
+
+        const searchSuggestion = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestion.path).toBe('/tech-notes/React/performance/');
+        // Verify hierarchy level: path has 3 segments (same depth as hooks-guide)
+        const segments = searchSuggestion.path.split('/').filter(Boolean);
+        expect(segments).toHaveLength(3);
+      });
+
+      it('should return all three patterns when evaluator produces them', async () => {
+        const allPatterns = [
+          {
+            path: '/tech-notes/React/',
+            label: 'Parent directory',
+            description: 'Parent directory of matching pages.',
+          },
+          {
+            path: '/tech-notes/React/hooks-guide/advanced/',
+            label: 'Subdirectory',
+            description: 'Under the hooks guide.',
+          },
+          {
+            path: '/tech-notes/React/performance/',
+            label: 'Sibling section',
+            description: 'New sibling alongside existing pages.',
+          },
+        ];
+        setupFullPipeline({ evaluated: allPatterns, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React' })
+          .expect(200);
+
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        expect(searchSuggestions).toHaveLength(3);
+        expect(searchSuggestions[0].path).toBe('/tech-notes/React/');
+        expect(searchSuggestions[1].path).toBe(
+          '/tech-notes/React/hooks-guide/advanced/',
+        );
+        expect(searchSuggestions[2].path).toBe(
+          '/tech-notes/React/performance/',
+        );
+        // All paths end with trailing slash
+        for (const s of searchSuggestions) {
+          expect(s.path).toMatch(/\/$/);
+        }
+      });
+    });
+
+    describe('graceful degradation', () => {
+      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')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
+
+      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')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
+
+      it('should omit search suggestions when search returns empty candidates', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = [];
+        testState.categorySuggestion = categorySuggestionFixture;
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2); // memo + category
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
+      });
+
+      it('should return memo + category when candidate evaluation fails', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = searchCandidates;
+        testState.evaluateCandidatesError = new Error('AI evaluation failed');
+        testState.categorySuggestion = categorySuggestionFixture;
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2);
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('category');
+      });
+
+      it('should return memo + search when category generation fails', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidates = searchCandidates;
+        testState.evaluatedSuggestions = singleEvaluated;
+        testState.categorySuggestionError = new Error(
+          'Category generation failed',
+        );
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(2);
+        expect(response.body.suggestions[0].type).toBe('memo');
+        expect(response.body.suggestions[1].type).toBe('search');
+      });
+
+      it('should return memo-only when all Phase 2 components fail', async () => {
+        testState.contentAnalysis = stockAnalysis;
+        testState.searchCandidatesError = new Error('Search service down');
+        testState.categorySuggestionError = new Error('Category failed');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions).toHaveLength(1);
+        expect(response.body.suggestions[0].type).toBe('memo');
+      });
+
+      it('should return correct memo structure even when Phase 2 degrades', async () => {
+        testState.contentAnalysisError = new Error('AI service unavailable');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        expect(response.body.suggestions[0]).toEqual({
+          type: 'memo',
+          path: '/user/alice/memo/',
+          label: 'Save as memo',
+          description: 'Save to your personal memo area',
+          grant: 4,
+        });
+      });
+
+      it('should skip search pipeline entirely when content analysis fails', async () => {
+        testState.contentAnalysisError = new Error('AI service unavailable');
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        // Only memo, no search or category
+        expect(response.body.suggestions).toHaveLength(1);
+        const types = response.body.suggestions.map(
+          (s: { type: string }) => s.type,
+        );
+        expect(types).not.toContain('search');
+        expect(types).not.toContain('category');
+      });
+    });
+
+    describe('response structure verification', () => {
+      it('should have trailing slashes on all suggestion paths', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        for (const suggestion of response.body.suggestions) {
+          expect(suggestion.path).toMatch(/\/$/);
+        }
+      });
+
+      it('should include all required fields in every suggestion', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const requiredFields = [
+          'type',
+          'path',
+          'label',
+          'description',
+          'grant',
+        ];
+        for (const suggestion of response.body.suggestions) {
+          for (const field of requiredFields) {
+            expect(suggestion).toHaveProperty(field);
+          }
+        }
+      });
+
+      it('should include grant values as numbers for all suggestion types', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        for (const suggestion of response.body.suggestions) {
+          expect(typeof suggestion.grant).toBe('number');
+        }
+      });
+
+      it('should have fixed description for memo type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const memo = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'memo',
+        );
+        expect(memo.description).toBe('Save to your personal memo area');
+      });
+
+      it('should have AI-generated description for search type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const search = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'search',
+        );
+        // AI-generated descriptions are non-empty and contextual
+        expect(search.description).toBeTruthy();
+        expect(search.description.length).toBeGreaterThan(10);
+      });
+
+      it('should have mechanical description for category type', async () => {
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const category = response.body.suggestions.find(
+          (s: { type: string }) => s.type === 'category',
+        );
+        // Mechanical description follows "Top-level category: {name}" format
+        expect(category.description).toMatch(/^Top-level category: /);
+      });
+
+      it('should have valid PageGrant values for all suggestions', async () => {
+        testState.parentGrant = 4;
+        setupFullPipeline();
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React hooks' })
+          .expect(200);
+
+        const validGrants = [1, 2, 4, 5];
+        for (const suggestion of response.body.suggestions) {
+          expect(validGrants).toContain(suggestion.grant);
+        }
+      });
+
+      it('should resolve different grant values per search suggestion path', async () => {
+        const multiEvaluated = [
+          {
+            path: '/public-docs/React/',
+            label: 'Public docs',
+            description: 'Public documentation area.',
+          },
+          {
+            path: '/private-notes/React/',
+            label: 'Private notes',
+            description: 'Private notes area.',
+          },
+        ];
+        setupFullPipeline({ evaluated: multiEvaluated, category: null });
+
+        const response = await request(app)
+          .post('/suggest-path')
+          .send({ body: 'Content about React' })
+          .expect(200);
+
+        const searchSuggestions = response.body.suggestions.filter(
+          (s: { type: string }) => s.type === 'search',
+        );
+        // Both use testState.parentGrant (1) — verifies grant resolution is called per path
+        expect(searchSuggestions).toHaveLength(2);
+        for (const s of searchSuggestions) {
+          expect(typeof s.grant).toBe('number');
+        }
+      });
+    });
+  });
+});

+ 182 - 0
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.spec.ts

@@ -0,0 +1,182 @@
+import type { Request, RequestHandler } from 'express';
+import type { Mock } from 'vitest';
+
+import type Crowi from '~/server/crowi';
+import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+const mocks = vi.hoisted(() => {
+  return {
+    generateSuggestionsMock: vi.fn(),
+    loginRequiredFactoryMock: vi.fn(),
+    certifyAiServiceMock: vi.fn(),
+    findAllUserGroupIdsMock: vi.fn(),
+    findAllExternalUserGroupIdsMock: vi.fn(),
+  };
+});
+
+vi.mock('../../services/generate-suggestions', () => ({
+  generateSuggestions: mocks.generateSuggestionsMock,
+}));
+
+vi.mock('~/server/middlewares/login-required', () => ({
+  default: mocks.loginRequiredFactoryMock,
+}));
+
+vi.mock(
+  '~/features/openai/server/routes/middlewares/certify-ai-service',
+  () => ({
+    certifyAiService: mocks.certifyAiServiceMock,
+  }),
+);
+
+vi.mock('~/server/middlewares/access-token-parser', () => ({
+  accessTokenParser: vi.fn(() => vi.fn()),
+}));
+
+vi.mock('~/server/middlewares/apiv3-form-validator', () => ({
+  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', () => {
+  const mockSearchService = { searchKeyword: vi.fn() };
+  const mockCrowi = {
+    searchService: mockSearchService,
+  } as unknown as Crowi;
+
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.loginRequiredFactoryMock.mockReturnValue(vi.fn());
+    mocks.findAllUserGroupIdsMock.mockResolvedValue(['group1']);
+    mocks.findAllExternalUserGroupIdsMock.mockResolvedValue(['extGroup1']);
+  });
+
+  describe('middleware chain', () => {
+    it('should return an array of request handlers', async () => {
+      const { suggestPathHandlersFactory } = await import('.');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      expect(Array.isArray(handlers)).toBe(true);
+      expect(handlers.length).toBeGreaterThanOrEqual(5);
+    });
+
+    it('should include certifyAiService in the middleware chain', async () => {
+      const { suggestPathHandlersFactory } = await import('.');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      expect(handlers).toContain(mocks.certifyAiServiceMock);
+    });
+  });
+
+  describe('handler', () => {
+    const createMockReqRes = () => {
+      const req = {
+        user: { _id: 'user123', username: 'alice' },
+        body: { body: 'Some page content' },
+      } as unknown as Request;
+
+      const res = {
+        apiv3: vi.fn(),
+        apiv3Err: vi.fn(),
+      } as unknown as ApiV3Response;
+
+      return { req, res };
+    };
+
+    it('should call generateSuggestions with user, body, userGroups, and searchService', 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('.');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(mocks.generateSuggestionsMock).toHaveBeenCalledWith(
+        { _id: 'user123', username: 'alice' },
+        'Some page content',
+        ['group1', 'extGroup1'],
+        mockSearchService,
+      );
+    });
+
+    it('should return suggestions array via res.apiv3', 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('.');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(res.apiv3).toHaveBeenCalledWith({ suggestions });
+    });
+
+    it('should return error when generateSuggestions throws', async () => {
+      mocks.generateSuggestionsMock.mockRejectedValue(
+        new Error('Unexpected error'),
+      );
+
+      const { suggestPathHandlersFactory } = await import('.');
+      const handlers = suggestPathHandlersFactory(mockCrowi);
+      const handler = handlers[handlers.length - 1] as RequestHandler;
+
+      const { req, res } = createMockReqRes();
+      await handler(req, res, vi.fn());
+
+      expect(res.apiv3Err).toHaveBeenCalled();
+      // Should not expose internal error details (Req 9.2)
+      const apiv3ErrMock = res.apiv3Err as Mock;
+      const errorCall = apiv3ErrMock.mock.calls[0];
+      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('.');
+      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']);
+    });
+  });
+});

+ 98 - 0
apps/app/src/features/ai-tools/suggest-path/server/routes/apiv3/index.ts

@@ -0,0 +1,98 @@
+import assert from 'node:assert';
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+import { SCOPE } from '@growi/core/dist/interfaces';
+import { ErrorV3 } from '@growi/core/dist/models';
+import type { Request, RequestHandler } from 'express';
+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 type Crowi from '~/server/crowi';
+import { accessTokenParser } from '~/server/middlewares/access-token-parser';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+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 loggerFactory from '~/utils/logger';
+
+import type { SearchService } from '../../../interfaces/suggest-path-types';
+import { generateSuggestions } from '../../services/generate-suggestions';
+
+const logger = loggerFactory('growi:features:suggest-path:routes');
+
+type ReqBody = {
+  body: string;
+};
+
+type SuggestPathReq = Request<
+  Record<string, string>,
+  ApiV3Response,
+  ReqBody
+> & {
+  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')
+    .isLength({ max: MAX_BODY_LENGTH })
+    .withMessage(`body must not exceed ${MAX_BODY_LENGTH} characters`),
+];
+
+export const suggestPathHandlersFactory = (crowi: Crowi): RequestHandler[] => {
+  const loginRequiredStrictly = loginRequiredFactory(crowi);
+
+  return [
+    accessTokenParser([SCOPE.READ.FEATURES.AI_ASSISTANT], {
+      acceptLegacy: true,
+    }),
+    loginRequiredStrictly,
+    certifyAiService,
+    ...validator,
+    apiV3FormValidator,
+    async (req: SuggestPathReq, res: ApiV3Response) => {
+      const { user } = req;
+      assert(
+        user != null,
+        'user is required (ensured by loginRequiredStrictly middleware)',
+      );
+
+      try {
+        const { searchService } = crowi;
+        assert(
+          searchService != null &&
+            typeof (searchService as unknown 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(
+            user,
+          )),
+        ];
+
+        const suggestions = await generateSuggestions(
+          user,
+          req.body.body,
+          userGroups,
+          typedSearchService,
+        );
+        return res.apiv3({ suggestions });
+      } catch (err) {
+        logger.error(err);
+        return res.apiv3Err(
+          new ErrorV3('Failed to generate path suggestions'),
+          500,
+        );
+      }
+    },
+  ];
+};

+ 390 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/analyze-content.spec.ts

@@ -0,0 +1,390 @@
+import type { ContentAnalysis } from '../../interfaces/suggest-path-types';
+import { analyzeContent } from './analyze-content';
+
+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('analyzeContent', () => {
+  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 keyword extraction with quality verification', () => {
+    it('should return keywords and informationType from AI response', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['React', 'hooks', 'useState'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent(
+        'A guide to React hooks and useState',
+      );
+
+      expect(result).toEqual({
+        keywords: ['React', 'hooks', 'useState'],
+        informationType: 'stock',
+      } satisfies ContentAnalysis);
+    });
+
+    it('should extract 1-5 keywords prioritizing proper nouns and technical terms', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: [
+                  'TypeScript',
+                  'generics',
+                  'mapped types',
+                  'conditional types',
+                ],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent(
+        'TypeScript generics and advanced type system features',
+      );
+
+      expect(result.keywords.length).toBeGreaterThanOrEqual(1);
+      expect(result.keywords.length).toBeLessThanOrEqual(5);
+    });
+
+    it('should pass content body to chatCompletion as user message', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['MongoDB'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      await analyzeContent('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 both keyword extraction and flow/stock classification', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['Next.js'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      await analyzeContent('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: JSON.stringify({
+                keywords: ['keyword'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      await analyzeContent('test content');
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.not.objectContaining({
+          stream: true,
+        }),
+      );
+    });
+  });
+
+  describe('correct flow/stock classification for representative content samples', () => {
+    it('should classify meeting notes as flow', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['sprint', 'retrospective', 'action items'],
+                informationType: 'flow',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent(
+        '2025/05/01 Sprint retrospective meeting notes. Action items discussed.',
+      );
+
+      expect(result.informationType).toBe('flow');
+    });
+
+    it('should classify documentation as stock', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['API', 'authentication', 'JWT'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent(
+        'API Authentication Guide: How to use JWT tokens for secure access.',
+      );
+
+      expect(result.informationType).toBe('stock');
+    });
+  });
+
+  describe('edge cases', () => {
+    it('should handle very short content', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['hello'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent('hello');
+
+      expect(result.keywords).toEqual(['hello']);
+      expect(result.informationType).toBe('stock');
+    });
+
+    it('should handle content with ambiguous information type', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['Docker', 'deployment'],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      const result = await analyzeContent('Docker deployment notes');
+
+      expect(result.keywords.length).toBeGreaterThanOrEqual(1);
+      expect(['flow', 'stock']).toContain(result.informationType);
+    });
+  });
+
+  describe('failure propagation', () => {
+    it('should throw when chatCompletion rejects', async () => {
+      mocks.chatCompletionMock.mockRejectedValue(new Error('API error'));
+
+      await expect(analyzeContent('test')).rejects.toThrow('API error');
+    });
+
+    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(
+        /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 () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({ informationType: 'stock' }),
+            },
+          },
+        ],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when AI returns JSON without informationType field', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({ keywords: ['test'] }),
+            },
+          },
+        ],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when AI returns invalid informationType value', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: ['test'],
+                informationType: 'invalid',
+              }),
+            },
+          },
+        ],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when keywords is not an array', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: 'not-an-array',
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when keywords array is empty', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                keywords: [],
+                informationType: 'stock',
+              }),
+            },
+          },
+        ],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when choices array is empty', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw when message content is null', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: null } }],
+      });
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+
+    it('should throw on streaming response', async () => {
+      const streamMock = {
+        [Symbol.asyncIterator]: () => ({}),
+      };
+      mocks.chatCompletionMock.mockResolvedValue(streamMock);
+
+      await expect(analyzeContent('test')).rejects.toThrow();
+    });
+  });
+});

+ 51 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/analyze-content.ts

@@ -0,0 +1,51 @@
+import { instructionsForInformationTypes } from '~/features/openai/server/services/assistant/instructions/commons';
+
+import type {
+  ContentAnalysis,
+  InformationType,
+} from '../../interfaces/suggest-path-types';
+import { callLlmForJson } from './call-llm-for-json';
+
+const VALID_INFORMATION_TYPES: readonly InformationType[] = ['flow', 'stock'];
+
+const SYSTEM_PROMPT = [
+  'You are a content analysis assistant. Analyze the following content and return a JSON object with two fields:\n',
+  '1. "keywords": An array of 1 to 5 search keywords extracted from the content. ',
+  'Prioritize proper nouns and technical terms over generic or common words.\n',
+  '2. "informationType": Classify the content as either "flow" or "stock".\n\n',
+  '## Classification Reference\n',
+  instructionsForInformationTypes,
+  '\n\n',
+  'Return only the JSON object, no other text.\n',
+  'Example: {"keywords": ["React", "useState", "hooks"], "informationType": "stock"}',
+].join('');
+
+const isValidContentAnalysis = (parsed: unknown): parsed is ContentAnalysis => {
+  if (parsed == null || typeof parsed !== 'object') {
+    return false;
+  }
+
+  const obj = parsed as Record<string, unknown>;
+
+  if (!Array.isArray(obj.keywords) || obj.keywords.length === 0) {
+    return false;
+  }
+
+  if (
+    typeof obj.informationType !== 'string' ||
+    !VALID_INFORMATION_TYPES.includes(obj.informationType as InformationType)
+  ) {
+    return false;
+  }
+
+  return true;
+};
+
+export const analyzeContent = (body: string): Promise<ContentAnalysis> => {
+  return callLlmForJson(
+    SYSTEM_PROMPT,
+    body,
+    isValidContentAnalysis,
+    'Invalid content analysis response: expected { keywords: string[], informationType: "flow" | "stock" }',
+  );
+};

+ 60 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/call-llm-for-json.ts

@@ -0,0 +1,60 @@
+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';
+
+/**
+ * Shared utility for making LLM calls that return JSON responses.
+ * Handles OpenAI client initialization, JSON parsing, and response validation.
+ * Consumed by `analyzeContent` (1st AI call) and `evaluateCandidates` (2nd AI call).
+ */
+export const callLlmForJson = async <T>(
+  systemPrompt: string,
+  userMessage: string,
+  validate: (parsed: unknown) => parsed is T,
+  validationErrorMessage: string,
+): Promise<T> => {
+  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: systemPrompt },
+      { role: 'user', content: userMessage },
+    ],
+  });
+
+  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) {
+    throw new Error('No content returned from chatCompletion');
+  }
+
+  let parsed: unknown;
+  try {
+    parsed = JSON.parse(content);
+  } catch {
+    throw new Error(
+      `Failed to parse LLM response as JSON: ${content.slice(0, 200)}`,
+    );
+  }
+
+  if (!validate(parsed)) {
+    throw new Error(validationErrorMessage);
+  }
+
+  return parsed;
+};

+ 511 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/evaluate-candidates.spec.ts

@@ -0,0 +1,511 @@
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  SearchCandidate,
+} from '../../interfaces/suggest-path-types';
+import { evaluateCandidates } from './evaluate-candidates';
+
+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,
+}));
+
+const stockAnalysis: ContentAnalysis = {
+  keywords: ['React', 'hooks', 'useState'],
+  informationType: 'stock',
+};
+
+const flowAnalysis: ContentAnalysis = {
+  keywords: ['sprint', 'retrospective'],
+  informationType: 'flow',
+};
+
+const sampleCandidates: SearchCandidate[] = [
+  {
+    pagePath: '/tech/React/hooks',
+    snippet: 'React hooks guide for state management',
+    score: 15,
+  },
+  {
+    pagePath: '/tech/React/state',
+    snippet: 'Managing state in React applications',
+    score: 12,
+  },
+];
+
+function mockAiResponse(suggestions: EvaluatedSuggestion[]) {
+  mocks.chatCompletionMock.mockResolvedValue({
+    choices: [
+      {
+        message: {
+          content: JSON.stringify(suggestions),
+        },
+      },
+    ],
+  });
+}
+
+describe('evaluateCandidates', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+      if (key === 'openai:serviceType') return 'openai';
+      return undefined;
+    });
+    mocks.getClientMock.mockReturnValue({
+      chatCompletion: mocks.chatCompletionMock,
+    });
+  });
+
+  describe('path pattern selection across all three patterns', () => {
+    it('should return parent directory pattern suggestion', async () => {
+      const parentSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near related pages',
+        description:
+          'This directory contains React documentation including hooks and state management.',
+      };
+      mockAiResponse([parentSuggestion]);
+
+      const result = await evaluateCandidates(
+        'A guide to React hooks',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+
+    it('should return subdirectory pattern suggestion', async () => {
+      const subdirSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/hooks/advanced/',
+        label: 'Save near related pages',
+        description:
+          'Advanced hooks content fits under the existing hooks documentation.',
+      };
+      mockAiResponse([subdirSuggestion]);
+
+      const result = await evaluateCandidates(
+        'Advanced React hooks patterns',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/hooks/advanced/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+
+    it('should return sibling directory pattern suggestion', async () => {
+      const siblingSuggestion: EvaluatedSuggestion = {
+        path: '/tech/React/performance/',
+        label: 'New section for performance topics',
+        description:
+          'A new section alongside existing React documentation for performance content.',
+      };
+      mockAiResponse([siblingSuggestion]);
+
+      const result = await evaluateCandidates(
+        'React performance optimization',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].path).toBe('/tech/React/performance/');
+      expect(result[0].path).toMatch(/\/$/);
+    });
+  });
+
+  describe('sibling path generation at correct hierarchy level', () => {
+    it('should generate sibling paths at the same level as the candidate page', async () => {
+      const candidates: SearchCandidate[] = [
+        {
+          pagePath: '/docs/frontend/React/basics',
+          snippet: 'React basics introduction',
+          score: 10,
+        },
+      ];
+      const siblingSuggestion: EvaluatedSuggestion = {
+        path: '/docs/frontend/React/advanced/',
+        label: 'New section for advanced topics',
+        description: 'Sibling section at the same level as the basics page.',
+      };
+      mockAiResponse([siblingSuggestion]);
+
+      const result = await evaluateCandidates(
+        'Advanced React patterns',
+        stockAnalysis,
+        candidates,
+      );
+
+      // Sibling path should be at the same depth as the candidate
+      const candidateDepth = '/docs/frontend/React/basics'
+        .split('/')
+        .filter(Boolean).length;
+      const resultDepth = result[0].path
+        .replace(/\/$/, '')
+        .split('/')
+        .filter(Boolean).length;
+      expect(resultDepth).toBe(candidateDepth);
+    });
+  });
+
+  describe('AI-generated description quality', () => {
+    it('should include non-empty descriptions for each suggestion', async () => {
+      const suggestions: EvaluatedSuggestion[] = [
+        {
+          path: '/tech/React/',
+          label: 'Save near related pages',
+          description:
+            'Contains documentation about React hooks and state management patterns.',
+        },
+        {
+          path: '/tech/React/hooks/custom/',
+          label: 'Save under hooks section',
+          description:
+            'Custom hooks content fits naturally under the existing hooks documentation.',
+        },
+      ];
+      mockAiResponse(suggestions);
+
+      const result = await evaluateCandidates(
+        'Custom React hooks',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(2);
+      for (const suggestion of result) {
+        expect(suggestion.description).toBeTruthy();
+        expect(suggestion.description.length).toBeGreaterThan(0);
+      }
+    });
+  });
+
+  describe('ranking order', () => {
+    it('should preserve AI-determined ranking order in results', async () => {
+      const rankedSuggestions: EvaluatedSuggestion[] = [
+        {
+          path: '/tech/React/hooks/',
+          label: 'Best match',
+          description: 'Closest content-destination fit.',
+        },
+        {
+          path: '/tech/React/',
+          label: 'Good match',
+          description: 'Broader category match.',
+        },
+      ];
+      mockAiResponse(rankedSuggestions);
+
+      const result = await evaluateCandidates(
+        'React hooks guide',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toHaveLength(2);
+      expect(result[0].path).toBe('/tech/React/hooks/');
+      expect(result[1].path).toBe('/tech/React/');
+    });
+  });
+
+  describe('flow/stock alignment consideration', () => {
+    it('should pass informationType to AI for ranking consideration', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/meetings/2025/',
+        label: 'Save near meeting notes',
+        description: 'Flow content fits well in the meetings area.',
+      };
+      mockAiResponse([suggestion]);
+
+      await evaluateCandidates(
+        'Sprint retrospective notes from today',
+        flowAnalysis,
+        [
+          {
+            pagePath: '/meetings/2025/01',
+            snippet: 'January meeting',
+            score: 10,
+          },
+        ],
+      );
+
+      // Verify the AI receives informationType in the prompt
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('flow'),
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should pass stock informationType to AI for ranking consideration', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near documentation',
+        description: 'Stock content aligns with reference documentation.',
+      };
+      mockAiResponse([suggestion]);
+
+      await evaluateCandidates(
+        'React hooks documentation',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('stock'),
+            }),
+          ]),
+        }),
+      );
+    });
+  });
+
+  describe('AI invocation details', () => {
+    it('should pass content body to AI', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates(
+        'My custom React hooks article',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'user',
+              content: expect.stringContaining('My custom React hooks article'),
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should pass candidate paths and snippets to AI, not full page bodies', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates(
+        'React hooks guide',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      const call = mocks.chatCompletionMock.mock.calls[0][0];
+      const userMessage = call.messages.find(
+        (m: { role: string }) => m.role === 'user',
+      );
+      expect(userMessage.content).toContain('/tech/React/hooks');
+      expect(userMessage.content).toContain(
+        'React hooks guide for state management',
+      );
+    });
+
+    it('should include a system prompt with evaluation instructions', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates('test content', stockAnalysis, sampleCandidates);
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.objectContaining({
+          messages: expect.arrayContaining([
+            expect.objectContaining({
+              role: 'system',
+            }),
+          ]),
+        }),
+      );
+    });
+
+    it('should not use streaming mode', async () => {
+      mockAiResponse([]);
+
+      await evaluateCandidates('test content', stockAnalysis, sampleCandidates);
+
+      expect(mocks.chatCompletionMock).toHaveBeenCalledWith(
+        expect.not.objectContaining({
+          stream: true,
+        }),
+      );
+    });
+  });
+
+  describe('empty and edge cases', () => {
+    it('should return empty array when AI evaluates no candidates as suitable', async () => {
+      mockAiResponse([]);
+
+      const result = await evaluateCandidates(
+        'Unrelated content',
+        stockAnalysis,
+        sampleCandidates,
+      );
+
+      expect(result).toEqual([]);
+    });
+
+    it('should handle single candidate input', async () => {
+      const suggestion: EvaluatedSuggestion = {
+        path: '/tech/React/',
+        label: 'Save near related pages',
+        description: 'Single candidate evaluation.',
+      };
+      mockAiResponse([suggestion]);
+
+      const result = await evaluateCandidates('React content', stockAnalysis, [
+        sampleCandidates[0],
+      ]);
+
+      expect(result).toHaveLength(1);
+    });
+  });
+
+  describe('failure propagation', () => {
+    it('should throw when chatCompletion rejects', async () => {
+      mocks.chatCompletionMock.mockRejectedValue(new Error('API error'));
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow('API error');
+    });
+
+    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(/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 () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify({
+                path: '/test/',
+                label: 'test',
+                description: 'test',
+              }),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when choices array is empty', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when message content is null', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [{ message: { content: null } }],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw on streaming response', async () => {
+      const streamMock = {
+        [Symbol.asyncIterator]: () => ({}),
+      };
+      mocks.chatCompletionMock.mockResolvedValue(streamMock);
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when suggestion item is missing required fields', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify([{ path: '/tech/' }]),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+
+    it('should throw when suggestion path does not end with trailing slash', async () => {
+      mocks.chatCompletionMock.mockResolvedValue({
+        choices: [
+          {
+            message: {
+              content: JSON.stringify([
+                { path: '/tech/React', label: 'test', description: 'test' },
+              ]),
+            },
+          },
+        ],
+      });
+
+      await expect(
+        evaluateCandidates('test', stockAnalysis, sampleCandidates),
+      ).rejects.toThrow();
+    });
+  });
+});

+ 115 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/evaluate-candidates.ts

@@ -0,0 +1,115 @@
+import { instructionsForInformationTypes } from '~/features/openai/server/services/assistant/instructions/commons';
+
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  SearchCandidate,
+} from '../../interfaces/suggest-path-types';
+import { callLlmForJson } from './call-llm-for-json';
+
+const SYSTEM_PROMPT = [
+  'You are a page save location evaluator for a wiki system. ',
+  'Given content to be saved, its analysis (keywords and information type), and a list of search candidate pages, ',
+  'propose optimal directory paths for saving the content.\n\n',
+  '## How to Read Wiki Paths\n',
+  'Treat the wiki path hierarchy as a content classification taxonomy. ',
+  'Each path segment represents a category or topic at a certain level of abstraction.\n',
+  'Example: `/engineering/frontend/react-testing-patterns`\n',
+  '- `engineering` = broad domain\n',
+  '- `frontend` = topic category within that domain\n',
+  '- `react-testing-patterns` = specific article\n\n',
+  'When proposing a save location, determine which level of the taxonomy the content belongs to ',
+  'and what category name best describes it. The proposed path should reflect where the content ',
+  'naturally fits in the existing classification structure.\n\n',
+  '## Path Proposal\n',
+  'For each suitable candidate, propose a directory path for the content. The proposed path may be:\n',
+  '- An existing directory in the candidate path hierarchy\n',
+  '- A new directory at the appropriate level of the taxonomy\n\n',
+  'Examples given candidate `/engineering/frontend/react-testing-patterns`:\n',
+  '- Content about React components → `/engineering/frontend/` (same topic category)\n',
+  '- Content about CSS architecture → `/engineering/frontend/css-architecture/` (sub-topic)\n',
+  '- Content about Express API design → `/engineering/backend/` (different topic at the same category level)\n\n',
+  'Only propose candidates that are genuinely suitable. Skip candidates where the content has no meaningful relationship.\n\n',
+  '## Flow/Stock Information Type\n',
+  instructionsForInformationTypes,
+  '\n\n',
+  'Use flow/stock alignment between the content and candidate locations as a RANKING FACTOR, not a hard filter.\n\n',
+  '## Output Format\n',
+  'Return a JSON array of suggestion objects, ranked by content-destination fit (best first).\n',
+  'Each object must have:\n',
+  '- "path": Directory path with trailing slash (e.g., "/engineering/backend/")\n',
+  '- "label": Short display label for the suggestion\n',
+  '- "description": Explanation of why this location is suitable, considering content relevance and flow/stock alignment\n\n',
+  'Return an empty array `[]` if no candidates are suitable.\n',
+  'Return only the JSON array, no other text.',
+].join('');
+
+function buildUserMessage(
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): string {
+  const candidateList = candidates
+    .map(
+      (c, i) =>
+        `${i + 1}. Path: ${c.pagePath}\n   Snippet: ${c.snippet}\n   Score: ${c.score}`,
+    )
+    .join('\n');
+
+  return [
+    '## Content to Save\n',
+    body,
+    '\n\n## Content Analysis\n',
+    `Keywords: ${analysis.keywords.join(', ')}\n`,
+    `Information Type: ${analysis.informationType}\n`,
+    '\n## Search Candidates\n',
+    candidateList,
+  ].join('');
+}
+
+const isValidEvaluatedSuggestion = (
+  item: unknown,
+): item is EvaluatedSuggestion => {
+  if (item == null || typeof item !== 'object') {
+    return false;
+  }
+
+  const obj = item as Record<string, unknown>;
+
+  if (typeof obj.path !== 'string' || !obj.path.endsWith('/')) {
+    return false;
+  }
+
+  if (typeof obj.label !== 'string' || obj.label.length === 0) {
+    return false;
+  }
+
+  if (typeof obj.description !== 'string' || obj.description.length === 0) {
+    return false;
+  }
+
+  return true;
+};
+
+const isValidEvaluatedSuggestionArray = (
+  parsed: unknown,
+): parsed is EvaluatedSuggestion[] => {
+  if (!Array.isArray(parsed)) {
+    return false;
+  }
+  return parsed.every(isValidEvaluatedSuggestion);
+};
+
+export const evaluateCandidates = (
+  body: string,
+  analysis: ContentAnalysis,
+  candidates: SearchCandidate[],
+): Promise<EvaluatedSuggestion[]> => {
+  const userMessage = buildUserMessage(body, analysis, candidates);
+  return callLlmForJson(
+    SYSTEM_PROMPT,
+    userMessage,
+    isValidEvaluatedSuggestionArray,
+    'Invalid candidate evaluation response: each item must have path (ending with /), label, and description',
+  );
+};

+ 170 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-category-suggestion.spec.ts

@@ -0,0 +1,170 @@
+import type { SearchCandidate } from '../../interfaces/suggest-path-types';
+import {
+  extractTopLevelSegmentName,
+  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 createCandidates(
+  pages: { path: string; score: number }[],
+): SearchCandidate[] {
+  return pages.map((p) => ({
+    pagePath: p.path,
+    snippet: '',
+    score: p.score,
+  }));
+}
+
+describe('extractTopLevelSegmentName', () => {
+  it('should extract segment name from nested path', () => {
+    expect(extractTopLevelSegmentName('/tech-notes/React/hooks')).toBe(
+      'tech-notes',
+    );
+  });
+
+  it('should extract segment name from two-level path', () => {
+    expect(extractTopLevelSegmentName('/tech-notes/React')).toBe('tech-notes');
+  });
+
+  it('should extract segment name from single-level path', () => {
+    expect(extractTopLevelSegmentName('/tech-notes')).toBe('tech-notes');
+  });
+
+  it('should return null for root path', () => {
+    expect(extractTopLevelSegmentName('/')).toBeNull();
+  });
+});
+
+describe('generateCategorySuggestion', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+  });
+
+  describe('when candidates are provided', () => {
+    it('should return a suggestion with type "category"', async () => {
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result).not.toBeNull();
+      expect(result?.type).toBe('category');
+    });
+
+    it('should extract top-level segment from top candidate path', async () => {
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+        { path: '/guides/TypeScript/basics', score: 8 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.path).toBe('/tech-notes/');
+    });
+
+    it('should return path with trailing slash', async () => {
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.path).toMatch(/\/$/);
+    });
+
+    it('should extract top-level even from deeply nested path', async () => {
+      const candidates = createCandidates([
+        { path: '/guides/a/b/c/d', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.path).toBe('/guides/');
+    });
+
+    it('should generate description from top-level segment name', async () => {
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.description).toBe('Top-level category: tech-notes');
+    });
+
+    it('should have label "Save under category"', async () => {
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.label).toBe('Save under category');
+    });
+
+    it('should resolve grant from top-level directory', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+      const candidates = createCandidates([
+        { path: '/tech-notes/React/hooks', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      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 candidates = createCandidates([
+        { path: '/nonexistent/page', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result?.grant).toBe(GRANT_OWNER);
+    });
+  });
+
+  describe('when top result is a single-segment page', () => {
+    it('should return the page path as category', async () => {
+      const candidates = createCandidates([
+        { path: '/engineering', score: 10 },
+      ]);
+
+      const result = await generateCategorySuggestion(candidates);
+
+      expect(result).not.toBeNull();
+      expect(result?.path).toBe('/engineering/');
+      expect(result?.description).toBe('Top-level category: engineering');
+    });
+  });
+
+  describe('when candidates are empty', () => {
+    it('should return null', async () => {
+      const result = await generateCategorySuggestion([]);
+
+      expect(result).toBeNull();
+    });
+
+    it('should not call resolveParentGrant', async () => {
+      await generateCategorySuggestion([]);
+
+      expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
+    });
+  });
+});

+ 37 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-category-suggestion.ts

@@ -0,0 +1,37 @@
+import type {
+  PathSuggestion,
+  SearchCandidate,
+} from '../../interfaces/suggest-path-types';
+import { SuggestionType } from '../../interfaces/suggest-path-types';
+import { resolveParentGrant } from './resolve-parent-grant';
+
+const CATEGORY_LABEL = 'Save under category';
+
+export function extractTopLevelSegmentName(pagePath: string): string | null {
+  const segments = pagePath.split('/').filter(Boolean);
+  return segments[0] ?? null;
+}
+
+export const generateCategorySuggestion = async (
+  candidates: SearchCandidate[],
+): Promise<PathSuggestion | null> => {
+  if (candidates.length === 0) {
+    return null;
+  }
+
+  const segmentName = extractTopLevelSegmentName(candidates[0].pagePath);
+  if (segmentName == null) {
+    return null;
+  }
+
+  const topLevelPath = `/${segmentName}/`;
+  const grant = await resolveParentGrant(topLevelPath);
+
+  return {
+    type: SuggestionType.CATEGORY,
+    path: topLevelPath,
+    label: CATEGORY_LABEL,
+    description: `Top-level category: ${segmentName}`,
+    grant,
+  };
+};

+ 136 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-memo-suggestion.spec.ts

@@ -0,0 +1,136 @@
+import { generateMemoSuggestion } from './generate-memo-suggestion';
+
+const mocks = vi.hoisted(() => {
+  return {
+    configManagerMock: {
+      getConfig: vi.fn(),
+    },
+    resolveParentGrantMock: vi.fn(),
+  };
+});
+
+vi.mock('@growi/core', () => ({
+  PageGrant: {
+    GRANT_PUBLIC: 1,
+    GRANT_RESTRICTED: 2,
+    GRANT_OWNER: 4,
+    GRANT_USER_GROUP: 5,
+  },
+}));
+
+vi.mock('@growi/core/dist/utils/page-path-utils', () => ({
+  userHomepagePath: (user: { username: string }) => `/user/${user.username}`,
+}));
+
+vi.mock('~/server/service/config-manager', () => {
+  return { configManager: mocks.configManagerMock };
+});
+
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: mocks.resolveParentGrantMock,
+}));
+
+const GRANT_PUBLIC = 1;
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+
+describe('generateMemoSuggestion', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+  });
+
+  describe('when user pages are enabled (default)', () => {
+    beforeEach(() => {
+      mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+        if (key === 'security:disableUserPages') return false;
+        return undefined;
+      });
+    });
+
+    it('should return a suggestion with type "memo"', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.type).toBe('memo');
+    });
+
+    it('should generate path under user home directory', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.path).toBe('/user/alice/memo/');
+    });
+
+    it('should set grant to GRANT_OWNER (4)', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.grant).toBe(GRANT_OWNER);
+    });
+
+    it('should not call resolveParentGrant', async () => {
+      await generateMemoSuggestion({ username: 'alice' });
+      expect(mocks.resolveParentGrantMock).not.toHaveBeenCalled();
+    });
+
+    it('should include a fixed description', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.description).toBe('Save to your personal memo area');
+    });
+
+    it('should include a label', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.label).toBe('Save as memo');
+    });
+
+    it('should generate path with trailing slash', async () => {
+      const result = await generateMemoSuggestion({ username: 'alice' });
+      expect(result.path).toMatch(/\/$/);
+    });
+  });
+
+  describe('when user pages are disabled', () => {
+    beforeEach(() => {
+      mocks.configManagerMock.getConfig.mockImplementation((key: string) => {
+        if (key === 'security:disableUserPages') return true;
+        return undefined;
+      });
+    });
+
+    it('should generate path under alternative namespace', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.path).toBe('/memo/bob/');
+    });
+
+    it('should resolve grant from parent page via resolveParentGrant', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_PUBLIC);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.grant).toBe(GRANT_PUBLIC);
+    });
+
+    it('should call resolveParentGrant with the generated path', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      await generateMemoSuggestion({ username: 'bob' });
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/memo/bob/');
+    });
+
+    it('should use GRANT_USER_GROUP when parent has user group grant', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_USER_GROUP);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.grant).toBe(GRANT_USER_GROUP);
+    });
+
+    it('should return a suggestion with type "memo"', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.type).toBe('memo');
+    });
+
+    it('should generate path with trailing slash', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.path).toMatch(/\/$/);
+    });
+
+    it('should include same fixed description as enabled case', async () => {
+      mocks.resolveParentGrantMock.mockResolvedValue(GRANT_OWNER);
+      const result = await generateMemoSuggestion({ username: 'bob' });
+      expect(result.description).toBe('Save to your personal memo area');
+    });
+  });
+});

+ 41 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-memo-suggestion.ts

@@ -0,0 +1,41 @@
+import { PageGrant } from '@growi/core';
+import { userHomepagePath } from '@growi/core/dist/utils/page-path-utils';
+
+import { configManager } from '~/server/service/config-manager';
+
+import type { PathSuggestion } from '../../interfaces/suggest-path-types';
+import { SuggestionType } from '../../interfaces/suggest-path-types';
+import { resolveParentGrant } from './resolve-parent-grant';
+
+const MEMO_LABEL = 'Save as memo';
+const MEMO_DESCRIPTION = 'Save to your personal memo area';
+
+export const generateMemoSuggestion = async (user: {
+  username: string;
+}): Promise<PathSuggestion> => {
+  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 {
+      type: SuggestionType.MEMO,
+      path,
+      label: MEMO_LABEL,
+      description: MEMO_DESCRIPTION,
+      grant,
+    };
+  }
+
+  // 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/`,
+    label: MEMO_LABEL,
+    description: MEMO_DESCRIPTION,
+    grant: PageGrant.GRANT_OWNER,
+  };
+};

+ 383 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.spec.ts

@@ -0,0 +1,383 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type {
+  ContentAnalysis,
+  EvaluatedSuggestion,
+  PathSuggestion,
+  SearchCandidate,
+  SearchService,
+} from '../../interfaces/suggest-path-types';
+
+const mocks = vi.hoisted(() => {
+  return {
+    generateMemoSuggestionMock: vi.fn(),
+    analyzeContentMock: vi.fn(),
+    retrieveSearchCandidatesMock: vi.fn(),
+    evaluateCandidatesMock: vi.fn(),
+    generateCategorySuggestionMock: vi.fn(),
+    resolveParentGrantMock: vi.fn(),
+    loggerErrorMock: vi.fn(),
+  };
+});
+
+vi.mock('./generate-memo-suggestion', () => ({
+  generateMemoSuggestion: mocks.generateMemoSuggestionMock,
+}));
+
+vi.mock('./analyze-content', () => ({
+  analyzeContent: mocks.analyzeContentMock,
+}));
+
+vi.mock('./retrieve-search-candidates', () => ({
+  retrieveSearchCandidates: mocks.retrieveSearchCandidatesMock,
+}));
+
+vi.mock('./evaluate-candidates', () => ({
+  evaluateCandidates: mocks.evaluateCandidatesMock,
+}));
+
+vi.mock('./generate-category-suggestion', () => ({
+  generateCategorySuggestion: mocks.generateCategorySuggestionMock,
+}));
+
+vi.mock('./resolve-parent-grant', () => ({
+  resolveParentGrant: mocks.resolveParentGrantMock,
+}));
+
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    error: mocks.loggerErrorMock,
+  }),
+}));
+
+const mockUser = {
+  _id: 'user123',
+  username: 'alice',
+} as unknown as IUserHasId;
+
+const mockUserGroups = [
+  'group1',
+  'group2',
+] as unknown as import('~/server/interfaces/mongoose-utils').ObjectIdLike[];
+
+const mockSearchService = {
+  searchKeyword: vi.fn(),
+} as unknown as SearchService;
+
+const memoSuggestion: PathSuggestion = {
+  type: 'memo',
+  path: '/user/alice/memo/',
+  label: 'Save as memo',
+  description: 'Save to your personal memo area',
+  grant: 4,
+};
+
+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/',
+  label: 'Save under category',
+  description: 'Top-level category: tech',
+  grant: 1,
+};
+
+describe('generateSuggestions', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.generateMemoSuggestionMock.mockResolvedValue(memoSuggestion);
+  });
+
+  const callGenerateSuggestions = async () => {
+    const { generateSuggestions } = await import('./generate-suggestions');
+    return generateSuggestions(
+      mockUser,
+      'Some page content',
+      mockUserGroups,
+      mockSearchService,
+    );
+  };
+
+  describe('successful full pipeline', () => {
+    beforeEach(() => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+    });
+
+    it('should return memo + search + category suggestions when all succeed', async () => {
+      const result = await callGenerateSuggestions();
+
+      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 () => {
+      const result = await callGenerateSuggestions();
+
+      expect(result[0]).toEqual(memoSuggestion);
+    });
+
+    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 () => {
+      mocks.resolveParentGrantMock
+        .mockResolvedValueOnce(1)
+        .mockResolvedValueOnce(4);
+
+      const result = await callGenerateSuggestions();
+
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledTimes(2);
+      expect(mocks.resolveParentGrantMock).toHaveBeenCalledWith('/tech/React/');
+      expect(mocks.resolveParentGrantMock).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.analyzeContentMock).toHaveBeenCalledWith(
+        'Some page content',
+      );
+    });
+
+    it('should pass keywords, user, userGroups, and searchService to retrieveSearchCandidates', async () => {
+      await callGenerateSuggestions();
+
+      expect(mocks.retrieveSearchCandidatesMock).toHaveBeenCalledWith(
+        ['React', 'hooks'],
+        mockUser,
+        mockUserGroups,
+        mockSearchService,
+      );
+    });
+
+    it('should pass body, analysis, and candidates to evaluateCandidates', async () => {
+      await callGenerateSuggestions();
+
+      expect(mocks.evaluateCandidatesMock).toHaveBeenCalledWith(
+        'Some page content',
+        mockAnalysis,
+        mockCandidates,
+      );
+    });
+
+    it('should pass candidates to generateCategorySuggestion', async () => {
+      await callGenerateSuggestions();
+
+      expect(mocks.generateCategorySuggestionMock).toHaveBeenCalledWith(
+        mockCandidates,
+      );
+    });
+  });
+
+  describe('graceful degradation', () => {
+    it('should fall back to memo only when content analysis fails', async () => {
+      mocks.analyzeContentMock.mockRejectedValue(
+        new Error('AI service unavailable'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+      expect(mocks.retrieveSearchCandidatesMock).not.toHaveBeenCalled();
+      expect(mocks.evaluateCandidatesMock).not.toHaveBeenCalled();
+      expect(mocks.generateCategorySuggestionMock).not.toHaveBeenCalled();
+    });
+
+    it('should log error when content analysis fails', async () => {
+      mocks.analyzeContentMock.mockRejectedValue(
+        new Error('AI service unavailable'),
+      );
+
+      await callGenerateSuggestions();
+
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+    });
+
+    it('should fall back to memo only when search candidate retrieval fails', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockRejectedValue(
+        new Error('Search service down'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+    });
+
+    it('should return memo + category when candidate evaluation fails', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockRejectedValue(
+        new Error('AI evaluation failed'),
+      );
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+      expect(mocks.loggerErrorMock).toHaveBeenCalled();
+    });
+
+    it('should return memo + search when category generation fails', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockRejectedValue(
+        new Error('Category failed'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      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 return memo only when both search pipeline and category fail', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockRejectedValue(
+        new Error('Search down'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+    });
+
+    it('should skip search suggestions when no candidates pass threshold (empty array)', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue([]);
+      mocks.generateCategorySuggestionMock.mockResolvedValue(null);
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion]);
+      expect(mocks.evaluateCandidatesMock).not.toHaveBeenCalled();
+    });
+
+    it('should omit category when generateCategorySuggestion returns null', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.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',
+      };
+      mocks.analyzeContentMock.mockResolvedValue(flowAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue([mockEvaluated[0]]);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.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 evaluate pipeline and category generation independently', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockRejectedValue(
+        new Error('Evaluate failed'),
+      );
+      mocks.generateCategorySuggestionMock.mockResolvedValue(
+        categorySuggestion,
+      );
+
+      const result = await callGenerateSuggestions();
+
+      expect(result).toEqual([memoSuggestion, categorySuggestion]);
+    });
+
+    it('should return search suggestions even when category fails', async () => {
+      mocks.analyzeContentMock.mockResolvedValue(mockAnalysis);
+      mocks.retrieveSearchCandidatesMock.mockResolvedValue(mockCandidates);
+      mocks.evaluateCandidatesMock.mockResolvedValue(mockEvaluated);
+      mocks.resolveParentGrantMock.mockResolvedValue(1);
+      mocks.generateCategorySuggestionMock.mockRejectedValue(
+        new Error('Category failed'),
+      );
+
+      const result = await callGenerateSuggestions();
+
+      const searchSuggestions = result.filter((s) => s.type === 'search');
+      expect(searchSuggestions).toHaveLength(2);
+    });
+  });
+});

+ 102 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/generate-suggestions.ts

@@ -0,0 +1,102 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+import type {
+  ContentAnalysis,
+  PathSuggestion,
+  SearchCandidate,
+  SearchService,
+} from '../../interfaces/suggest-path-types';
+import { SuggestionType } from '../../interfaces/suggest-path-types';
+import { analyzeContent } from './analyze-content';
+import { evaluateCandidates } from './evaluate-candidates';
+import { generateCategorySuggestion } from './generate-category-suggestion';
+import { generateMemoSuggestion } from './generate-memo-suggestion';
+import { resolveParentGrant } from './resolve-parent-grant';
+import { retrieveSearchCandidates } from './retrieve-search-candidates';
+
+const logger = loggerFactory(
+  'growi:features:suggest-path:generate-suggestions',
+);
+
+export const generateSuggestions = async (
+  user: IUserHasId,
+  body: string,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<PathSuggestion[]> => {
+  const memoSuggestion = await generateMemoSuggestion(user);
+
+  // 1st AI call: Content analysis (keyword extraction + flow/stock classification)
+  let analysis: ContentAnalysis;
+  try {
+    analysis = await analyzeContent(body);
+  } catch (err) {
+    logger.error('Content analysis failed, falling back to memo only:', err);
+    return [memoSuggestion];
+  }
+
+  // Retrieve search candidates (single ES query, shared by evaluate and category)
+  let candidates: SearchCandidate[];
+  try {
+    candidates = await retrieveSearchCandidates(
+      analysis.keywords,
+      user,
+      userGroups,
+      searchService,
+    );
+  } catch (err) {
+    logger.error(
+      'Search candidate retrieval failed, falling back to memo only:',
+      err,
+    );
+    return [memoSuggestion];
+  }
+
+  // Run evaluate pipeline and category generation in parallel
+  const [evaluateResult, categoryResult] = await Promise.allSettled([
+    // Evaluate pipeline: evaluate → grant resolution (skip if no candidates)
+    candidates.length > 0
+      ? (async (): Promise<PathSuggestion[]> => {
+          const evaluated = await evaluateCandidates(
+            body,
+            analysis,
+            candidates,
+          );
+          return Promise.all(
+            evaluated.map(async (s): Promise<PathSuggestion> => {
+              const grant = await resolveParentGrant(s.path);
+              return {
+                type: SuggestionType.SEARCH,
+                path: s.path,
+                label: s.label,
+                description: s.description,
+                grant,
+                informationType: analysis.informationType,
+              };
+            }),
+          );
+        })()
+      : Promise.resolve([]),
+    // Category generation (uses same candidates, no extra ES query)
+    generateCategorySuggestion(candidates),
+  ]);
+
+  const suggestions: PathSuggestion[] = [memoSuggestion];
+
+  if (evaluateResult.status === 'fulfilled') {
+    suggestions.push(...evaluateResult.value);
+  } else {
+    logger.error('Evaluate pipeline failed:', evaluateResult.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;
+};

+ 199 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/resolve-parent-grant.spec.ts

@@ -0,0 +1,199 @@
+import { getAncestorPaths, resolveParentGrant } from './resolve-parent-grant';
+
+const mocks = vi.hoisted(() => {
+  const leanMock = vi.fn();
+  const selectMock = vi.fn().mockReturnValue({ lean: leanMock });
+  const findMock = vi.fn().mockReturnValue({ select: selectMock });
+  return { findMock, selectMock, leanMock };
+});
+
+vi.mock('@growi/core', () => ({
+  PageGrant: {
+    GRANT_PUBLIC: 1,
+    GRANT_RESTRICTED: 2,
+    GRANT_OWNER: 4,
+    GRANT_USER_GROUP: 5,
+  },
+}));
+
+vi.mock('mongoose', () => ({
+  default: {
+    model: () => ({
+      find: mocks.findMock,
+    }),
+  },
+}));
+
+const GRANT_PUBLIC = 1;
+const GRANT_OWNER = 4;
+const GRANT_USER_GROUP = 5;
+
+describe('getAncestorPaths', () => {
+  it('should return ancestors from child to root', () => {
+    expect(getAncestorPaths('/a/b/c')).toEqual(['/a/b/c', '/a/b', '/a', '/']);
+  });
+
+  it('should return path and root for single-level path', () => {
+    expect(getAncestorPaths('/tech-notes')).toEqual(['/tech-notes', '/']);
+  });
+
+  it('should return only root for root path', () => {
+    expect(getAncestorPaths('/')).toEqual(['/']);
+  });
+
+  it('should respect max depth guard', () => {
+    const deepSegments = Array.from({ length: 60 }, (_, i) => `level${i}`);
+    const deepPath = `/${deepSegments.join('/')}`;
+
+    const result = getAncestorPaths(deepPath);
+    // 50 ancestors + root = 51 max
+    expect(result.length).toBeLessThanOrEqual(51);
+    expect(result[result.length - 1]).toBe('/');
+  });
+});
+
+describe('resolveParentGrant', () => {
+  beforeEach(() => {
+    vi.resetAllMocks();
+    mocks.findMock.mockReturnValue({ select: mocks.selectMock });
+    mocks.selectMock.mockReturnValue({ lean: mocks.leanMock });
+  });
+
+  describe('when parent page exists', () => {
+    it('should return GRANT_PUBLIC when page has public grant', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/tech-notes', grant: GRANT_PUBLIC },
+      ]);
+
+      const result = await resolveParentGrant('/tech-notes/');
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should return GRANT_OWNER when page has owner grant', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/user/alice/memo', grant: GRANT_OWNER },
+      ]);
+
+      const result = await resolveParentGrant('/user/alice/memo/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+
+    it('should return GRANT_USER_GROUP when page has user group grant', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/team/engineering', grant: GRANT_USER_GROUP },
+      ]);
+
+      const result = await resolveParentGrant('/team/engineering/');
+      expect(result).toBe(GRANT_USER_GROUP);
+    });
+  });
+
+  describe('ancestor path traversal', () => {
+    it('should find closest ancestor grant when direct parent does not exist', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/tech-notes/React', grant: GRANT_PUBLIC },
+      ]);
+
+      const result = await resolveParentGrant(
+        '/tech-notes/React/state-management/',
+      );
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should find grant from deeply nested ancestor', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/a', grant: GRANT_USER_GROUP },
+      ]);
+
+      const result = await resolveParentGrant('/a/b/c/d/');
+      expect(result).toBe(GRANT_USER_GROUP);
+    });
+
+    it('should find root page grant when no intermediate ancestor exists', async () => {
+      mocks.leanMock.mockResolvedValue([{ path: '/', grant: GRANT_PUBLIC }]);
+
+      const result = await resolveParentGrant('/nonexistent/deep/');
+      expect(result).toBe(GRANT_PUBLIC);
+    });
+
+    it('should return GRANT_OWNER when no ancestor exists at any level', async () => {
+      mocks.leanMock.mockResolvedValue([]);
+
+      const result = await resolveParentGrant('/nonexistent/deep/path/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+
+    it('should prefer closest ancestor when multiple ancestors exist', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/tech-notes', grant: GRANT_PUBLIC },
+        { path: '/tech-notes/React/hooks', grant: GRANT_USER_GROUP },
+      ]);
+
+      const result = await resolveParentGrant('/tech-notes/React/hooks/');
+      expect(result).toBe(GRANT_USER_GROUP);
+    });
+  });
+
+  describe('when no ancestor page exists', () => {
+    it('should return GRANT_OWNER (4) as safe default', async () => {
+      mocks.leanMock.mockResolvedValue([]);
+
+      const result = await resolveParentGrant('/memo/bob/');
+      expect(result).toBe(GRANT_OWNER);
+    });
+  });
+
+  describe('query optimization', () => {
+    it('should use a single $in query instead of multiple findOne calls', async () => {
+      mocks.leanMock.mockResolvedValue([{ path: '/a', grant: GRANT_PUBLIC }]);
+
+      await resolveParentGrant('/a/b/c/d/');
+
+      expect(mocks.findMock).toHaveBeenCalledTimes(1);
+      expect(mocks.findMock).toHaveBeenCalledWith({
+        path: { $in: ['/a/b/c/d', '/a/b/c', '/a/b', '/a', '/'] },
+      });
+    });
+
+    it('should select only path and grant fields', async () => {
+      mocks.leanMock.mockResolvedValue([]);
+
+      await resolveParentGrant('/tech-notes/');
+
+      expect(mocks.selectMock).toHaveBeenCalledWith('path grant');
+    });
+  });
+
+  describe('path normalization', () => {
+    it('should strip trailing slash for database lookup', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/tech-notes', grant: GRANT_PUBLIC },
+      ]);
+
+      await resolveParentGrant('/tech-notes/');
+      expect(mocks.findMock).toHaveBeenCalledWith({
+        path: { $in: ['/tech-notes', '/'] },
+      });
+    });
+
+    it('should handle path without trailing slash', async () => {
+      mocks.leanMock.mockResolvedValue([
+        { path: '/tech-notes', grant: GRANT_PUBLIC },
+      ]);
+
+      await resolveParentGrant('/tech-notes');
+      expect(mocks.findMock).toHaveBeenCalledWith({
+        path: { $in: ['/tech-notes', '/'] },
+      });
+    });
+
+    it('should handle root path', async () => {
+      mocks.leanMock.mockResolvedValue([{ path: '/', grant: GRANT_PUBLIC }]);
+
+      await resolveParentGrant('/');
+      expect(mocks.findMock).toHaveBeenCalledWith({
+        path: { $in: ['/'] },
+      });
+    });
+  });
+});

+ 43 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/resolve-parent-grant.ts

@@ -0,0 +1,43 @@
+import { PageGrant } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import mongoose from 'mongoose';
+
+type PageWithGrant = { path: string; grant: number };
+
+const MAX_ANCESTOR_DEPTH = 50;
+
+export function getAncestorPaths(pagePath: string): string[] {
+  const paths: string[] = [];
+  let current = pagePath;
+  let depth = 0;
+
+  while (current !== '/' && depth < MAX_ANCESTOR_DEPTH) {
+    paths.push(current);
+    current = pathUtils.getParentPath(current);
+    depth++;
+  }
+
+  paths.push('/');
+  return paths;
+}
+
+export const resolveParentGrant = async (dirPath: string): Promise<number> => {
+  const Page = mongoose.model<PageWithGrant>('Page');
+  const pagePath = pathUtils.removeTrailingSlash(dirPath);
+
+  const ancestorPaths = getAncestorPaths(pagePath);
+
+  const pages = await Page.find({ path: { $in: ancestorPaths } })
+    .select('path grant')
+    .lean();
+
+  // Find the closest ancestor (ancestorPaths is ordered from child to root)
+  for (const ancestorPath of ancestorPaths) {
+    const page = pages.find((p) => p.path === ancestorPath);
+    if (page != null) {
+      return page.grant;
+    }
+  }
+
+  return PageGrant.GRANT_OWNER;
+};

+ 366 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.spec.ts

@@ -0,0 +1,366 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { SearchCandidate } from '../../interfaces/suggest-path-types';
+import { retrieveSearchCandidates } from './retrieve-search-candidates';
+
+type HighlightData = Record<string, string[]>;
+
+type SearchResultPage = {
+  path: string;
+  score: number;
+  highlight?: HighlightData;
+};
+
+function createSearchResult(pages: SearchResultPage[]) {
+  return {
+    data: pages.map((p) => ({
+      _id: `id-${p.path}`,
+      _score: p.score,
+      _source: { path: p.path },
+      _highlight: p.highlight,
+    })),
+    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('retrieveSearchCandidates', () => {
+  describe('multi-result retrieval', () => {
+    it('should return all candidates above the score threshold', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 15 },
+        { path: '/tech/React/state', score: 12 },
+        { path: '/tech/Vue/basics', score: 8 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React', 'hooks'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(3);
+    });
+
+    it('should return candidates with correct structure', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: { body: ['Using <em>React</em> hooks for state'] },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0]).toEqual({
+        pagePath: '/tech/React/hooks',
+        snippet: 'Using React hooks for state',
+        score: 15,
+      } satisfies SearchCandidate);
+    });
+  });
+
+  describe('threshold filtering', () => {
+    it('should include candidates above the default threshold (5.0)', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 15 },
+        { path: '/tech/React/state', score: 3 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].pagePath).toBe('/tech/React/hooks');
+    });
+
+    it('should exclude candidates below the default threshold (5.0)', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 3 },
+        { path: '/tech/Vue/basics', score: 2 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(0);
+    });
+
+    it('should include candidates at exactly the default threshold (5.0)', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 5 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(1);
+      expect(result[0].score).toBe(5);
+    });
+
+    it('should filter mixed results correctly', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 20 },
+        { path: '/tech/React/state', score: 10 },
+        { path: '/guides/intro', score: 5 },
+        { path: '/random/page', score: 2 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toHaveLength(3);
+      expect(result.map((c) => c.pagePath)).toEqual([
+        '/tech/React/hooks',
+        '/tech/React/state',
+        '/guides/intro',
+      ]);
+    });
+  });
+
+  describe('empty result handling', () => {
+    it('should return empty array when search returns no results', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['nonexistent'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toEqual([]);
+    });
+
+    it('should return empty array when all results are below threshold', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 3 },
+        { path: '/tech/Vue/basics', score: 1 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result).toEqual([]);
+    });
+  });
+
+  describe('snippet extraction', () => {
+    it('should extract snippet from _highlight.body', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: {
+            body: ["Using <em class='highlighted-keyword'>React</em> hooks"],
+          },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('Using React hooks');
+    });
+
+    it('should fall back to body.en highlight', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: {
+            'body.en': ['<em>React</em> hooks guide'],
+          },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('React hooks guide');
+    });
+
+    it('should fall back to body.ja highlight', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: {
+            'body.ja': ['<em>React</em>のフックについて'],
+          },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('Reactのフックについて');
+    });
+
+    it('should return empty string when no highlight is available', async () => {
+      const searchResult = createSearchResult([
+        { path: '/tech/React/hooks', score: 15 },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('');
+    });
+
+    it('should join multiple highlight fragments', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: {
+            body: ['<em>React</em> hooks', 'custom <em>hooks</em> pattern'],
+          },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('React hooks ... custom hooks pattern');
+    });
+
+    it('should strip ES highlight tags from snippets', async () => {
+      const searchResult = createSearchResult([
+        {
+          path: '/tech/React/hooks',
+          score: 15,
+          highlight: {
+            body: ["<em class='highlighted-keyword'>React</em> hooks"],
+          },
+        },
+      ]);
+      const searchService = createMockSearchService(searchResult);
+
+      const result = await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        [],
+        searchService,
+      );
+
+      expect(result[0].snippet).toBe('React hooks');
+    });
+  });
+
+  describe('search service invocation', () => {
+    it('should join keywords with spaces for search query', async () => {
+      const searchResult = createSearchResult([]);
+      const searchService = createMockSearchService(searchResult);
+
+      await retrieveSearchCandidates(
+        ['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([]);
+      const searchService = createMockSearchService(searchResult);
+      const mockUserGroups = [
+        'group1',
+        'group2',
+      ] as unknown as import('~/server/interfaces/mongoose-utils').ObjectIdLike[];
+
+      await retrieveSearchCandidates(
+        ['React'],
+        mockUser,
+        mockUserGroups,
+        searchService,
+      );
+
+      expect(searchService.searchKeyword).toHaveBeenCalledWith(
+        expect.any(String),
+        null,
+        mockUser,
+        mockUserGroups,
+        expect.any(Object),
+      );
+    });
+  });
+});

+ 59 - 0
apps/app/src/features/ai-tools/suggest-path/server/services/retrieve-search-candidates.ts

@@ -0,0 +1,59 @@
+import type { IUserHasId } from '@growi/core/dist/interfaces';
+
+import type { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+
+import type {
+  SearchCandidate,
+  SearchResultItem,
+  SearchService,
+} from '../../interfaces/suggest-path-types';
+
+const SCORE_THRESHOLD = 5.0;
+const SEARCH_RESULT_LIMIT = 20;
+
+// Elasticsearch highlights use <em class='highlighted-keyword'> and </em>
+const ES_HIGHLIGHT_TAG_REGEX = /<\/?em[^>]*>/g;
+
+function stripHighlightTags(text: string): string {
+  return text.replace(ES_HIGHLIGHT_TAG_REGEX, '');
+}
+
+function extractSnippet(item: SearchResultItem): string {
+  const highlight = item._highlight;
+  if (highlight == null) {
+    return '';
+  }
+
+  const fragments =
+    highlight.body ?? highlight['body.en'] ?? highlight['body.ja'];
+  if (fragments == null || fragments.length === 0) {
+    return '';
+  }
+
+  return stripHighlightTags(fragments.join(' ... '));
+}
+
+export const retrieveSearchCandidates = async (
+  keywords: string[],
+  user: IUserHasId,
+  userGroups: ObjectIdLike[],
+  searchService: SearchService,
+): Promise<SearchCandidate[]> => {
+  const keyword = keywords.join(' ');
+
+  const [searchResult] = await searchService.searchKeyword(
+    keyword,
+    null,
+    user,
+    userGroups,
+    { limit: SEARCH_RESULT_LIMIT },
+  );
+
+  return searchResult.data
+    .filter((item) => item._score >= SCORE_THRESHOLD)
+    .map((item) => ({
+      pagePath: item._source.path,
+      snippet: extractSnippet(item),
+      score: item._score,
+    }));
+};

+ 3 - 0
apps/app/src/server/routes/apiv3/index.js

@@ -1,3 +1,4 @@
+import { factory as aiToolsRouteFactory } from '~/features/ai-tools/server/routes/apiv3';
 import { factory as auditLogBulkExportRouteFactory } from '~/features/audit-log-bulk-export/server/routes/apiv3';
 import growiPlugin from '~/features/growi-plugin/server/routes/apiv3/admin';
 import { factory as openaiRouteFactory } from '~/features/openai/server/routes';
@@ -190,6 +191,8 @@ module.exports = (crowi, app) => {
 
   router.use('/openai', openaiRouteFactory(crowi));
 
+  router.use('/ai-tools', aiToolsRouteFactory(crowi));
+
   router.use('/user', userRouteFactory(crowi));
 
   return [router, routerForAdmin, routerForAuth];

+ 3 - 2
packages/core/vite.config.ts

@@ -1,4 +1,3 @@
-import path from 'node:path';
 import glob from 'glob';
 import { nodeExternals } from 'rollup-plugin-node-externals';
 import { defineConfig } from 'vite';
@@ -22,7 +21,9 @@ export default defineConfig({
     outDir: 'dist',
     sourcemap: true,
     lib: {
-      entry: glob.sync(path.resolve(__dirname, 'src/**/*.ts'), {
+      entry: glob.sync('src/**/*.ts', {
+        cwd: __dirname,
+        absolute: true,
         ignore: '**/*.spec.ts',
       }),
       name: 'core-libs',