← Files QuestforgeARCHIVED FILE
scripts/narrative_lint.py
20 KB · Sep 30, 2026 · 23:13 UTC
"""Soft narrative-pattern lint for Questforge drafts and session recaps."""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable
@dataclass(frozen=True)
class MotifDefinition:
category: str
label: str
patterns: tuple[str, ...]
weird: bool = True
@dataclass(frozen=True)
class MotifHit:
category: str
label: str
count: int
examples: list[str]
@dataclass(frozen=True)
class NarrativeLintIssue:
level: str
code: str
message: str
categories: list[str]
@dataclass(frozen=True)
class NarrativeLintResult:
ok: bool
issue_count: int
warning_count: int
info_count: int
motif_count: int
category_count: int
hits: list[MotifHit]
issues: list[NarrativeLintIssue]
MOTIFS = (
MotifDefinition(
category="memory_trade",
label="memory taken, given, erased, or traded",
patterns=(
r"\brecuerd\w+\b",
r"\bmemoria\b",
r"\bolvid\w+\b",
r"\bnombre\s+borrad\w+\b",
r"\bmemory\b",
r"\bmemories\b",
r"\bforget(?:s|ting|ten)?\b",
r"\bforgot(?:ten)?\b",
r"\berased?\s+(?:name|memory|memories)\b",
),
),
MotifDefinition(
category="sentient_contract",
label="contract, bargain, debt, or oath with an object",
patterns=(
r"\bcontrato\b",
r"\bpacto\b",
r"\bdeuda\b",
r"\bpromesa\b",
r"\bobjeto\s+consciente\b",
r"\bobjeto\s+viv\w+\b",
r"\bcontract\b",
r"\bbargain\b",
r"\bdebt\b",
r"\boath\b",
r"\bsentient\s+object\b",
r"\bconscious\s+object\b",
r"\bliving\s+object\b",
),
),
MotifDefinition(
category="secret_rules",
label="hidden rules that govern behavior",
patterns=(
r"\bregla\s+secreta\b",
r"\breglas\s+secretas\b",
r"\bley\s+oculta\b",
r"\bleyes\s+ocultas\b",
r"\bhidden\s+rule\b",
r"\bsecret\s+rule\b",
r"\brules?\s+no\s+one\b",
r"\brules?\s+that\s+govern\b",
r"\bgoverns?\s+(?:conduct|behavior|behaviour)\b",
),
),
MotifDefinition(
category="unsayable",
label="things nobody can say aloud",
patterns=(
r"\bnadie\s+puede\s+decir\b",
r"\bno\s+se\s+puede\s+decir\b",
r"\bno\s+decir\s+en\s+voz\s+alta\b",
r"\bno\s+puede\s+pronunciar\b",
r"\bno\s+pronunciar\b",
r"\bno\s+one\s+can\s+(?:say|speak|acknowledge)\b",
r"\bcan't\s+(?:say|speak|acknowledge)\b",
r"\bcannot\s+(?:say|speak|acknowledge)\b",
r"\bout\s+loud\b",
r"\bunspeakable\b",
r"\bforbidden\s+word\b",
),
),
MotifDefinition(
category="hyperstition",
label="belief, rumor, or story that makes itself real",
patterns=(
r"\bhiperstici.n\b",
r"\brumor\s+que\s+se\s+vuelve\s+real\b",
r"\bcreencia\s+lo\s+hace\s+real\b",
r"\bhyperstition\b",
r"\bstory\s+becomes\s+real\b",
r"\brumou?r\s+becomes\s+real\b",
r"\bbelief\s+makes\s+it\s+(?:true|real)\b",
r"\bprophecy\s+(?:creates|causes)\b",
),
),
MotifDefinition(
category="dream_symbolism",
label="dream, vision, mirror, or symbolic double",
patterns=(
r"\bsue.o\b",
r"\bvisi(?:o|\xF3)n\b",
r"\bespejo\b",
r"\bdoble\b",
r"\bdream\b",
r"\bvision\b",
r"\bmirror\b",
r"\bdoppelganger\b",
r"\bsymbolic\s+double\b",
),
),
MotifDefinition(
category="theme_overexplicit",
label="explicit theme, lesson, meaning, or moral",
patterns=(
r"\bverdad\b",
r"\bsignificado\b",
r"\blecci(?:o|\xF3)n\b",
r"\bdestino\b",
r"\btheme\b",
r"\bmeaning\b",
r"\blesson\b",
r"\bmoral\b",
r"\btruth\b",
r"\bdestiny\b",
),
weird=False,
),
MotifDefinition(
category="tidy_convergence",
label="everything neatly converges on one answer",
patterns=(
r"\btodo\s+encaja\b",
r"\btodas?\s+las\s+pistas?\s+apuntan\b",
r"\bevery\s+clue\s+points\b",
r"\ball\s+roads\s+lead\b",
r"\bperfectly\s+fits\b",
r"\beverything\s+(?:fits|connects)\b",
),
weird=False,
),
)
WEIRD_CATEGORIES = {motif.category for motif in MOTIFS if motif.weird}
MATERIAL_ANCHOR_GROUPS = {
"livelihood": (
r"\bdiner\w*\b",
r"\bmoned\w*\b",
r"\bsalari\w*\b",
r"\btrabaj\w*\b",
r"\balquiler\b",
r"\bimpuest\w*\b",
r"\bcosech\w*\b",
r"\bmoney\b",
r"\bcoin\w*\b",
r"\bwage\w*\b",
r"\bwork\w*\b",
r"\brent\b",
r"\btax(?:es)?\b",
r"\bharvest\w*\b",
r"\btrade\b",
),
"resources": (
r"\bcomid\w*\b",
r"\bharin\w*\b",
r"\bmedicin\w*\b",
r"\ble.a\b",
r"\bhambre\b",
r"\bfood\b",
r"\bflour\b",
r"\bmedicine\b",
r"\bfirewood\b",
r"\bhunger\b",
r"\bsuppl(?:y|ies)\b",
),
"institutions": (
r"\bley\b",
r"\bgremi\w*\b",
r"\bguardia\b",
r"\bconsejo\b",
r"\btemplo\b",
r"\blaw\b",
r"\bguild\b",
r"\bguard\b",
r"\bcouncil\b",
r"\btemple\b",
),
"relationships": (
r"\bfamili\w*\b",
r"\bvecin\w*\b",
r"\brival\w*\b",
r"\bherman\w*\b",
r"\bfamily\b",
r"\bneighbou?r\w*\b",
r"\brival\w*\b",
r"\bsibling\w*\b",
r"\bsister\w*\b",
r"\bbrother\w*\b",
),
"logistics": (
r"\bcamino\b",
r"\bcarro\b",
r"\bherramient\w*\b",
r"\bpuente\b",
r"\bruta\b",
r"\broad\b",
r"\bcart\b",
r"\btool\w*\b",
r"\bbridge\b",
r"\broute\b",
r"\btransport\w*\b",
),
"built_environment": (
r"\bmercado\b",
r"\bforja\b",
r"\btejado\b",
r"\bmuro\b",
r"\bcasa\b",
r"\bmarket\b",
r"\bforge\b",
r"\broof\b",
r"\bwall\b",
r"\bhouse\b",
r"\bworkshop\b",
),
"terrain_and_materials": (
r"\btierra\b",
r"\bpiedra\b",
r"\barena\b",
r"\bhierro\b",
r"\bmadera\b",
r"\bbarro\b",
r"\bsoil\b",
r"\bstone\b",
r"\bsand\b",
r"\biron\b",
r"\bwood\b",
r"\bmud\b",
r"\bgrass\b",
),
"body_and_senses": (
r"\bfr(?:i|\xED)o\b",
r"\bcalor\b",
r"\bfatiga\b",
r"\bolor\b",
r"\bsudor\b",
r"\bcold\b",
r"\bheat\b",
r"\bfatigue\b",
r"\bsmell\b",
r"\bsweat\b",
r"\bache\w*\b",
),
"weather_and_light": (
r"\blluvia\b",
r"\bsol\b",
r"\bviento\b",
r"\btormenta\b",
r"\bhelada\b",
r"\bsequ(?:i|\xED)a\b",
r"\brain\b",
r"\bsun(?:light)?\b",
r"\bwind\b",
r"\bstorm\b",
r"\bfrost\b",
r"\bdrought\b",
),
}
ENVIRONMENTAL_MOTIFS = {
"precipitation": (
r"\blluvia\b",
r"\btormenta\b",
r"\brain\b",
r"\bstorm\b",
),
"fog": (r"\bniebla\b", r"\bbruma\b", r"\bfog\b", r"\bmist\b"),
"darkness": (
r"\boscur\w*\b",
r"\bmedianoche\b",
r"\bdark(?:ness)?\b",
r"\bmidnight\b",
),
"cold": (r"\bfr(?:i|\xED)o\b", r"\bhielo\b", r"\bcold\b", r"\bice\b"),
"heat": (r"\bcalor\b", r"\bsol\b", r"\bheat\b", r"\bsun(?:light)?\b"),
"wind": (r"\bviento\b", r"\bwind\b", r"\bgale\b"),
"snow": (r"\bnieve\b", r"\bnevada\b", r"\bsnow\b", r"\bblizzard\b"),
"water": (
r"\bagua\b",
r"\br(?:i|\xED)o\b",
r"\bpuerto\b",
r"\bwater\b",
r"\briver\b",
r"\bharbou?r\b",
),
"dust": (
r"\bpolvo\b",
r"\bsequ(?:i|\xED)a\b",
r"\bdust\b",
r"\bdrought\b",
),
"vegetation": (
r"\bbosque\b",
r"\bra(?:i|\xED)ces\b",
r"\bforest\b",
r"\broot\w*\b",
),
"underground": (
r"\bcueva\b",
r"\bsubterr(?:a|\xE1)ne\w*\b",
r"\bcave\b",
r"\bunderground\b",
),
}
REVISION_NUDGES = (
"Keep at most one metaphysical motif dominant in a scene or reveal.",
"Add material pressure: livelihood, safety, status, relationships, law, "
"scarcity, logistics, terrain, bodily needs, or work.",
"Let secrets belong to people, institutions, factions, or logistics before "
"making them cosmic rules.",
"Preserve ambiguity and consequence; avoid making every clue point at the "
"same symbolic answer.",
)
def lint_text(text: str) -> NarrativeLintResult:
hits = find_motif_hits(text)
issues = build_issues(text, hits)
warning_count = sum(1 for issue in issues if issue.level == "warning")
info_count = sum(1 for issue in issues if issue.level == "info")
motif_count = sum(hit.count for hit in hits)
category_count = len({hit.category for hit in hits})
return NarrativeLintResult(
ok=warning_count == 0,
issue_count=len(issues),
warning_count=warning_count,
info_count=info_count,
motif_count=motif_count,
category_count=category_count,
hits=hits,
issues=issues,
)
def lint_documents(documents: list[str]) -> NarrativeLintResult:
"""Lint a corpus while preserving cross-opening repetition evidence."""
if not documents:
raise ValueError("At least one document is required.")
individual_results = [lint_text(document) for document in documents]
if len(individual_results) == 1:
return individual_results[0]
hits_by_category: dict[str, MotifHit] = {}
issues_by_key: dict[
tuple[str, str, tuple[str, ...]], NarrativeLintIssue
] = {}
for result in individual_results:
for hit in result.hits:
existing = hits_by_category.get(hit.category)
if existing is None:
hits_by_category[hit.category] = hit
else:
hits_by_category[hit.category] = MotifHit(
category=hit.category,
label=hit.label,
count=existing.count + hit.count,
examples=(existing.examples + hit.examples)[:3],
)
for issue in result.issues:
key = (issue.level, issue.code, tuple(issue.categories))
issues_by_key.setdefault(key, issue)
if len(documents) >= 3:
for category, patterns in ENVIRONMENTAL_MOTIFS.items():
document_hits = sum(
1
for document in documents
if count_pattern_matches(document, patterns) > 0
)
if document_hits >= 3 and document_hits / len(documents) >= 0.75:
issue = NarrativeLintIssue(
level="warning",
code="environmental_motif_repeated_across_openings",
message=(
f"The environmental motif '{category}' appears in "
f"{document_hits} of {len(documents)} drafts. Vary the "
"physical situation, not only names and lore."
),
categories=[category],
)
key = (issue.level, issue.code, tuple(issue.categories))
issues_by_key[key] = issue
hits = sorted(hits_by_category.values(), key=lambda value: value.category)
issues = list(issues_by_key.values())
warning_count = sum(1 for issue in issues if issue.level == "warning")
info_count = sum(1 for issue in issues if issue.level == "info")
return NarrativeLintResult(
ok=warning_count == 0,
issue_count=len(issues),
warning_count=warning_count,
info_count=info_count,
motif_count=sum(hit.count for hit in hits),
category_count=len(hits),
hits=hits,
issues=issues,
)
def find_motif_hits(text: str) -> list[MotifHit]:
hits = []
for motif in MOTIFS:
matches = []
for pattern in motif.patterns:
matches.extend(re.finditer(pattern, text, re.IGNORECASE))
if not matches:
continue
matches.sort(key=lambda match: match.start())
hits.append(
MotifHit(
category=motif.category,
label=motif.label,
count=len(matches),
examples=[
snippet(text, match.start(), match.end())
for match in matches[:3]
],
)
)
return hits
def build_issues(text: str, hits: list[MotifHit]) -> list[NarrativeLintIssue]:
issues = []
hit_counts = {hit.category: hit.count for hit in hits}
categories = set(hit_counts)
weird_categories = sorted(categories & WEIRD_CATEGORIES)
if len(weird_categories) >= 3:
issues.append(
NarrativeLintIssue(
level="warning",
code="metaphysical_pileup",
message=(
"Several AI-prone metaphysical motifs appear together. "
"Choose one to dominate, then ground the rest in concrete "
"NPC motives or local consequences."
),
categories=weird_categories,
)
)
if hit_counts.get("memory_trade", 0) >= 4:
issues.append(
NarrativeLintIssue(
level="warning",
code="dominant_memory_motif",
message=(
"Memory loss, trade, or erasure is doing repeated work. "
"Keep it only if this scene pays off prior setup."
),
categories=["memory_trade"],
)
)
if hit_counts.get("secret_rules", 0) + hit_counts.get("unsayable", 0) >= 3:
issues.append(
NarrativeLintIssue(
level="warning",
code="secret_rule_stack",
message=(
"Hidden conduct rules and unsayable truths are stacking. "
"Make the rule practical, institutional, or socially "
"enforced before making it metaphysical."
),
categories=["secret_rules", "unsayable"],
)
)
if hit_counts.get("theme_overexplicit", 0) >= 5:
issues.append(
NarrativeLintIssue(
level="info",
code="theme_overexplicit",
message=(
"The draft may be stating theme too directly. Let the "
"player infer meaning from choices, costs, and NPC action."
),
categories=["theme_overexplicit"],
)
)
material_categories = material_anchor_categories(text)
if weird_categories and len(material_categories) < 2:
issues.append(
NarrativeLintIssue(
level="info",
code="mundane_anchor_missing",
message=(
"The weird premise lacks enough material anchors. Add "
"pressures from at least two concrete domains such as "
"livelihood, resources, institutions, relationships, "
"logistics, terrain, bodily needs, or built space."
),
categories=weird_categories,
)
)
for category, patterns in ENVIRONMENTAL_MOTIFS.items():
if count_pattern_matches(text, patterns) >= 5:
issues.append(
NarrativeLintIssue(
level="warning",
code="environmental_crutch",
message=(
f"The environmental motif '{category}' is doing "
"repeated atmospheric work in one draft. Keep it when "
"it changes choices; otherwise diversify the material "
"and sensory anchors."
),
categories=[category],
)
)
return issues
def material_anchor_categories(text: str) -> set[str]:
"""Return distinct grounding domains instead of rewarding one keyword."""
return {
category
for category, patterns in MATERIAL_ANCHOR_GROUPS.items()
if count_pattern_matches(text, patterns) > 0
}
def count_pattern_matches(text: str, patterns: Iterable[str]) -> int:
count = 0
for pattern in patterns:
count += len(re.findall(pattern, text, re.IGNORECASE))
return count
def snippet(text: str, start: int, end: int, radius: int = 48) -> str:
prefix_start = max(0, start - radius)
suffix_end = min(len(text), end + radius)
value = text[prefix_start:suffix_end]
return " ".join(value.split())
def format_markdown(result: NarrativeLintResult) -> str:
lines = [
"# Questforge Narrative Lint",
"",
(
f"- Status: {'ok' if result.ok else 'review'}"
f" ({result.warning_count} warnings, {result.info_count} info)"
),
f"- Motifs found: {result.motif_count} across {result.category_count} categories",
"",
]
if result.issues:
lines.append("## Findings")
lines.append("")
for issue in result.issues:
categories = ", ".join(issue.categories)
lines.append(
f"- `{issue.level}` `{issue.code}` ({categories}): "
f"{issue.message}"
)
lines.append("")
else:
lines.extend(["## Findings", "", "- None.", ""])
if result.hits:
lines.append("## Motif Hits")
lines.append("")
for hit in result.hits:
lines.append(f"- `{hit.category}` x{hit.count}: {hit.label}")
for example in hit.examples:
lines.append(f" - {example}")
lines.append("")
lines.append("## Revision Nudge")
lines.append("")
for nudge in REVISION_NUDGES:
lines.append(f"- {nudge}")
return "\n".join(lines) + "\n"
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description=(
"Soft-check Questforge drafts for overused AI-fiction narrative "
"motif pileups."
)
)
parser.add_argument("--text", action="append", default=[])
parser.add_argument(
"--file",
action="append",
type=Path,
default=[],
help="Text or Markdown file to lint. May be repeated.",
)
parser.add_argument(
"--format", choices=("markdown", "json"), default="markdown"
)
parser.add_argument(
"--strict",
action="store_true",
help="Exit non-zero when warnings are found.",
)
return parser
def read_inputs(parsed_arguments: argparse.Namespace) -> list[str]:
parts = list(parsed_arguments.text)
for file_path in parsed_arguments.file:
parts.append(file_path.read_text(encoding="utf-8"))
if not parts:
raise SystemExit("Provide --text or --file.")
return parts
def main(arguments: Iterable[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8")
parsed_arguments = build_parser().parse_args(arguments)
result = lint_documents(read_inputs(parsed_arguments))
if parsed_arguments.format == "json":
print(json.dumps(asdict(result), indent=2, ensure_ascii=False))
else:
print(format_markdown(result))
if parsed_arguments.strict and result.warning_count:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: c30fe20929989bfc26789ea2dff31a754bbc5fa417078336cc8c8196d4ba6d0d