145 lines
5.1 KiB
Python
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,
|
|
)
|
|
|
|
|