f-id / src /id /engine /environment.py
marcodsn's picture
Initial Gradio Space
0423b99
Raw
History Blame Contribute Delete
6.72 kB
"""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()