**Phase 118 final verification pass — complete.** All criteria verified; 4 pre-existing defects found and fixed.
- **Verified:** summary-seed wiring (`select_suggested` top-5 no-floor → summary blocks, no full text in HIGH prompt), all-doc markdown summaries + NULL backfill (`summary_backfilled`, no `sources_meta` bump), `read` adds full text with `read_docs`-only dedupe, `done.sources` = suggested+read / durable record = suggested+related+read + `suggested=N` log line (seen live in E2E), byte-locked PERSONA/LOW/TOOLS_SECTION, battery gate PASS recorded in `TOOL_CALLING_TESTING.md` §10 (turbo 2026-09-16: 1/2/4 GREEN, cond-3 reported 9/10 per A7, contract 21/21, caps 0).
- **Defects fixed (all pre-existing, none phase-118):** ① `ChatMessage` schema missing the phase-113 `related` key → `extra="forbid"` 422'd every done-time auto-save of grounded turns with a related tier, leaving `message_count=1` (root cause of `test_share_chat` 3F; browser-level instrumentation proved the PUT 422) — added the field + unit/integration pins; ② `test_theme_semantic_completion` pins stale vs phase-117 debox (border/chip removed) — re-targeted to assert border/chip *absence*; ③ `test_header_consistency` `<26`px pin red on 26.125px native date-input line — bound relaxed to `<34` (wrap-detection intent kept); ④ `test_navbar_refresh` bor.chat.v1 key set updated for `related`.
- **Test/lint/coverage:** `uv run pytest --cov=app --cov-report=term-missing` → **2506 passed, app/ 99%** (>90%); `uv run ruff check . && uv run pyright` → clean, 0 errors.
- **E2E:** new story suite in isolation → **2 passed**; full 103-suite matrix sweep (each isolated) → **all 103 green** after the fixes; `test_share_chat` 4 passed, `test_theme_semantic_completion` 8 passed, `test_header_consistency` 3 passed, `test_navbar_refresh` 7 passed.
- **Deviations:** none from LOCKED decisions. Note: orphaned diagnostic uvicorn processes briefly made E2E sessions exercise stale code — killed and re-verified; a sweep-regenerated tracked screenshot was restored. No commits made (harness commits).
- **Completion criteria:** all 7 ✅ (commit/phase-move is the harness's step).
- **Next pending phase:** none — `todo/` holds only this phase's overview pending the harness move.
376 lines
15 KiB
Python
376 lines
15 KiB
Python
"""Unit: the phase-106 D5 date surfaces (task 06) — the date rides every
|
|
document the model sees.
|
|
|
|
Three surfaces, pinned here against pure inputs (no database):
|
|
|
|
* the HIGH prompt's ``<document>`` block carries
|
|
``date="YYYY-MM-DD"`` — the row's ``created_at`` UTC date part,
|
|
APPENDED after ``title`` (the only position; always present,
|
|
``created_at`` is NOT NULL) — while the DEFLECTION prompt stays
|
|
byte-identical to the pre-phase text (the A8 contract — titles only,
|
|
no documents, no dates);
|
|
* the ``read`` tool result carries ``date: YYYY-MM-DD`` as its SECOND
|
|
line — the FIRST line stays ``Document {source}/{path}:``
|
|
BYTE-IDENTICAL (the E2E mock's ``_READ_RESULT_PREFIX`` header
|
|
contract) — in BOTH the plain and the truncated shapes (marker +
|
|
notice still follow the cut content);
|
|
* the ``ls`` FILE line ENDS with the appended `` | date: YYYY-MM-DD``
|
|
field (never inserted before ``title``) and the 50-line cap note is
|
|
unchanged; source/folder lines carry no date.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import uuid
|
|
from collections.abc import AsyncIterator
|
|
from datetime import UTC, datetime
|
|
from typing import Any, cast
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.config import Settings
|
|
from app.models import Document
|
|
from app.rag import agent
|
|
from app.rag.agent import READ_TRUNCATION_NOTICE, AgentHolder, run_agent
|
|
from app.rag.llm import (
|
|
LLMClient,
|
|
StreamPiece,
|
|
ToolCallPiece,
|
|
)
|
|
from app.rag.prompts import (
|
|
_base,
|
|
build_deflect_prompt,
|
|
build_high_prompt,
|
|
build_steering_section,
|
|
)
|
|
from app.rag.retriever import TRUNCATION_MARKER
|
|
|
|
#: A fixed creation date; the UTC date part is ``2024-06-15``.
|
|
CREATED_AT = datetime(2024, 6, 15, 12, 30, 45, tzinfo=UTC)
|
|
DATE = "2024-06-15"
|
|
|
|
|
|
def _doc(
|
|
source: str = "S",
|
|
path: str = "P",
|
|
title: str = "T",
|
|
content: str = "CONTENT",
|
|
created_at: datetime = CREATED_AT,
|
|
summary: str | None = None,
|
|
) -> Document:
|
|
return Document(
|
|
id=uuid.uuid4(),
|
|
source=source,
|
|
path=path,
|
|
full_path=f"/tmp/{path}",
|
|
title=title,
|
|
content=content,
|
|
content_hash="0" * 64,
|
|
created_at=created_at,
|
|
summary=summary,
|
|
)
|
|
|
|
|
|
def _settings(**kwargs: Any) -> Settings:
|
|
kwargs.setdefault("_env_file", None)
|
|
return Settings(**kwargs) # pyright: ignore[reportCallIssue]
|
|
|
|
|
|
class ScriptedLLM:
|
|
"""Canned stream sequences; records every ``chat_stream`` request."""
|
|
|
|
def __init__(self, *streams: list[StreamPiece | ToolCallPiece]) -> None:
|
|
self.streams: list[list[StreamPiece | ToolCallPiece]] = list(streams)
|
|
self.requests: list[tuple[list[dict[str, Any]], list[dict[str, Any]] | None]] = []
|
|
|
|
async def chat_stream(
|
|
self,
|
|
messages: list[dict[str, str]],
|
|
tools: list[dict[str, Any]] | None = None,
|
|
scaffolding: object = None,
|
|
) -> AsyncIterator[StreamPiece | ToolCallPiece]:
|
|
self.requests.append((messages, tools))
|
|
if not self.streams:
|
|
return
|
|
for piece in self.streams.pop(0):
|
|
yield piece
|
|
|
|
|
|
async def _run(
|
|
llm: ScriptedLLM,
|
|
holder: AgentHolder,
|
|
settings: Settings,
|
|
) -> None:
|
|
async for _piece in run_agent(
|
|
cast("LLMClient", llm),
|
|
lambda: cast("Session", MagicMock()), # SEC-14-04: session factory
|
|
system_prompt="SYSTEM_PROMPT",
|
|
user_message="QUESTION",
|
|
seed_docs=[],
|
|
settings=settings,
|
|
holder=holder,
|
|
):
|
|
pass
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# The HIGH prompt's <document> block — date="YYYY-MM-DD" after title
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_high_block_carries_date_attribute_after_title() -> None:
|
|
"""The block's identity attributes are ``source`` / ``path`` /
|
|
``title`` — and, phase 106 (D5), ``date`` APPENDED after ``title``
|
|
(the only position), always present."""
|
|
doc = _doc(source="S", path="P", title="T", content="THE BODY")
|
|
prompt = build_high_prompt([doc])
|
|
block = (
|
|
f'<document source="S" path="P" title="T" date="{DATE}">\n'
|
|
"THE BODY\n"
|
|
"</document>"
|
|
)
|
|
assert block in prompt
|
|
# The attribute order is pinned: date directly after title, then
|
|
# the closing bracket (nothing may land between title and date or
|
|
# after date before the block opens).
|
|
assert f'title="T" date="{DATE}">' in prompt
|
|
assert '<document source="S" path="P" title="T">\n' not in prompt # pre-phase shape is gone
|
|
|
|
|
|
def test_high_block_date_is_the_utc_date_part() -> None:
|
|
"""The date is the row's ``created_at`` UTC DATE part: a late UTC
|
|
instant (23:59:59 on 2024-06-15) renders ``2024-06-15`` — and the
|
|
time-of-day is never shown (full precision is stored; only the
|
|
date part rides the block). Stored rows are always UTC-aware
|
|
(``timestamptz`` read-back + :func:`normalize_doc_date`), so the
|
|
UTC date part is the stored date part."""
|
|
doc = _doc(created_at=datetime(2024, 6, 15, 23, 59, 59, tzinfo=UTC))
|
|
prompt = build_high_prompt([doc])
|
|
assert f'title="T" date="{DATE}">' in prompt
|
|
assert "2024-06-16" not in prompt
|
|
# The time-of-day is never rendered on the block.
|
|
assert "23:59" not in prompt and "12:30" not in prompt
|
|
|
|
|
|
def test_high_block_date_always_present_for_every_document() -> None:
|
|
"""Every document block carries the date (``created_at`` is NOT
|
|
NULL — the attribute is never omitted), each with its OWN row's
|
|
date."""
|
|
a = _doc(source="A", path="a.md", title="A", content="CA", created_at=CREATED_AT)
|
|
b = _doc(
|
|
source="B",
|
|
path="b.md",
|
|
title="B",
|
|
content="CB",
|
|
created_at=datetime(2019, 6, 15, 5, 0, 0, tzinfo=UTC),
|
|
)
|
|
prompt = build_high_prompt([a, b])
|
|
assert '<document source="A" path="a.md" title="A" date="2024-06-15">' in prompt
|
|
assert '<document source="B" path="b.md" title="B" date="2019-06-15">' in prompt
|
|
# No date-less block anywhere.
|
|
assert prompt.count("<document ") == prompt.count(' date="')
|
|
|
|
|
|
def test_high_block_date_survives_the_summary_body_change() -> None:
|
|
"""Phase 118 (task 03): the block BODY became the document's summary
|
|
(never the full content) — the D5 identity attributes, including
|
|
``date`` after ``title``, survive byte-identical around the new
|
|
body (the E2E mock's block parser keys off exactly these)."""
|
|
doc = _doc(
|
|
source="S",
|
|
path="P",
|
|
title="T",
|
|
content="FULL_CONTENT_SENTINEL_42",
|
|
created_at=CREATED_AT,
|
|
summary="The stored summary.",
|
|
)
|
|
prompt = build_high_prompt([doc])
|
|
block = (
|
|
f'<document source="S" path="P" title="T" date="{DATE}">\n'
|
|
"The stored summary.\n"
|
|
"</document>"
|
|
)
|
|
assert block in prompt
|
|
# Attribute order pinned: date directly after title, body after.
|
|
assert f'title="T" date="{DATE}">' in prompt
|
|
# The full content stays out (A6) — only the summary rides the block.
|
|
assert "FULL_CONTENT_SENTINEL_42" not in prompt
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# The deflection prompt — byte-identical to the pre-phase text (A8)
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_deflect_prompt_is_byte_identical_to_pre_phase() -> None:
|
|
"""The deflection path is titles-only (no documents involved): the
|
|
A8 byte-identity contract holds — same inputs, pre-phase bytes,
|
|
with or without steering notes."""
|
|
# No notes: the pre-phase LOW build verbatim.
|
|
expected_plain = (
|
|
_base("LOW")
|
|
+ "\n"
|
|
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
|
"your notes come to the question. They are titles only; do not pretend "
|
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
|
+ "Reply in plain text only — you have no tools in this mode.\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
assert build_deflect_prompt(["T1", "T2"]) == expected_plain
|
|
# With steering notes: the pre-phase text + the <tuning> section
|
|
# (phase 15) — still no date anywhere.
|
|
expected_steered = (
|
|
_base("LOW")
|
|
+ "\n"
|
|
+ build_steering_section(["be concise"])
|
|
+ "\n"
|
|
+ "DEFLECT_MODE: retrieval was weak — the titles below are the closest "
|
|
"your notes come to the question. They are titles only; do not pretend "
|
|
"they answer it. Use them to propose 2-3 alternative questions.\n"
|
|
+ "Reply in plain text only — you have no tools in this mode.\n"
|
|
+ "- T1\n- T2"
|
|
)
|
|
assert build_deflect_prompt(["T1", "T2"], notes=["be concise"]) == expected_steered
|
|
for prompt in (expected_plain, expected_steered):
|
|
assert "<document" not in prompt
|
|
assert 'date="' not in prompt
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# The read result — the date is the SECOND line (first line
|
|
# byte-identical — the mock's _READ_RESULT_PREFIX header contract)
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_read_result_plain_carries_date_second_line(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""At or under the cap: ``Document {source}/{path}:`` (byte-
|
|
identical FIRST line) + ``date: YYYY-MM-DD`` (the D5 SECOND line)
|
|
+ the full content — nothing else."""
|
|
doc = _doc(source="S", path="a.md", title="A", content="A-CONTENT")
|
|
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
|
holder = AgentHolder()
|
|
llm = ScriptedLLM(
|
|
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/a.md"})],
|
|
[StreamPiece("content", "ans")],
|
|
)
|
|
asyncio.run(_run(llm, holder, _settings()))
|
|
assert holder.read_docs == [doc] and holder.tool_calls == 1
|
|
content = llm.requests[1][0][3]["content"]
|
|
lines = content.splitlines()
|
|
assert lines[0] == "Document S/a.md:" # byte-identical first line
|
|
assert lines[1] == f"date: {DATE}" # the D5 second line
|
|
assert lines[2:] == ["A-CONTENT"]
|
|
|
|
|
|
def test_read_result_truncated_carries_date_then_marker_and_notice(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Over the cap: the SAME two header lines, then the first ``cap``
|
|
chars, the shared marker, and the pinned notice — the truncation
|
|
contract (phase 95) is unchanged by the date line."""
|
|
cap = 10
|
|
content_body = "x" * 25
|
|
doc = _doc(source="S", path="big.md", title="Big", content=content_body)
|
|
monkeypatch.setattr(agent, "find_document", lambda db, source, path: doc)
|
|
holder = AgentHolder()
|
|
llm = ScriptedLLM(
|
|
[ToolCallPiece(id="call_1", name="read", arguments={"path": "S/big.md"})],
|
|
[StreamPiece("content", "ans")],
|
|
)
|
|
asyncio.run(_run(llm, holder, _settings(read_max_chars=cap)))
|
|
expected = (
|
|
"Document S/big.md:\n"
|
|
f"date: {DATE}\n"
|
|
+ content_body[:cap]
|
|
+ "\n"
|
|
+ TRUNCATION_MARKER
|
|
+ "\n"
|
|
+ READ_TRUNCATION_NOTICE.format(shown=cap, total=len(content_body))
|
|
)
|
|
assert llm.requests[1][0][3]["content"] == expected
|
|
# The truncation still records on the holder (the ToolResultPiece
|
|
# signal) — the date line adds nothing to the counters.
|
|
assert holder.read_truncations == [("S/big.md", cap, len(content_body))]
|
|
assert holder.tool_calls == 1
|
|
|
|
|
|
# --------------------------------------------------------------------
|
|
# The ls FILE line — the appended " | date: YYYY-MM-DD" field
|
|
# --------------------------------------------------------------------
|
|
|
|
|
|
def test_ls_file_line_ends_with_appended_date() -> None:
|
|
"""The ``ls`` FILE line is ``source: X | path: Y | title: Z`` with
|
|
the phase-106 D5 field APPENDED at the END (never inserted before
|
|
``title`` — the mock's non-greedy ``path`` capture would swallow an
|
|
inserted field)."""
|
|
rows = [
|
|
("a.md", "A", "2019-06-15"),
|
|
("b.md", "B", "2024-06-15"),
|
|
]
|
|
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
|
|
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
|
assert total == 2
|
|
lines = rendered.splitlines()
|
|
assert lines[0] == "S — 2 documents, 0 folders:"
|
|
# The file lines end with the appended date field — per-row dates.
|
|
assert lines[2] == "source: S | path: a.md | title: A | date: 2019-06-15"
|
|
assert lines[3] == "source: S | path: b.md | title: B | date: 2024-06-15"
|
|
# The date field is the LAST field (appended, never inserted before
|
|
# ``title`` — where the mock's non-greedy path capture would
|
|
# swallow it).
|
|
for line in (lines[2], lines[3]):
|
|
assert line.index("date:") > line.index("title:")
|
|
assert line.rsplit(" | ", 1)[-1].startswith("date: ") # date is final
|
|
|
|
|
|
def test_ls_folder_and_source_lines_carry_no_date() -> None:
|
|
"""Only FILE lines are documents: the folder header, the indented
|
|
subfolder lines, and the top-level source lines carry NO date."""
|
|
rows = [
|
|
("a/x.md", "X", "2019-06-15"),
|
|
("a/y.md", "Y", "2020-01-02"),
|
|
("top.md", "Top", "2024-06-15"),
|
|
]
|
|
subfolders, files, total = agent.group_folder_listing("S", "", rows, {"a": "A stuff."})
|
|
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
|
lines = rendered.splitlines()
|
|
assert lines[0] == "S — 1 documents, 1 folders:" # header: no date
|
|
assert lines[2] == " a/ — 2 documents: A stuff." # subfolder: no date
|
|
# Only the DIRECT root file lists (a/ is a subfolder line — the
|
|
# drill-down is one level per call), and it alone carries a date.
|
|
file_lines = [line for line in lines if line.startswith("source: ")]
|
|
assert file_lines == [
|
|
"source: S | path: top.md | title: Top | date: 2024-06-15"
|
|
]
|
|
# The top level (source lines) is byte-identical to the pre-phase
|
|
# template — no date on source lines.
|
|
assert agent.render_ls_top([("S", 3, "Source stuff.")]) == (
|
|
"1 sources:\n\nS — 3 documents\n Source stuff."
|
|
)
|
|
assert "date" not in agent.render_ls_top([("S", 3, None)])
|
|
|
|
|
|
def test_ls_cap_note_unchanged_past_fifty() -> None:
|
|
"""The 50-line cap note is UNCHANGED by the date field: 51 files →
|
|
50 lines (each with its appended date) + the exact pre-phase note;
|
|
the line COUNT is unaffected by the wider lines."""
|
|
rows = [(f"f{i:03d}.md", f"T{i}", "2024-06-15") for i in range(51)]
|
|
subfolders, files, total = agent.group_folder_listing("S", "", rows, {})
|
|
rendered = agent.render_folder_listing("S", subfolders, files, total)
|
|
lines = rendered.splitlines()
|
|
assert total == 51 and len(lines) == 1 + 1 + 50 + 1 # header, blank, 50, note
|
|
assert lines[-1] == (
|
|
"…and 1 more documents in this folder — use grep (pattern) to "
|
|
"find a specific one."
|
|
)
|
|
assert lines[1] == "" # the blank after the header is untouched
|
|
assert all(
|
|
line.endswith(" | date: 2024-06-15")
|
|
for line in lines[2:52]
|
|
)
|