Add story-to-pack research and a structured situation review

Research toward building a content pack from a story corpus, kept on its own
branch and independent of the game. Records the selection experiments against
blind labels, and settles selection as gate G2 followed by a human review:
review.py writes REVIEW.md and a review.json form, apply_review.py checks the
filled form and writes situations.json for the next stage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6UDQ9o6L6Ey173U7XVou6
This commit is contained in:
JesseMarkowitz
2026-09-15 06:59:35 -04:00
co-authored by Claude Opus 5
parent d84ae495f4
commit fa3769d0fe
112 changed files with 79868 additions and 0 deletions
+67
View File
@@ -0,0 +1,67 @@
"""Reduce each scene to one generic sentence about the SITUATION, not the story.
The 2026-09-10 probe found that prose embeddings cluster by story identity --
names, setting, narrator voice -- rather than by what kind of situation a scene
is. This strips exactly those signals before embedding.
python3 summarise.py --limit 40 # pilot: time it and read the output
python3 summarise.py # everything, resuming from summaries.json
Model from STP_CHAT_MODEL (default qwen2.5:3b-instruct): the requirement is a
small local model, so the prompt is written for one.
"""
import argparse, json, os, pathlib, re, time
import ollama
ap = argparse.ArgumentParser()
ap.add_argument('--limit', type=int, default=None)
args = ap.parse_args()
MODEL = os.environ.get('STP_CHAT_MODEL', 'qwen2.5:3b-instruct')
# v1 gave examples of roles ("a clerk", "a landlady", "a rich man", "a city
# park"). A 3B model copied them into scenes that contain none of those people,
# which would make unrelated scenes cluster together and fake the very
# recurrence this probe measures. v2 names no example role at all.
SYSTEM = """Describe the social situation in a passage of fiction in ONE sentence.
Rules:
- Exactly one sentence, at most 25 words.
- Never use anyone's name. Refer to each person only by the role the passage gives them: their job, their family tie, or their position.
- Never name a place, business, street or city.
- Say what is happening between the people: who wants what from whom, who holds power, what is at stake.
- Do not describe the writing style, the narrator, or the mood.
- Output only the sentence."""
# The v2 pilot also tried one retry with a reminder whenever a name slipped
# through. It fired 35 times in 40, nearly doubled the run, and 27 summaries
# still carried names -- so there is no retry. Names are removed afterwards by
# strip_names.py, deterministically.
# A full stop after these is not the end of a sentence ("Mr. Peters", "St. Bernard").
END = re.compile(r'(?<!\bMr)(?<!\bMrs)(?<!\bMs)(?<!\bDr)(?<!\bSt)(?<!\bNo)[.!?](?:\s|$)')
def first_sentence(text):
text = re.sub(r'\s+', ' ', text).strip().strip('"')
m = END.search(text)
return text[:m.end()].strip() if m else text
chunks = json.loads(pathlib.Path('chunks.json').read_text(encoding='utf-8'))
if args.limit: chunks = chunks[:args.limit]
out = pathlib.Path('summaries.json')
state = json.loads(out.read_text()) if out.exists() else {'model': MODEL, 'prompt': SYSTEM, 'summaries': []}
if state['model'] != MODEL or state['prompt'] != SYSTEM:
raise SystemExit('summaries.json was made with a different model or prompt; move it aside first')
done = state['summaries']
print(f'{len(chunks)} scenes, resuming at {len(done)}, model {MODEL}', flush=True)
t0, gen_ns, pre_ns = time.time(), 0, 0
for i in range(len(done), len(chunks)):
text, g, p = ollama.chat(MODEL, SYSTEM, chunks[i]['text'])
gen_ns += g; pre_ns += p
done.append({'chunk': i, 'story': chunks[i]['story'],
'summary': first_sentence(text), 'model_output': text})
out.write_text(json.dumps(state, indent=1))
if (i + 1) % 10 == 0 or i + 1 == len(chunks):
el = time.time() - t0
print(f' {i + 1}/{len(chunks)} {el:.0f}s prefill {pre_ns / 1e9:.0f}s gen {gen_ns / 1e9:.0f}s', flush=True)
print(f'done: {len(done)} summaries, {time.time() - t0:.0f}s this session')