Files
Damkohler-CaptionForge/captionforge_claim_engine.py
T

1406 lines
51 KiB
Python

#!/usr/bin/env python
"""
CaptionForge Claim Extraction Engine
- CaptionForge
- This module is part of **CaptionForge**, a model-agnostic captioning
framework for ComfyUI developed by **J. L. Córdova**.
- Repository
https://github.com/Damkohler/CaptionForge
- CaptionForge focuses on practical dataset-captioning infrastructure for:
• LoRA dataset preparation
• multi-engine caption generation
• JSONL audit trails
• claim extraction and refinement
• consensus-oriented caption improvement
- Module Purpose
- The **CaptionForge Claim Extraction Engine** implements Pass B of the
CaptionForge caption-refinement pipeline.
- Pass B consumes Pass A caption audit records and decomposes caption text
into structured, atomic visual claims that can later be compared,
normalized, scored, audited, and recombined into cleaner final captions.
- Instead of treating a caption as a final answer, this engine treats each
caption as a source of visual evidence.
- The engine:
• Reads shared Pass A caption JSONL records
• Groups captions by image key
• Preserves source-caption references
• Extracts atomic visual claims
• Normalizes equivalent or near-equivalent claims
• Aggregates claim support across sources
• Detects simple mutually exclusive attribute conflicts
• Emits Pass B JSONL records for downstream refinement
• Exports future-LLM prompt bundles for backend development
- Current Backend Strategy
- v0.4.0 is a deterministic, LLM-ready scaffold.
- Supported backends:
• heuristic
Deterministic rule-based claim extraction for immediate local
testing and reproducible Pass B output.
• manual_json
Development backend for parsing precomputed JSON/JSONL claim
responses using the same response shape expected from a future
live text-only LLM backend.
- The engine is intentionally structured so a future backend such as Ollama,
Transformers, llama.cpp, or another local text model provider can be wired
into the same prompt and response contract.
- Claim Model
- Extracted claims preserve both original and normalized forms.
- Each atomic claim tracks:
• original claim text
• normalized claim text
• claim type
• specificity
• confidence
• source record index
• model family
• model name
• ensemble run index
• phrase index
- Normalized claims aggregate support across matching atomic claims and
preserve examples, source references, model families, support counts,
confidence labels, and conflict-relevant categories.
- Audit and Reproducibility
- Pass B records preserve the evidence chain from final normalized claims
back to the originating Pass A captions.
- v0.4.0 adds parser-audit fields for LLM-readiness:
• llm_parse_result
• parser_warnings
• rejected_claims
• raw_llm_response
- These fields allow future live LLM integrations to be tested without
losing visibility into malformed responses, rejected claims, or prompt
contract failures.
- Design Philosophy
- CaptionForge treats claim extraction as an intermediate reasoning layer,
not as a final captioning step.
- The purpose of Pass B is to make caption evidence inspectable.
- By decomposing captions into atomic claims, later passes can compare
agreement across engines, detect contradictions, normalize synonyms, and
produce captions that are closer to human-quality descriptive summaries.
- ⚠️ Development Status
- This is Pass B v0.4.0 infrastructure.
- The default backend is deterministic and heuristic.
- Live LLM claim extraction is not yet integrated.
- The prompt and response schema are intentionally present so future LLM
backends can be added without changing the downstream Pass B record shape.
- Attribution & License
- Concept and implementation by **J. L. Córdova**
with development assistance from **ChatGPT (OpenAI)**.
- Copyright (c) 2026 J. L. Córdova
- Released under the **MIT License**.
"""
from __future__ import annotations
MANIFEST = {
"name": "CaptionForge Claim Extraction Engine",
"version": (0, 4, 0),
"author": "J. L. Córdova",
"description": (
"CaptionForge Pass B text-only claim extraction engine for converting Pass A "
"caption JSONL records into auditable atomic visual claims. Provides deterministic "
"heuristic extraction, manual JSON parsing for future LLM response testing, source "
"caption preservation, claim normalization, support aggregation, simple conflict "
"detection, parser-audit fields, and future-LLM prompt bundle export. Designed as "
"an LLM-ready intermediate reasoning layer for model-agnostic, consensus-oriented "
"caption refinement workflows."
),
}
import argparse
import hashlib
import json
import re
from collections import Counter, defaultdict
from dataclasses import asdict, dataclass, field, is_dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Iterable, Optional
# -------------------------------------------------------------------------
# Dataclasses
# -------------------------------------------------------------------------
@dataclass
class ClaimExtractionConfig:
engine_name: str = "CaptionForge Claim Engine"
engine_version: str = "0.4.0"
# Extraction behavior.
min_claim_chars: int = 3
max_claim_chars: int = 120
max_claims_per_caption: int = 80
include_low_confidence: bool = True
dedupe_within_caption: bool = True
normalize_synonyms: bool = True
detect_conflicts: bool = True
# LLM-ready backend contract.
# v0.3 implements "heuristic" and "manual_json".
text_llm_backend: str = "heuristic"
text_llm_model: str = ""
text_llm_prompt: str = (
"Extract atomic visible visual claims from the provided image captions. "
"Return strict JSON only. Preserve source references. Do not add facts "
"that are not supported by the captions."
)
prompt_schema_version: str = "captionforge-pass-b-prompt-v0.4"
response_schema_version: str = "captionforge-pass-b-claims-v0.4"
preserve_raw_llm_response: bool = False
@dataclass
class BatchClaimConfig:
input_jsonl: str
output_jsonl: str = ""
write_jsonl: bool = True
dry_run: bool = False
limit_images: int = 0
include_error_pass_a_records: bool = False
overwrite: bool = True
# v0.3 LLM-ready diagnostics / manual backend.
manual_json_path: str = ""
write_llm_prompt_jsonl: bool = False
llm_prompt_jsonl: str = ""
# v0.4 focused debugging / parser audit.
image_key_filter: str = ""
@dataclass
class SourceCaptionRef:
source_record_index: int
image: str
image_key: str
model_family: str
model_name: str
ensemble_run_index: int
status: str
caption: str
raw_caption: str
timestamp: str
@dataclass
class AtomicClaim:
claim_id: str
# v0.2+ rich claim preservation.
original_claim: str
normalized_claim: str
claim_type: str
specificity: str
confidence: float
source_record_index: int
model_family: str
model_name: str
ensemble_run_index: int
phrase_index: int
# Backward-compatible aliases.
text: str = ""
normalized: str = ""
category: str = ""
@dataclass
class NormalizedClaim:
claim_id: str
normalized_claim: str
representative_original_claim: str
original_claims: list[str]
claim_type: str
specificity: str
support_count: int
model_families: list[str]
model_names: list[str]
source_refs: list[dict[str, Any]]
examples: list[str]
confidence: float
confidence_label: str
# Backward-compatible aliases.
normalized: str = ""
category: str = ""
@dataclass
class ConflictRecord:
conflict_type: str
category: str
values: list[str]
support_counts: dict[str, int]
note: str
@dataclass
class LLMParseResult:
backend: str
status: str
prompt_sha1: str = ""
prompt_schema_version: str = ""
response_schema_version: str = ""
parsed_claim_count: int = 0
rejected_claim_count: int = 0
parser_warnings: list[str] = field(default_factory=list)
raw_response_sha1: str = ""
error: str = ""
@dataclass
class RejectedClaim:
source_index: int
reason: str
item: Any
@dataclass
class ImageClaimRecord:
captionforge_pass: str
image_key: str
image: str
status: str
source_caption_count: int
source_caption_records: list[SourceCaptionRef]
atomic_claims: list[AtomicClaim]
normalized_claims: list[NormalizedClaim]
conflicts: list[ConflictRecord]
uncertainty_flags: list[str]
params: dict[str, Any]
timestamp: str
llm_parse_result: Optional[LLMParseResult] = None
parser_warnings: list[str] = field(default_factory=list)
rejected_claims: list[RejectedClaim] = field(default_factory=list)
raw_llm_response: str = ""
@dataclass
class BatchClaimResult:
records: list[ImageClaimRecord] = field(default_factory=list)
skipped: int = 0
failed: int = 0
@property
def processed(self) -> int:
return len([r for r in self.records if r.status == "ok"])
@property
def jsonl_text(self) -> str:
return "\n".join(json.dumps(record_to_json(r), ensure_ascii=False) for r in self.records)
# -------------------------------------------------------------------------
# Constants and normalization vocabulary
# -------------------------------------------------------------------------
BOILERPLATE_PREFIXES = [
"the image depicts",
"the image shows",
"this image depicts",
"this image shows",
"the photo shows",
"this photo shows",
"a photo of",
"a photograph of",
"an image of",
"in this image",
"in this digital illustration",
"digital illustration of",
]
STYLE_WORDS = {
"digital illustration", "illustration", "render", "3d render", "quasi-3d render",
"anime style", "doll-like", "stylized", "cartoon", "painting", "photograph", "photo",
"studio lighting", "soft lighting", "dramatic lighting", "moody lighting",
"glossy", "vinyl", "plastic", "dollora",
}
COLOR_SYNONYMS = {
"blond": "blonde",
"light blond": "blonde",
"light blonde": "blonde",
"golden": "blonde",
"golden blond": "blonde",
"golden blonde": "blonde",
"platinum": "platinum blonde",
"brunette": "brown",
"auburn": "red",
"ginger": "red",
"reddish": "red",
"grey": "gray",
"silver": "gray",
}
HAIR_COLORS = [
"platinum blonde", "blonde", "black", "brown", "red", "gray", "white", "pink",
"blue", "green", "purple", "orange", "dark", "light",
]
EYE_COLORS = ["blue", "green", "brown", "hazel", "gray", "black", "red", "pink", "purple"]
BACKGROUND_COLORS = ["white", "gray", "black", "blue", "green", "red", "pink", "purple", "yellow", "orange", "brown"]
CLOTHING_HINTS = [
"shirt", "t-shirt", "top", "tank top", "blouse", "dress", "jacket", "coat",
"skirt", "pants", "jeans", "shorts", "bodysuit", "sweater", "hoodie", "boots",
"shoes", "gloves", "hat", "scarf", "outfit", "clothing",
]
POSE_HINTS = [
"standing", "sitting", "kneeling", "crouching", "leaning", "looking", "facing",
"turned", "pose", "posed", "arms", "hands", "hand", "shoulder", "profile",
"three-quarter", "over one shoulder",
]
SHOT_HINTS = [
"close-up", "extreme close-up", "medium shot", "upper body", "portrait",
"full body", "cowboy shot", "above knee", "waist-up", "headshot",
]
MUTUALLY_EXCLUSIVE_CATEGORIES = {"hair_color", "eye_color", "background_color"}
# -------------------------------------------------------------------------
# Serialization and file helpers
# -------------------------------------------------------------------------
def iso_timestamp() -> str:
return datetime.now().isoformat(timespec="seconds")
def json_safe(value: Any) -> Any:
if is_dataclass(value):
return {k: json_safe(v) for k, v in asdict(value).items()}
if isinstance(value, set):
return sorted(value)
if isinstance(value, Path):
return str(value)
if isinstance(value, dict):
return {k: json_safe(v) for k, v in value.items()}
if isinstance(value, list):
return [json_safe(v) for v in value]
if isinstance(value, tuple):
return [json_safe(v) for v in value]
return value
def record_to_json(record: Any) -> dict[str, Any]:
return json_safe(record)
def safe_mkdir(path: Path) -> None:
path.mkdir(parents=True, exist_ok=True)
def stable_claim_id(*parts: str) -> str:
text = "::".join(str(p) for p in parts)
return hashlib.sha1(text.encode("utf-8")).hexdigest()[:16]
def sha1_text(text: str) -> str:
return hashlib.sha1(text.encode("utf-8")).hexdigest()
def read_jsonl(path: str | Path) -> list[dict[str, Any]]:
p = Path(path)
if p.is_dir():
raise IsADirectoryError(
f"input_jsonl points to a folder, not a JSONL file: {p}\n"
"Select the Pass A captions JSONL file, for example captions.jsonl."
)
if not p.exists():
raise FileNotFoundError(f"JSONL file does not exist: {p}")
records: list[dict[str, Any]] = []
with p.open("r", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid JSONL at {p}:{line_number}: {exc}") from exc
if isinstance(obj, dict):
records.append(obj)
else:
raise ValueError(f"JSONL record at {p}:{line_number} is not an object.")
return records
def write_jsonl(path: str | Path, records: Iterable[Any], overwrite: bool = True, dry_run: bool = False) -> None:
if dry_run:
return
p = Path(path)
safe_mkdir(p.parent)
mode = "w" if overwrite else "a"
with p.open(mode, encoding="utf-8") as f:
for record in records:
f.write(json.dumps(record_to_json(record), ensure_ascii=False) + "\n")
def default_output_path(input_jsonl: str | Path) -> Path:
p = Path(input_jsonl)
if p.is_dir():
raise IsADirectoryError(
f"input_jsonl points to a folder, not a JSONL file: {p}\n"
"Select the Pass A captions JSONL file, for example captions.jsonl."
)
stem = p.stem or "captions"
return p.with_name(f"{stem}_claims_pass_b.jsonl")
def default_prompt_output_path(input_jsonl: str | Path) -> Path:
p = Path(input_jsonl)
if p.is_dir():
raise IsADirectoryError(
f"input_jsonl points to a folder, not a JSONL file: {p}\n"
"Select the Pass A captions JSONL file, for example captions.jsonl."
)
stem = p.stem or "captions"
return p.with_name(f"{stem}_pass_b_llm_prompts.jsonl")
# -------------------------------------------------------------------------
# Pass A grouping
# -------------------------------------------------------------------------
def pass_a_record_is_usable(record: dict[str, Any], include_errors: bool = False) -> bool:
if str(record.get("captionforge_pass") or "").upper() not in {"", "A"}:
return False
status = str(record.get("status") or "ok").lower()
if status != "ok" and not include_errors:
return False
caption = str(record.get("caption") or record.get("raw_caption") or "").strip()
if not caption and status == "ok":
return False
return True
def get_image_key(record: dict[str, Any]) -> str:
return str(record.get("image_key") or record.get("image") or record.get("source") or "").strip()
def group_pass_a_records(records: list[dict[str, Any]], include_errors: bool = False) -> dict[str, list[dict[str, Any]]]:
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for index, record in enumerate(records):
if not pass_a_record_is_usable(record, include_errors=include_errors):
continue
key = get_image_key(record)
if not key:
continue
enriched = dict(record)
enriched["_source_record_index"] = index
groups[key].append(enriched)
return dict(groups)
def make_source_ref(record: dict[str, Any]) -> SourceCaptionRef:
return SourceCaptionRef(
source_record_index=int(record.get("_source_record_index", 0)),
image=str(record.get("image") or ""),
image_key=str(record.get("image_key") or get_image_key(record)),
model_family=str(record.get("model_family") or ""),
model_name=str(record.get("model_name") or ""),
ensemble_run_index=int(record.get("ensemble_run_index") or 0),
status=str(record.get("status") or "ok"),
caption=str(record.get("caption") or ""),
raw_caption=str(record.get("raw_caption") or ""),
timestamp=str(record.get("timestamp") or ""),
)
# -------------------------------------------------------------------------
# LLM prompt contract
# -------------------------------------------------------------------------
def build_llm_prompt_bundle(
image_key: str,
source_refs: list[SourceCaptionRef],
config: ClaimExtractionConfig,
) -> dict[str, Any]:
captions = [
{
"source_record_index": src.source_record_index,
"model_family": src.model_family,
"model_name": src.model_name,
"ensemble_run_index": src.ensemble_run_index,
"caption": src.caption or src.raw_caption,
}
for src in source_refs
]
instructions = config.text_llm_prompt.strip() or ClaimExtractionConfig().text_llm_prompt
response_contract = {
"claims": [
{
"claim_type": "hair_color | eye_color | clothing | pose_action | expression | style_medium | background_color | background_object | composition | visible_text | general",
"original_claim": "short atomic visible claim copied or minimally rewritten from captions",
"normalized_claim": "canonical comparison form; preserve useful specificity when uncertain",
"specificity": "generic | specific | specific_normalized",
"certainty": "visible | inferred_from_caption | uncertain",
"source_record_indexes": [0]
}
]
}
prompt_text = (
f"{instructions}\n\n"
"Return strict JSON only, with no markdown fences and no explanatory prose.\n"
"Do not add any visual fact that is not supported by the captions.\n"
"Keep claims atomic: one visible attribute/action/style/background fact per claim.\n\n"
f"Expected JSON response shape:\n{json.dumps(response_contract, ensure_ascii=False, indent=2)}\n\n"
f"Image key: {image_key}\n"
f"Source captions:\n{json.dumps(captions, ensure_ascii=False, indent=2)}\n"
)
return {
"captionforge_pass": "B_prompt",
"prompt_schema_version": config.prompt_schema_version,
"image_key": image_key,
"prompt": prompt_text,
"prompt_sha1": sha1_text(prompt_text),
"source_captions": captions,
"timestamp": iso_timestamp(),
}
def load_manual_claims(path: str | Path) -> dict[str, Any]:
if not str(path).strip():
return {}
p = Path(path)
if p.is_dir():
raise IsADirectoryError(f"manual_json_path points to a folder, not a JSON/JSONL file: {p}")
if not p.exists():
raise FileNotFoundError(f"manual_json_path does not exist: {p}")
by_key: dict[str, Any] = {}
if p.suffix.lower() == ".jsonl":
with p.open("r", encoding="utf-8") as f:
for line_number, line in enumerate(f, start=1):
line = line.strip()
if not line:
continue
obj = json.loads(line)
if not isinstance(obj, dict):
raise ValueError(f"manual_json record at {p}:{line_number} is not an object.")
key = str(obj.get("image_key") or "").strip()
if not key:
key = "__single__" if "__single__" not in by_key else f"__single__:{line_number}"
by_key[key] = obj
return by_key
obj = json.loads(p.read_text(encoding="utf-8"))
if isinstance(obj, dict):
if "image_key" in obj:
by_key[str(obj.get("image_key") or "__single__")] = obj
elif "claims" in obj:
by_key["__single__"] = obj
else:
# Treat as mapping: image_key -> response object
for key, value in obj.items():
by_key[str(key)] = value
return by_key
if isinstance(obj, list):
for idx, item in enumerate(obj):
if not isinstance(item, dict):
raise ValueError(f"manual_json list item {idx} is not an object.")
key = str(item.get("image_key") or f"__single__:{idx}").strip()
by_key[key] = item
return by_key
raise ValueError("manual_json_path must contain a JSON object/list or JSONL objects.")
def extract_json_object(text: str) -> dict[str, Any]:
text = text.strip()
if not text:
raise ValueError("Empty LLM/manual JSON response.")
if text.startswith("```"):
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
text = re.sub(r"\s*```$", "", text)
obj = json.loads(text)
if not isinstance(obj, dict):
raise ValueError("LLM/manual JSON response must be a JSON object.")
return obj
def normalize_manual_response(value: Any) -> dict[str, Any]:
if isinstance(value, str):
return extract_json_object(value)
if isinstance(value, dict):
if "claims" in value:
return value
# Accept direct mapping with response nested under "response".
if "response" in value:
return normalize_manual_response(value["response"])
raise ValueError("Manual response must be an object with a 'claims' list or a JSON string.")
def manual_claims_for_image(image_key: str, manual_by_key: dict[str, Any]) -> Optional[dict[str, Any]]:
if not manual_by_key:
return None
if image_key in manual_by_key:
return normalize_manual_response(manual_by_key[image_key])
if "__single__" in manual_by_key and len(manual_by_key) == 1:
return normalize_manual_response(manual_by_key["__single__"])
return None
# -------------------------------------------------------------------------
# Text splitting / normalization
# -------------------------------------------------------------------------
def normalize_whitespace(text: str) -> str:
text = text.replace("\n", " ")
text = re.sub(r"\s+", " ", text)
text = re.sub(r"\s+([,.;:!?])", r"\1", text)
return text.strip()
def strip_boilerplate(text: str) -> str:
s = normalize_whitespace(text)
lowered = s.lower()
for prefix in BOILERPLATE_PREFIXES:
if lowered.startswith(prefix):
return s[len(prefix):].lstrip(" ,:-")
return s
def split_caption_to_phrases(caption: str) -> list[str]:
caption = strip_boilerplate(caption)
raw_parts = re.split(r"[,;•\n]+|(?<=[.!?])\s+", caption)
phrases: list[str] = []
for raw in raw_parts:
p = normalize_whitespace(raw).strip(" .;:,")
if not p:
continue
subparts = re.split(r"\s+(?:and|while|with)\s+", p, flags=re.IGNORECASE)
if len(subparts) > 1:
for sub in subparts:
sub = normalize_whitespace(sub).strip(" .;:,")
if len(sub) >= 3:
phrases.append(sub)
else:
phrases.append(p)
return phrases
def canonical_color(value: str) -> str:
v = value.lower().strip()
v = re.sub(r"[-_]+", " ", v)
v = re.sub(r"\s+", " ", v)
return COLOR_SYNONYMS.get(v, v)
def clean_claim_text(text: str) -> str:
text = strip_boilerplate(text)
text = text.strip().strip(" .;:,")
text = re.sub(r"^(?:and|with|while|a|an|the)\s+", "", text, flags=re.IGNORECASE)
text = re.sub(r"\b(a|an)\s+(a|an|the)\b", r"\2", text, flags=re.IGNORECASE)
text = normalize_whitespace(text)
return text
def singularize_light(text: str) -> str:
replacements = {
"eyes": "eye",
"hands": "hand",
"arms": "arm",
"legs": "leg",
"buns": "bun",
"gloves": "glove",
"boots": "boot",
"shoes": "shoe",
}
words = text.split()
return " ".join(replacements.get(w, w) for w in words)
def normalize_claim_text(text: str, config: ClaimExtractionConfig) -> tuple[str, str, str]:
"""
Return (normalized_text, claim_type, specificity).
"""
raw = clean_claim_text(text)
lower = raw.lower()
lower = lower.replace("t shirt", "t-shirt")
lower = re.sub(r"\s+", " ", lower).strip()
hair_match = re.search(
r"\b((?:light|dark|golden|platinum|blond|blonde|black|brown|brunette|red|reddish|auburn|ginger|gray|grey|white|pink|blue|green|purple|orange)(?:\s+blond|\s+blonde)?)\s+hair\b",
lower,
)
if hair_match:
color_raw = hair_match.group(1)
color = canonical_color(color_raw)
specificity = "specific_normalized" if color != color_raw else "specific"
return f"{color} hair", "hair_color", specificity
eye_match = re.search(r"\b(blue|green|brown|hazel|gray|grey|black|red|pink|purple)\s+eyes?\b", lower)
if eye_match:
color_raw = eye_match.group(1)
color = canonical_color(color_raw)
specificity = "specific_normalized" if color != color_raw else "specific"
return f"{color} eyes", "eye_color", specificity
bg_match = re.search(
r"\b(white|gray|grey|black|blue|green|red|pink|purple|yellow|orange|brown)\s+(?:background|backdrop|wall)\b",
lower,
)
if bg_match:
color_raw = bg_match.group(1)
color = canonical_color(color_raw)
specificity = "specific_normalized" if color != color_raw else "specific"
return f"{color} background", "background_color", specificity
for hint in CLOTHING_HINTS:
if re.search(rf"\b{re.escape(hint)}\b", lower):
return singularize_light(lower), "clothing", "specific"
for hint in POSE_HINTS:
if re.search(rf"\b{re.escape(hint)}\b", lower):
return lower, "pose_action", "specific"
for hint in SHOT_HINTS:
if re.search(rf"\b{re.escape(hint)}\b", lower):
return lower, "composition", "specific"
for hint in STYLE_WORDS:
if hint in lower:
return lower, "style_medium", "specific"
if '"' in raw or " text " in f" {lower} " or "reads" in lower or "says" in lower:
return lower, "visible_text", "specific"
if any(w in lower for w in ["poster", "guitar", "graffiti", "wall", "floor", "room", "background", "backdrop"]):
return lower, "background_object", "specific"
if any(w in lower for w in ["hair", "hairstyle", "bun", "braid", "ponytail", "bangs"]):
return lower, "hair_style", "specific"
if any(w in lower for w in ["expression", "smile", "smiling", "serious", "neutral", "half-lidded", "brows", "mouth"]):
return lower, "expression", "specific"
return lower, "general", "generic"
def phrase_confidence(phrase: str, normalized: str, claim_type: str) -> float:
lower = phrase.lower()
confidence = 0.65
if claim_type not in {"general"}:
confidence += 0.15
if any(token in lower for token in ["maybe", "possibly", "appears to", "seems to", "likely"]):
confidence -= 0.25
if len(normalized.split()) <= 2:
confidence -= 0.05
if '"' in phrase:
confidence += 0.05
return max(0.05, min(0.95, round(confidence, 2)))
def confidence_label(confidence: float, support_count: int, model_families: list[str]) -> str:
if support_count >= 2 and len(set(model_families)) >= 2:
return "cross_model_supported"
if support_count >= 2:
return "multi_source_supported"
if confidence >= 0.75:
return "single_source_high"
return "single_source"
def should_keep_claim(text: str, normalized: str, config: ClaimExtractionConfig) -> bool:
if len(normalized) < config.min_claim_chars:
return False
if len(normalized) > config.max_claim_chars:
return False
if normalized in {"image", "photo", "illustration", "the scene", "scene"}:
return False
if re.fullmatch(r"\W+", normalized):
return False
return True
# -------------------------------------------------------------------------
# Extraction / aggregation
# -------------------------------------------------------------------------
def make_atomic_claim(
*,
image_key: str,
original_claim: str,
normalized_claim: str,
claim_type: str,
specificity: str,
confidence: float,
source: SourceCaptionRef,
phrase_index: int,
) -> AtomicClaim:
claim_id = stable_claim_id(image_key, str(source.source_record_index), normalized_claim, original_claim, str(phrase_index))
return AtomicClaim(
claim_id=claim_id,
original_claim=original_claim,
normalized_claim=normalized_claim,
claim_type=claim_type,
specificity=specificity,
confidence=confidence,
source_record_index=source.source_record_index,
model_family=source.model_family,
model_name=source.model_name,
ensemble_run_index=source.ensemble_run_index,
phrase_index=phrase_index,
text=original_claim,
normalized=normalized_claim,
category=claim_type,
)
def extract_claims_from_caption(caption: str, source: SourceCaptionRef, config: ClaimExtractionConfig) -> list[AtomicClaim]:
phrases = split_caption_to_phrases(caption)
claims: list[AtomicClaim] = []
seen_norms: set[str] = set()
for phrase_index, phrase in enumerate(phrases):
original = clean_claim_text(phrase)
if not original:
continue
normalized, claim_type, specificity = normalize_claim_text(original, config)
if not should_keep_claim(original, normalized, config):
continue
if config.dedupe_within_caption and normalized in seen_norms:
continue
seen_norms.add(normalized)
confidence = phrase_confidence(original, normalized, claim_type)
if not config.include_low_confidence and confidence < 0.5:
continue
claims.append(
make_atomic_claim(
image_key=source.image_key,
original_claim=original,
normalized_claim=normalized,
claim_type=claim_type,
specificity=specificity,
confidence=confidence,
source=source,
phrase_index=phrase_index,
)
)
if len(claims) >= config.max_claims_per_caption:
break
return claims
def claims_from_manual_response(
image_key: str,
response: dict[str, Any],
source_refs: list[SourceCaptionRef],
config: ClaimExtractionConfig,
) -> tuple[list[AtomicClaim], LLMParseResult, list[RejectedClaim], str]:
"""
Parse a manual/future-LLM JSON response into AtomicClaim objects.
v0.4 intentionally avoids crashing on per-claim mistakes. Bad claim items are
collected in rejected_claims and parser_warnings so the prompt/schema can be
improved before a live LLM backend is wired in.
"""
claims_obj = response.get("claims")
if not isinstance(claims_obj, list):
raise ValueError("Manual/LLM response must contain a 'claims' list.")
raw_response = response.get("raw_llm_response")
if raw_response is None:
raw_response = json.dumps(response, ensure_ascii=False, sort_keys=True)
else:
raw_response = str(raw_response)
source_by_index = {src.source_record_index: src for src in source_refs}
default_source = source_refs[0] if source_refs else SourceCaptionRef(0, image_key, image_key, "", "", 0, "ok", "", "", "")
claims: list[AtomicClaim] = []
rejected: list[RejectedClaim] = []
warnings: list[str] = []
for idx, item in enumerate(claims_obj):
if not isinstance(item, dict):
rejected.append(RejectedClaim(idx, "claim item is not an object", item))
continue
original = clean_claim_text(str(item.get("original_claim") or item.get("claim") or item.get("text") or ""))
if not original:
rejected.append(RejectedClaim(idx, "missing original_claim/claim/text", item))
continue
if len(original) < config.min_claim_chars:
rejected.append(RejectedClaim(idx, f"claim shorter than min_claim_chars={config.min_claim_chars}", item))
continue
if len(original) > config.max_claim_chars:
rejected.append(RejectedClaim(idx, f"claim longer than max_claim_chars={config.max_claim_chars}", item))
continue
normalized_raw = str(item.get("normalized_claim") or item.get("normalized") or "").strip()
claim_type_raw = str(item.get("claim_type") or item.get("category") or "").strip()
specificity = str(item.get("specificity") or "specific").strip() or "specific"
if normalized_raw:
normalized = clean_claim_text(normalized_raw).lower()
if not normalized:
normalized, claim_type, norm_specificity = normalize_claim_text(original, config)
if not item.get("specificity"):
specificity = norm_specificity
else:
claim_type = claim_type_raw or normalize_claim_text(original, config)[1]
else:
normalized, claim_type, norm_specificity = normalize_claim_text(original, config)
if not item.get("specificity"):
specificity = norm_specificity
if not claim_type:
claim_type = "general"
# Accept source_record_indexes, source_record_index, or no source.
src_indexes = item.get("source_record_indexes")
if src_indexes is None:
src_indexes = item.get("source_record_index")
if src_indexes is None:
src_indexes = [default_source.source_record_index]
warnings.append(f"claims[{idx}] missing source_record_indexes; defaulted to {default_source.source_record_index}.")
if not isinstance(src_indexes, list):
src_indexes = [src_indexes]
confidence = item.get("confidence")
try:
confidence_f = float(confidence)
except Exception:
confidence_f = 0.80
if confidence_f < 0.0 or confidence_f > 1.0:
warnings.append(f"claims[{idx}] confidence {confidence_f!r} outside 0..1; clamped.")
confidence_f = max(0.0, min(1.0, confidence_f))
made_any = False
for local_phrase_index, src_index_value in enumerate(src_indexes):
try:
src_index = int(src_index_value)
except Exception:
warnings.append(f"claims[{idx}] has non-integer source index {src_index_value!r}; defaulted.")
src_index = default_source.source_record_index
source = source_by_index.get(src_index)
if source is None:
warnings.append(f"claims[{idx}] source index {src_index!r} not found in Pass A records; defaulted.")
source = default_source
claims.append(
make_atomic_claim(
image_key=image_key,
original_claim=original,
normalized_claim=normalized,
claim_type=claim_type,
specificity=specificity,
confidence=confidence_f,
source=source,
phrase_index=idx * 1000 + local_phrase_index,
)
)
made_any = True
if not made_any:
rejected.append(RejectedClaim(idx, "no usable source_record_indexes", item))
status = "ok" if not rejected else "ok_with_rejected_claims"
parse_result = LLMParseResult(
backend="manual_json",
status=status,
response_schema_version=config.response_schema_version,
parsed_claim_count=len(claims),
rejected_claim_count=len(rejected),
parser_warnings=warnings,
raw_response_sha1=sha1_text(raw_response) if raw_response else "",
)
return claims, parse_result, rejected, raw_response
def aggregate_claims(image_key: str, claims: list[AtomicClaim]) -> list[NormalizedClaim]:
buckets: dict[str, list[AtomicClaim]] = defaultdict(list)
for claim in claims:
buckets[claim.normalized_claim].append(claim)
normalized_claims: list[NormalizedClaim] = []
for normalized, items in sorted(buckets.items(), key=lambda kv: (-len(kv[1]), kv[0])):
category_counts = Counter(item.claim_type for item in items)
claim_type = category_counts.most_common(1)[0][0]
source_refs = [
{
"source_record_index": item.source_record_index,
"model_family": item.model_family,
"model_name": item.model_name,
"ensemble_run_index": item.ensemble_run_index,
"phrase_index": item.phrase_index,
"claim_id": item.claim_id,
}
for item in items
]
original_claims: list[str] = []
for item in items:
if item.original_claim not in original_claims:
original_claims.append(item.original_claim)
examples = original_claims[:3]
model_families = sorted({item.model_family for item in items if item.model_family})
model_names = sorted({item.model_name for item in items if item.model_name})
avg_confidence = round(sum(item.confidence for item in items) / max(1, len(items)), 3)
specificity_counts = Counter(item.specificity for item in items)
specificity = specificity_counts.most_common(1)[0][0]
normalized_claims.append(
NormalizedClaim(
claim_id=stable_claim_id(image_key, normalized),
normalized_claim=normalized,
representative_original_claim=original_claims[0] if original_claims else normalized,
original_claims=original_claims,
claim_type=claim_type,
specificity=specificity,
support_count=len(items),
model_families=model_families,
model_names=model_names,
source_refs=source_refs,
examples=examples,
confidence=avg_confidence,
confidence_label=confidence_label(avg_confidence, len(items), model_families),
normalized=normalized,
category=claim_type,
)
)
return normalized_claims
def extract_value_for_conflict(normalized: str, category: str) -> str:
if category == "hair_color" and normalized.endswith(" hair"):
return normalized[:-5].strip()
if category == "eye_color" and normalized.endswith(" eyes"):
return normalized[:-5].strip()
if category == "background_color" and normalized.endswith(" background"):
return normalized[:-11].strip()
return normalized
def detect_conflicts(normalized_claims: list[NormalizedClaim]) -> list[ConflictRecord]:
conflicts: list[ConflictRecord] = []
by_category: dict[str, list[NormalizedClaim]] = defaultdict(list)
for claim in normalized_claims:
if claim.claim_type in MUTUALLY_EXCLUSIVE_CATEGORIES:
by_category[claim.claim_type].append(claim)
for category, items in by_category.items():
value_counts: Counter[str] = Counter()
for item in items:
value = extract_value_for_conflict(item.normalized_claim, category)
value_counts[value] += item.support_count
if len(value_counts) > 1:
conflicts.append(
ConflictRecord(
conflict_type="mutually_exclusive_attribute",
category=category,
values=sorted(value_counts.keys()),
support_counts=dict(sorted(value_counts.items())),
note=f"Multiple {category} values were extracted from the captions for this image.",
)
)
return conflicts
def make_uncertainty_flags(source_refs: list[SourceCaptionRef], normalized_claims: list[NormalizedClaim], conflicts: list[ConflictRecord]) -> list[str]:
flags: list[str] = []
if len(source_refs) < 2:
flags.append("single_caption_source")
if conflicts:
flags.append("conflicts_detected")
supported_by_multiple_models = [
c for c in normalized_claims
if len(set(c.model_families)) >= 2 or c.support_count >= 2
]
if not supported_by_multiple_models and normalized_claims:
flags.append("no_cross_model_claim_support")
low_support = [c for c in normalized_claims if c.support_count == 1]
if normalized_claims and len(low_support) / len(normalized_claims) > 0.75:
flags.append("mostly_single_source_claims")
return flags
def build_image_claim_record(
image_key: str,
pass_a_records: list[dict[str, Any]],
config: ClaimExtractionConfig,
*,
manual_response: Optional[dict[str, Any]] = None,
) -> ImageClaimRecord:
source_refs = [make_source_ref(r) for r in pass_a_records]
prompt_bundle = build_llm_prompt_bundle(image_key, source_refs, config)
prompt_sha1 = str(prompt_bundle.get("prompt_sha1") or "")
llm_parse_result: LLMParseResult
rejected_claims: list[RejectedClaim] = []
raw_llm_response = ""
if config.text_llm_backend == "manual_json":
if manual_response is None:
all_claims = []
llm_parse_result = LLMParseResult(
backend="manual_json",
status="missing_manual_response",
prompt_sha1=prompt_sha1,
prompt_schema_version=config.prompt_schema_version,
response_schema_version=config.response_schema_version,
parsed_claim_count=0,
error=f"No manual JSON response found for image_key={image_key!r}.",
)
else:
all_claims, llm_parse_result, rejected_claims, raw_llm_response = claims_from_manual_response(image_key, manual_response, source_refs, config)
llm_parse_result.prompt_sha1 = prompt_sha1
llm_parse_result.prompt_schema_version = config.prompt_schema_version
else:
all_claims: list[AtomicClaim] = []
for source in source_refs:
caption = source.caption or source.raw_caption
all_claims.extend(extract_claims_from_caption(caption, source, config))
llm_parse_result = LLMParseResult(
backend="heuristic",
status="ok",
prompt_sha1=prompt_sha1,
prompt_schema_version=config.prompt_schema_version,
response_schema_version=config.response_schema_version,
parsed_claim_count=len(all_claims),
)
normalized_claims = aggregate_claims(image_key, all_claims)
conflicts = detect_conflicts(normalized_claims) if config.detect_conflicts else []
uncertainty_flags = make_uncertainty_flags(source_refs, normalized_claims, conflicts)
if llm_parse_result.status not in {"ok"}:
uncertainty_flags.append(llm_parse_result.status)
if llm_parse_result.parser_warnings:
uncertainty_flags.append("parser_warnings_present")
image = source_refs[0].image if source_refs else image_key
status = "ok"
return ImageClaimRecord(
captionforge_pass="B",
image_key=image_key,
image=image,
status=status,
source_caption_count=len(source_refs),
source_caption_records=source_refs,
atomic_claims=all_claims,
normalized_claims=normalized_claims,
conflicts=conflicts,
uncertainty_flags=uncertainty_flags,
params={
"engine": config.engine_name,
"engine_version": config.engine_version,
"text_llm_backend": config.text_llm_backend,
"text_llm_model": config.text_llm_model,
"text_llm_prompt": config.text_llm_prompt,
"prompt_schema_version": config.prompt_schema_version,
"response_schema_version": config.response_schema_version,
"prompt_sha1": prompt_sha1,
"config": record_to_json(config),
},
timestamp=iso_timestamp(),
llm_parse_result=llm_parse_result,
parser_warnings=list(llm_parse_result.parser_warnings),
rejected_claims=rejected_claims,
raw_llm_response=raw_llm_response if config.preserve_raw_llm_response else "",
)
def extract_claims_batch(
batch: BatchClaimConfig,
config: Optional[ClaimExtractionConfig] = None,
) -> BatchClaimResult:
config = config or ClaimExtractionConfig()
config.text_llm_backend = (config.text_llm_backend or "heuristic").strip().lower()
if config.text_llm_backend not in {"heuristic", "manual_json"}:
raise ValueError(
f"Unsupported text_llm_backend={config.text_llm_backend!r}. "
"v0.4 supports: heuristic, manual_json."
)
result = BatchClaimResult()
input_records = read_jsonl(batch.input_jsonl)
groups = group_pass_a_records(input_records, include_errors=batch.include_error_pass_a_records)
items = list(groups.items())
image_key_filter = (batch.image_key_filter or "").strip()
if image_key_filter:
needle = image_key_filter.lower()
exact = [(k, v) for k, v in items if k == image_key_filter]
items = exact if exact else [(k, v) for k, v in items if needle in k.lower()]
if batch.limit_images and batch.limit_images > 0:
items = items[: int(batch.limit_images)]
manual_by_key = load_manual_claims(batch.manual_json_path) if config.text_llm_backend == "manual_json" else {}
prompt_bundles: list[dict[str, Any]] = []
for image_key, grouped_records in items:
source_refs = [make_source_ref(r) for r in grouped_records]
prompt_bundles.append(build_llm_prompt_bundle(image_key, source_refs, config))
if batch.write_llm_prompt_jsonl:
prompt_path = Path(batch.llm_prompt_jsonl) if batch.llm_prompt_jsonl.strip() else default_prompt_output_path(batch.input_jsonl)
write_jsonl(prompt_path, prompt_bundles, overwrite=True, dry_run=batch.dry_run)
for image_key, grouped_records in items:
try:
manual_response = manual_claims_for_image(image_key, manual_by_key) if config.text_llm_backend == "manual_json" else None
result.records.append(build_image_claim_record(image_key, grouped_records, config, manual_response=manual_response))
except Exception as exc:
result.failed += 1
result.records.append(
ImageClaimRecord(
captionforge_pass="B",
image_key=image_key,
image=str(grouped_records[0].get("image") or image_key) if grouped_records else image_key,
status="error",
source_caption_count=len(grouped_records),
source_caption_records=[],
atomic_claims=[],
normalized_claims=[],
conflicts=[],
uncertainty_flags=[f"error: {exc}"],
params={
"engine": config.engine_name,
"engine_version": config.engine_version,
"text_llm_backend": config.text_llm_backend,
"config": record_to_json(config),
},
timestamp=iso_timestamp(),
llm_parse_result=LLMParseResult(
backend=config.text_llm_backend,
status="error",
error=str(exc),
),
)
)
if batch.write_jsonl:
out = Path(batch.output_jsonl) if batch.output_jsonl.strip() else default_output_path(batch.input_jsonl)
write_jsonl(out, result.records, overwrite=batch.overwrite, dry_run=batch.dry_run)
return result
# -------------------------------------------------------------------------
# CLI
# -------------------------------------------------------------------------
def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="CaptionForge Pass B claim extraction from Pass A caption JSONL.")
parser.add_argument("--input-jsonl", required=True, help="Path to Pass A shared captions JSONL.")
parser.add_argument("--output-jsonl", default="", help="Path to Pass B claims JSONL. Defaults beside input.")
parser.add_argument("--limit-images", type=int, default=0, help="Process only the first N grouped images. 0 = no limit.")
parser.add_argument("--dry-run", action="store_true", help="Run extraction without writing JSONL.")
parser.add_argument("--append", action="store_true", help="Append to output JSONL instead of overwriting.")
parser.add_argument("--include-error-pass-a-records", action="store_true", help="Include Pass A records with status != ok.")
parser.add_argument("--no-conflicts", action="store_true", help="Disable simple conflict detection.")
parser.add_argument("--no-low-confidence", action="store_true", help="Drop lower-confidence heuristic claims.")
parser.add_argument("--text-llm-backend", default="heuristic", choices=["heuristic", "manual_json"])
parser.add_argument("--manual-json-path", default="", help="JSON/JSONL response file for manual_json backend.")
parser.add_argument("--image-key-filter", default="", help="Process only image_keys that exactly match or contain this string.")
parser.add_argument("--preserve-raw-llm-response", action="store_true", help="Store raw manual/LLM response text in each Pass B record.")
parser.add_argument("--write-llm-prompt-jsonl", action="store_true", help="Export future-LLM prompt bundles.")
parser.add_argument("--llm-prompt-jsonl", default="", help="Path for exported future-LLM prompt bundles.")
return parser
def main(argv: Optional[list[str]] = None) -> int:
args = build_arg_parser().parse_args(argv)
config = ClaimExtractionConfig(
include_low_confidence=not args.no_low_confidence,
detect_conflicts=not args.no_conflicts,
text_llm_backend=args.text_llm_backend,
preserve_raw_llm_response=bool(args.preserve_raw_llm_response),
)
output_jsonl = args.output_jsonl or str(default_output_path(args.input_jsonl))
batch = BatchClaimConfig(
input_jsonl=args.input_jsonl,
output_jsonl=output_jsonl,
write_jsonl=True,
dry_run=bool(args.dry_run),
limit_images=int(args.limit_images),
include_error_pass_a_records=bool(args.include_error_pass_a_records),
overwrite=not bool(args.append),
manual_json_path=args.manual_json_path,
write_llm_prompt_jsonl=bool(args.write_llm_prompt_jsonl),
llm_prompt_jsonl=args.llm_prompt_jsonl,
image_key_filter=args.image_key_filter,
)
result = extract_claims_batch(batch, config)
print(
f"[CaptionForge Claim Engine] processed={result.processed}, "
f"failed={result.failed}, output={output_jsonl}"
)
if args.dry_run:
print(result.jsonl_text)
return 0 if result.failed == 0 else 1
if __name__ == "__main__":
raise SystemExit(main())