Files
phernandez 9b199c6dcb fix: add FastEmbed runtime tuning knobs and provider caching
Add configurable cache_dir, threads, and parallel settings for FastEmbed
to support cloud deployments where defaults fail. Cache embedding providers
at the process level to avoid re-creating heavy ONNX model instances.

- Add semantic_embedding_cache_dir, semantic_embedding_threads, and
  semantic_embedding_parallel config fields
- Thread-safe provider cache with double-checked locking in factory
- Forward runtime knobs through to TextEmbedding and embed() calls
- Fix if/elif chain in factory for correct error handling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-03-03 10:26:47 -06:00

902 lines
27 KiB
Python

"""Tests for v2 schema router endpoints.
Tests the integration layer where ORM entities are converted to NoteData
and passed through the schema engine (infer, validate, diff).
Note: EntityType uses BeforeValidator(to_snake_case) so "Person" becomes "person"
in the database. All query params must use the stored (snake_case) form.
"""
from pathlib import Path
from textwrap import dedent
import pytest
from httpx import AsyncClient
from basic_memory.models import Project
from basic_memory.schemas.base import Entity as EntitySchema
from basic_memory.services.file_service import FileService
# --- Helpers ---
async def create_person_entities(entity_service, search_service):
"""Create multiple person entities with observations and relations for testing.
Returns the created entities after indexing them for search.
"""
persons = [
EntitySchema(
title="Alice",
directory="people",
note_type="person",
content=dedent("""\
## Observations
- [name] Alice Smith
- [role] Engineer
- [expertise] Python
## Relations
- works_at [[Acme Corp]]
"""),
),
EntitySchema(
title="Bob",
directory="people",
note_type="person",
content=dedent("""\
## Observations
- [name] Bob Jones
- [role] Designer
- [expertise] UI/UX
## Relations
- works_at [[Acme Corp]]
"""),
),
EntitySchema(
title="Carol",
directory="people",
note_type="person",
content=dedent("""\
## Observations
- [name] Carol Lee
- [role] Manager
## Relations
- works_at [[Globex]]
"""),
),
]
created = []
for schema in persons:
entity, _ = await entity_service.create_or_update_entity(schema)
await search_service.index_entity(entity)
created.append(entity)
return created
# --- Infer Endpoint Tests ---
@pytest.mark.asyncio
async def test_infer_schema(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Infer a schema from person notes with observations and relations."""
await create_person_entities(entity_service, search_service)
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"note_type": "person"},
)
assert response.status_code == 200
data = response.json()
assert data["note_type"] == "person"
assert data["notes_analyzed"] == 3
assert isinstance(data["field_frequencies"], list)
assert isinstance(data["suggested_schema"], dict)
assert isinstance(data["suggested_required"], list)
assert isinstance(data["suggested_optional"], list)
# "name" and "role" appear in all 3 notes -> should be in frequencies
freq_names = [f["name"] for f in data["field_frequencies"]]
assert "name" in freq_names
assert "role" in freq_names
# "name" appears in 100% of notes -> should be suggested as required
assert "name" in data["suggested_required"]
assert "role" in data["suggested_required"]
@pytest.mark.asyncio
async def test_infer_schema_no_matching_notes(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Infer returns empty result when no notes of the given type exist."""
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"note_type": "nonexistent"},
)
assert response.status_code == 200
data = response.json()
assert data["notes_analyzed"] == 0
assert data["field_frequencies"] == []
assert data["suggested_schema"] == {}
assert data["suggested_required"] == []
assert data["suggested_optional"] == []
@pytest.mark.asyncio
async def test_infer_schema_includes_relations(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Infer detects relation types as fields alongside observations."""
await create_person_entities(entity_service, search_service)
response = await client.post(
f"{v2_project_url}/schema/infer",
params={"note_type": "person"},
)
assert response.status_code == 200
data = response.json()
# "works_at" relation appears in all 3 notes -> should be in frequencies
freq_names = [f["name"] for f in data["field_frequencies"]]
assert "works_at" in freq_names
# Check it's identified as a relation source
works_at = next(f for f in data["field_frequencies"] if f["name"] == "works_at")
assert works_at["source"] == "relation"
assert works_at["count"] == 3
# --- Validate Endpoint Tests ---
@pytest.mark.asyncio
async def test_validate_with_inline_schema(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate a person note against an inline schema in its entity_metadata."""
# Create a person entity with an inline schema in entity_metadata.
# The resolver will pick this up as resolution path 1 (inline schema).
entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Dave",
directory="people",
note_type="person",
entity_metadata={
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [name] Dave Wilson
- [role] Architect
"""),
)
)
await search_service.index_entity(entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "person"},
)
assert response.status_code == 200
data = response.json()
# Entity was found and validated
assert data["total_notes"] == 1
assert isinstance(data["results"], list)
assert len(data["results"]) == 1
result = data["results"][0]
assert result["passed"] is True
assert result["schema_entity"] == "person"
# Both "name" and "role" should be present
field_statuses = {fr["field_name"]: fr["status"] for fr in result["field_results"]}
assert field_statuses["name"] == "present"
assert field_statuses["role"] == "present"
@pytest.mark.asyncio
async def test_validate_with_explicit_schema_reference_by_permalink_slug(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate resolves explicit schema refs by exact schema identifier (non-fuzzy)."""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Strict Person V2",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "person",
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [note] Strict schema for person notes
"""),
)
)
await search_service.index_entity(schema_entity)
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Frank",
directory="people",
note_type="person",
entity_metadata={
# Explicit schema identifier does not equal entity metadata field ("person")
# and must resolve by schema identifier, not fuzzy text search.
"schema": "strict-person-v2",
},
content=dedent("""\
## Observations
- [name] Frank Nguyen
- [role] Engineer
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"identifier": note_entity.permalink},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
assert len(data["results"]) == 1
assert data["results"][0]["schema_entity"] == "person"
assert data["results"][0]["passed"] is True
@pytest.mark.asyncio
async def test_validate_missing_required_field(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate detects a missing required field and produces a warning."""
# Schema requires "name" and "role", but note only has "name"
entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Eve",
directory="people",
note_type="person",
entity_metadata={
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [name] Eve Martinez
"""),
)
)
await search_service.index_entity(entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "person"},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
# Default validation mode is "warn" so missing required field produces warning, not error
assert result["passed"] is True
assert len(result["warnings"]) > 0
assert any("role" in w for w in result["warnings"])
@pytest.mark.asyncio
async def test_validate_no_matching_notes(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Validate returns empty result when no notes of the given type exist."""
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "nonexistent"},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 0
assert data["total_entities"] == 0
assert data["results"] == []
@pytest.mark.asyncio
async def test_validate_total_entities_without_schema(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate reports total_entities even when no schema exists (total_notes == 0)."""
await create_person_entities(entity_service, search_service)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "person"},
)
assert response.status_code == 200
data = response.json()
# No schema -> no notes validated
assert data["total_notes"] == 0
# But entities of this type do exist
assert data["total_entities"] == 3
assert data["results"] == []
# --- Frontmatter Validation Tests ---
@pytest.mark.asyncio
async def test_validate_with_frontmatter_rules_passes(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Validate a note whose frontmatter matches settings.frontmatter rules."""
# Schema note with frontmatter validation rules
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Person Schema FM",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "person_fm",
"schema": {"name": "string"},
"settings": {
"validation": "warn",
"frontmatter": {
"tags?(array)": "string",
"status?(enum)": ["draft", "published"],
},
},
},
content=dedent("""\
## Observations
- [note] Schema with frontmatter rules
"""),
)
)
await search_service.index_entity(schema_entity)
# Note with matching frontmatter
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Grace",
directory="people",
note_type="person_fm",
entity_metadata={
"tags": ["engineer", "python"],
"status": "published",
},
content=dedent("""\
## Observations
- [name] Grace Hopper
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"identifier": note_entity.permalink},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
assert result["passed"] is True
assert result["warnings"] == []
# Should have field results for "name" (observation) + "tags" + "status" (frontmatter)
field_names = [fr["field_name"] for fr in result["field_results"]]
assert "tags" in field_names
assert "status" in field_names
@pytest.mark.asyncio
async def test_validate_frontmatter_missing_required_key_warns(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Missing required frontmatter key produces a warning."""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Person Schema FM2",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "person_fm2",
"schema": {"name": "string"},
"settings": {
"validation": "warn",
"frontmatter": {
"status": "string", # required
},
},
},
content=dedent("""\
## Observations
- [note] Schema requiring status frontmatter
"""),
)
)
await search_service.index_entity(schema_entity)
# Note without the required "status" frontmatter key
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Hank",
directory="people",
note_type="person_fm2",
content=dedent("""\
## Observations
- [name] Hank Pym
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"identifier": note_entity.permalink},
)
assert response.status_code == 200
data = response.json()
result = data["results"][0]
assert result["passed"] is True
assert any("status" in w for w in result["warnings"])
@pytest.mark.asyncio
async def test_validate_frontmatter_enum_mismatch_warns(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Frontmatter enum mismatch produces a warning."""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Person Schema FM3",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "person_fm3",
"schema": {"name": "string"},
"settings": {
"validation": "warn",
"frontmatter": {
"status?(enum)": ["draft", "published"],
},
},
},
content=dedent("""\
## Observations
- [note] Schema with enum frontmatter
"""),
)
)
await search_service.index_entity(schema_entity)
# Note with invalid enum value in frontmatter
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Iris",
directory="people",
note_type="person_fm3",
entity_metadata={
"status": "archived", # not in [draft, published]
},
content=dedent("""\
## Observations
- [name] Iris West
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"identifier": note_entity.permalink},
)
assert response.status_code == 200
data = response.json()
result = data["results"][0]
assert result["passed"] is True
# Should have a warning about the enum mismatch
assert any("status" in w for w in result["warnings"])
# The field result should show enum_mismatch
status_fr = next(fr for fr in result["field_results"] if fr["field_name"] == "status")
assert status_fr["status"] == "enum_mismatch"
# --- Diff Endpoint Tests ---
@pytest.mark.asyncio
async def test_diff_no_schema_found(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
):
"""Diff returns empty drift report when no schema exists for the type."""
response = await client.get(
f"{v2_project_url}/schema/diff/nonexistent",
)
assert response.status_code == 200
data = response.json()
assert data["note_type"] == "nonexistent"
assert data["new_fields"] == []
assert data["dropped_fields"] == []
assert data["cardinality_changes"] == []
@pytest.mark.asyncio
async def test_diff_with_schema_note(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
):
"""Diff detects new fields and drift when a schema note exists."""
# Create a schema note (type="schema" with entity/schema metadata).
# The search_fn in the router looks for schema notes by searching
# with types=["schema"] and then returns row.metadata from the search index.
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Person Schema",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "person",
"version": 1,
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [note] Schema definition for person entities
"""),
)
)
await search_service.index_entity(schema_entity)
# Create person entities with extra fields not in schema
await create_person_entities(entity_service, search_service)
response = await client.get(
f"{v2_project_url}/schema/diff/person",
)
assert response.status_code == 200
data = response.json()
assert data["note_type"] == "person"
assert isinstance(data["new_fields"], list)
assert isinstance(data["dropped_fields"], list)
assert isinstance(data["cardinality_changes"], list)
# --- File-based schema frontmatter tests ---
@pytest.mark.asyncio
async def test_validate_reads_schema_from_file_not_database(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
file_service: FileService,
):
"""Validate uses schema frontmatter from the file, not stale database metadata.
Simulates the core bug from #634: user edits a schema file to change
validation mode from 'warn' to 'strict', but the file watcher hasn't
synced. The database still has 'warn', but validation should use 'strict'
from the file.
"""
# Create schema entity — DB gets validation=warn
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Editable Schema",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "editable_type",
"schema": {"name": "string", "role": "string"},
"settings": {"validation": "warn"},
},
content=dedent("""\
## Observations
- [note] Schema that will be edited on disk
"""),
)
)
await search_service.index_entity(schema_entity)
# Overwrite the file on disk with validation=strict
file_path = Path(file_service.base_path) / schema_entity.file_path
file_path.write_text(
dedent("""\
---
title: Editable Schema
permalink: schemas/editable-schema
type: schema
entity: editable_type
schema:
name: string
role: string
settings:
validation: strict
---
# Editable Schema
## Observations
- [note] Schema that will be edited on disk
""")
)
# Create a note missing "role" — strict mode should produce errors, not warnings
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="TestNote",
directory="notes",
note_type="editable_type",
content=dedent("""\
## Observations
- [name] Test Person
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"identifier": note_entity.permalink},
)
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
# strict mode: missing required field is an error, not a warning
assert result["passed"] is False
assert any("role" in e for e in result["errors"])
@pytest.mark.asyncio
async def test_validate_falls_back_to_db_on_incomplete_frontmatter(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
file_service: FileService,
):
"""Validate falls back to database metadata when file has incomplete frontmatter.
Simulates a mid-edit state where the user has removed the 'schema' key
from the file. The validator should use the last-known-good metadata
from the database rather than failing with a 500.
"""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Incomplete Schema",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "incomplete_type",
"schema": {"name": "string"},
},
content=dedent("""\
## Observations
- [note] Schema that will have incomplete frontmatter
"""),
)
)
await search_service.index_entity(schema_entity)
# Overwrite file with frontmatter missing the 'schema' key
file_path = Path(file_service.base_path) / schema_entity.file_path
file_path.write_text(
dedent("""\
---
title: Incomplete Schema
permalink: schemas/incomplete-schema
type: schema
entity: incomplete_type
---
# Incomplete Schema
## Observations
- [note] Mid-edit state
""")
)
# Create a note to validate against this schema
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="FallbackNote",
directory="notes",
note_type="incomplete_type",
content=dedent("""\
## Observations
- [name] Test Fallback
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "incomplete_type"},
)
# Should not 500 — falls back to DB metadata and validates successfully
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
assert result["passed"] is True
@pytest.mark.asyncio
async def test_validate_falls_back_to_db_on_missing_file(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
file_service: FileService,
):
"""Validate falls back to database metadata when schema file is missing.
Simulates a race condition where the file has been deleted but the
database still has the entity. The validator should use DB metadata
rather than failing entirely.
"""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Missing File Schema",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "missing_file_type",
"schema": {"name": "string"},
},
content=dedent("""\
## Observations
- [note] Schema whose file will be deleted
"""),
)
)
await search_service.index_entity(schema_entity)
# Delete the schema file from disk
file_path = Path(file_service.base_path) / schema_entity.file_path
file_path.unlink()
# Create a note to validate
note_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="OrphanNote",
directory="notes",
note_type="missing_file_type",
content=dedent("""\
## Observations
- [name] Test Orphan
"""),
)
)
await search_service.index_entity(note_entity)
response = await client.post(
f"{v2_project_url}/schema/validate",
params={"note_type": "missing_file_type"},
)
# Should not 500 — falls back to DB metadata and validates
assert response.status_code == 200
data = response.json()
assert data["total_notes"] == 1
result = data["results"][0]
assert result["passed"] is True
@pytest.mark.asyncio
async def test_diff_falls_back_to_db_on_missing_file(
client: AsyncClient,
test_project: Project,
v2_project_url: str,
entity_service,
search_service,
file_service: FileService,
):
"""Diff endpoint falls back to DB metadata when schema file is missing."""
schema_entity, _ = await entity_service.create_or_update_entity(
EntitySchema(
title="Diff Missing Schema",
directory="schemas",
note_type="schema",
entity_metadata={
"entity": "diff_missing_type",
"schema": {"name": "string", "role": "string"},
},
content=dedent("""\
## Observations
- [note] Schema for diff fallback test
"""),
)
)
await search_service.index_entity(schema_entity)
# Delete the schema file
file_path = Path(file_service.base_path) / schema_entity.file_path
file_path.unlink()
# Create person entities
await create_person_entities(entity_service, search_service)
response = await client.get(
f"{v2_project_url}/schema/diff/diff_missing_type",
)
# Should not 500 — falls back to DB metadata for schema resolution
assert response.status_code == 200
data = response.json()
assert data["note_type"] == "diff_missing_type"