Files
gti d21a9d1140 Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'
git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
2026-09-29 14:43:23 -03:00

159 lines
7.4 KiB
Python

"""Real LLM provider (OpenAI-compatible) for the NCM semantic evaluation.
Contract (mission REAL LLM EVALUATION V1):
- The API key lives ONLY in the Hub environment (AI_API_KEY). Never committed, logged,
written to artifacts, sent to the Agent or Firebird.
- The gold answer is never part of the prompt: items carry only description/brand/unit/
segment-weak-context + the official top-K hybrid candidates with hierarchical context.
- Strict structured output: per item {work_item_id, selected_ncm, confidence, abstain,
reason_short}; selected_ncm must be in the candidate list or null (abstain).
- Batching is safe only when the response is valid JSON with 1:1 work_item_id matching,
no omitted items, no cross-product mixing. Verified progressively (5/10/20).
"""
import hashlib
import json
import re
from dataclasses import dataclass
PROMPT_VERSION_LLM = "llm-ncm-eval-v1"
_SYSTEM_PROMPT = (
"You are a Brazilian customs classification assistant. For each product below, choose the "
"most likely official NCM code from the provided candidate list, or abstain. "
"Rules: selected_ncm MUST be one of the candidate codes for that exact item, or null with "
"abstain=true when evidence is insufficient. Never invent a code. confidence is your calibrated "
"probability in [0,1]. Reply with ONLY a JSON array, one object per item, in the same order, "
'each: {"work_item_id": str, "selected_ncm": str|null, "confidence": float, "abstain": bool, "reason_short": str}.'
)
@dataclass
class LlmEvalItem:
work_item_id: str
description: str
normalized_description: str
brand: str | None
unit: str | None
segment: str
candidates: list # [(code, official_description, hybrid_score, official_score, kb_score, tenant_count)]
class LlmAnswerError(ValueError):
pass
def item_prompt_payload(item: LlmEvalItem) -> dict:
return {
"work_item_id": item.work_item_id,
"description": item.description,
"normalized_description": item.normalized_description,
"brand": item.brand,
"unit": item.unit,
"segment_weak_context": item.segment,
"official_candidates": [
{"ncm": code, "official_description": text}
for code, text, *_ in item.candidates
],
}
def candidate_set_hash(item: LlmEvalItem) -> str:
codes = [code for code, *_ in item.candidates]
return hashlib.sha256("|".join(codes).encode()).hexdigest()
def input_fingerprint(item: LlmEvalItem) -> str:
payload = {**item_prompt_payload(item), "system": _SYSTEM_PROMPT}
return hashlib.sha256(json.dumps(payload, sort_keys=True, ensure_ascii=False).encode()).hexdigest()
def validate_answer(answer: dict, item: LlmEvalItem) -> dict:
"""Fail-closed validator: schema, work_item_id, candidate-list confinement."""
if not isinstance(answer, dict):
raise LlmAnswerError("answer must be an object")
if str(answer.get("work_item_id")) != str(item.work_item_id):
raise LlmAnswerError(f"work_item_id mismatch: {answer.get('work_item_id')!r} != {item.work_item_id!r}")
abstain = bool(answer.get("abstain"))
selected = answer.get("selected_ncm")
selected = None if selected in (None, "") else str(selected)
try:
confidence = float(answer.get("confidence"))
except (TypeError, ValueError):
raise LlmAnswerError("confidence must be numeric")
if not 0.0 <= confidence <= 1.0:
raise LlmAnswerError("confidence out of [0,1]")
codes = {code for code, *_ in item.candidates}
if abstain:
if selected is not None:
raise LlmAnswerError("abstain must carry selected_ncm=null")
else:
if selected is None:
raise LlmAnswerError("non-abstain requires selected_ncm")
if selected not in codes:
raise LlmAnswerError(f"selected_ncm {selected!r} is outside the candidate list")
return {"work_item_id": item.work_item_id, "selected_ncm": selected, "confidence": confidence, "abstain": abstain, "reason_short": str(answer.get("reason_short") or "")[:200]}
class OpenAICompatibleProvider:
"""Talks to any OpenAI-compatible /chat/completions endpoint. `transport` is
injectable for tests; production default uses httpx with the key from settings only."""
name = "openai-compatible"
prompt_version = PROMPT_VERSION_LLM
def __init__(self, api_key: str, model: str, base_url: str = "https://api.openai.com/v1", transport=None, timeout: float = 120.0):
self.api_key = api_key
self.model = model
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self._transport = transport or self._httpx_transport
self.http_calls = 0
self.retries = 0
self.usage = {"input_tokens": 0, "output_tokens": 0, "cached_tokens": 0}
def _httpx_transport(self, payload: dict) -> dict:
import httpx
response = httpx.post(f"{self.base_url}/chat/completions", json=payload, headers={"Authorization": f"Bearer {self.api_key}"}, timeout=self.timeout)
response.raise_for_status()
return response.json()
def list_models(self) -> list:
"""GET /models with the account key — validates auth/base URL/model availability
without spending completion tokens. Never raises with the key attached."""
import httpx
response = httpx.get(f"{self.base_url}/models", headers={"Authorization": f"Bearer {self.api_key}"}, timeout=self.timeout)
response.raise_for_status()
data = response.json()
return sorted(str(item.get("id")) for item in data.get("data", []) if item.get("id"))
def _request(self, messages: list, max_retries: int = 2) -> dict:
payload = {"model": self.model, "messages": messages, "temperature": 0}
last_error = None
for attempt in range(max_retries + 1):
self.http_calls += 1
if attempt:
self.retries += 1
try:
data = self._transport(payload)
usage = data.get("usage") or {}
self.usage["input_tokens"] += int(usage.get("prompt_tokens") or 0)
self.usage["output_tokens"] += int(usage.get("completion_tokens") or 0)
cached = usage.get("prompt_tokens_details") or {}
self.usage["cached_tokens"] += int(cached.get("cached_tokens") or 0)
return data
except Exception as error: # transport-level failure only; JSON contract errors are not retried
last_error = error
raise RuntimeError(f"LLM transport failed after {max_retries + 1} attempts: {last_error}")
def select_batch(self, items: list[LlmEvalItem]) -> list[dict]:
"""One HTTP call for the whole batch. Raises LlmAnswerError on any 1:1/schema violation
so the harness can shrink the batch size."""
user_content = json.dumps([item_prompt_payload(item) for item in items], ensure_ascii=False)
data = self._request([{"role": "system", "content": _SYSTEM_PROMPT}, {"role": "user", "content": user_content}])
content = data["choices"][0]["message"]["content"]
content = re.sub(r"^```(?:json)?|```$", "", content.strip(), flags=re.MULTILINE).strip()
answers = json.loads(content)
if not isinstance(answers, list) or len(answers) != len(items):
raise LlmAnswerError(f"expected {len(items)} answers, got {len(answers) if isinstance(answers, list) else type(answers).__name__}")
return [validate_answer(answer, item) for answer, item in zip(answers, items)]