xxixx1028 commited on
Commit
dc9f530
·
verified ·
1 Parent(s): 8e0c4a2

Deploy PromptStat — UI shell + MiniCPM4.1-8B + 4-LoRA hybrid (Modal)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. README.md +66 -9
  3. eval/__init__.py +0 -0
  4. eval/_cache/cascade_results.json +462 -0
  5. eval/_cache/critical.json +14 -0
  6. eval/_cache/critical_hybrid.json +13 -0
  7. eval/_cache/lora_critical.json +12 -0
  8. eval/_cache/lora_critical_c_r16e5.json +13 -0
  9. eval/_cache/lora_critical_c_r16e8.json +13 -0
  10. eval/_cache/lora_decomposition.json +5 -0
  11. eval/_cache/lora_decomposition_d_r16e5.json +6 -0
  12. eval/_cache/lora_decomposition_d_r16e8.json +6 -0
  13. eval/_cache/lora_decomposition_p6a.json +6 -0
  14. eval/_cache/lora_decomposition_p6b.json +6 -0
  15. eval/_cache/lora_decomposition_p6c.json +6 -0
  16. eval/_cache/lora_decomposition_p6d.json +6 -0
  17. eval/_cache/lora_goal_stated.json +5 -0
  18. eval/_cache/lora_goal_stated_g_r16e5.json +6 -0
  19. eval/_cache/lora_interaction.json +5 -0
  20. eval/_cache/lora_interaction_i_r16e5.json +6 -0
  21. eval/_cache/lora_interaction_i_r16e8.json +6 -0
  22. eval/_cache/lora_interaction_i_r32e5.json +6 -0
  23. eval/_cache/step_c.json +6 -0
  24. eval/_cache/step_d.json +7 -0
  25. eval/_ceiling/prep_p7.py +82 -0
  26. eval/cascade.py +129 -0
  27. eval/kappa.py +412 -0
  28. eval/measure_lora.py +113 -0
  29. eval/prompts_v2.py +112 -0
  30. eval/prompts_v3.py +51 -0
  31. eval/prompts_v4.py +128 -0
  32. eval/step_c.py +137 -0
  33. eval/step_critical.py +114 -0
  34. eval/step_d.py +170 -0
  35. prompt_card/__init__.py +0 -0
  36. prompt_card/adapters/__init__.py +0 -0
  37. prompt_card/adapters/chatgpt.py +62 -0
  38. prompt_card/adapters/claude.py +29 -0
  39. prompt_card/adapters/gemini.py +29 -0
  40. prompt_card/adapters/paste.py +52 -0
  41. prompt_card/app_core.py +89 -0
  42. prompt_card/card.py +108 -0
  43. prompt_card/evaluate.py +62 -0
  44. prompt_card/llm/__init__.py +0 -0
  45. prompt_card/llm/client.py +22 -0
  46. prompt_card/llm/critical_router.py +50 -0
  47. prompt_card/llm/judge.py +72 -0
  48. prompt_card/llm/lora_client.py +79 -0
  49. prompt_card/llm/lora_router.py +93 -0
  50. 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: Promptstat
3
- emoji: 📊
4
- colorFrom: purple
5
- colorTo: yellow
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
- app_file: app.py
10
  pinned: false
11
  license: apache-2.0
12
- short_description: Your AI usage skill card from AI conversation
 
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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)