Skip to content

Commit c4c9f84

Browse files
phernandezclaude
andcommitted
fix: schema_validate identifier resolution and text rendering
Fixes issues #29 and #33 from openclaw-basic-memory. 🔧 Identifier resolution (#33): - Router used get_by_permalink() which only matched exact permalinks. Replaced with link_resolver.resolve_link() so titles, paths, and fuzzy matches work consistently with other tools like read_note. - Set total_entities=1 and total_notes=len(results) on single-note path for consistency with batch path. - Guards for "no notes" and "no schema" now fire for identifier-based validation too, not just note_type-based. 🎨 Text rendering (#29): - Tool returned raw Pydantic model which LLMs rendered as "undefined — invalid". Now returns pre-formatted markdown. - Router uses entity.title (with permalink fallback) as note_identifier for human-readable output in both text and JSON modes. - JSON output (output_format="json") unchanged for CLI compatibility. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: phernandez <paul@basicmachines.co>
1 parent dc0ca71 commit c4c9f84

3 files changed

Lines changed: 155 additions & 23 deletions

File tree

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

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from fastapi import APIRouter, Path, Query
1414

15-
from basic_memory.deps import EntityRepositoryV2ExternalDep
15+
from basic_memory.deps import EntityRepositoryV2ExternalDep, LinkResolverV2ExternalDep
1616
from basic_memory.models.knowledge import Entity
1717
from basic_memory.schemas.schema import (
1818
ValidationReport,
@@ -80,6 +80,7 @@ def _entity_frontmatter(entity: Entity) -> dict:
8080
@router.post("/schema/validate", response_model=ValidationReport)
8181
async def validate_schema(
8282
entity_repository: EntityRepositoryV2ExternalDep,
83+
link_resolver: LinkResolverV2ExternalDep,
8384
project_id: str = Path(..., description="Project external UUID"),
8485
note_type: str | None = Query(None, description="Note type to validate"),
8586
identifier: str | None = Query(None, description="Specific note identifier"),
@@ -93,9 +94,11 @@ async def validate_schema(
9394

9495
# --- Single note validation ---
9596
if identifier:
96-
entity = await entity_repository.get_by_permalink(identifier)
97+
# Resolve identifier flexibly (permalink, title, path, fuzzy)
98+
# to match how read_note and other tools resolve identifiers
99+
entity = await link_resolver.resolve_link(identifier)
97100
if not entity:
98-
return ValidationReport(note_type=note_type, total_notes=0, results=[])
101+
return ValidationReport(note_type=note_type, total_notes=0, total_entities=0)
99102

100103
frontmatter = _entity_frontmatter(entity)
101104
schema_ref = frontmatter.get("schema")
@@ -111,7 +114,7 @@ async def search_fn(query: str) -> list[dict]:
111114
schema_def = await resolve_schema(frontmatter, search_fn)
112115
if schema_def:
113116
result = validate_note(
114-
entity.permalink or identifier,
117+
entity.title or entity.permalink or identifier,
115118
schema_def,
116119
_entity_observations(entity),
117120
_entity_relations(entity),
@@ -121,7 +124,8 @@ async def search_fn(query: str) -> list[dict]:
121124

122125
return ValidationReport(
123126
note_type=note_type or entity.note_type,
124-
total_notes=1,
127+
total_notes=len(results),
128+
total_entities=1,
125129
valid_count=1 if (results and results[0].passed) else 0,
126130
warning_count=sum(len(r.warnings) for r in results),
127131
error_count=sum(len(r.errors) for r in results),
@@ -146,7 +150,7 @@ async def search_fn(query: str) -> list[dict]:
146150
schema_def = await resolve_schema(frontmatter, search_fn)
147151
if schema_def:
148152
result = validate_note(
149-
entity.permalink or entity.file_path,
153+
entity.title or entity.permalink or entity.file_path,
150154
schema_def,
151155
_entity_observations(entity),
152156
_entity_relations(entity),

src/basic_memory/mcp/tools/schema.py

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,36 @@
1414
from basic_memory.schemas.schema import ValidationReport, InferenceReport, DriftReport
1515

1616

17+
def _format_validation_report(report: ValidationReport) -> str:
18+
"""Render a ValidationReport as readable markdown.
19+
20+
Produces output the LLM can display directly instead of trying to
21+
interpret raw JSON, which leads to "undefined — invalid" rendering.
22+
"""
23+
lines: list[str] = []
24+
25+
# --- Header ---
26+
type_label = report.note_type or "all"
27+
lines.append(f"# Schema Validation: {type_label}")
28+
lines.append("")
29+
lines.append(
30+
f"Notes: {report.total_notes} | Valid: {report.valid_count} "
31+
f"| Warnings: {report.warning_count} | Errors: {report.error_count}"
32+
)
33+
lines.append("")
34+
35+
# --- Per-note results ---
36+
for r in report.results:
37+
status = "valid" if r.passed else "INVALID"
38+
lines.append(f"- **{r.note_identifier}** — {status}")
39+
for w in r.warnings:
40+
lines.append(f" - warning: {w}")
41+
for e in r.errors:
42+
lines.append(f" - error: {e}")
43+
44+
return "\n".join(lines)
45+
46+
1747
def _no_notes_guidance(note_type: str, tool_name: str) -> str:
1848
"""Build guidance string when no notes of a given type exist.
1949
@@ -142,24 +172,25 @@ async def schema_validate(
142172
# Trigger: no entities of this type exist in the project
143173
# Why: can't validate notes that don't exist yet
144174
# Outcome: return guidance on creating notes of this type
145-
if note_type and result.total_entities == 0:
175+
effective_type = note_type or result.note_type or "unknown"
176+
if result.total_entities == 0:
146177
if output_format == "json":
147-
return {"error": f"No notes found of type '{note_type}'"}
148-
return _no_notes_guidance(note_type, "schema_validate")
178+
return {"error": f"No notes found of type '{effective_type}'"}
179+
return _no_notes_guidance(effective_type, "schema_validate")
149180

150181
# --- No schema guard ---
151182
# Trigger: entities exist but none were validated (no schema found)
152183
# Why: notes of this type exist but no schema was found, so none were validated
153184
# Outcome: return guidance on how to create a schema
154-
if note_type and result.total_notes == 0:
185+
if result.total_notes == 0:
155186
if output_format == "json":
156-
return {"error": f"No schema found for type '{note_type}'"}
157-
return _no_schema_guidance(note_type, "schema_validate")
187+
return {"error": f"No schema found for type '{effective_type}'"}
188+
return _no_schema_guidance(effective_type, "schema_validate")
158189

159190
if output_format == "json":
160191
return result.model_dump(mode="json", exclude_none=True)
161192

162-
return result
193+
return _format_validation_report(result)
163194

164195
except Exception as e:
165196
logger.error(f"Schema validation failed: {e}, project: {active_project.name}")

tests/mcp/test_tool_schema.py

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
from basic_memory.mcp.tools.schema import schema_validate, schema_infer, schema_diff
1414
from basic_memory.mcp.tools.write_note import write_note
15-
from basic_memory.schemas.schema import ValidationReport, InferenceReport, DriftReport
15+
from basic_memory.schemas.schema import InferenceReport, DriftReport
1616

1717

1818
# --- Helpers ---
@@ -82,8 +82,39 @@ async def test_schema_validate_by_type(app, test_project, sync_service):
8282
project=test_project.name,
8383
)
8484

85-
assert isinstance(result, ValidationReport)
86-
assert result.total_notes >= 1
85+
assert isinstance(result, str)
86+
assert "Schema Validation: person" in result
87+
assert "Notes: 1" in result
88+
assert "**Alice**" in result
89+
assert "valid" in result
90+
91+
92+
@pytest.mark.asyncio
93+
async def test_schema_validate_json_output(app, test_project, sync_service):
94+
"""JSON output returns a dict with full structured data."""
95+
project_path = Path(test_project.path)
96+
97+
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
98+
_write_schema_file(
99+
project_path,
100+
"people/Alice.md",
101+
PERSON_NOTE.format(name="Alice", permalink="alice"),
102+
)
103+
104+
await sync_service.sync(project_path)
105+
106+
result = await schema_validate(
107+
note_type="person",
108+
project=test_project.name,
109+
output_format="json",
110+
)
111+
112+
assert isinstance(result, dict)
113+
assert result["total_notes"] == 1
114+
assert result["valid_count"] == 1
115+
assert len(result["results"]) == 1
116+
assert result["results"][0]["note_identifier"] == "Alice"
117+
assert result["results"][0]["passed"] is True
87118

88119

89120
@pytest.mark.asyncio
@@ -105,8 +136,70 @@ async def test_schema_validate_by_identifier(app, test_project, sync_service):
105136
project=test_project.name,
106137
)
107138

108-
assert isinstance(result, ValidationReport)
109-
assert result.total_notes >= 1
139+
assert isinstance(result, str)
140+
assert "**Alice**" in result
141+
assert "valid" in result
142+
143+
144+
@pytest.mark.asyncio
145+
async def test_schema_validate_by_title(app, test_project, sync_service):
146+
"""Validate a specific note by title (not permalink).
147+
148+
Regression test for issue #33: schema_validate(identifier="Note Title")
149+
returned 0 notes because the router only searched by permalink.
150+
"""
151+
project_path = Path(test_project.path)
152+
153+
_write_schema_file(project_path, "schemas/Person.md", PERSON_SCHEMA)
154+
_write_schema_file(
155+
project_path,
156+
"people/Alice.md",
157+
PERSON_NOTE.format(name="Alice", permalink="alice"),
158+
)
159+
160+
await sync_service.sync(project_path)
161+
162+
# Use the title "Alice" instead of the permalink "people/alice"
163+
result = await schema_validate(
164+
identifier="Alice",
165+
project=test_project.name,
166+
)
167+
168+
assert isinstance(result, str)
169+
assert "**Alice**" in result
170+
assert "Notes: 1" in result
171+
assert "valid" in result
172+
173+
174+
@pytest.mark.asyncio
175+
async def test_schema_validate_identifier_no_schema_returns_guidance(
176+
app, test_project, sync_service
177+
):
178+
"""When a note exists but no schema is defined, return guidance.
179+
180+
Regression test for issue #33: when validating a single note by identifier
181+
and no schema exists, the tool should return guidance instead of an empty report.
182+
"""
183+
project_path = Path(test_project.path)
184+
185+
# Create a person note but no schema note
186+
_write_schema_file(
187+
project_path,
188+
"people/Alice.md",
189+
PERSON_NOTE.format(name="Alice", permalink="alice"),
190+
)
191+
192+
await sync_service.sync(project_path)
193+
194+
result = await schema_validate(
195+
identifier="Alice",
196+
project=test_project.name,
197+
)
198+
199+
# Should return guidance string about missing schema
200+
assert isinstance(result, str)
201+
assert "No Schema Found" in result
202+
assert "person" in result
110203

111204

112205
@pytest.mark.asyncio
@@ -214,8 +307,9 @@ async def test_write_note_metadata_creates_schema_note(app, test_project, sync_s
214307
project=test_project.name,
215308
)
216309

217-
assert isinstance(result, ValidationReport)
218-
assert result.total_notes >= 2
310+
assert isinstance(result, str)
311+
assert "Schema Validation: person" in result
312+
assert "valid" in result
219313

220314

221315
@pytest.mark.asyncio
@@ -279,10 +373,13 @@ async def test_schema_title_mismatch_finds_by_metadata(app, test_project, sync_s
279373
project=test_project.name,
280374
)
281375

282-
assert isinstance(result, ValidationReport)
283-
assert result.total_notes == 2
376+
assert isinstance(result, str)
377+
assert "Schema Validation: employee" in result
378+
assert "Notes: 2" in result
379+
assert "Valid: 2" in result
284380
# Both notes have name + department, schema requires name and optionally department
285-
assert result.valid_count == 2
381+
assert "**Alice**" in result
382+
assert "**Bob**" in result
286383

287384

288385
# --- Empty schema guard ---

0 commit comments

Comments
 (0)