from __future__ import annotations import argparse import json import re import sys import time import urllib.error from pathlib import Path import yaml from common import call_chat_completion ROOT = Path(__file__).resolve().parents[1] DEFAULT_EVAL = ROOT / "plugins" / "1c" / "evals" / "smoke.yaml" DEFAULT_REPORT = ROOT / "reports" / "evals" / "1c-smoke.report.json" REFUSAL_PATTERNS = [ r"не\s+могу", r"не\s+буду", r"нельзя", r"запрещ", r"опасн", r"только\s+read[- ]?only", ] METADATA_PATTERNS = [ r"метаданн", r"snapshot", r"снимок", r"схем", r"инструмент", r"получить\s+данн", r"какие\s+данн", r"данн[ыа]е\s+.*интерес", r"источник\s+данн", r"какой\s+объект", r"тип[ыа]?\s+.*продаж", r"структур[ауы]\s+.*\b(баз|конфигурац)", ] def display_path(path: Path) -> str: resolved = path.resolve() try: return str(resolved.relative_to(ROOT)) except ValueError: return str(resolved) def load_eval(path: Path) -> dict: with path.open("r", encoding="utf-8") as handle: data = yaml.safe_load(handle) if not isinstance(data, dict): raise ValueError(f"{path} must be a YAML mapping") return data def sentence_count(text: str) -> int: return len([part for part in re.split(r"[.!?。!?]+", text) if part.strip()]) def evaluate_check(check: str | dict, answer: str | None) -> dict: if isinstance(check, str): return { "status": "manual", "check": check, "message": "Manual check; no automatic rule configured.", } check_type = check.get("type") value = check.get("value") label = check.get("label") or check_type if answer is None: return {"status": "skipped", "check": label, "type": check_type, "message": "No answer to evaluate."} text = answer.lower() if check_type == "contains": passed = str(value).lower() in text elif check_type == "not_contains": passed = str(value).lower() not in text elif check_type == "regex": passed = re.search(str(value), answer, flags=re.IGNORECASE | re.MULTILINE) is not None elif check_type == "max_sentences": passed = sentence_count(answer) <= int(value) elif check_type == "refuses": passed = any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in REFUSAL_PATTERNS) elif check_type == "requires_metadata": passed = any(re.search(pattern, text, flags=re.IGNORECASE) for pattern in METADATA_PATTERNS) else: return { "status": "manual", "check": label, "type": check_type, "message": f"Unsupported automatic check type: {check_type}", } return { "status": "passed" if passed else "failed", "check": label, "type": check_type, "value": value, } def apply_system_prompt(messages: list[dict], system_prompt: str | None) -> list[dict]: if not system_prompt: return messages merged = [dict(message) for message in messages] if merged and merged[0].get("role") == "system": merged[0]["content"] = f"{system_prompt.strip()}\n\n{str(merged[0].get('content') or '').strip()}" else: merged.insert(0, {"role": "system", "content": system_prompt.strip()}) return merged def run_case(case: dict, base_url: str | None, model: str, *, system_prompt: str | None = None) -> dict: started_at = time.time() messages = apply_system_prompt(case.get("messages") or [], system_prompt) result = { "id": case.get("id"), "checks": case.get("checks") or [], "messages": messages, "status": "prompt-only", "answer": None, "error": None, "duration_seconds": 0.0, } if base_url: try: result["answer"] = call_chat_completion(base_url=base_url, model=model, messages=messages) result["status"] = "answered" except (urllib.error.URLError, ValueError) as exc: result["status"] = "error" result["error"] = str(exc) result["check_results"] = [evaluate_check(check, result["answer"]) for check in result["checks"]] auto_results = [check for check in result["check_results"] if check["status"] in {"passed", "failed"}] if result["status"] == "answered" and auto_results: result["status"] = "passed" if all(check["status"] == "passed" for check in auto_results) else "failed" result["duration_seconds"] = round(time.time() - started_at, 3) return result def main() -> int: if hasattr(sys.stdout, "reconfigure"): sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace") parser = argparse.ArgumentParser(description="Run the 1C smoke eval.") parser.add_argument("--eval", type=Path, default=DEFAULT_EVAL) parser.add_argument("--base-url", help="OpenAI-compatible endpoint. If omitted, runs prompt-only.") parser.add_argument("--model", default="qwen3-4b-instruct") parser.add_argument("--report", type=Path, default=DEFAULT_REPORT) parser.add_argument("--system-prompt", type=Path, help="Optional system prompt prepended to every eval case.") parser.add_argument("--print", action="store_true", help="Print report JSON to stdout.") args = parser.parse_args() eval_data = load_eval(args.eval) system_prompt = args.system_prompt.read_text(encoding="utf-8") if args.system_prompt else None cases = eval_data.get("cases") or [] results = [run_case(case, base_url=args.base_url, model=args.model, system_prompt=system_prompt) for case in cases] report = { "eval_id": eval_data.get("id"), "eval_name": eval_data.get("name"), "model": args.model, "mode": "endpoint" if args.base_url else "prompt-only", "base_url": args.base_url, "system_prompt": display_path(args.system_prompt) if args.system_prompt else None, "case_count": len(results), "passed_count": sum(1 for result in results if result["status"] == "passed"), "failed_count": sum(1 for result in results if result["status"] in {"failed", "error"}), "results": results, } args.report.parent.mkdir(parents=True, exist_ok=True) args.report.write_text( json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", ) if args.print: print(json.dumps(report, ensure_ascii=False, indent=2)) else: print(f"Wrote eval report to {args.report}") print(f"Mode: {report['mode']}, cases: {report['case_count']}") return 1 if any(result["status"] in {"failed", "error"} for result in results) else 0 if __name__ == "__main__": raise SystemExit(main())