"""Real LLM provider contract: strict answers, batching safety, cache, metrics attribution.""" import json from sqlalchemy import create_engine, select from sqlalchemy.orm import Session from central.app.models import Base, AiEvalCache def _item(work_item_id="w1", candidates=None): from central.app.ai.llm_provider import LlmEvalItem return LlmEvalItem(work_item_id=work_item_id, description="AGUA MINERAL 500ML", normalized_description="AGUA MINERAL 500ML", brand=None, unit="ML", segment="mercado", candidates=candidates or [("22011000", "AGUAS MINERAIS", 0.9), ("22019000", "AGUAS OUTRAS", 0.4)]) def _answer(work_item_id="w1", ncm="22011000", confidence=0.9, abstain=False): return {"work_item_id": work_item_id, "selected_ncm": ncm, "confidence": confidence, "abstain": abstain, "reason_short": "desc match"} def test_validate_answer_accepts_only_candidate_or_abstain(): from central.app.ai.llm_provider import LlmAnswerError, validate_answer item = _item() assert validate_answer(_answer(), item)["selected_ncm"] == "22011000" abstain = validate_answer(_answer(ncm=None, abstain=True), item) assert abstain["abstain"] is True and abstain["selected_ncm"] is None for bad in (_answer(ncm="99999999"), _answer(work_item_id="other"), _answer(ncm=None), _answer(confidence=1.5), _answer(ncm="22011000", abstain=True)): try: validate_answer(bad, item) raise AssertionError("must reject") except LlmAnswerError: pass def test_provider_batch_requires_1_to_1_answers(): from central.app.ai.llm_provider import LlmAnswerError, OpenAICompatibleProvider def transport(payload): content = json.dumps([_answer(), _answer()]) # two answers for one item return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5}} provider = OpenAICompatibleProvider(api_key="test-key", model="m", transport=transport) try: provider.select_batch([_item()]) raise AssertionError("must reject") except LlmAnswerError: pass assert provider.http_calls == 1 # contract violations are NOT retried def test_provider_retries_transport_failures_and_counts_usage(): from central.app.ai.llm_provider import OpenAICompatibleProvider calls = {"n": 0} def transport(payload): calls["n"] += 1 if calls["n"] == 1: raise ConnectionError("boom") content = json.dumps([_answer()]) return {"choices": [{"message": {"content": content}}], "usage": {"prompt_tokens": 100, "completion_tokens": 20, "prompt_tokens_details": {"cached_tokens": 50}}} provider = OpenAICompatibleProvider(api_key="k", model="m", transport=transport) answers = provider.select_batch([_item()]) assert answers[0]["selected_ncm"] == "22011000" assert provider.http_calls == 2 and provider.retries == 1 assert provider.usage == {"input_tokens": 100, "output_tokens": 20, "cached_tokens": 50} def test_prompt_hides_gold_and_carries_candidates(): from central.app.ai.llm_provider import candidate_set_hash, input_fingerprint, item_prompt_payload item = _item() payload = item_prompt_payload(item) assert "22011000" in json.dumps(payload) # candidates present assert item.normalized_description in json.dumps(payload) assert len(candidate_set_hash(item)) == 64 and len(input_fingerprint(item)) == 64 changed = _item(candidates=[("11111111", "X", 0.1)]) assert candidate_set_hash(changed) != candidate_set_hash(item) def test_eval_cache_roundtrip_deduplicates(): engine = create_engine("sqlite://") Base.metadata.create_all(engine) with Session(engine) as session: row = AiEvalCache(input_fingerprint="f", candidate_set_hash="c", prompt_version="p", model="m", dataset_version_ncm="d", phase="gold", answer={"selected_ncm": "22011000"}, gold_ncm="22011000") session.add(row); session.commit() existing = session.scalar(select(AiEvalCache).where(AiEvalCache.input_fingerprint == "f", AiEvalCache.candidate_set_hash == "c", AiEvalCache.prompt_version == "p", AiEvalCache.model == "m", AiEvalCache.dataset_version_ncm == "d")) assert existing.answer["selected_ncm"] == "22011000" def test_metrics_and_error_attribution(): from central.app.ai.eval_metrics import attribution, confidence_buckets, metrics rows = [ {"gold_in_candidates": True, "selected": "A", "gold": "A", "confidence": 0.99, "invalid": False}, {"gold_in_candidates": True, "selected": "B", "gold": "A", "confidence": 0.97, "invalid": False}, {"gold_in_candidates": True, "selected": None, "gold": "A", "confidence": 0.5, "invalid": False}, {"gold_in_candidates": False, "selected": "B", "gold": "A", "confidence": 0.9, "invalid": False}, {"gold_in_candidates": True, "selected": "zzz", "gold": "A", "confidence": 0.9, "invalid": True}, ] m = metrics(rows) assert m["TOTAL"] == 5 and m["CORRECT"] == 1 and m["WRONG"] == 2 and m["ABSTAIN"] == 1 and m["INVALID"] == 1 assert m["GOLD_IN_CANDIDATES"] == 4 assert abs(m["SELECTION_ACCURACY_WHEN_GOLD_AVAILABLE"] - 1 / 3) < 1e-4 # correct / (correct+wrong) among valid answers assert abs(m["ABSTENTION_RATE"] - 0.2) < 1e-9 a = attribution(rows) assert a["GOLD_NOT_IN_CANDIDATES"] == 1 and a["GOLD_IN_CANDIDATES_BUT_WRONG"] == 1 and a["GOLD_IN_CANDIDATES_BUT_ABSTAIN"] == 1 buckets = confidence_buckets(rows) assert buckets[">=0.99"] == {"total": 1, "correct": 1} and buckets["0.95-0.9899"] == {"total": 1, "correct": 0} def test_zai_model_selection_rules(): import importlib.util spec = importlib.util.spec_from_file_location("eval_runner", "central/scripts/run_real_llm_ncm_eval.py") module = importlib.util.module_from_spec(spec) spec.loader.exec_module(module) available = ["glm-5.3", "glm-5.3-flash", "glm-4.7", "other-vendor-x"] assert module._select_model(available, "glm-5.3") == ("glm-5.3", "requested") # requested missing -> current GLM flash fallback, never another vendor, never invented assert module._select_model(available, "glm-9.9") == ("glm-5.3-flash", "fallback_glm_flash") only_generic = ["glm-4", "glm-4.7"] assert module._select_model(only_generic, "glm-9.9") == ("glm-4.7", "fallback_glm_latest") assert module._select_model(["gpt-x", "claude-y"], "glm-5.3") == (None, "no_glm_available") assert module._diagnose_http(401) == "credential invalid (AI_API_KEY)" def test_budget_guard_unknown_cost_limited_run(): from central.app.ai.provider import BudgetGuard guard = BudgetGuard(max_run_cost_usd=5.0, price_in_per_m=None, price_out_per_m=None) plan = guard.estimate(1000, 100) assert plan["estimated_cost"] is None and plan["cost_unknown"] is True assert guard.authorize(plan) is False # residual runs refused without prices assert guard.authorize(plan, allow_unknown_cost=True) is True # explicitly limited GOLD benchmark may opt in