* 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>
181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""Typed, injectable compiler configuration (MASTER_SPEC §23).
|
|
|
|
Configuration changes behaviour, never architecture, and MUST stay deterministic:
|
|
there is no random-seed option and unknown keys are rejected. Every section and
|
|
field is optional; omitted values fall back to the §23.2 defaults.
|
|
|
|
Configuration is loaded once and injected into stages — this module intentionally
|
|
exposes no global/singleton config. Invalid configuration is a Fatal condition
|
|
(the compiler cannot run) and raises :class:`ConfigError` carrying an ``SC0014``
|
|
message.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Mapping
|
|
from dataclasses import dataclass, fields
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from compiler.common.result import Message, Severity
|
|
from schemas.validation import validate_document
|
|
|
|
_CONFIG_SCHEMA = "configuration"
|
|
|
|
|
|
class ConfigError(Exception):
|
|
"""Raised when configuration is invalid; carries the Fatal SC0014 message."""
|
|
|
|
def __init__(self, message: Message) -> None:
|
|
super().__init__(message.description)
|
|
self.message = message
|
|
|
|
|
|
def _section_from_json(section_cls: type, data: Mapping[str, Any] | None) -> Any:
|
|
"""Build a config section, filling missing fields with the section's defaults."""
|
|
values = data or {}
|
|
known = {f.name for f in fields(section_cls)}
|
|
return section_cls(**{name: values[name] for name in known if name in values})
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AnalyzerConfig:
|
|
"""Scene Analyzer options (§23.2). temperature 0 maximizes repeatability."""
|
|
|
|
backend: str = "ollama"
|
|
model: str = "llama3"
|
|
temperature: float = 0.0
|
|
max_retries: int = 3
|
|
timeout: int = 60
|
|
system_prompt: str | None = None
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
result: dict[str, Any] = {
|
|
"backend": self.backend,
|
|
"model": self.model,
|
|
"temperature": self.temperature,
|
|
"max_retries": self.max_retries,
|
|
"timeout": self.timeout,
|
|
}
|
|
if self.system_prompt is not None:
|
|
result["system_prompt"] = self.system_prompt
|
|
return result
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolverConfig:
|
|
"""Resolver options (§23.2)."""
|
|
|
|
knowledge_base: str = "knowledge_base/"
|
|
strict_mode: bool = True
|
|
allow_aliases: bool = True
|
|
expansion_enabled: bool = True
|
|
max_expansion_depth: int = 8
|
|
include_nsfw: bool = False
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
"knowledge_base": self.knowledge_base,
|
|
"strict_mode": self.strict_mode,
|
|
"allow_aliases": self.allow_aliases,
|
|
"expansion_enabled": self.expansion_enabled,
|
|
"max_expansion_depth": self.max_expansion_depth,
|
|
"include_nsfw": self.include_nsfw,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ValidatorConfig:
|
|
"""Scene Validator options (§23.2)."""
|
|
|
|
allow_unknown_fields: bool = False
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {"allow_unknown_fields": self.allow_unknown_fields}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PromptBuilderConfig:
|
|
"""Prompt Builder options (§23.2)."""
|
|
|
|
target: str = "easy_illustrious"
|
|
separator: str = ","
|
|
trim_empty_outputs: bool = True
|
|
remove_duplicate_tags: bool = True
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
"target": self.target,
|
|
"separator": self.separator,
|
|
"trim_empty_outputs": self.trim_empty_outputs,
|
|
"remove_duplicate_tags": self.remove_duplicate_tags,
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DebugConfig:
|
|
"""Debug/logging options (§23.2)."""
|
|
|
|
enabled: bool = False
|
|
level: str = "basic"
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {"enabled": self.enabled, "level": self.level}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Config:
|
|
"""The complete compiler configuration (§23.2), immutable once produced."""
|
|
|
|
schema: str = "1.0"
|
|
analyzer: AnalyzerConfig = AnalyzerConfig()
|
|
resolver: ResolverConfig = ResolverConfig()
|
|
validator: ValidatorConfig = ValidatorConfig()
|
|
prompt_builder: PromptBuilderConfig = PromptBuilderConfig()
|
|
debug: DebugConfig = DebugConfig()
|
|
|
|
@classmethod
|
|
def from_json(cls, data: Mapping[str, Any]) -> Config:
|
|
"""Validate and build a Config; raise ConfigError (SC0014) if invalid."""
|
|
issues = validate_document(data, _CONFIG_SCHEMA)
|
|
if issues:
|
|
details = "; ".join(f"{i.path or '<root>'}: {i.message}" for i in issues)
|
|
raise ConfigError(
|
|
Message(
|
|
code="SC0014",
|
|
severity=Severity.FATAL,
|
|
title="Invalid configuration",
|
|
description=f"Configuration failed schema validation: {details}",
|
|
context={"issues": [i.message for i in issues]},
|
|
)
|
|
)
|
|
return cls(
|
|
schema=data.get("schema", "1.0"),
|
|
analyzer=_section_from_json(AnalyzerConfig, data.get("analyzer")),
|
|
resolver=_section_from_json(ResolverConfig, data.get("resolver")),
|
|
validator=_section_from_json(ValidatorConfig, data.get("validator")),
|
|
prompt_builder=_section_from_json(PromptBuilderConfig, data.get("prompt_builder")),
|
|
debug=_section_from_json(DebugConfig, data.get("debug")),
|
|
)
|
|
|
|
def to_json(self) -> dict[str, Any]:
|
|
return {
|
|
"schema": self.schema,
|
|
"analyzer": self.analyzer.to_json(),
|
|
"resolver": self.resolver.to_json(),
|
|
"validator": self.validator.to_json(),
|
|
"prompt_builder": self.prompt_builder.to_json(),
|
|
"debug": self.debug.to_json(),
|
|
}
|
|
|
|
|
|
def load_config(path: str | Path) -> Config:
|
|
"""Load and validate configuration from a JSON file.
|
|
|
|
Raises:
|
|
ConfigError: If the file contents fail schema validation (SC0014).
|
|
"""
|
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
return Config.from_json(data)
|