Files
kunail0804-ComfyUI-Scene-Co…/compiler/common/knowledge_base.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

192 lines
6.7 KiB
Python

"""Knowledge Base loading and indexing (MASTER_SPEC §15.11, §27.3).
The compiler loads the complete Knowledge Base once during initialization and
reuses it across compilations. Loading is all-or-nothing: the whole Knowledge
Base is validated (delegated to :func:`compiler.common.kb_validation`) before any
index is built, so a failed load leaves no partial state and raises a Fatal
``SC0004`` :class:`KnowledgeBaseError`.
:class:`KnowledgeBaseLoader` caches the loaded Knowledge Base and exposes an
explicit :meth:`~KnowledgeBaseLoader.reload`; there is no automatic file
watching, and a failed reload is side-effect-free (the previous Knowledge Base is
kept).
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from pathlib import Path
from types import MappingProxyType
from typing import Any
from compiler.common.kb_validation import validate_knowledge_base
from compiler.common.log import StructuredLogger
from compiler.common.result import Message, Severity
class KnowledgeBaseError(Exception):
"""Raised when the Knowledge Base cannot be loaded or fails validation (SC0004)."""
def __init__(self, message: Message, findings: list[Message]) -> None:
super().__init__(message.description)
self.message = message
self.findings = findings
@dataclass(frozen=True)
class KnowledgeBaseEntry:
"""One canonical Knowledge Base concept (§15.4), immutable once produced."""
id: str
tags: tuple[str, ...]
category: str
aliases: tuple[str, ...] = ()
expand: tuple[str, ...] = ()
rating: str = "general"
deprecated: bool = False
notes: str | None = None
@classmethod
def from_json(cls, data: Mapping[str, Any]) -> KnowledgeBaseEntry:
return cls(
id=data["id"],
tags=tuple(data["tags"]),
category=data["category"],
aliases=tuple(data.get("aliases", ())),
expand=tuple(data.get("expand", ())),
rating=data.get("rating", "general"),
deprecated=data.get("deprecated", False),
notes=data.get("notes"),
)
class KnowledgeBase:
"""An immutable, validated Knowledge Base with id and alias lookups."""
def __init__(self, entries: list[KnowledgeBaseEntry]) -> None:
by_id: dict[str, KnowledgeBaseEntry] = {}
alias_to_id: dict[str, str] = {}
for entry in entries:
by_id[entry.id] = entry
for alias in entry.aliases:
alias_to_id[alias] = entry.id
self._by_id: Mapping[str, KnowledgeBaseEntry] = MappingProxyType(by_id)
self._alias_to_id: Mapping[str, str] = MappingProxyType(alias_to_id)
@property
def by_id(self) -> Mapping[str, KnowledgeBaseEntry]:
return self._by_id
def get(self, canonical_id: str) -> KnowledgeBaseEntry | None:
"""Return the entry for a canonical id, or None."""
return self._by_id.get(canonical_id)
def resolve_alias(self, alias: str) -> str | None:
"""Return the canonical id an alias resolves to, or None."""
return self._alias_to_id.get(alias)
def lookup(self, name: str) -> KnowledgeBaseEntry | None:
"""Return the entry named by a canonical id or an alias, or None."""
entry = self._by_id.get(name)
if entry is not None:
return entry
canonical = self._alias_to_id.get(name)
return self._by_id.get(canonical) if canonical is not None else None
def __len__(self) -> int:
return len(self._by_id)
def __contains__(self, name: str) -> bool:
return name in self._by_id or name in self._alias_to_id
def _read_entries(directory: Path) -> tuple[list[dict], list[Message]]:
"""Read all *.json files (arrays of entries); collect load-time SC0004 problems."""
entries: list[dict] = []
problems: list[Message] = []
for path in sorted(directory.glob("*.json")):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
problems.append(_load_problem(f"{path.name}: invalid JSON: {exc}", path.name))
continue
if isinstance(data, list):
entries.extend(data)
else:
problems.append(
_load_problem(f"{path.name}: expected a JSON array of entries.", path.name)
)
return entries, problems
def _load_problem(description: str, source: str) -> Message:
return Message(
code="SC0004",
severity=Severity.FATAL,
title="Knowledge Base Load Failure",
description=description,
context={"id": source},
)
def load_knowledge_base(
directory: str | Path,
logger: StructuredLogger | None = None,
) -> KnowledgeBase:
"""Load and validate a Knowledge Base directory into an in-memory index.
Raises:
KnowledgeBaseError: If any file cannot be read or the Knowledge Base
fails validation. Nothing is loaded in that case.
"""
directory = Path(directory)
raw_entries, problems = _read_entries(directory)
findings = problems + validate_knowledge_base(raw_entries)
if findings:
raise KnowledgeBaseError(
Message(
code="SC0004",
severity=Severity.FATAL,
title="Knowledge Base Load Failure",
description=(
f"Knowledge Base at '{directory}' failed validation "
f"({len(findings)} problem(s)); nothing was loaded."
),
context={"id": str(directory), "problem_count": len(findings)},
),
findings=findings,
)
entries = [KnowledgeBaseEntry.from_json(entry) for entry in raw_entries]
kb = KnowledgeBase(entries)
if logger is not None:
logger.basic("knowledge_base_loaded", directory=str(directory), entries=len(kb))
return kb
class KnowledgeBaseLoader:
"""Loads a Knowledge Base once and caches it; reload is explicit and safe."""
def __init__(self, directory: str | Path, logger: StructuredLogger | None = None) -> None:
self._directory = Path(directory)
self._logger = logger
self._cache: KnowledgeBase | None = None
def get(self) -> KnowledgeBase:
"""Return the cached Knowledge Base, loading it on first use."""
if self._cache is None:
self._cache = load_knowledge_base(self._directory, self._logger)
return self._cache
def reload(self) -> KnowledgeBase:
"""Reload the Knowledge Base from disk.
On failure the previously cached Knowledge Base is left untouched (the
load raises before the cache is replaced).
"""
kb = load_knowledge_base(self._directory, self._logger)
self._cache = kb
return kb