|
11 | 11 | """ |
12 | 12 |
|
13 | 13 | import os |
14 | | -from typing import Optional |
| 14 | +from typing import Literal, Optional |
15 | 15 |
|
16 | 16 | from fastapi import APIRouter, HTTPException, Body, Query, Path |
17 | 17 | from loguru import logger |
|
25 | 25 | ProjectExternalIdPathDep, |
26 | 26 | ) |
27 | 27 | from basic_memory.schemas import SyncReportResponse |
| 28 | +from basic_memory.models import Project |
| 29 | +from basic_memory.repository.project_repository import ProjectRepository |
28 | 30 | from basic_memory.schemas.project_info import ( |
29 | 31 | ProjectItem, |
30 | 32 | ProjectList, |
|
36 | 38 | from basic_memory.utils import normalize_project_path, generate_permalink |
37 | 39 |
|
38 | 40 | router = APIRouter(prefix="/projects", tags=["project_management-v2"]) |
| 41 | +ProjectResolveMethod = Literal["external_id", "name", "permalink"] |
| 42 | + |
| 43 | + |
| 44 | +def _split_qualified_project_identifier(identifier: str) -> tuple[str | None, str]: |
| 45 | + """Split ``<workspace>/<project>`` identifiers while preserving plain project names.""" |
| 46 | + cleaned = identifier.strip() |
| 47 | + if "/" not in cleaned: |
| 48 | + return None, cleaned |
| 49 | + |
| 50 | + workspace_identifier, project_identifier = cleaned.split("/", 1) |
| 51 | + if not workspace_identifier or not project_identifier: |
| 52 | + return None, cleaned |
| 53 | + return workspace_identifier, project_identifier |
| 54 | + |
| 55 | + |
| 56 | +async def _resolve_project_identifier_candidate( |
| 57 | + project_repository: ProjectRepository, |
| 58 | + identifier: str, |
| 59 | +) -> tuple[Project | None, ProjectResolveMethod]: |
| 60 | + """Resolve one project identifier candidate and report the matching method.""" |
| 61 | + identifier_permalink = generate_permalink(identifier) |
| 62 | + |
| 63 | + project = await project_repository.get_by_external_id(identifier) |
| 64 | + if project: |
| 65 | + return project, "external_id" |
| 66 | + |
| 67 | + project = await project_repository.get_by_permalink(identifier_permalink) |
| 68 | + if project: |
| 69 | + return project, "permalink" |
| 70 | + |
| 71 | + project = await project_repository.get_by_name_case_insensitive(identifier) |
| 72 | + if project: |
| 73 | + return project, "name" # pragma: no cover |
| 74 | + |
| 75 | + return None, "name" |
| 76 | + |
| 77 | + |
| 78 | +async def _resolve_project_identifier( |
| 79 | + project_repository: ProjectRepository, |
| 80 | + identifier: str, |
| 81 | +) -> tuple[Project | None, ProjectResolveMethod]: |
| 82 | + """Resolve exact identifiers first, then accepted workspace-qualified forms.""" |
| 83 | + project, resolution_method = await _resolve_project_identifier_candidate( |
| 84 | + project_repository, |
| 85 | + identifier, |
| 86 | + ) |
| 87 | + if project: |
| 88 | + return project, resolution_method |
| 89 | + |
| 90 | + workspace_identifier, project_identifier = _split_qualified_project_identifier(identifier) |
| 91 | + if workspace_identifier is None: |
| 92 | + return None, resolution_method |
| 93 | + |
| 94 | + # Trigger: an MCP disambiguation error suggested ``workspace/project``. |
| 95 | + # Why: request routing already selected the workspace/tenant; this endpoint |
| 96 | + # only needs the project segment to validate the active project. |
| 97 | + # Outcome: models can follow the hint verbatim instead of looping on a 404. |
| 98 | + project, resolution_method = await _resolve_project_identifier_candidate( |
| 99 | + project_repository, |
| 100 | + project_identifier, |
| 101 | + ) |
| 102 | + if project: |
| 103 | + return project, resolution_method |
| 104 | + |
| 105 | + return None, resolution_method |
39 | 106 |
|
40 | 107 |
|
41 | 108 | @router.get("/", response_model=ProjectList) |
@@ -247,28 +314,10 @@ async def resolve_project_identifier( |
247 | 314 | """ |
248 | 315 | logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'") |
249 | 316 |
|
250 | | - # Generate permalink for comparison |
251 | | - identifier_permalink = generate_permalink(data.identifier) |
252 | | - |
253 | | - resolution_method = "name" |
254 | | - project = None |
255 | | - |
256 | | - # Try external_id first (UUID format) |
257 | | - project = await project_repository.get_by_external_id(data.identifier) |
258 | | - if project: |
259 | | - resolution_method = "external_id" |
260 | | - |
261 | | - # If not found by external_id, try by permalink (exact match) |
262 | | - if not project: |
263 | | - project = await project_repository.get_by_permalink(identifier_permalink) |
264 | | - if project: |
265 | | - resolution_method = "permalink" |
266 | | - |
267 | | - # If not found by permalink, try case-insensitive name search |
268 | | - if not project: |
269 | | - project = await project_repository.get_by_name_case_insensitive(data.identifier) |
270 | | - if project: |
271 | | - resolution_method = "name" # pragma: no cover |
| 317 | + project, resolution_method = await _resolve_project_identifier( |
| 318 | + project_repository, |
| 319 | + data.identifier, |
| 320 | + ) |
272 | 321 |
|
273 | 322 | if not project: |
274 | 323 | raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'") |
|
0 commit comments