* feat(kb): automatic KB builder, ingestion & validation tooling (#35) Closes #118, #119, #120, #121, #122. - Ingest committed Danbooru alias/implication snapshots during generation: synonyms reach canonicals (long black stockings -> thighhighs), implications become expand relations. Curated-wins preserved. - Automatic candidate validator (structural rules reused from kb_validation + confidence heuristic, no approval gate; SC0022). - Duplicate/conflict detection tool (scripts/detect_kb_conflicts.py) + reusable detect_conflicts; clean reference KB reports zero. - KB coverage benchmark harness (scripts/coverage_benchmark.py) + JSON summary. - Single fully-automated, CI-runnable build pipeline (scripts/build_knowledge_base.py) that fails fast on rejections/conflicts and never prompts. Docs added. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(kb): use pytest.approx for confidence float assertions (SonarCloud reliability) 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>
132 lines
4.7 KiB
Python
132 lines
4.7 KiB
Python
"""Fully-automated Knowledge Base build pipeline (issue #122, epic #35).
|
|
|
|
One command runs the whole generation flow with **zero human input**:
|
|
|
|
build vocab (dev-only) → generate candidates → ingest aliases/implications
|
|
→ auto-validate candidates → conflict-scan → write gen_*.json + manifest
|
|
|
|
- The vocab build reads an external Danbooru/Raffle export and is skipped when that
|
|
source is absent (e.g. in CI); the committed ``data/danbooru_vocab.txt`` snapshot
|
|
is used instead, so the pipeline is CI-runnable with no network access.
|
|
- Candidates are validated automatically (structural rules + confidence, #119) and
|
|
the resulting Knowledge Base is conflict-scanned (#120). On any rejection or
|
|
conflict the pipeline writes a structured JSON log to stderr and exits non-zero
|
|
**without writing** — it never prompts.
|
|
- The curated-wins additive merge is preserved end-to-end (candidates colliding
|
|
with curated ids/aliases are rejected).
|
|
|
|
Usage:
|
|
python scripts/build_knowledge_base.py
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
|
|
from compiler.common.kb_autovalidate import validate_candidate # noqa: E402
|
|
from compiler.common.kb_conflicts import detect_conflicts # noqa: E402
|
|
from scripts import build_vocab # noqa: E402
|
|
from scripts import generate_kb_from_vocab as gen # noqa: E402
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
_KB_DIR = _REPO_ROOT / "knowledge_base"
|
|
_VOCAB = _REPO_ROOT / "data" / "danbooru_vocab.txt"
|
|
_GEN_PREFIX = "gen_"
|
|
_MANIFEST = "manifest.json"
|
|
|
|
|
|
def _source_vocab() -> set[str]:
|
|
"""The committed source-vocab tag set (candidate ids must be members)."""
|
|
tags: set[str] = set()
|
|
for line in _VOCAB.read_text(encoding="utf-8").splitlines():
|
|
if line.strip():
|
|
tags.add(line.split("\t", 1)[1].strip())
|
|
return tags
|
|
|
|
|
|
def _curated() -> tuple[list[dict], set[str], set[str]]:
|
|
"""Return (curated entries, curated ids, curated aliases)."""
|
|
entries: list[dict] = []
|
|
ids: set[str] = set()
|
|
aliases: set[str] = set()
|
|
for path in sorted(_KB_DIR.glob("*.json")):
|
|
if path.name.startswith(_GEN_PREFIX) or path.name == _MANIFEST:
|
|
continue
|
|
file_entries = json.loads(path.read_text(encoding="utf-8"))
|
|
entries.extend(file_entries)
|
|
for entry in file_entries:
|
|
ids.add(entry["id"])
|
|
aliases.update(entry.get("aliases", ()))
|
|
return entries, ids, aliases
|
|
|
|
|
|
def validate_candidates(
|
|
by_category: dict[str, list[dict]],
|
|
curated_ids: set[str],
|
|
curated_aliases: set[str],
|
|
source_vocab: set[str],
|
|
) -> list[dict]:
|
|
"""Auto-validate every candidate; return structured rejection records."""
|
|
candidates = [entry for entries in by_category.values() for entry in entries]
|
|
generated_ids = {entry["id"] for entry in candidates}
|
|
known_ids = curated_ids | generated_ids
|
|
rejections: list[dict] = []
|
|
for entry in candidates:
|
|
decision = validate_candidate(
|
|
entry,
|
|
curated_ids=curated_ids,
|
|
curated_aliases=curated_aliases,
|
|
known_ids=known_ids,
|
|
source_vocab=source_vocab,
|
|
)
|
|
if not decision.accepted:
|
|
rejections.append(
|
|
{"id": entry.get("id"), "reasons": [m.description for m in decision.reasons]}
|
|
)
|
|
return rejections
|
|
|
|
|
|
def run_pipeline(write: bool = True) -> int:
|
|
"""Run the full pipeline. Return 0 on success, 1 on rejection/conflict."""
|
|
if build_vocab._SOURCE.is_file():
|
|
build_vocab.main()
|
|
else:
|
|
print(f"[pipeline] external vocab source absent; using committed {_VOCAB.name}")
|
|
|
|
by_category = gen.build_generated_entries()
|
|
curated_entries, curated_ids, curated_aliases = _curated()
|
|
source_vocab = _source_vocab()
|
|
|
|
rejections = validate_candidates(by_category, curated_ids, curated_aliases, source_vocab)
|
|
candidates = [entry for entries in by_category.values() for entry in entries]
|
|
report = detect_conflicts(curated_entries + candidates, curated_ids)
|
|
|
|
if rejections or report.total:
|
|
sys.stderr.write(
|
|
json.dumps(
|
|
{
|
|
"status": "failed",
|
|
"rejected_candidates": rejections,
|
|
"conflicts": report.to_json(),
|
|
},
|
|
indent=2,
|
|
)
|
|
+ "\n"
|
|
)
|
|
return 1
|
|
|
|
if write:
|
|
counts = gen.write_generated(by_category)
|
|
print(f"[pipeline] wrote {sum(counts.values())} generated entries; manifest stamped.")
|
|
else:
|
|
print(f"[pipeline] dry run OK: {len(candidates)} candidates, no conflicts.")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(run_pipeline())
|