294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Dispatcher — pure Python (no LLM calls).
|
|
|
|
Reads scan output + decompose intent and produces the SEARCH /
|
|
REPLACE / INSERT_AFTER triple for apply. Two classes of concept get
|
|
different routing:
|
|
|
|
Top-level sentence concepts (one per prompt):
|
|
character, pose, expression, scene, style, quality
|
|
- Present in scan → SEARCH=scan[concept], REPLACE=formatted text
|
|
- Absent → SEARCH=(not present), INSERT_AFTER=canonical-before lookup
|
|
|
|
Sub-concepts (live inside the outfit clause):
|
|
tops, bottoms, footwear, legwear, handwear, armwear, headwear,
|
|
neckwear, accessories
|
|
- Route through locate-infill but ONLY on the outfit sentence, not
|
|
the full prompt. Tight scope, model can't grab whole prose.
|
|
|
|
Sub-concepts of character (hair, eyes, body, face):
|
|
- Same idea on the character sentence.
|
|
|
|
Outfit (meta — whole-outfit swap):
|
|
- Present → SEARCH=scan['outfit'], REPLACE=resolved outfit text
|
|
- Absent → INSERT_AFTER canonical-before
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
|
|
# Canonical order. Index = position in the prompt.
|
|
CANONICAL_ORDER = (
|
|
"character",
|
|
"outfit",
|
|
"pose",
|
|
"expression",
|
|
"scene",
|
|
"style",
|
|
"quality",
|
|
)
|
|
|
|
# Decompose vocabulary differs from dispatch vocabulary — `subject`
|
|
# from decompose maps to `character` here. Other concept names already
|
|
# align.
|
|
_CONCEPT_ALIASES = {
|
|
"subject": "character",
|
|
}
|
|
|
|
|
|
def normalize_concept(concept: str) -> str:
|
|
"""Map decompose concept names onto dispatch's canonical vocabulary."""
|
|
return _CONCEPT_ALIASES.get(concept, concept)
|
|
|
|
|
|
# Sentence-shaped concepts — they get their own sentence in the prompt.
|
|
SENTENCE_CONCEPTS = {
|
|
"character", "pose", "expression", "scene", "style", "quality",
|
|
"outfit", # outfit is sentence-shaped at the whole-swap level
|
|
}
|
|
|
|
# Sub-concepts that live inside the outfit sentence.
|
|
OUTFIT_SUBSLOTS = {
|
|
"tops", "bottoms", "footwear", "legwear", "handwear",
|
|
"armwear", "headwear", "neckwear", "accessories",
|
|
}
|
|
|
|
# Sub-concepts that live inside the character sentence.
|
|
CHARACTER_SUBSLOTS = {
|
|
"hair", "eyes", "body", "face",
|
|
}
|
|
|
|
|
|
def parent_concept(concept: str) -> Optional[str]:
|
|
"""Map a sub-concept to its parent sentence concept."""
|
|
if concept in OUTFIT_SUBSLOTS:
|
|
return "outfit"
|
|
if concept in CHARACTER_SUBSLOTS:
|
|
return "character"
|
|
if concept in SENTENCE_CONCEPTS:
|
|
return concept
|
|
return None
|
|
|
|
|
|
def canonical_anchor(concept: str, scan: dict) -> Optional[str]:
|
|
"""Walk BACK from `concept` through CANONICAL_ORDER and return the
|
|
sentence of the most recent present concept. None if nothing is
|
|
before this concept.
|
|
"""
|
|
try:
|
|
idx = CANONICAL_ORDER.index(concept)
|
|
except ValueError:
|
|
return None
|
|
for prev in CANONICAL_ORDER[:idx][::-1]:
|
|
v = (scan.get(prev) or "").strip()
|
|
if v:
|
|
return v
|
|
return None
|
|
|
|
|
|
def format_replace(concept: str, text: str,
|
|
verbatim: bool = False) -> str:
|
|
"""Mechanical formatting for inserted prose. Sentence-shaped
|
|
concepts get capitalized + period-terminated; sub-concepts (items)
|
|
stay verbatim.
|
|
|
|
Set `verbatim=True` for KB-resolved bodies (compose_*_natlang_v2,
|
|
style template body, scene base_natlang) — those come from the
|
|
DB already formatted exactly the way legacy emits them, so the
|
|
cap+period rule would just mangle them. The cap+period rule
|
|
exists for raw user text and vibe output, not for KB bodies.
|
|
"""
|
|
text = (text or "").strip()
|
|
if not text:
|
|
return text
|
|
if verbatim:
|
|
return text
|
|
if concept in SENTENCE_CONCEPTS:
|
|
if not text[0].isupper():
|
|
text = text[0].upper() + text[1:]
|
|
if not text.endswith((".", "!", "?")):
|
|
text = text + "."
|
|
return text
|
|
|
|
|
|
def format_section_header(concept: str,
|
|
resolved_match: Optional[dict] = None,
|
|
resolved_source: Optional[str] = None) -> str:
|
|
"""Build a `// Section: <metadata>` header for a sentence-shaped
|
|
concept. Mirrors legacy `core/natlang_render_v2.py` formats. When
|
|
resolved_match is absent (vibe path or raw text), emits the bare
|
|
`// Section:` form.
|
|
|
|
Returns empty string for non-sentence concepts.
|
|
"""
|
|
if concept not in SENTENCE_CONCEPTS:
|
|
return ""
|
|
|
|
m = resolved_match or {}
|
|
|
|
if concept == "character":
|
|
display = (m.get("display") or m.get("tag") or "").strip()
|
|
series = (m.get("series") or "").strip()
|
|
if display:
|
|
return f"// Character: {display} ({series})" if series \
|
|
else f"// Character: {display}"
|
|
return "// Character:"
|
|
|
|
if concept == "outfit":
|
|
outfit_name = (m.get("outfit_name") or m.get("name") or "").strip()
|
|
char_display = (m.get("character_display") or "").strip()
|
|
if outfit_name and char_display:
|
|
return f"// Outfit: {outfit_name} from Character: {char_display}"
|
|
if outfit_name:
|
|
return f"// Outfit: {outfit_name}"
|
|
return "// Outfit:"
|
|
|
|
if concept == "pose":
|
|
pose_name = (m.get("pose_name") or m.get("name") or "").strip()
|
|
is_signature = bool(m.get("is_signature"))
|
|
char_display = (m.get("character_display") or "").strip()
|
|
if pose_name:
|
|
suffix = pose_name
|
|
if is_signature:
|
|
suffix += " (signature)"
|
|
if char_display:
|
|
suffix += f" from Character: {char_display}"
|
|
return f"// Pose: {suffix}"
|
|
return "// Pose:"
|
|
|
|
if concept == "scene":
|
|
scene_name = (m.get("display_name") or m.get("name") or "").strip()
|
|
# Legacy renders this header as `// Setting:` in some paths and
|
|
# `// Scene:` in others — stick with `// Scene:` for parity with
|
|
# scan_prompt's concept name.
|
|
if scene_name:
|
|
return f"// Scene: {scene_name}"
|
|
return "// Scene:"
|
|
|
|
if concept == "style":
|
|
# `template_name` is the new field; `name` stays for older
|
|
# callers that pass an outfit/scene-shaped dict.
|
|
style_name = (m.get("template_name") or m.get("name") or "").strip()
|
|
if style_name:
|
|
return f"// Style: {style_name}"
|
|
return "// Style:"
|
|
|
|
if concept == "expression":
|
|
return "// Expression:"
|
|
|
|
if concept == "quality":
|
|
return "// Quality:"
|
|
|
|
return ""
|
|
|
|
|
|
def dispatch_sentence_concept(intent: dict, scan: dict,
|
|
resolved_match: Optional[dict] = None,
|
|
resolved_source: Optional[str] = None,
|
|
emit_headers: bool = True) -> dict:
|
|
"""For pose / expression / scene / style / quality / character /
|
|
outfit: produce SEARCH/REPLACE/INSERT_AFTER deterministically from
|
|
scan output and canonical order.
|
|
|
|
When the path is INSERT (concept absent from prompt) and the
|
|
surrounding prompt is sectioned (`emit_headers=True`), prepend a
|
|
`// Section:` header so the brand-new section arrives with its
|
|
metadata. For flat-prose prompts (`emit_headers=False`), emit a
|
|
bare body sentence that blends into the existing prose stream.
|
|
|
|
REPLACE (concept's sentence is already in the prompt) always emits
|
|
bare body — any existing header above the matched sentence stays
|
|
in place regardless of mode.
|
|
"""
|
|
concept = normalize_concept(intent["concept"])
|
|
op = (intent.get("op") or "replace").lower()
|
|
text = intent.get("resolved_text") or intent.get("text") or ""
|
|
# KB-resolved bodies (`resolved_source` set) pass through
|
|
# verbatim — they're already the exact prose legacy emits.
|
|
body = format_replace(concept, text, verbatim=bool(resolved_source))
|
|
|
|
present = (scan.get(concept) or "").strip()
|
|
# op=add explicitly means "insert another section of this concept
|
|
# next to the existing one" (e.g. multi-character build:
|
|
# subject=cammy + subject=tifa). Force INSERT even when scan
|
|
# already has the concept — REPLACE would clobber the prior char's
|
|
# section.
|
|
if present and op != "add":
|
|
return {
|
|
"search": present,
|
|
"replace": body,
|
|
"insert_after": None,
|
|
"method_hint": "sentence_replace",
|
|
}
|
|
if emit_headers:
|
|
header = format_section_header(concept, resolved_match, resolved_source)
|
|
replace = f"{header}\n{body}" if header and body else body
|
|
else:
|
|
replace = body
|
|
# When op=add and the concept is already present, anchor AFTER the
|
|
# existing body of this same concept so the new section lands
|
|
# immediately after the prior one (e.g. // Character: Tifa lands
|
|
# after // Character: Cammy in multi-char build). Falls through
|
|
# to canonical_anchor when the concept isn't present yet.
|
|
if present and op == "add":
|
|
anchor = present
|
|
else:
|
|
anchor = canonical_anchor(concept, scan)
|
|
return {
|
|
"search": "(not present)",
|
|
"replace": replace,
|
|
"insert_after": anchor or "",
|
|
"method_hint": "sentence_insert",
|
|
}
|
|
|
|
|
|
def dispatch(intent: dict, scan: dict,
|
|
resolved_match: Optional[dict] = None,
|
|
resolved_source: Optional[str] = None,
|
|
emit_headers: bool = True) -> dict:
|
|
"""Top-level dispatch given a decompose intent + scan result.
|
|
|
|
Returns dict with:
|
|
- kind: 'sentence' (use Python) or 'subslot' (use locate-infill)
|
|
- parent_sentence: for subslot kind, the sentence to scope locate-infill to
|
|
- parsed: for sentence kind, the {search, replace, insert_after} triple
|
|
"""
|
|
concept = normalize_concept(intent["concept"])
|
|
if concept in SENTENCE_CONCEPTS:
|
|
return {
|
|
"kind": "sentence",
|
|
"concept": concept,
|
|
"parsed": dispatch_sentence_concept(
|
|
intent, scan,
|
|
resolved_match=resolved_match,
|
|
resolved_source=resolved_source,
|
|
emit_headers=emit_headers,
|
|
),
|
|
}
|
|
parent = parent_concept(concept)
|
|
if parent is None:
|
|
# Unknown concept — fall back to locate-infill on full prompt
|
|
return {
|
|
"kind": "subslot",
|
|
"concept": concept,
|
|
"parent": None,
|
|
"parent_sentence": None,
|
|
}
|
|
parent_sentence = (scan.get(parent) or "").strip()
|
|
return {
|
|
"kind": "subslot",
|
|
"concept": concept,
|
|
"parent": parent,
|
|
"parent_sentence": parent_sentence or None,
|
|
}
|