Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29e6f346ef | |||
| cb570a5adc | |||
| ce4f56adfa | |||
| bdc93b4b67 | |||
| 9c9d71eb31 | |||
| 4199038b8b | |||
| e3dfe18811 | |||
| d759b90aa1 |
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user