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

137 lines
4.4 KiB
Python

"""Tests for Analyzer response parsing and validation (issue #17, §12.2, §12.8)."""
from __future__ import annotations
import json
from compiler.analyzer.response_parser import parse_scene_response
from schemas.models import Scene
def scene_response(**overrides) -> str:
scene = {
"characters": [
{
"id": 0,
"identity": ["female"],
"appearance": ["blonde hair"],
"clothing": [],
"accessories": [],
"pose": [],
"expression": [],
"actions": ["walking"],
}
],
"interactions": [],
"objects": [],
"environment": ["rain"],
"camera": [],
"lighting": [],
"metadata": {},
}
scene.update(overrides)
return json.dumps(scene)
def codes(messages) -> list[str]:
return [m.code for m in messages]
# --- valid responses -------------------------------------------------------
def test_valid_response_parses_to_scene() -> None:
result = parse_scene_response(scene_response())
assert result.success
assert isinstance(result.data, Scene)
assert result.data.characters[0].identity[0].name == "female"
assert result.data.environment[0].name == "rain"
def test_empty_metadata_is_stamped_with_schema_version() -> None:
# §12.2 shows the Analyzer emitting `"metadata": {}`; the parser stamps the
# compiler-owned schema_version so the document validates.
result = parse_scene_response(scene_response())
assert result.success
assert result.data.metadata.schema_version
def test_object_and_string_concept_forms() -> None:
scene = json.loads(scene_response())
scene["characters"][0]["appearance"] = [
"blonde hair",
{"name": "blue eyes", "metadata": {"confidence": 0.9}},
]
result = parse_scene_response(json.dumps(scene))
assert result.success
appearance = result.data.characters[0].appearance
assert appearance[0].name == "blonde hair"
assert appearance[1].name == "blue eyes"
assert appearance[1].metadata["confidence"] # confidence stays metadata-only
# --- invalid responses -> SC0011 -------------------------------------------
def test_malformed_json_is_rejected() -> None:
result = parse_scene_response("{ not valid json")
assert not result.success
assert result.data is None
assert "SC0011" in codes(result.errors)
def test_non_object_json_is_rejected() -> None:
result = parse_scene_response("[1, 2, 3]")
assert not result.success
assert "SC0011" in codes(result.errors)
def test_missing_required_field_is_rejected() -> None:
scene = json.loads(scene_response())
del scene["characters"]
result = parse_scene_response(json.dumps(scene))
assert not result.success
assert "SC0011" in codes(result.errors)
def test_wrong_type_is_rejected() -> None:
scene = json.loads(scene_response())
scene["characters"] = "not a list"
result = parse_scene_response(json.dumps(scene))
assert not result.success
assert "SC0011" in codes(result.errors)
def test_prose_wrapped_json_is_extracted() -> None:
# Local models often add prose around the JSON; the object is extracted from
# the surrounding text (unwrapping non-JSON, not repairing the JSON itself).
text = "Sure! Here is the scene:\n" + scene_response() + "\nHope that helps!"
result = parse_scene_response(text)
assert result.success
assert isinstance(result.data, Scene)
def test_prose_without_any_json_object_is_rejected() -> None:
result = parse_scene_response("I cannot help with that request.")
assert not result.success
assert "SC0011" in codes(result.errors)
def test_json_fenced_response_is_unwrapped() -> None:
# Most local models wrap JSON in a ```json fence; unwrapping is not repair.
result = parse_scene_response("```json\n" + scene_response() + "\n```")
assert result.success
assert isinstance(result.data, Scene)
def test_plain_fenced_response_is_unwrapped() -> None:
result = parse_scene_response("```\n" + scene_response() + "\n```")
assert result.success
def test_fenced_but_malformed_json_still_fails() -> None:
# Unwrapping only removes the fence; malformed JSON inside is still rejected.
result = parse_scene_response("```json\n{ not valid json\n```")
assert not result.success
assert "SC0011" in codes(result.errors)