"""M7: the imported knowledge library, against its acceptance contract. The criteria this file carries are G01-G10, C05, F05's and F06's imported halves, I05, H06-H09, and the additional cases `BUILD-MILESTONES.md` M7 names: campaign isolation, lexical retrieval without embeddings, a bounded knowledge budget, deletion that preserves historical prompt evidence, hidden Canon that does not leak into player knowledge, stale imported Canon losing to current state, and an abandoned line of story failing to influence the retrieval query. Three rules the assertions here follow, all learned the hard way in earlier milestones: * **Assert on the assembled prompt, not on a narration.** A model that fails to mention a leaked passage is not evidence the passage did not leak. Every authority and leakage test below reads the context the real builder produced. * **Go through the real chokepoints.** Retrieval runs through `knowledge.retrieval.retrieve`, the lineage-capped `history.tail`, and the same FTS5 query the product uses. A test that reimplemented any of them could pass while the product leaked. * **Positive controls.** Every negative assertion is paired with the positive one that proves the mechanism was working — "not retrieved while disabled" is worth nothing without "retrieved while enabled" beside it. python -m pytest tests/test_imported_knowledge.py -v """ import asyncio import pytest from fastapi import Depends from fastapi.testclient import TestClient from sqlalchemy import select, text as sql from app import auth, derived, limits, memorybank, models from app.database import Base, SessionLocal, engine, get_db from app.knowledge import classes, embeddings, fts, importer, inject, retrieval from app.main import app from app.providers import ProviderError from app.routers import adventures from fakes import ScriptedProvider, state_block # ------------------------------------------------------- the standard fixture # # The three files from `TEST-CAMPAIGN-FIXTURE.md` §12, **verbatim**, plus the # campaign canon (§11) and hidden Canon (§8) the traps are built on. # # M7's first pass used the shorter variants in `V1-ACCEPTANCE-TESTS.md` §5 # instead, and the difference was not cosmetic: §12's `inspiration.md` carries # "A frightened innkeeper concealed a dangerous political secret from a # stranger", which is the entire point of G07 against the canon rule "Mara is # not a spy". The trap was therefore never exercised (review finding M7-F4). # # This file is the **standard acceptance fixture** suite. The purpose-built # retrieval-mechanism fixtures live in `test_knowledge_retrieval_quality.py`. CANON_MD = """# Campaign Canon The Old Abbey lies five miles north of Westhaven. The abbey crypt bears a symbol shaped like a broken circle. Magic exists in this world, but resurrection is impossible. Mara has never visited the Old Abbey. """ REFERENCE_MD = """# Tavern Reference Medieval roadside taverns commonly used timber framing, stone hearths, wooden benches, shared tables, candles, and oil lamps. Cellars were often used for ale, food storage, and secure storage. Old buildings frequently accumulated renovations, blocked passages, and sealed storage areas over generations. """ INSPIRATION_MD = """# Atmospheric Inspiration A traveler entered a silent hall while rain tapped against dark shutters. A single lantern illuminated the room. Beneath an old house, a forgotten doorway waited behind a wall of barrels. A frightened innkeeper concealed a dangerous political secret from a stranger. """ #: Hidden Canon: the fixture's Silver Key function (§8), which the protagonist #: must not learn from the narrator merely because the narrator was given it. HIDDEN_CANON_MD = """The Silver Key opens the sealed cellar door beneath the Crooked Lantern. Edrin discovered this before he disappeared, and told no one. """ #: `TEST-CAMPAIGN-FIXTURE.md` §11, the campaign's own authoritative rules. CAMPAIGN_CANON = {"rules": [ "Magic exists.", "Resurrection is impossible.", "The Old Abbey lies five miles north of Westhaven.", "The Silver Key was found in Edrin's desk.", "Mara has never visited the Old Abbey.", "Mara is not a spy.", ]} class StubEmbedder: """A deterministic embedder, so semantic tests do not need a model. Distinct enough to separate the fixture's three files and the query text that should reach each of them. Tests that need a *real* embedding model are in `test_knowledge_real_model.py`, and skip without one. """ def __init__(self): self.calls = 0 self.texts: list[str] = [] async def embed(self, texts): self.calls += 1 self.texts.extend(texts) out = [] for text in texts: lowered = text.lower() out.append([ 1.0, 1.0 if ("abbey" in lowered or "crypt" in lowered or "westhaven" in lowered) else 0.0, 1.0 if ("tavern" in lowered or "hearth" in lowered or "timber" in lowered) else 0.0, 1.0 if ("rain" in lowered or "lantern" in lowered or "shutters" in lowered) else 0.0, 1.0 if ("resurrect" in lowered or "revive" in lowered or "death" in lowered or "dead" in lowered) else 0.0, ]) return out class FailingEmbedder: async def embed(self, texts): raise ProviderError("Embedding request failed: connection refused") @pytest.fixture() def client(monkeypatch): Base.metadata.create_all(bind=engine) memorybank._vector_cache.clear() embeddings._cache.clear() setup = SessionLocal() user = models.User(is_guest=False, email="m7@example.com") setup.add(user) setup.flush() setup.add(models.Settings( user_id=user.id, model="test-model", embedding_model="", context_token_budget=4000, max_output_tokens=400, memory_top_k=3, )) adventure = models.Adventure( user_id=user.id, title="Continuity Test", # The fixture's own canon rule, so C05 has a campaign rule to be # measured against rather than an invented one. campaign_canon=CAMPAIGN_CANON, ) setup.add(adventure) setup.flush() setup.add(models.Action( adventure_id=adventure.id, type="start", text="Aldric sits in the Crooked Lantern Tavern with Mara.", )) # A second campaign, for the isolation tests. Created here rather than in # each test so that "campaign B" is a real peer of campaign A throughout. other = models.Adventure(user_id=user.id, title="Second Campaign") setup.add(other) setup.flush() setup.add(models.Action( adventure_id=other.id, type="start", text="A different story entirely.", )) setup.commit() adv_id, other_id, user_id = adventure.id, other.id, user.id setup.close() monkeypatch.setattr(limits, "check_row_cap", lambda *a, **k: None) monkeypatch.setattr(adventures.turns, "OpenAICompatibleProvider", ScriptedProvider) # Both derived factories stubbed, for M6's finding M6-F3: with only one # replaced, the post-turn pass builds a real provider against the default # endpoint and every turn in the file opens a socket. monkeypatch.setattr(memorybank, "embedding_provider", lambda s: StubEmbedder()) monkeypatch.setattr(memorybank, "summary_provider", lambda s: StubEmbedder()) app.dependency_overrides[auth.get_current_user] = ( lambda db=Depends(get_db): db.get(models.User, user_id) ) test_client = TestClient(app) test_client.adv_id = adv_id test_client.other_id = other_id test_client.user_id = user_id try: yield test_client finally: app.dependency_overrides.clear() memorybank._vector_cache.clear() embeddings._cache.clear() Base.metadata.drop_all(bind=engine) # ----------------------------------------------------------------- helpers def upload(client, name, body, classification, adv_id=None, **fields): """Imports a file the way the browser does: multipart, no pathname.""" data = {"classification": classification} data.update({k: str(v).lower() if isinstance(v, bool) else str(v) for k, v in fields.items()}) return client.post( f"/api/adventures/{adv_id or client.adv_id}/knowledge", files={"file": (name, body.encode("utf-8"), "text/markdown")}, data=data, ) def import_fixture(client, adv_id=None): """The three standard files, classified as the acceptance document says.""" ids = {} for name, body, kind in ( ("canon.md", CANON_MD, "canon"), ("reference.md", REFERENCE_MD, "reference"), ("inspiration.md", INSPIRATION_MD, "inspiration"), ): response = upload(client, name, body, kind, adv_id=adv_id) assert response.status_code == 201, response.text[:400] ids[name] = response.json()["id"] return ids def play(client, text, prose="The room settles into quiet.", events=None): ScriptedProvider.replies = [f"{prose}\n{state_block(events or [])}"] response = client.post(f"/api/adventures/{client.adv_id}/actions", json={"type": "do", "text": text}) assert response.status_code == 200, response.text[:300] assert '"error"' not in response.text, response.text[:300] return response def context_report(client, adv_id=None): response = client.get(f"/api/adventures/{adv_id or client.adv_id}/context") assert response.status_code == 200, response.text[:400] return response.json() def prompt_text(report): return "\n".join(section["text"] for section in report["sections"]) def section(report, label): return next((s for s in report["sections"] if s["label"] == label), None) def used_files(report): return [u["filename"] for u in report["knowledge"]["used"]] def retrieve_now(client, adv_id=None): """Runs the real retrieval for a campaign, outside a turn.""" with SessionLocal() as db: adventure = db.get(models.Adventure, adv_id or client.adv_id) settings = db.execute( select(models.Settings).where(models.Settings.user_id == client.user_id) ).scalars().first() return asyncio.run(retrieval.retrieve(adventure, settings)) #: A calibrated model name. Semantic admission is per-model #: (`classes.SEMANTIC_CALIBRATION`); naming an unrecognised model would put #: these tests on the uncalibrated lexical-only path without saying so. #: `test_knowledge_calibration.py` is where that path is exercised deliberately. EMBED_MODEL = "nomic-embed-text" def set_embedding_model(client, name): with SessionLocal() as db: row = db.execute( select(models.Settings).where(models.Settings.user_id == client.user_id) ).scalars().first() row.embedding_model = name db.commit() def embed_all(client, adv_id=None): """Runs the real embedding pass. Returns how many vectors it wrote. Zero is a normal answer: importing with an embedding model already configured embeds through the router, so a later pass legitimately finds nothing pending. Tests that need vectors to exist assert that with `embedded_count`, which is the question they actually mean. """ with SessionLocal() as db: adventure = db.get(models.Adventure, adv_id or client.adv_id) settings = db.execute( select(models.Settings).where(models.Settings.user_id == client.user_id) ).scalars().first() written = asyncio.run(embeddings.embed_pending(db, adventure, settings)) db.commit() return written def embedded_count(client, adv_id=None): with SessionLocal() as db: return len(db.execute(select(models.KnowledgeEmbedding).where( models.KnowledgeEmbedding.adventure_id == (adv_id or client.adv_id) )).scalars().all()) # =========================================================== G01 / G02 import def test_g01_a_text_file_is_stored_and_indexed_with_provenance(client): """G01. `.txt` import: stored and indexed locally, with provenance.""" response = upload(client, "canon.txt", CANON_MD, "canon") assert response.status_code == 201, response.text[:400] body = response.json() assert body["original_filename"] == "canon.txt" assert body["classification"] == "canon" assert body["media_type"] == "text/plain" assert body["index_state"] == "ready" assert body["chunk_count"] >= 1 # The provenance a source has to retain: a content identity, a size, the # versions of the code that produced its passages, and when it arrived. assert len(body["content_hash"]) == 64 assert body["byte_size"] == len(CANON_MD.encode("utf-8")) assert body["parser_version"] >= 1 and body["chunking_version"] >= 1 assert body["imported_at"] # Stored locally, in this application's own database, and readable back # without the original file — which is the property §11 of the design asks # for and the one that makes an export possible. detail = client.get( f"/api/adventures/{client.adv_id}/knowledge/{body['id']}" ).json() assert detail["content"] == CANON_MD def test_g02_markdown_files_are_accepted_as_data(client): """G02. `.md` import: reference and inspiration are accepted as data.""" ids = import_fixture(client) listing = client.get(f"/api/adventures/{client.adv_id}/knowledge").json() assert {row["original_filename"] for row in listing} == { "canon.md", "reference.md", "inspiration.md" } assert all(row["index_state"] == "ready" for row in listing) assert all(row["media_type"] == "text/markdown" for row in listing) assert len(ids) == 3 def test_a_source_type_that_is_not_supported_is_refused(client): """Only `.txt` and `.md`, and the refusal says so.""" response = upload(client, "world.pdf", "%PDF-1.4 not really", "canon") assert response.status_code == 422 assert ".txt and .md" in response.json()["detail"] assert client.get(f"/api/adventures/{client.adv_id}/knowledge").json() == [] def test_binary_content_is_refused_even_with_an_allowed_extension(client): """An extension is not evidence (`SECURITY-THREAT-MODEL.md` §21).""" response = client.post( f"/api/adventures/{client.adv_id}/knowledge", files={"file": ("notes.txt", b"PK\x03\x04\x00\x00\x08\x00binary", "text/plain")}, data={"classification": "reference"}, ) assert response.status_code == 422 assert "binary" in response.json()["detail"].lower() assert client.get(f"/api/adventures/{client.adv_id}/knowledge").json() == [] def test_invalid_encoding_is_refused_rather_than_mangled(client): """§60: reject with a clear error; never silently corrupt the text.""" response = client.post( f"/api/adventures/{client.adv_id}/knowledge", files={"file": ("notes.md", "Café".encode("latin-1"), "text/markdown")}, data={"classification": "reference"}, ) assert response.status_code == 422 assert "UTF-8" in response.json()["detail"] assert client.get(f"/api/adventures/{client.adv_id}/knowledge").json() == [] def test_an_oversized_source_is_refused_with_a_useful_message(client): """The size limit is enforced server-side and says what to do about it.""" body = "The abbey stands. " * 80_000 # comfortably over MAX_SOURCE_BYTES assert len(body.encode("utf-8")) > importer.MAX_SOURCE_BYTES response = upload(client, "huge.md", body, "reference") assert response.status_code == 422 detail = response.json()["detail"] assert "limit" in detail and "split the file" in detail # Nothing was silently truncated and nothing was stored. assert client.get(f"/api/adventures/{client.adv_id}/knowledge").json() == [] def test_identical_content_is_not_silently_duplicated(client): """§13. A duplicate is a conflict naming the source that already holds it.""" first = upload(client, "canon.md", CANON_MD, "canon") assert first.status_code == 201 again = upload(client, "canon-copy.md", CANON_MD, "canon") assert again.status_code == 409 conflict = again.json()["detail"]["conflict"] assert conflict["source_id"] == first.json()["id"] assert len(client.get(f"/api/adventures/{client.adv_id}/knowledge").json()) == 1 # ...and the reader may still say they meant it. deliberate = upload(client, "canon-copy.md", CANON_MD, "reference", allow_duplicate=True) assert deliberate.status_code == 201 listing = client.get(f"/api/adventures/{client.adv_id}/knowledge").json() assert len(listing) == 2 assert {row["classification"] for row in listing} == {"canon", "reference"} # ================================================================== G03 class def test_g03_every_source_is_visibly_classified_and_reclassifiable(client): """G03. The class is stored, visible, and editable without reimport.""" ids = import_fixture(client) listing = {row["original_filename"]: row for row in client.get(f"/api/adventures/{client.adv_id}/knowledge").json()} assert listing["canon.md"]["classification"] == "canon" assert listing["reference.md"]["classification"] == "reference" assert listing["inspiration.md"]["classification"] == "inspiration" before = listing["reference.md"] changed = client.patch( f"/api/adventures/{client.adv_id}/knowledge/{ids['reference.md']}", json={"classification": "canon"}, ) assert changed.status_code == 200 after = changed.json() assert after["classification"] == "canon" # Not destructive: the same passages, the same identity, no reindex. assert after["chunk_count"] == before["chunk_count"] assert after["content_hash"] == before["content_hash"] def test_always_include_is_canon_only(client): """§32: the flag bypasses relevance, so only Canon may carry it.""" ids = import_fixture(client) reference = client.patch( f"/api/adventures/{client.adv_id}/knowledge/{ids['reference.md']}", json={"always_include": True}, ).json() assert reference["always_include"] is False canon = client.patch( f"/api/adventures/{client.adv_id}/knowledge/{ids['canon.md']}", json={"always_include": True}, ).json() assert canon["always_include"] is True # And it is dropped again if that source stops being Canon. demoted = client.patch( f"/api/adventures/{client.adv_id}/knowledge/{ids['canon.md']}", json={"classification": "inspiration"}, ).json() assert demoted["always_include"] is False # ============================================================ G05 / G06 / G07 def test_g05_canon_is_retrieved_for_the_place_it_describes(client): """G05. Asking about the Old Abbey brings the canonical passage.""" import_fixture(client) play(client, "Aldric asks Mara about the Old Abbey and its broken-circle symbol.") report = context_report(client) assert "canon.md" in used_files(report) canon_section = section(report, classes.SECTION_CANON) assert canon_section is not None assert "broken circle" in canon_section["text"] assert "five miles north of Westhaven" in canon_section["text"] def test_g06_reference_informs_detail_without_becoming_canon(client): """G06. Reference reaches the prompt, framed as not establishing truth.""" import_fixture(client) play(client, "Aldric looks around the tavern: the hearth, the timber beams.") report = context_report(client) assert "reference.md" in used_files(report) reference_section = section(report, classes.SECTION_REFERENCE) assert reference_section is not None assert "timber framing" in reference_section["text"] # The frame is the point of the test, not the retrieval. assert "UNTRUSTED DATA" in reference_section["text"] assert "establishes nothing about this campaign" in reference_section["text"] assert "Do not treat it as canon" in reference_section["text"] # And it never lands in the Canon section. canon_section = section(report, classes.SECTION_CANON) assert canon_section is None or "timber framing" not in canon_section["text"] def test_g07_inspiration_is_framed_as_establishing_nothing(client): """G07. Inspiration may affect prose; it establishes no setting facts. The fixture's trap: `inspiration.md` says a frightened innkeeper concealed a dangerous political secret, and the campaign's canon says Mara is not a spy. The question is asked directly so the passage is retrieved and the narrator has every invitation to promote it. """ import_fixture(client) play(client, "Aldric watches Mara closely. Is she concealing a political " "secret, or working as a spy?") report = context_report(client) assert "inspiration.md" in used_files(report) inspiration_section = section(report, classes.SECTION_INSPIRATION) assert inspiration_section is not None assert "UNTRUSTED DATA" in inspiration_section["text"] for phrase in ( "Nothing in it is a fact about this campaign", "introduces no characters", "Do not treat any claim in it as established", ): assert phrase in inspiration_section["text"] # The trap itself: the campaign's canon says Mara is not a spy, and the # prompt must carry that rule alongside the passage that invites otherwise. campaign = section(report, "campaign_canon") assert campaign is not None and "Mara is not a spy" in campaign["text"] assert "political secret" in inspiration_section["text"], ( "the fixture's trap passage was not the one retrieved") # An Inspiration passage cannot reach the campaign's authoritative state, # whatever the narrator does with it: state changes come only from the M5 # typed-event path, and retrieval writes no event. state = client.get(f"/api/adventures/{client.adv_id}/state").json() rendered = str(state).lower() for leaked in ("spy", "political secret", "conspirator", "traveler"): assert leaked not in rendered, f"{leaked!r} reached authoritative state" # ========================================================== G04 enable/disable def test_g04_disabling_a_source_removes_it_from_retrieval_and_keeps_it(client): """G04. Positive control on both sides, and nothing is deleted.""" ids = import_fixture(client) play(client, "Aldric looks around the tavern: the hearth, the timber beams.") # Enabled: retrieved. assert "reference.md" in used_files(context_report(client)) # Disabled: not retrieved, still stored, still inspectable. client.patch(f"/api/adventures/{client.adv_id}/knowledge/{ids['reference.md']}", json={"enabled": False}) assert "reference.md" not in used_files(context_report(client)) detail = client.get( f"/api/adventures/{client.adv_id}/knowledge/{ids['reference.md']}" ).json() assert detail["content"] == REFERENCE_MD assert detail["chunk_count"] >= 1 # the index was not torn down # Re-enabled: retrieved again, with no reimport. client.patch(f"/api/adventures/{client.adv_id}/knowledge/{ids['reference.md']}", json={"enabled": True}) assert "reference.md" in used_files(context_report(client)) # ============================================================ G08 / G09 / G10 def test_g08_a_url_in_a_source_is_never_fetched(client, monkeypatch): """G08. Importing, indexing and retrieving open no outbound connection. Asserted by making an outbound IP socket impossible rather than by reading the code: an `AF_INET`/`AF_INET6` socket, `create_connection`, and httpx's real transport all raise, so a request from any layer fails the test loudly. `AF_UNIX` is deliberately still allowed. The in-process test client runs the ASGI app over a socketpair of its own, and refusing that would fail every request in this test rather than the outbound one it is about. """ import socket import httpx real_socket = socket.socket opened: list = [] def refuse_ip(family=socket.AF_INET, *args, **kwargs): if family in (socket.AF_INET, socket.AF_INET6): opened.append(("socket", family)) raise AssertionError("the knowledge subsystem opened an IP socket") return real_socket(family, *args, **kwargs) def refuse(*args, **kwargs): opened.append(args) raise AssertionError("the knowledge subsystem made an outbound request") monkeypatch.setattr(socket, "socket", refuse_ip) monkeypatch.setattr(socket, "create_connection", refuse) monkeypatch.setattr(httpx.HTTPTransport, "handle_request", refuse) monkeypatch.setattr(httpx.AsyncHTTPTransport, "handle_async_request", refuse) body = ( "The abbey is described at https://example.com/something and also at\n" ". See http://tracker.example.com/beacon.\n" ) response = upload(client, "links.md", body, "reference") assert response.status_code == 201 play(client, "Aldric reads about the abbey.") report = context_report(client) assert opened == [] # The URL is retained as text — it was not stripped, resolved or previewed. detail = client.get( f"/api/adventures/{client.adv_id}/knowledge/{response.json()['id']}" ).json() assert "https://example.com/something" in detail["content"] assert report is not None def test_g09_a_remote_markdown_image_is_inert_text(client): """G09. The reference is stored and served as text; nothing loads it. The backend makes no request (the previous test proves that for every outbound path). What this proves is the other half: the reference survives as characters in the stored source and in any passage, and never becomes an `` — the browser has no element to fetch from because the API serves JSON and the panel renders it into a `
` as a text node.
    """
    body = "# Tracker\n\n![](https://example.invalid/tracker.png)\n\nOrdinary prose about the abbey.\n"
    created = upload(client, "tracker.md", body, "reference")
    assert created.status_code == 201
    source_id = created.json()["id"]

    detail = client.get(
        f"/api/adventures/{client.adv_id}/knowledge/{source_id}"
    )
    assert detail.headers["content-type"].startswith("application/json")
    assert "![](https://example.invalid/tracker.png)" in detail.json()["content"]

    chunks = client.get(
        f"/api/adventures/{client.adv_id}/knowledge/{source_id}/chunks"
    ).json()
    assert any("example.invalid/tracker.png" in chunk["text"] for chunk in chunks)
    # No endpoint anywhere renders it into markup.
    assert "