Skip to content

Commit e871298

Browse files
committed
Merge branch 'main' of github.com:basicmachines-co/basic-memory
2 parents 177ae21 + 3415fd1 commit e871298

6 files changed

Lines changed: 396 additions & 47 deletions

File tree

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

Lines changed: 72 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"""
1212

1313
import os
14-
from typing import Optional
14+
from typing import Literal, Optional
1515

1616
from fastapi import APIRouter, HTTPException, Body, Query, Path
1717
from loguru import logger
@@ -25,6 +25,8 @@
2525
ProjectExternalIdPathDep,
2626
)
2727
from basic_memory.schemas import SyncReportResponse
28+
from basic_memory.models import Project
29+
from basic_memory.repository.project_repository import ProjectRepository
2830
from basic_memory.schemas.project_info import (
2931
ProjectItem,
3032
ProjectList,
@@ -36,6 +38,71 @@
3638
from basic_memory.utils import normalize_project_path, generate_permalink
3739

3840
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
39106

40107

41108
@router.get("/", response_model=ProjectList)
@@ -247,28 +314,10 @@ async def resolve_project_identifier(
247314
"""
248315
logger.info(f"API v2 request: resolve_project_identifier for '{data.identifier}'")
249316

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+
)
272321

273322
if not project:
274323
raise HTTPException(status_code=404, detail=f"Project not found: '{data.identifier}'")

src/basic_memory/schema/parser.py

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
field: type, description # required field
99
field?: type, description # optional field
1010
field(array): type # array of values
11+
field(array, description): type # array with description
1112
field?(enum): [val1, val2] # enumeration
13+
field?(enum, description): [val1, val2] # enum with description
1214
field?(object): # nested object
1315
sub_field: type
1416
EntityName as type (capitalized) # entity reference
@@ -58,46 +60,88 @@ class SchemaDefinition:
5860
# with an uppercase letter is treated as an entity reference.
5961

6062
SCALAR_TYPES = frozenset({"string", "integer", "number", "boolean", "any"})
63+
MODIFIER_TYPES = frozenset({"array", "enum", "object"})
6164

6265

6366
# --- Field Name Parsing ---
6467

6568

66-
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
69+
def _parse_field_key_parts(key: str) -> tuple[str, bool, bool, bool, bool, str | None]:
6770
"""Parse a Picoschema field key into its components.
6871
69-
Returns (name, required, is_array, is_enum, is_object).
70-
The key format is: name[?][(array|enum|object)]
72+
Returns (name, required, is_array, is_enum, is_object, description).
73+
The key format is: name[?][(array|enum|object[, description])]
7174
7275
Examples:
73-
"name" -> ("name", True, False, False)
74-
"role?" -> ("role", False, False, False)
75-
"tags?(array)" -> ("tags", False, True, False)
76-
"status?(enum)" -> ("status", False, False, True)
77-
"metadata?(object)" -> ("metadata", False, False, False) + children
76+
"name" -> ("name", True, False, False, False, None)
77+
"role?" -> ("role", False, False, False, False, None)
78+
"tags?(array)" -> ("tags", False, True, False, False, None)
79+
"tags?(array, labels)" -> ("tags", False, True, False, False, "labels")
80+
"status?(enum)" -> ("status", False, False, True, False, None)
81+
"metadata?(object)" -> ("metadata", False, False, False, True, None)
7882
"""
7983
required = True
8084
is_array = False
8185
is_enum = False
8286
is_object = False
87+
description = None
88+
89+
key, modifier, description = _split_modifier_suffix(key)
8390

84-
# Check for modifier suffix: (array), (enum), (object)
85-
if key.endswith("(array)"):
91+
if modifier == "array":
8692
is_array = True
87-
key = key[: -len("(array)")]
88-
elif key.endswith("(enum)"):
93+
elif modifier == "enum":
8994
is_enum = True
90-
key = key[: -len("(enum)")]
91-
elif key.endswith("(object)"):
95+
elif modifier == "object":
9296
is_object = True
93-
key = key[: -len("(object)")]
9497

9598
# Check for optional marker
9699
if key.endswith("?"):
97100
required = False
98101
key = key[:-1]
99102

100-
return key, required, is_array, is_enum, is_object
103+
return key.strip(), required, is_array, is_enum, is_object, description
104+
105+
106+
def _parse_field_key(key: str) -> tuple[str, bool, bool, bool, bool]:
107+
"""Parse a Picoschema field key, discarding any modifier description."""
108+
name, required, is_array, is_enum, is_object, _description = _parse_field_key_parts(key)
109+
return name, required, is_array, is_enum, is_object
110+
111+
112+
def _split_modifier_suffix(key: str) -> tuple[str, str | None, str | None]:
113+
"""Split a trailing picoschema modifier from a field key."""
114+
stripped_key = key.rstrip()
115+
if not stripped_key.endswith(")"):
116+
return key, None, None
117+
118+
# Trigger: field names and modifier descriptions may both contain parentheses
119+
# Why: only the parenthesis paired with the final suffix can introduce a modifier
120+
# Outcome: preserves names like "risk(score)" and descriptions like "labels (freeform)"
121+
open_paren_index = -1
122+
depth = 0
123+
for index in range(len(stripped_key) - 1, -1, -1):
124+
char = stripped_key[index]
125+
if char == ")":
126+
depth += 1
127+
elif char == "(":
128+
depth -= 1
129+
if depth == 0:
130+
open_paren_index = index
131+
break
132+
133+
if open_paren_index == -1:
134+
return key, None, None
135+
136+
modifier_text = stripped_key[open_paren_index + 1 : -1].strip()
137+
modifier, separator, description = modifier_text.partition(",")
138+
modifier = modifier.strip()
139+
if modifier not in MODIFIER_TYPES:
140+
return key, None, None
141+
142+
key_without_modifier = stripped_key[:open_paren_index].rstrip()
143+
parsed_description = description.strip() if separator else None
144+
return key_without_modifier, modifier, parsed_description or None
101145

102146

103147
def _parse_type_and_description(value: str) -> tuple[str, str | None]:
@@ -170,7 +214,7 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
170214
fields: list[SchemaField] = []
171215

172216
for key, value in yaml_dict.items():
173-
name, required, is_array, is_enum, is_object = _parse_field_key(key)
217+
name, required, is_array, is_enum, is_object, key_description = _parse_field_key_parts(key)
174218

175219
# --- Enum fields ---
176220
# Trigger: value is a list or a string containing bracketed enum values
@@ -179,11 +223,12 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
179223
# in YAML to avoid parse errors)
180224
# Outcome: SchemaField with is_enum=True and enum_values populated
181225
if is_enum:
182-
description = None
226+
description = key_description
183227
if isinstance(value, list):
184228
enum_values = [str(v) for v in value]
185229
else:
186-
enum_values, description = _parse_enum_string(str(value))
230+
enum_values, value_description = _parse_enum_string(str(value))
231+
description = description or value_description
187232
fields.append(
188233
SchemaField(
189234
name=name,
@@ -207,13 +252,15 @@ def parse_picoschema(yaml_dict: dict) -> list[SchemaField]:
207252
name=name,
208253
type="object",
209254
required=required,
255+
description=key_description,
210256
children=children,
211257
)
212258
)
213259
continue
214260

215261
# --- Scalar and entity ref fields ---
216-
type_str, description = _parse_type_and_description(str(value))
262+
type_str, value_description = _parse_type_and_description(str(value))
263+
description = key_description or value_description
217264
is_entity_ref = _is_entity_ref_type(type_str)
218265

219266
fields.append(

tests/api/v2/test_project_router.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,21 @@ async def test_resolve_project_by_permalink(
321321
assert resolved.resolution_method in ["name", "permalink"]
322322

323323

324+
@pytest.mark.asyncio
325+
async def test_resolve_project_by_workspace_qualified_permalink(
326+
client: AsyncClient, test_project: Project, v2_projects_url
327+
):
328+
"""Resolve the workspace/project form shown by MCP disambiguation errors."""
329+
resolve_data = {"identifier": f"personal/{test_project.name}"}
330+
response = await client.post(f"{v2_projects_url}/resolve", json=resolve_data)
331+
332+
assert response.status_code == 200
333+
resolved = ProjectResolveResponse.model_validate(response.json())
334+
assert resolved.external_id == test_project.external_id
335+
assert resolved.name == test_project.name
336+
assert resolved.resolution_method == "permalink"
337+
338+
324339
@pytest.mark.asyncio
325340
async def test_resolve_project_by_id(client: AsyncClient, test_project: Project, v2_projects_url):
326341
"""Test resolving a project by external_id string returns correct project external_id."""

tests/mcp/test_tool_schema.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,71 @@ async def test_schema_validate_json_output(app, test_project, sync_service):
116116
assert result["results"][0]["passed"] is True
117117

118118

119+
@pytest.mark.asyncio
120+
async def test_schema_validate_picoschema_modifier_descriptions(app, test_project, sync_service):
121+
"""Modifier descriptions should not become literal field names."""
122+
project_path = Path(test_project.path)
123+
124+
_write_schema_file(
125+
project_path,
126+
"schemas/PicoTest.md",
127+
"""\
128+
---
129+
title: PicoTest
130+
type: schema
131+
entity: pico_test
132+
schema:
133+
name: string
134+
status(enum, current state): [active, inactive]
135+
tags(array, list of tags): string
136+
settings:
137+
validation: warn
138+
---
139+
140+
# PicoTest
141+
""",
142+
)
143+
_write_schema_file(
144+
project_path,
145+
"pico/PicoTest1.md",
146+
"""\
147+
---
148+
title: PicoTest1
149+
type: pico_test
150+
permalink: pico/pico-test-1
151+
---
152+
153+
# PicoTest1
154+
155+
## Observations
156+
- [name] PicoTest1
157+
- [status] active
158+
- [tags] foo
159+
- [tags] bar
160+
""",
161+
)
162+
163+
await sync_service.sync(project_path)
164+
165+
result = await schema_validate(
166+
note_type="pico_test",
167+
project=test_project.name,
168+
output_format="json",
169+
)
170+
171+
assert isinstance(result, dict)
172+
note_result = result["results"][0]
173+
field_statuses = {fr["field_name"]: fr["status"] for fr in note_result["field_results"]}
174+
assert result["valid_count"] == 1
175+
assert note_result["warnings"] == []
176+
assert note_result["unmatched_observations"] == {}
177+
assert field_statuses == {
178+
"name": "present",
179+
"status": "present",
180+
"tags": "present",
181+
}
182+
183+
119184
@pytest.mark.asyncio
120185
async def test_schema_validate_by_identifier(app, test_project, sync_service):
121186
"""Validate a specific note by identifier."""

0 commit comments

Comments
 (0)