mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
chore: rename entity_type to note_type (#600)
Signed-off-by: phernandez <paul@basicmachines.co> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
"""Rename entity_type column to note_type
|
||||
|
||||
Revision ID: j3d4e5f6g7h8
|
||||
Revises: i2c3d4e5f6g7
|
||||
Create Date: 2026-02-22 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy import text
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "j3d4e5f6g7h8"
|
||||
down_revision: Union[str, None] = "i2c3d4e5f6g7"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def table_exists(connection, table_name: str) -> bool:
|
||||
"""Check if a table exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.tables "
|
||||
"WHERE table_name = :table_name"
|
||||
),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='table' AND name = :table_name"),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def index_exists(connection, index_name: str) -> bool:
|
||||
"""Check if an index exists (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM pg_indexes WHERE indexname = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(
|
||||
text("SELECT 1 FROM sqlite_master WHERE type='index' AND name = :index_name"),
|
||||
{"index_name": index_name},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
|
||||
|
||||
def column_exists(connection, table: str, column: str) -> bool:
|
||||
"""Check if a column exists in a table (idempotent migration support)."""
|
||||
if connection.dialect.name == "postgresql":
|
||||
result = connection.execute(
|
||||
text(
|
||||
"SELECT 1 FROM information_schema.columns "
|
||||
"WHERE table_name = :table AND column_name = :column"
|
||||
),
|
||||
{"table": table, "column": column},
|
||||
)
|
||||
return result.fetchone() is not None
|
||||
# SQLite
|
||||
result = connection.execute(text(f"PRAGMA table_info({table})"))
|
||||
columns = [row[1] for row in result]
|
||||
return column in columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Rename entity_type → note_type on the entity table."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
# Skip if already migrated (idempotent)
|
||||
if column_exists(connection, "entity", "note_type"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
# Postgres supports direct column rename
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
|
||||
|
||||
# Recreate the index with new name
|
||||
op.execute("DROP INDEX IF EXISTS ix_entity_type")
|
||||
op.execute("CREATE INDEX ix_note_type ON entity (note_type)")
|
||||
else:
|
||||
# SQLite 3.25.0+ supports ALTER TABLE RENAME COLUMN directly.
|
||||
# Avoids batch_alter_table which fails on tables with generated columns
|
||||
# (duplicate column name error when recreating the table).
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN entity_type TO note_type")
|
||||
|
||||
# Recreate the index with new name
|
||||
if index_exists(connection, "ix_entity_type"):
|
||||
op.drop_index("ix_entity_type", table_name="entity")
|
||||
op.create_index("ix_note_type", "entity", ["note_type"])
|
||||
|
||||
# Update search index metadata: rename entity_type → note_type in JSON
|
||||
# This updates the stored metadata so search results use the new field name
|
||||
# Guard: search_index may not exist on a fresh DB (created by an earlier migration)
|
||||
if not table_exists(connection, "search_index"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = metadata - 'entity_type' || jsonb_build_object('note_type', metadata->'entity_type')
|
||||
WHERE metadata ? 'entity_type'
|
||||
""")
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = json_set(
|
||||
json_remove(metadata, '$.entity_type'),
|
||||
'$.note_type',
|
||||
json_extract(metadata, '$.entity_type')
|
||||
)
|
||||
WHERE json_extract(metadata, '$.entity_type') IS NOT NULL
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Rename note_type → entity_type on the entity table."""
|
||||
connection = op.get_bind()
|
||||
dialect = connection.dialect.name
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
|
||||
op.execute("DROP INDEX IF EXISTS ix_note_type")
|
||||
op.execute("CREATE INDEX ix_entity_type ON entity (entity_type)")
|
||||
else:
|
||||
op.execute("ALTER TABLE entity RENAME COLUMN note_type TO entity_type")
|
||||
|
||||
if index_exists(connection, "ix_note_type"):
|
||||
op.drop_index("ix_note_type", table_name="entity")
|
||||
op.create_index("ix_entity_type", "entity", ["entity_type"])
|
||||
|
||||
# Revert search index metadata
|
||||
if not table_exists(connection, "search_index"):
|
||||
return
|
||||
|
||||
if dialect == "postgresql":
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = metadata - 'note_type' || jsonb_build_object('entity_type', metadata->'note_type')
|
||||
WHERE metadata ? 'note_type'
|
||||
""")
|
||||
)
|
||||
else:
|
||||
op.execute(
|
||||
text("""
|
||||
UPDATE search_index
|
||||
SET metadata = json_set(
|
||||
json_remove(metadata, '$.note_type'),
|
||||
'$.entity_type',
|
||||
json_extract(metadata, '$.note_type')
|
||||
)
|
||||
WHERE json_extract(metadata, '$.note_type') IS NOT NULL
|
||||
""")
|
||||
)
|
||||
@@ -201,7 +201,7 @@ async def create_entity(
|
||||
Created entity with generated external_id (UUID) and file content
|
||||
"""
|
||||
logger.info(
|
||||
"API v2 request", endpoint="create_entity", entity_type=data.entity_type, title=data.title
|
||||
"API v2 request", endpoint="create_entity", note_type=data.note_type, title=data.title
|
||||
)
|
||||
|
||||
if fast:
|
||||
|
||||
@@ -145,14 +145,14 @@ async def create_resource(
|
||||
# Determine file details
|
||||
file_name = PathLib(data.file_path).name
|
||||
content_type = file_service.content_type(data.file_path)
|
||||
entity_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
note_type = "canvas" if data.file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Create a new entity model
|
||||
# Explicitly set external_id to ensure NOT NULL constraint is satisfied (fixes #512)
|
||||
entity = EntityModel(
|
||||
external_id=str(uuid.uuid4()),
|
||||
title=file_name,
|
||||
entity_type=entity_type,
|
||||
note_type=note_type,
|
||||
content_type=content_type,
|
||||
file_path=data.file_path,
|
||||
checksum=checksum,
|
||||
@@ -253,14 +253,14 @@ async def update_resource(
|
||||
# Determine file details
|
||||
file_name = PathLib(target_file_path).name
|
||||
content_type = file_service.content_type(target_file_path)
|
||||
entity_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
note_type = "canvas" if target_file_path.endswith(".canvas") else "file"
|
||||
|
||||
# Update entity using internal ID
|
||||
updated_entity = await entity_repository.update(
|
||||
entity.id,
|
||||
{
|
||||
"title": file_name,
|
||||
"entity_type": entity_type,
|
||||
"note_type": note_type,
|
||||
"content_type": content_type,
|
||||
"file_path": target_file_path,
|
||||
"checksum": checksum,
|
||||
|
||||
@@ -51,7 +51,7 @@ def _entity_relations(entity: Entity) -> list[RelationData]:
|
||||
RelationData(
|
||||
relation_type=rel.relation_type,
|
||||
target_name=rel.to_name,
|
||||
target_entity_type=rel.to_entity.entity_type if rel.to_entity else None,
|
||||
target_note_type=rel.to_entity.note_type if rel.to_entity else None,
|
||||
)
|
||||
for rel in entity.outgoing_relations
|
||||
]
|
||||
@@ -69,8 +69,8 @@ def _entity_to_note_data(entity: Entity) -> NoteData:
|
||||
def _entity_frontmatter(entity: Entity) -> dict:
|
||||
"""Build a frontmatter dict from an entity for schema resolution."""
|
||||
frontmatter = dict(entity.entity_metadata) if entity.entity_metadata else {}
|
||||
if entity.entity_type:
|
||||
frontmatter.setdefault("type", entity.entity_type)
|
||||
if entity.note_type:
|
||||
frontmatter.setdefault("type", entity.note_type)
|
||||
return frontmatter
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def _entity_frontmatter(entity: Entity) -> dict:
|
||||
async def validate_schema(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_type: str | None = Query(None, description="Entity type to validate"),
|
||||
note_type: str | None = Query(None, description="Note type to validate"),
|
||||
identifier: str | None = Query(None, description="Specific note identifier"),
|
||||
):
|
||||
"""Validate notes against their resolved schemas.
|
||||
@@ -95,7 +95,7 @@ async def validate_schema(
|
||||
if identifier:
|
||||
entity = await entity_repository.get_by_permalink(identifier)
|
||||
if not entity:
|
||||
return ValidationReport(entity_type=entity_type, total_notes=0, results=[])
|
||||
return ValidationReport(note_type=note_type, total_notes=0, results=[])
|
||||
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
schema_ref = frontmatter.get("schema")
|
||||
@@ -120,7 +120,7 @@ async def validate_schema(
|
||||
results.append(_to_note_validation_response(result))
|
||||
|
||||
return ValidationReport(
|
||||
entity_type=entity_type or entity.entity_type,
|
||||
note_type=note_type or entity.note_type,
|
||||
total_notes=1,
|
||||
valid_count=1 if (results and results[0].passed) else 0,
|
||||
warning_count=sum(len(r.warnings) for r in results),
|
||||
@@ -128,8 +128,8 @@ async def validate_schema(
|
||||
results=results,
|
||||
)
|
||||
|
||||
# --- Batch validation by entity type ---
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type) if entity_type else []
|
||||
# --- Batch validation by note type ---
|
||||
entities = await _find_by_note_type(entity_repository, note_type) if note_type else []
|
||||
|
||||
for entity in entities:
|
||||
frontmatter = _entity_frontmatter(entity)
|
||||
@@ -156,7 +156,7 @@ async def validate_schema(
|
||||
|
||||
valid = sum(1 for r in results if r.passed)
|
||||
return ValidationReport(
|
||||
entity_type=entity_type,
|
||||
note_type=note_type,
|
||||
total_notes=len(results),
|
||||
total_entities=len(entities),
|
||||
valid_count=valid,
|
||||
@@ -173,7 +173,7 @@ async def validate_schema(
|
||||
async def infer_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
entity_type: str = Query(..., description="Entity type to analyze"),
|
||||
note_type: str = Query(..., description="Note type to analyze"),
|
||||
threshold: float = Query(0.25, description="Minimum frequency for optional fields"),
|
||||
):
|
||||
"""Infer a schema from existing notes of a given type.
|
||||
@@ -181,13 +181,13 @@ async def infer_schema_endpoint(
|
||||
Examines observation categories and relation types across all notes
|
||||
of the given type. Returns frequency analysis and suggested Picoschema.
|
||||
"""
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
entities = await _find_by_note_type(entity_repository, note_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = infer_schema(entity_type, notes_data, optional_threshold=threshold)
|
||||
result = infer_schema(note_type, notes_data, optional_threshold=threshold)
|
||||
|
||||
return InferenceReport(
|
||||
entity_type=result.entity_type,
|
||||
note_type=result.note_type,
|
||||
notes_analyzed=result.notes_analyzed,
|
||||
field_frequencies=[
|
||||
FieldFrequencyResponse(
|
||||
@@ -212,10 +212,10 @@ async def infer_schema_endpoint(
|
||||
# --- Drift Detection ---
|
||||
|
||||
|
||||
@router.get("/schema/diff/{entity_type}", response_model=DriftReport)
|
||||
@router.get("/schema/diff/{note_type}", response_model=DriftReport)
|
||||
async def diff_schema_endpoint(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_type: str = Path(..., description="Entity type to check for drift"),
|
||||
note_type: str = Path(..., description="Note type to check for drift"),
|
||||
project_id: str = Path(..., description="Project external UUID"),
|
||||
):
|
||||
"""Show drift between a schema definition and actual note usage.
|
||||
@@ -229,21 +229,21 @@ async def diff_schema_endpoint(
|
||||
entities = await _find_schema_entities(entity_repository, query)
|
||||
return [_entity_frontmatter(e) for e in entities]
|
||||
|
||||
# Resolve schema by entity type
|
||||
schema_frontmatter = {"type": entity_type}
|
||||
# Resolve schema by note type
|
||||
schema_frontmatter = {"type": note_type}
|
||||
schema_def = await resolve_schema(schema_frontmatter, search_fn)
|
||||
|
||||
if not schema_def:
|
||||
return DriftReport(entity_type=entity_type, schema_found=False)
|
||||
return DriftReport(note_type=note_type, schema_found=False)
|
||||
|
||||
# Collect all notes of this type
|
||||
entities = await _find_by_entity_type(entity_repository, entity_type)
|
||||
entities = await _find_by_note_type(entity_repository, note_type)
|
||||
notes_data = [_entity_to_note_data(entity) for entity in entities]
|
||||
|
||||
result = diff_schema(schema_def, notes_data)
|
||||
|
||||
return DriftReport(
|
||||
entity_type=entity_type,
|
||||
note_type=note_type,
|
||||
new_fields=[
|
||||
DriftFieldResponse(
|
||||
name=f.name,
|
||||
@@ -271,19 +271,19 @@ async def diff_schema_endpoint(
|
||||
# --- Helpers ---
|
||||
|
||||
|
||||
async def _find_by_entity_type(
|
||||
async def _find_by_note_type(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
) -> list[Entity]:
|
||||
"""Find all entities of a given type using the repository's select pattern."""
|
||||
query = entity_repository.select().where(Entity.entity_type == entity_type)
|
||||
query = entity_repository.select().where(Entity.note_type == note_type)
|
||||
result = await entity_repository.execute_query(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def _find_schema_entities(
|
||||
entity_repository: EntityRepositoryV2ExternalDep,
|
||||
target_entity_type: str,
|
||||
target_note_type: str,
|
||||
*,
|
||||
allow_reference_match: bool = False,
|
||||
) -> list[Entity]:
|
||||
@@ -295,11 +295,11 @@ async def _find_schema_entities(
|
||||
2) Only when allow_reference_match=True and no entity match was found, try
|
||||
exact reference matching by title/permalink (explicit schema references)
|
||||
"""
|
||||
query = entity_repository.select().where(Entity.entity_type == "schema")
|
||||
query = entity_repository.select().where(Entity.note_type == "schema")
|
||||
result = await entity_repository.execute_query(query)
|
||||
entities = list(result.scalars().all())
|
||||
|
||||
normalized_target = generate_permalink(target_entity_type)
|
||||
normalized_target = generate_permalink(target_note_type)
|
||||
|
||||
entity_matches = [
|
||||
e
|
||||
|
||||
@@ -61,7 +61,7 @@ async def run_doctor() -> None:
|
||||
api_note = Entity(
|
||||
title=api_note_title,
|
||||
directory="doctor",
|
||||
entity_type="note",
|
||||
note_type="note",
|
||||
content_type="text/markdown",
|
||||
content=f"# {api_note_title}\n\n- [note] API to file check",
|
||||
entity_metadata={"tags": ["doctor"]},
|
||||
|
||||
@@ -775,16 +775,16 @@ def display_project_info(
|
||||
|
||||
console.print(stats_table)
|
||||
|
||||
# Entity types
|
||||
if info.statistics.entity_types:
|
||||
entity_types_table = Table(title="Entity Types")
|
||||
entity_types_table.add_column("Type", style="blue")
|
||||
entity_types_table.add_column("Count", style="green")
|
||||
# Note types
|
||||
if info.statistics.note_types:
|
||||
note_types_table = Table(title="Note Types")
|
||||
note_types_table.add_column("Type", style="blue")
|
||||
note_types_table.add_column("Count", style="green")
|
||||
|
||||
for entity_type, count in info.statistics.entity_types.items():
|
||||
entity_types_table.add_row(entity_type, str(count))
|
||||
for note_type, count in info.statistics.note_types.items():
|
||||
note_types_table.add_row(note_type, str(count))
|
||||
|
||||
console.print(entity_types_table)
|
||||
console.print(note_types_table)
|
||||
|
||||
# Most connected entities
|
||||
if info.statistics.most_connected_entities: # pragma: no cover
|
||||
@@ -815,7 +815,7 @@ def display_project_info(
|
||||
)
|
||||
recent_table.add_row(
|
||||
entity["title"],
|
||||
entity["entity_type"],
|
||||
entity["note_type"],
|
||||
updated_at.strftime("%Y-%m-%d %H:%M"),
|
||||
)
|
||||
|
||||
|
||||
@@ -47,8 +47,8 @@ def _resolve_project_name(project: Optional[str]) -> Optional[str]:
|
||||
|
||||
def _render_validate_table(data: dict) -> None:
|
||||
"""Render a validation report dict as a Rich table."""
|
||||
entity_type = data.get("entity_type")
|
||||
title_label = entity_type or "all"
|
||||
note_type = data.get("note_type")
|
||||
title_label = note_type or "all"
|
||||
|
||||
table = Table(title=f"Schema Validation: {title_label}")
|
||||
table.add_column("Note", style="cyan")
|
||||
@@ -84,12 +84,12 @@ def _render_validate_table(data: dict) -> None:
|
||||
|
||||
def _render_infer_table(data: dict) -> None:
|
||||
"""Render an inference report dict as a Rich table."""
|
||||
entity_type = data.get("entity_type", "")
|
||||
note_type = data.get("note_type", "")
|
||||
notes_analyzed = data.get("notes_analyzed", 0)
|
||||
suggested_required = data.get("suggested_required", [])
|
||||
suggested_optional = data.get("suggested_optional", [])
|
||||
|
||||
console.print(f"\n[bold]Analyzing {notes_analyzed} notes with type: {entity_type}...[/bold]\n")
|
||||
console.print(f"\n[bold]Analyzing {notes_analyzed} notes with type: {note_type}...[/bold]\n")
|
||||
|
||||
table = Table(title="Field Frequencies")
|
||||
table.add_column("Field", style="cyan")
|
||||
@@ -126,7 +126,7 @@ def _render_infer_table(data: dict) -> None:
|
||||
|
||||
def _render_diff_output(data: dict) -> None:
|
||||
"""Render a drift report dict as Rich output."""
|
||||
entity_type = data.get("entity_type", "")
|
||||
note_type = data.get("note_type", "")
|
||||
new_fields = data.get("new_fields", [])
|
||||
dropped_fields = data.get("dropped_fields", [])
|
||||
cardinality_changes = data.get("cardinality_changes", [])
|
||||
@@ -134,10 +134,10 @@ def _render_diff_output(data: dict) -> None:
|
||||
has_drift = new_fields or dropped_fields or cardinality_changes
|
||||
|
||||
if not has_drift:
|
||||
console.print(f"[green]No drift detected for {entity_type} schema.[/green]")
|
||||
console.print(f"[green]No drift detected for {note_type} schema.[/green]")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold]Schema drift detected for {entity_type}:[/bold]\n")
|
||||
console.print(f"\n[bold]Schema drift detected for {note_type}:[/bold]\n")
|
||||
|
||||
if new_fields:
|
||||
console.print("[green]+ New fields (common in notes, not in schema):[/green]")
|
||||
@@ -233,7 +233,7 @@ def validate(
|
||||
|
||||
@schema_app.command()
|
||||
def infer(
|
||||
entity_type: Annotated[
|
||||
note_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
|
||||
],
|
||||
@@ -268,7 +268,7 @@ def infer(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_infer(
|
||||
note_type=entity_type,
|
||||
note_type=note_type,
|
||||
threshold=threshold,
|
||||
project=project_name,
|
||||
output_format="json",
|
||||
@@ -285,7 +285,7 @@ def infer(
|
||||
|
||||
# Handle zero notes
|
||||
if result.get("notes_analyzed", 0) == 0:
|
||||
console.print(f"[yellow]No notes found with type: {entity_type}[/yellow]")
|
||||
console.print(f"[yellow]No notes found with type: {note_type}[/yellow]")
|
||||
return
|
||||
|
||||
_render_infer_table(result)
|
||||
@@ -293,7 +293,7 @@ def infer(
|
||||
if save:
|
||||
console.print(
|
||||
f"\n[yellow]--save not yet implemented. "
|
||||
f"Copy the schema above into schema/{entity_type}.md[/yellow]"
|
||||
f"Copy the schema above into schema/{note_type}.md[/yellow]"
|
||||
)
|
||||
except ValueError as e:
|
||||
console.print(f"[red]Error: {e}[/red]")
|
||||
@@ -308,7 +308,7 @@ def infer(
|
||||
|
||||
@schema_app.command()
|
||||
def diff(
|
||||
entity_type: Annotated[
|
||||
note_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to check for drift"),
|
||||
],
|
||||
@@ -337,7 +337,7 @@ def diff(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_diff(
|
||||
note_type=entity_type,
|
||||
note_type=note_type,
|
||||
project=project_name,
|
||||
output_format="json",
|
||||
)
|
||||
|
||||
@@ -493,7 +493,7 @@ def search_notes(
|
||||
page=page,
|
||||
after_date=after_date,
|
||||
page_size=page_size,
|
||||
types=note_types,
|
||||
note_types=note_types,
|
||||
entity_types=entity_types,
|
||||
metadata_filters=metadata_filters,
|
||||
tags=tags,
|
||||
@@ -654,7 +654,7 @@ def schema_validate(
|
||||
|
||||
@tool_app.command("schema-infer")
|
||||
def schema_infer(
|
||||
entity_type: Annotated[
|
||||
note_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to analyze (e.g., person, meeting)"),
|
||||
],
|
||||
@@ -688,7 +688,7 @@ def schema_infer(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_infer(
|
||||
note_type=entity_type,
|
||||
note_type=note_type,
|
||||
threshold=threshold,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
@@ -711,7 +711,7 @@ def schema_infer(
|
||||
|
||||
@tool_app.command("schema-diff")
|
||||
def schema_diff(
|
||||
entity_type: Annotated[
|
||||
note_type: Annotated[
|
||||
str,
|
||||
typer.Argument(help="Note type to check for drift"),
|
||||
],
|
||||
@@ -741,7 +741,7 @@ def schema_diff(
|
||||
with force_routing(local=local, cloud=cloud):
|
||||
result = run_with_cleanup(
|
||||
mcp_schema_diff(
|
||||
note_type=entity_type,
|
||||
note_type=note_type,
|
||||
project=project,
|
||||
workspace=workspace,
|
||||
output_format="json",
|
||||
|
||||
@@ -255,8 +255,8 @@ class EntityParser:
|
||||
else:
|
||||
metadata["title"] = title
|
||||
|
||||
entity_type = metadata.get("type")
|
||||
metadata["type"] = entity_type if entity_type is not None else "note"
|
||||
note_type = metadata.get("type")
|
||||
metadata["type"] = note_type if note_type is not None else "note"
|
||||
|
||||
tags = parse_tags(metadata.get("tags", [])) # pyright: ignore
|
||||
if tags:
|
||||
|
||||
@@ -50,7 +50,7 @@ def entity_model_from_markdown(
|
||||
|
||||
# Update basic fields
|
||||
model.title = markdown.frontmatter.title
|
||||
model.entity_type = markdown.frontmatter.type
|
||||
model.note_type = markdown.frontmatter.type
|
||||
# Only update permalink if it exists in frontmatter, otherwise preserve existing
|
||||
if markdown.frontmatter.permalink is not None:
|
||||
model.permalink = markdown.frontmatter.permalink
|
||||
@@ -86,7 +86,7 @@ async def schema_to_markdown(schema: Any) -> Post:
|
||||
Convert schema to markdown Post object.
|
||||
|
||||
Args:
|
||||
schema: Schema to convert (must have title, entity_type, and permalink attributes)
|
||||
schema: Schema to convert (must have title, note_type, and permalink attributes)
|
||||
|
||||
Returns:
|
||||
Post object with frontmatter metadata
|
||||
@@ -113,7 +113,7 @@ async def schema_to_markdown(schema: Any) -> Post:
|
||||
post = Post(
|
||||
content,
|
||||
title=schema.title,
|
||||
type=schema.entity_type,
|
||||
type=schema.note_type,
|
||||
)
|
||||
# set the permalink if passed in
|
||||
if schema.permalink:
|
||||
|
||||
@@ -24,7 +24,7 @@ class SchemaClient:
|
||||
Usage:
|
||||
async with get_client() as http_client:
|
||||
client = SchemaClient(http_client, project_id)
|
||||
report = await client.validate(entity_type="Person")
|
||||
report = await client.validate(note_type="person")
|
||||
"""
|
||||
|
||||
def __init__(self, http_client: AsyncClient, project_id: str):
|
||||
@@ -41,13 +41,13 @@ class SchemaClient:
|
||||
async def validate(
|
||||
self,
|
||||
*,
|
||||
entity_type: str | None = None,
|
||||
note_type: str | None = None,
|
||||
identifier: str | None = None,
|
||||
) -> ValidationReport:
|
||||
"""Validate notes against their resolved schemas.
|
||||
|
||||
Args:
|
||||
entity_type: Optional entity type to batch-validate
|
||||
note_type: Optional note type to batch-validate
|
||||
identifier: Optional specific note to validate
|
||||
|
||||
Returns:
|
||||
@@ -57,8 +57,8 @@ class SchemaClient:
|
||||
ToolError: If the request fails
|
||||
"""
|
||||
params: dict[str, str] = {}
|
||||
if entity_type:
|
||||
params["entity_type"] = entity_type
|
||||
if note_type:
|
||||
params["note_type"] = note_type
|
||||
if identifier:
|
||||
params["identifier"] = identifier
|
||||
|
||||
@@ -71,14 +71,14 @@ class SchemaClient:
|
||||
|
||||
async def infer(
|
||||
self,
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
*,
|
||||
threshold: float = 0.25,
|
||||
) -> InferenceReport:
|
||||
"""Infer a schema from existing notes of a given type.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to analyze
|
||||
note_type: The note type to analyze
|
||||
threshold: Minimum frequency for optional fields (0-1)
|
||||
|
||||
Returns:
|
||||
@@ -90,15 +90,15 @@ class SchemaClient:
|
||||
response = await call_post(
|
||||
self.http_client,
|
||||
f"{self._base_path}/infer",
|
||||
params={"entity_type": entity_type, "threshold": threshold},
|
||||
params={"note_type": note_type, "threshold": threshold},
|
||||
)
|
||||
return InferenceReport.model_validate(response.json())
|
||||
|
||||
async def diff(self, entity_type: str) -> DriftReport:
|
||||
async def diff(self, note_type: str) -> DriftReport:
|
||||
"""Show drift between schema definition and actual usage.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type to check for drift
|
||||
note_type: The note type to check for drift
|
||||
|
||||
Returns:
|
||||
DriftReport with detected differences
|
||||
@@ -108,6 +108,6 @@ class SchemaClient:
|
||||
"""
|
||||
response = await call_get(
|
||||
self.http_client,
|
||||
f"{self._base_path}/diff/{entity_type}",
|
||||
f"{self._base_path}/diff/{note_type}",
|
||||
)
|
||||
return DriftReport.model_validate(response.json())
|
||||
|
||||
@@ -128,7 +128,7 @@ async def schema_validate(
|
||||
|
||||
schema_client = SchemaClient(client, active_project.external_id)
|
||||
result = await schema_client.validate(
|
||||
entity_type=note_type,
|
||||
note_type=note_type,
|
||||
identifier=identifier,
|
||||
)
|
||||
|
||||
@@ -285,7 +285,7 @@ async def schema_infer(
|
||||
f"Error inferring schema for type '{note_type}': {e}\n\n"
|
||||
f"## Troubleshooting\n"
|
||||
f"1. Ensure notes of type '{note_type}' exist in the project\n"
|
||||
f'2. Try searching: `search_notes("{note_type}", types=["{note_type}"])`\n'
|
||||
f'2. Try searching: `search_notes("{note_type}", note_types=["{note_type}"])`\n'
|
||||
f"3. Verify the project has been synced: `basic-memory status`\n"
|
||||
)
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def _format_search_error_response(
|
||||
## Alternative search strategies:
|
||||
- Break into simpler terms: `search_notes("{project}", "{" ".join(clean_query.split()[:2])}")`
|
||||
- Try different search types: `search_notes("{project}","{clean_query}", search_type="title")`
|
||||
- Use filtering: `search_notes("{project}","{clean_query}", types=["entity"])`
|
||||
- Use filtering: `search_notes("{project}","{clean_query}", note_types=["note"])`
|
||||
""").strip()
|
||||
|
||||
# Project not found errors (check before general "not found")
|
||||
@@ -164,7 +164,7 @@ def _format_search_error_response(
|
||||
- Remove restrictive terms: Focus on the most important keywords
|
||||
|
||||
5. **Use filtering to narrow scope**:
|
||||
- By content type: `search_notes("{project}","{query}", types=["entity"])`
|
||||
- By content type: `search_notes("{project}","{query}", note_types=["note"])`
|
||||
- By recent content: `search_notes("{project}","{query}", after_date="1 week")`
|
||||
- By entity type: `search_notes("{project}","{query}", entity_types=["observation"])`
|
||||
|
||||
@@ -233,7 +233,7 @@ Error searching for '{query}': {error_message}
|
||||
- **Different search types**:
|
||||
- Title only: `search_notes("{project}","{query}", search_type="title")`
|
||||
- Permalink patterns: `search_notes("{project}","{query}*", search_type="permalink")`
|
||||
- **With filters**: `search_notes("{project}","{query}", types=["entity"])`
|
||||
- **With filters**: `search_notes("{project}","{query}", note_types=["note"])`
|
||||
- **Recent content**: `search_notes("{project}","{query}", after_date="1 week")`
|
||||
- **Boolean variations**: `search_notes("{project}","{" OR ".join(query.split()[:2])}")`
|
||||
|
||||
@@ -264,7 +264,7 @@ async def search_notes(
|
||||
page_size: int = 10,
|
||||
search_type: str | None = None,
|
||||
output_format: Literal["text", "json"] = "text",
|
||||
types: List[str] | None = None,
|
||||
note_types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
@@ -309,8 +309,8 @@ async def search_notes(
|
||||
text when disabled)
|
||||
|
||||
### Filtering Options
|
||||
- `search_notes("my-project", "query", types=["entity"])` - Search only entities
|
||||
- `search_notes("work-docs", "query", types=["note", "person"])` - Multiple content types
|
||||
- `search_notes("my-project", "query", note_types=["note"])` - Search only notes
|
||||
- `search_notes("work-docs", "query", note_types=["note", "person"])` - Multiple note types
|
||||
- `search_notes("research", "query", entity_types=["observation"])` - Filter by entity type
|
||||
- `search_notes("team-docs", "query", after_date="2024-01-01")` - Recent content only
|
||||
- `search_notes("my-project", "query", after_date="1 week")` - Relative date filtering
|
||||
@@ -353,7 +353,7 @@ async def search_notes(
|
||||
Default is dynamic: "hybrid" when semantic search is enabled, otherwise "text".
|
||||
output_format: "text" preserves existing structured search response behavior.
|
||||
"json" returns a machine-readable dictionary payload.
|
||||
types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
note_types: Optional list of note types to search (e.g., ["note", "person"])
|
||||
entity_types: Optional list of entity types to filter by (e.g., ["entity", "observation"])
|
||||
after_date: Optional date filter for recent content (e.g., "1 week", "2d", "2024-01-01")
|
||||
metadata_filters: Optional structured frontmatter filters (e.g., {"status": "in-progress"})
|
||||
@@ -387,10 +387,10 @@ async def search_notes(
|
||||
# Exact phrase search
|
||||
results = await search_notes("\"weekly standup meeting\"")
|
||||
|
||||
# Search with type filter
|
||||
# Search with note type filter
|
||||
results = await search_notes(
|
||||
"meeting notes",
|
||||
types=["entity"],
|
||||
note_types=["note"],
|
||||
)
|
||||
|
||||
# Search with entity type filter
|
||||
@@ -420,7 +420,7 @@ async def search_notes(
|
||||
# Complex search with multiple filters
|
||||
results = await search_notes(
|
||||
"(bug OR issue) AND NOT resolved",
|
||||
types=["entity"],
|
||||
note_types=["note"],
|
||||
after_date="2024-01-01"
|
||||
)
|
||||
|
||||
@@ -428,7 +428,7 @@ async def search_notes(
|
||||
results = await search_notes("project planning", project="my-project")
|
||||
"""
|
||||
# Avoid mutable-default-argument footguns. Treat None as "no filter".
|
||||
types = types or []
|
||||
note_types = note_types or []
|
||||
entity_types = entity_types or []
|
||||
|
||||
# Detect project from memory URL prefix before routing
|
||||
@@ -477,8 +477,8 @@ async def search_notes(
|
||||
# Add optional filters if provided (empty lists are treated as no filter)
|
||||
if entity_types:
|
||||
search_query.entity_types = [SearchItemType(t) for t in entity_types]
|
||||
if types:
|
||||
search_query.types = types
|
||||
if note_types:
|
||||
search_query.note_types = note_types
|
||||
if after_date:
|
||||
search_query.after_date = after_date
|
||||
if metadata_filters:
|
||||
|
||||
@@ -28,7 +28,7 @@ async def search_notes_ui(
|
||||
page: int = 1,
|
||||
page_size: int = 10,
|
||||
search_type: Optional[str] = None,
|
||||
types: List[str] | None = None,
|
||||
note_types: List[str] | None = None,
|
||||
entity_types: List[str] | None = None,
|
||||
after_date: Optional[str] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
@@ -44,7 +44,7 @@ async def search_notes_ui(
|
||||
page_size=page_size,
|
||||
search_type=search_type,
|
||||
output_format="json",
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
entity_types=entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
|
||||
@@ -174,7 +174,7 @@ async def write_note(
|
||||
entity = Entity(
|
||||
title=title,
|
||||
directory=directory,
|
||||
entity_type=note_type,
|
||||
note_type=note_type,
|
||||
content_type="text/markdown",
|
||||
content=content,
|
||||
entity_metadata=entity_metadata or None,
|
||||
|
||||
@@ -37,7 +37,7 @@ class Entity(Base):
|
||||
__tablename__ = "entity"
|
||||
__table_args__ = (
|
||||
# Regular indexes
|
||||
Index("ix_entity_type", "entity_type"),
|
||||
Index("ix_note_type", "note_type"),
|
||||
Index("ix_entity_title", "title"),
|
||||
Index("ix_entity_external_id", "external_id", unique=True),
|
||||
Index("ix_entity_created_at", "created_at"), # For timeline queries
|
||||
@@ -64,7 +64,7 @@ class Entity(Base):
|
||||
# External UUID for API references - stable identifier that won't change
|
||||
external_id: Mapped[str] = mapped_column(String, unique=True, default=lambda: str(uuid.uuid4()))
|
||||
title: Mapped[str] = mapped_column(String)
|
||||
entity_type: Mapped[str] = mapped_column(String)
|
||||
note_type: Mapped[str] = mapped_column(String)
|
||||
entity_metadata: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
|
||||
content_type: Mapped[str] = mapped_column(String)
|
||||
|
||||
@@ -133,7 +133,7 @@ class Entity(Base):
|
||||
return value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.entity_type}', checksum='{self.checksum}')"
|
||||
return f"Entity(id={self.id}, external_id='{self.external_id}', name='{self.title}', type='{self.note_type}', checksum='{self.checksum}')"
|
||||
|
||||
|
||||
class Observation(Base):
|
||||
|
||||
@@ -603,7 +603,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
@@ -619,7 +619,7 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -682,14 +682,14 @@ class PostgresSearchRepository(SearchRepositoryBase):
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"search_index.type IN ({type_list})")
|
||||
|
||||
# Handle entity type filter using JSONB containment
|
||||
if types:
|
||||
# Handle note type filter using JSONB containment (frontmatter type field)
|
||||
if note_types:
|
||||
# Use JSONB @> operator for efficient containment queries
|
||||
type_conditions = []
|
||||
for entity_type in types:
|
||||
# Create JSONB containment condition for each type
|
||||
for note_type in note_types:
|
||||
# Create JSONB containment condition for each note type
|
||||
type_conditions.append(
|
||||
f'search_index.metadata @> \'{{"entity_type": "{entity_type}"}}\''
|
||||
f'search_index.metadata @> \'{{"note_type": "{note_type}"}}\''
|
||||
)
|
||||
conditions.append(f"({' OR '.join(type_conditions)})")
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class SearchRepository(Protocol):
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
|
||||
@@ -108,7 +108,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[Dict[str, Any]] = None,
|
||||
@@ -124,7 +124,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink: Exact permalink match
|
||||
permalink_match: Permalink pattern match (supports *)
|
||||
title: Title search
|
||||
types: Filter by entity types (from metadata.entity_type)
|
||||
note_types: Filter by note types (from metadata.note_type)
|
||||
after_date: Filter by created_at > after_date
|
||||
search_item_types: Filter by SearchItemType (ENTITY, OBSERVATION, RELATION)
|
||||
metadata_filters: Structured frontmatter metadata filters
|
||||
@@ -761,7 +761,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink: Optional[str],
|
||||
permalink_match: Optional[str],
|
||||
title: Optional[str],
|
||||
types: Optional[List[str]],
|
||||
note_types: Optional[List[str]],
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
@@ -794,7 +794,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -813,7 +813,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -842,7 +842,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink: Optional[str],
|
||||
permalink_match: Optional[str],
|
||||
title: Optional[str],
|
||||
types: Optional[List[str]],
|
||||
note_types: Optional[List[str]],
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
@@ -911,7 +911,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink,
|
||||
permalink_match,
|
||||
title,
|
||||
types,
|
||||
note_types,
|
||||
after_date,
|
||||
search_item_types,
|
||||
metadata_filters,
|
||||
@@ -924,7 +924,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -1054,7 +1054,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink: Optional[str],
|
||||
permalink_match: Optional[str],
|
||||
title: Optional[str],
|
||||
types: Optional[List[str]],
|
||||
note_types: Optional[List[str]],
|
||||
after_date: Optional[datetime],
|
||||
search_item_types: Optional[List[SearchItemType]],
|
||||
metadata_filters: Optional[dict],
|
||||
@@ -1074,7 +1074,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -1087,7 +1087,7 @@ class SearchRepositoryBase(ABC):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
|
||||
@@ -588,7 +588,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
permalink: Optional[str] = None,
|
||||
permalink_match: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
types: Optional[List[str]] = None,
|
||||
note_types: Optional[List[str]] = None,
|
||||
after_date: Optional[datetime] = None,
|
||||
search_item_types: Optional[List[SearchItemType]] = None,
|
||||
metadata_filters: Optional[dict] = None,
|
||||
@@ -604,7 +604,7 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
permalink=permalink,
|
||||
permalink_match=permalink_match,
|
||||
title=title,
|
||||
types=types,
|
||||
note_types=note_types,
|
||||
after_date=after_date,
|
||||
search_item_types=search_item_types,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -671,11 +671,11 @@ class SQLiteSearchRepository(SearchRepositoryBase):
|
||||
type_list = ", ".join(f"'{t.value}'" for t in search_item_types)
|
||||
conditions.append(f"search_index.type IN ({type_list})")
|
||||
|
||||
# Handle type filter
|
||||
if types:
|
||||
type_list = ", ".join(f"'{t}'" for t in types)
|
||||
# Handle note type filter (frontmatter type field)
|
||||
if note_types:
|
||||
type_list = ", ".join(f"'{t}'" for t in note_types)
|
||||
conditions.append(
|
||||
f"json_extract(search_index.metadata, '$.entity_type') IN ({type_list})"
|
||||
f"json_extract(search_index.metadata, '$.note_type') IN ({type_list})"
|
||||
)
|
||||
|
||||
# Handle date filter using datetime() for proper comparison
|
||||
|
||||
@@ -30,14 +30,14 @@ class FieldFrequency:
|
||||
percentage: float
|
||||
sample_values: list[str] = field(default_factory=list)
|
||||
is_array: bool = False # True if typically appears multiple times per note
|
||||
target_type: str | None = None # For relations, the most common target entity type
|
||||
target_type: str | None = None # For relations, the most common target note type
|
||||
|
||||
|
||||
@dataclass
|
||||
class InferenceResult:
|
||||
"""Complete inference result with frequency analysis and suggested schema."""
|
||||
|
||||
entity_type: str
|
||||
note_type: str
|
||||
notes_analyzed: int
|
||||
field_frequencies: list[FieldFrequency]
|
||||
suggested_schema: dict # Ready-to-use Picoschema YAML dict
|
||||
@@ -65,7 +65,7 @@ class RelationData:
|
||||
|
||||
relation_type: str
|
||||
target_name: str
|
||||
target_entity_type: str | None = None
|
||||
target_note_type: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -85,7 +85,7 @@ class NoteData:
|
||||
|
||||
|
||||
def infer_schema(
|
||||
entity_type: str,
|
||||
note_type: str,
|
||||
notes: list[NoteData],
|
||||
required_threshold: float = 0.95,
|
||||
optional_threshold: float = 0.25,
|
||||
@@ -98,7 +98,7 @@ def infer_schema(
|
||||
appear less frequently become optional.
|
||||
|
||||
Args:
|
||||
entity_type: The entity type being analyzed (e.g., "Person").
|
||||
note_type: The note type being analyzed (e.g., "person").
|
||||
notes: List of NoteData objects to analyze.
|
||||
required_threshold: Frequency at or above which a field is required (default 0.95).
|
||||
optional_threshold: Frequency at or above which a field is optional (default 0.25).
|
||||
@@ -110,7 +110,7 @@ def infer_schema(
|
||||
total = len(notes)
|
||||
if total == 0:
|
||||
return InferenceResult(
|
||||
entity_type=entity_type,
|
||||
note_type=note_type,
|
||||
notes_analyzed=0,
|
||||
field_frequencies=[],
|
||||
suggested_schema={},
|
||||
@@ -145,7 +145,7 @@ def infer_schema(
|
||||
)
|
||||
|
||||
return InferenceResult(
|
||||
entity_type=entity_type,
|
||||
note_type=note_type,
|
||||
notes_analyzed=total,
|
||||
field_frequencies=all_frequencies,
|
||||
suggested_schema=suggested_schema,
|
||||
@@ -255,8 +255,8 @@ def analyze_relations(
|
||||
# Track target entity types from individual relations (not the source note)
|
||||
target_counter = rel_target_types.setdefault(rel_type, Counter())
|
||||
for rel in note_rel_objects[rel_type]:
|
||||
if rel.target_entity_type:
|
||||
target_counter[rel.target_entity_type] += 1
|
||||
if rel.target_note_type:
|
||||
target_counter[rel.target_note_type] += 1
|
||||
|
||||
frequencies: list[FieldFrequency] = []
|
||||
for rel_type, count in rel_note_count.most_common():
|
||||
|
||||
@@ -8,7 +8,7 @@ import everything from basic_memory.schemas.
|
||||
# Base types and models
|
||||
from basic_memory.schemas.base import (
|
||||
Observation,
|
||||
EntityType,
|
||||
NoteType,
|
||||
RelationType,
|
||||
Relation,
|
||||
Entity,
|
||||
@@ -56,7 +56,7 @@ from basic_memory.schemas.sync_report import (
|
||||
__all__ = [
|
||||
# Base
|
||||
"Observation",
|
||||
"EntityType",
|
||||
"NoteType",
|
||||
"RelationType",
|
||||
"Relation",
|
||||
"Entity",
|
||||
|
||||
@@ -156,8 +156,8 @@ Permalink = Annotated[str, MinLen(1)]
|
||||
"""Unique identifier in format '{path}/{normalized_name}'."""
|
||||
|
||||
|
||||
EntityType = Annotated[str, BeforeValidator(to_snake_case), MinLen(1), MaxLen(200)]
|
||||
"""Classification of entity (e.g., 'person', 'project', 'concept'). """
|
||||
NoteType = Annotated[str, BeforeValidator(to_snake_case), MinLen(1), MaxLen(200)]
|
||||
"""Classification of note (e.g., 'note', 'person', 'spec', 'schema'). """
|
||||
|
||||
ALLOWED_CONTENT_TYPES = {
|
||||
"text/markdown",
|
||||
@@ -228,7 +228,7 @@ class Entity(BaseModel):
|
||||
title: str
|
||||
content: Optional[str] = None
|
||||
directory: str
|
||||
entity_type: EntityType = "note"
|
||||
note_type: NoteType = "note"
|
||||
entity_metadata: Optional[Dict] = Field(default=None, description="Optional metadata")
|
||||
content_type: ContentType = Field(
|
||||
description="MIME type of the content (e.g. text/markdown, image/jpeg)",
|
||||
|
||||
@@ -18,7 +18,7 @@ class DirectoryNode(BaseModel):
|
||||
permalink: Optional[str] = None
|
||||
external_id: Optional[str] = None # UUID (primary API identifier for v2)
|
||||
entity_id: Optional[int] = None # Internal numeric ID
|
||||
entity_type: Optional[str] = None
|
||||
note_type: Optional[str] = None
|
||||
content_type: Optional[str] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
@@ -22,8 +22,8 @@ class ProjectStatistics(BaseModel):
|
||||
)
|
||||
|
||||
# Entity counts by type
|
||||
entity_types: Dict[str, int] = Field(
|
||||
description="Count of entities by type (e.g., note, conversation)"
|
||||
note_types: Dict[str, int] = Field(
|
||||
description="Count of entities by note type (e.g., note, conversation)"
|
||||
)
|
||||
|
||||
# Observation counts by category
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import List, Optional, Dict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from basic_memory.schemas.base import Relation, Permalink, EntityType, ContentType, Observation
|
||||
from basic_memory.schemas.base import Relation, Permalink, NoteType, ContentType, Observation
|
||||
|
||||
|
||||
class SQLAlchemyModel(BaseModel):
|
||||
@@ -162,7 +162,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
{
|
||||
"permalink": "component/memory-service",
|
||||
"file_path": "MemoryService",
|
||||
"entity_type": "component",
|
||||
"note_type": "component",
|
||||
"entity_metadata": {}
|
||||
"content_type: "text/markdown"
|
||||
"observations": [
|
||||
@@ -191,7 +191,7 @@ class EntityResponse(SQLAlchemyModel):
|
||||
permalink: Optional[Permalink]
|
||||
title: str
|
||||
file_path: str
|
||||
entity_type: EntityType
|
||||
note_type: NoteType
|
||||
entity_metadata: Optional[Dict] = None
|
||||
checksum: Optional[str] = None
|
||||
content_type: ContentType
|
||||
@@ -215,7 +215,7 @@ class EntityListResponse(SQLAlchemyModel):
|
||||
{
|
||||
"permalink": "component/search_service",
|
||||
"title": "SearchService",
|
||||
"entity_type": "component",
|
||||
"note_type": "component",
|
||||
"description": "Knowledge graph search",
|
||||
"observations": [
|
||||
{
|
||||
@@ -227,7 +227,7 @@ class EntityListResponse(SQLAlchemyModel):
|
||||
{
|
||||
"permalink": "document/api_docs",
|
||||
"title": "API_Documentation",
|
||||
"entity_type": "document",
|
||||
"note_type": "document",
|
||||
"description": "API Reference",
|
||||
"observations": [
|
||||
{
|
||||
@@ -255,7 +255,7 @@ class SearchNodesResponse(SQLAlchemyModel):
|
||||
{
|
||||
"permalink": "component/memory-service",
|
||||
"title": "MemoryService",
|
||||
"entity_type": "component",
|
||||
"note_type": "component",
|
||||
"description": "Core service",
|
||||
"observations": [...],
|
||||
"relations": [...]
|
||||
|
||||
@@ -45,7 +45,7 @@ class NoteValidationResponse(BaseModel):
|
||||
class ValidationReport(BaseModel):
|
||||
"""Full validation report for one or more notes."""
|
||||
|
||||
entity_type: str | None = None
|
||||
note_type: str | None = None
|
||||
total_notes: int = 0
|
||||
total_entities: int = 0
|
||||
valid_count: int = 0
|
||||
@@ -72,14 +72,14 @@ class FieldFrequencyResponse(BaseModel):
|
||||
)
|
||||
target_type: str | None = Field(
|
||||
default=None,
|
||||
description="For relations, the most common target entity type",
|
||||
description="For relations, the most common target note type",
|
||||
)
|
||||
|
||||
|
||||
class InferenceReport(BaseModel):
|
||||
"""Inference result with suggested schema definition."""
|
||||
|
||||
entity_type: str
|
||||
note_type: str
|
||||
notes_analyzed: int
|
||||
field_frequencies: list[FieldFrequencyResponse] = Field(default_factory=list)
|
||||
suggested_schema: dict = Field(
|
||||
@@ -110,7 +110,7 @@ class DriftFieldResponse(BaseModel):
|
||||
class DriftReport(BaseModel):
|
||||
"""Schema drift analysis comparing schema definition to actual usage."""
|
||||
|
||||
entity_type: str
|
||||
note_type: str
|
||||
schema_found: bool = Field(
|
||||
default=True,
|
||||
description="Whether a schema was found for this type",
|
||||
|
||||
@@ -40,7 +40,7 @@ class SearchQuery(BaseModel):
|
||||
- title: Title only search
|
||||
|
||||
Optionally filter results by:
|
||||
- types: Limit to specific entity types (frontmatter "type")
|
||||
- note_types: Limit to specific note types (frontmatter "type")
|
||||
- entity_types: Limit to search item types (entity/observation/relation)
|
||||
- after_date: Only items after date
|
||||
- metadata_filters: Structured frontmatter filters (field -> value)
|
||||
@@ -61,7 +61,7 @@ class SearchQuery(BaseModel):
|
||||
title: Optional[str] = None # title only search
|
||||
|
||||
# Optional filters
|
||||
types: Optional[List[str]] = None # Filter by type
|
||||
note_types: Optional[List[str]] = None # Filter by note type (frontmatter "type")
|
||||
entity_types: Optional[List[SearchItemType]] = None # Filter by entity type
|
||||
after_date: Optional[Union[datetime, str]] = None # Time-based filter
|
||||
metadata_filters: Optional[dict[str, Any]] = None # Structured frontmatter filters
|
||||
@@ -83,7 +83,7 @@ class SearchQuery(BaseModel):
|
||||
metadata_is_empty = not self.metadata_filters
|
||||
tags_is_empty = not self.tags
|
||||
status_is_empty = self.status is None or (isinstance(self.status, str) and not self.status)
|
||||
types_is_empty = not self.types
|
||||
note_types_is_empty = not self.note_types
|
||||
entity_types_is_empty = not self.entity_types
|
||||
return (
|
||||
self.permalink is None
|
||||
@@ -91,7 +91,7 @@ class SearchQuery(BaseModel):
|
||||
and self.title is None
|
||||
and text_is_empty
|
||||
and self.after_date is None
|
||||
and types_is_empty
|
||||
and note_types_is_empty
|
||||
and entity_types_is_empty
|
||||
and metadata_is_empty
|
||||
and tags_is_empty
|
||||
|
||||
@@ -118,7 +118,7 @@ class EntityResponseV2(BaseModel):
|
||||
|
||||
# Core entity fields
|
||||
title: str = Field(..., description="Entity title")
|
||||
entity_type: str = Field(..., description="Entity type")
|
||||
note_type: str = Field(..., description="Note type (from frontmatter 'type' field)")
|
||||
content_type: str = Field(default="text/markdown", description="Content MIME type")
|
||||
|
||||
# Secondary identifiers (for compatibility and convenience)
|
||||
|
||||
@@ -89,7 +89,7 @@ class DirectoryService:
|
||||
permalink=file.permalink,
|
||||
external_id=file.external_id, # UUID for v2 API
|
||||
entity_id=file.id,
|
||||
entity_type=file.entity_type,
|
||||
note_type=file.note_type,
|
||||
content_type=file.content_type,
|
||||
updated_at=_mtime_to_datetime(file),
|
||||
)
|
||||
@@ -254,7 +254,7 @@ class DirectoryService:
|
||||
permalink=file.permalink,
|
||||
external_id=file.external_id, # UUID for v2 API
|
||||
entity_id=file.id,
|
||||
entity_type=file.entity_type,
|
||||
note_type=file.note_type,
|
||||
content_type=file.content_type,
|
||||
updated_at=_mtime_to_datetime(file),
|
||||
)
|
||||
|
||||
@@ -206,14 +206,14 @@ class EntityService(BaseService[EntityModel]):
|
||||
return self._project_permalink
|
||||
|
||||
def _build_frontmatter_markdown(
|
||||
self, title: str, entity_type: str, permalink: str
|
||||
self, title: str, note_type: str, permalink: str
|
||||
) -> EntityMarkdown:
|
||||
"""Build a minimal EntityMarkdown object for permalink resolution."""
|
||||
from basic_memory.markdown.schemas import EntityFrontmatter
|
||||
|
||||
frontmatter_metadata = {
|
||||
"title": title,
|
||||
"type": entity_type,
|
||||
"type": note_type,
|
||||
"permalink": permalink,
|
||||
}
|
||||
frontmatter_obj = EntityFrontmatter(metadata=frontmatter_metadata)
|
||||
@@ -257,18 +257,18 @@ class EntityService(BaseService[EntityModel]):
|
||||
f"file for entity {schema.directory}/{schema.title} already exists: {file_path}"
|
||||
)
|
||||
|
||||
# Parse content frontmatter to check for user-specified permalink and entity_type
|
||||
# Parse content frontmatter to check for user-specified permalink and note_type
|
||||
content_markdown = None
|
||||
if schema.content and has_frontmatter(schema.content):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
|
||||
# If content has entity_type/type, use it to override the schema entity_type
|
||||
# If content has type, use it to override the schema note_type
|
||||
if "type" in content_frontmatter:
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
schema.note_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
schema.title, schema.note_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# Get unique permalink (prioritizing content frontmatter) unless disabled
|
||||
@@ -315,18 +315,18 @@ class EntityService(BaseService[EntityModel]):
|
||||
content=existing_content,
|
||||
)
|
||||
|
||||
# Parse content frontmatter to check for user-specified permalink and entity_type
|
||||
# Parse content frontmatter to check for user-specified permalink and note_type
|
||||
content_markdown = None
|
||||
if schema.content and has_frontmatter(schema.content):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
|
||||
# If content has entity_type/type, use it to override the schema entity_type
|
||||
# If content has type, use it to override the schema note_type
|
||||
if "type" in content_frontmatter:
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
schema.note_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
schema.title, schema.note_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# Check if we need to update the permalink based on content frontmatter (unless disabled)
|
||||
@@ -406,11 +406,11 @@ class EntityService(BaseService[EntityModel]):
|
||||
content_frontmatter = parse_frontmatter(schema.content)
|
||||
|
||||
if "type" in content_frontmatter:
|
||||
schema.entity_type = content_frontmatter["type"]
|
||||
schema.note_type = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
schema.title, schema.entity_type, content_frontmatter["permalink"]
|
||||
schema.title, schema.note_type, content_frontmatter["permalink"]
|
||||
)
|
||||
|
||||
# --- Permalink Resolution ---
|
||||
@@ -436,7 +436,7 @@ class EntityService(BaseService[EntityModel]):
|
||||
entity_metadata = {k: v for k, v in metadata.items() if v is not None}
|
||||
update_data = {
|
||||
"title": schema.title,
|
||||
"entity_type": schema.entity_type,
|
||||
"note_type": schema.note_type,
|
||||
"file_path": file_path.as_posix(),
|
||||
"content_type": schema.content_type,
|
||||
"entity_metadata": entity_metadata or None,
|
||||
@@ -488,12 +488,12 @@ class EntityService(BaseService[EntityModel]):
|
||||
if "title" in content_frontmatter:
|
||||
update_data["title"] = content_frontmatter["title"]
|
||||
if "type" in content_frontmatter:
|
||||
update_data["entity_type"] = content_frontmatter["type"]
|
||||
update_data["note_type"] = content_frontmatter["type"]
|
||||
|
||||
if "permalink" in content_frontmatter:
|
||||
content_markdown = self._build_frontmatter_markdown(
|
||||
update_data.get("title", entity.title),
|
||||
update_data.get("entity_type", entity.entity_type),
|
||||
update_data.get("note_type", entity.note_type),
|
||||
content_frontmatter["permalink"],
|
||||
)
|
||||
|
||||
|
||||
@@ -692,14 +692,14 @@ class ProjectService:
|
||||
)
|
||||
total_unresolved = unresolved_count_result.scalar() or 0
|
||||
|
||||
# Get entity counts by type
|
||||
entity_types_result = await self.repository.execute_query(
|
||||
# Get entity counts by note type
|
||||
note_types_result = await self.repository.execute_query(
|
||||
text(
|
||||
"SELECT entity_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY entity_type"
|
||||
"SELECT note_type, COUNT(*) FROM entity WHERE project_id = :project_id GROUP BY note_type"
|
||||
),
|
||||
{"project_id": project_id},
|
||||
)
|
||||
entity_types = {row[0]: row[1] for row in entity_types_result.fetchall()}
|
||||
note_types = {row[0]: row[1] for row in note_types_result.fetchall()}
|
||||
|
||||
# Get observation counts by category
|
||||
category_result = await self.repository.execute_query(
|
||||
@@ -761,7 +761,7 @@ class ProjectService:
|
||||
total_observations=total_observations,
|
||||
total_relations=total_relations,
|
||||
total_unresolved_relations=total_unresolved,
|
||||
entity_types=entity_types,
|
||||
note_types=note_types,
|
||||
observation_categories=observation_categories,
|
||||
relation_types=relation_types,
|
||||
most_connected_entities=most_connected,
|
||||
@@ -780,7 +780,7 @@ class ProjectService:
|
||||
# Get recently created entities (project filtered)
|
||||
created_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, created_at, file_path
|
||||
SELECT id, title, permalink, note_type, created_at, file_path
|
||||
FROM entity
|
||||
WHERE project_id = :project_id
|
||||
ORDER BY created_at DESC
|
||||
@@ -793,7 +793,7 @@ class ProjectService:
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"permalink": row[2],
|
||||
"entity_type": row[3],
|
||||
"note_type": row[3],
|
||||
"created_at": row[4],
|
||||
"file_path": row[5],
|
||||
}
|
||||
@@ -803,7 +803,7 @@ class ProjectService:
|
||||
# Get recently updated entities (project filtered)
|
||||
updated_result = await self.repository.execute_query(
|
||||
text("""
|
||||
SELECT id, title, permalink, entity_type, updated_at, file_path
|
||||
SELECT id, title, permalink, note_type, updated_at, file_path
|
||||
FROM entity
|
||||
WHERE project_id = :project_id
|
||||
ORDER BY updated_at DESC
|
||||
@@ -816,7 +816,7 @@ class ProjectService:
|
||||
"id": row[0],
|
||||
"title": row[1],
|
||||
"permalink": row[2],
|
||||
"entity_type": row[3],
|
||||
"note_type": row[3],
|
||||
"updated_at": row[4],
|
||||
"file_path": row[5],
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ class SearchService:
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
types=query.types,
|
||||
note_types=query.note_types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -210,7 +210,7 @@ class SearchService:
|
||||
permalink=query.permalink,
|
||||
permalink_match=query.permalink_match,
|
||||
title=query.title,
|
||||
types=query.types,
|
||||
note_types=query.note_types,
|
||||
search_item_types=query.entity_types,
|
||||
after_date=after_date,
|
||||
metadata_filters=metadata_filters,
|
||||
@@ -415,7 +415,7 @@ class SearchService:
|
||||
permalink=entity.permalink, # Required for Postgres NOT NULL constraint
|
||||
file_path=entity.file_path,
|
||||
metadata={
|
||||
"entity_type": entity.entity_type,
|
||||
"note_type": entity.note_type,
|
||||
},
|
||||
created_at=entity.created_at,
|
||||
updated_at=_mtime_to_datetime(entity),
|
||||
@@ -500,7 +500,7 @@ class SearchService:
|
||||
file_path=entity.file_path,
|
||||
entity_id=entity.id,
|
||||
metadata={
|
||||
"entity_type": entity.entity_type,
|
||||
"note_type": entity.note_type,
|
||||
},
|
||||
created_at=entity.created_at,
|
||||
updated_at=_mtime_to_datetime(entity),
|
||||
|
||||
@@ -761,7 +761,7 @@ class SyncService:
|
||||
try:
|
||||
entity = await self.entity_repository.add(
|
||||
Entity(
|
||||
entity_type="file",
|
||||
note_type="file",
|
||||
file_path=path,
|
||||
checksum=checksum,
|
||||
title=file_path.name,
|
||||
|
||||
Reference in New Issue
Block a user