8 Commits

Author SHA1 Message Date
Joseph Doherty 29e6f346ef merge: T79 _witness_role_for defensive None handling 2026-04-26 21:42:24 -04:00
Joseph Doherty cb570a5adc merge: T78 search_memories docstring SQL-bias note 2026-04-26 21:42:24 -04:00
Joseph Doherty ce4f56adfa merge: T77 AddresseeDecision.confidence as Literal 2026-04-26 21:42:24 -04:00
Joseph Doherty bdc93b4b67 merge: T76 narrate_skip timeout_s plumbed through 2026-04-26 21:42:24 -04:00
Joseph Doherty 9c9d71eb31 fix: _witness_role_for defensive None handling (T79) 2026-04-26 21:41:15 -04:00
Joseph Doherty 4199038b8b fix: AddresseeDecision.confidence as Literal[high|medium|low] (T77) 2026-04-26 21:40:47 -04:00
Joseph Doherty e3dfe18811 docs: search_memories docstring mentions SQL-side significance bias (T78) 2026-04-26 21:40:40 -04:00
Joseph Doherty d759b90aa1 fix: plumb narrate_skip timeout_s through to client.generate (T76) 2026-04-26 21:40:29 -04:00
7 changed files with 108 additions and 3 deletions
+3 -1
View File
@@ -22,6 +22,8 @@ from a fallback.
from __future__ import annotations
from typing import Literal
from pydantic import BaseModel
from chat.llm.classify import classify
@@ -39,7 +41,7 @@ class AddresseeDecision(BaseModel):
"""
addressee_id: str
confidence: str = "medium" # "high" | "medium" | "low"
confidence: Literal["high", "medium", "low"] = "medium"
reason: str = ""
+8 -1
View File
@@ -379,8 +379,15 @@ def _witness_role_for(speaker_bot_id: str, host_bot_id: str | None) -> str:
pinned the contract on ``search_memories``; this helper applies it
at the call site so a guest-as-speaker doesn't silently retrieve
memories under the wrong POV mask.
When ``host_bot_id`` is ``None`` (degenerate case from a half-seeded
chat or Phase-1 path), the speaker is treated as the host so the
query falls back to the host POV mask rather than silently masking
the speaker's own memories as a guest.
"""
return "host" if speaker_bot_id == host_bot_id else "guest"
if host_bot_id is None or speaker_bot_id == host_bot_id:
return "host"
return "guest"
def _resolve_addressee(
+1
View File
@@ -96,6 +96,7 @@ async def narrate_skip(
model=narrative_model,
max_tokens=200,
temperature=0.7,
timeout_s=timeout_s,
)
text = (result or "").strip()
if not text:
+10
View File
@@ -125,6 +125,16 @@ def search_memories(
so that stronger candidates yield smaller composite scores; the result is
sorted ascending and truncated to ``k``. The unmodified ``fts_rank`` and a
debug-friendly ``composite_score`` are kept on each returned dict.
The result ordering applies TWO independent significance boosts:
* **SQL-side** — ``ORDER BY (rank - significance * SIGNIFICANCE_RANK_BIAS)``
pushes higher-significance memories ahead in the FTS5 candidate set so
the over-fetch already prefers them for tied / near-tied BM25 ranks
(T57, §11.1).
* **Python-side** — a composite re-rank with ``_SIGNIFICANCE_WEIGHT``
reinforces the ordering after candidate retrieval, alongside the
recency boost above.
"""
if witness_role not in _VALID_WITNESS_ROLES:
raise ValueError(
+35
View File
@@ -97,3 +97,38 @@ async def test_classifier_failure_falls_back_to_host():
assert result.addressee_id == "bot_a"
assert result.reason == "fallback"
assert result.confidence == "low"
@pytest.mark.asyncio
async def test_invalid_confidence_value_falls_back_to_default():
"""Pydantic rejects ``confidence`` values outside the literal set
(``high`` / ``medium`` / ``low``). After the retry budget is
exhausted, classify returns the configured fallback default —
here that's ``confidence="low"`` with ``reason="fallback"``.
"""
canned = [
json.dumps(
{
"addressee_id": "bot_a",
"confidence": "VERY_HIGH",
"reason": "out-of-range value",
}
),
"still_bad",
"still_bad",
]
client = MockLLMClient(canned=canned)
result = await detect_addressee(
client,
classifier_model="test-model",
user_prose="anything",
host_id="bot_a",
host_name="BotA",
guest_id="bot_b",
guest_name="BotB",
)
assert result.addressee_id == "bot_a"
assert result.confidence == "low"
assert result.reason == "fallback"
+8 -1
View File
@@ -21,7 +21,7 @@ import chat.state.world # noqa: F401
import chat.state.events # noqa: F401
import chat.state.threads # noqa: F401
from chat.llm.client import Message
from chat.services.prompt import assemble_narrative_prompt
from chat.services.prompt import _witness_role_for, assemble_narrative_prompt
def _seed_basic(conn) -> None:
@@ -852,3 +852,10 @@ def test_assemble_with_open_thread_renders_block(tmp_path):
body = msgs[0].content
assert "Open threads:" in body
assert "Maya's job hunt" in body
def test_witness_role_for_none_host_returns_host():
assert _witness_role_for("bot_a", None) == "host"
# Sanity check: existing semantics preserved.
assert _witness_role_for("bot_a", "bot_a") == "host"
assert _witness_role_for("bot_a", "bot_b") == "guest"
+43
View File
@@ -98,6 +98,49 @@ class _RaisingMock:
yield # pragma: no cover - make this a generator
class _RecordingMock:
"""Mock LLMClient that records the kwargs passed to ``generate``.
Used to assert that callers plumb through optional parameters like
``timeout_s`` instead of swallowing them. Returns a fixed string so
the surrounding fallback path is not exercised.
"""
def __init__(self) -> None:
self.captured_kwargs: dict | None = None
async def generate(
self, messages: Sequence[Message], *, model: str, **params
) -> str:
self.captured_kwargs = dict(params)
return "ok"
async def stream(
self, messages: Sequence[Message], *, model: str, **params
) -> AsyncIterator[str]:
raise RuntimeError("not used")
yield # pragma: no cover - make this a generator
@pytest.mark.asyncio
async def test_narrate_skip_passes_timeout_through():
mock = _RecordingMock()
await narrate_skip(
mock,
narrative_model="x",
skip_kind="jump",
speaker_bot=_SPEAKER,
you_name="Me",
current_time="late evening",
new_time="next morning",
current_activity="winding down for the night",
landing_state_hint="having coffee in the kitchen",
timeout_s=12.5,
)
assert mock.captured_kwargs is not None
assert mock.captured_kwargs.get("timeout_s") == 12.5
@pytest.mark.asyncio
async def test_narrate_falls_back_on_generation_failure():
new_time = "next morning"