Files
JesseMarkowitzandClaude Opus 5 d27ee34901 Docs: consolidate active planning and archive historical material
The planning package had grown to where a new agent could not tell what was
authoritative. Phase 0 execution prompts sat beside the specification; four
completed milestone reports sat beside the current one; and upstream AI-DnD's
own `plan/` build log and `docs/` project site still described a hosted,
scripted, multi-user product with accounts — every screenshot in it showed a
Scripts tab and a Sign up button, none of which has existed since M2.

`planning/archive/` now holds the history and says so in its own README:
`phase0/` for the research that chose AI-DnD, `milestone-reports/` for M1 and
M2, `decisions/` for ADR 008, the Phase-0-before-build gate Phase 0 satisfied.
`planning/reports/` holds only the current milestone's report, because that is
the one M4 planning has to read; it moves to the archive when M4's replaces it.

Deleted rather than archived: the Phase 0B execution prompts and the
handoff/status/summary documents, the Phase 0A discovery and triage reports,
upstream's `plan/` and `docs/` trees, and `frontend/README.md`, which was Vite's
template boilerplate. All of it is in Git history, and the two recommendation
reports carry every conclusion the deleted research reached.

Archived documents are kept verbatim. Paths written inside them point at where
those files were when the document was written, which is the point: an evidence
record that has been quietly edited is no longer evidence.

Active documentation is corrected where it pointed at the removed trees or
described removed capability as present. `DEVELOPMENT.md`'s "things M1 did not
touch" list had gone stale at M2 and claimed QuickJS scripting was still tested;
its test count was 604 against an actual 638. `README.md` loses the upstream CI
badge, which reported upstream's pipeline rather than this fork's, and a
reference to `backend/app/worldstate/engine.py`, a file that does not exist.
`planning/README.md` is rewritten as the documentation index.

New: `planning/PROJECT-SOURCES.md` and `planning/project-sources.txt`, the
manifest of what belongs in the ChatGPT project's Sources.

Source comments referring to the deleted trees are reworded; no behaviour
changes. 638 backend tests pass, frontend lints and builds, and a reference scan
over all 48 tracked Markdown files reports no unresolved path in active
documentation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NCbwH7yLGKsj1rhXXzKSCu
2026-09-03 14:33:07 -04:00

257 lines
11 KiB
Python

"""A/B the memory prompt against a real model, on one story held constant.
Reviewing a prompt tells you what it asks for. It does not tell you what a model
does with it. This runs the real pipeline twice over the *same* blocks of the
*same* story, changing only the prompt, and prints the memories side by side.
Why `MEMORY_MAX_WORDS` exists is what it showed the first time it was run: two
consecutive memories from one story came back in two different persons, and the
same model wrote 34 words for one block and 105 for the next.
Three things make it a fair test rather than a demonstration:
- **The story is generated once**, through the app's own `build_context`, and
both arms summarize the same actions. The prompt is the only variable.
- **The control is read from git**, at the commit named by `--before`, so it is
the prompt that actually shipped rather than a paraphrase of it.
- **Every call goes through `OpenAICompatibleProvider`** to the endpoint in
`--endpoint`, so this exercises the provider, the streaming path and
`complete()` rather than a stub.
Point it at `tools/claude_shim.py` to spend a Claude subscription instead of API
credit:
python tools/claude_shim.py --port 8787 &
python tools/memory_ab.py --out /tmp/ab.md
Point `--endpoint` at the provider the deployed app really uses to learn
something this cannot tell you: whether a weaker model follows the framing rule
as well as a Claude model does.
Options:
--endpoint OpenAI-compatible base URL. Defaults to the local shim.
--model Model name to ask that endpoint for. Defaults to `sonnet`.
--before Git commit holding the prompt to compare against.
--turns How many player turns to generate. Six gives two blocks.
--out Write the full transcript here as Markdown.
This writes to a scratch SQLite file and drops it afterwards. It never touches
`backend/data.db`.
"""
import argparse
import asyncio
import os
import re
import subprocess
import sys
import tempfile
import textwrap
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
# The moves the player makes. Fixed, so that a re-run summarizes comparable
# story rather than a different adventure.
PLAYER_TURNS = [
"go quiet and signal Gwen to circle around the right flank",
"search the nearest bedroll for anything useful",
"move toward the strongbox, keeping low",
"grab her wrist and pull her down behind the woodpile",
"wait for the bandit to turn, then move",
"ask her whether she still trusts my read on this",
]
def prompt_at(commit: str, name: str) -> str:
"""Reads one prompt constant out of `memorybank.py` as of `commit`.
Read from git rather than pasted here, so that the control cannot drift out
of step with what was actually shipped.
"""
source = subprocess.run(
["git", "show", f"{commit}:backend/app/memorybank.py"],
capture_output=True, text=True, cwd=REPO, check=True,
).stdout
match = re.search(rf"^{name} = \((.*?)^\)$", source, re.S | re.M)
if not match:
raise SystemExit(f"{name} not found in memorybank.py at {commit}")
namespace: dict = {"MEMORY_MAX_WORDS": 50, "SUMMARY_MAX_WORDS": 250}
return eval(f"({match.group(1)})", namespace) # noqa: S307 — our own source
def words(text: str) -> int:
return len(text.split())
def framing(text: str) -> str:
"""How this memory refers to the protagonist. The thing being measured."""
tags = []
if re.search(r"\byou(r)?\b", text, re.I):
tags.append('second person ("you")')
if re.search(r"\bthe player\b", text, re.I):
tags.append('"the player"')
return ", ".join(tags) or "named"
async def main(args) -> None:
from app import memorybank, models, tree, worldstate
from app.context import build_context, truncate_to_last_tokens
from app.database import Base, SessionLocal, engine
from app.providers import OpenAICompatibleProvider
from app.routers.adventures.scenario_text import scenario_card_specs
from app.seed import seed_public_scenarios
def provider(model: str | None = None) -> OpenAICompatibleProvider:
return OpenAICompatibleProvider(
args.endpoint, "unused-by-the-shim", model or args.model, "chat", 0)
async def generate(system: str, story: str) -> str:
"""One turn, through the provider's real streaming path."""
from app.providers.base import PromptParts
out = []
async for kind, piece in provider().generate(
PromptParts(system=system, story=story), temperature=0.8,
max_tokens=args.max_tokens,
):
if kind == "text":
out.append(piece)
return "".join(out).strip()
Base.metadata.create_all(bind=engine)
seed_public_scenarios(engine)
db = SessionLocal()
user = models.User(is_guest=False, email="memory-ab@example.com")
db.add(user)
db.flush()
settings = models.Settings(user_id=user.id, api_key="enc:d",
model=args.model, max_output_tokens=args.max_tokens)
db.add(settings)
scenario = db.query(models.Scenario).filter(
models.Scenario.title.like("%Bandit Camp%")).one()
adventure = models.Adventure(
user_id=user.id, title=scenario.title, scenario_id=scenario.id,
script_state={}, auto_summarize=True, memory=scenario.memory,
authors_note=scenario.authors_note,
ai_instructions=scenario.ai_instructions,
world_state=worldstate.instantiate(scenario.stat_schema),
persona_name="Kaelen", persona_pronouns="he/him",
persona_desc=("A half-elf ranger, exiled from the northern holds for a "
"killing he still won't explain. Wary of nobles, soft on "
"strays."),
)
db.add(adventure)
db.flush()
tree.head_branch(db, adventure)
for ref, spec in scenario_card_specs(scenario, {}).items():
db.add(models.StoryCard(adventure_id=adventure.id, source_ref=ref, **spec))
opening = models.Action(adventure_id=adventure.id, type="start",
text=scenario.prompt)
tree.place_action(db, adventure, opening)
db.add(opening)
db.commit()
db.refresh(adventure)
print(f"Generating {args.turns} turns through build_context…", flush=True)
for i, move in enumerate(PLAYER_TURNS[:args.turns], 1):
player = models.Action(adventure_id=adventure.id, type="do", text=move)
tree.place_action(db, adventure, player)
db.add(player)
db.commit()
db.refresh(adventure)
system_text, story_text, _ = build_context(adventure, settings)
clean, delta = worldstate.extract_delta(await generate(system_text, story_text))
ai = models.Action(adventure_id=adventure.id, type="ai", text=clean)
tree.place_action(db, adventure, ai)
db.add(ai)
if delta:
adventure.world_state, report = worldstate.apply_delta(
adventure.world_state, scenario.stat_schema, delta, i)
ai.world_delta = report
db.commit()
db.refresh(adventure)
print(f" [{i}] {move} → {words(clean)} words"
+ (f", state {delta}" if delta else ""), flush=True)
old_system = prompt_at(args.before, "MEMORY_SYSTEM_PROMPT")
actions = sorted(
(a for a in adventure.actions if a.type in ("start", "do", "ai")),
key=lambda a: (a.depth if a.depth is not None else 0, a.id),
)
blocks = [actions[i:i + memorybank.MEMORY_INTERVAL]
for i in range(0, len(actions) - memorybank.MEMORY_INTERVAL + 1,
memorybank.MEMORY_INTERVAL)]
print(f"\n{len(actions)} actions → {len(blocks)} blocks. Summarizing each twice…",
flush=True)
rows = []
for n, block in enumerate(blocks, 1):
raw = "\n\n".join(a.text for a in block)
plain = (f"Story excerpt:\n\n"
f"{truncate_to_last_tokens(raw, 2000)}\n\nMemory:")
brief = memorybank.cast_brief(adventure, raw)
before = await provider(args.model).complete(old_system, plain)
after = await provider(args.model).complete(
memorybank.MEMORY_SYSTEM_PROMPT,
f"{brief}\n\n{plain}" if brief else plain)
rows.append((n, before.strip(), after.strip()))
print(f" block {n}: before {words(before)}w ({framing(before)}), "
f"after {words(after)}w ({framing(after)})", flush=True)
print("\n" + "=" * 74)
print(f"{'':16}{'words':>6} framing")
print("-" * 74)
for n, before, after in rows:
for label, text in (("before", before), ("after", after)):
print(f"memory {n} {label:7}{words(text):>6} {framing(text)}")
print("=" * 74)
if args.out:
with open(args.out, "w", encoding="utf-8") as fh:
fh.write("# Memory prompt A/B\n\n")
fh.write(f"One story, generated through `build_context` against "
f"`{args.model}` at `{args.endpoint}`. Both prompts then "
f"summarize the same blocks, so the prompt is the only "
f"variable. The control is `MEMORY_SYSTEM_PROMPT` as of "
f"`{args.before}`.\n\n")
fh.write("| memory | arm | words | framing |\n|---|---|---|---|\n")
for n, before, after in rows:
for label, text in (("before", before), ("after", after)):
fh.write(f"| {n} | {label} | {words(text)} | {framing(text)} |\n")
for n, before, after in rows:
fh.write(f"\n## Memory {n}\n\n**Before:** {before}\n\n"
f"**After:** {after}\n")
fh.write("\n## The story both arms summarized\n\n")
for a in actions:
fh.write(f"**{a.type}:** {a.text}\n\n")
print(f"\nwritten to {args.out}")
db.close()
Base.metadata.drop_all(bind=engine)
if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__.split("\n")[0])
parser.add_argument("--endpoint", default="http://127.0.0.1:8787/v1")
parser.add_argument("--model", default="sonnet")
parser.add_argument("--before", default="9cdcb55",
help="commit holding the prompt to compare against")
parser.add_argument("--turns", type=int, default=6)
parser.add_argument("--max-tokens", type=int, default=700)
parser.add_argument("--out")
args = parser.parse_args()
# A scratch database, so a run never touches the developer's own.
handle = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
handle.close()
os.environ["AIDND_DB_PATH"] = handle.name
os.environ.pop("AIDND_DATABASE_URL", None)
os.environ.pop("DATABASE_URL", None)
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
try:
asyncio.run(main(args))
finally:
Path(handle.name).unlink(missing_ok=True)