Files
chat/chat/services/state_update.py
T
2026-04-26 13:17:07 -04:00

145 lines
5.1 KiB
Python

"""Post-turn state-update pass.
Per Requirements §3.4, after every utterance we run a classifier on each
present entity (silent witnesses included) to extract directed-edge
deltas — what changed in *source*'s view of *target*. The classifier
returns three signals:
- ``affinity_delta`` — signed change in how warmly source feels (typical
range -3..+3; the edge handler clamps the running total to 0..100).
- ``trust_delta`` — signed change in source's trust of target (same
shape).
- ``knowledge_facts`` — concrete things source learned about target
during this exchange. Stored verbatim and appended to ``edge.knowledge``.
The wrapper deliberately uses :func:`chat.llm.classify.classify` with a
``default=StateUpdate()`` so a flapping classifier never blocks the turn
flow — at worst the edge sits unchanged and the next turn tries again
(§3.3 "graceful degradation" rule).
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from chat.llm.classify import classify
from chat.llm.client import LLMClient
class StateUpdate(BaseModel):
"""One directed-edge update from a single classifier call.
Defaults are deliberately a no-op (zero deltas, empty facts) so a
failing classifier produces a benign event rather than a disruption.
"""
affinity_delta: int = 0
trust_delta: int = 0
knowledge_facts: list[str] = Field(default_factory=list)
_SYSTEM_PROMPT = (
"You are reading a recent slice of dialogue from a roleplay scene. "
"You assess how SOURCE's view of TARGET shifted based on what was "
"said — including silent witnessing (SOURCE may not have spoken).\n\n"
"Output a JSON object with exactly three fields:\n"
"- affinity_delta: signed integer in [-3, 3]. How much warmer "
"(positive) or cooler (negative) SOURCE now feels toward TARGET.\n"
"- trust_delta: signed integer in [-3, 3]. How much more (positive) "
"or less (negative) SOURCE now trusts TARGET.\n"
"- knowledge_facts: list of short strings. New, concrete facts "
"SOURCE learned about TARGET in this exchange. Use TARGET's actual "
"stated content; do not infer or interpret. Empty list is fine.\n\n"
"Be conservative. Most turns produce small deltas (-1, 0, +1). "
"Reserve +/-2 or +/-3 for moments that materially shift the "
"relationship. Knowledge_facts should be specific things stated in "
"dialogue (e.g. \"works at the bakery\"), not interpretations "
"(\"seems lonely\")."
)
def _format_dialogue(recent_dialogue: list[dict]) -> str:
"""Render the recent-dialogue slice as plain ``Speaker: text`` lines."""
if not recent_dialogue:
return "(no dialogue yet)"
lines = []
for turn in recent_dialogue:
speaker = turn.get("speaker", "?")
text = turn.get("text", "")
lines.append(f"{speaker}: {text}")
return "\n".join(lines)
def _build_user_prompt(
*,
source_name: str,
source_persona: str,
target_name: str,
prior_affinity: int,
prior_trust: int,
prior_summary: str,
recent_dialogue: list[dict],
) -> str:
return (
f"SOURCE: {source_name}\n"
f"SOURCE_PERSONA: {source_persona or '(none)'}\n"
f"TARGET: {target_name}\n"
f"PRIOR_AFFINITY (0-100): {prior_affinity}\n"
f"PRIOR_TRUST (0-100): {prior_trust}\n"
f"PRIOR_SUMMARY: {prior_summary or '(none)'}\n\n"
f"RECENT_DIALOGUE:\n{_format_dialogue(recent_dialogue)}\n\n"
"How did SOURCE's view of TARGET shift? Respond with JSON only."
)
async def compute_state_update(
client: LLMClient,
*,
model: str,
source_id: str,
target_id: str,
source_name: str,
source_persona: str,
target_name: str,
prior_affinity: int,
prior_trust: int,
prior_summary: str,
recent_dialogue: list[dict],
timeout_s: float = 10.0,
) -> StateUpdate:
"""Run a classifier pass and return the directed-edge update.
On classifier failure (after retry) returns the schema default — a
no-op ``StateUpdate`` — so the turn flow can keep moving. The
``source_id`` / ``target_id`` arguments are accepted for symmetry
with the caller (T20's POST flow uses them when emitting the
``edge_update`` event); they're not currently embedded in the
prompt because the classifier reasons about names, not opaque ids.
"""
# ``source_id``/``target_id`` are kept on the signature even though
# the prompt only quotes the names: callers in turns.py thread the
# ids straight from this function's args into the appended event.
del source_id, target_id # silence unused-arg lint cleanly
user_prompt = _build_user_prompt(
source_name=source_name,
source_persona=source_persona,
target_name=target_name,
prior_affinity=prior_affinity,
prior_trust=prior_trust,
prior_summary=prior_summary,
recent_dialogue=recent_dialogue,
)
return await classify(
client,
model=model,
system=_SYSTEM_PROMPT,
user=user_prompt,
schema=StateUpdate,
default=StateUpdate(),
timeout_s=timeout_s,
)