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.
- qpilot/__init__.py +6 -0
- qpilot/_methods/absolute_quantification.md +54 -0
- qpilot/_methods/amp_call.md +34 -0
- qpilot/_methods/comparisons.md +70 -0
- qpilot/_methods/contrast.md +24 -0
- qpilot/_methods/curve_judge.md +161 -0
- qpilot/_methods/design_checks.md +125 -0
- qpilot/_methods/efficiency_model.md +32 -0
- qpilot/_methods/evidence.md +50 -0
- qpilot/_methods/genorm.md +38 -0
- qpilot/_methods/instrument_context.md +66 -0
- qpilot/_methods/inter_run_calibration.md +49 -0
- qpilot/_methods/linregpcr.md +44 -0
- qpilot/_methods/lookback.md +95 -0
- qpilot/_methods/melt_curve.md +66 -0
- qpilot/_methods/normfinder.md +104 -0
- qpilot/_methods/replicate_budget.md +68 -0
- qpilot/_methods/routes.md +28 -0
- qpilot/_methods/sheetcheck.md +32 -0
- qpilot/_methods/trace.md +82 -0
- qpilot/_packs/research-default.yaml +339 -0
- qpilot/agents/__init__.py +1 -0
- qpilot/agents/capabilities.py +1066 -0
- qpilot/agents/idempotency.py +119 -0
- qpilot/agents/outputs.py +785 -0
- qpilot/agents/refusals.py +199 -0
- qpilot/agents/surface.py +294 -0
- qpilot/agents/views.py +254 -0
- qpilot/ai/__init__.py +1 -0
- qpilot/ai/ask.py +924 -0
- qpilot/ai/client.py +328 -0
- qpilot/ai/config.py +124 -0
- qpilot/ai/explain.py +223 -0
- qpilot/ai/interpret.py +1651 -0
- qpilot/ai/payload_log.py +74 -0
- qpilot/ai/prism_draft.py +460 -0
- qpilot/ai/profile.py +75 -0
- qpilot/ai/prompts.py +115 -0
- qpilot/ai/suggest_only.py +100 -0
- qpilot/ai/validator.py +11 -0
- qpilot/app_state.py +436 -0
- qpilot/claims/__init__.py +24 -0
- qpilot/claims/assist.py +201 -0
- qpilot/claims/check.py +1516 -0
- qpilot/claims/examples.py +137 -0
- qpilot/claims/extract.py +1167 -0
- qpilot/claims/lexicon.py +442 -0
- qpilot/claims/models.py +276 -0
- qpilot/cli/__init__.py +71 -0
- qpilot/cli/_console.py +33 -0
- qpilot/cli/_types.py +6 -0
- qpilot/cli/core_cmds.py +493 -0
- qpilot/cli/engine_cmds.py +586 -0
- qpilot/cli/melt_cmds.py +154 -0
- qpilot/cli/prism_cmds.py +360 -0
- qpilot/cli/quality_cmds.py +597 -0
- qpilot/cli/quant_cmds.py +192 -0
- qpilot/cli/studio_cmds.py +465 -0
- qpilot/core/__init__.py +1 -0
- qpilot/core/card_kinds.py +190 -0
- qpilot/core/confidence.py +95 -0
- qpilot/core/consequence.py +190 -0
- qpilot/core/decisions.py +436 -0
- qpilot/core/facts.py +102 -0
- qpilot/core/firewall.py +275 -0
- qpilot/core/gate.py +582 -0
- qpilot/core/instruments.py +1392 -0
- qpilot/core/names.py +97 -0
- qpilot/core/proposals.py +1073 -0
- qpilot/core/pseudonym.py +165 -0
- qpilot/core/record.py +1112 -0
- qpilot/core/robustness.py +395 -0
- qpilot/core/rules.py +609 -0
- qpilot/core/store.py +1416 -0
- qpilot/core/validator.py +137 -0
- qpilot/core/vocabulary.py +104 -0
- qpilot/data/hgnc/LICENCE +9 -0
- qpilot/data/hgnc/README.md +65 -0
- qpilot/data/hgnc/hgnc_symbols.tsv +45279 -0
- qpilot/explain/__init__.py +1 -0
- qpilot/explain/evidence_text.py +387 -0
- qpilot/explain/instrument_text.py +211 -0
- qpilot/explain/lookback_text.py +140 -0
- qpilot/explain/quantity_text.py +82 -0
- qpilot/explain/sets.py +138 -0
- qpilot/explain/sheetcheck_text.py +243 -0
- qpilot/explain/templates.py +552 -0
- qpilot/explain/trace_text.py +460 -0
- qpilot/explain/words.py +17 -0
- qpilot/harness/__init__.py +1 -0
- qpilot/harness/make_evalset.py +315 -0
- qpilot/harness/report.py +201 -0
- qpilot/harness/run.py +96 -0
- qpilot/harness/score.py +144 -0
- qpilot/harness/studio.py +949 -0
- qpilot/history/__init__.py +19 -0
- qpilot/history/compare.py +271 -0
- qpilot/history/replay.py +701 -0
- qpilot/history/studio.py +453 -0
- qpilot/integration/__init__.py +25 -0
- qpilot/integration/__main__.py +166 -0
- qpilot/integration/mcp_tools.py +407 -0
- qpilot/integration/simulator.py +865 -0
- qpilot/integration/workflow.py +1070 -0
- qpilot/io/README.md +42 -0
- qpilot/io/__init__.py +1 -0
- qpilot/io/connectors/__init__.py +52 -0
- qpilot/io/connectors/base.py +364 -0
- qpilot/io/connectors/builder.py +346 -0
- qpilot/io/connectors/cfx.py +293 -0
- qpilot/io/connectors/common.py +264 -0
- qpilot/io/connectors/headers.py +81 -0
- qpilot/io/connectors/lightcycler.py +243 -0
- qpilot/io/connectors/quantstudio.py +587 -0
- qpilot/io/connectors/rdml_file.py +199 -0
- qpilot/io/connectors/registry.py +531 -0
- qpilot/io/connectors/sample_types.py +105 -0
- qpilot/io/connectors/tables.py +502 -0
- qpilot/io/connectors/tidy.py +328 -0
- qpilot/io/datasets.py +220 -0
- qpilot/io/inputs.py +151 -0
- qpilot/io/rawness.py +377 -0
- qpilot/io/rdml_io.py +747 -0
- qpilot/io/sheets.py +989 -0
- qpilot/io/symbols.py +419 -0
- qpilot/io/trust.py +310 -0
- qpilot/mcp_server.py +2325 -0
- qpilot/models/__init__.py +21 -0
- qpilot/models/curve_judge/METHOD_CARD.md +161 -0
- qpilot/models/curve_judge/metrics.json +1888 -0
- qpilot/models/curve_judge/model.json +1 -0
- qpilot/models/curve_judge/model.sha256 +1 -0
- qpilot/models/curve_judge/transfer.json +1844 -0
- qpilot/prism/__init__.py +660 -0
- qpilot/prism/project.py +580 -0
- qpilot/prism/provenance.py +393 -0
- qpilot/prism/pzfx.py +653 -0
- qpilot/prism/roundtrip.py +1195 -0
- qpilot/prism/script.py +454 -0
- qpilot/prism/tables.py +461 -0
- qpilot/prism/templates/README.md +109 -0
- qpilot/qpcr/__init__.py +6 -0
- qpilot/qpcr/absolute.py +1422 -0
- qpilot/qpcr/absolute_study.py +156 -0
- qpilot/qpcr/amp_call.py +341 -0
- qpilot/qpcr/axes/__init__.py +322 -0
- qpilot/qpcr/axes/efficiency.py +257 -0
- qpilot/qpcr/axes/normaliser.py +343 -0
- qpilot/qpcr/cards.py +62 -0
- qpilot/qpcr/confidence_checks.py +176 -0
- qpilot/qpcr/contrast.py +274 -0
- qpilot/qpcr/cq_only.py +304 -0
- qpilot/qpcr/curve_judge.py +709 -0
- qpilot/qpcr/curves.py +781 -0
- qpilot/qpcr/design.py +90 -0
- qpilot/qpcr/design_checks.py +1478 -0
- qpilot/qpcr/design_rules.py +1187 -0
- qpilot/qpcr/designs.py +929 -0
- qpilot/qpcr/evidence.py +1011 -0
- qpilot/qpcr/export.py +1032 -0
- qpilot/qpcr/export_runs.py +85 -0
- qpilot/qpcr/gate_instr.py +230 -0
- qpilot/qpcr/gate_lines.py +33 -0
- qpilot/qpcr/gate_rt.py +245 -0
- qpilot/qpcr/gate_rules.py +2189 -0
- qpilot/qpcr/inspect.py +482 -0
- qpilot/qpcr/instrument_context.py +2278 -0
- qpilot/qpcr/interrun.py +344 -0
- qpilot/qpcr/layout.py +881 -0
- qpilot/qpcr/lookback.py +683 -0
- qpilot/qpcr/melt.py +832 -0
- qpilot/qpcr/multiplex.py +183 -0
- qpilot/qpcr/multiplicity.py +189 -0
- qpilot/qpcr/normfinder.py +383 -0
- qpilot/qpcr/plan.py +1082 -0
- qpilot/qpcr/position_history.py +480 -0
- qpilot/qpcr/quant.py +248 -0
- qpilot/qpcr/quantify_study.py +718 -0
- qpilot/qpcr/refgenes.py +237 -0
- qpilot/qpcr/repeat.py +868 -0
- qpilot/qpcr/routes.py +166 -0
- qpilot/qpcr/run_links.py +153 -0
- qpilot/qpcr/sheetcheck.py +2980 -0
- qpilot/qpcr/space.py +1455 -0
- qpilot/qpcr/study.py +2101 -0
- qpilot/qpcr/trace.py +2924 -0
- qpilot/qpcr/trace_bench.py +1126 -0
- qpilot/qpcr/validation.py +434 -0
- qpilot/report/__init__.py +31 -0
- qpilot/report/build.py +333 -0
- qpilot/report/bundle.py +419 -0
- qpilot/report/context.py +212 -0
- qpilot/report/excel.py +462 -0
- qpilot/report/figures.py +994 -0
- qpilot/report/fmt.py +80 -0
- qpilot/report/html.py +385 -0
- qpilot/report/inputs.py +217 -0
- qpilot/report/markdown.py +145 -0
- qpilot/report/methods.py +919 -0
- qpilot/report/miqe.py +860 -0
- qpilot/report/miqe2_items.yaml +92 -0
- qpilot/report/model.py +235 -0
- qpilot/report/rdml_meta.py +237 -0
- qpilot/report/rdml_out.py +257 -0
- qpilot/report/review.py +761 -0
- qpilot/report/run_review.py +624 -0
- qpilot/report/svg.py +218 -0
- qpilot/report/tables.py +1558 -0
- qpilot/report/text.py +220 -0
- qpilot/schemas.py +318 -0
- qpilot/schemas_studio.py +787 -0
- qpilot/studio_axes.py +143 -0
- qpilot/studio_design.py +1063 -0
- qpilot/studio_service.py +3299 -0
- qpilot/studio_state.py +1302 -0
- qpilot/trace/__init__.py +68 -0
- qpilot/trace/context.py +771 -0
- qpilot/trace/gene.py +964 -0
- qpilot/trace/schema.py +398 -0
- qpilot/trace/study.py +321 -0
- qpilot/trace/welch.py +678 -0
- qpilot/trace/words.py +149 -0
- qpilot/vendor/__init__.py +1 -0
- qpilot/vendor/rdmlpython/LICENSE +22 -0
- qpilot/vendor/rdmlpython/README_VENDOR.md +39 -0
- qpilot/vendor/rdmlpython/__init__.py +1 -0
- qpilot/vendor/rdmlpython/rdml.py +15562 -0
- qpilot/vendor/rdmlpython/schema/LICENSE +22 -0
- qpilot/vendor/rdmlpython/schema/RDML_v1_0_REC.xsd +1206 -0
- qpilot/vendor/rdmlpython/schema/RDML_v1_1_REC.xsd +1276 -0
- qpilot/vendor/rdmlpython/schema/RDML_v1_2_REC.xsd +1308 -0
- qpilot/vendor/rdmlpython/schema/RDML_v1_3_REC.xsd +1606 -0
- qpilot/vendor/rdmlpython/schema/RDML_v1_4_CR.xsd +1660 -0
- qpilot/web/__init__.py +1 -0
- qpilot/web/absolute_views.py +505 -0
- qpilot/web/axes_views.py +312 -0
- qpilot/web/cache.py +277 -0
- qpilot/web/compare_views.py +443 -0
- qpilot/web/context.py +133 -0
- qpilot/web/contract.py +1303 -0
- qpilot/web/contract_absolute.py +319 -0
- qpilot/web/contract_audit.py +776 -0
- qpilot/web/contract_capture.py +84 -0
- qpilot/web/contract_claims.py +63 -0
- qpilot/web/contract_compare.py +139 -0
- qpilot/web/contract_cqonly.py +93 -0
- qpilot/web/contract_design.py +259 -0
- qpilot/web/contract_evidence.py +173 -0
- qpilot/web/contract_files.py +433 -0
- qpilot/web/contract_gate.py +303 -0
- qpilot/web/contract_history.py +366 -0
- qpilot/web/contract_instruments.py +264 -0
- qpilot/web/contract_melt.py +163 -0
- qpilot/web/contract_norm.py +72 -0
- qpilot/web/contract_ops.py +247 -0
- qpilot/web/contract_planner.py +370 -0
- qpilot/web/contract_report.py +195 -0
- qpilot/web/contract_sheets.py +184 -0
- qpilot/web/contract_trace.py +308 -0
- qpilot/web/contract_trust.py +139 -0
- qpilot/web/contract_units.py +134 -0
- qpilot/web/cq_table_views.py +235 -0
- qpilot/web/curves.py +113 -0
- qpilot/web/events.py +301 -0
- qpilot/web/folders.py +383 -0
- qpilot/web/intake.py +661 -0
- qpilot/web/jobs.py +234 -0
- qpilot/web/openapi.py +30 -0
- qpilot/web/ratelimit.py +642 -0
- qpilot/web/references_views.py +165 -0
- qpilot/web/results_views.py +1507 -0
- qpilot/web/routers/__init__.py +48 -0
- qpilot/web/routers/absolute.py +205 -0
- qpilot/web/routers/audit.py +1179 -0
- qpilot/web/routers/claims.py +113 -0
- qpilot/web/routers/cqonly.py +29 -0
- qpilot/web/routers/design.py +319 -0
- qpilot/web/routers/evidence.py +528 -0
- qpilot/web/routers/files.py +1141 -0
- qpilot/web/routers/gate.py +1375 -0
- qpilot/web/routers/history.py +111 -0
- qpilot/web/routers/instruments.py +590 -0
- qpilot/web/routers/melt.py +358 -0
- qpilot/web/routers/ops.py +1286 -0
- qpilot/web/routers/planner.py +775 -0
- qpilot/web/routers/references.py +36 -0
- qpilot/web/routers/report.py +519 -0
- qpilot/web/routers/results.py +426 -0
- qpilot/web/routers/sheets.py +254 -0
- qpilot/web/routers/studio.py +748 -0
- qpilot/web/routers/system.py +150 -0
- qpilot/web/routers/trace.py +366 -0
- qpilot/web/routers/trust.py +280 -0
- qpilot/web/routers/units.py +51 -0
- qpilot/web/routers/where.py +652 -0
- qpilot/web/routers/workspaces.py +398 -0
- qpilot/web/security.py +404 -0
- qpilot/web/server.py +280 -0
- qpilot/web/static/assets/AskDrawer-CUxSUS-x.js +1 -0
- qpilot/web/static/assets/ComparisonsPanel-D3FXhNHz.js +1 -0
- qpilot/web/static/assets/FactText-Dz8wh-gO.js +1 -0
- qpilot/web/static/assets/MeltPanel-Cw8Nsxcc.js +1 -0
- qpilot/web/static/assets/PrismFigure-CkTa0blX.js +1 -0
- qpilot/web/static/assets/RepeatPlan-BChHKdfV.js +1 -0
- qpilot/web/static/assets/TraceLink-DeQ7800y.js +1 -0
- qpilot/web/static/assets/WellCurve-CHQuPKbV.js +1 -0
- qpilot/web/static/assets/adapters-PmyB1S-K.js +1 -0
- qpilot/web/static/assets/agents-CoQ0p6Pa.js +1 -0
- qpilot/web/static/assets/api-B4NH57vK.js +1 -0
- qpilot/web/static/assets/api-lNzRMZwq.js +1 -0
- qpilot/web/static/assets/arrow-left-J4KquhEj.js +1 -0
- qpilot/web/static/assets/arrow-left-right-DskOmiWB.js +1 -0
- qpilot/web/static/assets/arrow-up-right-CUA0BG1N.js +1 -0
- qpilot/web/static/assets/audit-DoqbPwf9.js +1 -0
- qpilot/web/static/assets/charts-C5usZK2E.js +1 -0
- qpilot/web/static/assets/chevron-left-dRUodBJI.js +1 -0
- qpilot/web/static/assets/circle-minus-DeoM5xIK.js +1 -0
- qpilot/web/static/assets/circle-question-mark-VCGmGiNy.js +1 -0
- qpilot/web/static/assets/claims-DcoY19HA.js +2 -0
- qpilot/web/static/assets/cpu-DlUwpr2b.js +1 -0
- qpilot/web/static/assets/curves-B1ifh85o.js +1 -0
- qpilot/web/static/assets/curves-XOLvFA_8.js +1 -0
- qpilot/web/static/assets/data-5sJA9TGe.js +3 -0
- qpilot/web/static/assets/data-CnsZ1jie.css +1 -0
- qpilot/web/static/assets/design-96g2-GCi.js +4 -0
- qpilot/web/static/assets/download-CbMqoFTx.js +1 -0
- qpilot/web/static/assets/eye-BfvztZUo.js +1 -0
- qpilot/web/static/assets/file-archive-BHfTFACh.js +1 -0
- qpilot/web/static/assets/file-down-C3_7QBi7.js +1 -0
- qpilot/web/static/assets/gate-CkJHqoy7.js +2 -0
- qpilot/web/static/assets/gene-C_xoqCdK.js +2 -0
- qpilot/web/static/assets/git-compare-DbZBcP5d.js +1 -0
- qpilot/web/static/assets/history-DpMybOc0.js +1 -0
- qpilot/web/static/assets/home-Cj1zA296.js +1 -0
- qpilot/web/static/assets/inbox-CYTddybL.js +1 -0
- qpilot/web/static/assets/index-9TwPw7mS.css +1 -0
- qpilot/web/static/assets/index-BgRULiZl.js +10 -0
- qpilot/web/static/assets/info-BswNFHHj.js +1 -0
- qpilot/web/static/assets/instruments-jd32H0mH.js +1 -0
- qpilot/web/static/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- qpilot/web/static/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- qpilot/web/static/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- qpilot/web/static/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- qpilot/web/static/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- qpilot/web/static/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- qpilot/web/static/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-cyrillic-ext-wght-normal-EocZY2iu.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- qpilot/web/static/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- qpilot/web/static/assets/layers-D2r9RZW0.js +1 -0
- qpilot/web/static/assets/map-pin-BiPNAHHZ.js +1 -0
- qpilot/web/static/assets/planner-DxsNPgnB.js +1 -0
- qpilot/web/static/assets/plate-Dji8Bed1.js +1 -0
- qpilot/web/static/assets/plus-bJK5ipt2.js +1 -0
- qpilot/web/static/assets/point-lCZbdOEQ.js +1 -0
- qpilot/web/static/assets/prism-Beki88_Y.js +1 -0
- qpilot/web/static/assets/proposal-B8JUMKOg.js +1 -0
- qpilot/web/static/assets/react-D_CZztLv.js +9 -0
- qpilot/web/static/assets/record-0B21W6z5.js +1 -0
- qpilot/web/static/assets/report-DUyk8Imm.js +1 -0
- qpilot/web/static/assets/results-C-a1kb0M.js +2 -0
- qpilot/web/static/assets/rotate-ccw-BAyh8Avz.js +1 -0
- qpilot/web/static/assets/sections-CKGrfpub.js +2 -0
- qpilot/web/static/assets/settings-2NKo8-rw.js +1 -0
- qpilot/web/static/assets/sheetcheck-u29C7m87.js +1 -0
- qpilot/web/static/assets/shield-alert-CM5JXUmj.js +1 -0
- qpilot/web/static/assets/shield-check-CR27b7sP.js +1 -0
- qpilot/web/static/assets/sparkles-BSio9YeX.js +1 -0
- qpilot/web/static/assets/src-DmrZXBmt.js +1 -0
- qpilot/web/static/assets/table-2-7dzo7oVA.js +1 -0
- qpilot/web/static/assets/trace-OgKBXZhz.js +1 -0
- qpilot/web/static/assets/ui-B9WviEwM.js +44 -0
- qpilot/web/static/assets/ui-Dlfa4Gi0.css +1 -0
- qpilot/web/static/assets/undo-2-CNANXPsX.js +1 -0
- qpilot/web/static/assets/unlink-_3yYur6c.js +1 -0
- qpilot/web/static/assets/upload-CEiVqAZ1.js +1 -0
- qpilot/web/static/assets/useMutation-C3bsgiJY.js +1 -0
- qpilot/web/static/assets/useOpenWorkspace-CiHqAp_k.js +1 -0
- qpilot/web/static/assets/useQueries-Bpk0ankz.js +1 -0
- qpilot/web/static/assets/user-fGY6PXpe.js +1 -0
- qpilot/web/static/assets/words-B1YuDegT.js +1 -0
- qpilot/web/static/assets/wrench-gyiojqmh.js +1 -0
- qpilot/web/static/favicon.svg +1 -0
- qpilot/web/static/index.html +25 -0
- qpilot/web/studio_views.py +668 -0
- qpilot/web/units_views.py +414 -0
- qpilot/web/views.py +666 -0
- qpilot/web/warm.py +562 -0
- qpilot-0.2.0.dist-info/METADATA +511 -0
- qpilot-0.2.0.dist-info/RECORD +425 -0
- qpilot-0.2.0.dist-info/WHEEL +4 -0
- qpilot-0.2.0.dist-info/entry_points.txt +3 -0
- qpilot-0.2.0.dist-info/licenses/LICENSE +202 -0
- qpilot-0.2.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +492 -0
- qsim/__init__.py +3 -0
- qsim/app.py +883 -0
- qsim/app_lab.py +512 -0
- qsim/ask.py +650 -0
- qsim/attribution.py +160 -0
- qsim/benchmark.py +182 -0
- qsim/cli.py +258 -0
- qsim/compare.py +83 -0
- qsim/data.py +388 -0
- qsim/decisions.py +293 -0
- qsim/demo.py +271 -0
- qsim/designs.py +100 -0
- qsim/engine.py +493 -0
- qsim/explore.py +178 -0
- qsim/external.py +623 -0
- qsim/inference.py +146 -0
- qsim/influence.py +217 -0
- qsim/issues.py +248 -0
- qsim/known_answers.py +1141 -0
- qsim/moves.py +473 -0
- qsim/nodiff.py +264 -0
- qsim/plates.py +277 -0
- qsim/report.py +629 -0
- qsim/study_space.py +134 -0
- qsim/troubleshoot.py +360 -0
- qsim/truth.py +227 -0
- qsim/validate.py +400 -0
- 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
|