Files

398 lines
19 KiB
Python

#!/usr/bin/env python3
"""One-off story-knowledge cleanup:
./maintain-job.sh <novel> (runs as a Job; deploys do not interrupt it)
[--dry-run] [--duplicates-only | --prune-only | --looks-only | --terms-only | --aliases-only]
1. Fold one person recorded under several spellings ("Choi Jong Bom" / "Jong-bum Choi") into a
single character, so transliteration variants do not fill the cast with the same face.
2. Prune entries that are not named individuals (groups, "bald tattooed cultivator",
translators). Deterministic rules decide the obvious cases; one batched MiniMax M2.7 call judges
the rest.
3. Fold inflated look lists (power levels, injuries, disguises recorded as looks) into a
few real looks: one MiniMax M2.7 call per character with more than MAX_LOOKS looks.
4. Drop glossary "terms" that are ordinary words ("Ring", "Return"): a script test on the
chapter texts, no model call. A proper noun keeps its capitals; a common word shows up
lowercase mid-sentence. Mentions are then recounted from the text.
5. Drop aliases that link mentions to the wrong person: shared titles and kinship words,
pasted lists, and aliases two characters both have (store.alias_problem).
6. Point the timeline back at the looks that still exist, so a chapter that names a look whose
record was lost is illustrated as its owner again (store.repair_looks)."""
import json
import difflib
import re
import sys
import kavita
import llm
import store
MAX_LOOKS = 3
KEEP_CHAPTERS = 5 # an entry in this many chapters, or ever drawn, is a character
BATCH = 150
# Words that mark a group rather than one individual.
GROUP = re.compile(r"\b(cultivators|elders|disciples|members|guards|soldiers|people|trio|duo|group|crowd|"
r"shadows|villagers|sorcerers|warriors|men|women|children|monks|beasts|demons)\b", re.I)
def translators(novel):
"""Names from credit lines ("Translator: X", "Editors: A, B") are never characters."""
names = set()
with store.connect() as conn:
for c in store.known_characters(conn, novel):
if re.search(r"\b(translat|editor|proofread)", c["role"] or "", re.I):
names.add(c["name"])
return names
def prune(novel, dry_run):
"""Groups and translators always go. Anything else MiniMax judges "not one individual"
goes only if it is rare and was never drawn: recurring beings known by an epithet
("the fox", "[She]") are real characters in many novels."""
with store.connect() as conn:
known = store.known_characters(conn, novel)
counts = store.mention_counts(conn, novel)
drawn = {r["name"] for r in conn.execute(
"SELECT DISTINCT name FROM looks WHERE novel = ? AND portrait != ''", (novel,))}
rule = {c["name"] for c in known if GROUP.search(c["name"])} | translators(novel)
# "Geomchi16" and "Geomchi-3" are members of a group named Geomchi, not people with names:
# thirty of them were recorded, each with the same dojo-disciple description. A member that
# was already drawn in a scene stays, so the picture still matches its own cast.
rule |= {c["name"] for c in known if c["name"] not in drawn and store.variant_of(known, c["name"])}
keep = {c["name"] for c in known if counts.get(("c", c["name"]), 0) >= KEEP_CHAPTERS or c["name"] in drawn}
rest = [c for c in known if c["name"] not in rule and c["name"] not in keep]
judged = set()
for i in range(0, len(rest), BATCH):
batch = rest[i:i + BATCH]
listing = "\n".join(f"- {c['name']}: {c['role'] or c['description'][:80]}" for c in batch)
wanted = {c["name"] for c in batch}
judged |= {n for n in llm.chat(llm.PRUNE_SYSTEM, listing).get("remove") or [] if n in wanted}
print(f"prune: {len(rule)} by rule, {len(judged)} by MiniMax, of {len(known)} "
f"({len(keep)} recurring or drawn characters were not questioned)")
for name in sorted(rule | judged):
print(f" - {name}")
if not dry_run:
store.remove_characters(novel, rule | judged)
DUP_BATCH = 50 # a longer listing times out on the fallback provider
def _same_person_groups(entries, size=DUP_BATCH):
"""The groups of entries the model says are one person. A batch that times out is asked
again in two halves, so one oversized listing cannot lose the whole run."""
out = []
for i in range(0, len(entries), size):
batch = entries[i:i + size]
listing = "\n".join(f"- {name} ({n} chapters, first in chapter {first + 1}): {text}"
for name, first, n, text in batch)
try:
answer = llm.chat(llm.DUPLICATE_SYSTEM, listing)
except Exception as e:
if len(batch) > 10:
print(f"duplicates: {len(batch)}-name batch at {i} halved ({e})", file=sys.stderr)
out += _same_person_groups(batch, len(batch) // 2)
else:
print(f"duplicates: batch at {i} skipped ({e})", file=sys.stderr)
continue
for group in answer.get("groups") or []:
if not isinstance(group, list):
continue
members, seen = [], set()
for name in group:
# A group or a title is not a person to merge into anybody.
if not isinstance(name, str) or name in seen or GROUP.search(name) or name not in _names(batch):
continue
seen.add(name)
members.append(name)
if len(members) > 1:
out += spelling_variants(members)
return out
def _names(batch):
return {entry[0] for entry in batch}
NAME_SIMILAR = 0.78 # name similarity at which two spellings are the same person's
def _plain(name):
return re.sub(r"[^a-z]", "", name.casefold())
def _close(a, b):
"""How alike two spellings are, 0 to 1, by the letters they share in order."""
return difflib.SequenceMatcher(None, _plain(a), _plain(b)).ratio()
def spelling_variants(members):
"""The sub-groups of a proposed group that are really one name written two ways. Asked
which cast members are the same person, a model handed fifty names will happily report a
chain — Gross, Heiller, Pandal, Porom, Vindel "the same individual" — and merging that
chain folds five strangers into one face. Transliteration variants ("Jang Yoon-soo" /
"Jang Yoon Soo", "Kalis" / "Kallis") look alike on the page, so that is the test; so does
a name whose words are the same words in another order ("Choi Jong Bom" / "Jong-bum Choi")."""
groups, rest = [], list(dict.fromkeys(members))
while rest:
cluster = [rest.pop(0)]
grew = True
while grew:
grew = False
for name in list(rest):
if any(_close(name, other) >= NAME_SIMILAR or shares_a_name(name, other)
for other in cluster):
cluster.append(name)
rest.remove(name)
grew = True
if len(cluster) > 1:
groups.append(cluster)
return groups
SAME_PERSON = 0.8 # description overlap at which two records are one person
NO_DETAIL = re.compile(r"\bnot (?:further |physically |otherwise )?described\b"
r"|\bno (?:physical |other |further )?details?\b", re.I)
def _name_tokens(name):
return {t for t in re.split(r"[^A-Za-z]+", name) if len(t) >= 3}
def shares_a_name(one, other):
"""Whether two names are largely the same words: "Choi Jong Bom" and "Jong-bum Choi" are
the same name backwards, which is how a transliteration ends up recorded twice. One word
in common is not enough — Abalone Porridge and Cockles Porridge are two guilds, and they
would pass on "porridge" alone."""
first, second = _name_tokens(one), _name_tokens(other)
return bool(first & second) and len(first & second) / len(first | second) >= 0.5
def described_twice(known):
"""The pairs of characters whose descriptions are the same person's, by content words.
"Choi Jong Bom" and "Jong Bom" were both written from the same paragraph, and a model
asked to spot spelling variants will happily agree they are two people.
Two things keep this from folding strangers together: a description that says the text
gave no detail is a placeholder (the three bullies who corner Kim Yo in one corridor are
all written "Teenage bully; not further described."), and the two names have to share a
word, because the cast is full of people written from one template — every one of the
Instructor's disciples reads "Adult male disciple of the dojang: hard-trained martial
artist's build, short-cropped black hair", and they are thirty different men."""
words = {c["name"]: set() if NO_DETAIL.search(c["description"] or "")
else store.content(c["description"] or "") for c in known}
pairs = []
for i, a in enumerate(known):
for b in known[i + 1:]:
first, second = words[a["name"]], words[b["name"]]
if len(first) < 5 or len(second) < 5 or not shares_a_name(a["name"], b["name"]):
continue
shared = len(first & second) / min(len(first), len(second))
if shared > SAME_PERSON:
pairs.append((a["name"], b["name"]))
return pairs
def duplicates(novel, dry_run):
"""Fold one person recorded under several spellings into a single character, so the cast
does not fill up with transliteration variants of the same face. MiniMax groups the cast
in batches ordered by the chapter each name first appears in, so the variants of one
person are compared together; the best-attested name survives and the rest become its
aliases. Records with the same description are folded first, without asking anyone."""
with store.connect() as conn:
known = store.known_characters(conn, novel)
counts = store.mention_counts(conn, novel)
firsts = {r["name"]: r["p"] for r in conn.execute(
"SELECT name, MIN(position) AS p FROM mentions WHERE novel = ? AND kind = 'c' GROUP BY name", (novel,))}
drawn = {r["name"] for r in conn.execute(
"SELECT DISTINCT name FROM looks WHERE novel = ? AND portrait != ''", (novel,))}
groups = [[a, b] for a, b in described_twice(known)]
# Ordered by where the name first turns up: two spellings of one person were written
# down chapters apart at most, so they land in the same batch.
entries = sorted((c["name"], firsts.get(c["name"], 0), counts.get(("c", c["name"]), 0),
" ".join((c["description"] or c["role"] or "").split()[:8])) for c in known)
groups += [g for g in _same_person_groups(entries) if not set(g) & {n for pair in groups for n in pair}]
merged = 0
for members in groups:
# The best-attested name keeps the record; a drawn one keeps it over a busier rival.
keep = max(members, key=lambda n: (counts.get(("c", n), 0), n in drawn, n))
for name in members:
if name == keep:
continue
print(f" {name} -> {keep}")
if not dry_run:
store.merge_characters(novel, keep, name)
merged += 1
print(f"duplicates: {merged} duplicate names of {len(groups)} groups, of {len(known)} characters")
def yield_to_reader():
"""MiniMax calls share one Token Plan; wait while the reader's illustrations are queued
(a busy cleanup made every scene call take minutes)."""
import time
while any(r["kind"] == "illustration" for r in store.queue_pending()):
time.sleep(30)
def consolidate(novel, dry_run):
with store.connect() as conn:
crowded = [(c["name"], store.looks_of(conn, novel, c["name"])) for c in store.known_characters(conn, novel)]
crowded = [(name, looks) for name, looks in crowded if len(looks) > MAX_LOOKS]
firsts = {name: {l["label"]: store.first_seen(conn, novel, name, l["label"]) for l in looks}
for name, looks in crowded}
failed = []
for name, looks in crowded:
# Short descriptions keep the prompt small (a 100-look list timed out otherwise).
listing = "\n".join(f"- \"{l['label']}\" (from chapter {(firsts[name][l['label']] or 0) + 1}): "
f"{' '.join(l['description'].split()[:25])}" for l in looks)
yield_to_reader()
try:
groups = llm.chat(llm.CONSOLIDATE_SYSTEM, f"Character: {name}\nLooks:\n{listing}")["looks"]
except Exception as e: # one slow or odd answer must not stop the rest
failed.append(name)
print(f"{name}: skipped ({e})")
continue
# Anything MiniMax forgot to place keeps its own group rather than being lost.
placed = {m for g in groups for m in g.get("members", [])}
groups = [g for g in groups if g.get("members")]
groups += [{"label": l["label"], "description": l["description"], "members": [l["label"]]}
for l in looks if l["label"] not in placed]
seen = set()
for g in groups:
g["members"] = [m for m in g["members"] if m not in seen and m in firsts[name] and not seen.add(m)]
groups = [g for g in groups if g["members"]]
labels = set()
for g in groups: # distinct labels, as consolidate_looks requires
base, n = (g.get("label") or g["members"][0]).strip()[:55], 2
g["label"] = base
while g["label"] in labels:
g["label"], n = f"{base} {n}", n + 1
labels.add(g["label"])
print(f"{name}: {len(looks)} looks -> {len(groups)}: " + "; ".join(g["label"] for g in groups))
if not dry_run:
store.consolidate_looks(novel, name, groups)
if failed:
print(f"looks: {len(failed)} characters skipped, rerun to retry: {', '.join(failed)}")
TERM_SAMPLE = 25 # chapters sampled per term
def timeline(novel, dry_run):
"""Point the chapters back at the looks that still exist.
Folding, renaming and merging looks all move the timeline with them, but a look record
can go missing on its own — restoring `characters` and `looks` from a snapshot leaves a
newer timeline naming labels the snapshot never had — and every chapter from then on is
illustrated from the "initial" look instead. On the Moonlight Sculptor that is how Weed,
a lean twenty-year-old labourer, ended up drawn as a two-metre Chaos Warrior through the
first eighty chapters of his own story.
"""
with store.connect() as conn:
stale = store.stale_looks(conn, novel)
if not stale:
print("timeline: every look the chapters name is on file")
return
def choose(label, listing):
try:
return llm.chat(llm.LOOK_SYSTEM,
f"Look the chapters name but no record survives: {label}\nLooks:\n{listing}")["look"]
except Exception as e: # one slow or odd answer must not stop the rest
print(f" {label}: skipped ({e})", file=sys.stderr)
return None
moved = store.repair_looks(novel, choose, dry_run)
print(f"timeline: {len(moved)} of {len(stale)} lost looks found a record "
f"({sum(n for _, _, _, n in moved)} chapters)")
for name, label, target, n in moved:
print(f" {name}: {label} ({n} chapters) -> {target}")
if not dry_run and moved:
print(" the chapters that were illustrated as somebody else keep their pictures; "
"say so and they are repainted")
def clean_aliases(novel, dry_run):
"""Drop aliases that link mentions to the wrong person (store.alias_problem): descriptive
ones, shared titles and kinship words, pasted lists, and any alias another character also
has, which is dropped from all of them. Mentions are then recounted from the text."""
dropped = {}
with store.connect() as conn:
known = store.known_characters(conn, novel)
for c in known:
bad = {a: store.alias_problem(a, c["name"], known) for a in c["aliases"]}
bad.update({a: "own name" for a in c["aliases"] if a.casefold() == c["name"].casefold()})
bad = {a: why for a, why in bad.items() if why}
if bad:
dropped[c["name"]] = bad
if not dry_run:
for c in known:
if c["name"] in dropped:
conn.execute("UPDATE characters SET aliases = ? WHERE novel = ? AND name = ?",
(json.dumps([a for a in c["aliases"] if a not in dropped[c["name"]]]),
novel, c["name"]))
print(f"aliases: dropped from {len(dropped)} characters ({sum(map(len, dropped.values()))} aliases)")
for name, bad in dropped.items():
print(f" {name}: " + "; ".join(f"{a} ({why})" for a, why in bad.items()))
if dropped and not dry_run:
store.reset_mentions(novel)
def generic_terms(novel, dry_run):
state = store.index_status(novel)
with store.connect() as conn:
book = conn.execute("SELECT book FROM index_state WHERE novel = ?", (novel,)).fetchone()
terms = [r["term"] for r in conn.execute("SELECT term FROM terms WHERE novel = ?", (novel,))]
where = {}
for r in conn.execute("SELECT name, position FROM mentions WHERE novel = ? AND kind = 't'", (novel,)):
where.setdefault(r["name"], []).append(r["position"])
if not state or not book:
print("terms: not pre-read from Kavita; skipped")
return
texts = {}
def text(position):
if position not in texts:
got = kavita.chapter(book["book"], position)
texts[position] = got[1] if got else ""
return texts[position]
generic = []
for term in terms:
proper = common = 0
lower = term.lower()
for position in sorted(where.get(term, []))[:TERM_SAMPLE]:
body = text(position)
# Capitalised uses that are not just sentence starts, vs lowercase uses.
proper += len(re.findall(rf"(?<![.!?\"'“‘]\s)(?<![\w-]){re.escape(term)}(?![\w-])", body))
common += len(re.findall(rf"(?<![\w-]){re.escape(lower)}(?![\w-])", body)) if lower != term else 0
if common > proper:
generic.append(term)
print(f"terms: {len(generic)} generic of {len(terms)}: " + ", ".join(sorted(generic)[:60]))
if not dry_run:
store.remove_terms(novel, generic)
store.reset_mentions(novel)
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(__doc__)
novel, dry_run = sys.argv[1], "--dry-run" in sys.argv
store.migrate()
only = {f for f in ("--prune-only", "--looks-only", "--terms-only", "--aliases-only",
"--duplicates-only", "--timeline-only") if f in sys.argv}
# duplicates first: a variant name judged rare on its own is pruned before it can be folded in
if not only or "--duplicates-only" in only:
duplicates(novel, dry_run)
if not only or "--prune-only" in only:
prune(novel, dry_run)
if not only or "--looks-only" in only:
consolidate(novel, dry_run)
# last of the look work: the steps above are what leave a chapter pointing at a look that
# is no longer there, and the answer to that is whatever record survived them.
if not only or "--looks-only" in only or "--timeline-only" in only:
timeline(novel, dry_run)
if not only or only & {"--terms-only", "--aliases-only"}:
clean_aliases(novel, dry_run)
if not only or "--terms-only" in only:
generic_terms(novel, dry_run)