qpilot 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (425) hide show
  1. qpilot/__init__.py +6 -0
  2. qpilot/_methods/absolute_quantification.md +54 -0
  3. qpilot/_methods/amp_call.md +34 -0
  4. qpilot/_methods/comparisons.md +70 -0
  5. qpilot/_methods/contrast.md +24 -0
  6. qpilot/_methods/curve_judge.md +161 -0
  7. qpilot/_methods/design_checks.md +125 -0
  8. qpilot/_methods/efficiency_model.md +32 -0
  9. qpilot/_methods/evidence.md +50 -0
  10. qpilot/_methods/genorm.md +38 -0
  11. qpilot/_methods/instrument_context.md +66 -0
  12. qpilot/_methods/inter_run_calibration.md +49 -0
  13. qpilot/_methods/linregpcr.md +44 -0
  14. qpilot/_methods/lookback.md +95 -0
  15. qpilot/_methods/melt_curve.md +66 -0
  16. qpilot/_methods/normfinder.md +104 -0
  17. qpilot/_methods/replicate_budget.md +68 -0
  18. qpilot/_methods/routes.md +28 -0
  19. qpilot/_methods/sheetcheck.md +32 -0
  20. qpilot/_methods/trace.md +82 -0
  21. qpilot/_packs/research-default.yaml +339 -0
  22. qpilot/agents/__init__.py +1 -0
  23. qpilot/agents/capabilities.py +1066 -0
  24. qpilot/agents/idempotency.py +119 -0
  25. qpilot/agents/outputs.py +785 -0
  26. qpilot/agents/refusals.py +199 -0
  27. qpilot/agents/surface.py +294 -0
  28. qpilot/agents/views.py +254 -0
  29. qpilot/ai/__init__.py +1 -0
  30. qpilot/ai/ask.py +924 -0
  31. qpilot/ai/client.py +328 -0
  32. qpilot/ai/config.py +124 -0
  33. qpilot/ai/explain.py +223 -0
  34. qpilot/ai/interpret.py +1651 -0
  35. qpilot/ai/payload_log.py +74 -0
  36. qpilot/ai/prism_draft.py +460 -0
  37. qpilot/ai/profile.py +75 -0
  38. qpilot/ai/prompts.py +115 -0
  39. qpilot/ai/suggest_only.py +100 -0
  40. qpilot/ai/validator.py +11 -0
  41. qpilot/app_state.py +436 -0
  42. qpilot/claims/__init__.py +24 -0
  43. qpilot/claims/assist.py +201 -0
  44. qpilot/claims/check.py +1516 -0
  45. qpilot/claims/examples.py +137 -0
  46. qpilot/claims/extract.py +1167 -0
  47. qpilot/claims/lexicon.py +442 -0
  48. qpilot/claims/models.py +276 -0
  49. qpilot/cli/__init__.py +71 -0
  50. qpilot/cli/_console.py +33 -0
  51. qpilot/cli/_types.py +6 -0
  52. qpilot/cli/core_cmds.py +493 -0
  53. qpilot/cli/engine_cmds.py +586 -0
  54. qpilot/cli/melt_cmds.py +154 -0
  55. qpilot/cli/prism_cmds.py +360 -0
  56. qpilot/cli/quality_cmds.py +597 -0
  57. qpilot/cli/quant_cmds.py +192 -0
  58. qpilot/cli/studio_cmds.py +465 -0
  59. qpilot/core/__init__.py +1 -0
  60. qpilot/core/card_kinds.py +190 -0
  61. qpilot/core/confidence.py +95 -0
  62. qpilot/core/consequence.py +190 -0
  63. qpilot/core/decisions.py +436 -0
  64. qpilot/core/facts.py +102 -0
  65. qpilot/core/firewall.py +275 -0
  66. qpilot/core/gate.py +582 -0
  67. qpilot/core/instruments.py +1392 -0
  68. qpilot/core/names.py +97 -0
  69. qpilot/core/proposals.py +1073 -0
  70. qpilot/core/pseudonym.py +165 -0
  71. qpilot/core/record.py +1112 -0
  72. qpilot/core/robustness.py +395 -0
  73. qpilot/core/rules.py +609 -0
  74. qpilot/core/store.py +1416 -0
  75. qpilot/core/validator.py +137 -0
  76. qpilot/core/vocabulary.py +104 -0
  77. qpilot/data/hgnc/LICENCE +9 -0
  78. qpilot/data/hgnc/README.md +65 -0
  79. qpilot/data/hgnc/hgnc_symbols.tsv +45279 -0
  80. qpilot/explain/__init__.py +1 -0
  81. qpilot/explain/evidence_text.py +387 -0
  82. qpilot/explain/instrument_text.py +211 -0
  83. qpilot/explain/lookback_text.py +140 -0
  84. qpilot/explain/quantity_text.py +82 -0
  85. qpilot/explain/sets.py +138 -0
  86. qpilot/explain/sheetcheck_text.py +243 -0
  87. qpilot/explain/templates.py +552 -0
  88. qpilot/explain/trace_text.py +460 -0
  89. qpilot/explain/words.py +17 -0
  90. qpilot/harness/__init__.py +1 -0
  91. qpilot/harness/make_evalset.py +315 -0
  92. qpilot/harness/report.py +201 -0
  93. qpilot/harness/run.py +96 -0
  94. qpilot/harness/score.py +144 -0
  95. qpilot/harness/studio.py +949 -0
  96. qpilot/history/__init__.py +19 -0
  97. qpilot/history/compare.py +271 -0
  98. qpilot/history/replay.py +701 -0
  99. qpilot/history/studio.py +453 -0
  100. qpilot/integration/__init__.py +25 -0
  101. qpilot/integration/__main__.py +166 -0
  102. qpilot/integration/mcp_tools.py +407 -0
  103. qpilot/integration/simulator.py +865 -0
  104. qpilot/integration/workflow.py +1070 -0
  105. qpilot/io/README.md +42 -0
  106. qpilot/io/__init__.py +1 -0
  107. qpilot/io/connectors/__init__.py +52 -0
  108. qpilot/io/connectors/base.py +364 -0
  109. qpilot/io/connectors/builder.py +346 -0
  110. qpilot/io/connectors/cfx.py +293 -0
  111. qpilot/io/connectors/common.py +264 -0
  112. qpilot/io/connectors/headers.py +81 -0
  113. qpilot/io/connectors/lightcycler.py +243 -0
  114. qpilot/io/connectors/quantstudio.py +587 -0
  115. qpilot/io/connectors/rdml_file.py +199 -0
  116. qpilot/io/connectors/registry.py +531 -0
  117. qpilot/io/connectors/sample_types.py +105 -0
  118. qpilot/io/connectors/tables.py +502 -0
  119. qpilot/io/connectors/tidy.py +328 -0
  120. qpilot/io/datasets.py +220 -0
  121. qpilot/io/inputs.py +151 -0
  122. qpilot/io/rawness.py +377 -0
  123. qpilot/io/rdml_io.py +747 -0
  124. qpilot/io/sheets.py +989 -0
  125. qpilot/io/symbols.py +419 -0
  126. qpilot/io/trust.py +310 -0
  127. qpilot/mcp_server.py +2325 -0
  128. qpilot/models/__init__.py +21 -0
  129. qpilot/models/curve_judge/METHOD_CARD.md +161 -0
  130. qpilot/models/curve_judge/metrics.json +1888 -0
  131. qpilot/models/curve_judge/model.json +1 -0
  132. qpilot/models/curve_judge/model.sha256 +1 -0
  133. qpilot/models/curve_judge/transfer.json +1844 -0
  134. qpilot/prism/__init__.py +660 -0
  135. qpilot/prism/project.py +580 -0
  136. qpilot/prism/provenance.py +393 -0
  137. qpilot/prism/pzfx.py +653 -0
  138. qpilot/prism/roundtrip.py +1195 -0
  139. qpilot/prism/script.py +454 -0
  140. qpilot/prism/tables.py +461 -0
  141. qpilot/prism/templates/README.md +109 -0
  142. qpilot/qpcr/__init__.py +6 -0
  143. qpilot/qpcr/absolute.py +1422 -0
  144. qpilot/qpcr/absolute_study.py +156 -0
  145. qpilot/qpcr/amp_call.py +341 -0
  146. qpilot/qpcr/axes/__init__.py +322 -0
  147. qpilot/qpcr/axes/efficiency.py +257 -0
  148. qpilot/qpcr/axes/normaliser.py +343 -0
  149. qpilot/qpcr/cards.py +62 -0
  150. qpilot/qpcr/confidence_checks.py +176 -0
  151. qpilot/qpcr/contrast.py +274 -0
  152. qpilot/qpcr/cq_only.py +304 -0
  153. qpilot/qpcr/curve_judge.py +709 -0
  154. qpilot/qpcr/curves.py +781 -0
  155. qpilot/qpcr/design.py +90 -0
  156. qpilot/qpcr/design_checks.py +1478 -0
  157. qpilot/qpcr/design_rules.py +1187 -0
  158. qpilot/qpcr/designs.py +929 -0
  159. qpilot/qpcr/evidence.py +1011 -0
  160. qpilot/qpcr/export.py +1032 -0
  161. qpilot/qpcr/export_runs.py +85 -0
  162. qpilot/qpcr/gate_instr.py +230 -0
  163. qpilot/qpcr/gate_lines.py +33 -0
  164. qpilot/qpcr/gate_rt.py +245 -0
  165. qpilot/qpcr/gate_rules.py +2189 -0
  166. qpilot/qpcr/inspect.py +482 -0
  167. qpilot/qpcr/instrument_context.py +2278 -0
  168. qpilot/qpcr/interrun.py +344 -0
  169. qpilot/qpcr/layout.py +881 -0
  170. qpilot/qpcr/lookback.py +683 -0
  171. qpilot/qpcr/melt.py +832 -0
  172. qpilot/qpcr/multiplex.py +183 -0
  173. qpilot/qpcr/multiplicity.py +189 -0
  174. qpilot/qpcr/normfinder.py +383 -0
  175. qpilot/qpcr/plan.py +1082 -0
  176. qpilot/qpcr/position_history.py +480 -0
  177. qpilot/qpcr/quant.py +248 -0
  178. qpilot/qpcr/quantify_study.py +718 -0
  179. qpilot/qpcr/refgenes.py +237 -0
  180. qpilot/qpcr/repeat.py +868 -0
  181. qpilot/qpcr/routes.py +166 -0
  182. qpilot/qpcr/run_links.py +153 -0
  183. qpilot/qpcr/sheetcheck.py +2980 -0
  184. qpilot/qpcr/space.py +1455 -0
  185. qpilot/qpcr/study.py +2101 -0
  186. qpilot/qpcr/trace.py +2924 -0
  187. qpilot/qpcr/trace_bench.py +1126 -0
  188. qpilot/qpcr/validation.py +434 -0
  189. qpilot/report/__init__.py +31 -0
  190. qpilot/report/build.py +333 -0
  191. qpilot/report/bundle.py +419 -0
  192. qpilot/report/context.py +212 -0
  193. qpilot/report/excel.py +462 -0
  194. qpilot/report/figures.py +994 -0
  195. qpilot/report/fmt.py +80 -0
  196. qpilot/report/html.py +385 -0
  197. qpilot/report/inputs.py +217 -0
  198. qpilot/report/markdown.py +145 -0
  199. qpilot/report/methods.py +919 -0
  200. qpilot/report/miqe.py +860 -0
  201. qpilot/report/miqe2_items.yaml +92 -0
  202. qpilot/report/model.py +235 -0
  203. qpilot/report/rdml_meta.py +237 -0
  204. qpilot/report/rdml_out.py +257 -0
  205. qpilot/report/review.py +761 -0
  206. qpilot/report/run_review.py +624 -0
  207. qpilot/report/svg.py +218 -0
  208. qpilot/report/tables.py +1558 -0
  209. qpilot/report/text.py +220 -0
  210. qpilot/schemas.py +318 -0
  211. qpilot/schemas_studio.py +787 -0
  212. qpilot/studio_axes.py +143 -0
  213. qpilot/studio_design.py +1063 -0
  214. qpilot/studio_service.py +3299 -0
  215. qpilot/studio_state.py +1302 -0
  216. qpilot/trace/__init__.py +68 -0
  217. qpilot/trace/context.py +771 -0
  218. qpilot/trace/gene.py +964 -0
  219. qpilot/trace/schema.py +398 -0
  220. qpilot/trace/study.py +321 -0
  221. qpilot/trace/welch.py +678 -0
  222. qpilot/trace/words.py +149 -0
  223. qpilot/vendor/__init__.py +1 -0
  224. qpilot/vendor/rdmlpython/LICENSE +22 -0
  225. qpilot/vendor/rdmlpython/README_VENDOR.md +39 -0
  226. qpilot/vendor/rdmlpython/__init__.py +1 -0
  227. qpilot/vendor/rdmlpython/rdml.py +15562 -0
  228. qpilot/vendor/rdmlpython/schema/LICENSE +22 -0
  229. qpilot/vendor/rdmlpython/schema/RDML_v1_0_REC.xsd +1206 -0
  230. qpilot/vendor/rdmlpython/schema/RDML_v1_1_REC.xsd +1276 -0
  231. qpilot/vendor/rdmlpython/schema/RDML_v1_2_REC.xsd +1308 -0
  232. qpilot/vendor/rdmlpython/schema/RDML_v1_3_REC.xsd +1606 -0
  233. qpilot/vendor/rdmlpython/schema/RDML_v1_4_CR.xsd +1660 -0
  234. qpilot/web/__init__.py +1 -0
  235. qpilot/web/absolute_views.py +505 -0
  236. qpilot/web/axes_views.py +312 -0
  237. qpilot/web/cache.py +277 -0
  238. qpilot/web/compare_views.py +443 -0
  239. qpilot/web/context.py +133 -0
  240. qpilot/web/contract.py +1303 -0
  241. qpilot/web/contract_absolute.py +319 -0
  242. qpilot/web/contract_audit.py +776 -0
  243. qpilot/web/contract_capture.py +84 -0
  244. qpilot/web/contract_claims.py +63 -0
  245. qpilot/web/contract_compare.py +139 -0
  246. qpilot/web/contract_cqonly.py +93 -0
  247. qpilot/web/contract_design.py +259 -0
  248. qpilot/web/contract_evidence.py +173 -0
  249. qpilot/web/contract_files.py +433 -0
  250. qpilot/web/contract_gate.py +303 -0
  251. qpilot/web/contract_history.py +366 -0
  252. qpilot/web/contract_instruments.py +264 -0
  253. qpilot/web/contract_melt.py +163 -0
  254. qpilot/web/contract_norm.py +72 -0
  255. qpilot/web/contract_ops.py +247 -0
  256. qpilot/web/contract_planner.py +370 -0
  257. qpilot/web/contract_report.py +195 -0
  258. qpilot/web/contract_sheets.py +184 -0
  259. qpilot/web/contract_trace.py +308 -0
  260. qpilot/web/contract_trust.py +139 -0
  261. qpilot/web/contract_units.py +134 -0
  262. qpilot/web/cq_table_views.py +235 -0
  263. qpilot/web/curves.py +113 -0
  264. qpilot/web/events.py +301 -0
  265. qpilot/web/folders.py +383 -0
  266. qpilot/web/intake.py +661 -0
  267. qpilot/web/jobs.py +234 -0
  268. qpilot/web/openapi.py +30 -0
  269. qpilot/web/ratelimit.py +642 -0
  270. qpilot/web/references_views.py +165 -0
  271. qpilot/web/results_views.py +1507 -0
  272. qpilot/web/routers/__init__.py +48 -0
  273. qpilot/web/routers/absolute.py +205 -0
  274. qpilot/web/routers/audit.py +1179 -0
  275. qpilot/web/routers/claims.py +113 -0
  276. qpilot/web/routers/cqonly.py +29 -0
  277. qpilot/web/routers/design.py +319 -0
  278. qpilot/web/routers/evidence.py +528 -0
  279. qpilot/web/routers/files.py +1141 -0
  280. qpilot/web/routers/gate.py +1375 -0
  281. qpilot/web/routers/history.py +111 -0
  282. qpilot/web/routers/instruments.py +590 -0
  283. qpilot/web/routers/melt.py +358 -0
  284. qpilot/web/routers/ops.py +1286 -0
  285. qpilot/web/routers/planner.py +775 -0
  286. qpilot/web/routers/references.py +36 -0
  287. qpilot/web/routers/report.py +519 -0
  288. qpilot/web/routers/results.py +426 -0
  289. qpilot/web/routers/sheets.py +254 -0
  290. qpilot/web/routers/studio.py +748 -0
  291. qpilot/web/routers/system.py +150 -0
  292. qpilot/web/routers/trace.py +366 -0
  293. qpilot/web/routers/trust.py +280 -0
  294. qpilot/web/routers/units.py +51 -0
  295. qpilot/web/routers/where.py +652 -0
  296. qpilot/web/routers/workspaces.py +398 -0
  297. qpilot/web/security.py +404 -0
  298. qpilot/web/server.py +280 -0
  299. qpilot/web/static/assets/AskDrawer-CUxSUS-x.js +1 -0
  300. qpilot/web/static/assets/ComparisonsPanel-D3FXhNHz.js +1 -0
  301. qpilot/web/static/assets/FactText-Dz8wh-gO.js +1 -0
  302. qpilot/web/static/assets/MeltPanel-Cw8Nsxcc.js +1 -0
  303. qpilot/web/static/assets/PrismFigure-CkTa0blX.js +1 -0
  304. qpilot/web/static/assets/RepeatPlan-BChHKdfV.js +1 -0
  305. qpilot/web/static/assets/TraceLink-DeQ7800y.js +1 -0
  306. qpilot/web/static/assets/WellCurve-CHQuPKbV.js +1 -0
  307. qpilot/web/static/assets/adapters-PmyB1S-K.js +1 -0
  308. qpilot/web/static/assets/agents-CoQ0p6Pa.js +1 -0
  309. qpilot/web/static/assets/api-B4NH57vK.js +1 -0
  310. qpilot/web/static/assets/api-lNzRMZwq.js +1 -0
  311. qpilot/web/static/assets/arrow-left-J4KquhEj.js +1 -0
  312. qpilot/web/static/assets/arrow-left-right-DskOmiWB.js +1 -0
  313. qpilot/web/static/assets/arrow-up-right-CUA0BG1N.js +1 -0
  314. qpilot/web/static/assets/audit-DoqbPwf9.js +1 -0
  315. qpilot/web/static/assets/charts-C5usZK2E.js +1 -0
  316. qpilot/web/static/assets/chevron-left-dRUodBJI.js +1 -0
  317. qpilot/web/static/assets/circle-minus-DeoM5xIK.js +1 -0
  318. qpilot/web/static/assets/circle-question-mark-VCGmGiNy.js +1 -0
  319. qpilot/web/static/assets/claims-DcoY19HA.js +2 -0
  320. qpilot/web/static/assets/cpu-DlUwpr2b.js +1 -0
  321. qpilot/web/static/assets/curves-B1ifh85o.js +1 -0
  322. qpilot/web/static/assets/curves-XOLvFA_8.js +1 -0
  323. qpilot/web/static/assets/data-5sJA9TGe.js +3 -0
  324. qpilot/web/static/assets/data-CnsZ1jie.css +1 -0
  325. qpilot/web/static/assets/design-96g2-GCi.js +4 -0
  326. qpilot/web/static/assets/download-CbMqoFTx.js +1 -0
  327. qpilot/web/static/assets/eye-BfvztZUo.js +1 -0
  328. qpilot/web/static/assets/file-archive-BHfTFACh.js +1 -0
  329. qpilot/web/static/assets/file-down-C3_7QBi7.js +1 -0
  330. qpilot/web/static/assets/gate-CkJHqoy7.js +2 -0
  331. qpilot/web/static/assets/gene-C_xoqCdK.js +2 -0
  332. qpilot/web/static/assets/git-compare-DbZBcP5d.js +1 -0
  333. qpilot/web/static/assets/history-DpMybOc0.js +1 -0
  334. qpilot/web/static/assets/home-Cj1zA296.js +1 -0
  335. qpilot/web/static/assets/inbox-CYTddybL.js +1 -0
  336. qpilot/web/static/assets/index-9TwPw7mS.css +1 -0
  337. qpilot/web/static/assets/index-BgRULiZl.js +10 -0
  338. qpilot/web/static/assets/info-BswNFHHj.js +1 -0
  339. qpilot/web/static/assets/instruments-jd32H0mH.js +1 -0
  340. qpilot/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
  341. qpilot/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
  342. qpilot/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
  343. qpilot/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
  344. qpilot/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
  345. qpilot/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  346. qpilot/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
  347. qpilot/web/static/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
  348. qpilot/web/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  349. qpilot/web/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  350. qpilot/web/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  351. qpilot/web/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  352. qpilot/web/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  353. qpilot/web/static/assets/layers-D2r9RZW0.js +1 -0
  354. qpilot/web/static/assets/map-pin-BiPNAHHZ.js +1 -0
  355. qpilot/web/static/assets/planner-DxsNPgnB.js +1 -0
  356. qpilot/web/static/assets/plate-Dji8Bed1.js +1 -0
  357. qpilot/web/static/assets/plus-bJK5ipt2.js +1 -0
  358. qpilot/web/static/assets/point-lCZbdOEQ.js +1 -0
  359. qpilot/web/static/assets/prism-Beki88_Y.js +1 -0
  360. qpilot/web/static/assets/proposal-B8JUMKOg.js +1 -0
  361. qpilot/web/static/assets/react-D_CZztLv.js +9 -0
  362. qpilot/web/static/assets/record-0B21W6z5.js +1 -0
  363. qpilot/web/static/assets/report-DUyk8Imm.js +1 -0
  364. qpilot/web/static/assets/results-C-a1kb0M.js +2 -0
  365. qpilot/web/static/assets/rotate-ccw-BAyh8Avz.js +1 -0
  366. qpilot/web/static/assets/sections-CKGrfpub.js +2 -0
  367. qpilot/web/static/assets/settings-2NKo8-rw.js +1 -0
  368. qpilot/web/static/assets/sheetcheck-u29C7m87.js +1 -0
  369. qpilot/web/static/assets/shield-alert-CM5JXUmj.js +1 -0
  370. qpilot/web/static/assets/shield-check-CR27b7sP.js +1 -0
  371. qpilot/web/static/assets/sparkles-BSio9YeX.js +1 -0
  372. qpilot/web/static/assets/src-DmrZXBmt.js +1 -0
  373. qpilot/web/static/assets/table-2-7dzo7oVA.js +1 -0
  374. qpilot/web/static/assets/trace-OgKBXZhz.js +1 -0
  375. qpilot/web/static/assets/ui-B9WviEwM.js +44 -0
  376. qpilot/web/static/assets/ui-Dlfa4Gi0.css +1 -0
  377. qpilot/web/static/assets/undo-2-CNANXPsX.js +1 -0
  378. qpilot/web/static/assets/unlink-_3yYur6c.js +1 -0
  379. qpilot/web/static/assets/upload-CEiVqAZ1.js +1 -0
  380. qpilot/web/static/assets/useMutation-C3bsgiJY.js +1 -0
  381. qpilot/web/static/assets/useOpenWorkspace-CiHqAp_k.js +1 -0
  382. qpilot/web/static/assets/useQueries-Bpk0ankz.js +1 -0
  383. qpilot/web/static/assets/user-fGY6PXpe.js +1 -0
  384. qpilot/web/static/assets/words-B1YuDegT.js +1 -0
  385. qpilot/web/static/assets/wrench-gyiojqmh.js +1 -0
  386. qpilot/web/static/favicon.svg +1 -0
  387. qpilot/web/static/index.html +25 -0
  388. qpilot/web/studio_views.py +668 -0
  389. qpilot/web/units_views.py +414 -0
  390. qpilot/web/views.py +666 -0
  391. qpilot/web/warm.py +562 -0
  392. qpilot-0.2.0.dist-info/METADATA +511 -0
  393. qpilot-0.2.0.dist-info/RECORD +425 -0
  394. qpilot-0.2.0.dist-info/WHEEL +4 -0
  395. qpilot-0.2.0.dist-info/entry_points.txt +3 -0
  396. qpilot-0.2.0.dist-info/licenses/LICENSE +202 -0
  397. qpilot-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +492 -0
  398. qsim/__init__.py +3 -0
  399. qsim/app.py +883 -0
  400. qsim/app_lab.py +512 -0
  401. qsim/ask.py +650 -0
  402. qsim/attribution.py +160 -0
  403. qsim/benchmark.py +182 -0
  404. qsim/cli.py +258 -0
  405. qsim/compare.py +83 -0
  406. qsim/data.py +388 -0
  407. qsim/decisions.py +293 -0
  408. qsim/demo.py +271 -0
  409. qsim/designs.py +100 -0
  410. qsim/engine.py +493 -0
  411. qsim/explore.py +178 -0
  412. qsim/external.py +623 -0
  413. qsim/inference.py +146 -0
  414. qsim/influence.py +217 -0
  415. qsim/issues.py +248 -0
  416. qsim/known_answers.py +1141 -0
  417. qsim/moves.py +473 -0
  418. qsim/nodiff.py +264 -0
  419. qsim/plates.py +277 -0
  420. qsim/report.py +629 -0
  421. qsim/study_space.py +134 -0
  422. qsim/troubleshoot.py +360 -0
  423. qsim/truth.py +227 -0
  424. qsim/validate.py +400 -0
  425. qsim/verdict.py +147 -0
@@ -0,0 +1,1286 @@
1
+ """Operations: agents, Ask, the model test, what the model saw, and a fresh demo.
2
+
3
+ * ``GET /api/agents``: how an agent connects (``claude mcp add qpilot -- qpilot serve``), the tools
4
+ ``qpilot serve`` gives it (each marked read / open / propose / request), the requests waiting
5
+ for a person in every study, and what agents did recently, newest first.
6
+ * ``POST /api/studies/{id}/ask``: a question, answered as a job by qPilot's own tools
7
+ (:func:`qpilot.ai.ask.ask`, the MCP tool code), or, for verdict and what-if questions about an
8
+ audited study, by the audit's Ask engine. The answer says which tool answered, who wrote the
9
+ words and every fact it cites. Ask never writes (docs/app.md §1.1: every write from the page
10
+ is a person's): asked to change a choice, it names the Decision Card to decide it on.
11
+ * ``POST /api/model/test``: the harness scorecard of the page's model on one study (a job), saved
12
+ with the study's scorecards; ``GET /api/model/seen``: the last payload a model was sent.
13
+ * ``POST /api/demo/fresh``: moves the demo's earlier work aside (kept, never deleted).
14
+
15
+ A study of a Studio session whose design and curves are not both confirmed is blind: Ask and the
16
+ model test answer 403 ``blind``, and its agent entries are counted but not listed.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import inspect
23
+ import json
24
+ import math
25
+ import re
26
+ import threading
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Literal
30
+
31
+ from fastapi import APIRouter, Query
32
+
33
+ from qpilot.core.decisions import DecisionError
34
+ from qpilot.core.store import StudyNotFound, StudyStore
35
+ from qpilot.web import views
36
+ from qpilot.web.context import KEY, AppContext, Ctx, api_error, check_key, is_session, is_study
37
+ from qpilot.web.contract import JobStarted, JobView, ModelSeen
38
+ from qpilot.web.contract_ops import (
39
+ AgentActivity,
40
+ AgentRequest,
41
+ AgentsView,
42
+ AgentTool,
43
+ AskAnswer,
44
+ AskBody,
45
+ CitedFact,
46
+ FreshDemo,
47
+ ModelTestBody,
48
+ ModelTestResult,
49
+ ScorecardRow,
50
+ ToolAccess,
51
+ )
52
+ from qpilot.web.jobs import JobError, Progress
53
+
54
+ router = APIRouter(tags=["ops"])
55
+
56
+ #: The model of each job's result, so the page's types carry them (``routers.job_results``).
57
+ JOB_RESULTS = {"ask": AskAnswer, "model.test": ModelTestResult}
58
+
59
+ CONNECT = "claude mcp add qpilot -- qpilot serve"
60
+ CONNECT_NOTE = (
61
+ "Run this once in a terminal. Claude Code then starts `qpilot serve` itself; the agent reads "
62
+ "and writes this same study store, and what it does appears here within about a second."
63
+ )
64
+ ACTIVITY_LIMIT = 60
65
+ # How long the work usually takes on the demo with no model (templates answer), measured on
66
+ # 20 Oct under load. With a model the time is the model's: no typical time is claimed.
67
+ ASK_TYPICAL_S = 0.5 # 0.03-0.4 s
68
+ AUDIT_ASK_TYPICAL_S = 1.0
69
+ MODEL_TEST_TYPICAL_S = 3.5 # nine questions
70
+
71
+ #: What each tool of ``qpilot serve`` can change (``tests/web/test_agents.py`` checks that every
72
+ #: served tool is here). ``sim_*`` tools all read the study's verified audit and change nothing.
73
+ TOOL_ACCESS: dict[str, tuple[ToolAccess, str]] = {
74
+ "qpcr_study": (
75
+ "open",
76
+ "Analyses a file into a study with the default design, or reopens one; an instrument "
77
+ "export only with the design a person confirmed. It decides no Decision Card, and a "
78
+ "Studio session's design is used only once a person confirmed it.",
79
+ ),
80
+ "qpcr_robustness": ("read", "Nothing."),
81
+ "qpcr_decide": (
82
+ "request",
83
+ "Asks to decide a Decision Card, recorded as the agent's. A choice that can change a "
84
+ "conclusion always waits for a person; any other choice is applied only when the "
85
+ "study lets agents decide and tell, and otherwise waits too. On a card from the "
86
+ "controls (Run Gate) the request is filed as well exclusions in the Studio inbox, for a "
87
+ "person to approve or turn down.",
88
+ ),
89
+ "qpcr_gate": (
90
+ "read",
91
+ "Nothing: the controls are checked and the answer is not stored.",
92
+ ),
93
+ "qpcr_plan": ("read", "Nothing."),
94
+ "qpcr_propose": (
95
+ "propose",
96
+ "Adds a proposal to a Studio inbox, with the engine's own check. Only a person accepts, "
97
+ "edits or rejects it.",
98
+ ),
99
+ "qpcr_proposals": ("read", "Nothing."),
100
+ "qpcr_inspect": (
101
+ "open",
102
+ "Opens a Studio session for a file or an instrument export (converted once into the "
103
+ "study store, the import kept with the session): nothing is analysed or proposed, and a "
104
+ "session a person opened keeps its file and sample sheet.",
105
+ ),
106
+ "qpcr_check_design": ("read", "Nothing: the check is not saved."),
107
+ "qpcr_curve_calls": ("read", "Nothing."),
108
+ "qpcr_prism_preview": ("read", "Nothing: it shows what Send to Prism would write."),
109
+ "qpcr_prism_diff": ("read", "Nothing: the differences are not stored."),
110
+ "qpcr_evidence": ("read", "Nothing: the Evidence Ledger is read, or rebuilt without saving."),
111
+ "qpcr_run_review": (
112
+ "read",
113
+ "Nothing: no tool signs off a run review; a person does, in the Run Review.",
114
+ ),
115
+ "qpcr_run_context": (
116
+ "read",
117
+ "Nothing: it reads a run file inside the shared folders; serial numbers never leave it.",
118
+ ),
119
+ "qpcr_lookback": ("read", "Nothing: nothing is written to any logbook."),
120
+ "qpcr_instrument_history": ("read", "Nothing: what a person typed is counted, not shown."),
121
+ "qpcr_instrument_status": ("read", "Nothing."),
122
+ "qpcr_incident_log": (
123
+ "propose",
124
+ "Proposes an incident in an instrument's logbook (codes only, no free text). It matches "
125
+ "nothing until a person confirms it in Instruments, and it never touches a study.",
126
+ ),
127
+ "qpcr_trace": (
128
+ "read",
129
+ "Nothing: it opens no Decision Card. Setting a place aside is a person's decision.",
130
+ ),
131
+ "qpcr_check_sheet": (
132
+ "read",
133
+ "Nothing: the sheet is read, never edited, and its column mapping stays a guess until "
134
+ "a person confirms it.",
135
+ ),
136
+ "qpcr_comparisons": ("read", "Nothing: every comparison of a study with several, as analysed."),
137
+ "qpcr_units": (
138
+ "read",
139
+ "Nothing: each unit's value of a gene and the wells behind it, as the engine computed it.",
140
+ ),
141
+ "qpcr_melt": ("read", "Nothing: the melt curves are read from the file and judged."),
142
+ "qpcr_quantities": (
143
+ "read",
144
+ "Nothing: each sample's quantity is read from the standard curves, not stored.",
145
+ ),
146
+ "qpcr_repeat_plan": (
147
+ "read",
148
+ "Nothing: what to repeat and why, and the repeat plate. Re-running is a person's call.",
149
+ ),
150
+ "qpcr_export": (
151
+ "export",
152
+ "Writes a new file from the engine's own output (a workbook, tables, figures or a journal "
153
+ "supplement) into the study's export folder, listed in Report. With a folder named, "
154
+ "a copy goes there, only inside the shared folders and never over a file. It changes no "
155
+ "result and decides nothing.",
156
+ ),
157
+ "qpcr_request_set_aside": (
158
+ "request",
159
+ "Asks a person to set wells aside at a place Trace pointed at: the agent's well "
160
+ "exclusions wait in the Studio inbox, and Trace shows them at the place. Nothing is set "
161
+ "aside until a person accepts.",
162
+ ),
163
+ }
164
+ SIM_EFFECT = "Nothing: it reads the study's verified robustness audit."
165
+
166
+ #: What an agent can and cannot do (enforced in ``qpilot/mcp_server.py`` and the core).
167
+ RULES = (
168
+ "An agent is always recorded as an agent; it can never act as a person.",
169
+ "A choice that can change a conclusion always waits for a person: Approve or Turn down here "
170
+ "or on the Decision Cards.",
171
+ "Agents propose; only a person accepts, edits or rejects a proposal or confirms a stage.",
172
+ "Setting wells aside is a person's decision: an agent can only ask, and the request waits.",
173
+ "Results stay hidden from agents until a person confirms the design and the curves.",
174
+ "Names from files reach an agent as quoted data, never as instructions.",
175
+ "Agents open only files inside the folders allowed by QPILOT_DATA_ROOTS, and write only "
176
+ "new files: into the export folder, or a shared folder, never over a file.",
177
+ )
178
+
179
+ #: Questions the audit answers best: verdicts, what-if, what to check first.
180
+ AUDIT_WORDS = re.compile(
181
+ r"\b(verdicts?|solid|fragile|caveats?|depends?\s+on|what[\s-]+if|would\s+(it|the|this|that|"
182
+ r"my|our)|suppose|hypothetically|audit|check\s+(the\s+)?data|look\s+at\s+first|issues?|"
183
+ r"lean[s]?\s+on|survives?)\b",
184
+ re.I,
185
+ )
186
+ #: Questions only the audit answers: what-if, what to check first, verdicts and the audit itself
187
+ #: (the study's own tools would answer them with something else).
188
+ AUDIT_ONLY_WORDS = re.compile(
189
+ r"\b(what[\s-]+if|suppose|hypothetically|would\s+(it|the|this|that|my|our)|instead|"
190
+ r"look\s+at\s+first|check\s+(the\s+)?data|issues?|verdicts?|audit(ed|s)?)\b",
191
+ re.I,
192
+ )
193
+ NEEDS_AUDIT = (
194
+ "This is a question for the robustness audit, which re-runs the study under every "
195
+ "combination of the analysis choices. Run it in Audit, then ask again."
196
+ )
197
+ NO_AUDIT_ENGINE = "The audit's answers are not available in this build of qPilot."
198
+
199
+ #: Plain words for the facts qPilot's Ask adds to an answer (``qpilot/ai/ask.py``).
200
+ ANSWER_FACTS = {
201
+ "f_ans_fc": "fold change under the recommended choices",
202
+ "f_ans_ci_lo": "lower end of the 95 % confidence interval",
203
+ "f_ans_ci_hi": "upper end of the 95 % confidence interval",
204
+ "f_ans_p": "p-value",
205
+ "f_ans_holds": "share of the analyses qPilot checks with the same call",
206
+ "f_ans_n_cells": "analyses qPilot checks",
207
+ "f_ans_level": "confidence level",
208
+ "f_ans_min_flip": "choices that flip the call together",
209
+ "f_ans_n_plates": "plates",
210
+ "f_ans_n_samples": "samples",
211
+ "f_ans_n_reactions": "reactions",
212
+ "f_ans_n_genes": "genes in this study",
213
+ "f_ans_n_needed": "samples needed per group",
214
+ "f_ans_n_for_call": "samples per group for qPilot's rule to call it",
215
+ "f_ans_mde_fold": "smallest change the planned design detects",
216
+ "f_ans_needed_called": "how often qPilot's rule calls it at the needed size",
217
+ "f_ans_planned_called": "how often qPilot's rule calls it at the planned size",
218
+ "f_ans_n_up": "genes higher in the group",
219
+ "f_ans_n_down": "genes lower in the group",
220
+ "f_ans_n_no_call": "genes with no call",
221
+ "f_ans_m_cutoff": "geNorm's stability cut-off (M)",
222
+ }
223
+ #: Facts numbered by rank: their meaning by prefix.
224
+ ANSWER_FACT_PREFIXES = {
225
+ "f_ans_m_": "geNorm stability M among all candidates",
226
+ "f_ans_flips_": "genes whose call this choice can change",
227
+ }
228
+ STUDY_TOOL_WORDS = {
229
+ "qpcr_study": "the study summary",
230
+ "qpcr_robustness": "the robustness of one gene across every reasonable analysis",
231
+ "qpcr_decide": "a request to change a choice on a Decision Card",
232
+ "qpcr_plan": "the sample-size planner",
233
+ "explain_number": "where a number comes from",
234
+ "none": "no tool (the question is not about this study)",
235
+ }
236
+ AUDIT_TOOL_WORDS = {
237
+ "gene": "the audit's gene look-up",
238
+ "whatif": "a live what-if run of the audit's engine",
239
+ "decision": "the audit's choice summary",
240
+ "issues": "the audit's issue queue",
241
+ "overview": "the audit's study overview",
242
+ }
243
+ ROUTED_WORDS = {
244
+ "model": "the model",
245
+ "keywords": "keyword matching (no model)",
246
+ "rules": "fixed reading rules",
247
+ }
248
+
249
+
250
+ # ---- where a study lives, and whether it is blind ----------------------------------------------
251
+
252
+
253
+ @dataclass(frozen=True)
254
+ class _Place:
255
+ """The workspace a study belongs to, as the page routes to it."""
256
+
257
+ key: str
258
+ kind: Literal["session", "study"]
259
+ visible: bool
260
+
261
+
262
+ class _Places:
263
+ """Studies → their workspace, read once per request (sessions' ``studio.json``)."""
264
+
265
+ def __init__(self, ctx: AppContext) -> None:
266
+ self.ctx = ctx
267
+ self.session_of: dict[str, str] = {}
268
+ sessions = ctx.store.root / "sessions"
269
+ if sessions.is_dir():
270
+ for folder in sorted(sessions.iterdir()):
271
+ if folder.is_dir() and len(folder.name) == 64:
272
+ meta = views.session_meta(ctx.store, folder.name[:12])
273
+ for study_id in meta.get("studies") or {}:
274
+ self.session_of[study_id] = folder.name[:12]
275
+ self._visible: dict[str, bool] = {}
276
+ self._titles: dict[str, str] = {}
277
+
278
+ def visible(self, sid: str) -> bool:
279
+ """Whether a session's design and curves are both confirmed (fails closed)."""
280
+ if sid not in self._visible:
281
+ try:
282
+ self._visible[sid] = bool(self.ctx.studio.results_visible(sid))
283
+ except Exception: # an unreadable session is treated as blind, never as open
284
+ self._visible[sid] = False
285
+ return self._visible[sid]
286
+
287
+ def title(self, place: _Place) -> str:
288
+ """The workspace's title as the workspace list shows it (its file's name, or the demo's)."""
289
+ if place.key not in self._titles:
290
+ store = self.ctx.store
291
+ try:
292
+ if place.kind == "session":
293
+ record = views.session_source_record(store, place.key)
294
+ sha = store.find_session(place.key).name
295
+ else:
296
+ path = store.workspace(place.key) / "source.json"
297
+ record = json.loads(path.read_text(encoding="utf-8")) if path.exists() else {}
298
+ sha = store.get(place.key).input_sha256
299
+ name = Path(record["path"]).name if record.get("path") else None
300
+ self._titles[place.key] = views._title(name, sha)
301
+ except (StudyNotFound, OSError, ValueError, KeyError):
302
+ self._titles[place.key] = f"Study {place.key}"
303
+ return self._titles[place.key]
304
+
305
+ def of_study(self, study_id: str) -> _Place:
306
+ """The workspace of an analysed study."""
307
+ sid = self.session_of.get(study_id)
308
+ if sid is not None:
309
+ return _Place(sid, "session", self.visible(sid))
310
+ return _Place(study_id, "study", True)
311
+
312
+ def of_session(self, sid: str) -> _Place:
313
+ """A Studio session as a workspace."""
314
+ return _Place(sid, "session", self.visible(sid))
315
+
316
+
317
+ BLIND_MESSAGE = (
318
+ "Results stay hidden until the design and the curves are confirmed in this study's Studio "
319
+ "session. Confirm them in Design and Curves first."
320
+ )
321
+
322
+
323
+ def resolve_study(ctx: AppContext, key: str) -> tuple[str, _Place, _Places]:
324
+ """The analysed study a key names (a study id, or a session's current study), not blind.
325
+
326
+ Raises:
327
+ HTTPException: 404 ``not_found``, 403 ``blind``, 409 ``no_results``.
328
+ """
329
+ check_key(key)
330
+ places = _Places(ctx)
331
+ if is_study(ctx.store, key):
332
+ place = places.of_study(key)
333
+ study_id = key
334
+ elif is_session(ctx.store, key):
335
+ place = places.of_session(key)
336
+ study_id = views.session_meta(ctx.store, key).get("current_study")
337
+ else:
338
+ raise api_error(404, "not_found", "There is no study or session with this id.")
339
+ if not place.visible:
340
+ raise api_error(403, "blind", BLIND_MESSAGE, session=place.key)
341
+ if not study_id or not is_study(ctx.store, study_id):
342
+ raise api_error(
343
+ 409,
344
+ "no_results",
345
+ "This session has no analysed study yet: confirm the curves to analyse it.",
346
+ )
347
+ return study_id, place, places
348
+
349
+
350
+ # ---- agents -----------------------------------------------------------------------------------
351
+
352
+ _catalogue: tuple[list[AgentTool], bool] | None = None
353
+ _catalogue_lock = threading.Lock()
354
+
355
+
356
+ def _describe(name: str, description: str, read_only: bool | None) -> AgentTool:
357
+ text = inspect.cleandoc(description or "")
358
+ summary = text.split("\n\n", 1)[0].replace("\n", " ").strip()
359
+ simulator = name.startswith("sim_")
360
+ if name in TOOL_ACCESS:
361
+ access, effect = TOOL_ACCESS[name]
362
+ elif simulator or read_only:
363
+ access, effect = "read", SIM_EFFECT if simulator else "Nothing (a read-only tool)."
364
+ else: # a tool this page does not know yet: assume it may ask for changes
365
+ access, effect = "request", "Not described here yet; read the tool's own description."
366
+ return AgentTool(
367
+ name=name,
368
+ summary=summary,
369
+ description=text,
370
+ access=access,
371
+ effect=effect,
372
+ simulator=simulator,
373
+ )
374
+
375
+
376
+ def tool_catalogue(store: StudyStore) -> tuple[list[AgentTool], bool]:
377
+ """The tools ``qpilot serve`` registers on this install (built once: ~3 s of imports)."""
378
+ global _catalogue
379
+ with _catalogue_lock:
380
+ if _catalogue is None:
381
+ from qpilot.mcp_server import TOOL_NAMES, create_server, served_tool_names
382
+
383
+ names = served_tool_names()
384
+ server = create_server(store, simulator=names != TOOL_NAMES)
385
+ listed = asyncio.run(server.list_tools())
386
+ tools = []
387
+ for tool in listed:
388
+ hints = getattr(tool, "annotations", None)
389
+ read_only = getattr(hints, "readOnlyHint", None) if hints else None
390
+ tools.append(_describe(tool.name, tool.description or "", read_only))
391
+ _catalogue = (tools, names != TOOL_NAMES)
392
+ return list(_catalogue[0]), _catalogue[1]
393
+
394
+
395
+ def _connect(store: StudyStore) -> tuple[str, str | None]:
396
+ """The connect command; it names this store unless it is the built-in default.
397
+
398
+ Claude Code starts ``qpilot serve`` with its own environment, which need not carry this
399
+ shell's ``QPILOT_STUDIES_DIR``: any other store is named in the command.
400
+ """
401
+ builtin = Path.home() / ".cache" / "qpilot" / "studies"
402
+ if store.root.resolve() == builtin.resolve():
403
+ return CONNECT, None
404
+ root = str(store.root)
405
+ quoted = f'"{root}"' if re.search(r"[\s\"'$`\\]", root) else root
406
+ return (
407
+ f"claude mcp add qpilot -e QPILOT_STUDIES_DIR={quoted} -- qpilot serve",
408
+ "This page uses a study store other than the default one (~/.cache/qpilot/studies), "
409
+ "so the command names it for the agent.",
410
+ )
411
+
412
+
413
+ def _card_words(cards: dict[str, Any], card_id: str, option: str) -> tuple[str, str]:
414
+ from qpilot.ai.explain import KIND_TITLE
415
+
416
+ card = cards.get(card_id)
417
+ if card is None:
418
+ return card_id, option
419
+ label = next((o.label for o in card.options if o.id == option), option)
420
+ return KIND_TITLE.get(card.kind, card.kind), label
421
+
422
+
423
+ DECISION_WORDS = {
424
+ "applied": "Set “{title}” to “{label}”",
425
+ "auto": "Set “{title}” to “{label}”",
426
+ "pending_human": "Asked a person to set “{title}” to “{label}”",
427
+ "rejected": "Request turned down: “{title}”, “{label}”",
428
+ "stale": "Not applied (the card changed since the agent read it): “{title}”, “{label}”",
429
+ }
430
+
431
+
432
+ def _study_entries(
433
+ places: _Places, study_id: str
434
+ ) -> tuple[_Place | None, list[AgentRequest], list[AgentActivity]]:
435
+ """One study's pending agent requests and agent entries (decisions and refused attempts).
436
+
437
+ The study's workspace (and whether it is blind) is looked up only when there is something
438
+ to show, so a store without agent activity costs a few file reads.
439
+ """
440
+ from qpilot.core.decisions import needs_human
441
+
442
+ store = places.ctx.store
443
+ head, card_list, pending = store.snapshot(study_id)
444
+ log = store.entries(study_id)
445
+ rows = [e.model_dump() for e in log if e.actor == "agent"]
446
+ rows += [a for a in store.attempts(study_id) if a.get("actor") == "agent"]
447
+ if not pending and not rows:
448
+ return None, [], []
449
+ place = places.of_study(study_id)
450
+ title = places.title(place)
451
+ cards = {c.id: c for c in card_list}
452
+ requests = []
453
+ for entry in pending:
454
+ card_title, label = _card_words(cards, entry.card_id, entry.option)
455
+ card = cards.get(entry.card_id)
456
+ requests.append(
457
+ AgentRequest(
458
+ key=place.key,
459
+ kind=place.kind,
460
+ study_id=study_id,
461
+ title=title,
462
+ card_id=entry.card_id,
463
+ card_title=card_title,
464
+ option=entry.option,
465
+ option_label=label,
466
+ reason=entry.reason,
467
+ actor=entry.actor,
468
+ at=entry.at,
469
+ seq=entry.seq,
470
+ hash=entry.hash,
471
+ can_change_a_conclusion=bool(card is not None and needs_human(card)),
472
+ expected_head=head,
473
+ )
474
+ )
475
+ waiting = {e.hash for e in pending}
476
+ activity = []
477
+ for row in rows:
478
+ settled = _settled(log, row, waiting) if row["status"] == "pending_human" else {}
479
+ card_title, label = _card_words(cards, row["card_id"], row["option"])
480
+ status = str(row["status"])
481
+ words = DECISION_WORDS.get(status, "{title}: {label}")
482
+ activity.append(
483
+ AgentActivity(
484
+ kind="decision",
485
+ key=place.key,
486
+ workspace_kind=place.kind,
487
+ title=title,
488
+ at=str(row["at"]),
489
+ summary=words.format(title=card_title, label=label),
490
+ status=status,
491
+ reason=row.get("reason"),
492
+ card_id=row["card_id"],
493
+ option=row["option"],
494
+ seq=row.get("seq"),
495
+ hash=row.get("hash") or None,
496
+ **settled,
497
+ )
498
+ )
499
+ return place, requests, activity
500
+
501
+
502
+ def _settled(log: list[Any], request: dict[str, Any], waiting: set[str]) -> dict[str, Any]:
503
+ """What became of an agent's request: still waiting, or the later entry that settled it.
504
+
505
+ The same replay as the store's pending list (``core.store.pending_after``): an applied
506
+ decision on the card settles every request on it; a person's rejection settles the
507
+ requests for the option it names.
508
+ """
509
+ if request.get("hash") in waiting:
510
+ return {"request_state": "waiting"}
511
+ later = [e for e in log if e.seq > request["seq"] and e.card_id == request["card_id"]]
512
+ for entry in later:
513
+ if entry.status == "applied":
514
+ state = "approved" if entry.option == request["option"] else "decided_otherwise"
515
+ elif entry.status == "rejected" and entry.actor == "human":
516
+ if entry.option != request["option"]:
517
+ continue
518
+ state = "turned_down"
519
+ else:
520
+ continue
521
+ return {"request_state": state, "settled_by": entry.actor, "settled_at": entry.at}
522
+ return {"request_state": "closed"}
523
+
524
+
525
+ PROPOSAL_WORDS = {
526
+ "proposed": "Proposed",
527
+ "accepted": "Proposed (a person accepted it)",
528
+ "edited": "Proposed (a person edited and accepted it)",
529
+ "rejected": "Proposed (a person rejected it)",
530
+ "superseded": "Proposed (replaced by a later proposal)",
531
+ }
532
+
533
+
534
+ EXCLUDE_WORDS = {
535
+ "proposed": "",
536
+ "accepted": " (a person accepted it)",
537
+ "edited": " (a person changed it and accepted it)",
538
+ "rejected": " (a person turned it down)",
539
+ "superseded": " (replaced by a later request)",
540
+ }
541
+
542
+
543
+ def _proposal_summary(p: Any) -> str:
544
+ """One agent proposal in words; a request about wells says what it asks and where."""
545
+ if p.kind == "curve.exclude_well":
546
+ consequence = p.consequence or {}
547
+ source = (
548
+ "at a place in Trace"
549
+ if "trace" in consequence
550
+ else "on a control check (Controls)"
551
+ if "gate" in consequence
552
+ else None
553
+ )
554
+ if source is not None:
555
+ leave = p.effective_payload().get("excluded") is True
556
+ verb, where = ("leave", "out of") if leave else ("put", "back into")
557
+ end = EXCLUDE_WORDS.get(p.status, "")
558
+ return f"Asked to {verb} “{p.subject}” {where} the analysis, {source}{end}"
559
+ return f"{PROPOSAL_WORDS.get(p.status, 'Proposed')}: {p.kind} for “{p.subject}”"
560
+
561
+
562
+ def _session_proposals(places: _Places, sid: str) -> list[AgentActivity]:
563
+ """Proposals an agent made in one session's inbox (results-stage ones only once visible)."""
564
+ from qpilot.web.studio_views import blind_kind
565
+
566
+ made = [p for p in places.ctx.studio.inbox(sid) if p.made_by.kind == "agent"]
567
+ if any(not blind_kind(p) for p in made) and not places.visible(sid):
568
+ made = [p for p in made if blind_kind(p)]
569
+ if not made:
570
+ return []
571
+ place = _Place(sid, "session", True)
572
+ title = places.title(place)
573
+ return [
574
+ AgentActivity(
575
+ kind="proposal",
576
+ key=sid,
577
+ workspace_kind="session",
578
+ title=title,
579
+ at=p.created_at,
580
+ summary=_proposal_summary(p),
581
+ agent=p.made_by.agent,
582
+ status=p.status,
583
+ reason=p.reason or (p.reasons[0] if p.status == "proposed" and p.reasons else None),
584
+ proposal_id=p.id,
585
+ proposal_kind=p.kind,
586
+ check=p.check.status,
587
+ )
588
+ for p in made
589
+ ]
590
+
591
+
592
+ @router.get("/agents", response_model=AgentsView)
593
+ def agents(ctx: Ctx) -> AgentsView:
594
+ """How an agent connects, its tools, requests waiting for a person, recent agent activity."""
595
+ tools, simulator = tool_catalogue(ctx.store)
596
+ connect, store_note = _connect(ctx.store)
597
+ places = _Places(ctx)
598
+ pending: list[AgentRequest] = []
599
+ activity: list[AgentActivity] = []
600
+ hidden = 0
601
+ for study_id in _study_ids(ctx.store):
602
+ try:
603
+ place, requests, entries = _study_entries(places, study_id)
604
+ except (StudyNotFound, OSError, ValueError):
605
+ continue
606
+ if place is not None and not place.visible:
607
+ hidden += len(requests) + len(entries)
608
+ continue
609
+ pending += requests
610
+ activity += entries
611
+ for sid in sorted(_session_ids(ctx.store)):
612
+ try:
613
+ activity += _session_proposals(places, sid)
614
+ except (StudyNotFound, OSError, ValueError):
615
+ continue
616
+ pending.sort(key=lambda r: (r.at, r.seq))
617
+ activity.sort(key=lambda a: a.at, reverse=True)
618
+ return AgentsView(
619
+ connect=connect,
620
+ connect_note=CONNECT_NOTE,
621
+ store_note=store_note,
622
+ tools=tools,
623
+ simulator=simulator,
624
+ rules=list(RULES),
625
+ pending=pending,
626
+ activity=activity[:ACTIVITY_LIMIT],
627
+ activity_total=len(activity),
628
+ hidden_blind=hidden,
629
+ )
630
+
631
+
632
+ def _study_ids(store: StudyStore) -> list[str]:
633
+ """Study ids of every readable workspace; a damaged or half-written one is skipped.
634
+
635
+ The store is shared with agents, scripts and other qPilot processes (and may sit in a synced
636
+ folder), so one unreadable ``results.json`` must not hide every other study.
637
+ """
638
+ out = []
639
+ for folder in sorted(store.root.iterdir()):
640
+ path = folder / "results.json"
641
+ if not folder.is_dir() or not path.is_file(): # never open a pipe or a device
642
+ continue
643
+ try:
644
+ study_id = json.loads(path.read_text(encoding="utf-8"))["study_id"]
645
+ except (OSError, ValueError, KeyError, TypeError):
646
+ continue
647
+ if isinstance(study_id, str) and KEY.match(study_id):
648
+ out.append(study_id)
649
+ return out
650
+
651
+
652
+ def _session_ids(store: StudyStore) -> set[str]:
653
+ sessions = store.root / "sessions"
654
+ if not sessions.is_dir():
655
+ return set()
656
+ return {d.name[:12] for d in sessions.iterdir() if d.is_dir() and len(d.name) == 64}
657
+
658
+
659
+ # ---- Ask -------------------------------------------------------------------------------------
660
+
661
+
662
+ def _audit_engine() -> Any:
663
+ """The audit routes' cached-screen accessor (``audit_screen``), or None in a build without."""
664
+ try:
665
+ from qpilot.web.routers.audit import audit_screen
666
+ except ImportError:
667
+ return None
668
+ return audit_screen
669
+
670
+
671
+ Engine = Literal["study", "audit", "none"]
672
+ Autonomy = Literal["suggest", "decide_and_tell"]
673
+
674
+
675
+ def _pick_engine(question: str, source: str, audit: bool, engine: bool) -> Engine:
676
+ """Which engine answers: the audit for verdict and what-if questions, else the study's.
677
+
678
+ ``none`` when only the audit could answer and it has not been run (or this build lacks it).
679
+ A request to change a choice always goes to the study's tools (and so to the human gate).
680
+ """
681
+ from qpilot.ai.ask import DECIDE_WORDS, NOT_A_REQUEST
682
+
683
+ if source == "study":
684
+ return "study"
685
+ if source == "audit":
686
+ if not audit:
687
+ raise api_error(409, "no_audit", NEEDS_AUDIT)
688
+ if not engine:
689
+ raise api_error(409, "no_audit", NO_AUDIT_ENGINE)
690
+ return "audit"
691
+ if DECIDE_WORDS.search(question) and not NOT_A_REQUEST.search(question):
692
+ return "study"
693
+ if audit and engine and AUDIT_WORDS.search(question):
694
+ return "audit"
695
+ return "none" if AUDIT_ONLY_WORDS.search(question) else "study"
696
+
697
+
698
+ def _no_engine(question: str, audit: bool, autonomy: Autonomy, head: str) -> AskAnswer:
699
+ """The answer to a question only the audit answers, when there is no audit to ask."""
700
+ return AskAnswer(
701
+ question=question,
702
+ answer=NO_AUDIT_ENGINE if audit else NEEDS_AUDIT,
703
+ engine="none",
704
+ tool="none",
705
+ tool_words="no tool: only the robustness audit answers this",
706
+ routed_by="rules",
707
+ routed_words=ROUTED_WORDS["rules"],
708
+ written_by="template",
709
+ writer_words="qPilot's fixed words",
710
+ provider="none",
711
+ facts=[],
712
+ validated=True,
713
+ autonomy=autonomy,
714
+ head_before=head,
715
+ head_after=head,
716
+ )
717
+
718
+
719
+ def _autonomy(ctx: AppContext, study_id: str) -> Literal["suggest", "decide_and_tell"]:
720
+ """What the study lets agents do (``StudyStore.autonomy``; Ask itself never decides)."""
721
+ try:
722
+ level = ctx.store.autonomy(study_id)
723
+ except (StudyNotFound, OSError, ValueError):
724
+ level = "suggest"
725
+ return "decide_and_tell" if level == "decide_and_tell" else "suggest"
726
+
727
+
728
+ class _AskStore(StudyStore):
729
+ """The store as Ask sees it: it reads; it never decides, requests or turns down.
730
+
731
+ A change Ask is asked for reaches ``apply_decision`` like an agent's; here it is refused
732
+ before anything is written, and the answer names the card a person decides it on.
733
+ """
734
+
735
+ def append_decision(self, *args: Any, **kwargs: Any) -> Any:
736
+ """Refused: Ask never writes to the decision log."""
737
+ raise DecisionError(ASK_NEVER_DECIDES)
738
+
739
+ def request_human(self, *args: Any, **kwargs: Any) -> Any:
740
+ """Refused: Ask never files a request."""
741
+ raise DecisionError(ASK_NEVER_DECIDES)
742
+
743
+ def reject_pending(self, *args: Any, **kwargs: Any) -> Any:
744
+ """Refused: only a person turns a request down, on the card."""
745
+ raise DecisionError(ASK_NEVER_DECIDES)
746
+
747
+ def set_autonomy(self, *args: Any, **kwargs: Any) -> None:
748
+ """Refused: autonomy is a person's setting."""
749
+ raise DecisionError(ASK_NEVER_DECIDES)
750
+
751
+
752
+ ASK_NEVER_DECIDES = "Ask never changes a choice; a person decides it on its Decision Card"
753
+
754
+
755
+ def _client(ctx: AppContext) -> Any:
756
+ """A client over the page's model with its own call log (each job logs only its calls)."""
757
+ from qpilot.ai.client import ModelClient
758
+
759
+ return ModelClient(backend=ctx.studio.client.backend)
760
+
761
+
762
+ def _typical(ctx: AppContext, seconds: float) -> float | None:
763
+ """The measured time without a model; none is claimed when a model answers."""
764
+ return None if ctx.studio.client.available else seconds
765
+
766
+
767
+ def _model_note(ctx: AppContext) -> str | None:
768
+ """Why the page's model is not used, when it is not (no model, misconfigured)."""
769
+ status = ctx.studio.model_status()
770
+ if status["configured"]:
771
+ return None
772
+ return f"A template answered: {status['reason'] or 'no model is configured'}."
773
+
774
+
775
+ # ---- numbers as the page writes them (docs/app.md §5; frontend/src/lib/format.ts) -------------
776
+
777
+ NNBSP = "\u202f" # narrow no-break space: groups digits, separates units
778
+ MINUS = "\u2212"
779
+
780
+
781
+ def _group(text: str) -> str:
782
+ """Digits grouped by three with narrow no-break spaces; a real minus sign."""
783
+ negative = text.startswith("-")
784
+ body = text[1:] if negative else text
785
+ whole, _, frac = body.partition(".")
786
+ if len(whole) > 3:
787
+ whole = re.sub(r"\B(?=(\d{3})+(?!\d))", NNBSP, whole)
788
+ return (MINUS if negative else "") + whole + (f".{frac}" if frac else "")
789
+
790
+
791
+ def _fold(fc: float) -> str:
792
+ """``3.59×``, ``0.57×``, ``0.034×``: two decimals from 1 up, two significant digits below."""
793
+ if fc >= 1:
794
+ return _group(f"{fc:.2f}") + "×"
795
+ digits = max(2, 1 - math.floor(math.log10(fc)))
796
+ return f"{fc:.{min(digits, 6)}f}×"
797
+
798
+
799
+ def _share(fraction: float) -> str:
800
+ """A share of analyses, ``64 %``; never rounded to 0 or 100 unless it is."""
801
+ f = min(1.0, max(0.0, fraction))
802
+ if 0 < f < 0.005:
803
+ return f"<{NNBSP}1{NNBSP}%"
804
+ if 0.995 <= f < 1:
805
+ return f">{NNBSP}99{NNBSP}%"
806
+ return f"{math.floor(f * 100 + 0.5)}{NNBSP}%" # half up, as the page's Math.round
807
+
808
+
809
+ def _p_value(p: float) -> str:
810
+ """``< 0.001``, ``0.003``, ``0.43``; never rounded across 0.05."""
811
+ if p < 0.001:
812
+ return f"<{NNBSP}0.001"
813
+ if p >= 0.995:
814
+ return "1.00"
815
+ decimals = 3 if p < 0.1 else 2
816
+ text = f"{p:.{decimals}f}"
817
+ while decimals < 6 and (p < 0.05) != (float(text) < 0.05):
818
+ decimals += 1
819
+ text = f"{p:.{decimals}f}"
820
+ return text
821
+
822
+
823
+ def _page_value(fid: str, fact: Any) -> str:
824
+ """One fact as the page writes that kind of number (the record keeps the fact's format)."""
825
+ from qpilot.core.facts import render_fact
826
+
827
+ value = fact.value
828
+ number = isinstance(value, int | float) and not isinstance(value, bool)
829
+ if number and math.isfinite(value):
830
+ if fact.unit == "×" and value > 0:
831
+ return _fold(float(value))
832
+ if "%" in fact.fmt and not fact.unit:
833
+ return _share(float(value))
834
+ if fid == "f_ans_p" or fact.path.endswith(".p"):
835
+ return _p_value(float(value))
836
+ if isinstance(value, int) and fact.fmt in ("{}", "{:,}"):
837
+ return _group(str(value))
838
+ return render_fact(fact)
839
+
840
+
841
+ def _page_text(tokens: str, facts: dict[str, Any]) -> str:
842
+ """Token text with every known fact written as the page writes it."""
843
+ from qpilot.core.validator import TOKEN
844
+
845
+ def sub(match: re.Match[str]) -> str:
846
+ fact = facts.get(match.group(1))
847
+ return _page_value(match.group(1), fact) if fact is not None else match.group(0)
848
+
849
+ return TOKEN.sub(sub, tokens)
850
+
851
+
852
+ def _cited(text: str, facts: dict[str, Any]) -> list[CitedFact]:
853
+ from qpilot.ai.ask import _path_words
854
+
855
+ out: list[CitedFact] = []
856
+ for fid in dict.fromkeys(re.findall(r"\{\{\s*([A-Za-z0-9_]+)\s*\}\}", text)):
857
+ fact = facts.get(fid)
858
+ if fact is None:
859
+ continue
860
+ meaning = ANSWER_FACTS.get(fid) or next(
861
+ (words for prefix, words in ANSWER_FACT_PREFIXES.items() if fid.startswith(prefix)),
862
+ _path_words(fact.path),
863
+ )
864
+ out.append(CitedFact(id=fid, meaning=meaning, value=_page_value(fid, fact)))
865
+ return out
866
+
867
+
868
+ def _ask_study(ctx: AppContext, study_id: str, question: str, autonomy: Autonomy) -> AskAnswer:
869
+ """Ask with qPilot's own tools (the MCP tool code), through a store that never writes."""
870
+ from qpilot.ai.ask import ask
871
+ from qpilot.ai.suggest_only import load_profile_or_default
872
+
873
+ store = ctx.store
874
+ client = _client(ctx)
875
+ before = store.chain_head(study_id)
876
+ answer = ask(
877
+ question,
878
+ _AskStore(store.root),
879
+ study_id,
880
+ client=client,
881
+ profile=load_profile_or_default(),
882
+ )
883
+ facts = {**store.get(study_id).facts, **answer.facts}
884
+ call = answer.tool_calls[-1] if answer.tool_calls else {"tool": "none", "args": {}}
885
+ tool, args = str(call.get("tool")), dict(call.get("args") or {})
886
+ used = client.available and not answer.fallback
887
+ text, cited = _page_text(answer.answer, facts), _cited(answer.answer, facts)
888
+ focus = card_id = None
889
+ note = None
890
+ if answer.fallback:
891
+ note = _model_note(ctx) or (
892
+ f"A template answered; the model was not used: {answer.error}."
893
+ if answer.error
894
+ else None
895
+ )
896
+ if tool == "qpcr_decide":
897
+ cards = {c.id: c for c in store.cards(study_id)}
898
+ card_id = str(args.get("card_id") or "")
899
+ if card_id in cards:
900
+ option = str(args.get("option") or "")
901
+ title, label = _card_words(cards, card_id, option)
902
+ asked = option in {o.id for o in cards[card_id].options}
903
+ text = (
904
+ f"Ask never changes a choice. Decide “{title}” on its Decision Card"
905
+ + (f" (you asked for “{label}”)" if asked else "")
906
+ + ", with your reason."
907
+ )
908
+ cited, focus = [], f"card:{card_id}"
909
+ note = "Nothing was written: every choice is a person's, made on its card."
910
+ else:
911
+ card_id = None
912
+ return AskAnswer(
913
+ question=answer.question,
914
+ answer=text,
915
+ engine="study",
916
+ tool=tool,
917
+ args=args,
918
+ tool_words=STUDY_TOOL_WORDS.get(tool, tool),
919
+ routed_by="model" if answer.tool_from_model else "keywords",
920
+ routed_words=ROUTED_WORDS["model" if answer.tool_from_model else "keywords"],
921
+ written_by="model" if used else "template",
922
+ writer_words=f"the model {client.model or client.provider}, every number checked"
923
+ if used
924
+ else "qPilot's checked template",
925
+ provider=answer.provider,
926
+ model=client.model if client.available else None,
927
+ facts=cited,
928
+ validated=answer.validated or focus is not None, # the card's words are qPilot's own
929
+ note=note,
930
+ card_id=card_id,
931
+ focus=focus,
932
+ autonomy=autonomy,
933
+ head_before=before,
934
+ head_after=store.chain_head(study_id),
935
+ )
936
+
937
+
938
+ def _shown(text: str, facts: list[dict[str, Any]]) -> list[CitedFact]:
939
+ """The audit's facts that its answer shows (its words are rendered; values are matched).
940
+
941
+ The audit's Ask returns every fact its tool computed; the answer cites some of them. A fact
942
+ is listed when its value appears in the answer as a whole token.
943
+ """
944
+ out = []
945
+ for fact in facts:
946
+ value = str(fact.get("value", ""))
947
+ if value and re.search(rf"(?<![\w.]){re.escape(value)}(?![\w])", text):
948
+ meaning = str(fact.get("meaning") or fact.get("id") or "")
949
+ out.append(CitedFact(id=fact.get("id"), meaning=meaning, value=value))
950
+ return out
951
+
952
+
953
+ def _ask_audit(ctx: AppContext, study_id: str, question: str, autonomy: Autonomy) -> AskAnswer:
954
+ """The audit's Ask engine (qsim's ``Asker``) over the study's cached audit screen."""
955
+ from fastapi import HTTPException
956
+
957
+ audit_screen = _audit_engine()
958
+ try:
959
+ screen = audit_screen(ctx, study_id) if audit_screen is not None else None
960
+ except HTTPException as err: # the audit routes' own refusals (blind, not found), in words
961
+ detail = err.detail if isinstance(err.detail, dict) else {}
962
+ raise JobError(
963
+ str(detail.get("code", "audit")), str(detail.get("message", err.detail))
964
+ ) from err
965
+ if screen is None:
966
+ raise JobError("no_audit", "This study has no robustness audit yet: run it in Audit first.")
967
+ out = screen.ask(question)
968
+ routed = str(out.get("routed_code") or "keywords")
969
+ written = str(out.get("written_code") or "template")
970
+ model = out.get("model") if written == "model" or routed == "model" else None
971
+ head = ctx.store.chain_head(study_id)
972
+ problems = [str(p) for p in out.get("problems") or []]
973
+ return AskAnswer(
974
+ question=question,
975
+ answer=str(out["text"]),
976
+ engine="audit",
977
+ tool=str(out["tool"]),
978
+ args=dict(out.get("args") or {}),
979
+ tool_words=AUDIT_TOOL_WORDS.get(str(out["tool"]), str(out.get("tool_words", ""))),
980
+ routed_by="model" if routed == "model" else "rules" if routed == "rules" else "keywords",
981
+ routed_words=ROUTED_WORDS.get(routed, routed),
982
+ written_by="model" if written == "model" else "template",
983
+ writer_words=str(out.get("written_by") or "the audit's checked template"),
984
+ provider=ctx.studio.client.provider,
985
+ model=model,
986
+ facts=_shown(str(out["text"]), out.get("facts") or []),
987
+ validated=True, # the audit's answers are rendered from its facts, or rejected
988
+ note=("A template answered: " + "; ".join(problems) + ".") if problems else None,
989
+ autonomy=autonomy,
990
+ head_before=head,
991
+ head_after=head,
992
+ )
993
+
994
+
995
+ _asking: dict[tuple[int, str, str, str], str] = {} # (runner, study, engine, question) -> job
996
+ _asking_lock = threading.Lock()
997
+
998
+
999
+ @router.post("/studies/{study_id}/ask", response_model=JobStarted, status_code=202)
1000
+ def ask_question(study_id: str, body: AskBody, ctx: Ctx) -> JobStarted:
1001
+ """Answer a question about a study (a job); every number in the answer is a cited fact."""
1002
+ question = body.question.strip()
1003
+ if not question:
1004
+ raise api_error(422, "bad_request", "Type a question first.")
1005
+ study_id, place, _ = resolve_study(ctx, study_id)
1006
+ autonomy = _autonomy(ctx, study_id)
1007
+ audit = views.audit_dir(ctx.store, study_id) is not None
1008
+ engine = _pick_engine(question, body.source, audit, _audit_engine() is not None)
1009
+
1010
+ def work(progress: Progress) -> dict[str, Any]:
1011
+ if engine == "none":
1012
+ answer = _no_engine(question, audit, autonomy, ctx.store.chain_head(study_id))
1013
+ return answer.model_dump(mode="json")
1014
+ progress("Reading the audit" if engine == "audit" else "Looking it up in the study")
1015
+ answer = (_ask_audit if engine == "audit" else _ask_study)(
1016
+ ctx, study_id, question, autonomy
1017
+ )
1018
+ if answer.head_after != answer.head_before:
1019
+ for key in dict.fromkeys((study_id, place.key)):
1020
+ ctx.bus.publish({"type": "study.changed", "key": key, "source": "page"})
1021
+ return answer.model_dump(mode="json")
1022
+
1023
+ # The same question asked again while it runs gets the running job (a model call is not
1024
+ # repeated, a request is not filed twice); different questions run side by side.
1025
+ asking = (id(ctx.jobs), study_id, engine, " ".join(question.lower().split()))
1026
+ with _asking_lock:
1027
+ job = ctx.jobs.get(_asking.get(asking, ""))
1028
+ if job is None or job.status not in ("queued", "running"):
1029
+ job = ctx.jobs.submit(
1030
+ "ask",
1031
+ work,
1032
+ title=f"Answering “{question[:60]}{'…' if len(question) > 60 else ''}”",
1033
+ key=place.key,
1034
+ typical_s=_typical(
1035
+ ctx, AUDIT_ASK_TYPICAL_S if engine == "audit" else ASK_TYPICAL_S
1036
+ ),
1037
+ dedupe=False,
1038
+ )
1039
+ _asking[asking] = job.id
1040
+ while len(_asking) > 200: # the last few hundred questions are enough
1041
+ _asking.pop(next(iter(_asking)))
1042
+ return JobStarted(job=JobView(**job.view()))
1043
+
1044
+
1045
+ # ---- the model -------------------------------------------------------------------------------
1046
+
1047
+
1048
+ def _default_study(ctx: AppContext) -> str:
1049
+ """The demo study when it is analysed and visible, else the most recent visible study."""
1050
+ best: str | None = None
1051
+ for line in views.workspaces(ctx.studio): # newest first
1052
+ if not line.results_visible or not line.study_id or not is_study(ctx.store, line.study_id):
1053
+ continue
1054
+ if line.is_demo:
1055
+ return line.study_id
1056
+ best = best or line.study_id
1057
+ if best is None:
1058
+ raise api_error(
1059
+ 409,
1060
+ "no_study",
1061
+ "Analyse a study first: the model is tested with questions about its results "
1062
+ "(open the demo study, confirm its design and curves).",
1063
+ )
1064
+ return best
1065
+
1066
+
1067
+ def _row(saved: dict[str, Any]) -> ScorecardRow | None:
1068
+ """A saved scorecard as a row of the Ask table (None for a Studio-only scorecard)."""
1069
+ from qpilot.harness.make_evalset import ASK_KINDS
1070
+ from qpilot.harness.report import Report
1071
+
1072
+ report = Report.model_validate(saved)
1073
+ ask = [report.correct_by_kind.get(k, [0, 0]) for k in ASK_KINDS]
1074
+ right, total = sum(a[0] for a in ask), sum(a[1] for a in ask)
1075
+ if not total:
1076
+ return None
1077
+ caught = report.invented_numbers - sum(
1078
+ s.invented_numbers for s in report.items if s.kind not in ASK_KINDS
1079
+ )
1080
+ return ScorecardRow(
1081
+ profile=report.profile,
1082
+ provider=report.provider,
1083
+ model=report.model,
1084
+ endpoint=report.endpoint,
1085
+ run_at=report.run_at,
1086
+ n_items=total,
1087
+ correct=right,
1088
+ correct_by_model=report.correct_by_model,
1089
+ model_eligible=report.model_eligible,
1090
+ invented_numbers_caught=caught,
1091
+ numbers_shown=report.numbers_shown,
1092
+ safety_held=report.safety[0],
1093
+ safety_items=report.safety[1],
1094
+ fallbacks=None if report.provider == "none" else report.fallbacks,
1095
+ median_latency_s=report.median_latency_s,
1096
+ notes=list(report.notes),
1097
+ )
1098
+
1099
+
1100
+ FALLBACKS = (
1101
+ "Fallbacks count answers where the model was not usable and a template answered instead."
1102
+ )
1103
+
1104
+
1105
+ def _rows(saved: dict[str, Any]) -> dict[str, ScorecardRow]:
1106
+ """Saved scorecards by profile, as rows of the Ask table (Studio-only ones left out)."""
1107
+ rows = {}
1108
+ for profile, report in saved.items():
1109
+ if profile == "history" or not isinstance(report, dict): # the CLI keeps earlier runs
1110
+ continue
1111
+ row = _row(report)
1112
+ if row is not None:
1113
+ rows[profile] = row
1114
+ return rows
1115
+
1116
+
1117
+ def _scorecards(
1118
+ study_id: str, title: str, rows: dict[str, ScorecardRow], profile: str, note: str
1119
+ ) -> ModelTestResult:
1120
+ current = rows.get(profile)
1121
+ return ModelTestResult(
1122
+ study_id=study_id,
1123
+ title=title,
1124
+ current=current,
1125
+ saved=list(rows.values()),
1126
+ n_questions=current.n_items if current else 0,
1127
+ note=note,
1128
+ )
1129
+
1130
+
1131
+ @router.post("/model/test", response_model=JobStarted, status_code=202)
1132
+ def test_model(ctx: Ctx, body: ModelTestBody | None = None) -> JobStarted:
1133
+ """Score the page's model on questions about one study (a job); saved with the study."""
1134
+ key = (body.study if body else None) or _default_study(ctx)
1135
+ study_id, place, places = resolve_study(ctx, key)
1136
+ title = places.title(place)
1137
+
1138
+ def work(progress: Progress) -> dict[str, Any]:
1139
+ from qpilot.ai.suggest_only import run_scorecard
1140
+
1141
+ client = _client(ctx)
1142
+ progress(
1143
+ "Asking the questions"
1144
+ + (f" to {client.model or client.provider}" if client.available else " (templates)")
1145
+ )
1146
+ profile, saved, n = run_scorecard(ctx.store, study_id, client)
1147
+ rows = _rows(saved)
1148
+ if profile not in rows:
1149
+ raise JobError("failed", "The model test produced no scorecard.")
1150
+ note = f"Computed now on {n} questions about {title}. {FALLBACKS}"
1151
+ return _scorecards(study_id, title, rows, profile, note).model_dump(mode="json")
1152
+
1153
+ job = ctx.jobs.submit(
1154
+ "model.test",
1155
+ work,
1156
+ title=f"Testing the model on {title}",
1157
+ key=place.key,
1158
+ typical_s=_typical(ctx, MODEL_TEST_TYPICAL_S),
1159
+ )
1160
+ return JobStarted(job=JobView(**job.view()))
1161
+
1162
+
1163
+ @router.get("/model/scorecards", response_model=ModelTestResult)
1164
+ def model_scorecards(
1165
+ ctx: Ctx, study: str | None = Query(default=None, max_length=12)
1166
+ ) -> ModelTestResult:
1167
+ """A study's saved scorecards (default: the demo study, else the latest visible one)."""
1168
+ from qpilot.ai.suggest_only import SCORECARD_KEYS
1169
+
1170
+ study_id, place, places = resolve_study(ctx, study or _default_study(ctx))
1171
+ title = places.title(place)
1172
+ provider = ctx.studio.client.provider
1173
+ profile = SCORECARD_KEYS.get(provider, provider)
1174
+ rows = _rows(ctx.store.scorecard(study_id))
1175
+ if profile in rows:
1176
+ note = f"The last test of this model on {title}. {FALLBACKS}"
1177
+ else:
1178
+ note = f"This model has not been tested on {title} yet: Test this model scores it."
1179
+ return _scorecards(study_id, title, rows, profile, note)
1180
+
1181
+
1182
+ @router.get("/model/seen", response_model=ModelSeen)
1183
+ def model_seen(ctx: Ctx, study: str | None = Query(default=None, max_length=12)) -> ModelSeen:
1184
+ """The last payload a model was sent (memory only), withheld while results are blind.
1185
+
1186
+ With ``study`` (a study or session id) it is gated by that workspace; without it, by every
1187
+ session (hidden while any is blind, unless the Design Interpreter sent it).
1188
+ """
1189
+ from qpilot.web import studio_views as sv
1190
+
1191
+ places = _Places(ctx)
1192
+ if study is not None:
1193
+ check_key(study)
1194
+ if is_study(ctx.store, study):
1195
+ sids = [places.session_of[study]] if study in places.session_of else []
1196
+ elif is_session(ctx.store, study):
1197
+ sids = [study]
1198
+ else:
1199
+ raise api_error(404, "not_found", "There is no study or session with this id.")
1200
+ else:
1201
+ sids = sorted(_session_ids(ctx.store))
1202
+ blind = next((sid for sid in sids if not places.visible(sid)), None)
1203
+ if blind is not None:
1204
+ return sv.model_seen(ctx.studio, blind)
1205
+ if sids:
1206
+ return sv.model_seen(ctx.studio, sids[0])
1207
+ return _seen_ungated()
1208
+
1209
+
1210
+ def _seen_ungated() -> ModelSeen:
1211
+ """The last payload, shown: no Studio session can be blind in this store."""
1212
+ import hashlib
1213
+
1214
+ from qpilot.ai import payload_log
1215
+ from qpilot.ai.prompts import SYSTEM_INTERPRET
1216
+ from qpilot.web.studio_views import MAX_PAYLOAD_CHARS, WORDS
1217
+
1218
+ last = payload_log.last_payload()
1219
+ if last is None:
1220
+ return ModelSeen(state="none", message=WORDS["seen_none"], interpreter=False)
1221
+ canonical = payload_log.canonical(dict(last)).encode("utf-8")
1222
+ return ModelSeen(
1223
+ state="shown",
1224
+ message=WORDS["seen_shown"],
1225
+ payload_text=json.dumps(last, indent=2, ensure_ascii=False, default=str)[
1226
+ :MAX_PAYLOAD_CHARS
1227
+ ],
1228
+ sha256=hashlib.sha256(canonical).hexdigest(),
1229
+ interpreter=last.get("system") == SYSTEM_INTERPRET,
1230
+ )
1231
+
1232
+
1233
+ # ---- the fresh demo --------------------------------------------------------------------------
1234
+
1235
+
1236
+ @router.post("/demo/fresh", response_model=FreshDemo)
1237
+ def fresh_demo(ctx: Ctx) -> FreshDemo:
1238
+ """Move the demo's session, studies and audits aside (kept, never deleted)."""
1239
+ demo_keys = _demo_keys(ctx.store)
1240
+ # Work that only reads (preparing a view, reading the file) never holds the demo back.
1241
+ if any(job.key in demo_keys and job.kind not in READING_JOBS for job in ctx.jobs.active()):
1242
+ raise api_error(
1243
+ 409,
1244
+ "busy",
1245
+ "Work on the demo study is still running; start a fresh demo once it has finished.",
1246
+ )
1247
+ with ctx.lock:
1248
+ try:
1249
+ moved = views.archive_demo(ctx.store)
1250
+ except (OSError, ValueError, KeyError, TypeError) as err: # nothing was moved yet
1251
+ raise api_error(
1252
+ 409,
1253
+ "damaged",
1254
+ "A study folder in the store could not be read, so nothing was moved: "
1255
+ f"{str(err)[:200]}",
1256
+ ) from err
1257
+ for key in sorted(demo_keys):
1258
+ ctx.bus.publish({"type": "study.changed", "key": key, "source": "page"})
1259
+ ctx.bus.publish({"type": "study.changed", "key": None, "source": "page"})
1260
+ if not moved:
1261
+ return FreshDemo(moved=[], message="There was no earlier demo work to move aside.")
1262
+ return FreshDemo(
1263
+ moved=[str(p) for p in moved],
1264
+ archive=str(moved[0].parent),
1265
+ message=f"Earlier demo work moved to {moved[0].parent} (kept, never deleted). "
1266
+ "Open the demo study to start again.",
1267
+ )
1268
+
1269
+
1270
+ #: Jobs that only read a workspace (they prepare a view in memory): a fresh demo may go ahead.
1271
+ READING_JOBS = frozenset(
1272
+ {"warm", "prepare", "gate.run", "trace.load", "audit.load", "audit.check", "proposal.matters"}
1273
+ )
1274
+
1275
+
1276
+ def _demo_keys(store: StudyStore) -> set[str]:
1277
+ """The demo's session id and its studies' ids."""
1278
+ sha = views.demo_sha()
1279
+ keys = {sha[:12]}
1280
+ for study_id in _study_ids(store):
1281
+ try:
1282
+ if store.get(study_id).input_sha256 == sha:
1283
+ keys.add(study_id)
1284
+ except (StudyNotFound, OSError, ValueError):
1285
+ continue
1286
+ return keys