diff --git a/.claude/rules/file-map.md b/.claude/rules/file-map.md index 8a221bc0..91f7f772 100644 --- a/.claude/rules/file-map.md +++ b/.claude/rules/file-map.md @@ -132,6 +132,7 @@ Full module index for LocalChat. **Keep this current** — update in the same co | `docs/CONFIGURATION.md` | Configuration reference — every env var, default and effect (lifted out of README) | | `docs/MIGRATIONS.md` | Migration docs — how to apply, write, and roll back | | `tests/integration/test_object_authorization_over_the_wire.py` | P2-2b — a plain user holding workspace A addresses workspace B's objects over HTTP against a real Postgres, and every route must refuse. The route list is derived from `app.openapi()` (not `app.routes`, which this FastAPI version leaves unflattened as `_IncludedRouter` with `path=None`), an unclassified path parameter fails rather than silently dropping its routes, and the fixture proves its own objects are reachable by their owner — a 404 is a pass here, so an empty fixture would be a perfect green run testing nothing. Runs as an editor and as an owner of A (R-5), and probes ids named in a body or header beside a control with the caller's own | +| `tests/integration/test_ontology_reads_live_documents.py` | A workspace's ontology counts only its live documents and live chunks: a retired document and a chunk retired by a re-ingest leave it | | `tests/integration/test_chunk_statistics_scope.py` | R-1 (QP-9 review) — document statistics count and sample only the caller's workspace's live chunks, with a second workspace and a retired document beside it | | `tests/integration/test_migrations_apply.py` | TQ-5b — the migration chain applies against a real database, lands on the single head, and a second run does nothing | | `tests/integration/test_set_local_scope_mechanism.py` | P2-1b's precondition, made executable — a transaction-local scope plus RLS returns zero rows when unset, does not survive `COMMIT`, and is not inherited by the next transaction; the properties `hnsw.ef_search` lacks | diff --git a/CHANGELOG.md b/CHANGELOG.md index 1840adf3..2d132c55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ OpenAI, `https://api.openai.com/v1`) and drop any `provider/` prefix from `CLOUD not OpenAI-shaped (Anthropic's native API, Vertex) is no longer reachable, as ADR-4 accepted. +### Fixed + +- **A workspace's ontology counts only its live documents.** `workspace_ontology` counted + the entities of retired documents, and of chunks a re-ingest had retired. Found by the + separate review of #527. + ## [3.5.0] — 2026-10-31 The release that closes the quality plan's maturity target (Initiative 12). All six diff --git a/src/db/entities.py b/src/db/entities.py index 3273c169..42a9d212 100644 --- a/src/db/entities.py +++ b/src/db/entities.py @@ -110,7 +110,7 @@ def related_names( return [r[0] for r in cursor.fetchall()] def workspace_ontology(self, top_n: int = 20, *, scope: Scope) -> dict[str, Any]: - """Return the ontology of *scope*: top entity types and relation patterns.""" + """Return the ontology of *scope*'s live documents: top entity types and relation patterns.""" if not self._db.is_connected: return {"entity_types": [], "relation_patterns": []} @@ -124,7 +124,8 @@ def workspace_ontology(self, top_n: int = 20, *, scope: Scope) -> dict[str, Any] SELECT e.type, COUNT(DISTINCT er.source_id) AS entity_count FROM entity_relations er JOIN entities e ON e.id = er.source_id - JOIN documents d ON d.id = er.doc_id + JOIN documents d ON d.id = er.doc_id AND d.deleted_at IS NULL + JOIN document_chunks c ON c.id = er.chunk_id AND c.deleted_at IS NULL WHERE true {ws_filter} GROUP BY e.type ORDER BY entity_count DESC @@ -142,7 +143,8 @@ def workspace_ontology(self, top_n: int = 20, *, scope: Scope) -> dict[str, Any] f""" SELECT er.relation, COUNT(*) AS freq FROM entity_relations er - JOIN documents d ON d.id = er.doc_id + JOIN documents d ON d.id = er.doc_id AND d.deleted_at IS NULL + JOIN document_chunks c ON c.id = er.chunk_id AND c.deleted_at IS NULL WHERE true {ws_filter} GROUP BY er.relation ORDER BY freq DESC diff --git a/tests/integration/test_ontology_reads_live_documents.py b/tests/integration/test_ontology_reads_live_documents.py new file mode 100644 index 00000000..46eb1734 --- /dev/null +++ b/tests/integration/test_ontology_reads_live_documents.py @@ -0,0 +1,55 @@ +"""A workspace's ontology counts only its live documents and their live chunks. + +`workspace_ontology` joined each relation to its document without asking whether the +document, or the chunk the relation came from, was retired, so a retired document's +entities kept shaping the ontology. Found by the separate review of #527. +""" + +from __future__ import annotations + +import uuid +from collections.abc import Iterator +from typing import Any + +import pytest + +pytestmark = [pytest.mark.integration, pytest.mark.db] + + +@pytest.fixture(scope="module") +def database() -> Any: + from src.db import Database + + db = Database() + ok, message = db.initialize() + if not ok or not db.is_connected: + pytest.skip(f"PostgreSQL is not available: {message}") + from src.app_bootstrap import _run_alembic_migrations + + _run_alembic_migrations() + return db + + +@pytest.fixture() +def workspace(database: Any) -> Iterator[str]: + ws = database.workspaces.create(f"onto-{uuid.uuid4().hex[:8]}", owner_id=None) + yield ws + database.workspaces.retire(ws) + + +def _relate(database: Any, ws: str, entity_type: str) -> int: + tag = uuid.uuid4().hex[:8] + doc = database.documents.insert(f"onto-{tag}.txt", "x", workspace_id=ws, scope=ws) + chunk = database.documents.insert_chunks([(doc, "x", 0, [0.02] * 768)], scope=ws)[0] + database.entities.insert_relation( + database.entities.upsert(f"A{tag}", entity_type), database.entities.upsert(f"B{tag}", entity_type), + doc, chunk) + return doc + + +def test_a_retired_document_and_a_retired_chunk_leave_the_ontology(database, workspace): + _relate(database, workspace, "LIVE") + database.documents.retire(_relate(database, workspace, "RETIRED_DOC"), scope=workspace) + database.documents.retire_chunks(_relate(database, workspace, "RETIRED_CHUNK"), scope=workspace) + types = {t["type"] for t in database.entities.workspace_ontology(scope=workspace)["entity_types"]} + assert types == {"LIVE"}