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, ""