"""World chat: deterministic environment lookup + harmless improv + writeback. Tiered resolution (Section 8.4): 1. Authored salient detail -> deterministic lookup; the environment tier only *narrates* the retrieved fact (no invention). 2. Unauthored detail -> may improvise, but only if the query touches no evidential object/clue topic; otherwise defer ("nothing notable"). 3. Invariant: improvised details are never incriminating/exculpatory, and are written back to world_delta.json so re-asks stay consistent. """ from __future__ import annotations import json from dataclasses import dataclass from pathlib import Path from ..llm.client import LLMClient from ..llm.prompts import PromptRegistry from ..models import EnvObject from ..worldio import World from .clues import ClueGraph @dataclass class WorldAnswer: text: str discovered_clue: str = "" source: str = "" # authored | improv | deferred | delta object_id: str = "" class WorldDelta: """Persisted improvised harmless details (Section 9 / runtime state).""" def __init__(self, path: Path) -> None: self.path = path self.details: dict[str, str] = {} if path.exists(): self.details = json.loads(path.read_text("utf-8")).get("details", {}) def get(self, key: str) -> str | None: return self.details.get(key) def put(self, key: str, value: str) -> None: self.details[key] = value self.path.write_text(json.dumps({"details": self.details}, indent=2), "utf-8") class EnvironmentChat: def __init__( self, *, world: World, client: LLMClient, prompts: PromptRegistry, clue_graph: ClueGraph, delta: WorldDelta, ) -> None: self.world = world self.client = client self.prompts = prompts self.clue_graph = clue_graph self.delta = delta def _match_authored( self, query: str, location: str | None ) -> list[tuple[EnvObject, int]]: """Return (object, overlap_score) for objects the query plausibly hits, ranked best-first. Scoring by token overlap stops a generic shared word (e.g. "desk") from shadowing a more specific match.""" q = query.lower().replace("?", " ").replace(",", " ") tokens = {t.strip(".'\"") for t in q.split() if len(t) > 3} scored: list[tuple[EnvObject, int]] = [] for obj in self.world.environment: if location and obj.location.lower() != location.lower(): continue hay = f"{obj.id} {obj.description_true}".lower() score = sum(1 for t in tokens if t in hay) if score == 0 and location and not tokens: score = 1 # bare "look @location" surfaces something there if score: scored.append((obj, score)) scored.sort(key=lambda os: os[1], reverse=True) return scored def ask( self, *, query: str, location: str | None, discovered: set[str] ) -> WorldAnswer: # 1) authored salient detail -> deterministic lookup matches = self._match_authored(query, location) visible = [ (o, score) for (o, score) in matches if o.visible_by_default or self._unlocked(o, discovered) ] if visible: # Prefer a still-undiscovered piece of evidence over already-found or # non-evidential objects, then by match strength. def rank(item: tuple[EnvObject, int]) -> tuple[int, int]: o, score = item fresh = ( o.evidential and o.clue is not None and self.clue_graph.is_unlocked(o.clue, discovered) and o.clue not in discovered ) return (1 if fresh else 0, score) obj = max(visible, key=rank)[0] narration = self._narrate(query, obj.description_true) clue = "" if obj.evidential and obj.clue and self.clue_graph.is_unlocked( obj.clue, discovered ): clue = obj.clue return WorldAnswer( text=narration, discovered_clue=clue, source="authored", object_id=obj.id, ) # If a match exists but is gated/evidential and not yet unlocked: defer. if matches: return WorldAnswer( text="Nothing notable catches your eye there — at least not yet.", source="deferred", ) # 2) unauthored: does the query touch an evidential object/clue topic? if self._touches_evidential(query): return WorldAnswer( text="You look carefully, but find nothing notable.", source="deferred", ) # check delta cache for prior improv key = f"{location or 'scene'}::{query.strip().lower()}" cached = self.delta.get(key) if cached: return WorldAnswer(text=cached, source="delta") # 3) harmless improv + writeback narration = self._improvise(query, location) self.delta.put(key, narration) return WorldAnswer(text=narration, source="improv") def _unlocked(self, obj: EnvObject, discovered: set[str]) -> bool: if not obj.clue: return True return self.clue_graph.is_unlocked(obj.clue, discovered) def _touches_evidential(self, query: str) -> bool: q = query.lower() for obj in self.world.environment: if not obj.evidential: continue tokens = [t for t in obj.description_true.lower().split() if len(t) > 4] if any(t in q for t in tokens) or obj.id.lower() in q: return True for node in self.world.clues: tokens = [t for t in node.reveals.lower().split() if len(t) > 4] if any(t in q for t in tokens): return True return False def _narrate(self, query: str, fact: str) -> str: prompt = self.prompts.render( "environment/narrate.md.j2", query=query, fact=fact, improvise=False, ) return self.client.complete( tier="environment", task="env_narrate", user=prompt, ).text.strip() def _improvise(self, query: str, location: str | None) -> str: prompt = self.prompts.render( "environment/narrate.md.j2", query=query, fact="", improvise=True, location=location or "the scene", ) return self.client.complete( tier="environment", task="env_narrate", user=prompt, ).text.strip()