Files
Conflict_Checker/backend/llm.py
T
woogi 76e0a52658
Docker Release / build-and-push (push) Successful in 1m1s
Docker Release / release (push) Skipped
Fix sheet-extraction page loss: bare-list wrap, compact retry, reasoning cap
- SheetExtractorAgent accepts top-level array responses as the objects
  array instead of discarding them (recovered the failure mode behind
  16/38 failed sheets on job 475a6f184dd1)
- Second-chance compact retry per page before declaring extraction failed
- call_json: reasoning_effort param (cloud-only extra_body), finish_reason
  capture + explicit max_tokens log line, finish_reason in raw dumps,
  cache key covers reasoning_effort
- EXTRACT_MAX_TOKENS default 16384 -> 32768 (Gemini thinking tokens count
  against the cap); new EXTRACT_REASONING_EFFORT=low default for extract
- tests: 5 new fallback-ladder tests
2026-08-07 07:35:53 -05:00

515 lines
19 KiB
Python

"""
llm.py - Thin OpenRouter client + a JSON-returning chat helper.
Mirrors the call pattern proven in the IronBid pipeline: OpenAI SDK pointed at
OpenRouter, base64 image data URLs, markdown-fence stripping, and a single
retry on JSON parse failure. One helper serves both the extraction (Stage 1,
with an image) and conflict-reasoning (Stage 3, with images) calls.
"""
import os
import re
import json
import hashlib
import threading
from typing import List, Optional, Dict, Any
from openai import OpenAI
from backend import config
_clients: Dict[str, OpenAI] = {}
# Per-run text backend choice (hybrid). Set by the runner at job start; read by
# call_json when routing a no-image (text) call. Module-global mirrors the
# set_stage/cost pattern (single-user tool).
_text_local = False
# Per-job model overrides (user picked models in the UI). Same module-global
# pattern: set by the job runner before the pipeline starts, cleared after.
# Vision applies to image calls, text to no-image calls on OpenRouter (and to
# the local->cloud fallback). The LOCAL endpoint's model name is never taken
# from these overrides - hybrid local keeps LOCAL_TEXT_MODEL.
_vision_model_override: Optional[str] = None
_text_model_override: Optional[str] = None
# Per-job raw request/response dumps (missed/hallucinated-item debugging).
# Set by the job runner to <out_dir>/llm_raw at job start, cleared after.
# Same module-global pattern as the model overrides (single-user tool).
_raw_dump_dir: Optional[str] = None
_seq_lock = threading.Lock()
_call_seq = 0
def set_raw_dump_dir(path: Optional[str]) -> None:
"""Point raw LLM request/response dumps at a directory. None disables."""
global _raw_dump_dir, _call_seq
with _seq_lock:
_raw_dump_dir = path
_call_seq = 0
def _next_seq() -> int:
global _call_seq
with _seq_lock:
_call_seq += 1
return _call_seq
def _summarize_parsed(parsed: Any) -> str:
"""
Compact digest of a parsed response for the job log. List values become
item counts (e.g. conflicts[3]) so a stage that returned nothing (miss)
or invented items (hallucination) is visible without opening the raw dump.
"""
if isinstance(parsed, list):
return f"list[{len(parsed)}]"
if not isinstance(parsed, dict):
return type(parsed).__name__
parts = []
for k, v in parsed.items():
if isinstance(v, list):
parts.append(f"{k}[{len(v)}]")
elif isinstance(v, dict):
parts.append(f"{k}{{{len(v)}}}")
else:
s = str(v)
parts.append(f"{k}={s[:40]!r}{'...' if len(s) > 40 else ''}")
out = ", ".join(parts)
return out[:300] + ("..." if len(out) > 300 else "")
def _dump_raw(seq: int, be: Dict[str, Any], usage_stage: str,
system_prompt: str, user_text: str,
images_b64: Optional[List[str]], max_tokens: int,
raw: str, parsed: Any,
finish_reason: Optional[str] = None) -> None:
"""Write the full request/response for one call to the job's llm_raw dir."""
if not _raw_dump_dir:
return
try:
os.makedirs(_raw_dump_dir, exist_ok=True)
safe_stage = re.sub(r"[^A-Za-z0-9_.-]+", "_", usage_stage)[:40]
safe_model = re.sub(r"[^A-Za-z0-9_.-]+", "_", be["model"])
payload = {
"seq": seq,
"stage": usage_stage,
"model": be["model"],
"backend": "local" if be.get("local") else "cloud",
"max_tokens": max_tokens,
# base64 image payloads deliberately excluded (multi-MB each);
# the count + source.pdf in the job dir identify what was sent.
"n_images": len(images_b64 or []),
"system_prompt": system_prompt,
"user_text": user_text,
"finish_reason": finish_reason,
"raw_response": raw,
"parsed": parsed,
}
path = os.path.join(_raw_dump_dir, f"{seq:04d}_{safe_stage}_{safe_model}.json")
tmp = f"{path}.{threading.get_ident()}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
os.replace(tmp, path) # atomic so thread-pooled stages can't tear it
except OSError as e:
print(f"[LLM] raw dump failed: {e}")
def _log_call(seq: int, be: Dict[str, Any], usage_stage: str,
user_text: str, images_b64: Optional[List[str]],
raw: str, parsed: Any, usd: Optional[float],
cached: bool = False) -> None:
"""One verbose per-call line for the job log (tee'd by job_log.py)."""
if not config.LLM_VERBOSE:
return
backend = "local" if be.get("local") else "cloud"
if cached:
cost_str = "cache hit"
elif usd is not None:
cost_str = f"${usd:.4f}"
else:
cost_str = "cost n/a"
print(f"[LLM] #{seq:04d} {usage_stage} | {be['model']} ({backend}) | "
f"in {len(user_text)}ch+{len(images_b64 or [])}img | "
f"out {len(raw)}ch | {cost_str} | {_summarize_parsed(parsed)}")
def set_text_backend(local: bool) -> None:
"""Choose whether text (no-image) calls go to the local endpoint this run."""
global _text_local
_text_local = bool(local)
def set_model_overrides(vision: Optional[str] = None, text: Optional[str] = None) -> None:
"""Per-run OpenRouter vision/text model picks. None/blank clears to defaults."""
global _vision_model_override, _text_model_override
_vision_model_override = (vision or "").strip() or None
_text_model_override = (text or "").strip() or None
# --- per-job cost accounting -------------------------------------------------
# OpenRouter returns the real USD cost of each call when we request usage
# accounting. We accumulate it in a module-level counter; the runner resets it
# at the start of a job and reads it at the end. (Single-user tool: overlapping
# jobs would share this counter, which is acceptable here.)
_cost_lock = threading.Lock()
_cost = {"usd": 0.0, "calls": 0, "cached": 0}
_by_stage: Dict[str, Dict[str, Any]] = {}
_current_stage = "?"
# Model usage tracking - records which models were actually called this run.
_models: Dict[str, Any] = {
"vision": set(), # models used for vision (image) calls
"text_local": set(), # local model names attempted
"text_cloud": set(), # cloud text model names used
"fallback_count": 0, # times local fell back to cloud
}
def set_stage(name: str) -> None:
"""Label subsequent calls so cost can be attributed per pipeline stage."""
global _current_stage
_current_stage = name
def reset_cost() -> None:
with _cost_lock:
_cost["usd"] = 0.0
_cost["calls"] = 0
_cost["cached"] = 0
_by_stage.clear()
_models["vision"] = set()
_models["text_local"] = set()
_models["text_cloud"] = set()
_models["fallback_count"] = 0
def get_cost() -> Dict[str, Any]:
with _cost_lock:
out = dict(_cost)
out["by_stage"] = {k: dict(v) for k, v in _by_stage.items()}
out["models"] = {
"vision": sorted(_models["vision"]),
"text_local": sorted(_models["text_local"]),
"text_cloud": sorted(_models["text_cloud"]),
"fallback_count": _models["fallback_count"],
}
return out
def _record_model(be: Dict[str, Any], has_images: bool, fell_back: bool) -> None:
"""Thread-safe recording of which model was actually used for a call."""
model = be["model"]
with _cost_lock:
if has_images:
_models["vision"].add(model)
elif fell_back or not be.get("local"):
_models["text_cloud"].add(model)
else:
_models["text_local"].add(model)
def _stage_bucket() -> Dict[str, Any]:
return _by_stage.setdefault(_current_stage, {"usd": 0.0, "calls": 0, "cached": 0})
def _add_cost(usd: float) -> None:
with _cost_lock:
_cost["usd"] += usd
_cost["calls"] += 1
b = _stage_bucket()
b["usd"] += usd
b["calls"] += 1
def _add_cached() -> None:
with _cost_lock:
_cost["cached"] += 1
_stage_bucket()["cached"] += 1
# --- disk-backed response cache (testing loop; opt-in via config.LLM_CACHE) ---
def _cache_key(model: str, system_prompt: str, user_text: str,
images_b64: Optional[List[str]], max_tokens: int,
json_mode: bool, reasoning_effort: Optional[str] = None) -> str:
h = hashlib.sha256()
parts = [model, str(max_tokens), str(json_mode), str(reasoning_effort),
system_prompt, user_text]
for b in (images_b64 or []):
parts.append(b)
for p in parts:
h.update(b"\x00")
h.update(p.encode("utf-8", "ignore"))
return h.hexdigest()
def _cache_get(key: str) -> Optional[Dict[str, Any]]:
path = os.path.join(config.LLM_CACHE_DIR, key + ".json")
try:
with open(path, encoding="utf-8") as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return None
def _cache_set(key: str, value: Dict[str, Any]) -> None:
try:
os.makedirs(config.LLM_CACHE_DIR, exist_ok=True)
path = os.path.join(config.LLM_CACHE_DIR, key + ".json")
tmp = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(value, f)
os.replace(tmp, path) # atomic so thread-pooled stages can't tear it
except OSError as e:
print(f"[LLM] cache write failed: {e}")
def get_client(base_url: str, api_key: str) -> OpenAI:
"""Lazily build + cache one OpenAI-compatible client per endpoint."""
client = _clients.get(base_url)
if client is None:
client = OpenAI(base_url=base_url, api_key=api_key)
_clients[base_url] = client
return client
def _resolve_backend(has_images: bool, model_override: Optional[str]) -> Dict[str, Any]:
"""
Pick (base_url, api_key, model, usage) for this call.
Vision (has_images) always uses OpenRouter. Text (no images) uses the local
endpoint when the run chose hybrid AND it's configured; otherwise OpenRouter.
"""
if not has_images and _text_local and config.LOCAL_BASE_URL:
return {
"base_url": config.LOCAL_BASE_URL,
"api_key": config.LOCAL_API_KEY,
# Local model name comes from per-call args or LOCAL_TEXT_MODEL —
# never the UI's OpenRouter picks, which a local server won't serve.
"model": model_override or config.LOCAL_TEXT_MODEL or config.TEXT_MODEL,
"usage": False, # local has no OpenRouter usage accounting
"local": True,
}
# Vision, or text-on-OpenRouter (default / fallback). A per-job override
# (user's UI model pick) wins over per-call and env defaults.
if has_images:
model = _vision_model_override or model_override or config.MODEL
else:
model = _text_model_override or model_override or config.TEXT_MODEL
return {
"base_url": config.AI_BASE_URL,
"api_key": config.AI_API_KEY,
"model": model,
"usage": True,
"local": False,
}
def _strip_fences(raw: str) -> str:
raw = raw.strip()
if raw.startswith("```"):
# ```json ... ``` or ``` ... ```
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
raw = raw.strip()
return raw
def _repair_truncated(raw: str) -> Optional[Dict[str, Any]]:
"""
Best-effort recovery of a truncated JSON object/array.
Walks the text tracking bracket depth and string state, finds the last
point where we can safely cut (after a complete element), drops the partial
trailing element, and closes the still-open brackets. Lets a response that
got cut off by max_tokens still yield its complete leading items instead of
nothing. Returns None if nothing salvageable.
"""
stack: List[str] = []
in_str = esc = False
cut = None # (index_exclusive, stack_snapshot) of a safe truncation point
for i, ch in enumerate(raw):
if in_str:
if esc:
esc = False
elif ch == "\\":
esc = True
elif ch == '"':
in_str = False
continue
if ch == '"':
in_str = True
elif ch in "{[":
stack.append("}" if ch == "{" else "]")
elif ch in "}]":
if stack:
stack.pop()
cut = (i + 1, list(stack)) # after a complete sub-structure
elif ch == ",":
cut = (i, list(stack)) # before an (incomplete) next element
if not cut:
return None
idx, open_brackets = cut
candidate = raw[:idx].rstrip().rstrip(",")
candidate += "".join(reversed(open_brackets))
try:
return json.loads(candidate)
except json.JSONDecodeError:
return None
def _response_cost(response) -> Optional[float]:
"""Pull OpenRouter's per-call USD cost out of the usage object, if present."""
try:
dump = response.model_dump()
except Exception:
return None
usage = dump.get("usage") or {}
cost = usage.get("cost")
if cost is None:
cost = (usage.get("cost_details") or {}).get("upstream_inference_cost")
return float(cost) if isinstance(cost, (int, float)) else None
def _parse(raw: str) -> Optional[Dict[str, Any]]:
"""Parse model JSON, falling back to truncation repair."""
try:
return json.loads(raw)
except json.JSONDecodeError:
repaired = _repair_truncated(raw)
if repaired is not None:
print("[LLM] recovered truncated JSON (dropped partial trailing item)")
return repaired
def call_json(
system_prompt: str,
user_text: str,
images_b64: Optional[List[str]] = None,
max_tokens: int = 4096,
model: Optional[str] = None,
usage_tracker: Optional[Any] = None,
usage_stage: str = "?",
reasoning_effort: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
Send one chat completion expecting a JSON object back.
Uses the provider's JSON mode (response_format) so the model returns a bare
JSON object instead of prose/empty text, and recovers from max_tokens
truncation. images_b64: optional base64 JPEGs attached as high-detail image
parts. reasoning_effort: optional OpenRouter reasoning knob ("low"/"medium"/
"high") - keeps thinking models from burning the output budget on hidden
reasoning. Returns the parsed dict, or None on a hard failure (caller
degrades).
"""
has_images = bool(images_b64)
be = _resolve_backend(has_images, model)
seq = _next_seq()
cache_key = None
if config.LLM_CACHE:
cache_key = _cache_key(be["model"], system_prompt, user_text,
images_b64, max_tokens, json_mode=True,
reasoning_effort=reasoning_effort)
hit = _cache_get(cache_key)
if hit is not None:
_add_cached()
if usage_tracker:
usage_tracker.record(
usage_stage, be["model"], cached=True, has_images=has_images
)
_log_call(seq, be, usage_stage, user_text, images_b64,
"", hit, None, cached=True)
return hit
content: List[Dict[str, Any]] = []
for b64 in images_b64 or []:
content.append({
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{b64}", "detail": "high"},
})
content.append({"type": "text", "text": user_text})
messages = [
{"role": "system", "content": system_prompt},
{"role": "user", "content": content},
]
use_json_mode = True
fell_back = False
for attempt in range(2):
try:
client = get_client(be["base_url"], be["api_key"])
kwargs: Dict[str, Any] = dict(model=be["model"], messages=messages,
max_tokens=max_tokens, timeout=config.LLM_TIMEOUT)
extra_body: Dict[str, Any] = {}
if be["usage"]:
extra_body["usage"] = {"include": True}
# OpenRouter reasoning knob; only sent to cloud backends (local
# servers reject unknown fields). Skipped when effort is blank.
if reasoning_effort and not be.get("local"):
extra_body["reasoning"] = {"effort": reasoning_effort}
if extra_body:
kwargs["extra_body"] = extra_body
if use_json_mode:
kwargs["response_format"] = {"type": "json_object"}
response = client.chat.completions.create(**kwargs)
usd = _response_cost(response)
if usd is not None:
_add_cost(usd)
if usage_tracker:
usage_tracker.record(
usage_stage, be["model"], usd=usd or 0.0, has_images=has_images
)
choice = response.choices[0]
finish_reason = getattr(choice, "finish_reason", None)
raw = _strip_fences(choice.message.content or "")
if finish_reason == "length":
# Hit max_tokens (thinking tokens included on reasoning models).
# Logged explicitly so silent truncation isn't mistaken for a
# parse problem; _parse below still salvages what it can.
print(f"[LLM] output hit max_tokens (finish_reason=length, "
f"{len(raw)}ch returned)")
parsed = _parse(raw)
if parsed is not None:
_record_model(be, has_images, fell_back)
if cache_key:
_cache_set(cache_key, parsed)
_log_call(seq, be, usage_stage, user_text, images_b64,
raw, parsed, usd)
if config.LLM_RAW_DUMP:
_dump_raw(seq, be, usage_stage, system_prompt, user_text,
images_b64, max_tokens, raw, parsed,
finish_reason=finish_reason)
return parsed
if attempt == 0:
print("[LLM] JSON parse error (retrying)")
continue
print(f"[LLM] JSON parse error (giving up); raw head: {raw[:300]}")
return None
except Exception as e:
# Some models reject response_format -> drop it and retry once.
if use_json_mode and "response_format" in str(e).lower():
print("[LLM] model rejected JSON mode; retrying without it")
use_json_mode = False
continue
# Local endpoint unreachable -> fall back to OpenRouter text backend once.
if be["local"] and not fell_back:
print(f"[LLM] local endpoint failed ({e}); falling back to OpenRouter")
with _cost_lock:
_models["text_local"].add(be["model"])
_models["fallback_count"] += 1
be = {"base_url": config.AI_BASE_URL, "api_key": config.AI_API_KEY,
"model": _text_model_override or config.TEXT_MODEL,
"usage": True, "local": False}
cache_key = None # don't cache fallback under the local-model key
fell_back = True
continue
print(f"[LLM] call error: {e}")
if attempt == 0:
continue
return None
return None