Files
kunail0804-ComfyUI-Scene-Co…/compiler/resolver/illustrious_resolver.py
T
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

298 lines
10 KiB
Python

"""The Illustrious Resolver (MASTER_SPEC §17).
Deterministically converts the semantic concepts of a validated Scene into
Illustrious Resolved Tags using the Knowledge Base. No language model is involved
and no concept or tag is ever invented.
Per-concept pipeline (§17.2): normalize -> alias resolution (direct only) ->
canonical lookup -> expansion -> tag generation. Concepts are processed in scene
discovery order (§17.8) and every generated tag keeps the category of its
Knowledge Base Entry.
"""
from __future__ import annotations
import unicodedata
from collections.abc import Iterator
from compiler.common.categories import is_valid_category
from compiler.common.config import Config
from compiler.common.knowledge_base import KnowledgeBase, KnowledgeBaseEntry
from compiler.common.log import StructuredLogger
from compiler.common.message_codes import message
from compiler.common.result import CompilerResult, Message
from schemas.models import ResolvedTag, Scene
_CHARACTER_CONCEPT_FIELDS = (
"identity",
"appearance",
"clothing",
"accessories",
"pose",
"expression",
"actions",
)
def normalize_concept(name: str) -> str:
"""Normalize a concept for lookup (§17.3).
Applies Unicode (NFKC) normalization and lowercasing, and unifies underscores
with spaces before collapsing whitespace, so natural-language concepts
("holding hands"), snake_case ids ("holding_hands"), and spaced aliases all
map to the same key.
"""
text = unicodedata.normalize("NFKC", name).replace("_", " ").lower()
return " ".join(text.split())
def resolve_scene(
scene: Scene,
knowledge_base: KnowledgeBase,
config: Config,
logger: StructuredLogger | None = None,
) -> CompilerResult:
"""Resolve a validated Scene into an ordered tuple of Resolved Tags.
Returns:
A CompilerResult whose ``data`` is the tuple of :class:`ResolvedTag` on
success, or ``None`` with error messages when an error condition
(circular expansion SC0003, invalid category SC0008) stops resolution.
"""
include_nsfw = config.resolver.include_nsfw
index = _build_normalized_index(knowledge_base, include_nsfw)
resolver = _ConceptResolver(knowledge_base, index, config, logger)
tags: list[ResolvedTag] = []
warnings: list[Message] = []
errors: list[Message] = []
for concept_name in _iter_scene_concepts(scene):
resolved = resolver.resolve(concept_name)
tags.extend(resolved.tags)
warnings.extend(resolved.warnings)
errors.extend(resolved.errors)
if errors:
result = CompilerResult()
for error in errors:
result = result.add_error(error)
return result
deduped, dedup_warnings = _deduplicate(tags)
warnings.extend(dedup_warnings)
result = CompilerResult(data=tuple(deduped))
for warning in warnings:
result = result.add_warning(warning)
if logger is not None:
logger.basic("scene_resolved", tags=len(deduped), warnings=len(warnings))
return result
def _build_normalized_index(
knowledge_base: KnowledgeBase,
include_nsfw: bool,
) -> dict[str, KnowledgeBaseEntry]:
"""Map every normalized canonical id and alias to its entry (aliases direct).
Explicit-rated entries are omitted unless ``include_nsfw`` is set, so a gated
concept simply has no entry and is reported as unknown (SC0001).
"""
index: dict[str, KnowledgeBaseEntry] = {}
for entry in knowledge_base.by_id.values():
if entry.rating == "explicit" and not include_nsfw:
continue
index[normalize_concept(entry.id)] = entry
for alias in entry.aliases:
index[normalize_concept(alias)] = entry
return index
def _iter_scene_concepts(scene: Scene) -> Iterator[str]:
"""Yield concept names in scene discovery order (§17.8)."""
for character in scene.characters:
for field in _CHARACTER_CONCEPT_FIELDS:
for concept in getattr(character, field):
yield concept.name
for interaction in scene.interactions:
yield interaction.concept
for field in ("objects", "environment", "camera", "lighting"):
for concept in getattr(scene, field):
yield concept.name
class _Resolution:
"""The outcome of resolving one concept."""
__slots__ = ("tags", "warnings", "errors")
def __init__(self) -> None:
self.tags: list[ResolvedTag] = []
self.warnings: list[Message] = []
self.errors: list[Message] = []
class _ConceptResolver:
"""Resolves a single concept (with expansion) against a normalized index."""
def __init__(
self,
knowledge_base: KnowledgeBase,
index: dict[str, KnowledgeBaseEntry],
config: Config,
logger: StructuredLogger | None,
) -> None:
self._kb = knowledge_base
self._index = index
self._allow_aliases = config.resolver.allow_aliases
self._expansion_enabled = config.resolver.expansion_enabled
self._max_depth = config.resolver.max_expansion_depth
self._include_nsfw = config.resolver.include_nsfw
self._logger = logger
def resolve(self, concept_name: str) -> _Resolution:
outcome = _Resolution()
key = normalize_concept(concept_name)
entry = self._lookup(key)
if entry is None:
entry, reduced_key = self._reduce(key)
else:
reduced_key = None
if entry is None:
outcome.warnings.append(
message(
"SC0001",
f"Unknown concept '{concept_name}' has no Knowledge Base entry; ignored.",
concept=concept_name,
)
)
if self._logger is not None:
self._logger.verbose("concept_unknown", concept=concept_name, normalized=key)
return outcome
if reduced_key is not None:
outcome.warnings.append(
message(
"SC0019",
(
f"Concept '{concept_name}' was reduced to its head noun "
f"'{reduced_key}' for resolution; leading modifiers were dropped."
),
concept=concept_name,
reduced_to=reduced_key,
)
)
self._expand(entry, concept_name, depth=0, path=(), outcome=outcome)
return outcome
def _reduce(self, key: str) -> tuple[KnowledgeBaseEntry | None, str | None]:
"""Fall back to the concept's head noun when the full key has no entry (§17.2).
English noun phrases carry modifiers on the left ("white summer dress"),
so progressively dropping leading tokens and looking up the longest
remaining suffix recovers the head-noun Knowledge Base Entry ("dress")
instead of discarding the whole concept. The lookup stays exact and
deterministic; no tag or concept is invented.
"""
tokens = key.split()
for start in range(1, len(tokens)):
reduced_key = " ".join(tokens[start:])
entry = self._lookup(reduced_key)
if entry is not None:
return entry, reduced_key
return None, None
def _lookup(self, key: str) -> KnowledgeBaseEntry | None:
entry = self._index.get(key)
if entry is None:
return None
# A key that matches an alias but not the entry's own id is an alias hit.
if not self._allow_aliases and key != normalize_concept(entry.id):
return None
return entry
def _expand(
self,
entry: KnowledgeBaseEntry,
source_concept: str,
depth: int,
path: tuple[str, ...],
outcome: _Resolution,
) -> None:
if entry.id in path:
outcome.errors.append(
message(
"SC0003",
f"Circular expansion detected at '{entry.id}' (path: {' -> '.join(path)}).",
entry=entry.id,
)
)
return
if not is_valid_category(entry.category):
outcome.errors.append(
message(
"SC0008",
f"Entry '{entry.id}' has invalid category '{entry.category}'.",
entry=entry.id,
)
)
return
if entry.deprecated:
outcome.warnings.append(
message(
"SC0006",
f"Concept '{entry.id}' is deprecated; migration is encouraged.",
entry=entry.id,
)
)
for tag in entry.tags:
outcome.tags.append(
ResolvedTag(
tag=tag,
category=entry.category,
source_concept=source_concept,
knowledge_base_entry=entry.id,
)
)
if self._logger is not None:
self._logger.verbose(
"concept_resolved",
concept=source_concept,
canonical=entry.id,
tags=list(entry.tags),
)
if not self._expansion_enabled or depth >= self._max_depth:
return
for target_id in entry.expand:
target = self._kb.get(target_id)
if target is None:
continue
if target.rating == "explicit" and not self._include_nsfw:
continue # gated NSFW expansion target
self._expand(target, source_concept, depth + 1, (*path, entry.id), outcome)
def _deduplicate(tags: list[ResolvedTag]) -> tuple[list[ResolvedTag], list[Message]]:
"""Remove duplicate tags after expansion, keeping the first occurrence (§17.7)."""
seen: set[str] = set()
kept: list[ResolvedTag] = []
warnings: list[Message] = []
for resolved in tags:
if resolved.tag in seen:
warnings.append(
message(
"SC0007",
f"Duplicate tag '{resolved.tag}' removed after expansion.",
tag=resolved.tag,
)
)
continue
seen.add(resolved.tag)
kept.append(resolved)
return kept, warnings