|
| 1 | +"""Cat consultation decision + prompt assembly for GameLLMAgent. |
| 2 | +
|
| 3 | +GameLLM consults the catgirl at narrative decision points (visible choices, |
| 4 | +scene transitions, dialogue accumulation). The catgirl reply is **reference |
| 5 | +information**, not a directive — GameLLM keeps full decision authority. |
| 6 | +
|
| 7 | +The plan-level integration is fire-and-forget: the agent sends a consultation |
| 8 | +prompt through ``AgentMessageRouter`` and continues immediately. Replies arrive |
| 9 | +through the existing inbound message channel and are merged into |
| 10 | +``shared["cat_opinions"]`` by :func:`inject_cat_opinion`. ``cat_opinions`` is |
| 11 | +capped at ``MAX_CAT_OPINIONS`` so the strategy context cannot grow unbounded. |
| 12 | +
|
| 13 | +This module is intentionally pure (no I/O, no agent reference) so it is easy to |
| 14 | +unit-test. ``GameLLMAgent`` is expected to call ``decide_consultation`` from |
| 15 | +its ``tick`` loop, build the prompt with ``build_consult_prompt``, and dispatch |
| 16 | +the message via its existing outbound channel. |
| 17 | +""" |
| 18 | + |
| 19 | +from __future__ import annotations |
| 20 | + |
| 21 | +import time |
| 22 | +from dataclasses import dataclass, field |
| 23 | +from typing import Any |
| 24 | + |
| 25 | +from .llm_prompts import ( |
| 26 | + CONSULT_CAT_CHOICE_QUESTION_TEMPLATE, |
| 27 | + CONSULT_CAT_PROMPT_TEMPLATE, |
| 28 | + CONSULT_CAT_SCENE_CHANGE_QUESTION_TEMPLATE, |
| 29 | + CONSULT_CAT_STORY_PROGRESS_QUESTION_TEMPLATE, |
| 30 | +) |
| 31 | + |
| 32 | + |
| 33 | +CONSULT_COOLDOWN_SECONDS: float = 30.0 |
| 34 | +"""Lower bound on time between consultations (any reason).""" |
| 35 | + |
| 36 | +CONSULT_PROGRESS_LINE_THRESHOLD: int = 8 |
| 37 | +"""Dialogue lines since last consult before the progress reason fires.""" |
| 38 | + |
| 39 | +MAX_CAT_OPINIONS: int = 5 |
| 40 | +"""Cap on retained ``shared['cat_opinions']`` entries — older entries drop.""" |
| 41 | + |
| 42 | +MAX_RECENT_LINES_IN_PROMPT: int = 5 |
| 43 | +"""When building a progress prompt, fold at most this many recent lines.""" |
| 44 | + |
| 45 | +CONSULT_REASON_CHOICE: str = "choice" |
| 46 | +CONSULT_REASON_SCENE_CHANGE: str = "scene_change" |
| 47 | +CONSULT_REASON_STORY_PROGRESS: str = "story_progress" |
| 48 | + |
| 49 | +_REASON_PRIORITY: tuple[str, ...] = ( |
| 50 | + CONSULT_REASON_CHOICE, |
| 51 | + CONSULT_REASON_SCENE_CHANGE, |
| 52 | + CONSULT_REASON_STORY_PROGRESS, |
| 53 | +) |
| 54 | + |
| 55 | + |
| 56 | +@dataclass(frozen=True, slots=True) |
| 57 | +class ConsultInputs: |
| 58 | + """Snapshot of the data needed to make a consultation decision.""" |
| 59 | + |
| 60 | + character_mode: str = "off" |
| 61 | + character_fixed_name: str = "" |
| 62 | + scene_id: str = "" |
| 63 | + visible_choices: tuple[str, ...] = () |
| 64 | + scene_changed: bool = False |
| 65 | + lines_since_last_consult: int = 0 |
| 66 | + now: float = 0.0 |
| 67 | + last_consult_ts: float = 0.0 |
| 68 | + profile_known: bool = False |
| 69 | + |
| 70 | + |
| 71 | +@dataclass(frozen=True, slots=True) |
| 72 | +class ConsultDecision: |
| 73 | + """Outcome of :func:`decide_consultation`.""" |
| 74 | + |
| 75 | + should_consult: bool |
| 76 | + reason: str = "" |
| 77 | + skip_reason: str = "" |
| 78 | + character_name: str = "" |
| 79 | + |
| 80 | + @property |
| 81 | + def consulted(self) -> bool: |
| 82 | + return self.should_consult |
| 83 | + |
| 84 | + |
| 85 | +@dataclass(frozen=True, slots=True) |
| 86 | +class CatOpinion: |
| 87 | + """A single recorded catgirl reply, returned by :func:`inject_cat_opinion`.""" |
| 88 | + |
| 89 | + opinion: str |
| 90 | + scene_id: str |
| 91 | + reason: str |
| 92 | + ts: float |
| 93 | + metadata: dict[str, Any] = field(default_factory=dict) |
| 94 | + |
| 95 | + def to_dict(self) -> dict[str, Any]: |
| 96 | + return { |
| 97 | + "opinion": self.opinion, |
| 98 | + "scene_id": self.scene_id, |
| 99 | + "reason": self.reason, |
| 100 | + "ts": self.ts, |
| 101 | + "metadata": dict(self.metadata), |
| 102 | + } |
| 103 | + |
| 104 | + |
| 105 | +# --------------------------------------------------------------------------- |
| 106 | +# Decision |
| 107 | +# --------------------------------------------------------------------------- |
| 108 | + |
| 109 | + |
| 110 | +def decide_consultation(inputs: ConsultInputs) -> ConsultDecision: |
| 111 | + """Decide whether to consult the catgirl, and why. |
| 112 | +
|
| 113 | + Order of checks (highest priority first): |
| 114 | +
|
| 115 | + 1. Mode gate — only ``fixed`` mode consults; ``off`` never does. |
| 116 | + 2. Profile gate — refuse when the fixed character is not in the profile |
| 117 | + set (the consultation prompt depends on its voice block). |
| 118 | + 3. Cooldown — refuse when the last consult was < ``CONSULT_COOLDOWN_SECONDS`` |
| 119 | + ago. Cooldown wins over every trigger to prevent fast-forward flooding. |
| 120 | + 4. Trigger — first matching reason among choice / scene change / progress. |
| 121 | + """ |
| 122 | + |
| 123 | + if (inputs.character_mode or "").strip().lower() != "fixed": |
| 124 | + return ConsultDecision(False, skip_reason="mode_off") |
| 125 | + if not (inputs.character_fixed_name or "").strip(): |
| 126 | + return ConsultDecision(False, skip_reason="no_fixed_character") |
| 127 | + if not inputs.profile_known: |
| 128 | + return ConsultDecision( |
| 129 | + False, |
| 130 | + skip_reason="profile_missing", |
| 131 | + character_name=inputs.character_fixed_name, |
| 132 | + ) |
| 133 | + |
| 134 | + elapsed = max(0.0, float(inputs.now) - float(inputs.last_consult_ts)) |
| 135 | + if inputs.last_consult_ts > 0 and elapsed < CONSULT_COOLDOWN_SECONDS: |
| 136 | + return ConsultDecision( |
| 137 | + False, |
| 138 | + skip_reason="cooldown", |
| 139 | + character_name=inputs.character_fixed_name, |
| 140 | + ) |
| 141 | + |
| 142 | + reason = _select_reason(inputs) |
| 143 | + if not reason: |
| 144 | + return ConsultDecision( |
| 145 | + False, |
| 146 | + skip_reason="no_trigger", |
| 147 | + character_name=inputs.character_fixed_name, |
| 148 | + ) |
| 149 | + return ConsultDecision( |
| 150 | + True, |
| 151 | + reason=reason, |
| 152 | + character_name=inputs.character_fixed_name, |
| 153 | + ) |
| 154 | + |
| 155 | + |
| 156 | +def _select_reason(inputs: ConsultInputs) -> str: |
| 157 | + for candidate in _REASON_PRIORITY: |
| 158 | + if _reason_active(candidate, inputs): |
| 159 | + return candidate |
| 160 | + return "" |
| 161 | + |
| 162 | + |
| 163 | +def _reason_active(reason: str, inputs: ConsultInputs) -> bool: |
| 164 | + if reason == CONSULT_REASON_CHOICE: |
| 165 | + return bool(inputs.visible_choices) |
| 166 | + if reason == CONSULT_REASON_SCENE_CHANGE: |
| 167 | + return bool(inputs.scene_changed) |
| 168 | + if reason == CONSULT_REASON_STORY_PROGRESS: |
| 169 | + return inputs.lines_since_last_consult >= CONSULT_PROGRESS_LINE_THRESHOLD |
| 170 | + return False |
| 171 | + |
| 172 | + |
| 173 | +# --------------------------------------------------------------------------- |
| 174 | +# Prompt construction |
| 175 | +# --------------------------------------------------------------------------- |
| 176 | + |
| 177 | + |
| 178 | +def build_consult_prompt( |
| 179 | + *, |
| 180 | + reason: str, |
| 181 | + character_name: str, |
| 182 | + character_voice_summary: str, |
| 183 | + scene_summary: str, |
| 184 | + visible_choices: tuple[str, ...] = (), |
| 185 | + recent_lines: tuple[str, ...] = (), |
| 186 | +) -> str: |
| 187 | + """Render the consultation prompt using the shared templates.""" |
| 188 | + |
| 189 | + question = _build_consult_question( |
| 190 | + reason=reason, |
| 191 | + character_name=character_name, |
| 192 | + visible_choices=visible_choices, |
| 193 | + recent_lines=recent_lines, |
| 194 | + ) |
| 195 | + return CONSULT_CAT_PROMPT_TEMPLATE.format( |
| 196 | + scene_summary=(scene_summary or "").strip() or "(暂无场景摘要)", |
| 197 | + consult_question=question, |
| 198 | + character_name=(character_name or "").strip() or "(未指定角色)", |
| 199 | + character_voice_summary=(character_voice_summary or "").strip() |
| 200 | + or "(角色说话方式未提供)", |
| 201 | + ) |
| 202 | + |
| 203 | + |
| 204 | +def _build_consult_question( |
| 205 | + *, |
| 206 | + reason: str, |
| 207 | + character_name: str, |
| 208 | + visible_choices: tuple[str, ...], |
| 209 | + recent_lines: tuple[str, ...], |
| 210 | +) -> str: |
| 211 | + name = (character_name or "").strip() or "(未指定角色)" |
| 212 | + if reason == CONSULT_REASON_CHOICE: |
| 213 | + return CONSULT_CAT_CHOICE_QUESTION_TEMPLATE.format( |
| 214 | + choices=";".join(choice for choice in visible_choices if choice) |
| 215 | + or "(未提供选项)", |
| 216 | + character_name=name, |
| 217 | + ) |
| 218 | + if reason == CONSULT_REASON_SCENE_CHANGE: |
| 219 | + return CONSULT_CAT_SCENE_CHANGE_QUESTION_TEMPLATE.format( |
| 220 | + character_name=name |
| 221 | + ) |
| 222 | + if reason == CONSULT_REASON_STORY_PROGRESS: |
| 223 | + recent_text = "\n".join( |
| 224 | + line.strip() |
| 225 | + for line in recent_lines[-MAX_RECENT_LINES_IN_PROMPT:] |
| 226 | + if (line or "").strip() |
| 227 | + ) or "(暂无最近台词)" |
| 228 | + return CONSULT_CAT_STORY_PROGRESS_QUESTION_TEMPLATE.format( |
| 229 | + recent_lines=recent_text |
| 230 | + ) |
| 231 | + return f"作为 {name},你有什么想说的?" |
| 232 | + |
| 233 | + |
| 234 | +def summarize_character_voice(profile: dict[str, Any] | None) -> str: |
| 235 | + """Compact voice summary suitable for the consultation prompt header.""" |
| 236 | + if not isinstance(profile, dict): |
| 237 | + return "" |
| 238 | + voice = profile.get("character_voice") |
| 239 | + if not isinstance(voice, dict): |
| 240 | + return "" |
| 241 | + traits = voice.get("core_traits") |
| 242 | + if not isinstance(traits, list): |
| 243 | + return "" |
| 244 | + parts: list[str] = [] |
| 245 | + for trait_obj in traits[:2]: |
| 246 | + if not isinstance(trait_obj, dict): |
| 247 | + continue |
| 248 | + trait = str(trait_obj.get("trait") or "").strip() |
| 249 | + speech = str(trait_obj.get("speech_effect") or "").strip() |
| 250 | + if trait and speech: |
| 251 | + parts.append(f"{trait}→{speech}") |
| 252 | + pronoun = str(voice.get("first_person_pronoun") or "").strip() |
| 253 | + if pronoun: |
| 254 | + parts.append(f"自称「{pronoun}」") |
| 255 | + return ";".join(parts) |
| 256 | + |
| 257 | + |
| 258 | +# --------------------------------------------------------------------------- |
| 259 | +# Opinion injection |
| 260 | +# --------------------------------------------------------------------------- |
| 261 | + |
| 262 | + |
| 263 | +def inject_cat_opinion( |
| 264 | + shared: dict[str, Any], |
| 265 | + opinion: str, |
| 266 | + *, |
| 267 | + scene_id: str = "", |
| 268 | + reason: str = "", |
| 269 | + ts: float | None = None, |
| 270 | + metadata: dict[str, Any] | None = None, |
| 271 | +) -> CatOpinion | None: |
| 272 | + """Append a catgirl opinion to ``shared['cat_opinions']`` (capped).""" |
| 273 | + text = (opinion or "").strip() |
| 274 | + if not text: |
| 275 | + return None |
| 276 | + record = CatOpinion( |
| 277 | + opinion=text, |
| 278 | + scene_id=(scene_id or "").strip(), |
| 279 | + reason=(reason or "").strip(), |
| 280 | + ts=float(ts if ts is not None else time.time()), |
| 281 | + metadata=dict(metadata or {}), |
| 282 | + ) |
| 283 | + queue = shared.get("cat_opinions") |
| 284 | + if not isinstance(queue, list): |
| 285 | + queue = [] |
| 286 | + queue = list(queue) |
| 287 | + queue.append(record.to_dict()) |
| 288 | + if len(queue) > MAX_CAT_OPINIONS: |
| 289 | + queue = queue[-MAX_CAT_OPINIONS:] |
| 290 | + shared["cat_opinions"] = queue |
| 291 | + return record |
| 292 | + |
| 293 | + |
| 294 | +def get_recent_cat_opinions( |
| 295 | + shared: dict[str, Any], n: int = MAX_CAT_OPINIONS |
| 296 | +) -> list[dict[str, Any]]: |
| 297 | + if n <= 0: |
| 298 | + return [] |
| 299 | + queue = shared.get("cat_opinions") |
| 300 | + if not isinstance(queue, list): |
| 301 | + return [] |
| 302 | + return [dict(entry) for entry in queue[-n:]] |
| 303 | + |
| 304 | + |
| 305 | +def render_cat_opinions_for_strategy( |
| 306 | + shared: dict[str, Any], n: int = MAX_CAT_OPINIONS |
| 307 | +) -> str: |
| 308 | + """Render recent opinions as a strategy-context block. |
| 309 | +
|
| 310 | + The header is intentional: cat replies are high-priority fixed-character |
| 311 | + POV advice, but GameLLM still keeps final decision authority. |
| 312 | + """ |
| 313 | + recent = get_recent_cat_opinions(shared, n) |
| 314 | + if not recent: |
| 315 | + return "" |
| 316 | + lines = ["[Fixed-character POV advice: high-priority reference, not a command]"] |
| 317 | + for entry in recent: |
| 318 | + opinion = str(entry.get("opinion") or "").strip() |
| 319 | + if not opinion: |
| 320 | + continue |
| 321 | + reason = str(entry.get("reason") or "").strip() |
| 322 | + tag = f"({reason})" if reason else "" |
| 323 | + lines.append(f"· {opinion}{tag}") |
| 324 | + return "\n".join(lines) if len(lines) > 1 else "" |
| 325 | + |
| 326 | + |
| 327 | +__all__ = [ |
| 328 | + "ConsultInputs", |
| 329 | + "ConsultDecision", |
| 330 | + "CatOpinion", |
| 331 | + "decide_consultation", |
| 332 | + "build_consult_prompt", |
| 333 | + "summarize_character_voice", |
| 334 | + "inject_cat_opinion", |
| 335 | + "get_recent_cat_opinions", |
| 336 | + "render_cat_opinions_for_strategy", |
| 337 | + "CONSULT_COOLDOWN_SECONDS", |
| 338 | + "CONSULT_PROGRESS_LINE_THRESHOLD", |
| 339 | + "MAX_CAT_OPINIONS", |
| 340 | + "MAX_RECENT_LINES_IN_PROMPT", |
| 341 | + "CONSULT_REASON_CHOICE", |
| 342 | + "CONSULT_REASON_SCENE_CHANGE", |
| 343 | + "CONSULT_REASON_STORY_PROGRESS", |
| 344 | +] |
0 commit comments