Deploy PromptStat — UI shell + MiniCPM4.1-8B + 4-LoRA hybrid (Modal)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- README.md +66 -9
- eval/__init__.py +0 -0
- eval/_cache/cascade_results.json +462 -0
- eval/_cache/critical.json +14 -0
- eval/_cache/critical_hybrid.json +13 -0
- eval/_cache/lora_critical.json +12 -0
- eval/_cache/lora_critical_c_r16e5.json +13 -0
- eval/_cache/lora_critical_c_r16e8.json +13 -0
- eval/_cache/lora_decomposition.json +5 -0
- eval/_cache/lora_decomposition_d_r16e5.json +6 -0
- eval/_cache/lora_decomposition_d_r16e8.json +6 -0
- eval/_cache/lora_decomposition_p6a.json +6 -0
- eval/_cache/lora_decomposition_p6b.json +6 -0
- eval/_cache/lora_decomposition_p6c.json +6 -0
- eval/_cache/lora_decomposition_p6d.json +6 -0
- eval/_cache/lora_goal_stated.json +5 -0
- eval/_cache/lora_goal_stated_g_r16e5.json +6 -0
- eval/_cache/lora_interaction.json +5 -0
- eval/_cache/lora_interaction_i_r16e5.json +6 -0
- eval/_cache/lora_interaction_i_r16e8.json +6 -0
- eval/_cache/lora_interaction_i_r32e5.json +6 -0
- eval/_cache/step_c.json +6 -0
- eval/_cache/step_d.json +7 -0
- eval/_ceiling/prep_p7.py +82 -0
- eval/cascade.py +129 -0
- eval/kappa.py +412 -0
- eval/measure_lora.py +113 -0
- eval/prompts_v2.py +112 -0
- eval/prompts_v3.py +51 -0
- eval/prompts_v4.py +128 -0
- eval/step_c.py +137 -0
- eval/step_critical.py +114 -0
- eval/step_d.py +170 -0
- prompt_card/__init__.py +0 -0
- prompt_card/adapters/__init__.py +0 -0
- prompt_card/adapters/chatgpt.py +62 -0
- prompt_card/adapters/claude.py +29 -0
- prompt_card/adapters/gemini.py +29 -0
- prompt_card/adapters/paste.py +52 -0
- prompt_card/app_core.py +89 -0
- prompt_card/card.py +108 -0
- prompt_card/evaluate.py +62 -0
- prompt_card/llm/__init__.py +0 -0
- prompt_card/llm/client.py +22 -0
- prompt_card/llm/critical_router.py +50 -0
- prompt_card/llm/judge.py +72 -0
- prompt_card/llm/lora_client.py +79 -0
- prompt_card/llm/lora_router.py +93 -0
- prompt_card/llm/minicpm.py +73 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
prompt_card/training/acquired/conversations.jsonl filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -1,15 +1,72 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
|
| 9 |
-
app_file: app.py
|
| 10 |
pinned: false
|
| 11 |
license: apache-2.0
|
| 12 |
-
short_description:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
---
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: PromptStat
|
| 3 |
+
emoji: 🎮
|
| 4 |
+
colorFrom: gray
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: 6.6.0
|
| 8 |
+
app_file: space_app.py
|
|
|
|
| 9 |
pinned: false
|
| 10 |
license: apache-2.0
|
| 11 |
+
short_description: Rate your AI-collaboration skill — MiniCPM-8B + LoRA
|
| 12 |
+
tags:
|
| 13 |
+
- thousand-token-wood
|
| 14 |
+
- minicpm
|
| 15 |
+
- modal
|
| 16 |
+
- off-brand
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# 🎮 PromptStat
|
| 20 |
+
|
| 21 |
+
Upload your **ChatGPT or Claude export** (or paste a conversation / share link) and get an esports-style
|
| 22 |
+
**stat card** rating *how you collaborate with AI* across 5 observable axes:
|
| 23 |
+
|
| 24 |
+
**Focus · Technique · Critical Engagement · Interaction · Input Quality** → an overall score + tier (D→S).
|
| 25 |
+
|
| 26 |
+
> 🔗 **Live demo:** [demo link — TODO] · 🐦 **Write-up:** [social link — TODO]
|
| 27 |
+
|
| 28 |
+
## What makes it real
|
| 29 |
+
- **Scored by MiniCPM4.1-8B** (OpenBMB) using a validated observable-detection method — not vibes.
|
| 30 |
+
The model is **served on Modal** (vLLM on an A100-40GB, the 4 LoRA adapters loaded warm and addressable
|
| 31 |
+
by name). The card tags every score "✓ scored on MiniCPM-8B + LoRA hybrid"; if no model endpoint is
|
| 32 |
+
configured it falls back to a transparent heuristic and says so (amber tag).
|
| 33 |
+
- **Per-category LoRA hybrid** (trained **on Modal**, H100): the 8B is fine-tuned only where it makes
|
| 34 |
+
correctable errors — `decomposition` 0.26→**0.66 κ**, `critical·source_request` 0.47→**0.66**, plus
|
| 35 |
+
`independent_verification` and `interaction` — and stays base everywhere it's already strong. Each adapter
|
| 36 |
+
overrides only its own feature, so there's no catastrophic forgetting.
|
| 37 |
+
- **Honest validation**: every axis ships its measured **Cohen's κ** vs a 159-conversation human-labeled
|
| 38 |
+
set. We document where the 8B hits its ceiling (it can't out-agree the human rater) instead of inflating.
|
| 39 |
+
|
| 40 |
+
## Two ways in
|
| 41 |
+
- **Upload export** — full profile across your whole history (representative 30-conversation sample, every
|
| 42 |
+
turn within each scored; set `UI_MAX_CONVS=0` for the entire history).
|
| 43 |
+
- **Paste a conversation or a ChatGPT/Claude share link** — fast single-conversation sample for quick
|
| 44 |
+
feedback / demo. Clearly labelled "Sample analysis — not your overall AI usage."
|
| 45 |
+
|
| 46 |
+
## Privacy
|
| 47 |
+
Your chat content is parsed **in memory only** — never written to disk, never logged. A pasted share link is
|
| 48 |
+
fetched once to read the conversation and is likewise never stored.
|
| 49 |
+
|
| 50 |
+
## Architecture (Modal end-to-end)
|
| 51 |
+
- **Inference:** `modal_serve_minicpm.py` — vLLM serves base MiniCPM4.1-8B **plus** all 4 LoRA adapters from a
|
| 52 |
+
single warm A100; the app routes each axis to its adapter by model name over HTTP. Model weights are baked
|
| 53 |
+
into the image so container starts don't touch the Hub.
|
| 54 |
+
- **Training:** `modal_train_lora.py` / `modal_eval_lora.py` — the LoRA adapters were trained and evaluated on
|
| 55 |
+
Modal GPUs against the human-labeled validation set.
|
| 56 |
+
|
| 57 |
+
## Run locally
|
| 58 |
+
```bash
|
| 59 |
+
pip install -r requirements.txt
|
| 60 |
+
# real scoring needs a MiniCPM endpoint (auto-loaded from eval/.secrets.env if present):
|
| 61 |
+
export OPENBMB_BASE_URL=... # OpenBMB free API, or your Modal vLLM URL
|
| 62 |
+
export OPENBMB_TOKEN=...
|
| 63 |
+
python -m ui.app # or: python space_app.py (or ./run_demo.sh for the LoRA hybrid)
|
| 64 |
+
python -m pytest ui/tests -q # 19 UI tests
|
| 65 |
+
```
|
| 66 |
+
Without the env vars the app still runs (heuristic fallback, amber tag). See `DEPLOY.md` for the Space deploy
|
| 67 |
+
+ Modal model-serving + speed tuning, and `handoff_to_card.md` for the `card_data` schema.
|
| 68 |
+
|
| 69 |
+
## Built for the OpenBMB "Build Small" hackathon
|
| 70 |
+
Models ≤32B, English. Uses **MiniCPM4.1-8B** (reasoning) — eligible model — with **Modal** for both LoRA
|
| 71 |
+
training and warm inference. Maintainer notes: backend/ML workstream in `prompt_card/`, `modal_*.py`,
|
| 72 |
+
`SPEC.md`; UI shell in `ui/`.
|
eval/__init__.py
ADDED
|
File without changes
|
eval/_cache/cascade_results.json
ADDED
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"baseline": {
|
| 3 |
+
"_new_calls": 0,
|
| 4 |
+
"technique": {
|
| 5 |
+
"headline": 0.12970513816185567,
|
| 6 |
+
"axis_any": 0.3282188125416949,
|
| 7 |
+
"cats": {
|
| 8 |
+
"zero_shot_role": [
|
| 9 |
+
0.5291833914588411,
|
| 10 |
+
{
|
| 11 |
+
"tp": 9,
|
| 12 |
+
"fp": 7,
|
| 13 |
+
"fn": 8,
|
| 14 |
+
"tn": 453
|
| 15 |
+
},
|
| 16 |
+
17
|
| 17 |
+
],
|
| 18 |
+
"few_shot": [
|
| 19 |
+
0.0,
|
| 20 |
+
{
|
| 21 |
+
"tp": 0,
|
| 22 |
+
"fp": 0,
|
| 23 |
+
"fn": 7,
|
| 24 |
+
"tn": 470
|
| 25 |
+
},
|
| 26 |
+
7
|
| 27 |
+
],
|
| 28 |
+
"thought_generation": [
|
| 29 |
+
0.0,
|
| 30 |
+
{
|
| 31 |
+
"tp": 0,
|
| 32 |
+
"fp": 2,
|
| 33 |
+
"fn": 0,
|
| 34 |
+
"tn": 475
|
| 35 |
+
},
|
| 36 |
+
0
|
| 37 |
+
],
|
| 38 |
+
"decomposition": [
|
| 39 |
+
-0.0075597554196795,
|
| 40 |
+
{
|
| 41 |
+
"tp": 0,
|
| 42 |
+
"fp": 2,
|
| 43 |
+
"fn": 17,
|
| 44 |
+
"tn": 458
|
| 45 |
+
},
|
| 46 |
+
17
|
| 47 |
+
],
|
| 48 |
+
"self_criticism": [
|
| 49 |
+
null,
|
| 50 |
+
{
|
| 51 |
+
"tp": 0,
|
| 52 |
+
"fp": 0,
|
| 53 |
+
"fn": 0,
|
| 54 |
+
"tn": 477
|
| 55 |
+
},
|
| 56 |
+
0
|
| 57 |
+
],
|
| 58 |
+
"flipped": [
|
| 59 |
+
-0.002803083391738918,
|
| 60 |
+
{
|
| 61 |
+
"tp": 0,
|
| 62 |
+
"fp": 1,
|
| 63 |
+
"fn": 2,
|
| 64 |
+
"tn": 474
|
| 65 |
+
},
|
| 66 |
+
2
|
| 67 |
+
]
|
| 68 |
+
},
|
| 69 |
+
"parse_fail": 3
|
| 70 |
+
},
|
| 71 |
+
"input_quality": {
|
| 72 |
+
"headline": 0.09211310491513845,
|
| 73 |
+
"axis_any": 0.1846153846153846,
|
| 74 |
+
"cats": {
|
| 75 |
+
"goal_stated": [
|
| 76 |
+
0.09353119536482576,
|
| 77 |
+
{
|
| 78 |
+
"tp": 31,
|
| 79 |
+
"fp": 219,
|
| 80 |
+
"fn": 6,
|
| 81 |
+
"tn": 221
|
| 82 |
+
},
|
| 83 |
+
37
|
| 84 |
+
],
|
| 85 |
+
"specific_specification": [
|
| 86 |
+
0.09069501446545114,
|
| 87 |
+
{
|
| 88 |
+
"tp": 42,
|
| 89 |
+
"fp": 6,
|
| 90 |
+
"fn": 249,
|
| 91 |
+
"tn": 180
|
| 92 |
+
},
|
| 93 |
+
291
|
| 94 |
+
]
|
| 95 |
+
},
|
| 96 |
+
"parse_fail": 3
|
| 97 |
+
},
|
| 98 |
+
"interaction": {
|
| 99 |
+
"headline": 0.13385383129426917,
|
| 100 |
+
"counts": {
|
| 101 |
+
"tp": 176,
|
| 102 |
+
"fp": 392,
|
| 103 |
+
"fn": 30,
|
| 104 |
+
"tn": 218
|
| 105 |
+
},
|
| 106 |
+
"pos": 206,
|
| 107 |
+
"n": 816,
|
| 108 |
+
"parse_fail": 1
|
| 109 |
+
},
|
| 110 |
+
"focus": {
|
| 111 |
+
"headline": 0.39427812903901904,
|
| 112 |
+
"T": 0.55,
|
| 113 |
+
"recall": 0.684931506849315
|
| 114 |
+
},
|
| 115 |
+
"_secs": 70.5
|
| 116 |
+
},
|
| 117 |
+
"v2": {
|
| 118 |
+
"_new_calls": 0,
|
| 119 |
+
"technique": {
|
| 120 |
+
"headline": 0.29018965857011875,
|
| 121 |
+
"axis_any": 0.551405033291953,
|
| 122 |
+
"cats": {
|
| 123 |
+
"zero_shot_role": [
|
| 124 |
+
0.9000069876318915,
|
| 125 |
+
{
|
| 126 |
+
"tp": 14,
|
| 127 |
+
"fp": 0,
|
| 128 |
+
"fn": 3,
|
| 129 |
+
"tn": 460
|
| 130 |
+
},
|
| 131 |
+
17
|
| 132 |
+
],
|
| 133 |
+
"few_shot": [
|
| 134 |
+
0.0,
|
| 135 |
+
{
|
| 136 |
+
"tp": 0,
|
| 137 |
+
"fp": 0,
|
| 138 |
+
"fn": 7,
|
| 139 |
+
"tn": 470
|
| 140 |
+
},
|
| 141 |
+
7
|
| 142 |
+
],
|
| 143 |
+
"thought_generation": [
|
| 144 |
+
0.0,
|
| 145 |
+
{
|
| 146 |
+
"tp": 0,
|
| 147 |
+
"fp": 1,
|
| 148 |
+
"fn": 0,
|
| 149 |
+
"tn": 476
|
| 150 |
+
},
|
| 151 |
+
0
|
| 152 |
+
],
|
| 153 |
+
"decomposition": [
|
| 154 |
+
0.26075164664858347,
|
| 155 |
+
{
|
| 156 |
+
"tp": 3,
|
| 157 |
+
"fp": 2,
|
| 158 |
+
"fn": 14,
|
| 159 |
+
"tn": 458
|
| 160 |
+
},
|
| 161 |
+
17
|
| 162 |
+
],
|
| 163 |
+
"self_criticism": [
|
| 164 |
+
null,
|
| 165 |
+
{
|
| 166 |
+
"tp": 0,
|
| 167 |
+
"fp": 0,
|
| 168 |
+
"fn": 0,
|
| 169 |
+
"tn": 477
|
| 170 |
+
},
|
| 171 |
+
0
|
| 172 |
+
],
|
| 173 |
+
"flipped": [
|
| 174 |
+
0.0,
|
| 175 |
+
{
|
| 176 |
+
"tp": 0,
|
| 177 |
+
"fp": 0,
|
| 178 |
+
"fn": 2,
|
| 179 |
+
"tn": 475
|
| 180 |
+
},
|
| 181 |
+
2
|
| 182 |
+
]
|
| 183 |
+
},
|
| 184 |
+
"parse_fail": 0
|
| 185 |
+
},
|
| 186 |
+
"input_quality": {
|
| 187 |
+
"headline": 0.3971921361556304,
|
| 188 |
+
"axis_any": 0.5678643257052781,
|
| 189 |
+
"cats": {
|
| 190 |
+
"goal_stated": [
|
| 191 |
+
0.226437865359231,
|
| 192 |
+
{
|
| 193 |
+
"tp": 8,
|
| 194 |
+
"fp": 14,
|
| 195 |
+
"fn": 29,
|
| 196 |
+
"tn": 426
|
| 197 |
+
},
|
| 198 |
+
37
|
| 199 |
+
],
|
| 200 |
+
"specific_specification": [
|
| 201 |
+
0.5679464069520299,
|
| 202 |
+
{
|
| 203 |
+
"tp": 225,
|
| 204 |
+
"fp": 35,
|
| 205 |
+
"fn": 66,
|
| 206 |
+
"tn": 151
|
| 207 |
+
},
|
| 208 |
+
291
|
| 209 |
+
]
|
| 210 |
+
},
|
| 211 |
+
"parse_fail": 0
|
| 212 |
+
},
|
| 213 |
+
"interaction": {
|
| 214 |
+
"headline": 0.07623546939015509,
|
| 215 |
+
"counts": {
|
| 216 |
+
"tp": 32,
|
| 217 |
+
"fp": 57,
|
| 218 |
+
"fn": 174,
|
| 219 |
+
"tn": 553
|
| 220 |
+
},
|
| 221 |
+
"pos": 206,
|
| 222 |
+
"n": 816,
|
| 223 |
+
"parse_fail": 0
|
| 224 |
+
},
|
| 225 |
+
"focus": {
|
| 226 |
+
"headline": 0.4046963209425367,
|
| 227 |
+
"T": 0.55,
|
| 228 |
+
"recall": 0.684931506849315
|
| 229 |
+
},
|
| 230 |
+
"_secs": 68.0
|
| 231 |
+
},
|
| 232 |
+
"v3": {
|
| 233 |
+
"_new_calls": 0,
|
| 234 |
+
"technique": {
|
| 235 |
+
"headline": 0.29018965857011875,
|
| 236 |
+
"axis_any": 0.551405033291953,
|
| 237 |
+
"cats": {
|
| 238 |
+
"zero_shot_role": [
|
| 239 |
+
0.9000069876318915,
|
| 240 |
+
{
|
| 241 |
+
"tp": 14,
|
| 242 |
+
"fp": 0,
|
| 243 |
+
"fn": 3,
|
| 244 |
+
"tn": 460
|
| 245 |
+
},
|
| 246 |
+
17
|
| 247 |
+
],
|
| 248 |
+
"few_shot": [
|
| 249 |
+
0.0,
|
| 250 |
+
{
|
| 251 |
+
"tp": 0,
|
| 252 |
+
"fp": 0,
|
| 253 |
+
"fn": 7,
|
| 254 |
+
"tn": 470
|
| 255 |
+
},
|
| 256 |
+
7
|
| 257 |
+
],
|
| 258 |
+
"thought_generation": [
|
| 259 |
+
0.0,
|
| 260 |
+
{
|
| 261 |
+
"tp": 0,
|
| 262 |
+
"fp": 1,
|
| 263 |
+
"fn": 0,
|
| 264 |
+
"tn": 476
|
| 265 |
+
},
|
| 266 |
+
0
|
| 267 |
+
],
|
| 268 |
+
"decomposition": [
|
| 269 |
+
0.26075164664858347,
|
| 270 |
+
{
|
| 271 |
+
"tp": 3,
|
| 272 |
+
"fp": 2,
|
| 273 |
+
"fn": 14,
|
| 274 |
+
"tn": 458
|
| 275 |
+
},
|
| 276 |
+
17
|
| 277 |
+
],
|
| 278 |
+
"self_criticism": [
|
| 279 |
+
null,
|
| 280 |
+
{
|
| 281 |
+
"tp": 0,
|
| 282 |
+
"fp": 0,
|
| 283 |
+
"fn": 0,
|
| 284 |
+
"tn": 477
|
| 285 |
+
},
|
| 286 |
+
0
|
| 287 |
+
],
|
| 288 |
+
"flipped": [
|
| 289 |
+
0.0,
|
| 290 |
+
{
|
| 291 |
+
"tp": 0,
|
| 292 |
+
"fp": 0,
|
| 293 |
+
"fn": 2,
|
| 294 |
+
"tn": 475
|
| 295 |
+
},
|
| 296 |
+
2
|
| 297 |
+
]
|
| 298 |
+
},
|
| 299 |
+
"parse_fail": 0
|
| 300 |
+
},
|
| 301 |
+
"input_quality": {
|
| 302 |
+
"headline": 0.36485526886734726,
|
| 303 |
+
"axis_any": 0.5401767615340956,
|
| 304 |
+
"cats": {
|
| 305 |
+
"goal_stated": [
|
| 306 |
+
0.1937101132639565,
|
| 307 |
+
{
|
| 308 |
+
"tp": 31,
|
| 309 |
+
"fp": 141,
|
| 310 |
+
"fn": 6,
|
| 311 |
+
"tn": 299
|
| 312 |
+
},
|
| 313 |
+
37
|
| 314 |
+
],
|
| 315 |
+
"specific_specification": [
|
| 316 |
+
0.536000424470738,
|
| 317 |
+
{
|
| 318 |
+
"tp": 213,
|
| 319 |
+
"fp": 32,
|
| 320 |
+
"fn": 78,
|
| 321 |
+
"tn": 154
|
| 322 |
+
},
|
| 323 |
+
291
|
| 324 |
+
]
|
| 325 |
+
},
|
| 326 |
+
"parse_fail": 4
|
| 327 |
+
},
|
| 328 |
+
"interaction": {
|
| 329 |
+
"headline": 0.3197441320340984,
|
| 330 |
+
"counts": {
|
| 331 |
+
"tp": 148,
|
| 332 |
+
"fp": 199,
|
| 333 |
+
"fn": 58,
|
| 334 |
+
"tn": 411
|
| 335 |
+
},
|
| 336 |
+
"pos": 206,
|
| 337 |
+
"n": 816,
|
| 338 |
+
"parse_fail": 0
|
| 339 |
+
},
|
| 340 |
+
"focus": {
|
| 341 |
+
"headline": 0.4046963209425367,
|
| 342 |
+
"T": 0.55,
|
| 343 |
+
"recall": 0.684931506849315
|
| 344 |
+
},
|
| 345 |
+
"_secs": 68.4
|
| 346 |
+
},
|
| 347 |
+
"v4": {
|
| 348 |
+
"_new_calls": 2204,
|
| 349 |
+
"technique": {
|
| 350 |
+
"headline": 0.1942222178493125,
|
| 351 |
+
"axis_any": 0.3235641692271324,
|
| 352 |
+
"cats": {
|
| 353 |
+
"zero_shot_role": [
|
| 354 |
+
0.631597013644554,
|
| 355 |
+
{
|
| 356 |
+
"tp": 8,
|
| 357 |
+
"fp": 0,
|
| 358 |
+
"fn": 9,
|
| 359 |
+
"tn": 460
|
| 360 |
+
},
|
| 361 |
+
17
|
| 362 |
+
],
|
| 363 |
+
"few_shot": [
|
| 364 |
+
-0.0036822724881613316,
|
| 365 |
+
{
|
| 366 |
+
"tp": 0,
|
| 367 |
+
"fp": 1,
|
| 368 |
+
"fn": 7,
|
| 369 |
+
"tn": 469
|
| 370 |
+
},
|
| 371 |
+
7
|
| 372 |
+
],
|
| 373 |
+
"thought_generation": [
|
| 374 |
+
0.0,
|
| 375 |
+
{
|
| 376 |
+
"tp": 0,
|
| 377 |
+
"fp": 1,
|
| 378 |
+
"fn": 0,
|
| 379 |
+
"tn": 476
|
| 380 |
+
},
|
| 381 |
+
0
|
| 382 |
+
],
|
| 383 |
+
"decomposition": [
|
| 384 |
+
0.1489741302408574,
|
| 385 |
+
{
|
| 386 |
+
"tp": 2,
|
| 387 |
+
"fp": 5,
|
| 388 |
+
"fn": 15,
|
| 389 |
+
"tn": 455
|
| 390 |
+
},
|
| 391 |
+
17
|
| 392 |
+
],
|
| 393 |
+
"self_criticism": [
|
| 394 |
+
null,
|
| 395 |
+
{
|
| 396 |
+
"tp": 0,
|
| 397 |
+
"fp": 0,
|
| 398 |
+
"fn": 0,
|
| 399 |
+
"tn": 477
|
| 400 |
+
},
|
| 401 |
+
0
|
| 402 |
+
],
|
| 403 |
+
"flipped": [
|
| 404 |
+
0.0,
|
| 405 |
+
{
|
| 406 |
+
"tp": 0,
|
| 407 |
+
"fp": 0,
|
| 408 |
+
"fn": 2,
|
| 409 |
+
"tn": 475
|
| 410 |
+
},
|
| 411 |
+
2
|
| 412 |
+
]
|
| 413 |
+
},
|
| 414 |
+
"parse_fail": 2
|
| 415 |
+
},
|
| 416 |
+
"input_quality": {
|
| 417 |
+
"headline": 0.19584802394592618,
|
| 418 |
+
"axis_any": 0.42787859824780966,
|
| 419 |
+
"cats": {
|
| 420 |
+
"goal_stated": [
|
| 421 |
+
0.1445087236840895,
|
| 422 |
+
{
|
| 423 |
+
"tp": 34,
|
| 424 |
+
"fp": 192,
|
| 425 |
+
"fn": 3,
|
| 426 |
+
"tn": 248
|
| 427 |
+
},
|
| 428 |
+
37
|
| 429 |
+
],
|
| 430 |
+
"specific_specification": [
|
| 431 |
+
0.24718732420776285,
|
| 432 |
+
{
|
| 433 |
+
"tp": 96,
|
| 434 |
+
"fp": 7,
|
| 435 |
+
"fn": 195,
|
| 436 |
+
"tn": 179
|
| 437 |
+
},
|
| 438 |
+
291
|
| 439 |
+
]
|
| 440 |
+
},
|
| 441 |
+
"parse_fail": 0
|
| 442 |
+
},
|
| 443 |
+
"interaction": {
|
| 444 |
+
"headline": 0.21034110636700612,
|
| 445 |
+
"counts": {
|
| 446 |
+
"tp": 152,
|
| 447 |
+
"fp": 276,
|
| 448 |
+
"fn": 54,
|
| 449 |
+
"tn": 334
|
| 450 |
+
},
|
| 451 |
+
"pos": 206,
|
| 452 |
+
"n": 816,
|
| 453 |
+
"parse_fail": 0
|
| 454 |
+
},
|
| 455 |
+
"focus": {
|
| 456 |
+
"headline": 0.4333764553686933,
|
| 457 |
+
"T": 0.5,
|
| 458 |
+
"recall": 0.4931506849315068
|
| 459 |
+
},
|
| 460 |
+
"_secs": 445.8
|
| 461 |
+
}
|
| 462 |
+
}
|
eval/_cache/critical.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"per_type": {
|
| 3 |
+
"skepticism": 0.5761426708319434,
|
| 4 |
+
"rebuttal": 0.5209181524638488,
|
| 5 |
+
"source_request": 0.4667274384685487,
|
| 6 |
+
"independent_verification": 0.03541359607502818,
|
| 7 |
+
"re_questioning": 0.057768710404807265
|
| 8 |
+
},
|
| 9 |
+
"any": 0.5667524582590905,
|
| 10 |
+
"headline": 0.33139411364883525,
|
| 11 |
+
"mae": 0.33962264150943394,
|
| 12 |
+
"decision": "LoRA NEEDED (headline < 0.4)",
|
| 13 |
+
"parse_fail": 2
|
| 14 |
+
}
|
eval/_cache/critical_hybrid.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"per_type": {
|
| 3 |
+
"skepticism": 0.576,
|
| 4 |
+
"rebuttal": 0.521,
|
| 5 |
+
"source_request": 0.664,
|
| 6 |
+
"independent_verification": 0.578,
|
| 7 |
+
"re_questioning": 0.058
|
| 8 |
+
},
|
| 9 |
+
"headline": 0.479,
|
| 10 |
+
"routing": {"base": ["skepticism","rebuttal","re_questioning"], "lora": ["source_request","independent_verification"]},
|
| 11 |
+
"lora_adapter": "critical_c_r16e5",
|
| 12 |
+
"note": "Phase-7 locked per-type hybrid. base per-type from critical.json; source_request+independent_verification from critical_c_r16e5."
|
| 13 |
+
}
|
eval/_cache/lora_critical.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "critical",
|
| 3 |
+
"lora_per_type": {
|
| 4 |
+
"skepticism": 0.3848012434786635,
|
| 5 |
+
"rebuttal": 0.4037305065742538,
|
| 6 |
+
"source_request": 0.3970315398886822,
|
| 7 |
+
"independent_verification": 0.5821293774453546,
|
| 8 |
+
"re_questioning": 0.061319706731838106
|
| 9 |
+
},
|
| 10 |
+
"lora_headline": 0.36580247482375844,
|
| 11 |
+
"base_headline": 0.33139411364883525
|
| 12 |
+
}
|
eval/_cache/lora_critical_c_r16e5.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "critical",
|
| 3 |
+
"lora_per_type": {
|
| 4 |
+
"skepticism": 0.4967512752003869,
|
| 5 |
+
"rebuttal": 0.5270003941663386,
|
| 6 |
+
"source_request": 0.663908996897621,
|
| 7 |
+
"independent_verification": 0.5775455588122841,
|
| 8 |
+
"re_questioning": 0.02836335760827934
|
| 9 |
+
},
|
| 10 |
+
"lora_headline": 0.45871391653698196,
|
| 11 |
+
"base_headline": 0.33139411364883525,
|
| 12 |
+
"adapter": "critical_c_r16e5"
|
| 13 |
+
}
|
eval/_cache/lora_critical_c_r16e8.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "critical",
|
| 3 |
+
"lora_per_type": {
|
| 4 |
+
"skepticism": 0.5559570075297539,
|
| 5 |
+
"rebuttal": 0.554183813443073,
|
| 6 |
+
"source_request": 0.5518786625301633,
|
| 7 |
+
"independent_verification": 0.6110889509373758,
|
| 8 |
+
"re_questioning": 0.058456190046997245
|
| 9 |
+
},
|
| 10 |
+
"lora_headline": 0.46631292489747267,
|
| 11 |
+
"base_headline": 0.33139411364883525,
|
| 12 |
+
"adapter": "critical_c_r16e8"
|
| 13 |
+
}
|
eval/_cache/lora_decomposition.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.4521019986216408,
|
| 4 |
+
"base_kappa": 0.261
|
| 5 |
+
}
|
eval/_cache/lora_decomposition_d_r16e5.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.6560426882030562,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_d_r16e5"
|
| 6 |
+
}
|
eval/_cache/lora_decomposition_d_r16e8.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.6560426882030562,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_d_r16e8"
|
| 6 |
+
}
|
eval/_cache/lora_decomposition_p6a.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.48731249123790904,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_p6a"
|
| 6 |
+
}
|
eval/_cache/lora_decomposition_p6b.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.5473769825132166,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_p6b"
|
| 6 |
+
}
|
eval/_cache/lora_decomposition_p6c.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.5919589392643283,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_p6c"
|
| 6 |
+
}
|
eval/_cache/lora_decomposition_p6d.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "decomposition",
|
| 3 |
+
"lora_kappa": 0.6120374135827567,
|
| 4 |
+
"base_kappa": 0.261,
|
| 5 |
+
"adapter": "decomposition_p6d"
|
| 6 |
+
}
|
eval/_cache/lora_goal_stated.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "goal_stated",
|
| 3 |
+
"lora_kappa": 0.4188278588656541,
|
| 4 |
+
"base_kappa": 0.226
|
| 5 |
+
}
|
eval/_cache/lora_goal_stated_g_r16e5.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "goal_stated",
|
| 3 |
+
"lora_kappa": 0.4370514497145227,
|
| 4 |
+
"base_kappa": 0.226,
|
| 5 |
+
"adapter": "goal_stated_g_r16e5"
|
| 6 |
+
}
|
eval/_cache/lora_interaction.json
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "interaction",
|
| 3 |
+
"lora_kappa": 0.4497702102011602,
|
| 4 |
+
"base_kappa": 0.3197441320340984
|
| 5 |
+
}
|
eval/_cache/lora_interaction_i_r16e5.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "interaction",
|
| 3 |
+
"lora_kappa": 0.47302563796270025,
|
| 4 |
+
"base_kappa": 0.32,
|
| 5 |
+
"adapter": "interaction_i_r16e5"
|
| 6 |
+
}
|
eval/_cache/lora_interaction_i_r16e8.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "interaction",
|
| 3 |
+
"lora_kappa": 0.4320932517529365,
|
| 4 |
+
"base_kappa": 0.32,
|
| 5 |
+
"adapter": "interaction_i_r16e8"
|
| 6 |
+
}
|
eval/_cache/lora_interaction_i_r32e5.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"axis": "interaction",
|
| 3 |
+
"lora_kappa": 0.4458529518290473,
|
| 4 |
+
"base_kappa": 0.32,
|
| 5 |
+
"adapter": "interaction_i_r32e5"
|
| 6 |
+
}
|
eval/_cache/step_c.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"goal": 0.1610096976611523,
|
| 3 |
+
"spec": 0.18226702324624555,
|
| 4 |
+
"iq_mean": 0.17163836045369893,
|
| 5 |
+
"interaction_sc": 0.30050531092090343
|
| 6 |
+
}
|
eval/_cache/step_d.json
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"interaction_think": 0.22752203222903328,
|
| 3 |
+
"focus_think": 0.31570533819020363,
|
| 4 |
+
"focus_T": 0.55,
|
| 5 |
+
"int_parsefail": 436,
|
| 6 |
+
"foc_parsefail": 71
|
| 7 |
+
}
|
eval/_ceiling/prep_p7.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase-7 ceiling-check prep for Interaction + Critical (goal/decomp reuse Phase-6 artifacts).
|
| 2 |
+
|
| 3 |
+
Builds 50-unit stratified samples (positive-enriched so inter-model κ is stable) from the 159-conv
|
| 4 |
+
validation set, with Kim GT attached, plus a BLIND version (context only) for Opus/Sonnet labelers.
|
| 5 |
+
|
| 6 |
+
Interaction unit: (prev_user, this_user) -> refinement_attempt bool.
|
| 7 |
+
Critical unit: (prev_assistant, this_user) -> set of CE types (5).
|
| 8 |
+
|
| 9 |
+
Run: python -m eval._ceiling.prep_p7
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import random
|
| 16 |
+
|
| 17 |
+
from eval import kappa as K
|
| 18 |
+
from prompt_card.scoring import observable_axes as OA
|
| 19 |
+
from eval.step_critical import CE
|
| 20 |
+
|
| 21 |
+
HERE = os.path.dirname(__file__)
|
| 22 |
+
SEED = 7
|
| 23 |
+
N = 50
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _interaction_units(gt, convs):
|
| 27 |
+
units = []
|
| 28 |
+
for r in gt:
|
| 29 |
+
ut = K.user_turns(convs[r["id"]])
|
| 30 |
+
for row in r["interaction"]:
|
| 31 |
+
i = int(row["turn"][1:]) - 1
|
| 32 |
+
if i < 1 or i >= len(ut):
|
| 33 |
+
continue
|
| 34 |
+
units.append({"cid": r["id"], "turn": row["turn"], "prev_user": ut[i - 1],
|
| 35 |
+
"this_user": ut[i], "kim": bool(row["refinement"])})
|
| 36 |
+
return units
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _critical_units(gt, convs):
|
| 40 |
+
units = []
|
| 41 |
+
for r in gt:
|
| 42 |
+
conv = convs[r["id"]]; ut = K.user_turns(conv)
|
| 43 |
+
for row in r["critical"]:
|
| 44 |
+
i = int(row["turn"][1:]) - 1
|
| 45 |
+
if i < 0 or i >= len(ut):
|
| 46 |
+
continue
|
| 47 |
+
units.append({"cid": r["id"], "turn": row["turn"],
|
| 48 |
+
"prev_assistant": OA._prev_assistant(conv, i) or "",
|
| 49 |
+
"this_user": ut[i], "kim": sorted(row["types"])})
|
| 50 |
+
return units
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _stratified(units, is_pos, n, rng, pos_frac):
|
| 54 |
+
pos = [u for u in units if is_pos(u)]
|
| 55 |
+
neg = [u for u in units if not is_pos(u)]
|
| 56 |
+
rng.shuffle(pos); rng.shuffle(neg)
|
| 57 |
+
npos = min(len(pos), int(n * pos_frac))
|
| 58 |
+
sample = pos[:npos] + neg[:n - npos]
|
| 59 |
+
rng.shuffle(sample)
|
| 60 |
+
for k, u in enumerate(sample):
|
| 61 |
+
u["idx"] = k
|
| 62 |
+
return sample
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _write(name, sample, blind_fields):
|
| 66 |
+
json.dump(sample, open(os.path.join(HERE, f"{name}_samples_p7.json"), "w"), ensure_ascii=False, indent=1)
|
| 67 |
+
blind = [{**{"idx": u["idx"]}, **{f: u[f] for f in blind_fields}} for u in sample]
|
| 68 |
+
json.dump(blind, open(os.path.join(HERE, f"{name}_blind_p7.json"), "w"), ensure_ascii=False, indent=1)
|
| 69 |
+
print(f"[prep_p7] {name}: {len(sample)} units ({sum(1 for u in sample if (u['kim'] if isinstance(u['kim'],bool) else u['kim']))} pos-ish)")
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
rng = random.Random(SEED)
|
| 74 |
+
gt = K.load_gt(); convs = K.load_convs()
|
| 75 |
+
inter = _stratified(_interaction_units(gt, convs), lambda u: u["kim"], N, rng, 0.4)
|
| 76 |
+
_write("interaction", inter, ["prev_user", "this_user"])
|
| 77 |
+
crit = _stratified(_critical_units(gt, convs), lambda u: len(u["kim"]) > 0, N, rng, 0.5)
|
| 78 |
+
_write("critical", crit, ["prev_assistant", "this_user"])
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
main()
|
eval/cascade.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cascade runner: measure base-8B per axis under different prompt/inference strategies, compare to
|
| 2 |
+
baseline, apply the decision gate. Reuses eval.kappa scoring; the 8B cache makes unchanged prompts free.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python -m eval.cascade baseline v2 # measure these versions, print before/after κ table
|
| 6 |
+
Versions are registered in VERSIONS (a builders module per key). Results persist to
|
| 7 |
+
eval/_cache/cascade_results.json so later steps accumulate.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import importlib
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
from eval import kappa as K
|
| 18 |
+
from prompt_card.scoring import observable_axes as OA
|
| 19 |
+
|
| 20 |
+
RESULTS = os.path.join(os.path.dirname(__file__), "_cache", "cascade_results.json")
|
| 21 |
+
VERSIONS = {"baseline": OA, "v2": "eval.prompts_v2", "v3": "eval.prompts_v3", "v4": "eval.prompts_v4"}
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _builders(v):
|
| 25 |
+
b = VERSIONS[v]
|
| 26 |
+
return importlib.import_module(b) if isinstance(b, str) else b
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def measure(builders, gt, convs, embedder, client):
|
| 30 |
+
"""Return per-axis headline κ + detail. Cache-served calls are free."""
|
| 31 |
+
prompts, plan, geom = K.build_prompts(gt, convs, embedder, builders=builders)
|
| 32 |
+
pi = {}
|
| 33 |
+
for it in plan:
|
| 34 |
+
pi.setdefault(it[0], []).append(it)
|
| 35 |
+
n_before = client.misses
|
| 36 |
+
responses = client.run_all(prompts)
|
| 37 |
+
new_calls = client.misses - n_before
|
| 38 |
+
|
| 39 |
+
out = {"_new_calls": new_calls}
|
| 40 |
+
# technique / input_quality (per-category + axis-level "any")
|
| 41 |
+
for axis, fields in (("technique", K.TECH), ("input_quality", K.IQ)):
|
| 42 |
+
per, fail = K.score_binary_axis(gt, responses, pi, axis, fields)
|
| 43 |
+
cats = {f: K.cohen_kappa(*per[f]) for f in fields}
|
| 44 |
+
n = len(per[fields[0]][0])
|
| 45 |
+
anyt = [int(any(per[f][0][j] for f in fields)) for j in range(n)]
|
| 46 |
+
anyp = [int(any(per[f][1][j] for f in fields)) for j in range(n)]
|
| 47 |
+
feat_ks = [cats[f] for f in fields if sum(per[f][0]) > 0] # only categories with positives
|
| 48 |
+
headline = (sum(feat_ks) / len(feat_ks)) if feat_ks else None
|
| 49 |
+
out[axis] = {"headline": headline, "axis_any": K.cohen_kappa(anyt, anyp),
|
| 50 |
+
"cats": {f: (cats[f], K.binary_counts(*per[f]), sum(per[f][0])) for f in fields},
|
| 51 |
+
"parse_fail": fail}
|
| 52 |
+
# interaction
|
| 53 |
+
yt, yp, fail = K.score_interaction(gt, responses, pi)
|
| 54 |
+
out["interaction"] = {"headline": K.cohen_kappa(yt, yp), "counts": K.binary_counts(yt, yp),
|
| 55 |
+
"pos": sum(yt), "n": len(yt), "parse_fail": fail}
|
| 56 |
+
# focus (sweep best T)
|
| 57 |
+
best = None
|
| 58 |
+
for T in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:
|
| 59 |
+
fyt, fyp, info = K.score_focus(gt, responses, pi, geom, T)
|
| 60 |
+
k = K.cohen_kappa(fyt, fyp)
|
| 61 |
+
cand = (k if k is not None else -9, info["recall"] or 0)
|
| 62 |
+
if best is None or cand > best[0]:
|
| 63 |
+
best = (cand, T, k, info["recall"])
|
| 64 |
+
out["focus"] = {"headline": best[2], "T": best[1], "recall": best[3]}
|
| 65 |
+
return out
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def gate(k):
|
| 69 |
+
if k is None:
|
| 70 |
+
return "N/A"
|
| 71 |
+
if k >= 0.6:
|
| 72 |
+
return "SOLID"
|
| 73 |
+
if k >= 0.4:
|
| 74 |
+
return "OK"
|
| 75 |
+
if k >= 0.2:
|
| 76 |
+
return "try-next"
|
| 77 |
+
return "must-next"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def main(version_keys):
|
| 81 |
+
base_url = os.environ.get("OPENBMB_BASE_URL"); token = os.environ.get("OPENBMB_TOKEN")
|
| 82 |
+
if not base_url or not token:
|
| 83 |
+
print("ERROR: set OPENBMB_BASE_URL and OPENBMB_TOKEN", file=sys.stderr); sys.exit(2)
|
| 84 |
+
from prompt_card.llm.minicpm import MiniCPMClient
|
| 85 |
+
gt = K.load_gt(); convs = K.load_convs(); embedder = K.FastEmbedder()
|
| 86 |
+
client = K.CachedClient(MiniCPMClient(base_url, token), workers=8)
|
| 87 |
+
|
| 88 |
+
prior = {}
|
| 89 |
+
if os.path.exists(RESULTS):
|
| 90 |
+
prior = json.load(open(RESULTS))
|
| 91 |
+
|
| 92 |
+
results = dict(prior)
|
| 93 |
+
for v in version_keys:
|
| 94 |
+
t0 = time.time()
|
| 95 |
+
print(f"\n=== measuring '{v}' ===", flush=True)
|
| 96 |
+
r = measure(_builders(v), gt, convs, embedder, client)
|
| 97 |
+
r["_secs"] = round(time.time() - t0, 1)
|
| 98 |
+
results[v] = r
|
| 99 |
+
print(f" new 8B calls: {r['_new_calls']} · {r['_secs']}s", flush=True)
|
| 100 |
+
json.dump(results, open(RESULTS, "w"), indent=1, default=str)
|
| 101 |
+
|
| 102 |
+
axes = ["technique", "input_quality", "interaction", "focus"]
|
| 103 |
+
print("\n================ κ comparison (headline per axis) ================")
|
| 104 |
+
head = "axis".ljust(16) + "".join(v.ljust(12) for v in version_keys) + "gate(last)"
|
| 105 |
+
print(head)
|
| 106 |
+
for ax in axes:
|
| 107 |
+
row = ax.ljust(16)
|
| 108 |
+
last = None
|
| 109 |
+
for v in version_keys:
|
| 110 |
+
k = results[v][ax]["headline"]; last = k
|
| 111 |
+
row += (f"{k:+.3f}" if k is not None else "N/A").ljust(12)
|
| 112 |
+
print(row + gate(last))
|
| 113 |
+
# per-category technique/IQ detail for the last version
|
| 114 |
+
last = version_keys[-1]
|
| 115 |
+
print(f"\n--- per-category detail ({last}) ---")
|
| 116 |
+
for ax in ("technique", "input_quality"):
|
| 117 |
+
for f, (k, c, npos) in results[last][ax]["cats"].items():
|
| 118 |
+
ks = f"{k:+.3f}" if k is not None else "N/A"
|
| 119 |
+
print(f" {ax[:4]}.{f:22} κ={ks} pos={npos} [TN {c['tn']} FP {c['fp']} FN {c['fn']} TP {c['tp']}]")
|
| 120 |
+
fi = results[last]["interaction"]; c = fi["counts"]
|
| 121 |
+
print(f" interaction.refinement κ={fi['headline']:+.3f} pos={fi['pos']}/{fi['n']} "
|
| 122 |
+
f"[TN {c['tn']} FP {c['fp']} FN {c['fn']} TP {c['tp']}]")
|
| 123 |
+
ff = results[last]["focus"]
|
| 124 |
+
print(f" focus.topic_shift κ={ff['headline']:+.3f} T={ff['T']} recall={ff['recall']:.2f}")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
if __name__ == "__main__":
|
| 128 |
+
keys = sys.argv[1:] or ["baseline", "v2"]
|
| 129 |
+
main(keys)
|
eval/kappa.py
ADDED
|
@@ -0,0 +1,412 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase-2 measurement harness: base MiniCPM4.1-8B + bge-small embedder + rules vs Kim-verified
|
| 2 |
+
ground truth. Computes per-axis Cohen's kappa, per-category breakdowns (sparse → N/A), confusion
|
| 3 |
+
matrices, an OOD report, and embedder boundary-recall vs hand-identified Focus candidates.
|
| 4 |
+
|
| 5 |
+
Pure metrics (cohen_kappa, confusion) are dependency-free and unit-tested in tests/test_kappa.py.
|
| 6 |
+
8B calls go through a disk cache (eval/_cache/preds_8b.jsonl) keyed by sha1(prompt) + a thread pool,
|
| 7 |
+
so runs are resumable and Phase 3 (Critical) reuses every cached response.
|
| 8 |
+
|
| 9 |
+
Credentials (shared OpenBMB hackathon key) are read from the environment, never hardcoded:
|
| 10 |
+
OPENBMB_BASE_URL, OPENBMB_TOKEN
|
| 11 |
+
Run: python -m eval.kappa # 4 Phase-2 axes (Focus, Technique, Interaction, Input Quality)
|
| 12 |
+
python -m eval.kappa --limit 5 # quick probe on 5 convs (latency/parse check before full run)
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import concurrent.futures as cf
|
| 17 |
+
import hashlib
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import sys
|
| 21 |
+
import threading
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
|
| 25 |
+
from prompt_card.scoring import observable_axes as OA
|
| 26 |
+
from prompt_card.scoring.embedding import FastEmbedder
|
| 27 |
+
|
| 28 |
+
HERE = os.path.dirname(__file__)
|
| 29 |
+
ROOT = os.path.dirname(HERE)
|
| 30 |
+
VP = os.path.join(ROOT, "prompt_card", "training", "validation_pool")
|
| 31 |
+
CACHE = os.path.join(HERE, "_cache", "preds_8b.jsonl")
|
| 32 |
+
|
| 33 |
+
TECH = ["zero_shot_role", "few_shot", "thought_generation", "decomposition", "self_criticism", "flipped"]
|
| 34 |
+
IQ = ["goal_stated", "specific_specification"]
|
| 35 |
+
CE = ["skepticism", "rebuttal", "source_request", "independent_verification", "re_questioning"]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------- pure metrics (no deps) ----------------
|
| 39 |
+
|
| 40 |
+
def confusion(y_true, y_pred, labels):
|
| 41 |
+
idx = {l: i for i, l in enumerate(labels)}
|
| 42 |
+
m = [[0] * len(labels) for _ in labels]
|
| 43 |
+
for t, p in zip(y_true, y_pred):
|
| 44 |
+
m[idx[t]][idx[p]] += 1
|
| 45 |
+
return m
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def cohen_kappa(y_true, y_pred):
|
| 49 |
+
"""Cohen's kappa. Returns None when undefined (empty, or both raters use one identical label
|
| 50 |
+
→ chance agreement is 1, kappa degenerate). Caller reports those as N/A."""
|
| 51 |
+
n = len(y_true)
|
| 52 |
+
if n == 0:
|
| 53 |
+
return None
|
| 54 |
+
labels = sorted(set(y_true) | set(y_pred))
|
| 55 |
+
if len(labels) == 1:
|
| 56 |
+
return None # degenerate: everything one class (e.g. an all-negative sparse category)
|
| 57 |
+
m = confusion(y_true, y_pred, labels)
|
| 58 |
+
po = sum(m[i][i] for i in range(len(labels))) / n
|
| 59 |
+
rows = [sum(r) for r in m]
|
| 60 |
+
cols = [sum(m[i][j] for i in range(len(labels))) for j in range(len(labels))]
|
| 61 |
+
pe = sum((rows[i] / n) * (cols[i] / n) for i in range(len(labels)))
|
| 62 |
+
if pe >= 1.0:
|
| 63 |
+
return None
|
| 64 |
+
return (po - pe) / (1 - pe)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def binary_counts(y_true, y_pred):
|
| 68 |
+
tp = sum(1 for t, p in zip(y_true, y_pred) if t and p)
|
| 69 |
+
fp = sum(1 for t, p in zip(y_true, y_pred) if not t and p)
|
| 70 |
+
fn = sum(1 for t, p in zip(y_true, y_pred) if t and not p)
|
| 71 |
+
tn = sum(1 for t, p in zip(y_true, y_pred) if not t and not p)
|
| 72 |
+
return dict(tp=tp, fp=fp, fn=fn, tn=tn)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def prf(c):
|
| 76 |
+
tp, fp, fn = c["tp"], c["fp"], c["fn"]
|
| 77 |
+
p = tp / (tp + fp) if tp + fp else None
|
| 78 |
+
r = tp / (tp + fn) if tp + fn else None
|
| 79 |
+
f1 = (2 * p * r / (p + r)) if (p and r) else None
|
| 80 |
+
return p, r, f1
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
# ---------------- cached concurrent 8B ----------------
|
| 84 |
+
|
| 85 |
+
class CachedClient:
|
| 86 |
+
"""Wraps any `generate(prompt)->str` client with a sha1-keyed disk cache + thread pool."""
|
| 87 |
+
|
| 88 |
+
def __init__(self, base, workers=8):
|
| 89 |
+
self.base = base
|
| 90 |
+
self.workers = workers
|
| 91 |
+
self._lock = threading.Lock()
|
| 92 |
+
self.cache = {}
|
| 93 |
+
if os.path.exists(CACHE):
|
| 94 |
+
for line in open(CACHE):
|
| 95 |
+
try:
|
| 96 |
+
d = json.loads(line)
|
| 97 |
+
self.cache[d["k"]] = d["v"]
|
| 98 |
+
except Exception:
|
| 99 |
+
pass
|
| 100 |
+
self.hits = 0
|
| 101 |
+
self.misses = 0
|
| 102 |
+
|
| 103 |
+
@staticmethod
|
| 104 |
+
def _key(prompt):
|
| 105 |
+
return hashlib.sha1(prompt.encode("utf-8")).hexdigest()
|
| 106 |
+
|
| 107 |
+
def _persist(self, k, v):
|
| 108 |
+
with self._lock:
|
| 109 |
+
with open(CACHE, "a") as f:
|
| 110 |
+
f.write(json.dumps({"k": k, "v": v}, ensure_ascii=False) + "\n")
|
| 111 |
+
|
| 112 |
+
def run_all(self, prompts):
|
| 113 |
+
"""Return {prompt: response} for a list of prompts, using cache + concurrency."""
|
| 114 |
+
uniq = list(dict.fromkeys(prompts))
|
| 115 |
+
todo = [p for p in uniq if self._key(p) not in self.cache]
|
| 116 |
+
self.hits += len(uniq) - len(todo)
|
| 117 |
+
|
| 118 |
+
def work(p):
|
| 119 |
+
v = self.base.generate(p)
|
| 120 |
+
k = self._key(p)
|
| 121 |
+
self.cache[k] = v
|
| 122 |
+
self._persist(k, v)
|
| 123 |
+
return p
|
| 124 |
+
|
| 125 |
+
if todo:
|
| 126 |
+
with cf.ThreadPoolExecutor(max_workers=self.workers) as ex:
|
| 127 |
+
for i, _ in enumerate(ex.map(work, todo), 1):
|
| 128 |
+
self.misses += 1
|
| 129 |
+
if i % 50 == 0:
|
| 130 |
+
print(f" ... {i}/{len(todo)} new 8B calls", flush=True)
|
| 131 |
+
return {p: self.cache[self._key(p)] for p in uniq}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
# ---------------- data loading ----------------
|
| 135 |
+
|
| 136 |
+
def load_convs():
|
| 137 |
+
convs = {}
|
| 138 |
+
for fn in ("pool.jsonl", "supplement.jsonl"):
|
| 139 |
+
for line in open(os.path.join(VP, fn)):
|
| 140 |
+
c = json.loads(line)
|
| 141 |
+
convs[c["id"]] = c
|
| 142 |
+
return convs
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def user_turns(conv):
|
| 146 |
+
return [t["text"] for t in conv["turns"] if t["role"] == "user"]
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def load_gt():
|
| 150 |
+
return [json.loads(l) for l in open(os.path.join(VP, "ground_truth.jsonl"))]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ---------------- prediction assembly ----------------
|
| 154 |
+
|
| 155 |
+
def build_prompts(gt, convs, embedder, focus_tmax=0.70, builders=OA,
|
| 156 |
+
axes=("technique", "input_quality", "interaction", "focus")):
|
| 157 |
+
"""Return (prompts, plan, focus_geom). `builders` supplies build_*_prompt (swap for prompt versions);
|
| 158 |
+
`axes` selects which axes to assemble (lets the cascade re-run only changed axes)."""
|
| 159 |
+
prompts, plan = [], []
|
| 160 |
+
focus_geom = {} # conv_id -> list of (i, cosine) for every adjacent user-turn boundary
|
| 161 |
+
for r in gt:
|
| 162 |
+
cid = r["id"]
|
| 163 |
+
ut = user_turns(convs[cid])
|
| 164 |
+
if "technique" in axes:
|
| 165 |
+
for row in r["technique"]:
|
| 166 |
+
i = int(row["turn"][1:]) - 1
|
| 167 |
+
p = builders.build_technique_prompt(ut[i])
|
| 168 |
+
prompts.append(p); plan.append(("technique", cid, row["turn"], p))
|
| 169 |
+
if "input_quality" in axes:
|
| 170 |
+
for row in r["input_quality"]:
|
| 171 |
+
i = int(row["turn"][1:]) - 1
|
| 172 |
+
p = builders.build_input_quality_prompt(ut[i])
|
| 173 |
+
prompts.append(p); plan.append(("input_quality", cid, row["turn"], p))
|
| 174 |
+
if "interaction" in axes:
|
| 175 |
+
for row in r["interaction"]:
|
| 176 |
+
i = int(row["turn"][1:]) - 1
|
| 177 |
+
p = builders.build_interaction_prompt(ut[i - 1], ut[i])
|
| 178 |
+
prompts.append(p); plan.append(("interaction", cid, row["turn"], p))
|
| 179 |
+
# focus: embedder geometry over ALL adjacent boundaries; 8B only on cos<focus_tmax
|
| 180 |
+
if "focus" in axes and len(ut) >= 2:
|
| 181 |
+
vecs = embedder.embed(ut)
|
| 182 |
+
geom = []
|
| 183 |
+
for i in range(len(ut) - 1):
|
| 184 |
+
cos = float(np.dot(vecs[i], vecs[i + 1]))
|
| 185 |
+
geom.append((i, cos))
|
| 186 |
+
if cos < focus_tmax:
|
| 187 |
+
p = builders.build_focus_boundary_prompt(ut[i], ut[i + 1])
|
| 188 |
+
prompts.append(p); plan.append(("focus", cid, f"U{i+1}→U{i+2}", p))
|
| 189 |
+
focus_geom[cid] = geom
|
| 190 |
+
return prompts, plan, focus_geom
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------- axis scoring ----------------
|
| 194 |
+
|
| 195 |
+
def score_binary_axis(gt, responses, plan_index, axis, fields):
|
| 196 |
+
"""For a per-turn multi-binary axis (technique/input_quality): return per-field (yt,yp) + parse fails."""
|
| 197 |
+
per = {f: ([], []) for f in fields}
|
| 198 |
+
parse_fail = 0
|
| 199 |
+
gtmap = {r["id"]: r for r in gt}
|
| 200 |
+
key = "types" if axis in ("technique", "critical") else "features"
|
| 201 |
+
for (ax, cid, turn, prompt) in plan_index[axis]:
|
| 202 |
+
row = next(rr for rr in gtmap[cid][axis] if rr["turn"] == turn)
|
| 203 |
+
pred = OA.parse(responses[prompt], fields)
|
| 204 |
+
if pred is None:
|
| 205 |
+
parse_fail += 1
|
| 206 |
+
pred = {f: False for f in fields}
|
| 207 |
+
for f in fields:
|
| 208 |
+
per[f][0].append(int(f in row[key]))
|
| 209 |
+
per[f][1].append(int(bool(pred.get(f))))
|
| 210 |
+
return per, parse_fail
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
def score_interaction(gt, responses, plan_index):
|
| 214 |
+
yt, yp = [], []
|
| 215 |
+
parse_fail = 0
|
| 216 |
+
gtmap = {r["id"]: r for r in gt}
|
| 217 |
+
for (ax, cid, turn, prompt) in plan_index["interaction"]:
|
| 218 |
+
row = next(rr for rr in gtmap[cid]["interaction"] if rr["turn"] == turn)
|
| 219 |
+
pred = OA.parse(responses[prompt], ("refinement_attempt",))
|
| 220 |
+
if pred is None:
|
| 221 |
+
parse_fail += 1
|
| 222 |
+
pred = {}
|
| 223 |
+
yt.append(int(bool(row["refinement"])))
|
| 224 |
+
yp.append(int(bool(pred.get("refinement_attempt"))))
|
| 225 |
+
return yt, yp, parse_fail
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def score_focus(gt, responses, plan_index, focus_geom, threshold):
|
| 229 |
+
"""Binary shift vs not over EVERY adjacent boundary; embedder gates 8B at `threshold`.
|
| 230 |
+
Also returns embedder recall on GT topic_shift boundaries and a 3-class tally on co-identified."""
|
| 231 |
+
rel_pred = {(cid, turn): OA.parse(responses[p], ("relation",)) for (ax, cid, turn, p) in plan_index["focus"]}
|
| 232 |
+
gtmap = {r["id"]: r for r in gt}
|
| 233 |
+
yt, yp = [], []
|
| 234 |
+
gt_shift_total = recall_hit = 0
|
| 235 |
+
cand_total = 0
|
| 236 |
+
three = {} # (gt_rel, pred_rel) -> count on co-identified candidates
|
| 237 |
+
for r in gt:
|
| 238 |
+
cid = r["id"]
|
| 239 |
+
gt_rel = {f"U{c['a'][1:]}→U{c['b'][1:]}" if False else f"{c['a']}→{c['b']}": c["relation"] for c in r["focus"]}
|
| 240 |
+
for (i, cos) in focus_geom.get(cid, []):
|
| 241 |
+
bkey = f"U{i+1}→U{i+2}"
|
| 242 |
+
gtr = gt_rel.get(bkey)
|
| 243 |
+
is_gt_shift = (gtr == "topic_shift")
|
| 244 |
+
yt.append(int(is_gt_shift))
|
| 245 |
+
is_cand = cos < threshold
|
| 246 |
+
cand_total += int(is_cand)
|
| 247 |
+
pred_shift = 0
|
| 248 |
+
if is_cand:
|
| 249 |
+
pr = rel_pred.get((cid, bkey)) or {}
|
| 250 |
+
prel = pr.get("relation")
|
| 251 |
+
pred_shift = int(prel == "topic_shift")
|
| 252 |
+
if gtr is not None: # co-identified: both GT and embedder flagged this boundary
|
| 253 |
+
three[(gtr, prel)] = three.get((gtr, prel), 0) + 1
|
| 254 |
+
yp.append(pred_shift)
|
| 255 |
+
if is_gt_shift:
|
| 256 |
+
gt_shift_total += 1
|
| 257 |
+
recall_hit += int(is_cand)
|
| 258 |
+
recall = recall_hit / gt_shift_total if gt_shift_total else None
|
| 259 |
+
return yt, yp, dict(recall=recall, gt_shift=gt_shift_total, cand_total=cand_total, three=three)
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
# ---------------- OOD ----------------
|
| 263 |
+
|
| 264 |
+
def ood_report(gt, convs, embedder):
|
| 265 |
+
ids = [r["id"] for r in gt]
|
| 266 |
+
centroids = []
|
| 267 |
+
for cid in ids:
|
| 268 |
+
ut = user_turns(convs[cid])
|
| 269 |
+
v = embedder.embed(ut)
|
| 270 |
+
c = v.mean(axis=0)
|
| 271 |
+
centroids.append(c / (np.linalg.norm(c) + 1e-9))
|
| 272 |
+
M = np.array(centroids)
|
| 273 |
+
glob = M.mean(axis=0); glob /= np.linalg.norm(glob) + 1e-9
|
| 274 |
+
sims = M @ glob
|
| 275 |
+
mu, sd = float(sims.mean()), float(sims.std())
|
| 276 |
+
flagged = [(ids[i], float(sims[i])) for i in range(len(ids)) if sims[i] < mu - 2 * sd]
|
| 277 |
+
return dict(mu=mu, sd=sd, flagged=sorted(flagged, key=lambda x: x[1]))
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# ---------------- main ----------------
|
| 281 |
+
|
| 282 |
+
def _fmt_k(k):
|
| 283 |
+
return "N/A" if k is None else f"{k:+.3f}"
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def main(limit=None, workers=8):
|
| 287 |
+
base_url = os.environ.get("OPENBMB_BASE_URL")
|
| 288 |
+
token = os.environ.get("OPENBMB_TOKEN")
|
| 289 |
+
if not base_url or not token:
|
| 290 |
+
print("ERROR: set OPENBMB_BASE_URL and OPENBMB_TOKEN in the environment (shared hackathon key).",
|
| 291 |
+
file=sys.stderr)
|
| 292 |
+
sys.exit(2)
|
| 293 |
+
from prompt_card.llm.minicpm import MiniCPMClient
|
| 294 |
+
base = MiniCPMClient(base_url, token)
|
| 295 |
+
|
| 296 |
+
gt = load_gt()
|
| 297 |
+
if limit:
|
| 298 |
+
gt = gt[:limit]
|
| 299 |
+
convs = load_convs()
|
| 300 |
+
embedder = FastEmbedder()
|
| 301 |
+
print(f"[measure] {len(gt)} convs; embedding + assembling prompts ...", flush=True)
|
| 302 |
+
prompts, plan, focus_geom = build_prompts(gt, convs, embedder)
|
| 303 |
+
plan_index = {}
|
| 304 |
+
for item in plan:
|
| 305 |
+
plan_index.setdefault(item[0], []).append(item)
|
| 306 |
+
print(f"[measure] {len(prompts)} prompts "
|
| 307 |
+
f"(tech {len(plan_index.get('technique',[]))}, iq {len(plan_index.get('input_quality',[]))}, "
|
| 308 |
+
f"inter {len(plan_index.get('interaction',[]))}, focus {len(plan_index.get('focus',[]))})", flush=True)
|
| 309 |
+
|
| 310 |
+
client = CachedClient(base, workers=workers)
|
| 311 |
+
responses = client.run_all(prompts)
|
| 312 |
+
print(f"[measure] 8B done (cache hits {client.hits}, new {client.misses})", flush=True)
|
| 313 |
+
|
| 314 |
+
# axis scoring
|
| 315 |
+
tech_per, tech_fail = score_binary_axis(gt, responses, plan_index, "technique", TECH)
|
| 316 |
+
iq_per, iq_fail = score_binary_axis(gt, responses, plan_index, "input_quality", IQ)
|
| 317 |
+
inter_yt, inter_yp, inter_fail = score_interaction(gt, responses, plan_index)
|
| 318 |
+
# focus threshold sweep
|
| 319 |
+
sweep = {}
|
| 320 |
+
for T in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:
|
| 321 |
+
yt, yp, info = score_focus(gt, responses, plan_index, focus_geom, T)
|
| 322 |
+
sweep[T] = (cohen_kappa(yt, yp), info["recall"], info["cand_total"], yt, yp, info)
|
| 323 |
+
# pick T maximizing kappa (tie-break higher recall)
|
| 324 |
+
bestT = max(sweep, key=lambda T: (sweep[T][0] if sweep[T][0] is not None else -9, sweep[T][1] or 0))
|
| 325 |
+
fk, frec, fcand, fyt, fyp, finfo = sweep[bestT]
|
| 326 |
+
|
| 327 |
+
write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail,
|
| 328 |
+
sweep, bestT, ood_report(gt, convs, embedder))
|
| 329 |
+
print("[measure] wrote eval/measurement_results.md", flush=True)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def write_results(gt, tech_per, tech_fail, iq_per, iq_fail, inter_yt, inter_yp, inter_fail,
|
| 333 |
+
sweep, bestT, ood):
|
| 334 |
+
L = ["# Phase-2 measurement — base MiniCPM4.1-8B + bge-small + rules vs ground truth", ""]
|
| 335 |
+
L.append(f"Validation set: {len(gt)} conversations. Metric: Cohen's κ (per axis & per category). "
|
| 336 |
+
"Sparse categories (no positives) → κ undefined → **N/A**. Confusion as [TN FP / FN TP].")
|
| 337 |
+
L.append("")
|
| 338 |
+
|
| 339 |
+
def axis_block(title, scope, per, fail, fields):
|
| 340 |
+
L.append(f"## {title}")
|
| 341 |
+
L.append(f"_{scope}_ · parse failures: {fail}")
|
| 342 |
+
L.append("| category | κ | TN | FP | FN | TP | precision | recall | f1 |")
|
| 343 |
+
L.append("|---|---|---|---|---|---|---|---|---|")
|
| 344 |
+
for f in fields:
|
| 345 |
+
yt, yp = per[f]
|
| 346 |
+
k = cohen_kappa(yt, yp); c = binary_counts(yt, yp); p, r, f1 = prf(c)
|
| 347 |
+
note = " ← N/A (0 positives)" if sum(yt) == 0 else ""
|
| 348 |
+
L.append(f"| {f} | {_fmt_k(k)}{note} | {c['tn']} | {c['fp']} | {c['fn']} | {c['tp']} | "
|
| 349 |
+
f"{'-' if p is None else f'{p:.2f}'} | {'-' if r is None else f'{r:.2f}'} | "
|
| 350 |
+
f"{'-' if f1 is None else f'{f1:.2f}'} |")
|
| 351 |
+
# axis-level "any" binary
|
| 352 |
+
anyt = [int(any(per[f][0][j] for f in fields)) for j in range(len(per[fields[0]][0]))]
|
| 353 |
+
anyp = [int(any(per[f][1][j] for f in fields)) for j in range(len(per[fields[0]][1]))]
|
| 354 |
+
L.append(f"\n**Axis-level (any {title.split()[0].lower()} present): κ = {_fmt_k(cohen_kappa(anyt, anyp))}** "
|
| 355 |
+
f"(n={len(anyt)} turns, {sum(anyt)} positive).")
|
| 356 |
+
L.append("")
|
| 357 |
+
|
| 358 |
+
axis_block("Technique (6 binary)", "first ≤3 user turns/conv", tech_per, tech_fail, TECH)
|
| 359 |
+
axis_block("Input Quality (2 binary)", "first ≤3 user turns/conv", iq_per, iq_fail, IQ)
|
| 360 |
+
|
| 361 |
+
L.append("## Interaction (refinement_attempt)")
|
| 362 |
+
L.append(f"_all follow-up turns_ · parse failures: {inter_fail}")
|
| 363 |
+
k = cohen_kappa(inter_yt, inter_yp); c = binary_counts(inter_yt, inter_yp); p, r, f1 = prf(c)
|
| 364 |
+
L.append(f"- **κ = {_fmt_k(k)}** · n={len(inter_yt)}, positives={sum(inter_yt)}")
|
| 365 |
+
L.append(f"- confusion [TN {c['tn']} · FP {c['fp']} / FN {c['fn']} · TP {c['tp']}] · "
|
| 366 |
+
f"P {'-' if p is None else f'{p:.2f}'} R {'-' if r is None else f'{r:.2f}'} F1 {'-' if f1 is None else f'{f1:.2f}'}")
|
| 367 |
+
L.append("")
|
| 368 |
+
|
| 369 |
+
L.append("## Focus (topic_shift detection, embedder-gated)")
|
| 370 |
+
L.append("Binary shift-vs-not over **every** adjacent user-turn boundary; the embedder gates which "
|
| 371 |
+
"boundaries the 8B classifies (cosine < T). Threshold swept; κ-maximizing T selected.")
|
| 372 |
+
L.append("| T | κ (shift) | embedder recall@T (on GT shifts) | candidates flagged |")
|
| 373 |
+
L.append("|---|---|---|---|")
|
| 374 |
+
for T in sorted(sweep):
|
| 375 |
+
k, rec, cand, *_ = sweep[T]
|
| 376 |
+
mark = " ← selected" if T == bestT else ""
|
| 377 |
+
L.append(f"| {T:.2f} | {_fmt_k(k)} | {'-' if rec is None else f'{rec:.2f}'} | {cand} |{mark}")
|
| 378 |
+
fk, frec, fcand, fyt, fyp, finfo = sweep[bestT]
|
| 379 |
+
cc = binary_counts(fyt, fyp)
|
| 380 |
+
rec_str = "-" if frec is None else f"{frec:.2f}"
|
| 381 |
+
hits = "" if frec is None else f" ({int(round(frec * finfo['gt_shift']))}/{finfo['gt_shift']} GT shifts flagged)"
|
| 382 |
+
L.append("")
|
| 383 |
+
L.append(f"**Selected T={bestT:.2f}: κ = {_fmt_k(fk)}**, embedder boundary-recall = {rec_str}{hits}. "
|
| 384 |
+
f"Confusion [TN {cc['tn']} · FP {cc['fp']} / FN {cc['fn']} · TP {cc['tp']}].")
|
| 385 |
+
if finfo["three"]:
|
| 386 |
+
L.append("\n3-class agreement on co-identified candidates (GT rel → 8B rel):")
|
| 387 |
+
for (g, p2), n in sorted(finfo["three"].items(), key=lambda x: -x[1]):
|
| 388 |
+
L.append(f"- {g} → {p2}: {n}")
|
| 389 |
+
L.append("\n_Embedder boundary-recall is the model-agnostic metric Kim requested: of the boundaries Kim "
|
| 390 |
+
"hand-identified as topic_shift, how many the production embedder surfaces as candidates._")
|
| 391 |
+
L.append("")
|
| 392 |
+
|
| 393 |
+
L.append("## OOD report (validation-set self-check)")
|
| 394 |
+
L.append(f"Per-conv mean-user-turn embedding vs the set centroid: μ_sim={ood['mu']:.3f}, σ={ood['sd']:.3f}. "
|
| 395 |
+
f"Flagged (sim < μ−2σ, i.e. atypical conversations): {len(ood['flagged'])}.")
|
| 396 |
+
for cid, s in ood["flagged"][:15]:
|
| 397 |
+
L.append(f"- `{cid[:12]}` sim={s:.3f}")
|
| 398 |
+
L.append("\n_In production this same distance flags user uploads far from the validation distribution "
|
| 399 |
+
"(low-confidence / out-of-scope scoring)._")
|
| 400 |
+
L.append("")
|
| 401 |
+
|
| 402 |
+
with open(os.path.join(HERE, "measurement_results.md"), "w") as f:
|
| 403 |
+
f.write("\n".join(L) + "\n")
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
if __name__ == "__main__":
|
| 407 |
+
import argparse
|
| 408 |
+
ap = argparse.ArgumentParser()
|
| 409 |
+
ap.add_argument("--limit", type=int, default=None)
|
| 410 |
+
ap.add_argument("--workers", type=int, default=8)
|
| 411 |
+
args = ap.parse_args()
|
| 412 |
+
main(limit=args.limit, workers=args.workers)
|
eval/measure_lora.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3 Step 5 — re-measure a trained LoRA on the validation set and compare to base-8B.
|
| 2 |
+
|
| 3 |
+
Builds the SAME validation prompts used for the base measurement (apples-to-apples), runs them through
|
| 4 |
+
the Modal base+adapter batch function, parses, and computes Cohen's κ vs the Kim ground truth.
|
| 5 |
+
|
| 6 |
+
Run (from a Modal-authed machine): python -m eval.measure_lora interaction
|
| 7 |
+
python -m eval.measure_lora critical
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
|
| 15 |
+
from eval import kappa as K
|
| 16 |
+
from eval.prompts_v3 import build_interaction_prompt
|
| 17 |
+
from eval.step_critical import build_critical_prompt, CE
|
| 18 |
+
from eval.step_c import goal_prompt, decomp_prompt
|
| 19 |
+
from prompt_card.scoring import observable_axes as OA
|
| 20 |
+
|
| 21 |
+
# base-8B κ per axis/category (from the cascade) for the lock-vs-keep comparison
|
| 22 |
+
BASE_KAPPA = {"interaction": 0.320, "goal_stated": 0.226, "decomposition": 0.261, "re_questioning": 0.058}
|
| 23 |
+
# single-feature axes: (prompt builder over the scored turns, the feature name, which GT axis holds it)
|
| 24 |
+
SINGLE = {"goal_stated": (goal_prompt, "goal_stated", "input_quality"),
|
| 25 |
+
"decomposition": (decomp_prompt, "decomposition", "technique")}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def build(axis, gt, convs):
|
| 29 |
+
prompts, truth = [], []
|
| 30 |
+
for r in gt:
|
| 31 |
+
conv = convs[r["id"]]
|
| 32 |
+
ut = OA._user_turns(conv)
|
| 33 |
+
if axis == "interaction":
|
| 34 |
+
for row in r["interaction"]:
|
| 35 |
+
i = int(row["turn"][1:]) - 1
|
| 36 |
+
prompts.append(build_interaction_prompt(ut[i - 1], ut[i]))
|
| 37 |
+
truth.append(set(["refinement_attempt"]) if row["refinement"] else set())
|
| 38 |
+
elif axis == "critical":
|
| 39 |
+
for row in r["critical"]:
|
| 40 |
+
i = int(row["turn"][1:]) - 1
|
| 41 |
+
prompts.append(build_critical_prompt(OA._prev_assistant(conv, i), ut[i]))
|
| 42 |
+
truth.append(set(row["types"]))
|
| 43 |
+
elif axis in SINGLE:
|
| 44 |
+
builder, feat, gt_axis = SINGLE[axis]
|
| 45 |
+
key = "features" if gt_axis == "input_quality" else "types"
|
| 46 |
+
for row in r[gt_axis]:
|
| 47 |
+
i = int(row["turn"][1:]) - 1
|
| 48 |
+
prompts.append(builder(ut[i]))
|
| 49 |
+
truth.append({feat} if feat in row[key] else set())
|
| 50 |
+
return prompts, truth
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main(axis, adapter=""):
|
| 54 |
+
from modal_eval_lora import app, evaluate
|
| 55 |
+
gt = K.load_gt(); convs = K.load_convs()
|
| 56 |
+
prompts, truth = build(axis, gt, convs)
|
| 57 |
+
tag = f"{axis} (adapter={adapter or axis})"
|
| 58 |
+
print(f"[lora-eval] {tag}: {len(prompts)} prompts -> Modal base+adapter ...", flush=True)
|
| 59 |
+
with app.run():
|
| 60 |
+
resp = evaluate.remote(axis, prompts, adapter=adapter)
|
| 61 |
+
|
| 62 |
+
if axis == "interaction":
|
| 63 |
+
fields = ("refinement_attempt",)
|
| 64 |
+
elif axis in SINGLE:
|
| 65 |
+
fields = (SINGLE[axis][1],)
|
| 66 |
+
else:
|
| 67 |
+
fields = CE
|
| 68 |
+
fail = 0
|
| 69 |
+
preds = []
|
| 70 |
+
for rtext in resp:
|
| 71 |
+
d = OA.parse(rtext, fields)
|
| 72 |
+
if d is None:
|
| 73 |
+
fail += 1; d = {}
|
| 74 |
+
preds.append({f for f in fields if d.get(f)})
|
| 75 |
+
|
| 76 |
+
print(f"[lora-eval] {axis}: parse_fail {fail}/{len(prompts)}")
|
| 77 |
+
if axis == "interaction" or axis in SINGLE:
|
| 78 |
+
feat = "refinement_attempt" if axis == "interaction" else SINGLE[axis][1]
|
| 79 |
+
yt = [int(feat in s) for s in truth]
|
| 80 |
+
yp = [int(feat in s) for s in preds]
|
| 81 |
+
k = K.cohen_kappa(yt, yp)
|
| 82 |
+
base_k = BASE_KAPPA[axis]
|
| 83 |
+
print(f" {axis} κ: base {base_k:+.3f} -> LoRA {k:+.3f} [{K.binary_counts(yt, yp)}]")
|
| 84 |
+
result = {"axis": axis, "lora_kappa": k, "base_kappa": base_k}
|
| 85 |
+
else:
|
| 86 |
+
base = json.load(open(os.path.join(os.path.dirname(__file__), "_cache", "critical.json")))
|
| 87 |
+
per = {}
|
| 88 |
+
for t in CE:
|
| 89 |
+
yt = [int(t in s) for s in truth]; yp = [int(t in s) for s in preds]
|
| 90 |
+
per[t] = K.cohen_kappa(yt, yp)
|
| 91 |
+
print(f" {t:24} base {base['per_type'][t]:+.3f} -> LoRA {(per[t] if per[t] is not None else float('nan')):+.3f}")
|
| 92 |
+
valid = [v for v in per.values() if v is not None]
|
| 93 |
+
head = sum(valid) / len(valid)
|
| 94 |
+
print(f" per-type mean: base {base['headline']:+.3f} -> LoRA {head:+.3f}")
|
| 95 |
+
result = {"axis": axis, "lora_per_type": per, "lora_headline": head, "base_headline": base["headline"]}
|
| 96 |
+
|
| 97 |
+
result["adapter"] = adapter or axis
|
| 98 |
+
json.dump(result, open(os.path.join(os.path.dirname(__file__), "_cache", f"lora_{adapter or axis}.json"), "w"),
|
| 99 |
+
indent=1, default=str)
|
| 100 |
+
print(f" decision: {'LOCK LoRA' if _wins(result, axis) else 'KEEP base'}")
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _wins(result, axis):
|
| 104 |
+
# Phase-5 rule: LoRA κ ≥ base + 0.1 → use LoRA.
|
| 105 |
+
if "lora_kappa" in result:
|
| 106 |
+
return (result["lora_kappa"] or 0) >= (result["base_kappa"] or 0) + 0.1
|
| 107 |
+
return (result["lora_headline"] or 0) >= (result["base_headline"] or 0) + 0.1
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
if __name__ == "__main__":
|
| 111 |
+
# usage: python -m eval.measure_lora <axis> [adapter_dir_name]
|
| 112 |
+
main(sys.argv[1] if len(sys.argv) > 1 else "interaction",
|
| 113 |
+
sys.argv[2] if len(sys.argv) > 2 else "")
|
eval/prompts_v2.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP A prompt rewrites (v2). Same field names/JSON shape as schemas.py so eval.kappa.parse is
|
| 2 |
+
unchanged — only the instruction text changes (→ new cache keys, clean A/B vs baseline).
|
| 3 |
+
|
| 4 |
+
Each prompt now carries: (1) the exact GT definition from observable_schemas_review.md, (2) positive
|
| 5 |
+
AND negative examples chosen to counter the measured base-8B biases, (3) a strict JSON template.
|
| 6 |
+
|
| 7 |
+
Measured baseline biases this targets:
|
| 8 |
+
goal_stated R0.84/P0.12 → model too LIBERAL → make strict, show task-only negatives
|
| 9 |
+
specific_specification R0.14/P0.88 → model too STRICT → make liberal, show what counts
|
| 10 |
+
refinement R0.85/P0.31 → model over-fires → tighten, default NO, show new-subtask negatives
|
| 11 |
+
few_shot 0/7, decomposition 0/17 → model too strict → concrete positives in WildChat style
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _ask(instr: str, template: str, payload_label: str, payload: str) -> str:
|
| 17 |
+
return (f"{instr}\n\nReturn ONLY a JSON object of EXACTLY this shape (no prose, no markdown):\n"
|
| 18 |
+
f"{template}\n\n{payload_label}:\n\"\"\"\n{payload}\n\"\"\"\nJSON:")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ---------------- Technique ----------------
|
| 22 |
+
|
| 23 |
+
_TECH_TEMPLATE = ('{"zero_shot_role": <bool>, "few_shot": <bool>, "thought_generation": <bool>, '
|
| 24 |
+
'"decomposition": <bool>, "self_criticism": <bool>, "flipped": <bool>}')
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def build_technique_prompt(user_msg: str) -> str:
|
| 28 |
+
instr = (
|
| 29 |
+
"Detect which prompting techniques THIS single user message uses. Mark true only when the feature "
|
| 30 |
+
"is genuinely present in the user's own words. Judge each independently.\n"
|
| 31 |
+
"- zero_shot_role: the user assigns a role/persona to the AI. ▸ 'You are a senior tax lawyer…', "
|
| 32 |
+
"'Act as a Linux terminal'. ✗ the user describing THEMSELF ('I'm a law student') — that is NOT this.\n"
|
| 33 |
+
"- few_shot: the user gives one or more concrete in-context examples / a sample to imitate. "
|
| 34 |
+
"▸ 'Format like: Input: 3 → Output: nine', 'Here's an example email: <text>. Write three more like it', "
|
| 35 |
+
"'Translate these: cat=gato, dog=perro, bird=?'. ✗ 'for example, keep it short' (no actual exemplar).\n"
|
| 36 |
+
"- thought_generation: the user explicitly asks the AI to REASON/show its thinking. ▸ 'think step by "
|
| 37 |
+
"step', 'show your reasoning', 'explain your reasoning before answering'. ✗ 'write a step-by-step guide' "
|
| 38 |
+
"(that asks for step-by-step OUTPUT, not reasoning).\n"
|
| 39 |
+
"- decomposition: the user breaks the request into multiple ordered/numbered sub-tasks or parts. "
|
| 40 |
+
"▸ 'First summarize it, then list risks, then propose fixes', '1) do X 2) do Y 3) do Z', a numbered "
|
| 41 |
+
"list of distinct requirements. ✗ a single undifferentiated ask.\n"
|
| 42 |
+
"- self_criticism: the user asks the AI to check/critique/verify ITS OWN answer. ▸ 'double-check your "
|
| 43 |
+
"answer for errors', 'review what you wrote and flag mistakes'. ✗ 'check my code' (the user's work, not the AI's).\n"
|
| 44 |
+
"- flipped: the user asks the AI to ask THEM clarifying questions first. ▸ 'ask me any questions you "
|
| 45 |
+
"need before starting'. ✗ the user simply asking a question."
|
| 46 |
+
)
|
| 47 |
+
return _ask(instr, _TECH_TEMPLATE, "User message", user_msg)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ---------------- Input Quality ----------------
|
| 51 |
+
|
| 52 |
+
_IQ_TEMPLATE = '{"goal_stated": <bool>, "specific_specification": <bool>}'
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_input_quality_prompt(user_msg: str) -> str:
|
| 56 |
+
instr = (
|
| 57 |
+
"Detect two INDEPENDENT features of this single user message. Apply each rule exactly — they have "
|
| 58 |
+
"deliberately different strictness.\n\n"
|
| 59 |
+
"goal_stated — STRICT. True ONLY if the user states a WHY / purpose / intended outcome, not merely the "
|
| 60 |
+
"task. Look for 'so that', 'for', 'because', 'to help me…', 'I'm trying to…', a use-for.\n"
|
| 61 |
+
" ▸ TRUE: 'Dedupe my survey data FOR ANALYSIS', 'I'm trying to speed up my API, help me add caching', "
|
| 62 |
+
"'Summarize this so I can brief my team'.\n"
|
| 63 |
+
" ✗ FALSE (task only, no why): 'Write a sorting function', 'Dedupe this list', 'Translate this email', "
|
| 64 |
+
"'Give me 10 marketing ideas'. If you only see WHAT to do and not WHY, it is FALSE.\n\n"
|
| 65 |
+
"specific_specification — LIBERAL. True if the message contains ANY concrete detail of ANY kind: a "
|
| 66 |
+
"number, version, named tool/library/framework, file/data/schema, an explicit constraint, OR the user's "
|
| 67 |
+
"OWN background/role/skill level. Be generous — a single concrete detail is enough.\n"
|
| 68 |
+
" ▸ TRUE: 'Python 3.11, under 100ms', 'I'm a beginner', 'here's my CSV with columns date,revenue', "
|
| 69 |
+
"'in React with TypeScript', 'a 1000-word post', 'using pandas'.\n"
|
| 70 |
+
" ✗ FALSE only when fully vague: 'make it fast', 'help me with my code', 'write something good'.\n"
|
| 71 |
+
"Note: a role assigned to the AI ('you are an expert') is NOT input quality — ignore it here."
|
| 72 |
+
)
|
| 73 |
+
return _ask(instr, _IQ_TEMPLATE, "User message", user_msg)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
# ---------------- Interaction ----------------
|
| 77 |
+
|
| 78 |
+
_INT_TEMPLATE = '{"refinement_attempt": <bool>}'
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def build_interaction_prompt(prev_user: str, this_user: str) -> str:
|
| 82 |
+
instr = (
|
| 83 |
+
"Did the user, in THIS follow-up, attempt to REFINE the PRIOR request to reshape the SAME output? "
|
| 84 |
+
"Detect the attempt only (not whether it worked). Default to FALSE unless it clearly reshapes the "
|
| 85 |
+
"previous output.\n"
|
| 86 |
+
" ▸ TRUE (reshapes the same deliverable): 'make it shorter', 'use Python instead', 'actually I meant X', "
|
| 87 |
+
"'more formal tone', 'that's wrong, fix the loop', 'redo it without the intro'.\n"
|
| 88 |
+
" ✗ FALSE — a NEW request or NEW sub-task on the same project: 'now write tests for it', 'also add a "
|
| 89 |
+
"README', 'next, explain how it works', a brand-new question, 'thanks', or simply accepting the output. "
|
| 90 |
+
"Producing a NEW artifact is NOT a refinement; only editing/adjusting the PREVIOUS one is."
|
| 91 |
+
)
|
| 92 |
+
payload = f"PRIOR user request:\n{prev_user}\n\nTHIS follow-up:\n{this_user}"
|
| 93 |
+
return _ask(instr, _INT_TEMPLATE, "Turns", payload)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---------------- Focus boundary ----------------
|
| 97 |
+
|
| 98 |
+
_FOCUS_TEMPLATE = '{"relation": "same_topic" | "related_sub_task" | "topic_shift"}'
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def build_focus_boundary_prompt(turn_a: str, turn_b: str) -> str:
|
| 102 |
+
instr = (
|
| 103 |
+
"Classify how the SECOND user turn relates to the FIRST. Pick exactly one.\n"
|
| 104 |
+
"- same_topic: clearly the same subject/question. ▸ 'explain binary search' → 'what's its time complexity?'\n"
|
| 105 |
+
"- related_sub_task: a different sub-task that serves the SAME concrete project/artifact/goal (same "
|
| 106 |
+
"project, NOT merely the same field). ▸ 'write the Flask endpoint' → 'now write pytest tests for it'.\n"
|
| 107 |
+
"- topic_shift: a genuinely unrelated change, OR two separate questions that merely share a domain. "
|
| 108 |
+
"▸ 'debug my Python loop' → 'what's a good carbonara recipe?' ▸ 'explain recursion' → 'explain dynamic "
|
| 109 |
+
"programming' (same field, different question = topic_shift)."
|
| 110 |
+
)
|
| 111 |
+
payload = f"FIRST user turn:\n{turn_a}\n\nSECOND user turn:\n{turn_b}"
|
| 112 |
+
return _ask(instr, _FOCUS_TEMPLATE, "Turns", payload)
|
eval/prompts_v3.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP A iter 2 (v3). Keeps v2's winners unchanged (technique, specific_specification, focus → cache
|
| 2 |
+
reused) and only re-balances the two v2 over-corrections:
|
| 3 |
+
- interaction: v2 over-tightened (recall 0.85→0.16). Broaden TRUE to corrections / added constraints /
|
| 4 |
+
redo-differently while still excluding genuinely NEW deliverables.
|
| 5 |
+
- goal_stated: v2 over-strict (recall 0.84→0.22). Accept implied purpose / use / audience, not just
|
| 6 |
+
explicit 'so that'.
|
| 7 |
+
specific_specification keeps the v2 (liberal) wording verbatim so its cache entries are reused.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from eval.prompts_v2 import build_technique_prompt, build_focus_boundary_prompt, _ask # reused as-is
|
| 12 |
+
|
| 13 |
+
_IQ_TEMPLATE = '{"goal_stated": <bool>, "specific_specification": <bool>}'
|
| 14 |
+
_INT_TEMPLATE = '{"refinement_attempt": <bool>}'
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def build_input_quality_prompt(user_msg: str) -> str:
|
| 18 |
+
instr = (
|
| 19 |
+
"Detect two INDEPENDENT features of this single user message. Apply each rule exactly.\n\n"
|
| 20 |
+
"goal_stated. True if the user conveys a PURPOSE, use, audience, or reason — stated OR clearly implied "
|
| 21 |
+
"by context — for the request. False if it is only the bare task with no purpose/use/audience.\n"
|
| 22 |
+
" ▸ TRUE: 'summarize this for my class', 'I'm building a budgeting app, write the DB schema', "
|
| 23 |
+
"'help me debug so the tests pass', 'dedupe my survey data for analysis', 'a cover letter for a "
|
| 24 |
+
"data-analyst job', 'explain it so a beginner gets it'.\n"
|
| 25 |
+
" ✗ FALSE (task only, no why/use/audience): 'write a poem', 'fix this code', 'list 10 startup names', "
|
| 26 |
+
"'translate this', 'write a sorting function'.\n\n"
|
| 27 |
+
"specific_specification — LIBERAL. True if the message contains ANY concrete detail of ANY kind: a "
|
| 28 |
+
"number, version, named tool/library/framework, file/data/schema, an explicit constraint, OR the user's "
|
| 29 |
+
"OWN background/role/skill level. Be generous — a single concrete detail is enough.\n"
|
| 30 |
+
" ▸ TRUE: 'Python 3.11, under 100ms', 'I'm a beginner', 'here's my CSV with columns date,revenue', "
|
| 31 |
+
"'in React with TypeScript', 'a 1000-word post', 'using pandas'.\n"
|
| 32 |
+
" ✗ FALSE only when fully vague: 'make it fast', 'help me with my code', 'write something good'.\n"
|
| 33 |
+
"A role assigned to the AI ('you are an expert') is NOT input quality — ignore it here."
|
| 34 |
+
)
|
| 35 |
+
return _ask(instr, _IQ_TEMPLATE, "User message", user_msg)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def build_interaction_prompt(prev_user: str, this_user: str) -> str:
|
| 39 |
+
instr = (
|
| 40 |
+
"Did the user, in THIS follow-up, attempt to REFINE the PRIOR request — i.e. adjust, correct, or "
|
| 41 |
+
"re-direct it to change the SAME deliverable? Detect the attempt only (not whether it worked).\n"
|
| 42 |
+
" ▸ TRUE (modifies the same output): shorten/lengthen, change tone/format/language, corrections "
|
| 43 |
+
"('that's wrong', 'fix the loop', 'no, I meant…'), add or drop a constraint on the same output, "
|
| 44 |
+
"'redo it but more formal', 'try again', 'make the intro punchier'. If the follow-up is clearly "
|
| 45 |
+
"changing the previous answer, choose TRUE.\n"
|
| 46 |
+
" ✗ FALSE only for a genuinely NEW deliverable or NEW question: 'now write tests for it', 'also build "
|
| 47 |
+
"a UI', a fresh unrelated ask, or mere acceptance / 'thanks'. Producing a NEW artifact is not a "
|
| 48 |
+
"refinement; adjusting the PREVIOUS one is."
|
| 49 |
+
)
|
| 50 |
+
payload = f"PRIOR user request:\n{prev_user}\n\nTHIS follow-up:\n{this_user}"
|
| 51 |
+
return _ask(instr, _INT_TEMPLATE, "Turns", payload)
|
eval/prompts_v4.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP B (few-shot). Best-per-axis prompts (technique=v2, input_quality=v2, interaction=v3, focus=v2)
|
| 2 |
+
PLUS a hand-authored few-shot block per axis. All few-shot examples are SYNTHETIC/illustrative and
|
| 3 |
+
deliberately DISJOINT from the 159 validation conversations — no train-on-test leakage.
|
| 4 |
+
|
| 5 |
+
Targets the STEP-A residual failures: few_shot (0/7), decomposition (0.26), goal_stated (oscillating
|
| 6 |
+
~0.2), interaction false positives.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from eval.prompts_v2 import build_focus_boundary_prompt as _focus_v2 # focus unchanged (already OK)
|
| 11 |
+
|
| 12 |
+
# import the v2/v3 instruction bodies by rebuilding with an examples block appended.
|
| 13 |
+
from eval.prompts_v2 import _ask, _TECH_TEMPLATE, _IQ_TEMPLATE
|
| 14 |
+
from eval.prompts_v3 import _INT_TEMPLATE
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ---------------- Technique (v2 defs + few-shot) ----------------
|
| 18 |
+
|
| 19 |
+
_TECH_FEWSHOT = (
|
| 20 |
+
"\n\nExamples (message → JSON):\n"
|
| 21 |
+
"'You are an experienced chef. Suggest a tasting menu.' → "
|
| 22 |
+
'{"zero_shot_role": true, "few_shot": false, "thought_generation": false, "decomposition": false, "self_criticism": false, "flipped": false}\n'
|
| 23 |
+
"'Rewrite in a friendly tone. Example: \"Submit the form.\" → \"Pop your details in!\" Now: \"Payment failed.\"' → "
|
| 24 |
+
'{"zero_shot_role": false, "few_shot": true, "thought_generation": false, "decomposition": false, "self_criticism": false, "flipped": false}\n'
|
| 25 |
+
"'Plan my week: first list my goals, then schedule them by priority, then add breaks.' → "
|
| 26 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": false, "decomposition": true, "self_criticism": false, "flipped": false}\n'
|
| 27 |
+
"'Solve this problem. Think step by step and show your reasoning.' → "
|
| 28 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": true, "decomposition": false, "self_criticism": false, "flipped": false}\n'
|
| 29 |
+
"'Write the function, then review your own code and flag any bugs.' → "
|
| 30 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": false, "decomposition": false, "self_criticism": true, "flipped": false}\n'
|
| 31 |
+
"'I want to plan a trip. Ask me whatever you need to know before suggesting anything.' → "
|
| 32 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": false, "decomposition": false, "self_criticism": false, "flipped": true}\n'
|
| 33 |
+
"'Write a step-by-step guide to baking bread.' → "
|
| 34 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": false, "decomposition": false, "self_criticism": false, "flipped": false} (step-by-step OUTPUT, not reasoning; one undivided task)\n'
|
| 35 |
+
"'Translate to French, for example keep it natural.' → "
|
| 36 |
+
'{"zero_shot_role": false, "few_shot": false, "thought_generation": false, "decomposition": false, "self_criticism": false, "flipped": false} (no actual exemplar)'
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
_TECH_INSTR = (
|
| 40 |
+
"Detect which prompting techniques THIS single user message uses. Mark true only when genuinely present "
|
| 41 |
+
"in the user's own words; judge each independently.\n"
|
| 42 |
+
"- zero_shot_role: assigns a role/persona to the AI ('You are a tax lawyer'). The user describing THEMSELF is not this.\n"
|
| 43 |
+
"- few_shot: the user gives a concrete in-context EXAMPLE / sample to imitate (input→output pair, a sample to copy).\n"
|
| 44 |
+
"- thought_generation: explicitly asks the AI to REASON / 'think step by step' / show reasoning (not 'write a step-by-step guide').\n"
|
| 45 |
+
"- decomposition: breaks the request into multiple ordered/numbered sub-tasks ('first… then… then…', '1) 2) 3)').\n"
|
| 46 |
+
"- self_criticism: asks the AI to check/critique/verify ITS OWN answer (not 'check my code').\n"
|
| 47 |
+
"- flipped: asks the AI to ask THEM clarifying questions first."
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_technique_prompt(user_msg: str) -> str:
|
| 52 |
+
return _ask(_TECH_INSTR + _TECH_FEWSHOT, _TECH_TEMPLATE, "User message", user_msg)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# ---------------- Input Quality (v2 defs + few-shot) ----------------
|
| 56 |
+
|
| 57 |
+
_IQ_FEWSHOT = (
|
| 58 |
+
"\n\nExamples (message → JSON):\n"
|
| 59 |
+
"'Write a sorting function.' → {\"goal_stated\": false, \"specific_specification\": false}\n"
|
| 60 |
+
"'Write a sorting function in Python 3.11.' → {\"goal_stated\": false, \"specific_specification\": true}\n"
|
| 61 |
+
"'Summarize this paper so I can present it to my class.' → {\"goal_stated\": true, \"specific_specification\": false}\n"
|
| 62 |
+
"'I'm a beginner. Help me dedupe my CSV for analysis.' → {\"goal_stated\": true, \"specific_specification\": true}\n"
|
| 63 |
+
"'Draft a cover letter for a marketing internship.' → {\"goal_stated\": true, \"specific_specification\": false}\n"
|
| 64 |
+
"'Give me 10 startup names.' → {\"goal_stated\": false, \"specific_specification\": false}\n"
|
| 65 |
+
"'Make it better.' → {\"goal_stated\": false, \"specific_specification\": false}\n"
|
| 66 |
+
"'Optimize this query, it's slow on our 2M-row orders table.' → {\"goal_stated\": true, \"specific_specification\": true}"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
_IQ_INSTR = (
|
| 70 |
+
"Detect two INDEPENDENT features of this single user message.\n"
|
| 71 |
+
"goal_stated: TRUE if the user conveys a PURPOSE / use / audience / reason (stated or clearly implied), "
|
| 72 |
+
"FALSE if it is only the bare task. specific_specification: LIBERAL — TRUE if ANY concrete detail is "
|
| 73 |
+
"present (number, version, named tool, file/data, constraint, or the user's OWN background), FALSE only "
|
| 74 |
+
"when fully vague. A role assigned to the AI is NOT input quality."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def build_input_quality_prompt(user_msg: str) -> str:
|
| 79 |
+
return _ask(_IQ_INSTR + _IQ_FEWSHOT, _IQ_TEMPLATE, "User message", user_msg)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ---------------- Interaction (v3 defs + few-shot) ----------------
|
| 83 |
+
|
| 84 |
+
_INT_FEWSHOT = (
|
| 85 |
+
"\n\nExamples (prior → follow-up → JSON):\n"
|
| 86 |
+
"'write a poem about the sea' → 'make it rhyme' → {\"refinement_attempt\": true}\n"
|
| 87 |
+
"'summarize this article' → 'too long, give me 3 bullets' → {\"refinement_attempt\": true}\n"
|
| 88 |
+
"'translate to French' → 'actually, Spanish instead' → {\"refinement_attempt\": true}\n"
|
| 89 |
+
"'write the SQL for top customers' → 'also include their signup date' → {\"refinement_attempt\": true}\n"
|
| 90 |
+
"'write a poem about the sea' → 'now write one about mountains' → {\"refinement_attempt\": false}\n"
|
| 91 |
+
"'write the login function' → 'now write unit tests for it' → {\"refinement_attempt\": false}\n"
|
| 92 |
+
"'give me startup ideas' → 'thanks, that helps' → {\"refinement_attempt\": false}\n"
|
| 93 |
+
"'explain photosynthesis' → 'what about cellular respiration?' → {\"refinement_attempt\": false}"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
_INT_INSTR = (
|
| 97 |
+
"Did the user, in THIS follow-up, REFINE the PRIOR request — adjust, correct, or re-direct it to change "
|
| 98 |
+
"the SAME deliverable? TRUE for shorten/lengthen, tone/format/language change, corrections ('that's "
|
| 99 |
+
"wrong', 'fix the loop', 'no, I meant…'), adding/dropping a constraint on the same output, or 'redo it "
|
| 100 |
+
"differently'. FALSE for a genuinely NEW deliverable or NEW question, or mere acceptance/'thanks'."
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def build_interaction_prompt(prev_user: str, this_user: str) -> str:
|
| 105 |
+
payload = f"PRIOR user request:\n{prev_user}\n\nTHIS follow-up:\n{this_user}"
|
| 106 |
+
return _ask(_INT_INSTR + _INT_FEWSHOT, _INT_TEMPLATE, "Turns", payload)
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ---------------- Focus (v2 + few-shot) ----------------
|
| 110 |
+
|
| 111 |
+
_FOCUS_TEMPLATE = '{"relation": "same_topic" | "related_sub_task" | "topic_shift"}'
|
| 112 |
+
_FOCUS_FEWSHOT = (
|
| 113 |
+
"\n\nExamples (first → second → JSON):\n"
|
| 114 |
+
"'explain binary search' → 'what's its time complexity?' → {\"relation\": \"same_topic\"}\n"
|
| 115 |
+
"'write the login API' → 'now write tests for it' → {\"relation\": \"related_sub_task\"}\n"
|
| 116 |
+
"'debug my python loop' → 'what's a good carbonara recipe?' → {\"relation\": \"topic_shift\"}\n"
|
| 117 |
+
"'explain recursion' → 'explain dynamic programming' → {\"relation\": \"topic_shift\"}"
|
| 118 |
+
)
|
| 119 |
+
_FOCUS_INSTR = (
|
| 120 |
+
"Classify how the SECOND user turn relates to the FIRST. same_topic = same subject/question. "
|
| 121 |
+
"related_sub_task = a different sub-task serving the SAME concrete project/artifact (same project, not "
|
| 122 |
+
"merely the same field). topic_shift = unrelated change, OR two separate questions sharing only a domain."
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
def build_focus_boundary_prompt(turn_a: str, turn_b: str) -> str:
|
| 127 |
+
payload = f"FIRST user turn:\n{turn_a}\n\nSECOND user turn:\n{turn_b}"
|
| 128 |
+
return _ask(_FOCUS_INSTR + _FOCUS_FEWSHOT, _FOCUS_TEMPLATE, "Turns", payload)
|
eval/step_c.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP C — per-feature dedicated prompts + self-consistency. Compares to the A+B best.
|
| 2 |
+
|
| 3 |
+
C1: Input Quality as TWO single-feature binary prompts (goal_stated solo, specific_specification solo),
|
| 4 |
+
removing any trade-off the combined prompt forced. Deterministic, cached.
|
| 5 |
+
C2: Self-consistency for Interaction — sample the v3 prompt 3× at temperature 0.6, majority vote
|
| 6 |
+
(variance reduction on the noisy axis). Uses the vLLM `n` param (one request → 3 completions).
|
| 7 |
+
|
| 8 |
+
Run: python -m eval.step_c
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import sys
|
| 15 |
+
|
| 16 |
+
import requests
|
| 17 |
+
|
| 18 |
+
from eval import kappa as K
|
| 19 |
+
from eval.prompts_v2 import _ask
|
| 20 |
+
from eval.prompts_v3 import build_interaction_prompt as int_v3
|
| 21 |
+
|
| 22 |
+
_GOAL_T = '{"goal_stated": <bool>}'
|
| 23 |
+
_SPEC_T = '{"specific_specification": <bool>}'
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def goal_prompt(msg):
|
| 27 |
+
instr = (
|
| 28 |
+
"Does THIS user message state a PURPOSE / use / audience / reason (a WHY) for the request — not just "
|
| 29 |
+
"the task itself? TRUE only if a why/use/audience is present (stated or clearly implied).\n"
|
| 30 |
+
" ▸ TRUE: 'summarize for my class', 'I'm building a budgeting app, write the schema', 'dedupe my data "
|
| 31 |
+
"for analysis'. ✗ FALSE (task only): 'write a sorting function', 'fix this code', 'list 10 names'."
|
| 32 |
+
)
|
| 33 |
+
return _ask(instr, _GOAL_T, "User message", msg)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
_DECOMP_T = '{"decomposition": <bool>}'
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def decomp_prompt(msg):
|
| 40 |
+
instr = (
|
| 41 |
+
"Does THIS user message break the REQUEST into multiple ordered/numbered SUB-TASKS the AI must "
|
| 42 |
+
"perform? TRUE for 'first X, then Y, then Z' or '1) … 2) … 3) …' of distinct sub-tasks.\n"
|
| 43 |
+
" ▸ TRUE: 'First summarize it, then list 3 risks, then propose fixes', '1) clean the data 2) plot it'.\n"
|
| 44 |
+
" ✗ FALSE: a single ask ('write a sorting function'); 'write a step-by-step guide' (output, not a "
|
| 45 |
+
"decomposed request); 'think step by step' (that's reasoning); a numbered list of data/options."
|
| 46 |
+
)
|
| 47 |
+
return _ask(instr, _DECOMP_T, "User message", msg)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def spec_prompt(msg):
|
| 51 |
+
instr = (
|
| 52 |
+
"Does THIS user message contain ANY concrete detail of ANY kind — a number, version, named "
|
| 53 |
+
"tool/library/framework, file/data/schema, an explicit constraint, OR the user's OWN "
|
| 54 |
+
"background/role/skill level? Be LIBERAL: a single concrete detail is enough.\n"
|
| 55 |
+
" ▸ TRUE: 'Python 3.11', 'I'm a beginner', 'under 100ms', 'here's my CSV', 'using pandas', "
|
| 56 |
+
"'a 1000-word post'. ✗ FALSE only when fully vague: 'make it fast', 'help me', 'write something good'."
|
| 57 |
+
)
|
| 58 |
+
return _ask(instr, _SPEC_T, "User message", msg)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class SamplingClient:
|
| 62 |
+
"""vLLM OpenAI-style endpoint with n completions per request (for self-consistency)."""
|
| 63 |
+
|
| 64 |
+
def __init__(self, base_url, token, timeout=90):
|
| 65 |
+
self.base = base_url.rstrip("/"); self.token = token; self.timeout = timeout
|
| 66 |
+
|
| 67 |
+
def sample(self, prompt, n=3, temperature=0.6):
|
| 68 |
+
body = {"model": "MiniCPM4.1-8B", "messages": [{"role": "user", "content": prompt}],
|
| 69 |
+
"temperature": temperature, "n": n, "max_tokens": 256,
|
| 70 |
+
"chat_template_kwargs": {"enable_thinking": False}}
|
| 71 |
+
r = requests.post(f"{self.base}/v1/chat/completions",
|
| 72 |
+
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
| 73 |
+
json=body, timeout=self.timeout)
|
| 74 |
+
r.raise_for_status()
|
| 75 |
+
from prompt_card.llm.minicpm import clean_content
|
| 76 |
+
return [clean_content(c["message"]["content"]) for c in r.json()["choices"]]
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def main():
|
| 80 |
+
base = os.environ.get("OPENBMB_BASE_URL"); token = os.environ.get("OPENBMB_TOKEN")
|
| 81 |
+
if not base or not token:
|
| 82 |
+
print("ERROR: set OPENBMB_BASE_URL/OPENBMB_TOKEN", file=sys.stderr); sys.exit(2)
|
| 83 |
+
from prompt_card.llm.minicpm import MiniCPMClient
|
| 84 |
+
gt = K.load_gt(); convs = K.load_convs()
|
| 85 |
+
client = K.CachedClient(MiniCPMClient(base, token), workers=8)
|
| 86 |
+
|
| 87 |
+
# ---- C1: per-feature IQ ----
|
| 88 |
+
goal_prompts, spec_prompts, rows = [], [], []
|
| 89 |
+
for r in gt:
|
| 90 |
+
ut = K.user_turns(convs[r["id"]])
|
| 91 |
+
for row in r["input_quality"]:
|
| 92 |
+
i = int(row["turn"][1:]) - 1
|
| 93 |
+
goal_prompts.append(goal_prompt(ut[i])); spec_prompts.append(spec_prompt(ut[i]))
|
| 94 |
+
rows.append((int("goal_stated" in row["features"]), int("specific_specification" in row["features"])))
|
| 95 |
+
gres = client.run_all(goal_prompts); sres = client.run_all(spec_prompts)
|
| 96 |
+
gyt = [r[0] for r in rows]; gyp = [int(bool((K.OA.parse(gres[p], ("goal_stated",)) or {}).get("goal_stated"))) for p in goal_prompts]
|
| 97 |
+
syt = [r[1] for r in rows]; syp = [int(bool((K.OA.parse(sres[p], ("specific_specification",)) or {}).get("specific_specification"))) for p in spec_prompts]
|
| 98 |
+
gk = K.cohen_kappa(gyt, gyp); sk = K.cohen_kappa(syt, syp)
|
| 99 |
+
print("=== C1 per-feature IQ (dedicated single-feature prompts) ===")
|
| 100 |
+
print(f" goal_stated κ={gk:+.3f} [TN/FP/FN/TP {K.binary_counts(gyt,gyp)}] (A+B best v2: +0.226)")
|
| 101 |
+
print(f" specific_specification κ={sk:+.3f} [TN/FP/FN/TP {K.binary_counts(syt,syp)}] (A+B best v2: +0.568)")
|
| 102 |
+
print(f" IQ headline (mean) κ={(gk+sk)/2:+.3f} (A+B best: +0.397)")
|
| 103 |
+
|
| 104 |
+
# ---- C2: self-consistency interaction ----
|
| 105 |
+
sc = SamplingClient(base, token)
|
| 106 |
+
iyt, iyp = [], []
|
| 107 |
+
items = []
|
| 108 |
+
for r in gt:
|
| 109 |
+
ut = K.user_turns(convs[r["id"]])
|
| 110 |
+
for row in r["interaction"]:
|
| 111 |
+
i = int(row["turn"][1:]) - 1
|
| 112 |
+
items.append((int(bool(row["refinement"])), int_v3(ut[i - 1], ut[i])))
|
| 113 |
+
print(f"\n=== C2 self-consistency interaction (3×, temp 0.6, majority) over {len(items)} turns ===", flush=True)
|
| 114 |
+
import concurrent.futures as cf
|
| 115 |
+
cache = {}
|
| 116 |
+
|
| 117 |
+
def vote(prompt):
|
| 118 |
+
outs = sc.sample(prompt, n=3, temperature=0.6)
|
| 119 |
+
yes = 0
|
| 120 |
+
for o in outs:
|
| 121 |
+
d = K.OA.parse(o, ("refinement_attempt",)) or {}
|
| 122 |
+
yes += int(bool(d.get("refinement_attempt")))
|
| 123 |
+
return int(yes >= 2)
|
| 124 |
+
|
| 125 |
+
prompts = [p for _, p in items]
|
| 126 |
+
with cf.ThreadPoolExecutor(max_workers=8) as ex:
|
| 127 |
+
votes = list(ex.map(vote, prompts))
|
| 128 |
+
iyt = [t for t, _ in items]; iyp = votes
|
| 129 |
+
ik = K.cohen_kappa(iyt, iyp)
|
| 130 |
+
print(f" interaction (self-consistency) κ={ik:+.3f} [TN/FP/FN/TP {K.binary_counts(iyt,iyp)}] (A+B best v3: +0.320)")
|
| 131 |
+
|
| 132 |
+
json.dump({"goal": gk, "spec": sk, "iq_mean": (gk + sk) / 2, "interaction_sc": ik},
|
| 133 |
+
open(os.path.join(os.path.dirname(__file__), "_cache", "step_c.json"), "w"), indent=1)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
if __name__ == "__main__":
|
| 137 |
+
main()
|
eval/step_critical.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Phase 3 Step 1 — Critical Engagement (5 types) base-8B measurement, STEP-A style (sharp GT-aligned
|
| 2 |
+
definitions, NO few-shot). Per-type κ + confusion + overall. Reuses the base-8B cache (:8001).
|
| 3 |
+
|
| 4 |
+
Run: python -m eval.step_critical
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
|
| 12 |
+
from eval import kappa as K
|
| 13 |
+
from eval.prompts_v2 import _ask
|
| 14 |
+
from prompt_card.scoring import observable_axes as OA
|
| 15 |
+
|
| 16 |
+
CE = ["skepticism", "rebuttal", "source_request", "independent_verification", "re_questioning"]
|
| 17 |
+
_CE_T = ('{"skepticism": <bool>, "rebuttal": <bool>, "source_request": <bool>, '
|
| 18 |
+
'"independent_verification": <bool>, "re_questioning": <bool>}')
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build_critical_prompt(prev_assistant: str, this_user: str) -> str:
|
| 22 |
+
instr = (
|
| 23 |
+
"Detect critical-engagement TYPES in THE USER's turn as it reacts to what the AI just said. Multiple "
|
| 24 |
+
"types can be true at once; mark true only when genuinely present.\n"
|
| 25 |
+
"- skepticism: doubts/questions the AI's claim WITHOUT giving a reason. ▸ 'really?', 'are you sure?', "
|
| 26 |
+
"'that doesn't sound right'. ✗ 'ok, thanks' (acceptance).\n"
|
| 27 |
+
"- rebuttal: pushes back with the user's OWN counter-argument or correction. ▸ 'that's wrong — if X "
|
| 28 |
+
"were true, Y wouldn't happen', 'no, it returns 0 not 1'. ✗ a bare 'are you sure?' (that is skepticism).\n"
|
| 29 |
+
"- source_request: asks for a citation / evidence / source. ▸ 'what's your source?', 'cite that'. "
|
| 30 |
+
"Note: asking for REASONING ('why did you pick X?') is NOT source_request.\n"
|
| 31 |
+
"- independent_verification: the user states an EXPLICIT external check they performed. ▸ 'I ran it and "
|
| 32 |
+
"it throws on empty input', 'I checked the docs, it says X'. ✗ a bare confident assertion with no stated "
|
| 33 |
+
"check (that is rebuttal).\n"
|
| 34 |
+
"- re_questioning: RE-ASKS the same question because the AI's answer was unsatisfactory. ▸ 'that's not "
|
| 35 |
+
"what I asked — I meant the async case'. ✗ a NEW/different question."
|
| 36 |
+
)
|
| 37 |
+
payload = f"AI just said:\n{prev_assistant}\n\nUser's turn:\n{this_user}"
|
| 38 |
+
return _ask(instr, _CE_T, "Turns", payload)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def main():
|
| 42 |
+
base = os.environ.get("OPENBMB_BASE_URL"); token = os.environ.get("OPENBMB_TOKEN")
|
| 43 |
+
if not base or not token:
|
| 44 |
+
print("ERROR: creds", file=sys.stderr); sys.exit(2)
|
| 45 |
+
from prompt_card.llm.minicpm import MiniCPMClient
|
| 46 |
+
gt = K.load_gt(); convs = K.load_convs()
|
| 47 |
+
client = K.CachedClient(MiniCPMClient(base, token), workers=8)
|
| 48 |
+
|
| 49 |
+
prompts, rows = [], []
|
| 50 |
+
for r in gt:
|
| 51 |
+
conv = convs[r["id"]]
|
| 52 |
+
for j, row in enumerate(r["critical"]):
|
| 53 |
+
prev = OA._prev_assistant(conv, int(row["turn"][1:]) - 1)
|
| 54 |
+
users = OA._user_turns(conv)
|
| 55 |
+
utext = users[int(row["turn"][1:]) - 1]
|
| 56 |
+
prompts.append(build_critical_prompt(prev, utext))
|
| 57 |
+
rows.append(set(row["types"]))
|
| 58 |
+
print(f"[critical] {len(prompts)} turn-prompts", flush=True)
|
| 59 |
+
resp = client.run_all(prompts)
|
| 60 |
+
print(f"[critical] 8B done (new {client.misses})", flush=True)
|
| 61 |
+
|
| 62 |
+
preds = []
|
| 63 |
+
fail = 0
|
| 64 |
+
for p in prompts:
|
| 65 |
+
d = OA.parse(resp[p], CE)
|
| 66 |
+
if d is None:
|
| 67 |
+
fail += 1; d = {}
|
| 68 |
+
preds.append({t for t in CE if d.get(t)})
|
| 69 |
+
|
| 70 |
+
print("\n=== Critical Engagement — base-8B per-type κ (vs Kim ground truth) ===")
|
| 71 |
+
print(f"parse failures: {fail}/{len(prompts)}")
|
| 72 |
+
per_k = {}
|
| 73 |
+
for t in CE:
|
| 74 |
+
yt = [int(t in s) for s in rows]; yp = [int(t in s) for s in preds]
|
| 75 |
+
k = K.cohen_kappa(yt, yp); c = K.binary_counts(yt, yp); p, rc, f1 = K.prf(c)
|
| 76 |
+
per_k[t] = k
|
| 77 |
+
ks = f"{k:+.3f}" if k is not None else "N/A"
|
| 78 |
+
print(f" {t:24} κ={ks} pos={sum(yt)} [TN {c['tn']} FP {c['fp']} FN {c['fn']} TP {c['tp']}] "
|
| 79 |
+
f"P{'-' if p is None else f'{p:.2f}'} R{'-' if rc is None else f'{rc:.2f}'}")
|
| 80 |
+
# any-CE per turn
|
| 81 |
+
anyt = [int(bool(s)) for s in rows]; anyp = [int(bool(s)) for s in preds]
|
| 82 |
+
anyk = K.cohen_kappa(anyt, anyp)
|
| 83 |
+
# distinct-type count per conv (the product signal): agreement
|
| 84 |
+
gi = 0; gt_counts = []; pr_counts = []
|
| 85 |
+
for r in gt:
|
| 86 |
+
n = len(r["critical"])
|
| 87 |
+
gtypes = set(); ptypes = set()
|
| 88 |
+
for _ in range(n):
|
| 89 |
+
gtypes |= rows[gi]; ptypes |= preds[gi]; gi += 1
|
| 90 |
+
gt_counts.append(len(gtypes)); pr_counts.append(len(ptypes))
|
| 91 |
+
# mean abs error on the 0-5 distinct-type signal
|
| 92 |
+
mae = sum(abs(a - b) for a, b in zip(gt_counts, pr_counts)) / len(gt_counts)
|
| 93 |
+
valid = [per_k[t] for t in CE if per_k[t] is not None]
|
| 94 |
+
headline = sum(valid) / len(valid) if valid else None
|
| 95 |
+
print(f"\n any-CE-present (per turn) κ = {anyk:+.3f}")
|
| 96 |
+
print(f" per-type mean κ (headline) = {headline:+.3f}")
|
| 97 |
+
print(f" distinct-type count (0-5) MAE per conv = {mae:.2f}")
|
| 98 |
+
|
| 99 |
+
if headline is None:
|
| 100 |
+
decision = "N/A"
|
| 101 |
+
elif headline >= 0.6:
|
| 102 |
+
decision = "NO LoRA — base 8B solid"
|
| 103 |
+
elif headline >= 0.4:
|
| 104 |
+
decision = "NO LoRA — borderline, document caveats (save time)"
|
| 105 |
+
else:
|
| 106 |
+
decision = "LoRA NEEDED (headline < 0.4)"
|
| 107 |
+
print(f"\n >>> CRITICAL LoRA DECISION: {decision}")
|
| 108 |
+
json.dump({"per_type": per_k, "any": anyk, "headline": headline, "mae": mae,
|
| 109 |
+
"decision": decision, "parse_fail": fail},
|
| 110 |
+
open(os.path.join(os.path.dirname(__file__), "_cache", "critical.json"), "w"), indent=1, default=str)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
if __name__ == "__main__":
|
| 114 |
+
main()
|
eval/step_d.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""STEP D — model swap to MiniCPM-V-4.6-Thinking (:8004) for the reasoning-heavy axes (Focus, Interaction;
|
| 2 |
+
and re-usable for Phase-3 Critical). Compares to the base-8B A+B best (focus 0.433, interaction 0.320).
|
| 3 |
+
|
| 4 |
+
The Thinking model emits a reasoning block then JSON, sometimes with a dangling `</think>` (no opening
|
| 5 |
+
tag), so we parse robustly: take text after the last `</think>`, then json/regex-extract the field.
|
| 6 |
+
Separate cache namespace (think_8004.jsonl) so identical prompt text doesn't collide with base-8B cache.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import concurrent.futures as cf
|
| 11 |
+
import hashlib
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import re
|
| 15 |
+
import sys
|
| 16 |
+
import threading
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
|
| 20 |
+
from eval import kappa as K
|
| 21 |
+
from eval.prompts_v4 import build_focus_boundary_prompt
|
| 22 |
+
from eval.prompts_v3 import build_interaction_prompt
|
| 23 |
+
|
| 24 |
+
CACHE = os.path.join(os.path.dirname(__file__), "_cache", "think_8004.jsonl")
|
| 25 |
+
MODEL = "MiniCPM-V-4.6-Thinking"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_think(text, field):
|
| 29 |
+
"""Robust to dangling </think> + reasoning noise. Returns {field: value} or None."""
|
| 30 |
+
t = text or ""
|
| 31 |
+
if "</think>" in t:
|
| 32 |
+
t = t.rsplit("</think>", 1)[-1]
|
| 33 |
+
t = re.sub(r"^```[a-zA-Z0-9]*\n?|\n?```$", "", t.strip()).strip()
|
| 34 |
+
try:
|
| 35 |
+
d = json.loads(t)
|
| 36 |
+
if isinstance(d, dict) and field in d:
|
| 37 |
+
return {field: d[field]}
|
| 38 |
+
except Exception:
|
| 39 |
+
pass
|
| 40 |
+
# regex fallback: "field": value
|
| 41 |
+
m = re.search(rf'"{field}"\s*:\s*("?[a-zA-Z_]+"?|true|false)', t)
|
| 42 |
+
if m:
|
| 43 |
+
v = m.group(1).strip('"')
|
| 44 |
+
if v in ("true", "false"):
|
| 45 |
+
return {field: v == "true"}
|
| 46 |
+
return {field: v}
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class ThinkClient:
|
| 51 |
+
def __init__(self, base, token, workers=8):
|
| 52 |
+
self.base = base.rstrip("/"); self.token = token; self.workers = workers
|
| 53 |
+
self._lock = threading.Lock(); self.cache = {}
|
| 54 |
+
if os.path.exists(CACHE):
|
| 55 |
+
for line in open(CACHE):
|
| 56 |
+
try:
|
| 57 |
+
d = json.loads(line); self.cache[d["k"]] = d["v"]
|
| 58 |
+
except Exception:
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
@staticmethod
|
| 62 |
+
def _key(p):
|
| 63 |
+
return hashlib.sha1(("THINK::" + p).encode()).hexdigest()
|
| 64 |
+
|
| 65 |
+
def _call(self, prompt, retries=4):
|
| 66 |
+
body = {"model": MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0,
|
| 67 |
+
"max_tokens": 1536, "chat_template_kwargs": {"enable_thinking": True}}
|
| 68 |
+
last = None
|
| 69 |
+
for attempt in range(retries):
|
| 70 |
+
try:
|
| 71 |
+
r = requests.post(f"{self.base}/v1/chat/completions",
|
| 72 |
+
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
| 73 |
+
json=body, timeout=240)
|
| 74 |
+
r.raise_for_status()
|
| 75 |
+
return r.json()["choices"][0]["message"]["content"]
|
| 76 |
+
except (requests.exceptions.RequestException,) as e:
|
| 77 |
+
last = e # transient endpoint hiccup — backoff and retry
|
| 78 |
+
import time
|
| 79 |
+
time.sleep(2 * (attempt + 1))
|
| 80 |
+
raise last
|
| 81 |
+
|
| 82 |
+
def run_all(self, prompts):
|
| 83 |
+
uniq = list(dict.fromkeys(prompts))
|
| 84 |
+
todo = [p for p in uniq if self._key(p) not in self.cache]
|
| 85 |
+
|
| 86 |
+
def work(p):
|
| 87 |
+
v = self._call(p); k = self._key(p)
|
| 88 |
+
with self._lock:
|
| 89 |
+
self.cache[k] = v
|
| 90 |
+
with open(CACHE, "a") as f:
|
| 91 |
+
f.write(json.dumps({"k": k, "v": v}, ensure_ascii=False) + "\n")
|
| 92 |
+
return p
|
| 93 |
+
if todo:
|
| 94 |
+
with cf.ThreadPoolExecutor(max_workers=self.workers) as ex:
|
| 95 |
+
for i, _ in enumerate(ex.map(work, todo), 1):
|
| 96 |
+
if i % 50 == 0:
|
| 97 |
+
print(f" ... {i}/{len(todo)} thinking calls", flush=True)
|
| 98 |
+
return {p: self.cache[self._key(p)] for p in uniq}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def main():
|
| 102 |
+
base = os.environ.get("OPENBMB_BASE_URL"); token = os.environ.get("OPENBMB_TOKEN")
|
| 103 |
+
if not base or not token:
|
| 104 |
+
print("ERROR: creds", file=sys.stderr); sys.exit(2)
|
| 105 |
+
base = base.replace("8001", "8004")
|
| 106 |
+
gt = K.load_gt(); convs = K.load_convs(); embedder = K.FastEmbedder()
|
| 107 |
+
client = ThinkClient(base, token, workers=8)
|
| 108 |
+
|
| 109 |
+
# interaction
|
| 110 |
+
int_prompts, int_rows = [], []
|
| 111 |
+
for r in gt:
|
| 112 |
+
ut = K.user_turns(convs[r["id"]])
|
| 113 |
+
for row in r["interaction"]:
|
| 114 |
+
i = int(row["turn"][1:]) - 1
|
| 115 |
+
int_prompts.append(build_interaction_prompt(ut[i - 1], ut[i]))
|
| 116 |
+
int_rows.append(int(bool(row["refinement"])))
|
| 117 |
+
# focus (embedder-gated, T<=0.70 to cover the sweep)
|
| 118 |
+
foc_tasks = []
|
| 119 |
+
geom = {}
|
| 120 |
+
for r in gt:
|
| 121 |
+
ut = K.user_turns(convs[r["id"]])
|
| 122 |
+
if len(ut) < 2:
|
| 123 |
+
continue
|
| 124 |
+
import numpy as np
|
| 125 |
+
vecs = embedder.embed(ut); g = []
|
| 126 |
+
for i in range(len(ut) - 1):
|
| 127 |
+
cos = float(np.dot(vecs[i], vecs[i + 1])); g.append((i, cos))
|
| 128 |
+
if cos < 0.70:
|
| 129 |
+
foc_tasks.append((r["id"], f"U{i+1}->U{i+2}", build_focus_boundary_prompt(ut[i], ut[i + 1])))
|
| 130 |
+
geom[r["id"]] = g
|
| 131 |
+
|
| 132 |
+
print(f"[step_d] thinking calls: interaction {len(int_prompts)} + focus {len(foc_tasks)}", flush=True)
|
| 133 |
+
all_prompts = int_prompts + [t[2] for t in foc_tasks]
|
| 134 |
+
resp = client.run_all(all_prompts)
|
| 135 |
+
|
| 136 |
+
# interaction kappa
|
| 137 |
+
iyp = [int(bool((parse_think(resp[p], "refinement_attempt") or {}).get("refinement_attempt"))) for p in int_prompts]
|
| 138 |
+
ifail = sum(1 for p in int_prompts if parse_think(resp[p], "refinement_attempt") is None)
|
| 139 |
+
ik = K.cohen_kappa(int_rows, iyp)
|
| 140 |
+
|
| 141 |
+
# focus: rebuild per-boundary relation preds, sweep T
|
| 142 |
+
rel = {(cid, bk): (parse_think(resp[p], "relation") or {}).get("relation") for (cid, bk, p) in foc_tasks}
|
| 143 |
+
ffail = sum(1 for (cid, bk, p) in foc_tasks if parse_think(resp[p], "relation") is None)
|
| 144 |
+
best = None
|
| 145 |
+
for T in [0.40, 0.45, 0.50, 0.55, 0.60, 0.65, 0.70]:
|
| 146 |
+
yt, yp = [], []; hit = tot = 0
|
| 147 |
+
for r in gt:
|
| 148 |
+
gtrel = {f"{c['a']}->{c['b']}": c["relation"] for c in r["focus"]}
|
| 149 |
+
for (i, cos) in geom.get(r["id"], []):
|
| 150 |
+
bk = f"U{i+1}->U{i+2}"; isgt = (gtrel.get(bk) == "topic_shift")
|
| 151 |
+
yt.append(int(isgt))
|
| 152 |
+
pred = int(cos < T and rel.get((r["id"], bk)) == "topic_shift")
|
| 153 |
+
yp.append(pred)
|
| 154 |
+
if isgt:
|
| 155 |
+
tot += 1; hit += int(cos < T)
|
| 156 |
+
k = K.cohen_kappa(yt, yp); rec = hit / tot if tot else None
|
| 157 |
+
cand = (k if k is not None else -9, rec or 0)
|
| 158 |
+
if best is None or cand > best[0]:
|
| 159 |
+
best = (cand, T, k, rec)
|
| 160 |
+
|
| 161 |
+
print("\n=== STEP D (MiniCPM-V-4.6-Thinking, :8004) vs base-8B best ===")
|
| 162 |
+
print(f" interaction κ={ik:+.3f} parse_fail={ifail}/{len(int_prompts)} (base-8B best v3: +0.320)")
|
| 163 |
+
print(f" focus κ={best[2]:+.3f} T={best[1]} recall={best[3]:.2f} parse_fail={ffail}/{len(foc_tasks)} (base-8B best v4: +0.433)")
|
| 164 |
+
json.dump({"interaction_think": ik, "focus_think": best[2], "focus_T": best[1],
|
| 165 |
+
"int_parsefail": ifail, "foc_parsefail": ffail},
|
| 166 |
+
open(os.path.join(os.path.dirname(__file__), "_cache", "step_d.json"), "w"), indent=1)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
if __name__ == "__main__":
|
| 170 |
+
main()
|
prompt_card/__init__.py
ADDED
|
File without changes
|
prompt_card/adapters/__init__.py
ADDED
|
File without changes
|
prompt_card/adapters/chatgpt.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ChatGPT export adapter. `conversations.json` is a list of conversations, each with a
|
| 2 |
+
`mapping` tree of nodes keyed by id; we walk parent->children from the root to recover order."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from ..schema import Conversation, Turn, normalize_role
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def _text_from_parts(content: dict) -> str:
|
| 9 |
+
"""Join string parts only; multimodal parts (dicts) are dropped."""
|
| 10 |
+
if not isinstance(content, dict):
|
| 11 |
+
return ""
|
| 12 |
+
parts = content.get("parts") or []
|
| 13 |
+
return "\n".join(p for p in parts if isinstance(p, str)).strip()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _walk(mapping: dict) -> list[Turn]:
|
| 17 |
+
# find root: node whose parent is None (or missing)
|
| 18 |
+
root_id = None
|
| 19 |
+
for node_id, node in mapping.items():
|
| 20 |
+
if not node.get("parent"):
|
| 21 |
+
root_id = node_id
|
| 22 |
+
break
|
| 23 |
+
if root_id is None:
|
| 24 |
+
root_id = next(iter(mapping))
|
| 25 |
+
|
| 26 |
+
turns: list[Turn] = []
|
| 27 |
+
cur = root_id
|
| 28 |
+
seen = set()
|
| 29 |
+
while cur and cur in mapping and cur not in seen:
|
| 30 |
+
seen.add(cur)
|
| 31 |
+
node = mapping[cur]
|
| 32 |
+
msg = node.get("message")
|
| 33 |
+
if msg:
|
| 34 |
+
role = normalize_role((msg.get("author") or {}).get("role", ""))
|
| 35 |
+
text = _text_from_parts(msg.get("content") or {})
|
| 36 |
+
if role and text:
|
| 37 |
+
ct = msg.get("create_time")
|
| 38 |
+
turns.append(Turn(role=role, text=text, time=str(ct) if ct is not None else None))
|
| 39 |
+
children = node.get("children") or []
|
| 40 |
+
cur = children[0] if children else None
|
| 41 |
+
return turns
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def parse(data) -> list[Conversation]:
|
| 45 |
+
if not isinstance(data, list):
|
| 46 |
+
raise ValueError("ChatGPT export must be a list of conversations")
|
| 47 |
+
convs: list[Conversation] = []
|
| 48 |
+
for conv in data:
|
| 49 |
+
if not isinstance(conv, dict):
|
| 50 |
+
continue
|
| 51 |
+
mapping = conv.get("mapping")
|
| 52 |
+
if not isinstance(mapping, dict):
|
| 53 |
+
continue
|
| 54 |
+
ct = conv.get("create_time")
|
| 55 |
+
convs.append(
|
| 56 |
+
Conversation(
|
| 57 |
+
provider="chatgpt",
|
| 58 |
+
created_at=str(ct) if ct is not None else None,
|
| 59 |
+
turns=_walk(mapping),
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
return convs
|
prompt_card/adapters/claude.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Claude export adapter. List of conversations, each with `chat_messages`
|
| 2 |
+
(`sender`: human|assistant, `text`)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from ..schema import Conversation, Turn, normalize_role
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def parse(data) -> list[Conversation]:
|
| 9 |
+
if not isinstance(data, list):
|
| 10 |
+
raise ValueError("Claude export must be a list of conversations")
|
| 11 |
+
convs: list[Conversation] = []
|
| 12 |
+
for conv in data:
|
| 13 |
+
if not isinstance(conv, dict):
|
| 14 |
+
continue
|
| 15 |
+
msgs = conv.get("chat_messages")
|
| 16 |
+
if not isinstance(msgs, list):
|
| 17 |
+
continue
|
| 18 |
+
turns: list[Turn] = []
|
| 19 |
+
for m in msgs:
|
| 20 |
+
if not isinstance(m, dict):
|
| 21 |
+
continue
|
| 22 |
+
role = normalize_role(m.get("sender", ""))
|
| 23 |
+
text = (m.get("text") or "").strip()
|
| 24 |
+
if role and text:
|
| 25 |
+
turns.append(Turn(role=role, text=text, time=m.get("created_at")))
|
| 26 |
+
convs.append(
|
| 27 |
+
Conversation(provider="claude", created_at=conv.get("created_at"), turns=turns)
|
| 28 |
+
)
|
| 29 |
+
return convs
|
prompt_card/adapters/gemini.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gemini export adapter. List of conversations, each with `turns`
|
| 2 |
+
(`role`: user|model, `text`)."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from ..schema import Conversation, Turn, normalize_role
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def parse(data) -> list[Conversation]:
|
| 9 |
+
if not isinstance(data, list):
|
| 10 |
+
raise ValueError("Gemini export must be a list of conversations")
|
| 11 |
+
convs: list[Conversation] = []
|
| 12 |
+
for conv in data:
|
| 13 |
+
if not isinstance(conv, dict):
|
| 14 |
+
continue
|
| 15 |
+
raw_turns = conv.get("turns")
|
| 16 |
+
if not isinstance(raw_turns, list):
|
| 17 |
+
continue
|
| 18 |
+
turns: list[Turn] = []
|
| 19 |
+
for t in raw_turns:
|
| 20 |
+
if not isinstance(t, dict):
|
| 21 |
+
continue
|
| 22 |
+
role = normalize_role(t.get("role", ""))
|
| 23 |
+
text = (t.get("text") or "").strip()
|
| 24 |
+
if role and text:
|
| 25 |
+
turns.append(Turn(role=role, text=text, time=t.get("create_time")))
|
| 26 |
+
convs.append(
|
| 27 |
+
Conversation(provider="gemini", created_at=conv.get("create_time"), turns=turns)
|
| 28 |
+
)
|
| 29 |
+
return convs
|
prompt_card/adapters/paste.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Paste fallback. Parses pasted free-text into user/assistant turns.
|
| 2 |
+
|
| 3 |
+
Priority: explicit role markers at line start ("User:", "Assistant:", "You:", "AI:", "ChatGPT:",
|
| 4 |
+
"Human:", "Q:", "A:", …) group multi-line turns. With no markers, blank-line-separated blocks are
|
| 5 |
+
treated as an alternating user/assistant transcript starting with the user. This matters because the
|
| 6 |
+
observable axes (interaction/critical/focus) need assistant turns, not an all-user blob.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
from ..schema import Conversation, Turn
|
| 13 |
+
|
| 14 |
+
_USER = re.compile(r"^\s*(user|you|human|me|prompt|q)\s*[:>\-]\s*", re.I)
|
| 15 |
+
_ASST = re.compile(r"^\s*(assistant|ai|chatgpt|chat gpt|claude|gpt|bot|gemini|a|answer|response)\s*[:>\-]\s*", re.I)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def parse(text: str) -> list[Conversation]:
|
| 19 |
+
if not isinstance(text, str):
|
| 20 |
+
raise ValueError("Paste input must be a string")
|
| 21 |
+
if not text.strip():
|
| 22 |
+
raise ValueError("No non-empty lines to parse")
|
| 23 |
+
|
| 24 |
+
marked = []
|
| 25 |
+
cur_role, cur_buf = None, []
|
| 26 |
+
|
| 27 |
+
def flush():
|
| 28 |
+
if cur_role and cur_buf:
|
| 29 |
+
t = "\n".join(cur_buf).strip()
|
| 30 |
+
if t:
|
| 31 |
+
marked.append((cur_role, t))
|
| 32 |
+
|
| 33 |
+
for ln in text.splitlines():
|
| 34 |
+
if _USER.match(ln):
|
| 35 |
+
flush(); cur_role, cur_buf = "user", [_USER.sub("", ln, count=1)]
|
| 36 |
+
elif _ASST.match(ln):
|
| 37 |
+
flush(); cur_role, cur_buf = "assistant", [_ASST.sub("", ln, count=1)]
|
| 38 |
+
elif cur_role is not None:
|
| 39 |
+
cur_buf.append(ln)
|
| 40 |
+
flush()
|
| 41 |
+
|
| 42 |
+
if marked:
|
| 43 |
+
turns = [Turn(role=r, text=t, time=None) for r, t in marked]
|
| 44 |
+
return [Conversation(provider="paste", created_at=None, turns=turns)]
|
| 45 |
+
|
| 46 |
+
# No role markers: split on blank lines into blocks, alternate user/assistant starting with user.
|
| 47 |
+
blocks = [b.strip() for b in re.split(r"\n\s*\n", text) if b.strip()]
|
| 48 |
+
if not blocks:
|
| 49 |
+
blocks = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
| 50 |
+
roles = ("user", "assistant")
|
| 51 |
+
turns = [Turn(role=roles[i % 2], text=b, time=None) for i, b in enumerate(blocks)]
|
| 52 |
+
return [Conversation(provider="paste", created_at=None, turns=turns)]
|
prompt_card/app_core.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""App integrator (UI-agnostic): take uploaded export text or pasted text, produce the card
|
| 2 |
+
PNG + tips. Kept separate from the Gradio layer so the whole flow is unit-testable.
|
| 3 |
+
|
| 4 |
+
v2: scoring goes through the Modal LoRA evaluator (`lora_client`); tips text is written by a
|
| 5 |
+
separate generative client (`tips_client`, optional). Privacy: uploads are read into memory and
|
| 6 |
+
the file is deleted before scoring; nothing is written to disk or logged here.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
|
| 14 |
+
from .adapters import chatgpt, claude, gemini, paste
|
| 15 |
+
from .preprocess import InsufficientData
|
| 16 |
+
from .evaluate import evaluate
|
| 17 |
+
from .llm.lora_client import LoRAClient, InferenceUnavailable
|
| 18 |
+
from .llm.client import LLMClient
|
| 19 |
+
from .llm import tips as tips_mod
|
| 20 |
+
from . import card
|
| 21 |
+
|
| 22 |
+
_ADAPTERS = {"chatgpt": chatgpt.parse, "claude": claude.parse, "gemini": gemini.parse}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class AnalysisError(Exception):
|
| 26 |
+
"""User-facing error with a friendly message."""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def detect_provider(raw) -> str:
|
| 30 |
+
"""Sniff the export shape. ChatGPT has `mapping`; Claude `chat_messages`; Gemini `turns`."""
|
| 31 |
+
sample = raw[0] if isinstance(raw, list) and raw else raw
|
| 32 |
+
if isinstance(sample, dict):
|
| 33 |
+
if "mapping" in sample:
|
| 34 |
+
return "chatgpt"
|
| 35 |
+
if "chat_messages" in sample:
|
| 36 |
+
return "claude"
|
| 37 |
+
if "turns" in sample:
|
| 38 |
+
return "gemini"
|
| 39 |
+
raise AnalysisError("Unrecognized export format. Use a ChatGPT, Claude, or Gemini export, or paste text.")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _style_comment(result: dict) -> str:
|
| 43 |
+
"""Placeholder style comment grounding the tips (real MiniCPM grounding is a later step)."""
|
| 44 |
+
weak = tips_mod.weak_axes(result["axes"])
|
| 45 |
+
return f"Weakest areas: {', '.join(weak)}." if weak else "Strong across the board."
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _insufficient_message(err: InsufficientData) -> str:
|
| 49 |
+
m = re.search(r"found (\d+)", str(err))
|
| 50 |
+
found = int(m.group(1)) if m else 0
|
| 51 |
+
if found == 0:
|
| 52 |
+
return "We couldn't find any English messages to score. This tool currently evaluates English chats only."
|
| 53 |
+
return f"We could only read {found} English messages, but need at least 20 to build a card."
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def analyze(content: str, lora_client: LoRAClient, *, name: str = "", tips_client: LLMClient | None = None) -> dict:
|
| 57 |
+
"""content = export JSON text or pasted free text. Returns {card_png, tips, result}."""
|
| 58 |
+
try:
|
| 59 |
+
raw = json.loads(content)
|
| 60 |
+
is_json = True
|
| 61 |
+
except (json.JSONDecodeError, TypeError):
|
| 62 |
+
raw, is_json = None, False
|
| 63 |
+
|
| 64 |
+
try:
|
| 65 |
+
if is_json:
|
| 66 |
+
provider = detect_provider(raw)
|
| 67 |
+
conversations = _ADAPTERS[provider](raw)
|
| 68 |
+
else:
|
| 69 |
+
conversations = paste.parse(content)
|
| 70 |
+
result = evaluate(conversations, lora_client)
|
| 71 |
+
except InsufficientData as e:
|
| 72 |
+
raise AnalysisError(_insufficient_message(e)) from e
|
| 73 |
+
except InferenceUnavailable as e:
|
| 74 |
+
raise AnalysisError("The scoring service is temporarily unavailable. Please try again in a moment.") from e
|
| 75 |
+
except ValueError as e:
|
| 76 |
+
raise AnalysisError(str(e)) from e
|
| 77 |
+
|
| 78 |
+
tips = tips_mod.generate_tips(result["axes"], tips_client, _style_comment(result)) if tips_client else []
|
| 79 |
+
png = card.render_png(result, name=name)
|
| 80 |
+
return {"card_png": png, "tips": tips, "result": result}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def analyze_upload(path: str, lora_client: LoRAClient, *, name: str = "", tips_client: LLMClient | None = None, delete: bool = True) -> dict:
|
| 84 |
+
"""Read the upload into memory, delete the file immediately, then analyze."""
|
| 85 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as fh:
|
| 86 |
+
content = fh.read()
|
| 87 |
+
if delete and os.path.exists(path):
|
| 88 |
+
os.remove(path)
|
| 89 |
+
return analyze(content, lora_client, name=name, tips_client=tips_client)
|
prompt_card/card.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Player card rendering — pure Pillow first cut (no plotly/matplotlib/kaleido).
|
| 2 |
+
|
| 3 |
+
`radar_points` is pure geometry (unit-tested). `render_png` composes the card: radar grid +
|
| 4 |
+
score polygon, per-axis labels, overall rating and star tier. Swap for richer visuals later;
|
| 5 |
+
the interactive Gradio view can use Plotly without changing this PNG path.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import io
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 13 |
+
|
| 14 |
+
from .scoring.overall import AXES
|
| 15 |
+
|
| 16 |
+
# short labels for the rim (full names live in AXES)
|
| 17 |
+
SHORT = {
|
| 18 |
+
"Technique": "TEC",
|
| 19 |
+
"Context": "CTX",
|
| 20 |
+
"Interaction": "INT",
|
| 21 |
+
"Focus": "FOC",
|
| 22 |
+
"Critical Engagement": "CRI",
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
_BG = (18, 22, 33)
|
| 26 |
+
_GRID = (60, 68, 86)
|
| 27 |
+
_FILL = (90, 170, 255)
|
| 28 |
+
_ACCENT = (255, 205, 90)
|
| 29 |
+
_TEXT = (230, 235, 245)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def radar_points(scores, center, max_radius):
|
| 33 |
+
"""Map scores (0-10) to (x, y) points on a radar, first axis pointing straight up,
|
| 34 |
+
going clockwise. Score 0 -> center, score 10 -> rim."""
|
| 35 |
+
cx, cy = center
|
| 36 |
+
n = len(scores)
|
| 37 |
+
pts = []
|
| 38 |
+
for i, s in enumerate(scores):
|
| 39 |
+
angle = -math.pi / 2 + 2 * math.pi * i / n
|
| 40 |
+
r = (max(0.0, min(10.0, s)) / 10.0) * max_radius
|
| 41 |
+
pts.append((cx + r * math.cos(angle), cy + r * math.sin(angle)))
|
| 42 |
+
return pts
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _font(size):
|
| 46 |
+
try:
|
| 47 |
+
return ImageFont.truetype("DejaVuSans.ttf", size)
|
| 48 |
+
except Exception:
|
| 49 |
+
return ImageFont.load_default()
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _ring(draw, center, radius, n):
|
| 53 |
+
pts = radar_points([10] * n, center, radius)
|
| 54 |
+
draw.polygon(pts, outline=_GRID)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def render_png(result: dict, name: str = "") -> bytes:
|
| 58 |
+
W, H = 600, 720
|
| 59 |
+
center = (W // 2, 300)
|
| 60 |
+
max_radius = 200
|
| 61 |
+
|
| 62 |
+
img = Image.new("RGB", (W, H), _BG)
|
| 63 |
+
d = ImageDraw.Draw(img, "RGBA")
|
| 64 |
+
|
| 65 |
+
axes = result["axes"]
|
| 66 |
+
labels = list(axes.keys())
|
| 67 |
+
scores = [axes[k] for k in labels]
|
| 68 |
+
n = len(labels)
|
| 69 |
+
|
| 70 |
+
# grid rings + spokes
|
| 71 |
+
for frac in (0.25, 0.5, 0.75, 1.0):
|
| 72 |
+
_ring(d, center, max_radius * frac, n)
|
| 73 |
+
for x, y in radar_points([10] * n, center, max_radius):
|
| 74 |
+
d.line([center, (x, y)], fill=_GRID)
|
| 75 |
+
|
| 76 |
+
# score polygon
|
| 77 |
+
poly = radar_points(scores, center, max_radius)
|
| 78 |
+
if n >= 3:
|
| 79 |
+
d.polygon(poly, fill=(_FILL[0], _FILL[1], _FILL[2], 90), outline=_FILL)
|
| 80 |
+
for x, y in poly:
|
| 81 |
+
d.ellipse([x - 4, y - 4, x + 4, y + 4], fill=_FILL)
|
| 82 |
+
|
| 83 |
+
# rim labels
|
| 84 |
+
lf = _font(20)
|
| 85 |
+
for (x, y), key in zip(radar_points([11.4] * n, center, max_radius), labels):
|
| 86 |
+
txt = SHORT.get(key, key[:3].upper())
|
| 87 |
+
bb = d.textbbox((0, 0), txt, font=lf)
|
| 88 |
+
d.text((x - (bb[2] - bb[0]) / 2, y - (bb[3] - bb[1]) / 2), txt, fill=_TEXT, font=lf)
|
| 89 |
+
|
| 90 |
+
# header
|
| 91 |
+
if name:
|
| 92 |
+
d.text((30, 24), name, fill=_TEXT, font=_font(34))
|
| 93 |
+
|
| 94 |
+
# footer: overall + stars + per-axis values
|
| 95 |
+
overall = result.get("overall", 0.0)
|
| 96 |
+
stars = result.get("stars", 1)
|
| 97 |
+
d.text((30, 560), f"OVERALL {overall:.1f}", fill=_ACCENT, font=_font(30))
|
| 98 |
+
d.text((30, 600), "★" * stars + "☆" * (5 - stars), fill=_ACCENT, font=_font(30))
|
| 99 |
+
|
| 100 |
+
sf = _font(18)
|
| 101 |
+
y = 560
|
| 102 |
+
for key in labels:
|
| 103 |
+
d.text((330, y), f"{SHORT.get(key, key[:3])} {axes[key]:.1f}", fill=_TEXT, font=sf)
|
| 104 |
+
y += 26
|
| 105 |
+
|
| 106 |
+
buf = io.BytesIO()
|
| 107 |
+
img.save(buf, format="PNG")
|
| 108 |
+
return buf.getvalue()
|
prompt_card/evaluate.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""v2 evaluation orchestrator (replaces score.py's engine path).
|
| 2 |
+
|
| 3 |
+
Flow: preprocess (English filter, min-20 gate, 500-cap, session grouping) -> stratified-sample
|
| 4 |
+
the prompts for the per-prompt axes -> call the Modal LoRA evaluator with prompts + conversation
|
| 5 |
+
payloads -> aggregate the returned labels via saturation curves / CE mean -> overall + stars.
|
| 6 |
+
|
| 7 |
+
Per-prompt axes (Technique, Context) score the sampled prompts; per-conversation axes
|
| 8 |
+
(Interaction, Focus, Critical Engagement) score the conversation payloads. `InferenceUnavailable`
|
| 9 |
+
propagates to the caller (app layer), which renders a friendly message — analysis never crashes.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from .schema import Conversation, ROLE_USER
|
| 14 |
+
from .preprocess import preprocess
|
| 15 |
+
from .scoring import labels, overall
|
| 16 |
+
from .scoring.sampling import stratified_sample, DEFAULT_N
|
| 17 |
+
from .llm.lora_client import LoRAClient
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def conversation_payload(conv: Conversation) -> dict:
|
| 21 |
+
"""Serialize a conversation for the per-conversation LoRA axes. Keeps assistant turns
|
| 22 |
+
(needed for clarifying-question / sycophancy detection); turn-count thresholds are applied
|
| 23 |
+
Modal-side."""
|
| 24 |
+
turns = [{"role": t.role, "text": t.text} for t in conv.turns]
|
| 25 |
+
user_turns = sum(1 for t in conv.turns if t.role == ROLE_USER)
|
| 26 |
+
return {"turns": turns, "user_turns": user_turns}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def evaluate(
|
| 30 |
+
conversations: list[Conversation],
|
| 31 |
+
lora_client: LoRAClient,
|
| 32 |
+
*,
|
| 33 |
+
sample_n: int = DEFAULT_N,
|
| 34 |
+
seed: int = 0,
|
| 35 |
+
min_user_msgs: int = 20,
|
| 36 |
+
) -> dict:
|
| 37 |
+
prepared = preprocess(conversations, min_user_msgs=min_user_msgs)
|
| 38 |
+
all_prompts = prepared.user_prompts
|
| 39 |
+
sampled = stratified_sample(all_prompts, n=sample_n, seed=seed)
|
| 40 |
+
conv_payloads = [conversation_payload(c) for c in prepared.conversations]
|
| 41 |
+
|
| 42 |
+
out = lora_client.evaluate(sampled, conv_payloads) # may raise InferenceUnavailable
|
| 43 |
+
|
| 44 |
+
axes = labels.assemble_axes(
|
| 45 |
+
technique=out["technique"],
|
| 46 |
+
context=out["context"],
|
| 47 |
+
interaction=out["interaction"],
|
| 48 |
+
focus=out["focus"],
|
| 49 |
+
critical_engagement=out["critical_engagement"],
|
| 50 |
+
)
|
| 51 |
+
ov = overall.overall(axes)
|
| 52 |
+
return {
|
| 53 |
+
"axes": axes,
|
| 54 |
+
"overall": ov,
|
| 55 |
+
"stars": overall.star_tier(ov),
|
| 56 |
+
"raw": {
|
| 57 |
+
"n_user_prompts": len(all_prompts),
|
| 58 |
+
"n_prompts_scored": len(sampled),
|
| 59 |
+
"sampled": len(sampled) < len(all_prompts),
|
| 60 |
+
"n_conversations": len(conv_payloads),
|
| 61 |
+
},
|
| 62 |
+
}
|
prompt_card/llm/__init__.py
ADDED
|
File without changes
|
prompt_card/llm/client.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LLM client interface. Phase 1 uses MockLLMClient; the real MiniCPM-on-Modal client
|
| 2 |
+
is wired in Phase 2-B behind the same `generate` contract."""
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Protocol
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class LLMClient(Protocol):
|
| 9 |
+
def generate(self, prompt: str) -> str:
|
| 10 |
+
...
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MockLLMClient:
|
| 14 |
+
"""Deterministic stand-in: returns a fixed response and records every prompt it received."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, response: str = ""):
|
| 17 |
+
self._response = response
|
| 18 |
+
self.calls: list[str] = []
|
| 19 |
+
|
| 20 |
+
def generate(self, prompt: str) -> str:
|
| 21 |
+
self.calls.append(prompt)
|
| 22 |
+
return self._response
|
prompt_card/llm/critical_router.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Per-type hybrid router for Critical Engagement (Kim's Option B).
|
| 2 |
+
|
| 3 |
+
Validation κ vs the Phase-7 retrained Critical LoRA `critical_c_r16e5` (see eval/decisions.md):
|
| 4 |
+
skepticism base 0.576 > LoRA 0.497 → base
|
| 5 |
+
rebuttal base 0.521 ≈ LoRA 0.527 → base (tie, keep base default)
|
| 6 |
+
source_request base 0.467 < LoRA 0.664 → LoRA (Phase-7 WIN: targeted weak-type synthesis, +0.197)
|
| 7 |
+
independent_verification base 0.035 << LoRA 0.578 → LoRA
|
| 8 |
+
re_questioning base 0.058 > LoRA 0.028 → base (documented limitation: insufficient training data)
|
| 9 |
+
|
| 10 |
+
Hybrid per-type-best κ ≈ 0.479 (Phase 7; was 0.441 with indep_verif-only). The LoRA is routed for the two
|
| 11 |
+
types it wins (independent_verification + source_request); the rest stay base, avoiding the catastrophic
|
| 12 |
+
forgetting it caused on skepticism/re_questioning.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
CE = ["skepticism", "rebuttal", "source_request", "independent_verification", "re_questioning"]
|
| 17 |
+
|
| 18 |
+
# Types routed to the LoRA; everything else uses the base model.
|
| 19 |
+
LORA_TYPES = frozenset({"independent_verification", "source_request"})
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def merge_critical(base_pred: dict | None, lora_pred: dict | None) -> dict:
|
| 23 |
+
"""Combine two 5-bool predictions per the routing table → one 5-bool dict."""
|
| 24 |
+
base_pred = base_pred or {}
|
| 25 |
+
lora_pred = lora_pred or {}
|
| 26 |
+
out = {}
|
| 27 |
+
for t in CE:
|
| 28 |
+
src = lora_pred if t in LORA_TYPES else base_pred
|
| 29 |
+
out[t] = bool(src.get(t))
|
| 30 |
+
return out
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def merge_batch(base_preds: list, lora_preds: list) -> list:
|
| 34 |
+
"""Element-wise merge of aligned base/LoRA prediction lists."""
|
| 35 |
+
return [merge_critical(b, l) for b, l in zip(base_preds, lora_preds)]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def lora_critical_predict(prompts: list[str]) -> list[dict]:
|
| 39 |
+
"""Run the Critical LoRA (base+adapter on Modal) over prompts → parsed 5-bool dicts.
|
| 40 |
+
One batched remote call per upload (cold-start amortized). Returns {} for parse failures."""
|
| 41 |
+
from modal_eval_lora import app, evaluate
|
| 42 |
+
from ..scoring.observable_axes import parse
|
| 43 |
+
from .lora_router import ADAPTER_DIR
|
| 44 |
+
with app.run():
|
| 45 |
+
raw = evaluate.remote("critical", prompts, adapter=ADAPTER_DIR.get("critical", ""))
|
| 46 |
+
out = []
|
| 47 |
+
for r in raw:
|
| 48 |
+
d = parse(r, CE)
|
| 49 |
+
out.append(d if d is not None else {})
|
| 50 |
+
return out
|
prompt_card/llm/judge.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniCPM-as-judge for Critical Engagement (the noisy axis).
|
| 2 |
+
|
| 3 |
+
Tier-3 only runs on hard / low-confidence cases: when the heuristic markers leave the verdict
|
| 4 |
+
ambiguous we ask the model; clear cases (no markers, or many) are decided by the heuristic alone.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import re
|
| 10 |
+
|
| 11 |
+
from .client import LLMClient
|
| 12 |
+
from ..scoring import heuristics as H
|
| 13 |
+
|
| 14 |
+
ESCALATE_THRESHOLD = 0.6
|
| 15 |
+
_JSON = re.compile(r"\{.*?\}", re.S)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def build_ce_judge_prompt(prompts: list[str]) -> str:
|
| 19 |
+
joined = "\n".join(f"- {p}" for p in prompts)
|
| 20 |
+
return (
|
| 21 |
+
"You are evaluating whether a user critically engages with an AI assistant — "
|
| 22 |
+
"challenging answers, asking for sources, pushing back, or verifying claims, rather "
|
| 23 |
+
"than passively accepting everything.\n\n"
|
| 24 |
+
"User messages:\n"
|
| 25 |
+
f"{joined}\n\n"
|
| 26 |
+
'Respond ONLY with JSON: {"critical_engagement": true|false, "confidence": 0.0-1.0}'
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def parse_ce_verdict(response: str) -> dict:
|
| 31 |
+
m = _JSON.search(response or "")
|
| 32 |
+
if not m:
|
| 33 |
+
raise ValueError("no JSON object found in judge response")
|
| 34 |
+
data = json.loads(m.group(0))
|
| 35 |
+
return {
|
| 36 |
+
"critical_engagement": bool(data.get("critical_engagement", False)),
|
| 37 |
+
"confidence": float(data.get("confidence", 0.0)),
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def heuristic_confidence(prompts: list[str]) -> float:
|
| 42 |
+
"""Confident when the marker signal is unambiguous: clearly none, or clearly frequent.
|
| 43 |
+
A small nonzero rate (a stray borderline marker) is the uncertain band that we escalate."""
|
| 44 |
+
rate = H.ce_rate(prompts)
|
| 45 |
+
if rate == 0.0:
|
| 46 |
+
return 0.9 # no markers at all -> confidently not critically engaged
|
| 47 |
+
if rate >= 0.5:
|
| 48 |
+
return 0.85 # challenges dominate -> confidently engaged
|
| 49 |
+
return 0.4 # markers present but noisy -> ambiguous, let the model decide
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def should_escalate(confidence: float, *, threshold: float = ESCALATE_THRESHOLD) -> bool:
|
| 53 |
+
return confidence < threshold
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def judge_ce(prompts: list[str], client: LLMClient, *, threshold: float = ESCALATE_THRESHOLD) -> dict:
|
| 57 |
+
"""Return a CE verdict. Escalate to the model only when the heuristic is uncertain."""
|
| 58 |
+
confidence = heuristic_confidence(prompts)
|
| 59 |
+
heuristic_verdict = {
|
| 60 |
+
"critical_engagement": H.ce_rate(prompts) >= 0.5,
|
| 61 |
+
"confidence": confidence,
|
| 62 |
+
"escalated": False,
|
| 63 |
+
}
|
| 64 |
+
if not should_escalate(confidence, threshold=threshold):
|
| 65 |
+
return heuristic_verdict
|
| 66 |
+
try:
|
| 67 |
+
verdict = parse_ce_verdict(client.generate(build_ce_judge_prompt(prompts)))
|
| 68 |
+
except ValueError:
|
| 69 |
+
# model emitted unparseable output -> degrade to the heuristic rather than abort
|
| 70 |
+
return {**heuristic_verdict, "escalated": True, "parse_error": True}
|
| 71 |
+
verdict["escalated"] = True
|
| 72 |
+
return verdict
|
prompt_card/llm/lora_client.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client to the Modal multi-LoRA evaluator endpoint.
|
| 2 |
+
|
| 3 |
+
`ModalLoRAClient` POSTs {prompts, conversations} and returns label lists for all 5 axes. It
|
| 4 |
+
retries transient failures with exponential backoff and, on final failure, raises
|
| 5 |
+
`InferenceUnavailable` — the caller turns that into a friendly message. Inference failure must
|
| 6 |
+
never crash the analysis (the v1 judge-crash bug). `MockLoRAClient` lets the app run end-to-end
|
| 7 |
+
before real LoRAs exist; swap it for `ModalLoRAClient` once the endpoint is deployed.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from typing import Protocol, Sequence
|
| 13 |
+
|
| 14 |
+
import requests
|
| 15 |
+
|
| 16 |
+
_AXES = ["technique", "context", "interaction", "focus", "critical_engagement"]
|
| 17 |
+
_TECH_FIELDS = ("zero_shot_role", "few_shot", "thought_generation",
|
| 18 |
+
"decomposition", "self_criticism", "flipped")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class InferenceUnavailable(Exception):
|
| 22 |
+
"""Raised when the Modal endpoint cannot be reached after retries."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class LoRAClient(Protocol):
|
| 26 |
+
def evaluate(self, prompts: Sequence[str], conversations: Sequence[dict]) -> dict:
|
| 27 |
+
...
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class ModalLoRAClient:
|
| 31 |
+
def __init__(self, base_url: str, token: str, *, retries: int = 2, timeout: int = 180,
|
| 32 |
+
backoff: float = 0.5, post=None, sleep=None):
|
| 33 |
+
self.base_url = base_url.rstrip("/")
|
| 34 |
+
self.token = token
|
| 35 |
+
self.retries = retries
|
| 36 |
+
self.timeout = timeout
|
| 37 |
+
self.backoff = backoff
|
| 38 |
+
self._post = post or requests.post
|
| 39 |
+
self._sleep = sleep or time.sleep
|
| 40 |
+
|
| 41 |
+
def evaluate(self, prompts: Sequence[str], conversations: Sequence[dict]) -> dict:
|
| 42 |
+
payload = {"prompts": list(prompts), "conversations": list(conversations)}
|
| 43 |
+
last_err = None
|
| 44 |
+
for attempt in range(self.retries + 1):
|
| 45 |
+
try:
|
| 46 |
+
resp = self._post(
|
| 47 |
+
f"{self.base_url}/evaluate",
|
| 48 |
+
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
| 49 |
+
json=payload,
|
| 50 |
+
timeout=self.timeout,
|
| 51 |
+
)
|
| 52 |
+
resp.raise_for_status()
|
| 53 |
+
return resp.json()
|
| 54 |
+
except Exception as e: # network error or HTTP error -> retry, then degrade
|
| 55 |
+
last_err = e
|
| 56 |
+
if attempt < self.retries:
|
| 57 |
+
self._sleep(self.backoff * (2 ** attempt))
|
| 58 |
+
raise InferenceUnavailable(f"Modal evaluator unreachable after {self.retries + 1} attempts") from last_err
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class MockLoRAClient:
|
| 62 |
+
"""Neutral canned labels sized to the inputs, so the full pipeline runs without real LoRAs.
|
| 63 |
+
Pass `responder(prompts, conversations) -> dict` to inject custom labels for testing."""
|
| 64 |
+
|
| 65 |
+
def __init__(self, responder=None):
|
| 66 |
+
self._responder = responder
|
| 67 |
+
self.calls: list = []
|
| 68 |
+
|
| 69 |
+
def evaluate(self, prompts: Sequence[str], conversations: Sequence[dict]) -> dict:
|
| 70 |
+
self.calls.append((list(prompts), list(conversations)))
|
| 71 |
+
if self._responder:
|
| 72 |
+
return self._responder(prompts, conversations)
|
| 73 |
+
return {
|
| 74 |
+
"technique": [{f: False for f in _TECH_FIELDS} for _ in prompts],
|
| 75 |
+
"context": [{"overall_richness": 1} for _ in prompts],
|
| 76 |
+
"interaction": [{"is_interactive_learning": False} for _ in conversations],
|
| 77 |
+
"focus": [{"is_focused": True} for _ in conversations],
|
| 78 |
+
"critical_engagement": [{"engagement_type": "passive_accept"} for _ in conversations],
|
| 79 |
+
}
|
prompt_card/llm/lora_router.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""General per-category LoRA routing. Each locked adapter overrides ONE feature of ONE observable axis;
|
| 2 |
+
all other features stay base 8B (per-category hybrid → no catastrophic forgetting). One Modal session per
|
| 3 |
+
upload runs every enabled adapter.
|
| 4 |
+
|
| 5 |
+
Locked adapters (validation κ, base → LoRA; see eval/decisions.md):
|
| 6 |
+
interaction 0.320 → 0.450 (refinement_attempt)
|
| 7 |
+
critical per-type hybrid (Phase 7): LoRA for independent_verification 0.578 + source_request
|
| 8 |
+
0.467→0.664; skepticism/rebuttal/re_questioning stay base. Hybrid headline 0.441→0.479.
|
| 9 |
+
goal_stated 0.226 → 0.419 (goal_stated, within Input Quality)
|
| 10 |
+
decomposition 0.261 → 0.656 (decomposition, within Technique; Phase-7 retrain, adapter decomposition_d_r16e5)
|
| 11 |
+
|
| 12 |
+
Each adapter is TRUSTED only for its own feature(s); the LoRA's other outputs are ignored.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from eval.step_critical import CE
|
| 17 |
+
from ..scoring.observable_axes import parse
|
| 18 |
+
|
| 19 |
+
# axis -> (json fields parsed from the adapter output, the feature(s) the adapter is trusted for)
|
| 20 |
+
ADAPTERS = {
|
| 21 |
+
"interaction": (("refinement_attempt",), {"refinement_attempt"}),
|
| 22 |
+
"critical": (CE, {"independent_verification"}),
|
| 23 |
+
"goal_stated": (("goal_stated",), {"goal_stated"}),
|
| 24 |
+
"decomposition": (("decomposition",), {"decomposition"}),
|
| 25 |
+
}
|
| 26 |
+
DEFAULT_ENABLED = frozenset(ADAPTERS) # all locked adapters
|
| 27 |
+
|
| 28 |
+
# Per-axis adapter-dir override on the Modal volume (/data/adapters/<dir>). When absent, evaluate()
|
| 29 |
+
# defaults to the axis name. Phase 6 retrained decomposition into a suffixed dir (see eval/decisions.md);
|
| 30 |
+
# point production at the winning adapter without copying volume files.
|
| 31 |
+
ADAPTER_DIR = {
|
| 32 |
+
"decomposition": "decomposition_d_r16e5", # Phase-7 winner: κ 0.656 (expanded data + hard negs; beat Phase-6 p6d 0.612)
|
| 33 |
+
"critical": "critical_c_r16e5", # Phase-7: serves independent_verification + source_request (hybrid 0.441→0.479)
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def run(requests: dict, *, workers=None, progress=None) -> dict:
|
| 38 |
+
"""requests: {axis: [prompts]} -> {axis: [parsed dict]}. Returns {} for any axis whose call fails
|
| 39 |
+
(caller degrades that feature to base). `progress` (if given) is ticked per completed call.
|
| 40 |
+
|
| 41 |
+
PRODUCTION path (LORA_ENDPOINT=1): the configured OPENBMB endpoint is the Modal vLLM server that serves
|
| 42 |
+
every adapter by name (model=<adapter>), so LoRA inference is plain HTTP on the SAME warm server as base
|
| 43 |
+
— no `modal` package or Modal auth needed on the Space. Legacy path: per-adapter Modal H100 batch jobs."""
|
| 44 |
+
import os
|
| 45 |
+
if os.environ.get("LORA_ENDPOINT") and os.environ.get("OPENBMB_BASE_URL"):
|
| 46 |
+
return _run_via_endpoint(requests, workers=workers, progress=progress)
|
| 47 |
+
from modal_eval_lora import app, evaluate
|
| 48 |
+
out = {}
|
| 49 |
+
with app.run():
|
| 50 |
+
for axis, prompts in requests.items():
|
| 51 |
+
if not prompts:
|
| 52 |
+
out[axis] = []
|
| 53 |
+
continue
|
| 54 |
+
fields = ADAPTERS[axis][0]
|
| 55 |
+
raw = evaluate.remote(axis, prompts, adapter=ADAPTER_DIR.get(axis, ""))
|
| 56 |
+
out[axis] = [parse(r, fields) or {} for r in raw]
|
| 57 |
+
if progress is not None:
|
| 58 |
+
progress.tick(len(prompts))
|
| 59 |
+
return out
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _run_via_endpoint(requests: dict, *, workers=None, progress=None) -> dict:
|
| 63 |
+
"""Route ALL axes' prompts to the vLLM endpoint in ONE wide concurrent pool (not per-axis serial).
|
| 64 |
+
Each prompt goes to its adapter's served model name; one shared executor at `workers` concurrency
|
| 65 |
+
saturates the warm server. `progress` ticked per completed call."""
|
| 66 |
+
import os
|
| 67 |
+
import concurrent.futures as cf
|
| 68 |
+
from .minicpm import MiniCPMClient
|
| 69 |
+
base, token = os.environ["OPENBMB_BASE_URL"], os.environ.get("OPENBMB_TOKEN", "")
|
| 70 |
+
timeout = int(os.environ.get("OPENBMB_TIMEOUT") or "180")
|
| 71 |
+
workers = workers or max(1, int(os.environ.get("SCORE_CONCURRENCY") or "48"))
|
| 72 |
+
# one client per axis (each routes to its adapter's served model name on the same endpoint)
|
| 73 |
+
clients = {axis: MiniCPMClient(base, token, model=ADAPTER_DIR.get(axis, axis), max_tokens=96,
|
| 74 |
+
timeout=timeout) for axis in requests}
|
| 75 |
+
out = {axis: [None] * len(prompts) for axis, prompts in requests.items()}
|
| 76 |
+
jobs = [(axis, i, p) for axis, prompts in requests.items() for i, p in enumerate(prompts)]
|
| 77 |
+
if not jobs:
|
| 78 |
+
return {axis: [] for axis in requests}
|
| 79 |
+
with cf.ThreadPoolExecutor(max_workers=workers) as ex:
|
| 80 |
+
futs = {ex.submit(clients[axis].generate, p): (axis, i) for axis, i, p in jobs}
|
| 81 |
+
for f in cf.as_completed(futs):
|
| 82 |
+
axis, i = futs[f]
|
| 83 |
+
try:
|
| 84 |
+
out[axis][i] = parse(f.result(), ADAPTERS[axis][0]) or {}
|
| 85 |
+
except Exception:
|
| 86 |
+
out[axis][i] = {} # this feature degrades to base for that turn
|
| 87 |
+
if progress is not None:
|
| 88 |
+
progress.tick()
|
| 89 |
+
return out
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def trusted(axis) -> set:
|
| 93 |
+
return ADAPTERS[axis][1]
|
prompt_card/llm/minicpm.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MiniCPM client over the OpenBMB free API (OpenAI-style vLLM endpoint).
|
| 2 |
+
|
| 3 |
+
MiniCPM4.1-8B is a reasoning model: it can emit a `<think>...</think>` block and tends to wrap
|
| 4 |
+
JSON in a markdown fence. We disable thinking (`enable_thinking=False`) for our simple JSON
|
| 5 |
+
tasks and `clean_content` strips any leftover think-block / code fence so the existing judge and
|
| 6 |
+
tips parsers work unchanged. HTTP is injectable (`post`) so unit tests need no network.
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import re
|
| 11 |
+
|
| 12 |
+
import requests
|
| 13 |
+
|
| 14 |
+
DEFAULT_MODEL = "MiniCPM4.1-8B"
|
| 15 |
+
_THINK = re.compile(r"<think>.*?</think>", re.S)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def clean_content(text: str) -> str:
|
| 19 |
+
"""Strip reasoning blocks and a surrounding markdown code fence."""
|
| 20 |
+
t = _THINK.sub("", text or "").strip()
|
| 21 |
+
if t.startswith("```"):
|
| 22 |
+
t = re.sub(r"^```[a-zA-Z0-9]*\n?", "", t)
|
| 23 |
+
t = re.sub(r"\n?```$", "", t)
|
| 24 |
+
return t.strip()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class MiniCPMClient:
|
| 28 |
+
def __init__(
|
| 29 |
+
self,
|
| 30 |
+
base_url: str,
|
| 31 |
+
token: str,
|
| 32 |
+
*,
|
| 33 |
+
model: str = DEFAULT_MODEL,
|
| 34 |
+
max_tokens: int = 512,
|
| 35 |
+
timeout: int = 60,
|
| 36 |
+
enable_thinking: bool = False,
|
| 37 |
+
post=None,
|
| 38 |
+
):
|
| 39 |
+
self.base_url = base_url.rstrip("/")
|
| 40 |
+
self.token = token
|
| 41 |
+
self.model = model
|
| 42 |
+
self.max_tokens = max_tokens
|
| 43 |
+
self.timeout = timeout
|
| 44 |
+
self.enable_thinking = enable_thinking
|
| 45 |
+
# Pool connections so the hundreds of concurrent scoring calls reuse TCP/TLS instead of a fresh
|
| 46 |
+
# handshake per request (a real per-call cost against a remote Modal endpoint). Injectable `post`
|
| 47 |
+
# (tests) bypasses the session entirely.
|
| 48 |
+
if post is not None:
|
| 49 |
+
self._post = post
|
| 50 |
+
else:
|
| 51 |
+
sess = requests.Session()
|
| 52 |
+
adapter = requests.adapters.HTTPAdapter(pool_connections=64, pool_maxsize=256)
|
| 53 |
+
sess.mount("http://", adapter)
|
| 54 |
+
sess.mount("https://", adapter)
|
| 55 |
+
self._post = sess.post
|
| 56 |
+
|
| 57 |
+
def generate(self, prompt: str) -> str:
|
| 58 |
+
body = {
|
| 59 |
+
"model": self.model,
|
| 60 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 61 |
+
"temperature": 0,
|
| 62 |
+
"max_tokens": self.max_tokens,
|
| 63 |
+
"chat_template_kwargs": {"enable_thinking": self.enable_thinking},
|
| 64 |
+
}
|
| 65 |
+
resp = self._post(
|
| 66 |
+
f"{self.base_url}/v1/chat/completions",
|
| 67 |
+
headers={"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"},
|
| 68 |
+
json=body,
|
| 69 |
+
timeout=self.timeout,
|
| 70 |
+
)
|
| 71 |
+
resp.raise_for_status()
|
| 72 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 73 |
+
return clean_content(content)
|