"""M5: showing the narrative state — to the model, and to the reader. Two audiences, one document, and they want different things. The model needs the state compactly, in the vocabulary it must answer in, close to where it generates. The reader needs it grouped and named, in the words the campaign uses. Both are read-only views. Neither can change state, and the browser gets its own data from the API rather than from anything assembled here, because `BUILD-MILESTONES.md` M5 is explicit that the browser is a presentation layer and must not become the owner of state. """ from __future__ import annotations from . import model # How much of a long section reaches the prompt. A campaign accumulates facts # faster than it accumulates anything else, and the context budget is finite; # the newest are the ones the current scene is most likely to need. M6 owns # retrieval-ranked selection, so this is deliberately a simple recency cut and # is documented as such rather than pretending to be a relevance model. PROMPT_FACTS = 30 PROMPT_RELATIONSHIPS = 20 PROMPT_THREADS = 12 # The headings of `for_prompt`, each a whole line. `extract` recognises a copy of # this section pasted into a narration by these, so they are named once here and # the two cannot drift apart. A small local model reproduced the section in its # prose on 42 of 104 turns in the first M01 run with the memory bank on. HEADING_SCENE = "Scene:" HEADING_ENTITIES = "Who and what exists:" HEADING_HELD = "Held:" HEADING_FACTS = "Established:" HEADING_WITHDRAWN = "No longer true — do not treat these as established:" HEADING_RELATIONSHIPS = "Between them:" HEADING_THREADS = "Still open:" #: Every heading except the scene's, which also opens the line it heads. SECTION_HEADINGS = ( HEADING_ENTITIES, HEADING_HELD, HEADING_FACTS, HEADING_WITHDRAWN, HEADING_RELATIONSHIPS, HEADING_THREADS, ) def for_prompt(state) -> str: """The current state as the narrator is shown it. Empty string when the campaign has established nothing, so a new story's prompt carries no heading with nothing under it. """ document = model.normalize(state) if model.is_empty(document): return "" lines: list[str] = [] scene = document.get("scene") or {} if scene.get("summary") or scene.get("location"): where = scene.get("location") head = f"{HEADING_SCENE} " + str(scene.get("summary") or "").strip() if where: head += f" (at {model.entity_name(document, where)})" lines.append(head.strip()) entities = document["entities"] if entities: lines.append("") lines.append(HEADING_ENTITIES) for key, entity in entities.items(): lines.append(f" {key}: {_entity_line(document, key, entity)}") possessions = document["possessions"] if possessions: lines.append("") lines.append(HEADING_HELD) for item, owner in sorted(possessions.items()): lines.append( f" {model.entity_name(document, item)} — " f"{model.entity_name(document, owner)}" ) facts = model.active_facts(document) if facts: lines.append("") lines.append(HEADING_FACTS) for fact in facts[-PROMPT_FACTS:]: lines.append(f" {_fact_line(document, fact)}") # What the campaign has taken back. Placed straight after what stands, so # the contradiction is resolved in the same breath it could be raised: the # story above may still narrate the moment, and this says it did not hold # (C04, M5 review Finding 4). withdrawn = model.withdrawn_facts(document) if withdrawn: lines.append("") lines.append(HEADING_WITHDRAWN) for fact in withdrawn[-PROMPT_FACTS:]: line = f" {_fact_line(document, fact)}" reason = fact.get("invalidated_reason") if reason: line += f" — {reason}" lines.append(line) relationships = model.active_relationships(document) if relationships: lines.append("") lines.append(HEADING_RELATIONSHIPS) for relationship in relationships[-PROMPT_RELATIONSHIPS:]: lines.append( f" {model.entity_name(document, relationship['source'])} " f"{relationship['type']} " f"{model.entity_name(document, relationship['target'])}" ) threads = model.open_threads(document) if threads: lines.append("") lines.append(HEADING_THREADS) for key, thread in list(threads.items())[:PROMPT_THREADS]: lines.append(f" {key}: {thread.get('title', key)}") return "\n".join(lines).strip() def _entity_line(document: dict, key: str, entity: dict) -> str: parts = [entity.get("name") or key] kind = entity.get("type") if kind and kind != "other": parts.append(f"({kind})") status = entity.get("status") if status and status != "active": parts.append(f"[{status}]") where = entity.get("location") if where: parts.append(f"at {model.entity_name(document, where)}") conditions = entity.get("conditions") or [] if conditions: parts.append("— " + ", ".join(conditions)) attributes = entity.get("attributes") or {} if attributes: parts.append( "— " + ", ".join(f"{name}={value}" for name, value in sorted(attributes.items())) ) return " ".join(str(p) for p in parts) def _fact_line(document: dict, fact: dict) -> str: parts = [] if fact.get("subject"): parts.append(model.entity_name(document, fact["subject"])) parts.append(str(fact.get("predicate", ""))) if fact.get("object"): parts.append(model.entity_name(document, fact["object"])) if fact.get("value") is not None: parts.append(str(fact["value"])) line = " ".join(str(p) for p in parts if p) if fact.get("authority") == "manual_correction": # The reader corrected this. Saying so in the prompt is what stops the # model re-deriving the thing the correction removed. line += " [corrected by the player]" return line def for_inspector(state) -> dict: """The current state grouped for the browser panel. Only categories that actually hold something are returned, so the panel can render what it is given without deciding what to hide — a category with no rows is a heading that tells the reader nothing. Every entry carries the key as well as the name. The key is what a manual correction has to name, so the panel can offer a correction without the user having to guess at an identifier. """ document = model.normalize(state) groups: list[dict] = [] scene = document.get("scene") or {} if scene.get("summary") or scene.get("location"): rows = [] if scene.get("summary"): rows.append({"key": "summary", "label": str(scene["summary"])}) if scene.get("location"): rows.append({ "key": scene["location"], "label": model.entity_name(document, scene["location"]), "detail": "location", }) groups.append({"title": "Current Scene", "rows": rows}) by_type: dict[str, list] = {} for key, entity in document["entities"].items(): by_type.setdefault(entity.get("type") or "other", []).append((key, entity)) # Characters and locations first because they are what a reader looks for; # everything else in whatever categories the campaign actually used, so a # science-fiction campaign's `vehicle` appears without this code knowing the # word (J02). order = ["character", "location"] + sorted( set(by_type) - {"character", "location"} ) for kind in order: members = by_type.get(kind) if not members: continue rows = [] for key, entity in sorted(members): detail = [] if entity.get("status") and entity["status"] != "active": detail.append(str(entity["status"])) if entity.get("location"): detail.append("at " + model.entity_name(document, entity["location"])) if entity.get("conditions"): detail.append(", ".join(entity["conditions"])) for name, value in sorted((entity.get("attributes") or {}).items()): detail.append(f"{name}: {value}") held = model.held_by(document, key) if held: detail.append( "carrying " + ", ".join(model.entity_name(document, i) for i in held) ) rows.append({ "key": key, "label": entity.get("name") or key, "detail": " · ".join(detail), }) groups.append({"title": _title_for(kind), "rows": rows}) possessions = document["possessions"] if possessions: groups.append({"title": "Possessions", "rows": [ { "key": item, "label": model.entity_name(document, item), "detail": "held by " + model.entity_name(document, owner), } for item, owner in sorted(possessions.items()) ]}) facts = model.active_facts(document) if facts: groups.append({"title": "Important Facts", "rows": [ { "key": fact.get("id") or "", "label": _fact_line(document, fact), "detail": _source_label(fact), } for fact in facts ]}) relationships = model.active_relationships(document) if relationships: groups.append({"title": "Relationships", "rows": [ { "key": relationship.get("id") or "", "label": ( f"{model.entity_name(document, relationship['source'])} " f"{relationship['type']} " f"{model.entity_name(document, relationship['target'])}" ), "detail": relationship.get("description") or "", } for relationship in relationships ]}) threads = model.open_threads(document) if threads: groups.append({"title": "Open Story Threads", "rows": [ { "key": key, "label": thread.get("title") or key, "detail": thread.get("description") or "", } for key, thread in sorted(threads.items()) ]}) return {"groups": groups, "empty": not groups} def _title_for(kind: str) -> str: """A heading for an entity category the campaign chose. Pluralised generically rather than from a table, because the categories are open: `DATA-MODEL.md` §9 suggests nine and permits any, so a lookup would silently mislabel the tenth. """ known = { "character": "Characters", "location": "Locations", "organization": "Organizations", "item": "Items", "vehicle": "Vehicles", "creature": "Creatures", "structure": "Structures", "concept": "Concepts", "other": "Other", } if kind in known: return known[kind] word = kind.replace("_", " ").strip().title() return word if word.endswith("s") else word + "s" def _source_label(fact: dict) -> str: source = fact.get("authority") or fact.get("source") or "" return { "manual_correction": "your correction", "campaign_canon": "campaign canon", "accepted_story": "from the story", }.get(source, str(source).replace("_", " "))