Files
54b76ca75a fix(compiler): recover lost scene detail via 16.4k Danbooru KB + harden analyzer (#79)
* fix: recover concept detail lost in resolver and harden analyzer parsing

Three shippability fixes for the v1 pipeline:

P2 (main): the Illustrious Resolver did exact-key KB lookup only, so any
compound concept the analyzer emits ("white summer dress", "black business
suit") missed and was dropped silently as SC0001. Add a deterministic
head-noun fallback (§17.2): on a miss, drop leading modifiers and look up the
longest remaining suffix ("white summer dress" -> "dress"). No tag/concept is
invented. Emits SC0019 (Concept Reduced) so the reduction is visible.

P1a: the analyzer required the entire model response to be a single JSON
document, so any surrounding prose ("Sure! Here is the scene:") failed parsing
and, with the prompt reused unchanged, every retry failed identically ->
empty output. Extract the first balanced top-level {...} object from
surrounding text (string-aware brace scan); the object bytes are unchanged and
must still validate, so this is unwrapping, not JSON repair.

P1b: the node's "raw" output was to_raw_json(result.data) -- a re-serialization
of the parsed Scene, so on failure it was empty and the model's actual text was
invisible. Capture the last raw model text in analyzer metadata and surface it
as the node's raw output.

Regenerated goldens: unknown_concepts "hologram jacket" now resolves to
"jacket" via head-noun fallback (intended).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: add entry rating + resolver.include_nsfw gating

Foundations for the Danbooru-vocab KB: a per-entry `rating` (general|explicit,
default general) and a `resolver.include_nsfw` config flag (default false). The
resolver omits explicit-rated entries from lookup and expansion unless enabled,
so a gated concept is reported as unknown (SC0001). Schema, model, config, and
example config updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: generate KB from embedded Danbooru vocab (188 -> 16.4k tags)

Replace the coverage ceiling of the 188-entry hand KB by importing a large,
validated Danbooru tag vocabulary. This is the fix for the core "detail loss"
symptom: compound concepts the analyzer emits ("red dress", "sunflower field",
"train station platform") are now real tags that resolve instead of being
dropped.

- scripts/build_vocab.py: one-time dev step that filters the Raffle node's
  categorized tag dump (drops artist/character/copyright/attribution categories
  and subjective adjectives, keeps only id-pattern-valid tags) into a committed,
  self-contained snapshot at data/danbooru_vocab.txt (16.4k tags).
- scripts/generate_kb_from_vocab.py: additive, non-destructive generator. Writes
  knowledge_base/gen_<category>.json, mapping the Raffle categories to the 19
  canonical categories and tagging NSFW categories rating=explicit. Skips any tag
  already defined as a hand-curated id/alias, so the curated canonicalization
  (female + aliases) and expansions (school_uniform -> blazer, pleated_skirt) are
  preserved and always win.

Result: 16.4k additive entries + 188 curated. Plain natural-language concepts
now resolve broadly via curated aliases + head-noun fallback with no analyzer
prompt change; NSFW gated behind resolver.include_nsfw; golden and dataset tests
stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* style: ruff format build_vocab.py

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(security): drop untrusted CLI path in build_vocab (SonarCloud S8707)

The --source CLI argument fed an untrusted path to the file system (taint).
The script is a one-time dev step; hard-code the source path constant instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: kunail0804 <kunail0804@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:45:32 +02:00

134 lines
4.9 KiB
Python

"""Parsing and validation of the Analyzer's model response (MASTER_SPEC §12.2, §12.8).
Turns the raw model response text into a validated :class:`Scene`. The response
MUST be a single valid JSON document conforming to the Scene schema; invalid
output MUST NOT continue through the compiler.
JSON is never repaired heuristically (§12.9): malformed or markdown-wrapped output
fails here, which the retry orchestration (issue #18) uses to request a corrected
response. Any parse or validation failure is reported as SC0011.
"""
from __future__ import annotations
import json
from typing import Any
from compiler.common.log import StructuredLogger
from compiler.common.message_codes import message
from compiler.common.result import CompilerResult
from schemas.models import Scene
from schemas.validation import validate_document
# The Scene schema version stamped onto compiler-owned metadata when the model
# leaves it empty (the model is instructed to emit `"metadata": {}`).
SCENE_SCHEMA_VERSION = "1.0"
def parse_scene_response(text: str, logger: StructuredLogger | None = None) -> CompilerResult:
"""Parse and validate an Analyzer response into a Scene.
Returns:
A CompilerResult whose ``data`` is the validated :class:`Scene` on
success, or ``None`` with an SC0011 error when the response is not a
valid Scene JSON document.
"""
unwrapped = _strip_code_fence(text)
try:
data = json.loads(unwrapped)
except json.JSONDecodeError as exc:
embedded = _extract_json_object(unwrapped)
if embedded is None:
return _reject("json_decode", f"Analyzer response is not valid JSON: {exc}", logger)
try:
data = json.loads(embedded)
except json.JSONDecodeError:
return _reject("json_decode", f"Analyzer response is not valid JSON: {exc}", logger)
if not isinstance(data, dict):
return _reject(
"not_object",
f"Analyzer response must be a JSON object, got {type(data).__name__}.",
logger,
)
_stamp_metadata(data)
issues = validate_document(data, "scene")
if issues:
detail = "; ".join(f"{issue.path or '<root>'}: {issue.message}" for issue in issues)
return _reject("schema", f"Analyzer response failed Scene validation: {detail}", logger)
scene = Scene.from_json(data)
if logger is not None:
logger.verbose("analyzer_response_parsed", characters=len(scene.characters))
return CompilerResult(data=scene)
def _strip_code_fence(text: str) -> str:
"""Unwrap a Markdown code fence around the response, if present.
Most local models wrap their JSON in a ```json ... ``` (or plain ```) fence
despite being told not to. Unwrapping the fence is not JSON repair (§12.9):
the JSON inside is left byte-for-byte unchanged; only the surrounding code-
block markers are removed. Text without a fence is returned unchanged.
"""
stripped = text.strip()
if not stripped.startswith("```"):
return text
lines = stripped.splitlines()
lines = lines[1:] # drop the opening ``` / ```json line
if lines and lines[-1].strip() == "```":
lines = lines[:-1] # drop the closing ``` line
return "\n".join(lines)
def _extract_json_object(text: str) -> str | None:
"""Return the first balanced top-level JSON object embedded in ``text``.
Local models often surround the JSON with prose ("Sure! Here is the scene:").
Extracting the outermost ``{...}`` is an unwrapping of surrounding non-JSON
text, not JSON repair (§12.9): the object's bytes are left unchanged and must
still parse and validate. Braces inside JSON strings are ignored so quoted
text cannot unbalance the scan. Returns ``None`` when no balanced object is
found.
"""
start = text.find("{")
if start == -1:
return None
depth = 0
in_string = False
escaped = False
for index in range(start, len(text)):
char = text[index]
if in_string:
if escaped:
escaped = False
elif char == "\\":
escaped = True
elif char == '"':
in_string = False
continue
if char == '"':
in_string = True
elif char == "{":
depth += 1
elif char == "}":
depth -= 1
if depth == 0:
return text[start : index + 1]
return None
def _stamp_metadata(data: dict[str, Any]) -> None:
"""Ensure compiler-owned metadata carries a schema_version before validation."""
metadata = data.get("metadata")
if isinstance(metadata, dict):
metadata.setdefault("schema_version", SCENE_SCHEMA_VERSION)
def _reject(kind: str, description: str, logger: StructuredLogger | None) -> CompilerResult:
if logger is not None:
logger.basic("analyzer_response_rejected", kind=kind)
return CompilerResult().add_error(message("SC0011", description, kind=kind))