feat: post-turn state-update pass per present entity
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
"""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,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user