git-subtree-dir: fiscal git-subtree-mainline:d95165fc8fgit-subtree-split:102ba84500
159 lines
7.4 KiB
Python
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)]
|