Phase 47 (owner permission 2026-08-27, TODO.md L10–11, roadmap R1): the
full Podman quadlet family (.container, .network, .volume, .image,
.pod, .kube, .swap, .os, .endpoint) and .j2 Jinja templates join the
allowed + default A9 import formats, chunked as plain text (owner
decision — no TOML/Jinja-aware splitter). No env configuration needed:
a default import now indexes them.
- app/config.py: _ALLOWED_IMPORT_EXTENSIONS + the default
import_extensions CSV gain the ten names (the original seven first);
the never-widen BOR_IMPORT_EXTENSIONS validator is untouched and
still rejects truly unknown extensions.
- app/rag/chunker.py: ten _FORMAT_CHUNKERS entries -> chunk_text
(HARD_MAX_CHARS 1200 honored, unknown-suffix fallback unchanged);
docstring/comments cite the A9 revision 2026-08-27.
- tests/fixtures/docs/homelab/: quadlet/compose.container (realistic
quadlet TOML, >1500 chars, [Unit]/[Service]/[Container] sections,
RESE-QUADLET-SENTINEL-77aa), quadlet/lan.network,
quadlet/cache.volume, templates/deploy.j2 (for/set/if Jinja
constructs + RESE-JINJA-SENTINEL-33dd). Every suite that seeds the
fixture tree updates its 9 -> 13 document-count constants.
- tests/unit/test_config.py: allowed set carries all seventeen formats,
default CSV + dotted import_extension_set include the ten, the
validator accepts the new names and still rejects unknowns.
- tests/unit/test_chunker.py: dispatch parity with chunk_text for every
new suffix (parametrized), the .container fixture chunks >=2 under
the cap with the sentinel surviving, the .j2 fixture keeps {{ }}
verbatim, the unknown-suffix fallback is unchanged.
- tests/unit/test_importer.py: a default-extensions walk over a temp
tree indexes exactly the ten new files (unknown/hidden/excluded
filtered), the original seven still walk, stem-title fallback holds.
- tests/integration/test_import_quadlet_jinja.py (new): import_sources
over a temp tree with .container/.volume/.j2 -> documents + chunks
rows with stem titles; delta re-import updates only the changed .j2
doc; prune drops the deleted .volume doc with cascade.
- tests/e2e/test_quadlet_jinja_import.py (new, story suite, mock-only,
isolation): GET /api/docs (admin session) lists the four new-format
docs with non-zero chunk counts and stem titles; the Sources table
renders a row + .doc-link per file; the phase-26 modal shows the
.container TOML ([Container] section + sentinel) with stem title and
the container format badge; a RESE-JINJA-SENTINEL-33dd question
FTS-matches the .j2 chunk -> honest-positive (A8: LOW requires zero
FTS hits) — the bubble is not .is-deflected and a source chip names
templates/deploy.j2.
- README.md + .env.example: the extended default format set (A9
revised 2026-08-27, plain-text chunking, narrow-only rule intact).
- .agent/PLAN.md: the A9 revision (owner-locked R1) — A9 row status,
the revision note under the anchors table, and the §5 chunking-policy
+ §11 workflow lines. The only PLAN edit this phase.
Gates: uv run pytest 795 passed; app/ coverage TOTAL 99% (>90%);
ruff check + pyright clean; story E2E 4/4 in isolation (DB up);
regression E2E suites test_import_documents (3) / test_sync_button
(3) / test_git_sources_admin (6) green in isolation.
Also records the 47_quadlet_jinja_import task-file moves (01–03)
todo/ -> complete/.
342 lines
13 KiB
Python
342 lines
13 KiB
Python
"""Integration: steering notes (phase 15) — CRUD + system-prompt injection.
|
|
|
|
Real Postgres (``podman compose up -d db``); the chat path reuses the
|
|
deterministic fake LLM from ``test_chat_api`` (token-overlap embeddings),
|
|
so the stored note's journey — API → Postgres → ``<tuning>`` section of
|
|
the captured system prompt — is verified end-to-end without a network.
|
|
|
|
Requires: podman compose up -d db
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from datetime import UTC, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from sqlalchemy import select, text
|
|
from test_chat_api import FakeRagLLM, _stream_chat
|
|
|
|
from app.api import chat as chat_api
|
|
from app.api.steering import load_steering_notes
|
|
from app.main import app as fastapi_app
|
|
from app.models import SteeringNote
|
|
from app.rag.importer import import_sources
|
|
from app.rag.prompts import build_steering_section
|
|
|
|
FIXTURES = Path(__file__).resolve().parents[1] / "fixtures" / "docs"
|
|
QUESTION = "How is my Kubernetes cluster set up?"
|
|
OFF_TOPIC = "How do I bake sourdough bread?"
|
|
NOTE = "STEEER-MARKER be concise"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_steering(db) -> Iterator[None]:
|
|
"""Steering notes + query log are global state: reset around every test."""
|
|
db.execute(text("TRUNCATE steering_notes, query_log"))
|
|
db.commit()
|
|
yield
|
|
db.execute(text("TRUNCATE steering_notes, query_log"))
|
|
db.commit()
|
|
|
|
|
|
@pytest.fixture()
|
|
def seeded_kb(db) -> Iterator[FakeRagLLM]:
|
|
"""Fresh Postgres with the fixture docs imported (real pipeline)."""
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
llm = FakeRagLLM()
|
|
summary = asyncio.run(import_sources([FIXTURES], llm, session=db))
|
|
assert summary.added == 13 # A9 formats (phase 47 added quadlet+j2); .hidden/ skipped
|
|
yield llm
|
|
db.execute(text("TRUNCATE chunks, documents, query_log, steering_notes"))
|
|
db.commit()
|
|
|
|
|
|
def _turn_log_lines(caplog: pytest.LogCaptureFixture) -> list[str]:
|
|
"""The per-turn ``question=…`` log lines (PLAN §9) from this test."""
|
|
return [r.getMessage() for r in caplog.records if "question=" in r.getMessage()]
|
|
|
|
|
|
# ---------- CRUD ----------
|
|
|
|
|
|
def test_create_note_returns_201_and_stores_trimmed(admin_client: TestClient, db) -> None:
|
|
r = admin_client.post("/api/steering", json={"note": f" {NOTE} "})
|
|
assert r.status_code == 201
|
|
body = r.json()
|
|
assert body["note"] == NOTE # trimmed before storage
|
|
uuid.UUID(body["id"]) # valid UUID
|
|
assert body["created_at"]
|
|
rows = db.scalars(select(SteeringNote)).all()
|
|
assert [row.note for row in rows] == [NOTE]
|
|
|
|
|
|
def test_list_notes_empty(admin_client: TestClient) -> None:
|
|
r = admin_client.get("/api/steering")
|
|
assert r.status_code == 200
|
|
assert r.json() == {"notes": []}
|
|
|
|
|
|
def test_list_notes_newest_first(admin_client: TestClient, db) -> None:
|
|
base = datetime.now(UTC)
|
|
db.add_all(
|
|
[
|
|
SteeringNote(note="oldest", created_at=base),
|
|
SteeringNote(note="newest", created_at=base + timedelta(hours=2)),
|
|
SteeringNote(note="middle", created_at=base + timedelta(hours=1)),
|
|
]
|
|
)
|
|
db.commit()
|
|
|
|
r = admin_client.get("/api/steering")
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert [n["note"] for n in body["notes"]] == ["newest", "middle", "oldest"]
|
|
for n in body["notes"]:
|
|
assert set(n) == {"id", "note", "created_at"}
|
|
uuid.UUID(n["id"])
|
|
|
|
|
|
def test_delete_note_returns_204_and_removes(admin_client: TestClient, db) -> None:
|
|
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
|
|
|
assert admin_client.delete(f"/api/steering/{created['id']}").status_code == 204
|
|
assert admin_client.get("/api/steering").json() == {"notes": []}
|
|
assert db.scalars(select(SteeringNote)).all() == []
|
|
|
|
|
|
def test_delete_unknown_note_returns_404(admin_client: TestClient) -> None:
|
|
r = admin_client.delete(f"/api/steering/{uuid.uuid4()}")
|
|
assert r.status_code == 404
|
|
assert "not found" in r.json()["detail"]
|
|
|
|
|
|
def test_delete_invalid_id_returns_422(admin_client: TestClient) -> None:
|
|
assert admin_client.delete("/api/steering/not-a-uuid").status_code == 422
|
|
|
|
|
|
def test_create_rejects_empty_and_blank_notes(admin_client: TestClient) -> None:
|
|
assert admin_client.post("/api/steering", json={"note": ""}).status_code == 422
|
|
assert admin_client.post("/api/steering", json={"note": " \t\n "}).status_code == 422
|
|
assert admin_client.get("/api/steering").json() == {"notes": []}
|
|
|
|
|
|
def test_create_enforces_2000_char_limit(admin_client: TestClient) -> None:
|
|
assert admin_client.post("/api/steering", json={"note": "x" * 2001}).status_code == 422
|
|
r = admin_client.post("/api/steering", json={"note": "x" * 2000})
|
|
assert r.status_code == 201
|
|
assert len(r.json()["note"]) == 2000
|
|
|
|
|
|
# ---------- update (PUT) — phase 27: edit a note in place ----------
|
|
|
|
|
|
def test_update_note_returns_200_and_replaces_text(admin_client: TestClient, db) -> None:
|
|
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
|
|
|
r = admin_client.put(f"/api/steering/{created['id']}", json={"note": f" {NOTE}-v2 "})
|
|
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert body["id"] == created["id"]
|
|
assert body["note"] == f"{NOTE}-v2" # trimmed, full replacement
|
|
# Editing does not redate the note.
|
|
assert datetime.fromisoformat(body["created_at"]) == datetime.fromisoformat(
|
|
created["created_at"]
|
|
)
|
|
row = db.get(SteeringNote, uuid.UUID(created["id"]))
|
|
assert row is not None
|
|
assert row.note == f"{NOTE}-v2"
|
|
|
|
|
|
def test_update_is_reflected_in_list_load_and_prompt(admin_client: TestClient, db) -> None:
|
|
base = datetime.now(UTC)
|
|
db.add_all(
|
|
[
|
|
SteeringNote(note="oldest note", created_at=base),
|
|
SteeringNote(note="newest note", created_at=base + timedelta(hours=1)),
|
|
]
|
|
)
|
|
db.commit()
|
|
oldest = db.scalars(select(SteeringNote).order_by(SteeringNote.created_at.asc())).first()
|
|
assert oldest is not None
|
|
updated = "oldest note — updated"
|
|
assert admin_client.put(f"/api/steering/{oldest.id}", json={"note": updated}).status_code == 200
|
|
|
|
# The GET list keeps its newest-first order and carries the new text.
|
|
body = admin_client.get("/api/steering").json()
|
|
assert [n["note"] for n in body["notes"]] == ["newest note", updated]
|
|
|
|
# The chat path reads the updated note back, oldest first… (expire the
|
|
# test session's identity map so the fresh DB values are loaded).
|
|
db.expire_all()
|
|
notes = load_steering_notes(db)
|
|
assert notes == [updated, "newest note"]
|
|
|
|
# …and it appears, in order, in the <tuning> prompt section.
|
|
section = build_steering_section(notes)
|
|
assert f"1. {updated}" in section
|
|
assert "2. newest note" in section
|
|
assert section.index("1. ") < section.index("2. ")
|
|
|
|
|
|
def test_update_unknown_note_returns_404(admin_client: TestClient) -> None:
|
|
r = admin_client.put(f"/api/steering/{uuid.uuid4()}", json={"note": "x"})
|
|
assert r.status_code == 404
|
|
assert r.json() == {"detail": "steering note not found"}
|
|
|
|
|
|
def test_update_rejects_invalid_bodies(admin_client: TestClient) -> None:
|
|
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
|
note_id = created["id"]
|
|
assert admin_client.put(f"/api/steering/{note_id}", json={"note": ""}).status_code == 422
|
|
assert admin_client.put(f"/api/steering/{note_id}", json={"note": " "}).status_code == 422
|
|
assert (
|
|
admin_client.put(f"/api/steering/{note_id}", json={"note": "x" * 2001}).status_code == 422
|
|
)
|
|
# The 2000-char boundary passes and is stored as-is.
|
|
r = admin_client.put(f"/api/steering/{note_id}", json={"note": "y" * 2000})
|
|
assert r.status_code == 200
|
|
assert len(r.json()["note"]) == 2000
|
|
|
|
|
|
def test_update_anonymous_returns_403(admin_client: TestClient, db) -> None:
|
|
created = admin_client.post("/api/steering", json={"note": NOTE}).json()
|
|
anon = TestClient(fastapi_app) # fresh jar: truly anonymous
|
|
|
|
r = anon.put(f"/api/steering/{created['id']}", json={"note": "anonymous edit"})
|
|
|
|
assert r.status_code == 403
|
|
assert r.json() == {"detail": "admin only"}
|
|
# The text is untouched, in both the API and the chat path.
|
|
body = admin_client.get("/api/steering").json()
|
|
assert [n["note"] for n in body["notes"]] == [NOTE]
|
|
assert load_steering_notes(db) == [NOTE]
|
|
|
|
|
|
# ---------- chat turn: note reaches the system prompt ----------
|
|
|
|
|
|
def test_chat_turn_high_mode_receives_note_in_system_prompt(
|
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
admin_client.post("/api/steering", json={"note": NOTE})
|
|
caplog.set_level(logging.INFO, logger="app.chat")
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
_, _, frames = _stream_chat(admin_client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
assert frames[-1]["type"] == "done"
|
|
assert frames[-1]["deflected"] is False
|
|
|
|
# The LLM received the HIGH prompt with the <tuning> section.
|
|
(system, user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
|
assert user["content"] == QUESTION
|
|
assert "<relevance>HIGH</relevance>" in system["content"]
|
|
assert "<tuning>" in system["content"]
|
|
assert f"1. {NOTE}" in system["content"]
|
|
assert "<documents>" in system["content"]
|
|
# The section sits between the relevance marker and the documents.
|
|
assert (
|
|
system["content"].index("<relevance>HIGH</relevance>")
|
|
< system["content"].index("<tuning>")
|
|
< system["content"].index("</tuning>")
|
|
< system["content"].index("<documents>")
|
|
)
|
|
|
|
# The per-turn log line records tuning=N (PLAN §9).
|
|
lines = _turn_log_lines(caplog)
|
|
assert lines and "tuning=1" in lines[-1]
|
|
|
|
|
|
def test_chat_turn_low_mode_receives_note_in_system_prompt(
|
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
admin_client.post("/api/steering", json={"note": NOTE})
|
|
caplog.set_level(logging.INFO, logger="app.chat")
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
_, _, frames = _stream_chat(admin_client, OFF_TOPIC)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
assert frames[-1]["deflected"] is True
|
|
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
|
assert "<relevance>LOW</relevance>" in system["content"]
|
|
assert "DEFLECT_MODE" in system["content"]
|
|
assert "<tuning>" in system["content"]
|
|
assert f"1. {NOTE}" in system["content"]
|
|
lines = _turn_log_lines(caplog)
|
|
assert lines and "tuning=1" in lines[-1]
|
|
|
|
|
|
def test_chat_turn_without_notes_has_no_tuning_section(
|
|
admin_client: TestClient, seeded_kb: FakeRagLLM, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
caplog.set_level(logging.INFO, logger="app.chat")
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
_stream_chat(admin_client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
|
assert "<tuning>" not in system["content"]
|
|
lines = _turn_log_lines(caplog)
|
|
assert lines and "tuning=0" in lines[-1]
|
|
|
|
|
|
def test_chat_turn_numbers_notes_oldest_first(admin_client: TestClient, db, seeded_kb) -> None:
|
|
base = datetime.now(UTC)
|
|
db.add_all(
|
|
[
|
|
SteeringNote(note="older note", created_at=base),
|
|
SteeringNote(note="newer note", created_at=base + timedelta(hours=1)),
|
|
]
|
|
)
|
|
db.commit()
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
_stream_chat(admin_client, QUESTION)
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|
|
|
|
(system, _user) = seeded_kb.seen_messages[0][0], seeded_kb.seen_messages[0][1]
|
|
assert "<tuning>" in system["content"]
|
|
assert "1. older note" in system["content"]
|
|
assert "2. newer note" in system["content"]
|
|
assert system["content"].index("1. older note") < system["content"].index("2. newer note")
|
|
|
|
|
|
def test_multiple_turns_keep_reading_notes(admin_client: TestClient, seeded_kb) -> None:
|
|
"""The note steers EVERY subsequent turn, not just the next one."""
|
|
admin_client.post("/api/steering", json={"note": NOTE})
|
|
|
|
fastapi_app.dependency_overrides[chat_api.get_llm] = lambda: seeded_kb
|
|
try:
|
|
_stream_chat(admin_client, QUESTION)
|
|
_stream_chat(admin_client, QUESTION)
|
|
assert len(seeded_kb.seen_messages) == 2
|
|
for messages in seeded_kb.seen_messages:
|
|
assert f"1. {NOTE}" in messages[0]["content"]
|
|
|
|
# Delete → the following turn is clean again.
|
|
note_id = admin_client.get("/api/steering").json()["notes"][0]["id"]
|
|
assert admin_client.delete(f"/api/steering/{note_id}").status_code == 204
|
|
_stream_chat(admin_client, QUESTION)
|
|
assert len(seeded_kb.seen_messages) == 3
|
|
assert "<tuning>" not in seeded_kb.seen_messages[-1][0]["content"]
|
|
finally:
|
|
fastapi_app.dependency_overrides.clear()
|