Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 141 additions & 28 deletions src/basic_memory/api/v2/routers/schema_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
FieldResultResponse,
FieldFrequencyResponse,
DriftFieldResponse,
TypeValidationSummary,
)
from basic_memory.schema.resolver import resolve_schema
from basic_memory.schema.validator import validate_note
Expand Down Expand Up @@ -140,7 +141,9 @@ async def validate_schema(
):
"""Validate notes against their resolved schemas.

Validates a specific note (by identifier) or all notes of a given type.
Validates a specific note (by identifier), all notes of a given type, or —
when neither is provided — all notes of every type that has a schema
defined, with a per-type breakdown in type_summaries.
Returns warnings/errors based on the schema's validation mode.

Schema definitions are read directly from their files to ensure the
Expand Down Expand Up @@ -191,41 +194,57 @@ async def search_fn(query: str) -> list[dict]:
)

# --- Batch validation by note type ---
entities = await _find_by_note_type(session, entity_repository, note_type) if note_type else []

for entity in entities:
frontmatter = _entity_frontmatter(entity)
schema_ref = frontmatter.get("schema")

async def search_fn(query: str) -> list[dict]:
entities = await _find_schema_entities(
session,
entity_repository,
query,
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
)
return [await _schema_frontmatter_from_file(file_service, e) for e in entities]
if note_type:
entities = await _find_by_note_type(session, entity_repository, note_type)
results = await _validate_note_entities(session, entity_repository, file_service, entities)
return ValidationReport(
note_type=note_type,
total_notes=len(results),
total_entities=len(entities),
valid_count=sum(1 for r in results if r.passed),
warning_count=sum(len(r.warnings) for r in results),
error_count=sum(len(r.errors) for r in results),
results=results,
)

schema_def = await resolve_schema(frontmatter, search_fn)
if schema_def:
result = validate_note(
entity.title or entity.permalink or entity.file_path,
schema_def,
_entity_observations(entity),
_entity_relations(entity),
frontmatter=frontmatter,
# --- All-types validation ---
# Trigger: neither identifier nor note_type was provided
# Why: schema_validate() with no arguments should check every note type that
# has a schema defined instead of returning an empty report (#1013)
# Outcome: aggregated report with a per-type breakdown in type_summaries
covered_types = await _schema_covered_note_types(session, entity_repository)
Comment thread
phernandez marked this conversation as resolved.

type_summaries: list[TypeValidationSummary] = []
total_entities = 0
for target_type, stored_note_types in covered_types.items():
entities = []
for stored_type in stored_note_types:
entities.extend(await _find_by_note_type(session, entity_repository, stored_type))
type_results = await _validate_note_entities(
session, entity_repository, file_service, entities
)
type_summaries.append(
TypeValidationSummary(
note_type=target_type,
total_notes=len(type_results),
total_entities=len(entities),
valid_count=sum(1 for r in type_results if r.passed),
warning_count=sum(len(r.warnings) for r in type_results),
error_count=sum(len(r.errors) for r in type_results),
)
results.append(_to_note_validation_response(result))
)
results.extend(type_results)
total_entities += len(entities)

valid = sum(1 for r in results if r.passed)
return ValidationReport(
note_type=note_type,
note_type=None,
total_notes=len(results),
total_entities=len(entities),
valid_count=valid,
total_entities=total_entities,
valid_count=sum(1 for r in results if r.passed),
warning_count=sum(len(r.warnings) for r in results),
error_count=sum(len(r.errors) for r in results),
results=results,
type_summaries=type_summaries,
)


Expand Down Expand Up @@ -337,6 +356,100 @@ async def search_fn(query: str) -> list[dict]:
# --- Helpers ---


async def _validate_note_entities(
session: AsyncSession,
entity_repository: EntityRepositoryV2ExternalDep,
file_service: FileServiceV2ExternalDep,
entities: list[Entity],
) -> list[NoteValidationResponse]:
"""Validate a batch of note entities against their resolved schemas.

Entities whose frontmatter resolves to no schema are skipped, which is why
a report's total_notes can be lower than its total_entities.
"""
results: list[NoteValidationResponse] = []

for entity in entities:
frontmatter = _entity_frontmatter(entity)
schema_ref = frontmatter.get("schema")

async def search_fn(query: str) -> list[dict]:
found = await _find_schema_entities(
session,
entity_repository,
query,
allow_reference_match=isinstance(schema_ref, str) and query == schema_ref,
)
return [await _schema_frontmatter_from_file(file_service, e) for e in found]

schema_def = await resolve_schema(frontmatter, search_fn)
if schema_def:
result = validate_note(
entity.title or entity.permalink or entity.file_path,
schema_def,
_entity_observations(entity),
_entity_relations(entity),
frontmatter=frontmatter,
)
results.append(_to_note_validation_response(result))

return results


async def _schema_covered_note_types(
session: AsyncSession,
entity_repository: EntityRepositoryV2ExternalDep,
) -> dict[str, list[str]]:
"""Map each schema-covered target type to the stored note_type values it covers.

Coverage comes from both standalone schema notes and notes that carry inline
schemas or explicit schema references. Stored note types are snake_case while
schema authors may write "Person" or "person", so both sides are compared
through generate_permalink normalization — the same matching rule
_find_schema_entities uses for implicit type lookup. Standalone targets with no
matching notes map to an empty list so they still appear in the report.
"""
schema_query = entity_repository.select().where(Entity.note_type == "schema")
Comment thread
phernandez marked this conversation as resolved.
schema_result = await entity_repository.execute_query(session, schema_query)

# normalized target -> (display label, stored note types); first label wins
targets: dict[str, tuple[str, set[str]]] = {}
for schema_entity in schema_result.scalars().all():
target = (schema_entity.entity_metadata or {}).get("entity")
if isinstance(target, str) and target:
targets.setdefault(generate_permalink(target), (target, set()))

# Column-only select: skip eager-load options, which apply only to full entities.
# Reading metadata here also discovers inline schemas and explicit references;
# those notes are covered even when their type differs from a schema's entity.
note_query = entity_repository.select(Entity.note_type, Entity.entity_metadata).where(
Entity.note_type != "schema"
)
note_result = await entity_repository.execute_query(
session, note_query, use_query_options=False
)
for stored_type, metadata in note_result.all():
if not stored_type:
continue

normalized_type = generate_permalink(stored_type)
if normalized_type in targets:
targets[normalized_type][1].add(stored_type)

schema_value = (metadata or {}).get("schema")
has_direct_schema = isinstance(schema_value, dict) or (
isinstance(schema_value, str) and bool(schema_value)
)
if has_direct_schema:
_, stored_types = targets.setdefault(normalized_type, (stored_type, set()))
stored_types.add(stored_type)

return {
display_label: sorted(stored_types)
for _, (display_label, stored_types) in sorted(targets.items())
}


async def _find_by_note_type(
session: AsyncSession,
entity_repository: EntityRepositoryV2ExternalDep,
Expand Down
54 changes: 53 additions & 1 deletion src/basic_memory/mcp/tools/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ def _format_validation_report(report: ValidationReport) -> str:
)
lines.append("")

# --- Per-type breakdown (all-types mode) ---
if report.type_summaries:
lines.append("## By Type")
lines.append("")
for summary in report.type_summaries:
if summary.total_entities == 0:
lines.append(f"- **{summary.note_type}**: no notes")
else:
lines.append(
f"- **{summary.note_type}**: {summary.valid_count}/{summary.total_notes} valid"
)
lines.append("")

# --- Per-note results ---
for r in report.results:
status = "valid" if r.passed else "INVALID"
Expand Down Expand Up @@ -167,6 +180,26 @@ def _no_notes_guidance(note_type: str, tool_name: str) -> str:
)


def _no_schemas_defined_guidance(tool_name: str) -> str:
"""Build guidance string when validating all types but no schemas exist.

Used by schema_validate when called without arguments in a project that
has no schema notes — there is nothing to validate yet.
"""
return (
f"# No Schemas Defined\n\n"
f"`{tool_name}` was called without `note_type` or `identifier`, which "
f"validates every note type that has a schema — but this project has "
f"no schema notes yet, so there is nothing to validate.\n\n"
f"## Next Steps\n\n"
f'1. **Infer a schema** — run `schema_infer("<note_type>")` to analyze '
f"existing notes and get a suggested schema\n"
f"2. **Create a schema note** — write a note with `type: schema` and an "
f"`entity` field naming the note type it validates\n"
f"3. **Re-run** — call `{tool_name}()` again once a schema exists\n"
)


def _no_schema_guidance(note_type: str, tool_name: str) -> str:
"""Build guidance string when no schema exists for a note type.

Expand Down Expand Up @@ -224,7 +257,9 @@ async def schema_validate(
) -> ValidationReport | str | dict:
"""Validate notes against their resolved schema.

Validates a specific note (by identifier) or all notes of a given type.
Validates a specific note (by identifier), all notes of a given type, or —
when called with neither — all notes of every type that has a schema
defined, with a per-type breakdown.
Returns warnings/errors based on the schema's validation mode.

Schemas are resolved in priority order:
Expand Down Expand Up @@ -258,6 +293,9 @@ async def schema_validate(
# Validate a specific note
schema_validate(identifier="people/paul-graham")

# Validate every type that has a schema defined
schema_validate()

# Validate in a specific project
schema_validate(note_type="person", project="my-research")
"""
Expand Down Expand Up @@ -285,6 +323,20 @@ async def schema_validate(
f"warnings={result.warning_count} errors={result.error_count}"
)

# --- All-types mode ---
# Trigger: called with neither identifier nor note_type (#1013)
# Why: an empty report here means "no schemas defined", not
# "no notes of type 'unknown'" — the guards below would mislead
# Outcome: per-type summary, or guidance for defining a first schema
if note_type is None and identifier is None:
if not result.type_summaries:
if output_format == "json":
return {"error": "No schemas defined in this project"}
return _no_schemas_defined_guidance("schema_validate")
if output_format == "json":
return result.model_dump(mode="json", exclude_none=True)
return _format_validation_report(result)

# --- No notes guard ---
# Trigger: no entities of this type exist in the project
# Why: can't validate notes that don't exist yet
Expand Down
15 changes: 15 additions & 0 deletions src/basic_memory/schemas/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,17 @@ class NoteValidationResponse(BaseModel):
errors: list[str] = Field(default_factory=list)


class TypeValidationSummary(BaseModel):
"""Per-type rollup used when validating all schema-covered types at once."""

note_type: str
total_notes: int = 0
total_entities: int = 0
valid_count: int = 0
warning_count: int = 0
error_count: int = 0


class ValidationReport(BaseModel):
"""Full validation report for one or more notes."""

Expand All @@ -52,6 +63,10 @@ class ValidationReport(BaseModel):
warning_count: int = 0
error_count: int = 0
results: list[NoteValidationResponse] = Field(default_factory=list)
type_summaries: list[TypeValidationSummary] = Field(
default_factory=list,
description="Per-type breakdown, populated when validating all schema-covered types",
)


# --- Inference Response Models ---
Expand Down
Loading
Loading