Spaces:
Running on Zero
Running on Zero
File size: 3,848 Bytes
1676aa7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 | """
Validation layer.
Every raw output from a model (VLM classification, caption, LLM JSON) passes
through here before it is allowed to become a schemas.py object. This is
where "never blindly trust malformed model output" (section 8) is enforced
in one place instead of scattered ad-hoc checks through the agent code.
"""
from __future__ import annotations
import json
import re
class ValidationError(Exception):
pass
def safe_parse_llm_json(raw_text: str, required_keys: list[str]) -> dict:
"""
LLMs (especially small ones) sometimes wrap JSON in prose or markdown
fences, or emit near-JSON with trailing commas. This extracts the first
plausible JSON object and validates required keys exist, raising
ValidationError (never a raw crash) on failure so callers can fall back.
"""
if not raw_text or not raw_text.strip():
raise ValidationError("empty model output")
# Strip markdown code fences if present
text = re.sub(r"```(?:json)?", "", raw_text).strip()
# Find the first {...} block
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
raise ValidationError(f"no JSON object found in output: {raw_text[:200]!r}")
candidate = match.group(0)
try:
data = json.loads(candidate)
except json.JSONDecodeError as e:
raise ValidationError(f"malformed JSON: {e}") from e
missing = [k for k in required_keys if k not in data]
if missing:
raise ValidationError(f"missing required keys {missing} in {data}")
return data
def clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def coerce_int(value, default: int = 0, lo: int = 0, hi: int = 999) -> int:
try:
v = int(round(float(value)))
except (TypeError, ValueError):
return default
return max(lo, min(hi, v))
def coerce_float(value, default: float = 0.0, lo: float = 0.0, hi: float = 10.0) -> float:
try:
v = float(value)
except (TypeError, ValueError):
return default
return max(lo, min(hi, v))
def coerce_need_types(value) -> list[str]:
"""Normalizes need-type lists from LLM output against the fixed vocabulary."""
from core.schemas import VALID_NEED_TYPES
if isinstance(value, str):
value = [value]
if not isinstance(value, list):
return []
out = []
for v in value:
v_norm = str(v).strip().lower()
if v_norm in VALID_NEED_TYPES:
out.append(v_norm)
return list(dict.fromkeys(out)) # dedupe, preserve order
def validate_image_file(path: str, max_bytes: int = 15 * 1024 * 1024) -> tuple[bool, str]:
"""Returns (is_valid, error_message). Never raises — callers check the bool."""
import os
if not path or not os.path.exists(path):
return False, f"image file not found: {path}"
size = os.path.getsize(path)
if size == 0:
return False, "image file is empty (0 bytes)"
if size > max_bytes:
return False, f"image file too large ({size} bytes > {max_bytes} limit)"
try:
from PIL import Image
with Image.open(path) as img:
img.verify()
except Exception as e:
return False, f"invalid or corrupt image: {e}"
return True, ""
def validate_report_text(text: str, max_chars: int = 4000) -> tuple[str, str]:
"""
Returns (cleaned_text, warning). Handles empty and very-long reports
per section 25's failure-handling requirement instead of crashing
or silently truncating without telling anyone.
"""
if text is None:
return "", "no report text provided"
text = text.strip()
if not text:
return "", "empty report text"
if len(text) > max_chars:
return text[:max_chars], f"report truncated from {len(text)} to {max_chars} characters"
return text, ""
|