"""Evaluate persona and route contracts after Qwen3 SFT. Reports syntax/shape validity, persona mood accuracy, route intent sequence exact match, per-action intent accuracy, and slot exact match. """ from __future__ import annotations import argparse import json import os from pathlib import Path os.environ.setdefault("HF_HOME", "/mnt/D/.cache/huggingface") os.environ.setdefault("HF_DATASETS_CACHE", "/mnt/D/.cache/huggingface/datasets") import torch from peft import PeftModel from transformers import AutoModelForCausalLM, AutoTokenizer HERE = Path(__file__).resolve().parent def rows(path: str, limit: int) -> list[dict]: out = [json.loads(line) for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()] return out[:limit] if limit else out def normalized_route(value): if isinstance(value, dict): value = [value] return value if isinstance(value, list) else None @torch.inference_mode() def complete(tok, model, messages, max_new_tokens): prompt = tok.apply_chat_template(messages, tokenize=False, add_generation_prompt=True, enable_thinking=False) ids = tok(prompt, add_special_tokens=False, return_tensors="pt").input_ids.to(model.device) out = model.generate(input_ids=ids, max_new_tokens=max_new_tokens, do_sample=False, pad_token_id=tok.eos_token_id) return tok.decode(out[0][ids.shape[1]:], skip_special_tokens=True).strip() def main() -> None: ap = argparse.ArgumentParser() ap.add_argument("--base", default=str(HERE / "Qwen3-1.7B-ru-cpt")) ap.add_argument("--adapter", default=str(HERE / "Qwen3-1.7B-maven-sft")) ap.add_argument("--persona-eval", default=str(HERE / "data/persona_eval.jsonl")) ap.add_argument("--route-eval", default=str(HERE / "data/route_eval.jsonl")) ap.add_argument("--limit", type=int, default=0) ap.add_argument("--output") args = ap.parse_args() tok = AutoTokenizer.from_pretrained(args.adapter) base = AutoModelForCausalLM.from_pretrained(args.base, torch_dtype=torch.bfloat16, attn_implementation="eager", device_map={"":0}) model = PeftModel.from_pretrained(base, args.adapter).eval() persona_valid = persona_mood = 0 persona_rows = rows(args.persona_eval, args.limit) for row in persona_rows: expected = json.loads(row["messages"][-1]["content"]) raw = complete(tok, model, row["messages"][:-1], 160) try: got = json.loads(raw) valid = set(got) == {"response", "mood"} and isinstance(got["response"], str) except Exception: valid, got = False, {} persona_valid += valid persona_mood += valid and got["mood"] == expected["mood"] route_valid = route_seq = action_total = intent_ok = slots_ok = 0 route_rows = rows(args.route_eval, args.limit) for row in route_rows: expected = normalized_route(json.loads(row["messages"][-1]["content"])) raw = complete(tok, model, row["messages"][:-1], 160) try: got = normalized_route(json.loads(raw)) valid = bool(got) and all(isinstance(x, dict) and "intent" in x for x in got) except Exception: valid, got = False, None route_valid += valid if not valid: continue route_seq += [x["intent"] for x in got] == [x["intent"] for x in expected] for exp, actual in zip(expected, got): action_total += 1 intent_ok += actual.get("intent") == exp.get("intent") slots_ok += all(actual.get(k) == v for k, v in exp.items() if k != "intent") report = { "persona": {"cases":len(persona_rows), "json_valid_pct":100*persona_valid/len(persona_rows), "mood_accuracy_pct":100*persona_mood/len(persona_rows)}, "route": {"cases":len(route_rows), "json_valid_pct":100*route_valid/len(route_rows), "intent_sequence_exact_pct":100*route_seq/len(route_rows), "action_intent_accuracy_pct":100*intent_ok/action_total if action_total else 0, "slot_exact_pct":100*slots_ok/action_total if action_total else 0}, } rendered = json.dumps(report, indent=2, ensure_ascii=False) + "\n" if args.output: Path(args.output).write_text(rendered, encoding="utf-8") print(rendered, end="") if __name__ == "__main__": main()