2279 lines
93 KiB
Python
2279 lines
93 KiB
Python
"""
|
|
CaptionForge VLM Validator Engine
|
|
|
|
- CaptionForge
|
|
- This engine is part of **CaptionForge**, a model-agnostic captioning
|
|
framework for ComfyUI developed by **J. L. Córdova**.
|
|
|
|
- Repository:
|
|
https://github.com/Damkohler/CaptionForge
|
|
|
|
- CaptionForge 1.0 uses independent Pass-A witnesses, text-LLM synthesis,
|
|
image-aware validation, SHORT/TAGGY formatting, and JSONL audit trails to
|
|
produce grounded LoRA dataset captions.
|
|
|
|
- Engine Purpose
|
|
- The **CaptionForge VLM Validator Engine** is the Pass C image-aware
|
|
grounding, validation, pruning, correction, and final-caption candidate
|
|
engine.
|
|
|
|
- It consumes CaptionForge Pass B distiller records, resolves the
|
|
corresponding source image, sends the image plus the distilled evidence to
|
|
a vision-language model, and emits validated caption candidates.
|
|
|
|
- The engine validates:
|
|
• accepted claims
|
|
• plausible singleton candidates
|
|
• rejected or unresolved conflicts
|
|
• rich narrative draft captions
|
|
• taggy/comma-style draft captions
|
|
• trigger-word and user-anchor behavior as auditable metadata
|
|
|
|
- CaptionForge Pipeline Role
|
|
- This engine participates in **Pass C_VLM_VALIDATED**.
|
|
|
|
- Pipeline position:
|
|
|
|
Pass A raw caption witness JSONL
|
|
-> Pass B_DISTILL text-LLM distillation
|
|
-> Pass C_VLM_VALIDATED image-aware validation
|
|
-> Pass D final TXT/JSONL export
|
|
|
|
- Pass C is precision-oriented. It checks Pass B evidence against the actual
|
|
image, removes unsupported details, corrects visible errors, preserves
|
|
useful supported detail, and can add clearly visible missing details when
|
|
they improve LoRA training value.
|
|
|
|
- Execution Model
|
|
- Primary backend: local Ollama VLM.
|
|
|
|
- Additional supported modes include manual JSON and prompt-only workflows
|
|
for debugging, inspection, or controlled testing.
|
|
|
|
- Image files are resolved from CaptionForge record metadata and an optional
|
|
image-root directory, including loose filename matching for common
|
|
sanitized-key cases.
|
|
|
|
- The engine writes structured JSONL records and optional readable sidecars.
|
|
|
|
- The module also contains an experimental cleaner retained for historical
|
|
diagnostics; it is not part of the production CaptionForge 1.0 workflow.
|
|
|
|
- Design Philosophy
|
|
- The distiller is recall-oriented; the VLM validator is grounding-oriented.
|
|
|
|
- The validator should not summarize away useful LoRA details merely for
|
|
brevity.
|
|
|
|
- Trigger words are training metadata and should be preserved exactly when
|
|
provided.
|
|
|
|
- User caption anchors are user-supplied training guidance and should be
|
|
retained unless the VLM explicitly rejects or corrects that anchor.
|
|
|
|
- Final candidate captions should remain rich, visually grounded, and
|
|
auditable.
|
|
|
|
- Reference Status
|
|
- This CLI-oriented engine is retained for reference, diagnostics, and
|
|
controlled experiments. It is not imported or registered by the active
|
|
ComfyUI pipeline; production Pass C runs inside ``jlc_captionforge_node``.
|
|
|
|
- Attribution & License
|
|
- Concept and implementation by **J. L. Córdova**
|
|
with development assistance from **ChatGPT (OpenAI)**.
|
|
|
|
- Designed for use with:
|
|
https://github.com/comfyanonymous/ComfyUI
|
|
|
|
- Copyright (c) 2026 J. L. Córdova
|
|
|
|
- Released under the **MIT License**.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from ..captionforge_version import CAPTIONFORGE_VERSION
|
|
|
|
MANIFEST = {
|
|
"name": "CaptionForge VLM Validator Engine",
|
|
"version": CAPTIONFORGE_VERSION,
|
|
"author": "J. L. Córdova",
|
|
"description": (
|
|
"CaptionForge Pass C image-aware VLM validation engine. Consumes Pass B "
|
|
"distiller records, resolves source images, validates accepted and "
|
|
"singleton claims against the image, removes unsupported details, "
|
|
"corrects visible errors, preserves supported LoRA-relevant detail, "
|
|
"and writes rich validated final-caption candidates."
|
|
),
|
|
}
|
|
|
|
import argparse
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
import mimetypes
|
|
import re
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from dataclasses import asdict, dataclass, field, is_dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path, PureWindowsPath
|
|
from typing import Any, Iterable, Optional
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Dataclasses
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
@dataclass
|
|
class VLMValidatorConfig:
|
|
engine_name: str = "CaptionForge VLM Validator Engine"
|
|
engine_version: str = CAPTIONFORGE_VERSION
|
|
|
|
vlm_backend: str = "ollama" # ollama, manual_json, prompt_only
|
|
vlm_model: str = ""
|
|
|
|
# Internal fixed validator prompt. The node may still pass a widget prompt,
|
|
# but CaptionForge's intended path is one strong grounding/copywriting contract.
|
|
vlm_validator_prompt: str = (
|
|
"You are CaptionForge Pass C: an image-grounded rich-caption validator and copywriter. "
|
|
"You see the actual image and a Pass B pollster record. Pass B contains accepted claims, "
|
|
"plausible singleton candidates, rejected or unresolved conflicts, and rich/taggy draft captions. "
|
|
"Your job is not to summarize. Your job is to ground the evidence against the image, keep all "
|
|
"supported useful details, correct visibly wrong details, visually confirm useful singletons when "
|
|
"possible, add clearly visible missing details when they improve LoRA training value, and write a "
|
|
"rich final caption. Use no deterministic semantic taxonomy. Judge natural-language claims only. "
|
|
"Reject details only when they are visibly false, contradicted, not visible enough, or inappropriate "
|
|
"as a training caption claim. Preserve accurate outfit construction, materials, jewelry, accessories, "
|
|
"makeup, hair, eye details, body pose, hand placement, crop, lighting, background, and visible texture. "
|
|
"Do not over-prune. Do not compress detailed jewelry, makeup, fabric, pose, or material evidence into "
|
|
"generic phrases. Treat trigger words as metadata and do not prepend them yourself; the engine will "
|
|
"prepend trigger and anchor after parsing. Treat the user caption anchor as training guidance: preserve "
|
|
"it when compatible with the image and reject it only when clearly contradicted. The final narrative "
|
|
"caption should be rich LoRA-training prose. The final comma caption should be a dense taggy caption "
|
|
"with nearly the same visual content."
|
|
)
|
|
|
|
|
|
prompt_schema_version: str = "captionforge-vlm-validator-prompt-v0.2.0"
|
|
response_schema_version: str = "captionforge-vlm-validator-response-v0.2.0"
|
|
|
|
trigger_word: str = ""
|
|
user_caption_anchor: str = ""
|
|
|
|
preserve_raw_vlm_response: bool = False
|
|
max_input_caption_chars: int = 2400
|
|
max_output_caption_chars: int = 1800
|
|
max_audit_note_chars: int = 260
|
|
allow_missing_images: bool = False
|
|
|
|
ollama_base_url: str = "http://127.0.0.1:11434"
|
|
ollama_timeout_sec: int = 900
|
|
ollama_keep_alive: str = "10m"
|
|
ollama_num_predict: int = 2200
|
|
ollama_temperature: float = 0.0
|
|
ollama_top_p: float = 0.92
|
|
ollama_top_k: int = 80
|
|
ollama_seed: int = -1
|
|
ollama_format_mode: str = "schema" # json or schema
|
|
|
|
|
|
@dataclass
|
|
class BatchVLMValidatorConfig:
|
|
input_jsonl: str
|
|
image_root: str = ""
|
|
output_jsonl: str = ""
|
|
readable_sidecar_dir: str = ""
|
|
write_jsonl: bool = True
|
|
write_readable_sidecars: bool = True
|
|
dry_run: bool = False
|
|
limit_images: int = 0
|
|
overwrite: bool = True
|
|
resume: bool = False
|
|
rerun_errors: bool = True
|
|
rerun_image_keys: list[str] = field(default_factory=list)
|
|
image_key_filter: str = ""
|
|
|
|
manual_json_path: str = ""
|
|
write_prompt_jsonl: bool = False
|
|
prompt_jsonl: str = ""
|
|
|
|
|
|
@dataclass
|
|
class VLMValidatorParseResult:
|
|
backend: str
|
|
status: str
|
|
prompt_sha1: str = ""
|
|
prompt_schema_version: str = ""
|
|
response_schema_version: str = ""
|
|
raw_response_sha1: str = ""
|
|
parser_warnings: list[str] = field(default_factory=list)
|
|
error_class: str = ""
|
|
error: str = ""
|
|
prompt_chars: int = 0
|
|
raw_response_chars: int = 0
|
|
elapsed_sec: float = 0.0
|
|
|
|
|
|
@dataclass
|
|
class ImageVLMValidatedRecord:
|
|
captionforge_pass: str
|
|
image_key: str
|
|
image: str
|
|
image_resolved_path: str
|
|
status: str
|
|
|
|
validated_caption_narrative: str
|
|
validated_caption_comma: str
|
|
trigger_word: str
|
|
user_caption_anchor: str
|
|
|
|
removed_or_rejected_details: list[str]
|
|
corrected_details: list[dict[str, Any]]
|
|
uncertain_details: list[str]
|
|
visual_validation_notes: list[str]
|
|
validated_claims: list[str]
|
|
added_visible_details: list[str]
|
|
|
|
source_distiller: dict[str, Any]
|
|
params: dict[str, Any]
|
|
vlm_parse_result: VLMValidatorParseResult
|
|
parser_warnings: list[str]
|
|
raw_vlm_response: str
|
|
metrics: dict[str, Any]
|
|
timestamp: str
|
|
|
|
|
|
@dataclass
|
|
class BatchVLMValidatorResult:
|
|
records: list[ImageVLMValidatedRecord] = 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 in {"ok", "prompt_only"}])
|
|
|
|
@property
|
|
def jsonl_text(self) -> str:
|
|
return "\n".join(json.dumps(record_to_json(r), ensure_ascii=False) for r in self.records)
|
|
|
|
|
|
class VLMResponseParseError(ValueError):
|
|
def __init__(self, message: str, raw_response: str = "") -> None:
|
|
super().__init__(message)
|
|
self.raw_response = str(raw_response or "")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Generic 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, tuple):
|
|
return [json_safe(v) for v in value]
|
|
if isinstance(value, Path):
|
|
return str(value)
|
|
if isinstance(value, dict):
|
|
return {str(k): json_safe(v) for k, v in value.items()}
|
|
if isinstance(value, list):
|
|
return [json_safe(v) for v in value]
|
|
return value
|
|
|
|
|
|
def record_to_json(record: Any) -> dict[str, Any]:
|
|
return json_safe(record)
|
|
|
|
|
|
def sha1_text(text: str) -> str:
|
|
return hashlib.sha1(str(text or "").encode("utf-8")).hexdigest()
|
|
|
|
|
|
def sha1_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str:
|
|
h = hashlib.sha1()
|
|
with Path(path).open("rb") as f:
|
|
while True:
|
|
chunk = f.read(chunk_size)
|
|
if not chunk:
|
|
break
|
|
h.update(chunk)
|
|
return h.hexdigest()
|
|
|
|
|
|
def normalize_whitespace(text: Any) -> str:
|
|
text = str(text or "").replace("\n", " ")
|
|
text = re.sub(r"\s+", " ", text)
|
|
return text.strip()
|
|
|
|
|
|
def truncate_text(text: Any, max_chars: int) -> str:
|
|
s = normalize_whitespace(text)
|
|
if max_chars <= 0 or len(s) <= max_chars:
|
|
return s
|
|
cut = s[:max_chars].rstrip()
|
|
boundary = max(cut.rfind(" "), cut.rfind("."), cut.rfind(","), cut.rfind(";"))
|
|
if boundary >= max(80, int(max_chars * 0.65)):
|
|
cut = cut[:boundary].rstrip()
|
|
return cut.rstrip(" ,;:-") + "…"
|
|
|
|
|
|
def safe_mkdir(path: Path) -> None:
|
|
path.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
def read_jsonl(path: str | Path) -> list[dict[str, Any]]:
|
|
p = Path(path)
|
|
if p.is_dir():
|
|
raise IsADirectoryError(f"JSONL path points to a folder, not a file: {p}")
|
|
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 not isinstance(obj, dict):
|
|
raise ValueError(f"JSONL record at {p}:{line_number} is not an object.")
|
|
records.append(obj)
|
|
return records
|
|
|
|
|
|
def append_jsonl_record(path: str | Path, record: Any, dry_run: bool = False) -> None:
|
|
if dry_run:
|
|
return
|
|
p = Path(path)
|
|
safe_mkdir(p.parent)
|
|
with p.open("a", encoding="utf-8") as f:
|
|
f.write(json.dumps(record_to_json(record), ensure_ascii=False) + "\n")
|
|
f.flush()
|
|
|
|
|
|
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)
|
|
return p.with_name(f"{p.stem or 'captionforge'}_vlm_validated.jsonl")
|
|
|
|
|
|
def default_prompt_output_path(input_jsonl: str | Path) -> Path:
|
|
p = Path(input_jsonl)
|
|
return p.with_name(f"{p.stem or 'captionforge'}_vlm_validator_prompts.jsonl")
|
|
|
|
|
|
def default_sidecar_dir(output_jsonl: str | Path) -> Path:
|
|
p = Path(output_jsonl)
|
|
return p.with_name(f"{p.stem}_readable")
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Input field compatibility
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def first_nonempty(record: dict[str, Any], keys: Iterable[str]) -> str:
|
|
for key in keys:
|
|
value = normalize_whitespace(record.get(key))
|
|
if value:
|
|
return value
|
|
return ""
|
|
|
|
|
|
def get_distilled_narrative(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(record.get("distilled_caption_narrative"))
|
|
|
|
|
|
def get_distilled_comma(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(record.get("distilled_caption_comma"))
|
|
|
|
|
|
def get_rich_caption_draft(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(
|
|
record.get("rich_caption_draft")
|
|
or record.get("copywriter_caption")
|
|
or record.get("draft_caption")
|
|
or record.get("distilled_caption_narrative")
|
|
or ""
|
|
)
|
|
|
|
|
|
def get_taggy_caption_draft(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(
|
|
record.get("taggy_caption_draft")
|
|
or record.get("dense_caption_draft")
|
|
or record.get("comma_caption_draft")
|
|
or record.get("distilled_caption_comma")
|
|
or ""
|
|
)
|
|
|
|
|
|
def _claim_text(item: Any) -> str:
|
|
if isinstance(item, dict):
|
|
return normalize_whitespace(item.get("claim") or item.get("text") or item.get("value") or "")
|
|
return normalize_whitespace(item)
|
|
|
|
|
|
def coerce_claim_records(value: Any, max_items: int = 120, max_chars: int = 320) -> list[dict[str, Any]]:
|
|
if value is None:
|
|
return []
|
|
items = value if isinstance(value, list) else [value]
|
|
out: list[dict[str, Any]] = []
|
|
for item in items:
|
|
if isinstance(item, dict):
|
|
claim = truncate_text(item.get("claim") or item.get("text") or item.get("value") or "", max_chars)
|
|
if not claim:
|
|
continue
|
|
out.append({
|
|
"claim": claim,
|
|
"support_count": item.get("support_count", 0),
|
|
"supporting_model_families": item.get("supporting_model_families") or item.get("supporting_models") or [],
|
|
"supporting_source_indices": item.get("supporting_source_indices") or item.get("source_indices") or [],
|
|
"contradicted_by": item.get("contradicted_by") or item.get("contradictions") or [],
|
|
"confidence": truncate_text(item.get("confidence") or "", max_chars),
|
|
"reason": truncate_text(item.get("reason") or item.get("rationale") or "", max_chars),
|
|
})
|
|
else:
|
|
claim = truncate_text(item, max_chars)
|
|
if claim:
|
|
out.append({"claim": claim})
|
|
if len(out) >= max_items:
|
|
break
|
|
return out
|
|
|
|
|
|
def get_claim_bucket(record: dict[str, Any], key: str) -> list[dict[str, Any]]:
|
|
return coerce_claim_records(record.get(key) or [])
|
|
|
|
|
|
def get_trigger_word(record: dict[str, Any], override: str = "") -> str:
|
|
override = normalize_whitespace(override)
|
|
if override:
|
|
return override
|
|
return first_nonempty(record, [
|
|
"trigger_word",
|
|
"caption_trigger",
|
|
"lora_trigger",
|
|
"trigger",
|
|
])
|
|
|
|
|
|
def get_user_caption_anchor(record: dict[str, Any], override: str = "") -> str:
|
|
override = normalize_whitespace(override)
|
|
if override:
|
|
return override
|
|
return first_nonempty(record, [
|
|
"user_caption_anchor",
|
|
"caption_anchor",
|
|
"style_anchor",
|
|
"caption_prefix",
|
|
])
|
|
|
|
|
|
def strip_leading_trigger(text: str, trigger_word: str) -> str:
|
|
text = normalize_whitespace(text)
|
|
trig = normalize_whitespace(trigger_word)
|
|
if not text or not trig:
|
|
return text
|
|
pattern = r"^\s*" + re.escape(trig) + r"\s*[,;:\-–—]?\s*"
|
|
return normalize_whitespace(re.sub(pattern, "", text, flags=re.IGNORECASE))
|
|
|
|
|
|
def prepend_trigger(text: str, trigger_word: str) -> str:
|
|
text = strip_leading_trigger(text, trigger_word)
|
|
trig = normalize_whitespace(trigger_word)
|
|
if not trig:
|
|
return text
|
|
if not text:
|
|
return trig
|
|
return f"{trig}, {text}"
|
|
|
|
|
|
def _normalize_for_caption_match(text: Any) -> str:
|
|
text = normalize_whitespace(text).lower()
|
|
text = re.sub(r"[^a-z0-9]+", " ", text)
|
|
return re.sub(r"\s+", " ", text).strip()
|
|
|
|
|
|
def _strip_leading_phrase(text: str, phrase: str) -> str:
|
|
text = normalize_whitespace(text)
|
|
phrase = normalize_whitespace(phrase)
|
|
if not text or not phrase:
|
|
return text
|
|
pattern = r"^\s*" + re.escape(phrase) + r"\s*[,;:\-–—]?\s*"
|
|
return normalize_whitespace(re.sub(pattern, "", text, flags=re.IGNORECASE))
|
|
|
|
|
|
def _anchor_already_present(text: str, anchor: str) -> bool:
|
|
a = _normalize_for_caption_match(anchor)
|
|
if not a:
|
|
return True
|
|
t = _normalize_for_caption_match(text)
|
|
return a in t
|
|
|
|
|
|
def _flatten_audit_strings(value: Any) -> list[str]:
|
|
out: list[str] = []
|
|
if value is None:
|
|
return out
|
|
if isinstance(value, dict):
|
|
for v in value.values():
|
|
out.extend(_flatten_audit_strings(v))
|
|
return out
|
|
if isinstance(value, list):
|
|
for item in value:
|
|
out.extend(_flatten_audit_strings(item))
|
|
return out
|
|
text = normalize_whitespace(value)
|
|
if text:
|
|
out.append(text)
|
|
return out
|
|
|
|
|
|
def _anchor_explicitly_rejected(anchor: str, response: dict[str, Any]) -> bool:
|
|
"""
|
|
Return True only when the validator appears to have explicitly rejected or
|
|
corrected the user caption anchor itself. The anchor is training intent, so
|
|
absence from the VLM's final caption is not enough to suppress it.
|
|
"""
|
|
anchor_norm = _normalize_for_caption_match(anchor)
|
|
if not anchor_norm:
|
|
return False
|
|
|
|
removed = []
|
|
for key in ("removed_or_rejected_details", "rejected_details", "removed_details"):
|
|
removed.extend(_flatten_audit_strings(response.get(key)))
|
|
for text in removed:
|
|
if anchor_norm and anchor_norm in _normalize_for_caption_match(text):
|
|
return True
|
|
|
|
corrections = response.get("corrected_details") or response.get("corrections") or []
|
|
if isinstance(corrections, list):
|
|
for item in corrections:
|
|
if isinstance(item, dict):
|
|
src = item.get("from") or item.get("from_detail") or item.get("original") or ""
|
|
if anchor_norm in _normalize_for_caption_match(src):
|
|
return True
|
|
|
|
caution_words = {
|
|
"contradict", "contradicted", "contradicts", "unsupported", "not visible",
|
|
"not shown", "false", "wrong", "reject", "rejected", "remove", "removed",
|
|
}
|
|
notes = []
|
|
for key in ("visual_validation_notes", "validation_notes", "uncertain_details"):
|
|
notes.extend(_flatten_audit_strings(response.get(key)))
|
|
for text in notes:
|
|
text_norm = _normalize_for_caption_match(text)
|
|
if anchor_norm in text_norm and any(word in text.lower() for word in caution_words):
|
|
return True
|
|
|
|
return False
|
|
|
|
|
|
def prepend_trigger_and_anchor(
|
|
text: str,
|
|
trigger_word: str,
|
|
user_caption_anchor: str,
|
|
response: dict[str, Any] | None = None,
|
|
) -> str:
|
|
"""
|
|
Prefix final captions as:
|
|
<trigger_word>, <user_caption_anchor>, <caption body>
|
|
|
|
The trigger is always metadata. The anchor is user-supplied training intent:
|
|
preserve it unless the VLM explicitly rejected/corrected that anchor. This
|
|
keeps hard-to-see but user-important labels such as stylization, doll texture,
|
|
unusual eye color, or illustration/photo intent available for LoRA training.
|
|
"""
|
|
response = response or {}
|
|
body = strip_leading_trigger(text, trigger_word)
|
|
anchor = normalize_whitespace(user_caption_anchor)
|
|
trig = normalize_whitespace(trigger_word)
|
|
|
|
if anchor:
|
|
body = _strip_leading_phrase(body, anchor)
|
|
if _anchor_already_present(text, anchor):
|
|
anchor = ""
|
|
elif _anchor_explicitly_rejected(anchor, response):
|
|
anchor = ""
|
|
|
|
parts = [p for p in (trig, anchor, body) if normalize_whitespace(p)]
|
|
return normalize_whitespace(", ".join(parts))
|
|
|
|
|
|
def normalize_comma_caption(text: str) -> str:
|
|
text = normalize_whitespace(text)
|
|
text = re.sub(r"\s*,\s*", ", ", text)
|
|
text = re.sub(r"(,\s*){2,}", ", ", text)
|
|
return text.strip(" ,")
|
|
|
|
|
|
def stable_filename_from_image_key(image_key: str) -> str:
|
|
base = PureWindowsPath(str(image_key)).name if "\\" in str(image_key) else Path(str(image_key)).name
|
|
base = base or "image"
|
|
stem = Path(base).stem or "image"
|
|
safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._") or "image"
|
|
digest = sha1_text(str(image_key))[:8]
|
|
return f"{safe}__{digest}.txt"
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Image resolution
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def pathish(value: str) -> str:
|
|
return str(value or "").strip().strip('"')
|
|
|
|
|
|
def basename_cross_platform(value: str) -> str:
|
|
text = pathish(value)
|
|
if not text:
|
|
return ""
|
|
return PureWindowsPath(text).name if "\\" in text else Path(text).name
|
|
|
|
|
|
def candidate_image_names(record: dict[str, Any]) -> list[str]:
|
|
names: list[str] = []
|
|
for key in ("image", "image_key", "image_path", "source_image"):
|
|
value = pathish(record.get(key, ""))
|
|
if value and value not in names:
|
|
names.append(value)
|
|
base = basename_cross_platform(value)
|
|
if base and base not in names:
|
|
names.append(base)
|
|
|
|
source = record.get("source_distiller") or record.get("source_pass_b") or {}
|
|
if isinstance(source, dict):
|
|
for key in ("image", "image_key", "image_path", "source_image"):
|
|
value = pathish(source.get(key, ""))
|
|
if value and value not in names:
|
|
names.append(value)
|
|
base = basename_cross_platform(value)
|
|
if base and base not in names:
|
|
names.append(base)
|
|
|
|
source_records = record.get("source_caption_records") or []
|
|
if isinstance(source_records, list):
|
|
for src in source_records:
|
|
if not isinstance(src, dict):
|
|
continue
|
|
value = pathish(src.get("image", "") or src.get("image_key", ""))
|
|
if value and value not in names:
|
|
names.append(value)
|
|
base = basename_cross_platform(value)
|
|
if base and base not in names:
|
|
names.append(base)
|
|
return names
|
|
|
|
|
|
|
|
_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tif", ".tiff"}
|
|
_IMAGE_ROOT_INDEX_CACHE: dict[str, dict[str, list[Path]]] = {}
|
|
|
|
|
|
def canonical_image_stem(value: Any) -> str:
|
|
"""
|
|
Build a loose comparison key for image stems.
|
|
|
|
CaptionForge caption records may use sanitized image_keys such as
|
|
``ChatGPT_Image_May_10__2026__07_06_01_PM__10`` while the source file on
|
|
disk may be named ``ChatGPT Image May 10, 2026, 07_06_01 PM (10).png``.
|
|
This canonical form removes punctuation, spaces, underscores, and case so
|
|
those names can still be matched without forcing users to rename files.
|
|
"""
|
|
text = pathish(value)
|
|
if not text:
|
|
return ""
|
|
base = basename_cross_platform(text)
|
|
stem = Path(base).stem or base
|
|
return re.sub(r"[^a-z0-9]+", "", stem.lower())
|
|
|
|
|
|
def image_root_index(root: Path) -> dict[str, list[Path]]:
|
|
"""Index image files under a root by loose canonical stem, cached per root."""
|
|
try:
|
|
root_key = str(root.resolve())
|
|
except Exception:
|
|
root_key = str(root)
|
|
cached = _IMAGE_ROOT_INDEX_CACHE.get(root_key)
|
|
if cached is not None:
|
|
return cached
|
|
|
|
index: dict[str, list[Path]] = {}
|
|
if not root.exists() or not root.is_dir():
|
|
_IMAGE_ROOT_INDEX_CACHE[root_key] = index
|
|
return index
|
|
|
|
try:
|
|
iterator = root.rglob("*")
|
|
for p in iterator:
|
|
if not p.is_file() or p.suffix.lower() not in _IMAGE_EXTENSIONS:
|
|
continue
|
|
key = canonical_image_stem(p.name)
|
|
if key:
|
|
index.setdefault(key, []).append(p)
|
|
except Exception:
|
|
pass
|
|
|
|
for key in list(index.keys()):
|
|
index[key] = sorted(index[key], key=lambda p: (len(str(p)), str(p).lower()))
|
|
_IMAGE_ROOT_INDEX_CACHE[root_key] = index
|
|
return index
|
|
|
|
|
|
def resolve_image_path(record: dict[str, Any], image_root: str = "") -> Optional[Path]:
|
|
candidates = candidate_image_names(record)
|
|
|
|
# 1. Direct absolute/relative paths embedded in records.
|
|
for candidate in candidates:
|
|
p = Path(candidate)
|
|
if p.exists() and p.is_file():
|
|
return p
|
|
|
|
root_text = pathish(image_root)
|
|
if not root_text:
|
|
return None
|
|
|
|
root = Path(root_text)
|
|
if not root.exists() or not root.is_dir():
|
|
return None
|
|
|
|
# 2. Exact basename and relative-path matches under image_root.
|
|
for candidate in candidates:
|
|
candidate_text = pathish(candidate)
|
|
if not candidate_text:
|
|
continue
|
|
|
|
# If a future caption record preserves a relative source path, honor it.
|
|
rel = Path(candidate_text)
|
|
if not rel.is_absolute() and any(sep in candidate_text for sep in ("/", "\\")):
|
|
p = root / rel
|
|
if p.exists() and p.is_file():
|
|
return p
|
|
|
|
base = basename_cross_platform(candidate_text)
|
|
if not base:
|
|
continue
|
|
p = root / base
|
|
if p.exists() and p.is_file():
|
|
return p
|
|
|
|
# 3. Exact basename search anywhere below image_root.
|
|
basenames = {basename_cross_platform(c) for c in candidates if basename_cross_platform(c)}
|
|
for base in basenames:
|
|
matches = [p for p in root.rglob(base) if p.is_file()]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if len(matches) > 1:
|
|
return sorted(matches, key=lambda p: (len(str(p)), str(p).lower()))[0]
|
|
|
|
# 4. Loose sanitized-stem match for user files with spaces, commas,
|
|
# parentheses, and other punctuation. This lets a sanitized CaptionForge key
|
|
# still resolve the original image file without imposing strict user naming.
|
|
wanted = {canonical_image_stem(c) for c in candidates if canonical_image_stem(c)}
|
|
if wanted:
|
|
index = image_root_index(root)
|
|
matches: list[Path] = []
|
|
for key in wanted:
|
|
matches.extend(index.get(key, []))
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
if len(matches) > 1:
|
|
return sorted(set(matches), key=lambda p: (len(str(p)), str(p).lower()))[0]
|
|
|
|
return None
|
|
|
|
|
|
def image_to_base64(path: str | Path) -> str:
|
|
return base64.b64encode(Path(path).read_bytes()).decode("ascii")
|
|
|
|
|
|
_IMAGE_BASE64_CACHE: dict[tuple[str, int, int], str] = {}
|
|
|
|
|
|
def image_to_base64_cached(path: str | Path) -> str:
|
|
p = Path(path)
|
|
stat = p.stat()
|
|
key = (str(p.resolve()), int(stat.st_mtime_ns), int(stat.st_size))
|
|
cached = _IMAGE_BASE64_CACHE.get(key)
|
|
if cached is not None:
|
|
return cached
|
|
encoded = image_to_base64(p)
|
|
_IMAGE_BASE64_CACHE[key] = encoded
|
|
return encoded
|
|
|
|
|
|
def image_mime_type(path: str | Path) -> str:
|
|
mime, _ = mimetypes.guess_type(str(path))
|
|
return mime or "application/octet-stream"
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Prompt and response contract
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def build_response_schema() -> dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"validated_caption_narrative": {"type": "string"},
|
|
"validated_caption_comma": {"type": "string"},
|
|
"validated_claims": {"type": "array", "items": {"type": "string"}},
|
|
"added_visible_details": {"type": "array", "items": {"type": "string"}},
|
|
"removed_or_rejected_details": {"type": "array", "items": {"type": "string"}},
|
|
"corrected_details": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string"},
|
|
"to": {"type": "string"},
|
|
"reason": {"type": "string"},
|
|
},
|
|
"required": ["from", "to"],
|
|
"additionalProperties": True,
|
|
},
|
|
},
|
|
"uncertain_details": {"type": "array", "items": {"type": "string"}},
|
|
"visual_validation_notes": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": [
|
|
"validated_caption_narrative",
|
|
"validated_caption_comma",
|
|
"validated_claims",
|
|
"added_visible_details",
|
|
"removed_or_rejected_details",
|
|
"corrected_details",
|
|
"uncertain_details",
|
|
"visual_validation_notes",
|
|
],
|
|
"additionalProperties": True,
|
|
}
|
|
|
|
|
|
|
|
def build_prompt_bundle(
|
|
record: dict[str, Any],
|
|
image_path: Optional[Path],
|
|
config: VLMValidatorConfig,
|
|
) -> dict[str, Any]:
|
|
image_key = str(record.get("image_key") or record.get("image") or "")
|
|
trigger_word = get_trigger_word(record, config.trigger_word)
|
|
anchor = get_user_caption_anchor(record, config.user_caption_anchor)
|
|
|
|
rich_draft = truncate_text(
|
|
strip_leading_trigger(get_rich_caption_draft(record), trigger_word),
|
|
int(config.max_input_caption_chars),
|
|
)
|
|
taggy_draft = truncate_text(
|
|
strip_leading_trigger(get_taggy_caption_draft(record), trigger_word),
|
|
int(config.max_input_caption_chars),
|
|
)
|
|
|
|
accepted_claims = get_claim_bucket(record, "accepted_claims")
|
|
singleton_candidates = get_claim_bucket(record, "singleton_candidates")
|
|
rejected_or_unresolved_claims = get_claim_bucket(record, "rejected_or_unresolved_claims")
|
|
claim_vote_summary = coerce_string_list(
|
|
record.get("claim_vote_summary") or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
)
|
|
|
|
payload = {
|
|
"image_key": image_key,
|
|
"trigger_word": trigger_word,
|
|
"user_caption_anchor": anchor,
|
|
"important_metadata_policy": {
|
|
"trigger_word": "Metadata/training token. Do not treat as visible evidence. Do not prepend in your response; the engine prepends it exactly.",
|
|
"user_caption_anchor": (
|
|
"User-provided training guidance, not pure visible evidence. Preserve it when compatible with the image; "
|
|
"reject only if clearly contradicted."
|
|
),
|
|
},
|
|
"pass_b_pollster_record": {
|
|
"accepted_claims": accepted_claims,
|
|
"singleton_candidates": singleton_candidates,
|
|
"rejected_or_unresolved_claims": rejected_or_unresolved_claims,
|
|
"claim_vote_summary": claim_vote_summary,
|
|
"rich_caption_draft": rich_draft,
|
|
"taggy_caption_draft": taggy_draft,
|
|
},
|
|
"required_output": build_response_schema(),
|
|
}
|
|
|
|
prompt = (
|
|
f"{normalize_whitespace(config.vlm_validator_prompt)}\n\n"
|
|
"You are seeing one actual image. Validate the Pass B evidence against this image.\n"
|
|
"Return strict JSON only. No markdown. No prose outside JSON.\n\n"
|
|
|
|
"GROUNDING RULES:\n"
|
|
"1. Keep all accepted claims that are visible or strongly implied by the image.\n"
|
|
"2. Inspect singleton_candidates carefully. Promote them when visible; reject them when unsupported.\n"
|
|
"3. Do not promote rejected_or_unresolved_claims unless the image clearly resolves the issue.\n"
|
|
"4. Add clearly visible missing details when they improve LoRA-training value, especially face, eyes, makeup, hair, jewelry, accessories, clothing construction, materials, pose, hands, crop, background, lighting, and texture.\n"
|
|
"5. Correct visibly wrong details instead of merely deleting them when a clear correction is visible.\n"
|
|
"6. Do not summarize or compress rich supported details. A rich caption is the goal.\n"
|
|
"7. Do not mention uncertainty, sources, captions, drafts, claims, ballots, or validation in final captions.\n"
|
|
"8. Put uncertain/unsupported details only in audit fields, not in the final captions.\n"
|
|
"9. Do not prepend the trigger word yourself. The engine will prepend trigger/anchor after parsing.\n\n"
|
|
|
|
"FINAL CAPTION STYLE:\n"
|
|
"- validated_caption_narrative: rich LoRA-training prose, one paragraph or a long fluent sentence.\n"
|
|
"- validated_caption_comma: dense taggy comma-separated caption with nearly the same visual content.\n"
|
|
"- validated_claims: list of supported claim strings you used.\n"
|
|
"- added_visible_details: visible details you added from the image that were not explicit in Pass B.\n\n"
|
|
|
|
"Input JSON:\n"
|
|
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n"
|
|
)
|
|
|
|
image_info = {
|
|
"resolved_path": str(image_path) if image_path else "",
|
|
"exists": bool(image_path and image_path.exists()),
|
|
"mime_type": image_mime_type(image_path) if image_path else "",
|
|
"size_bytes": image_path.stat().st_size if image_path and image_path.exists() else 0,
|
|
"sha1": sha1_file(image_path) if image_path and image_path.exists() else "",
|
|
}
|
|
|
|
return {
|
|
"captionforge_pass": "VLM_validator_prompt",
|
|
"prompt_schema_version": config.prompt_schema_version,
|
|
"image_key": image_key,
|
|
"image": str(record.get("image") or ""),
|
|
"image_info": image_info,
|
|
"prompt": prompt,
|
|
"prompt_payload": payload,
|
|
"prompt_sha1": sha1_text(prompt),
|
|
"timestamp": iso_timestamp(),
|
|
}
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# JSON response parsing# -----------------------------------------------------------------------------
|
|
# JSON response parsing
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def normalize_json_text(text: str) -> str:
|
|
text = str(text or "").strip()
|
|
if text.startswith("```"):
|
|
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.IGNORECASE)
|
|
text = re.sub(r"\s*```$", "", text)
|
|
replacements = {
|
|
"\u201c": '"',
|
|
"\u201d": '"',
|
|
"\u201e": '"',
|
|
"\u201f": '"',
|
|
"\u2018": "'",
|
|
"\u2019": "'",
|
|
"\u201a": "'",
|
|
"\u201b": "'",
|
|
"\u00a0": " ",
|
|
}
|
|
for src_char, dst_char in replacements.items():
|
|
text = text.replace(src_char, dst_char)
|
|
return text.strip()
|
|
|
|
|
|
def first_balanced_json_object_text(text: str) -> str:
|
|
text = str(text or "")
|
|
start = text.find("{")
|
|
if start < 0:
|
|
raise ValueError("Invalid VLM JSON response: no JSON object start found.")
|
|
|
|
depth = 0
|
|
in_string = False
|
|
escape = False
|
|
|
|
for idx in range(start, len(text)):
|
|
ch = text[idx]
|
|
if in_string:
|
|
if escape:
|
|
escape = False
|
|
elif ch == "\\":
|
|
escape = True
|
|
elif ch == '"':
|
|
in_string = False
|
|
continue
|
|
if ch == '"':
|
|
in_string = True
|
|
elif ch == "{":
|
|
depth += 1
|
|
elif ch == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return text[start:idx + 1]
|
|
|
|
return text[start:]
|
|
|
|
|
|
def extract_json_object(text: str) -> dict[str, Any]:
|
|
text = normalize_json_text(text)
|
|
if not text:
|
|
raise ValueError("Empty VLM response.")
|
|
|
|
last_error: Optional[Exception] = None
|
|
for candidate in (text, first_balanced_json_object_text(text)):
|
|
try:
|
|
obj = json.loads(candidate)
|
|
if not isinstance(obj, dict):
|
|
raise ValueError("VLM response must be a JSON object.")
|
|
return obj
|
|
except json.JSONDecodeError as exc:
|
|
last_error = exc
|
|
|
|
raise ValueError(f"Invalid VLM JSON response: {last_error}") from last_error
|
|
|
|
|
|
def coerce_string_list(value: Any, max_chars: int = 260) -> list[str]:
|
|
if value is None:
|
|
return []
|
|
if isinstance(value, list):
|
|
out: list[str] = []
|
|
for item in value:
|
|
if isinstance(item, list):
|
|
out.extend(coerce_string_list(item, max_chars=max_chars))
|
|
else:
|
|
text = truncate_text(item, max_chars)
|
|
if text:
|
|
out.append(text)
|
|
return out
|
|
text = truncate_text(value, max_chars)
|
|
return [text] if text else []
|
|
|
|
|
|
def coerce_corrections(value: Any, max_chars: int = 260) -> list[dict[str, Any]]:
|
|
if not isinstance(value, list):
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
for item in value:
|
|
if isinstance(item, dict):
|
|
src = truncate_text(item.get("from") or item.get("from_detail") or item.get("original") or "", max_chars)
|
|
dst = truncate_text(item.get("to") or item.get("to_detail") or item.get("corrected") or "", max_chars)
|
|
reason = truncate_text(item.get("reason") or item.get("evidence_note") or "", max_chars)
|
|
if src or dst:
|
|
out.append({"from": src, "to": dst, "reason": reason})
|
|
else:
|
|
text = truncate_text(item, max_chars)
|
|
if text:
|
|
out.append({"from": "", "to": text, "reason": ""})
|
|
return out
|
|
|
|
|
|
def parse_vlm_response(
|
|
response: dict[str, Any],
|
|
*,
|
|
backend: str,
|
|
config: VLMValidatorConfig,
|
|
prompt_bundle: dict[str, Any],
|
|
raw_response: str,
|
|
elapsed_sec: float,
|
|
) -> tuple[dict[str, Any], VLMValidatorParseResult, list[str]]:
|
|
warnings: list[str] = []
|
|
|
|
narrative = normalize_whitespace(response.get("validated_caption_narrative"))
|
|
comma = normalize_comma_caption(response.get("validated_caption_comma"))
|
|
|
|
if not narrative:
|
|
warnings.append("validated_caption_narrative missing or empty.")
|
|
if not comma:
|
|
warnings.append("validated_caption_comma missing or empty.")
|
|
|
|
narrative = truncate_text(narrative, int(config.max_output_caption_chars))
|
|
comma = truncate_text(comma, int(config.max_output_caption_chars))
|
|
|
|
payload = prompt_bundle.get("prompt_payload") or {}
|
|
trigger_word = normalize_whitespace(payload.get("trigger_word") or get_trigger_word({}, config.trigger_word))
|
|
user_caption_anchor = normalize_whitespace(payload.get("user_caption_anchor") or get_user_caption_anchor({}, config.user_caption_anchor))
|
|
|
|
fields = {
|
|
"validated_caption_narrative": prepend_trigger_and_anchor(narrative, trigger_word, user_caption_anchor, response),
|
|
"validated_caption_comma": prepend_trigger_and_anchor(comma, trigger_word, user_caption_anchor, response),
|
|
"validated_claims": coerce_string_list(
|
|
response.get("validated_claims")
|
|
or response.get("supported_claims")
|
|
or response.get("kept_claims")
|
|
or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
"added_visible_details": coerce_string_list(
|
|
response.get("added_visible_details")
|
|
or response.get("added_details")
|
|
or response.get("image_added_details")
|
|
or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
"removed_or_rejected_details": coerce_string_list(
|
|
response.get("removed_or_rejected_details")
|
|
or response.get("rejected_details")
|
|
or response.get("removed_details")
|
|
or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
"corrected_details": coerce_corrections(
|
|
response.get("corrected_details") or response.get("corrections") or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
"uncertain_details": coerce_string_list(
|
|
response.get("uncertain_details") or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
"visual_validation_notes": coerce_string_list(
|
|
response.get("visual_validation_notes")
|
|
or response.get("validation_notes")
|
|
or [],
|
|
max_chars=int(config.max_audit_note_chars),
|
|
),
|
|
}
|
|
|
|
forbidden_phrases = [
|
|
"in another image",
|
|
"different image",
|
|
"source caption",
|
|
"draft caption",
|
|
"caption says",
|
|
]
|
|
for phrase in forbidden_phrases:
|
|
if phrase in fields["validated_caption_narrative"].lower() or phrase in fields["validated_caption_comma"].lower():
|
|
warnings.append(f"final caption contains discouraged phrase: {phrase!r}")
|
|
|
|
parse = VLMValidatorParseResult(
|
|
backend=backend,
|
|
status="ok" if not warnings else "ok_with_parser_warnings",
|
|
prompt_sha1=str(prompt_bundle.get("prompt_sha1") or ""),
|
|
prompt_schema_version=config.prompt_schema_version,
|
|
response_schema_version=config.response_schema_version,
|
|
raw_response_sha1=sha1_text(raw_response) if raw_response else "",
|
|
parser_warnings=warnings,
|
|
prompt_chars=len(str(prompt_bundle.get("prompt") or "")),
|
|
raw_response_chars=len(raw_response or ""),
|
|
elapsed_sec=round(elapsed_sec, 3),
|
|
)
|
|
return fields, parse, warnings
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Manual response handling
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def normalize_manual_response(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, str):
|
|
return extract_json_object(value)
|
|
if isinstance(value, dict):
|
|
if "response" in value and isinstance(value.get("response"), (str, dict)):
|
|
return normalize_manual_response(value["response"])
|
|
return value
|
|
raise ValueError("Manual VLM response must be a JSON object or JSON string.")
|
|
|
|
|
|
def load_manual_responses(path: str | Path) -> dict[str, Any]:
|
|
if not str(path or "").strip():
|
|
return {}
|
|
p = Path(path)
|
|
if p.is_dir():
|
|
raise IsADirectoryError(f"manual_json_path points to a folder, not a 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 JSONL 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 or "validated_caption_narrative" in obj or "response" in obj:
|
|
by_key[str(obj.get("image_key") or "__single__")] = obj
|
|
else:
|
|
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.")
|
|
by_key[str(item.get("image_key") or f"__single__:{idx}")] = item
|
|
return by_key
|
|
raise ValueError("Manual JSON must contain an object/list or JSONL object records.")
|
|
|
|
|
|
def manual_response_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
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Ollama backend
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def normalize_ollama_base_url(url: str) -> str:
|
|
return (url or "http://127.0.0.1:11434").strip().rstrip("/")
|
|
|
|
|
|
def ollama_request_json(*, base_url: str, endpoint: str, payload: Optional[dict[str, Any]] = None, timeout_sec: int = 900) -> Any:
|
|
base_url = normalize_ollama_base_url(base_url)
|
|
url = f"{base_url}{endpoint}"
|
|
data = None
|
|
headers = {"Content-Type": "application/json"}
|
|
method = "GET"
|
|
if payload is not None:
|
|
data = json.dumps(payload).encode("utf-8")
|
|
method = "POST"
|
|
req = urllib.request.Request(url=url, data=data, headers=headers, method=method)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=int(timeout_sec)) as resp:
|
|
text = resp.read().decode("utf-8", errors="replace")
|
|
except urllib.error.URLError as exc:
|
|
raise RuntimeError("Could not reach Ollama. Make sure Ollama is running and test with: ollama list") from exc
|
|
if not text.strip():
|
|
return None
|
|
try:
|
|
return json.loads(text)
|
|
except json.JSONDecodeError as exc:
|
|
raise RuntimeError(f"Ollama returned invalid JSON from {endpoint}: {exc}") from exc
|
|
|
|
|
|
def ollama_model_names(base_url: str, timeout_sec: int = 30) -> set[str]:
|
|
obj = ollama_request_json(base_url=base_url, endpoint="/api/tags", payload=None, timeout_sec=timeout_sec)
|
|
names: set[str] = set()
|
|
if isinstance(obj, dict):
|
|
for item in obj.get("models", []) or []:
|
|
if isinstance(item, dict) and item.get("name"):
|
|
names.add(str(item["name"]))
|
|
return names
|
|
|
|
|
|
def ollama_model_is_available(model_name: str, base_url: str, timeout_sec: int = 30) -> bool:
|
|
model_name = str(model_name or "").strip()
|
|
if not model_name:
|
|
return False
|
|
names = ollama_model_names(base_url, timeout_sec=timeout_sec)
|
|
if model_name in names:
|
|
return True
|
|
if ":" not in model_name:
|
|
return any(n.startswith(model_name + ":") for n in names)
|
|
return False
|
|
|
|
|
|
def ensure_ollama_model_available(config: VLMValidatorConfig) -> None:
|
|
model_name = str(config.vlm_model or "").strip()
|
|
if not model_name:
|
|
raise ValueError("--vlm-model is required when --vlm-backend ollama is used.")
|
|
if ollama_model_is_available(model_name, config.ollama_base_url, timeout_sec=min(int(config.ollama_timeout_sec), 60)):
|
|
return
|
|
raise RuntimeError(f"Ollama model is not installed: {model_name}\nInstall it with: ollama pull {model_name}")
|
|
|
|
|
|
def resolve_ollama_format(config: VLMValidatorConfig) -> str | dict[str, Any]:
|
|
mode = str(config.ollama_format_mode or "json").strip().lower()
|
|
if mode == "schema":
|
|
return build_response_schema()
|
|
return "json"
|
|
|
|
|
|
def ollama_generate_vlm_response(
|
|
prompt_bundle: dict[str, Any],
|
|
image_path: Path,
|
|
config: VLMValidatorConfig,
|
|
) -> str:
|
|
ensure_ollama_model_available(config)
|
|
if not image_path.exists():
|
|
raise FileNotFoundError(f"Image does not exist for Ollama VLM call: {image_path}")
|
|
|
|
payload: dict[str, Any] = {
|
|
"model": str(config.vlm_model or "").strip(),
|
|
"prompt": str(prompt_bundle.get("prompt") or ""),
|
|
"images": [image_to_base64_cached(image_path)],
|
|
"stream": False,
|
|
"format": resolve_ollama_format(config),
|
|
"options": {
|
|
"temperature": float(config.ollama_temperature),
|
|
"num_predict": int(config.ollama_num_predict),
|
|
"top_p": float(config.ollama_top_p),
|
|
"top_k": int(config.ollama_top_k),
|
|
},
|
|
}
|
|
if int(getattr(config, "ollama_seed", -1)) >= 0:
|
|
payload["options"]["seed"] = int(config.ollama_seed)
|
|
if str(config.ollama_keep_alive or "").strip():
|
|
payload["keep_alive"] = str(config.ollama_keep_alive).strip()
|
|
|
|
print(
|
|
f"[CaptionForge VLM Validator] Sending image to Ollama model: {config.vlm_model} "
|
|
f"| num_predict={int(config.ollama_num_predict)} "
|
|
f"| temperature={float(config.ollama_temperature):g} "
|
|
f"| top_p={float(config.ollama_top_p):g} "
|
|
f"| top_k={int(config.ollama_top_k)} "
|
|
f"| seed={int(getattr(config, 'ollama_seed', -1))} "
|
|
f"| format_mode={config.ollama_format_mode} "
|
|
f"| image_cache=on",
|
|
flush=True,
|
|
)
|
|
obj = ollama_request_json(
|
|
base_url=config.ollama_base_url,
|
|
endpoint="/api/generate",
|
|
payload=payload,
|
|
timeout_sec=int(config.ollama_timeout_sec),
|
|
)
|
|
if not isinstance(obj, dict):
|
|
raise RuntimeError("Ollama returned a non-object response.")
|
|
if obj.get("error"):
|
|
raise RuntimeError(f"Ollama generation failed: {obj.get('error')}")
|
|
response_text = str(obj.get("response") or "").strip()
|
|
if not response_text:
|
|
raise RuntimeError("Ollama returned an empty response.")
|
|
return response_text
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Record construction
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def make_prompt_only_fields(record: dict[str, Any], config: VLMValidatorConfig) -> dict[str, Any]:
|
|
trigger = get_trigger_word(record, config.trigger_word)
|
|
anchor = get_user_caption_anchor(record, config.user_caption_anchor)
|
|
narrative_seed = get_rich_caption_draft(record) or get_distilled_narrative(record)
|
|
comma_seed = get_taggy_caption_draft(record) or get_distilled_comma(record)
|
|
narrative = prepend_trigger_and_anchor(strip_leading_trigger(narrative_seed, trigger), trigger, anchor, {})
|
|
comma = prepend_trigger_and_anchor(normalize_comma_caption(strip_leading_trigger(comma_seed, trigger)), trigger, anchor, {})
|
|
claim_seed = [_claim_text(c) for c in get_claim_bucket(record, "accepted_claims") if _claim_text(c)]
|
|
return {
|
|
"validated_caption_narrative": narrative,
|
|
"validated_caption_comma": comma,
|
|
"validated_claims": claim_seed,
|
|
"added_visible_details": [],
|
|
"removed_or_rejected_details": [],
|
|
"corrected_details": [],
|
|
"uncertain_details": ["prompt_only backend: no VLM validation performed"],
|
|
"visual_validation_notes": ["Pass B draft captions passed through unchanged except trigger/anchor normalization."],
|
|
}
|
|
|
|
|
|
|
|
def source_distiller_metadata(record: dict[str, Any], image_sha1: str = "") -> dict[str, Any]:
|
|
params = record.get("params") if isinstance(record.get("params"), dict) else {}
|
|
metrics = record.get("metrics") if isinstance(record.get("metrics"), dict) else {}
|
|
parse = record.get("distill_parse_result") or record.get("llm_parse_result") or record.get("distiller_parse_result") or {}
|
|
if not isinstance(parse, dict):
|
|
parse = {}
|
|
raw_response = str(record.get("raw_distill_response") or record.get("raw_llm_response") or record.get("raw_distiller_response") or "")
|
|
|
|
accepted = get_claim_bucket(record, "accepted_claims")
|
|
singletons = get_claim_bucket(record, "singleton_candidates")
|
|
rejected = get_claim_bucket(record, "rejected_or_unresolved_claims")
|
|
|
|
return {
|
|
"captionforge_pass": record.get("captionforge_pass", ""),
|
|
"image_key": record.get("image_key", ""),
|
|
"image": record.get("image", ""),
|
|
"status": record.get("status", ""),
|
|
"engine": record.get("engine") or params.get("engine", ""),
|
|
"engine_version": record.get("engine_version") or params.get("engine_version", ""),
|
|
"llm_backend": params.get("llm_backend", params.get("text_llm_backend", "")),
|
|
"llm_model": params.get("llm_model", params.get("text_llm_model", "")),
|
|
"prompt_sha1": parse.get("prompt_sha1", params.get("prompt_sha1", "")),
|
|
"trigger_word": get_trigger_word(record),
|
|
"user_caption_anchor": get_user_caption_anchor(record),
|
|
"accepted_claim_count": len(accepted),
|
|
"singleton_candidate_count": len(singletons),
|
|
"rejected_or_unresolved_claim_count": len(rejected),
|
|
"rich_caption_draft_sha1": sha1_text(get_rich_caption_draft(record)),
|
|
"taggy_caption_draft_sha1": sha1_text(get_taggy_caption_draft(record)),
|
|
"parse_result": parse,
|
|
"metrics": metrics,
|
|
"raw_response_sha1": sha1_text(raw_response) if raw_response else "",
|
|
"image_sha1": image_sha1,
|
|
}
|
|
|
|
|
|
|
|
def make_metrics(prompt_bundle: dict[str, Any], raw_response: str, parse: VLMValidatorParseResult, image_path: Optional[Path]) -> dict[str, Any]:
|
|
return {
|
|
"prompt_chars": int(getattr(parse, "prompt_chars", 0) or len(str(prompt_bundle.get("prompt") or ""))),
|
|
"raw_response_chars": len(raw_response or ""),
|
|
"elapsed_sec": parse.elapsed_sec,
|
|
"parser_warning_count": len(parse.parser_warnings or []),
|
|
"image_exists": bool(image_path and image_path.exists()),
|
|
"image_size_bytes": image_path.stat().st_size if image_path and image_path.exists() else 0,
|
|
}
|
|
|
|
|
|
def build_validated_record(
|
|
record: dict[str, Any],
|
|
*,
|
|
image_path: Optional[Path],
|
|
config: VLMValidatorConfig,
|
|
manual_response: Optional[dict[str, Any]] = None,
|
|
) -> ImageVLMValidatedRecord:
|
|
image_key = str(record.get("image_key") or record.get("image") or "")
|
|
prompt_bundle = build_prompt_bundle(record, image_path, config)
|
|
raw_response = ""
|
|
t0 = time.perf_counter()
|
|
|
|
trigger = get_trigger_word(record, config.trigger_word)
|
|
anchor = get_user_caption_anchor(record, config.user_caption_anchor)
|
|
|
|
if config.vlm_backend == "prompt_only":
|
|
fields = make_prompt_only_fields(record, config)
|
|
elapsed = time.perf_counter() - t0
|
|
parse = VLMValidatorParseResult(
|
|
backend="prompt_only",
|
|
status="prompt_only",
|
|
prompt_sha1=str(prompt_bundle.get("prompt_sha1") or ""),
|
|
prompt_schema_version=config.prompt_schema_version,
|
|
response_schema_version=config.response_schema_version,
|
|
prompt_chars=len(str(prompt_bundle.get("prompt") or "")),
|
|
elapsed_sec=round(elapsed, 3),
|
|
)
|
|
parser_warnings: list[str] = []
|
|
status = "prompt_only"
|
|
|
|
elif config.vlm_backend == "manual_json":
|
|
if manual_response is None:
|
|
fields = make_prompt_only_fields(record, config)
|
|
elapsed = time.perf_counter() - t0
|
|
parse = VLMValidatorParseResult(
|
|
backend="manual_json",
|
|
status="missing_manual_response",
|
|
prompt_sha1=str(prompt_bundle.get("prompt_sha1") or ""),
|
|
prompt_schema_version=config.prompt_schema_version,
|
|
response_schema_version=config.response_schema_version,
|
|
error_class="missing_manual_response",
|
|
error=f"No manual VLM response found for image_key={image_key!r}.",
|
|
prompt_chars=len(str(prompt_bundle.get("prompt") or "")),
|
|
elapsed_sec=round(elapsed, 3),
|
|
)
|
|
parser_warnings = [parse.error]
|
|
status = "error"
|
|
else:
|
|
raw_response = json.dumps(manual_response, ensure_ascii=False, sort_keys=True)
|
|
fields, parse, parser_warnings = parse_vlm_response(
|
|
manual_response,
|
|
backend="manual_json",
|
|
config=config,
|
|
prompt_bundle=prompt_bundle,
|
|
raw_response=raw_response,
|
|
elapsed_sec=time.perf_counter() - t0,
|
|
)
|
|
status = "ok"
|
|
|
|
elif config.vlm_backend == "ollama":
|
|
if image_path is None or not image_path.exists():
|
|
raise FileNotFoundError(f"Could not resolve image for image_key={image_key!r}")
|
|
raw_response = ollama_generate_vlm_response(prompt_bundle, image_path, config)
|
|
try:
|
|
response = extract_json_object(raw_response)
|
|
except Exception as exc:
|
|
raise VLMResponseParseError(str(exc), raw_response=raw_response) from exc
|
|
fields, parse, parser_warnings = parse_vlm_response(
|
|
response,
|
|
backend="ollama",
|
|
config=config,
|
|
prompt_bundle=prompt_bundle,
|
|
raw_response=raw_response,
|
|
elapsed_sec=time.perf_counter() - t0,
|
|
)
|
|
status = "ok"
|
|
else:
|
|
raise ValueError(f"Unsupported vlm_backend={config.vlm_backend!r}. Use ollama, manual_json, or prompt_only.")
|
|
|
|
image_sha1 = sha1_file(image_path) if image_path and image_path.exists() else ""
|
|
|
|
return ImageVLMValidatedRecord(
|
|
captionforge_pass="C_VLM_VALIDATED",
|
|
image_key=image_key,
|
|
image=str(record.get("image") or image_key),
|
|
image_resolved_path=str(image_path) if image_path else "",
|
|
status=status,
|
|
validated_caption_narrative=fields["validated_caption_narrative"],
|
|
validated_caption_comma=fields["validated_caption_comma"],
|
|
trigger_word=trigger,
|
|
user_caption_anchor=anchor,
|
|
removed_or_rejected_details=fields["removed_or_rejected_details"],
|
|
corrected_details=fields["corrected_details"],
|
|
uncertain_details=fields["uncertain_details"],
|
|
visual_validation_notes=fields["visual_validation_notes"],
|
|
validated_claims=fields.get("validated_claims", []),
|
|
added_visible_details=fields.get("added_visible_details", []),
|
|
source_distiller=source_distiller_metadata(record, image_sha1=image_sha1),
|
|
params={
|
|
"engine": config.engine_name,
|
|
"engine_version": config.engine_version,
|
|
"vlm_backend": config.vlm_backend,
|
|
"vlm_model": config.vlm_model,
|
|
"prompt_schema_version": config.prompt_schema_version,
|
|
"response_schema_version": config.response_schema_version,
|
|
"prompt_sha1": str(prompt_bundle.get("prompt_sha1") or ""),
|
|
"trigger_word": trigger,
|
|
"user_caption_anchor": anchor,
|
|
"config": record_to_json(config),
|
|
},
|
|
vlm_parse_result=parse,
|
|
parser_warnings=parser_warnings,
|
|
raw_vlm_response=raw_response if config.preserve_raw_vlm_response else "",
|
|
metrics=make_metrics(prompt_bundle, raw_response, parse, image_path),
|
|
timestamp=iso_timestamp(),
|
|
)
|
|
|
|
|
|
def classify_vlm_error(exc: BaseException, raw_response: str = "") -> str:
|
|
message = str(exc or "").lower()
|
|
raw = str(raw_response or "")
|
|
if isinstance(exc, TimeoutError) or "timed out" in message or "timeout" in message:
|
|
return "timed_out"
|
|
if isinstance(exc, FileNotFoundError) or "could not resolve image" in message or "image does not exist" in message:
|
|
return "image_missing"
|
|
if "ollama model is not installed" in message:
|
|
return "ollama_model_missing"
|
|
if "could not reach ollama" in message:
|
|
return "ollama_unreachable"
|
|
if "empty" in message and "response" in message:
|
|
return "empty_response"
|
|
if isinstance(exc, VLMResponseParseError):
|
|
if raw.strip() and not raw.strip().endswith(("}", "]")):
|
|
return "truncated_or_invalid_json"
|
|
return "invalid_json"
|
|
if isinstance(exc, json.JSONDecodeError) or "json" in message:
|
|
if raw.strip() and not raw.strip().endswith(("}", "]")):
|
|
return "truncated_or_invalid_json"
|
|
return "invalid_json"
|
|
return "error"
|
|
|
|
|
|
def make_error_record(
|
|
record: dict[str, Any],
|
|
*,
|
|
image_path: Optional[Path],
|
|
config: VLMValidatorConfig,
|
|
exc: BaseException,
|
|
) -> ImageVLMValidatedRecord:
|
|
image_key = str(record.get("image_key") or record.get("image") or "")
|
|
prompt_bundle = build_prompt_bundle(record, image_path, config)
|
|
raw_response = str(getattr(exc, "raw_response", "") or "")
|
|
error_class = classify_vlm_error(exc, raw_response=raw_response)
|
|
trigger = get_trigger_word(record, config.trigger_word)
|
|
anchor = get_user_caption_anchor(record, config.user_caption_anchor)
|
|
fields = make_prompt_only_fields(record, config)
|
|
parse = VLMValidatorParseResult(
|
|
backend=config.vlm_backend,
|
|
status="error",
|
|
prompt_sha1=str(prompt_bundle.get("prompt_sha1") or ""),
|
|
prompt_schema_version=config.prompt_schema_version,
|
|
response_schema_version=config.response_schema_version,
|
|
raw_response_sha1=sha1_text(raw_response) if raw_response else "",
|
|
error_class=error_class,
|
|
error=str(exc),
|
|
prompt_chars=len(str(prompt_bundle.get("prompt") or "")),
|
|
raw_response_chars=len(raw_response),
|
|
elapsed_sec=0.0,
|
|
)
|
|
image_sha1 = sha1_file(image_path) if image_path and image_path.exists() else ""
|
|
return ImageVLMValidatedRecord(
|
|
captionforge_pass="C_VLM_VALIDATED",
|
|
image_key=image_key,
|
|
image=str(record.get("image") or image_key),
|
|
image_resolved_path=str(image_path) if image_path else "",
|
|
status="error",
|
|
validated_caption_narrative="",
|
|
validated_caption_comma="",
|
|
trigger_word=trigger,
|
|
user_caption_anchor=anchor,
|
|
removed_or_rejected_details=[],
|
|
corrected_details=[],
|
|
uncertain_details=fields["uncertain_details"],
|
|
visual_validation_notes=[f"No VLM validation decision was made because the backend returned an error: {error_class}."],
|
|
validated_claims=[],
|
|
added_visible_details=[],
|
|
source_distiller=source_distiller_metadata(record, image_sha1=image_sha1),
|
|
params={
|
|
"engine": config.engine_name,
|
|
"engine_version": config.engine_version,
|
|
"vlm_backend": config.vlm_backend,
|
|
"vlm_model": config.vlm_model,
|
|
"prompt_schema_version": config.prompt_schema_version,
|
|
"response_schema_version": config.response_schema_version,
|
|
"prompt_sha1": str(prompt_bundle.get("prompt_sha1") or ""),
|
|
"trigger_word": trigger,
|
|
"user_caption_anchor": anchor,
|
|
"config": record_to_json(config),
|
|
},
|
|
vlm_parse_result=parse,
|
|
parser_warnings=[str(exc)],
|
|
raw_vlm_response=raw_response if config.preserve_raw_vlm_response else "",
|
|
metrics=make_metrics(prompt_bundle, raw_response, parse, image_path),
|
|
timestamp=iso_timestamp(),
|
|
)
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Readable sidecars
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def write_readable_sidecar(record: ImageVLMValidatedRecord, sidecar_dir: str | Path, dry_run: bool = False) -> Path:
|
|
sidecar_path = Path(sidecar_dir) / stable_filename_from_image_key(record.image_key)
|
|
if dry_run:
|
|
return sidecar_path
|
|
safe_mkdir(sidecar_path.parent)
|
|
lines = [
|
|
f"CaptionForge VLM Validated Caption",
|
|
f"image_key: {record.image_key}",
|
|
f"image: {record.image}",
|
|
f"status: {record.status}",
|
|
f"trigger_word: {record.trigger_word}",
|
|
f"user_caption_anchor: {record.user_caption_anchor}",
|
|
"",
|
|
"[validated_caption_narrative]",
|
|
record.validated_caption_narrative,
|
|
"",
|
|
"[validated_caption_comma]",
|
|
record.validated_caption_comma,
|
|
"",
|
|
"[removed_or_rejected_details]",
|
|
]
|
|
lines.extend(f"- {x}" for x in record.removed_or_rejected_details)
|
|
lines.extend(["", "[validated_claims]"])
|
|
lines.extend(f"- {x}" for x in record.validated_claims)
|
|
lines.extend(["", "[added_visible_details]"])
|
|
lines.extend(f"- {x}" for x in record.added_visible_details)
|
|
lines.extend(["", "[corrected_details]"])
|
|
lines.extend(f"- {x.get('from','')} -> {x.get('to','')} ({x.get('reason','')})" for x in record.corrected_details)
|
|
lines.extend(["", "[uncertain_details]"])
|
|
lines.extend(f"- {x}" for x in record.uncertain_details)
|
|
lines.extend(["", "[visual_validation_notes]"])
|
|
lines.extend(f"- {x}" for x in record.visual_validation_notes)
|
|
sidecar_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
|
|
return sidecar_path
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Batch orchestration
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def existing_record_is_success(record: dict[str, Any]) -> bool:
|
|
if not isinstance(record, dict):
|
|
return False
|
|
if str(record.get("status") or "").lower() not in {"ok", "prompt_only"}:
|
|
return False
|
|
parse = record.get("vlm_parse_result") or {}
|
|
if isinstance(parse, dict):
|
|
status = str(parse.get("status") or "").lower()
|
|
if status and status not in {"ok", "ok_with_parser_warnings", "prompt_only"}:
|
|
return False
|
|
return True
|
|
|
|
|
|
def load_existing_records(path: str | Path) -> dict[str, dict[str, Any]]:
|
|
p = Path(path)
|
|
if not p.exists() or p.is_dir():
|
|
return {}
|
|
by_key: dict[str, 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:
|
|
print(f"[CaptionForge VLM Validator] WARNING: ignoring invalid resume line {p}:{line_number}", flush=True)
|
|
continue
|
|
if isinstance(obj, dict) and obj.get("image_key"):
|
|
by_key[str(obj["image_key"])] = obj
|
|
return by_key
|
|
|
|
|
|
def parse_rerun_image_keys(values: Iterable[str]) -> set[str]:
|
|
keys: set[str] = set()
|
|
for value in values or []:
|
|
for part in str(value or "").split(","):
|
|
part = part.strip()
|
|
if part:
|
|
keys.add(part)
|
|
return keys
|
|
|
|
|
|
def filter_input_records(records: list[dict[str, Any]], image_key_filter: str = "") -> list[dict[str, Any]]:
|
|
out: list[dict[str, Any]] = []
|
|
for record in records:
|
|
# CaptionForge v0.2 Pass B records are pollster/copywriter records.
|
|
# Legacy narrative/comma records are still accepted when present.
|
|
has_pass_b_payload = bool(
|
|
get_rich_caption_draft(record)
|
|
or get_taggy_caption_draft(record)
|
|
or get_claim_bucket(record, "accepted_claims")
|
|
or get_claim_bucket(record, "singleton_candidates")
|
|
or get_distilled_narrative(record)
|
|
or get_distilled_comma(record)
|
|
)
|
|
if not has_pass_b_payload:
|
|
continue
|
|
status = str(record.get("status") or "ok").lower()
|
|
if status != "ok":
|
|
continue
|
|
image_key = str(record.get("image_key") or record.get("image") or "")
|
|
if image_key_filter:
|
|
needle = image_key_filter.lower()
|
|
if image_key != image_key_filter and needle not in image_key.lower():
|
|
continue
|
|
out.append(record)
|
|
return out
|
|
|
|
|
|
def extract_validate_batch(batch: BatchVLMValidatorConfig, config: Optional[VLMValidatorConfig] = None) -> BatchVLMValidatorResult:
|
|
config = config or VLMValidatorConfig()
|
|
config.vlm_backend = str(config.vlm_backend or "ollama").strip().lower()
|
|
if config.vlm_backend not in {"ollama", "manual_json", "prompt_only"}:
|
|
raise ValueError("Unsupported VLM backend. Use: ollama, manual_json, prompt_only.")
|
|
|
|
input_records = read_jsonl(batch.input_jsonl)
|
|
items = filter_input_records(input_records, batch.image_key_filter)
|
|
if batch.limit_images and batch.limit_images > 0:
|
|
items = items[: int(batch.limit_images)]
|
|
|
|
manual_by_key = load_manual_responses(batch.manual_json_path) if config.vlm_backend == "manual_json" else {}
|
|
|
|
prompt_bundles: list[dict[str, Any]] = []
|
|
for record in items:
|
|
image_path = resolve_image_path(record, batch.image_root)
|
|
prompt_bundles.append(build_prompt_bundle(record, image_path, config))
|
|
if batch.write_prompt_jsonl:
|
|
prompt_path = Path(batch.prompt_jsonl) if str(batch.prompt_jsonl or "").strip() else default_prompt_output_path(batch.input_jsonl)
|
|
write_jsonl(prompt_path, prompt_bundles, overwrite=True, dry_run=batch.dry_run)
|
|
print(f"[CaptionForge VLM Validator] Wrote prompt bundles: {prompt_path}", flush=True)
|
|
|
|
result = BatchVLMValidatorResult()
|
|
out: Optional[Path] = None
|
|
sidecar_dir: Optional[Path] = None
|
|
|
|
if batch.write_jsonl:
|
|
out = Path(batch.output_jsonl) if str(batch.output_jsonl or "").strip() else default_output_path(batch.input_jsonl)
|
|
explicit_rerun_keys = parse_rerun_image_keys(batch.rerun_image_keys)
|
|
if batch.resume and out.exists():
|
|
existing = load_existing_records(out)
|
|
kept_existing: list[dict[str, Any]] = []
|
|
filtered: list[dict[str, Any]] = []
|
|
for record in items:
|
|
key = str(record.get("image_key") or record.get("image") or "")
|
|
old = existing.get(key)
|
|
force = key in explicit_rerun_keys
|
|
if old and existing_record_is_success(old) and not force:
|
|
kept_existing.append(old)
|
|
result.skipped += 1
|
|
elif old and not batch.rerun_errors and not force:
|
|
kept_existing.append(old)
|
|
result.skipped += 1
|
|
else:
|
|
filtered.append(record)
|
|
items = filtered
|
|
if not batch.dry_run:
|
|
safe_mkdir(out.parent)
|
|
with out.open("w", encoding="utf-8") as f:
|
|
for old in kept_existing:
|
|
f.write(json.dumps(old, ensure_ascii=False) + "\n")
|
|
print(
|
|
f"[CaptionForge VLM Validator] Resume mode: preserved={len(kept_existing)}, queued={len(items)}, output={out}",
|
|
flush=True,
|
|
)
|
|
elif batch.overwrite and not batch.dry_run:
|
|
safe_mkdir(out.parent)
|
|
out.write_text("", encoding="utf-8")
|
|
|
|
if batch.write_readable_sidecars:
|
|
sidecar_dir = Path(batch.readable_sidecar_dir) if str(batch.readable_sidecar_dir or "").strip() else default_sidecar_dir(out)
|
|
|
|
total = len(items)
|
|
for idx, record in enumerate(items, start=1):
|
|
image_key = str(record.get("image_key") or record.get("image") or "")
|
|
print(f"[CaptionForge VLM Validator] [{idx}/{total}] Processing image_key: {image_key}", flush=True)
|
|
t0 = time.perf_counter()
|
|
image_path = resolve_image_path(record, batch.image_root)
|
|
|
|
try:
|
|
if image_path is None and config.vlm_backend == "ollama" and not config.allow_missing_images:
|
|
raise FileNotFoundError(
|
|
f"Could not resolve image for image_key={image_key!r}. "
|
|
"Use --image-root to point at the original image directory."
|
|
)
|
|
manual_response = manual_response_for_image(image_key, manual_by_key) if config.vlm_backend == "manual_json" else None
|
|
validated = build_validated_record(
|
|
record,
|
|
image_path=image_path,
|
|
config=config,
|
|
manual_response=manual_response,
|
|
)
|
|
if validated.status == "error":
|
|
result.failed += 1
|
|
result.records.append(validated)
|
|
except KeyboardInterrupt:
|
|
print(f"[CaptionForge VLM Validator] Interrupted while processing image_key: {image_key}", flush=True)
|
|
raise
|
|
except Exception as exc:
|
|
result.failed += 1
|
|
validated = make_error_record(record, image_path=image_path, config=config, exc=exc)
|
|
result.records.append(validated)
|
|
|
|
elapsed = time.perf_counter() - t0
|
|
print(
|
|
f"[CaptionForge VLM Validator] [{idx}/{total}] Finished image_key: {image_key} "
|
|
f"in {elapsed:.1f}s | status={validated.status}",
|
|
flush=True,
|
|
)
|
|
if out is not None:
|
|
append_jsonl_record(out, validated, dry_run=batch.dry_run)
|
|
print(f"[CaptionForge VLM Validator] [{idx}/{total}] Wrote record to: {out}", flush=True)
|
|
if sidecar_dir is not None:
|
|
sidecar_path = write_readable_sidecar(validated, sidecar_dir, dry_run=batch.dry_run)
|
|
print(f"[CaptionForge VLM Validator] [{idx}/{total}] Wrote readable sidecar to: {sidecar_path}", flush=True)
|
|
|
|
return result
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# CLI
|
|
# -----------------------------------------------------------------------------
|
|
|
|
|
|
def build_arg_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="CaptionForge VLM validation/pruning/correction engine for B_DISTILL JSONL."
|
|
)
|
|
parser.add_argument("--input-jsonl", required=True, help="Path to B_DISTILL distiller JSONL.")
|
|
parser.add_argument("--image-root", default="", help="Original image directory/root used to resolve image paths.")
|
|
parser.add_argument("--output-jsonl", default="", help="Path to VLM validated JSONL. Defaults beside input.")
|
|
parser.add_argument("--readable-sidecar-dir", default="", help="Directory for readable .txt sidecars. Defaults beside output.")
|
|
parser.add_argument("--no-readable-sidecars", action="store_true", help="Disable readable sidecar writing.")
|
|
|
|
parser.add_argument("--trigger-word", default="", help="Override trigger word. If omitted, inherited from distiller records.")
|
|
parser.add_argument("--user-caption-anchor", default="", help="Override user caption anchor. If omitted, inherited from distiller records.")
|
|
parser.add_argument("--vlm-validator-prompt", default="", help="Override the user-editable validator/richness prompt.")
|
|
|
|
parser.add_argument("--vlm-backend", default="ollama", choices=["ollama", "manual_json", "prompt_only"], help="VLM backend.")
|
|
parser.add_argument("--vlm-model", default="", help="VLM model name. For Ollama, e.g. llama3.2-vision:11b.")
|
|
parser.add_argument("--limit-images", type=int, default=0, help="Process only first N distiller records. 0 = no limit.")
|
|
parser.add_argument("--image-key-filter", default="", help="Process only image_keys that exactly match or contain this string.")
|
|
parser.add_argument("--dry-run", action="store_true", help="Run without writing output JSONL; prints JSONL result.")
|
|
parser.add_argument("--append", action="store_true", help="Append output JSONL instead of overwriting.")
|
|
parser.add_argument("--resume", action="store_true", help="Preserve existing successful records and rerun only missing/error/flagged keys.")
|
|
parser.add_argument("--no-rerun-errors", action="store_true", help="In --resume mode, preserve existing error records instead of rerunning them.")
|
|
parser.add_argument("--rerun-image-key", action="append", default=[], help="Force rerun for one image_key in --resume mode. May repeat or comma-separate.")
|
|
parser.add_argument("--allow-missing-images", action="store_true", help="Do not fail immediately when images cannot be resolved. Useful with prompt_only/manual_json.")
|
|
|
|
parser.add_argument("--max-input-caption-chars", type=int, default=2400, help="Max chars per input distiller caption in prompt.")
|
|
parser.add_argument("--max-output-caption-chars", type=int, default=1800, help="Max chars retained per validated caption.")
|
|
parser.add_argument("--max-audit-note-chars", type=int, default=260, help="Max chars retained per audit note/correction.")
|
|
parser.add_argument("--preserve-raw-vlm-response", action="store_true", help="Store raw VLM response in output JSONL.")
|
|
|
|
parser.add_argument("--manual-json-path", default="", help="JSON/JSONL file containing manual VLM responses for manual_json backend.")
|
|
parser.add_argument("--write-prompt-jsonl", action="store_true", help="Write prompt bundles for review/debugging.")
|
|
parser.add_argument("--prompt-jsonl", default="", help="Prompt bundle JSONL path. Defaults beside input.")
|
|
|
|
parser.add_argument("--ollama-base-url", default="http://127.0.0.1:11434", help="Ollama server URL.")
|
|
parser.add_argument("--ollama-timeout-sec", type=int, default=900, help="Ollama request timeout in seconds.")
|
|
parser.add_argument("--ollama-keep-alive", default="10m", help="Ollama keep_alive value.")
|
|
parser.add_argument("--ollama-num-predict", type=int, default=2200, help="Ollama num_predict cap. Default tuned slightly richer than the first smoke test.")
|
|
parser.add_argument("--ollama-temperature", type=float, default=0.0, help="Ollama sampling temperature.")
|
|
parser.add_argument("--ollama-top-p", type=float, default=0.92, help="Ollama top_p sampling cap.")
|
|
parser.add_argument("--ollama-top-k", type=int, default=80, help="Ollama top_k sampling cap. Higher than the first validator test for modestly richer captions.")
|
|
parser.add_argument("--ollama-seed", type=int, default=-1, help="Optional Ollama seed. -1 omits seed.")
|
|
parser.add_argument("--ollama-format-mode", default="schema", choices=["json", "schema"], help="Ollama response format mode.")
|
|
return parser
|
|
|
|
|
|
def main(argv: Optional[list[str]] = None) -> int:
|
|
args = build_arg_parser().parse_args(argv)
|
|
|
|
config = VLMValidatorConfig(
|
|
vlm_backend=args.vlm_backend,
|
|
vlm_model=args.vlm_model,
|
|
vlm_validator_prompt=args.vlm_validator_prompt or VLMValidatorConfig().vlm_validator_prompt,
|
|
trigger_word=args.trigger_word,
|
|
user_caption_anchor=args.user_caption_anchor,
|
|
preserve_raw_vlm_response=bool(args.preserve_raw_vlm_response),
|
|
max_input_caption_chars=max(0, int(args.max_input_caption_chars)),
|
|
max_output_caption_chars=max(0, int(args.max_output_caption_chars)),
|
|
max_audit_note_chars=max(0, int(args.max_audit_note_chars)),
|
|
allow_missing_images=bool(args.allow_missing_images),
|
|
ollama_base_url=args.ollama_base_url,
|
|
ollama_timeout_sec=int(args.ollama_timeout_sec),
|
|
ollama_keep_alive=args.ollama_keep_alive,
|
|
ollama_num_predict=int(args.ollama_num_predict),
|
|
ollama_temperature=float(args.ollama_temperature),
|
|
ollama_top_p=float(args.ollama_top_p),
|
|
ollama_top_k=int(args.ollama_top_k),
|
|
ollama_seed=int(args.ollama_seed),
|
|
ollama_format_mode=args.ollama_format_mode,
|
|
)
|
|
|
|
output_jsonl = args.output_jsonl or str(default_output_path(args.input_jsonl))
|
|
batch = BatchVLMValidatorConfig(
|
|
input_jsonl=args.input_jsonl,
|
|
image_root=args.image_root,
|
|
output_jsonl=output_jsonl,
|
|
readable_sidecar_dir=args.readable_sidecar_dir,
|
|
write_jsonl=True,
|
|
write_readable_sidecars=not bool(args.no_readable_sidecars),
|
|
dry_run=bool(args.dry_run),
|
|
limit_images=int(args.limit_images),
|
|
overwrite=not bool(args.append),
|
|
resume=bool(args.resume),
|
|
rerun_errors=not bool(args.no_rerun_errors),
|
|
rerun_image_keys=list(args.rerun_image_key or []),
|
|
image_key_filter=args.image_key_filter,
|
|
manual_json_path=args.manual_json_path,
|
|
write_prompt_jsonl=bool(args.write_prompt_jsonl),
|
|
prompt_jsonl=args.prompt_jsonl,
|
|
)
|
|
|
|
result = extract_validate_batch(batch, config)
|
|
print(
|
|
f"[CaptionForge VLM Validator] processed={result.processed}, skipped={result.skipped}, "
|
|
f"failed={result.failed}, output={output_jsonl}",
|
|
flush=True,
|
|
)
|
|
if args.dry_run:
|
|
print(result.jsonl_text)
|
|
return 0 if result.failed == 0 else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# CaptionForge Experimental Reversed Pipeline: Pass B VLM Cleaner
|
|
# =============================================================================
|
|
|
|
CLEANER_PASS = "B_VLM_CLEANED"
|
|
|
|
|
|
def _cf_cleaner_get_raw_caption(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(record.get("caption") or record.get("raw_caption") or "")
|
|
|
|
|
|
def _cf_cleaner_pass_a_usable(record: dict[str, Any]) -> bool:
|
|
pass_name = str(record.get("captionforge_pass") or "").upper()
|
|
if pass_name not in {"", "A", "A_RAW_CAPTIONS"}:
|
|
return False
|
|
status = str(record.get("status") or "ok").lower()
|
|
if status != "ok":
|
|
return False
|
|
return bool(_cf_cleaner_get_raw_caption(record))
|
|
|
|
|
|
def _cf_cleaner_record_key(record: dict[str, Any]) -> str:
|
|
return normalize_whitespace(record.get("image_key") or record.get("image") or record.get("source") or "")
|
|
|
|
|
|
def build_cleaner_response_schema() -> dict[str, Any]:
|
|
return {
|
|
"type": "object",
|
|
"properties": {
|
|
"cleaned_caption": {"type": "string"},
|
|
"removed_or_rejected_details": {"type": "array", "items": {"type": "string"}},
|
|
"corrected_details": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"from": {"type": "string"},
|
|
"to": {"type": "string"},
|
|
"reason": {"type": "string"},
|
|
},
|
|
"required": ["from", "to"],
|
|
"additionalProperties": True,
|
|
},
|
|
},
|
|
"uncertain_details": {"type": "array", "items": {"type": "string"}},
|
|
"visual_validation_notes": {"type": "array", "items": {"type": "string"}},
|
|
},
|
|
"required": [
|
|
"cleaned_caption",
|
|
"removed_or_rejected_details",
|
|
"corrected_details",
|
|
"uncertain_details",
|
|
"visual_validation_notes",
|
|
],
|
|
"additionalProperties": True,
|
|
}
|
|
|
|
|
|
DEFAULT_VLM_CLEANER_PROMPT = (
|
|
"You are CaptionForge Pass B: an image-grounded caption cleaner. "
|
|
"You receive one image and one raw caption written by another image captioning model. "
|
|
"Your job is not to summarize, improve style, write a new caption, or make the caption shorter. "
|
|
"Your job is to check every visual statement in the caption against the image. "
|
|
"Keep statements that are clearly supported by the image. Delete statements that are false, contradicted, "
|
|
"not visible, or too speculative. Correct small wording errors only when the intended visible detail is obvious. "
|
|
"Do not add new visual details. Do not compress the caption. Do not replace specific details with generic phrases. "
|
|
"Preserve the original level of detail whenever supported. Output only the cleaned caption and audit fields."
|
|
)
|
|
|
|
|
|
def build_cleaner_prompt_bundle(record: dict[str, Any], image_path: Optional[Path], config: VLMValidatorConfig) -> dict[str, Any]:
|
|
raw_caption = truncate_text(_cf_cleaner_get_raw_caption(record), int(config.max_input_caption_chars))
|
|
image_key = _cf_cleaner_record_key(record)
|
|
instruction = normalize_whitespace(config.vlm_validator_prompt or DEFAULT_VLM_CLEANER_PROMPT)
|
|
if "image-grounded caption cleaner" not in instruction.lower() and "caption cleaner" not in instruction.lower():
|
|
instruction = DEFAULT_VLM_CLEANER_PROMPT
|
|
|
|
payload = {
|
|
"image_key": image_key,
|
|
"source_model_family": str(record.get("model_family") or ""),
|
|
"source_model_name": str(record.get("model_name") or ""),
|
|
"source_ensemble_run_index": record.get("ensemble_run_index", 0),
|
|
"raw_caption": raw_caption,
|
|
"required_output": build_cleaner_response_schema(),
|
|
}
|
|
prompt = (
|
|
f"{instruction}\n\n"
|
|
"Return strict JSON only. No markdown. No prose outside JSON.\n\n"
|
|
"CLEANING RULES:\n"
|
|
"1. Work statement by statement through the raw caption.\n"
|
|
"2. Keep each supported statement with as much original specificity as possible.\n"
|
|
"3. Delete unsupported, false, contradicted, invisible, or speculative statements.\n"
|
|
"4. Correct a statement only when the correction is obvious from the image.\n"
|
|
"5. Do not add details that are absent from the raw caption, even if visible in the image.\n"
|
|
"6. Do not summarize or shorten supported detail.\n"
|
|
"7. Do not replace concrete details with generic phrases.\n"
|
|
"8. The cleaned_caption should be one coherent paragraph, but it may remain detailed.\n\n"
|
|
"Input JSON:\n"
|
|
f"{json.dumps(payload, ensure_ascii=False, indent=2)}\n"
|
|
)
|
|
image_info = {
|
|
"resolved_path": str(image_path) if image_path else "",
|
|
"exists": bool(image_path and image_path.exists()),
|
|
"mime_type": image_mime_type(image_path) if image_path else "",
|
|
"size_bytes": image_path.stat().st_size if image_path and image_path.exists() else 0,
|
|
"sha1": sha1_file(image_path) if image_path and image_path.exists() else "",
|
|
}
|
|
return {
|
|
"captionforge_pass": "B_VLM_CLEANER_PROMPT",
|
|
"prompt_schema_version": "captionforge-vlm-cleaner-prompt-v0.1.0",
|
|
"image_key": image_key,
|
|
"image": str(record.get("image") or ""),
|
|
"image_info": image_info,
|
|
"prompt": prompt,
|
|
"prompt_payload": payload,
|
|
"prompt_sha1": sha1_text(prompt),
|
|
"timestamp": iso_timestamp(),
|
|
}
|
|
|
|
|
|
def _cf_parse_cleaner_json(raw_response: str) -> dict[str, Any]:
|
|
obj = extract_json_object(raw_response)
|
|
cleaned = normalize_whitespace(
|
|
obj.get("cleaned_caption")
|
|
or obj.get("validated_caption")
|
|
or obj.get("caption")
|
|
or obj.get("final_caption")
|
|
or ""
|
|
)
|
|
if not cleaned:
|
|
raise VLMResponseParseError("Cleaner response did not contain cleaned_caption.", raw_response)
|
|
return {
|
|
"cleaned_caption": cleaned,
|
|
"removed_or_rejected_details": coerce_string_list(obj.get("removed_or_rejected_details") or obj.get("removed_details") or obj.get("rejected_details") or []),
|
|
"corrected_details": coerce_corrections(obj.get("corrected_details") or obj.get("corrections") or []),
|
|
"uncertain_details": coerce_string_list(obj.get("uncertain_details") or []),
|
|
"visual_validation_notes": coerce_string_list(obj.get("visual_validation_notes") or obj.get("validation_notes") or []),
|
|
}
|
|
|
|
|
|
def _cf_cleaner_record(
|
|
*,
|
|
source_record: dict[str, Any],
|
|
image_path: Optional[Path],
|
|
config: VLMValidatorConfig,
|
|
prompt_bundle: dict[str, Any],
|
|
parsed: dict[str, Any],
|
|
raw_response: str,
|
|
status: str,
|
|
error: str = "",
|
|
elapsed_sec: float = 0.0,
|
|
) -> dict[str, Any]:
|
|
raw_caption = _cf_cleaner_get_raw_caption(source_record)
|
|
cleaned = normalize_whitespace(parsed.get("cleaned_caption") or "")
|
|
return {
|
|
"captionforge_pass": CLEANER_PASS,
|
|
"engine": "CaptionForge VLM Validator Engine",
|
|
"engine_version": getattr(config, "engine_version", CAPTIONFORGE_VERSION),
|
|
"contract": "vlm_statement_cleaner_v0.1",
|
|
"image_key": _cf_cleaner_record_key(source_record),
|
|
"image": str(source_record.get("image") or ""),
|
|
"image_resolved_path": str(image_path) if image_path else "",
|
|
"status": status,
|
|
"model_family": str(source_record.get("model_family") or ""),
|
|
"model_name": str(source_record.get("model_name") or ""),
|
|
"ensemble_run_index": int(source_record.get("ensemble_run_index") or 0),
|
|
"raw_caption": raw_caption,
|
|
"caption": cleaned,
|
|
"cleaned_caption": cleaned,
|
|
"cleaned_caption_narrative": cleaned,
|
|
"cleaned_caption_comma": normalize_comma_caption(cleaned),
|
|
"removed_or_rejected_details": parsed.get("removed_or_rejected_details", []),
|
|
"corrected_details": parsed.get("corrected_details", []),
|
|
"uncertain_details": parsed.get("uncertain_details", []),
|
|
"visual_validation_notes": parsed.get("visual_validation_notes", []),
|
|
"source_caption_record": source_record,
|
|
"params": {
|
|
"vlm_backend": config.vlm_backend,
|
|
"vlm_model": config.vlm_model,
|
|
"ollama_num_predict": config.ollama_num_predict,
|
|
"ollama_temperature": config.ollama_temperature,
|
|
"ollama_top_p": config.ollama_top_p,
|
|
"ollama_top_k": config.ollama_top_k,
|
|
"ollama_seed": int(getattr(config, "ollama_seed", -1)),
|
|
},
|
|
"prompt_sha1": prompt_bundle.get("prompt_sha1", ""),
|
|
"raw_vlm_response": raw_response if config.preserve_raw_vlm_response else "",
|
|
"metrics": {
|
|
"raw_caption_chars": len(raw_caption),
|
|
"cleaned_caption_chars": len(cleaned),
|
|
"removed_count": len(parsed.get("removed_or_rejected_details", [])),
|
|
"corrected_count": len(parsed.get("corrected_details", [])),
|
|
"raw_response_chars": len(raw_response or ""),
|
|
"elapsed_sec": round(float(elapsed_sec), 3),
|
|
},
|
|
"timestamp": iso_timestamp(),
|
|
"error": error,
|
|
}
|
|
|
|
|
|
def extract_clean_batch(batch: BatchVLMValidatorConfig, config: Optional[VLMValidatorConfig] = None) -> BatchVLMValidatorResult:
|
|
"""Experimental reversed-pipeline Pass B: clean each raw Pass A caption against the image.
|
|
|
|
Input: A_RAW_CAPTIONS JSONL, one record per raw caption.
|
|
Output: B_VLM_CLEANED JSONL, one record per input caption. The VLM is deliberately
|
|
narrow: it deletes/corrects unsupported statements without adding new details or compressing.
|
|
"""
|
|
config = config or VLMValidatorConfig()
|
|
records = [r for r in read_jsonl(batch.input_jsonl) if _cf_cleaner_pass_a_usable(r)]
|
|
if batch.image_key_filter:
|
|
records = [r for r in records if _cf_cleaner_record_key(r) == batch.image_key_filter]
|
|
if batch.limit_images and batch.limit_images > 0:
|
|
# Limit by image group count rather than raw-record count.
|
|
allowed: list[str] = []
|
|
for rec in records:
|
|
key = _cf_cleaner_record_key(rec)
|
|
if key not in allowed:
|
|
allowed.append(key)
|
|
if len(allowed) >= int(batch.limit_images):
|
|
break
|
|
records = [r for r in records if _cf_cleaner_record_key(r) in set(allowed)]
|
|
|
|
out_path = Path(batch.output_jsonl) if batch.output_jsonl else default_output_path(batch.input_jsonl)
|
|
prompt_path = Path(batch.prompt_jsonl) if batch.prompt_jsonl else default_prompt_output_path(out_path)
|
|
sidecar_dir = Path(batch.readable_sidecar_dir) if batch.readable_sidecar_dir else default_sidecar_dir(out_path)
|
|
|
|
if batch.write_jsonl and batch.overwrite and not batch.dry_run:
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
out_path.write_text("", encoding="utf-8")
|
|
if batch.write_prompt_jsonl and batch.overwrite and not batch.dry_run:
|
|
prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
|
prompt_path.write_text("", encoding="utf-8")
|
|
if batch.write_readable_sidecars and not batch.dry_run:
|
|
sidecar_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
result = BatchVLMValidatorResult()
|
|
total = len(records)
|
|
print(f"[CaptionForge VLM Cleaner] Found {total} raw caption record(s).", flush=True)
|
|
|
|
old_format = str(getattr(config, "ollama_format_mode", "json") or "json")
|
|
config.ollama_format_mode = "json"
|
|
try:
|
|
for i, rec in enumerate(records, start=1):
|
|
key = _cf_cleaner_record_key(rec)
|
|
image_path = resolve_image_path(rec, batch.image_root)
|
|
print(f"[{i}/{total}] CLEAN {key} | {rec.get('model_family','')} run {rec.get('ensemble_run_index',0)}", flush=True)
|
|
if image_path is None and config.vlm_backend == "ollama" and not config.allow_missing_images:
|
|
parsed = {"cleaned_caption": ""}
|
|
out = _cf_cleaner_record(
|
|
source_record=rec, image_path=None, config=config, prompt_bundle={}, parsed=parsed,
|
|
raw_response="", status="error", error=f"Image could not be resolved for {key}", elapsed_sec=0.0,
|
|
)
|
|
else:
|
|
prompt_bundle = build_cleaner_prompt_bundle(rec, image_path, config)
|
|
if batch.write_prompt_jsonl:
|
|
append_jsonl_record(prompt_path, prompt_bundle, dry_run=batch.dry_run)
|
|
if config.vlm_backend == "prompt_only":
|
|
parsed = {"cleaned_caption": _cf_cleaner_get_raw_caption(rec), "visual_validation_notes": ["prompt_only backend; caption was not image-cleaned."]}
|
|
raw = ""
|
|
elapsed = 0.0
|
|
status = "prompt_only"
|
|
error = ""
|
|
else:
|
|
t0 = time.time()
|
|
raw = ollama_generate_vlm_response(prompt_bundle, image_path, config) if config.vlm_backend == "ollama" else ""
|
|
elapsed = time.time() - t0
|
|
parsed = _cf_parse_cleaner_json(raw)
|
|
status = "ok"
|
|
error = ""
|
|
out = _cf_cleaner_record(
|
|
source_record=rec, image_path=image_path, config=config, prompt_bundle=prompt_bundle, parsed=parsed,
|
|
raw_response=raw, status=status, error=error, elapsed_sec=elapsed,
|
|
)
|
|
result.records.append(out) # type: ignore[arg-type]
|
|
result.failed += int(out.get("status") not in {"ok", "prompt_only"})
|
|
if batch.write_jsonl:
|
|
append_jsonl_record(out_path, out, dry_run=batch.dry_run)
|
|
if batch.write_readable_sidecars and not batch.dry_run:
|
|
stem = stable_filename_from_image_key(f"{key}_{rec.get('model_family','model')}_{rec.get('ensemble_run_index',0)}")
|
|
(sidecar_dir / stem).write_text(json.dumps(json_safe(out), ensure_ascii=False, indent=2), encoding="utf-8")
|
|
finally:
|
|
config.ollama_format_mode = old_format
|
|
|
|
print(
|
|
f"[CaptionForge VLM Cleaner] Done. ok={result.processed} failed={result.failed} output={out_path}",
|
|
flush=True,
|
|
)
|
|
return result
|