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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .claude/rules/file-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/db/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": []}

Expand All @@ -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
Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/integration/test_ontology_reads_live_documents.py
Original file line number Diff line number Diff line change
@@ -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()

Check warning on line 33 in tests/integration/test_ontology_reads_live_documents.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove empty parentheses from this decorator.

See more on https://sonarcloud.io/project/issues?id=jwvanderstam_LocalChat&issues=AaG9PcuGTyWH8afVFDzr&open=AaG9PcuGTyWH8afVFDzr&pullRequest=535
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"}
Loading