Skip to content

Commit c82c194

Browse files
committed
Expose owning project on entity resolve
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 61891f2 commit c82c194

3 files changed

Lines changed: 61 additions & 1 deletion

File tree

src/basic_memory/api/v2/routers/knowledge_router.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
EntityServiceV2ExternalDep,
1919
SearchServiceV2ExternalDep,
2020
LinkResolverV2ExternalDep,
21+
ProjectRepositoryDep,
2122
ProjectConfigV2ExternalDep,
2223
AppConfigDep,
2324
EntityRepositoryV2ExternalDep,
@@ -152,6 +153,7 @@ async def resolve_identifier(
152153
data: EntityResolveRequest,
153154
link_resolver: LinkResolverV2ExternalDep,
154155
entity_repository: EntityRepositoryV2ExternalDep,
156+
project_repository: ProjectRepositoryDep,
155157
) -> EntityResolveResponse:
156158
"""Resolve a string identifier (external_id, permalink, title, or path) to entity info.
157159
@@ -175,6 +177,7 @@ async def resolve_identifier(
175177
{
176178
"external_id": "550e8400-e29b-41d4-a716-446655440000",
177179
"entity_id": 123,
180+
"project_external_id": "4b9b7a10-7a63-48d2-ae3f-0d6a2c69313f",
178181
"permalink": "specs/search",
179182
"file_path": "specs/search.md",
180183
"title": "Search Specification",
@@ -209,9 +212,17 @@ async def resolve_identifier(
209212
if not entity:
210213
raise HTTPException(status_code=404, detail=f"Entity not found: '{data.identifier}'")
211214

215+
owner_project = await project_repository.get_by_id(entity.project_id)
216+
if not owner_project: # pragma: no cover
217+
raise HTTPException(
218+
status_code=500,
219+
detail="Resolved entity references an unknown project",
220+
)
221+
212222
result = EntityResolveResponse(
213223
external_id=entity.external_id,
214224
entity_id=entity.id,
225+
project_external_id=owner_project.external_id,
215226
permalink=entity.permalink,
216227
file_path=entity.file_path,
217228
title=entity.title,

src/basic_memory/schemas/v2/entity.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ class EntityResolveResponse(BaseModel):
4545

4646
external_id: str = Field(..., description="External UUID (primary API identifier)")
4747
entity_id: int = Field(..., description="Numeric entity ID (internal identifier)")
48+
project_external_id: str = Field(..., description="External UUID of the owning project")
4849
permalink: Optional[str] = Field(None, description="Entity permalink")
4950
file_path: str = Field(..., description="Relative file path")
5051
title: str = Field(..., description="Entity title")

tests/api/v2/test_knowledge_router.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
"""Tests for V2 knowledge graph API routes (ID-based endpoints)."""
22

3+
from datetime import datetime, timezone
34
import uuid
45

56
import pytest
67
from httpx import AsyncClient
78

8-
from basic_memory.models import Project
9+
from basic_memory.models import Entity as EntityModel, Project
10+
from basic_memory.repository.entity_repository import EntityRepository
11+
from basic_memory.repository.project_repository import ProjectRepository
912
from basic_memory.schemas import DeleteEntitiesResponse
1013
from basic_memory.schemas.response import DirectoryMoveResult, DirectoryDeleteResult
1114
from basic_memory.schemas.v2 import EntityResponseV2, EntityResolveResponse
@@ -40,10 +43,55 @@ async def test_resolve_identifier_by_permalink(
4043
assert response.status_code == 200
4144
resolved = EntityResolveResponse.model_validate(response.json())
4245
assert resolved.entity_id == entity_id
46+
assert resolved.project_external_id == test_project.external_id
4347
assert resolved.permalink == created_entity.permalink
4448
assert resolved.resolution_method == "permalink"
4549

4650

51+
@pytest.mark.asyncio
52+
async def test_resolve_identifier_returns_target_project_external_id_for_cross_project_link(
53+
client: AsyncClient,
54+
session_maker,
55+
tmp_path,
56+
v2_project_url,
57+
):
58+
"""Cross-project resolves should expose the owning project external ID."""
59+
project_repository = ProjectRepository(session_maker)
60+
other_project = await project_repository.create(
61+
{
62+
"name": "other-project",
63+
"description": "Secondary project",
64+
"path": str(tmp_path / "other-project"),
65+
"is_active": True,
66+
"is_default": False,
67+
}
68+
)
69+
now = datetime.now(timezone.utc)
70+
other_entity_repository = EntityRepository(session_maker, project_id=other_project.id)
71+
target = await other_entity_repository.add(
72+
EntityModel(
73+
title="Cross Project Note",
74+
note_type="note",
75+
content_type="text/markdown",
76+
file_path="docs/Cross Project Note.md",
77+
permalink=f"{other_project.permalink}/docs/cross-project-note",
78+
created_at=now,
79+
updated_at=now,
80+
project_id=other_project.id,
81+
)
82+
)
83+
84+
response = await client.post(
85+
f"{v2_project_url}/knowledge/resolve",
86+
json={"identifier": "other-project::Cross Project Note", "strict": True},
87+
)
88+
89+
assert response.status_code == 200
90+
resolved = EntityResolveResponse.model_validate(response.json())
91+
assert resolved.entity_id == target.id
92+
assert resolved.project_external_id == other_project.external_id
93+
94+
4795
@pytest.mark.asyncio
4896
async def test_resolve_identifier_not_found(client: AsyncClient, v2_project_url):
4997
"""Test resolving a non-existent identifier returns 404."""

0 commit comments

Comments
 (0)