evalrx 0.1.2__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.
- evalrx/__init__.py +139 -0
- evalrx/agent_assets/__init__.py +2 -0
- evalrx/agent_assets/skills/README.md +28 -0
- evalrx/agent_assets/skills/eval-chart-style/SKILL.md +172 -0
- evalrx/agent_assets/skills/evalrx-report-ui/SKILL.md +116 -0
- evalrx/agent_assets/skills/nature-figure/LICENSE +201 -0
- evalrx/agent_assets/skills/nature-figure/README.md +412 -0
- evalrx/agent_assets/skills/nature-figure/SKILL.md +60 -0
- evalrx/agent_assets/skills/nature-figure/manifest.yaml +59 -0
- evalrx/agent_assets/skills/nature-figure/references/api.md +436 -0
- evalrx/agent_assets/skills/nature-figure/references/backend-selection.md +100 -0
- evalrx/agent_assets/skills/nature-figure/references/chart-types.md +281 -0
- evalrx/agent_assets/skills/nature-figure/references/common-patterns.md +350 -0
- evalrx/agent_assets/skills/nature-figure/references/demos.md +65 -0
- evalrx/agent_assets/skills/nature-figure/references/design-theory.md +439 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-contract.md +93 -0
- evalrx/agent_assets/skills/nature-figure/references/figure-legend-conventions.md +71 -0
- evalrx/agent_assets/skills/nature-figure/references/nature-2026-observations.md +112 -0
- evalrx/agent_assets/skills/nature-figure/references/qa-contract.md +119 -0
- evalrx/agent_assets/skills/nature-figure/references/r-template-index.md +66 -0
- evalrx/agent_assets/skills/nature-figure/references/r-workflow.md +161 -0
- evalrx/agent_assets/skills/nature-figure/references/tutorials.md +251 -0
- evalrx/agent_assets/skills/nature-figure/static/core/contract.md +29 -0
- evalrx/agent_assets/skills/nature-figure/static/core/stance.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/python.md +37 -0
- evalrx/agent_assets/skills/nature-figure/static/fragments/backend/r.md +44 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/SKILL.md +213 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/assets/analysis_report_template.md +53 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/references/model_selection.md +72 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.R +130 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/explanatory_var_eda.py +150 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.R +181 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/fit_outcome_model.py +186 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.R +149 -0
- evalrx/agent_assets/skills/outcome-driver-analysis/scripts/univariate_eda.py +177 -0
- evalrx/agent_assets/skills.py +27 -0
- evalrx/agent_runtime/__init__.py +78 -0
- evalrx/agent_runtime/_docker_runner.py +89 -0
- evalrx/agent_runtime/cli_runtime.py +103 -0
- evalrx/agent_runtime/cli_transcript.py +138 -0
- evalrx/agent_runtime/cli_types.py +68 -0
- evalrx/agent_runtime/codegen/__init__.py +5 -0
- evalrx/agent_runtime/codegen/runner.py +94 -0
- evalrx/agent_runtime/experiment_harness.py +117 -0
- evalrx/agent_runtime/factory.py +102 -0
- evalrx/agent_runtime/json_shape.py +44 -0
- evalrx/agent_runtime/judges/__init__.py +28 -0
- evalrx/agent_runtime/judges/agy.py +179 -0
- evalrx/agent_runtime/judges/autodetect.py +135 -0
- evalrx/agent_runtime/judges/claude.py +159 -0
- evalrx/agent_runtime/judges/codex.py +120 -0
- evalrx/agent_runtime/providers/__init__.py +21 -0
- evalrx/agent_runtime/providers/antigravity.py +31 -0
- evalrx/agent_runtime/providers/base.py +145 -0
- evalrx/agent_runtime/providers/claude_code.py +49 -0
- evalrx/agent_runtime/providers/codex.py +37 -0
- evalrx/agent_runtime/providers/gemini_cli.py +26 -0
- evalrx/agent_runtime/providers/kimi_cli.py +27 -0
- evalrx/agent_runtime/providers/opencode.py +27 -0
- evalrx/agent_runtime/providers/registry.py +58 -0
- evalrx/agent_runtime/sandbox.py +517 -0
- evalrx/agent_runtime/skill_audit.py +143 -0
- evalrx/agent_runtime/skills/__init__.py +19 -0
- evalrx/agent_runtime/skills/installer.py +68 -0
- evalrx/agent_runtime/skills/prompt_policy.py +86 -0
- evalrx/agent_runtime/skills/resolver.py +19 -0
- evalrx/analysis/__init__.py +132 -0
- evalrx/analysis/adjudicate.py +154 -0
- evalrx/analysis/analysis_module.py +361 -0
- evalrx/analysis/api.py +171 -0
- evalrx/analysis/case_studio.py +651 -0
- evalrx/analysis/cli.py +114 -0
- evalrx/analysis/dashboard.py +350 -0
- evalrx/analysis/eval_case_matrix.py +118 -0
- evalrx/analysis/eval_viz_theme.py +833 -0
- evalrx/analysis/explore_run.py +333 -0
- evalrx/analysis/explorer.py +1276 -0
- evalrx/analysis/failure_modes.py +607 -0
- evalrx/analysis/fused_pipeline.py +489 -0
- evalrx/analysis/holdout.py +300 -0
- evalrx/analysis/hypothesis_agent.py +230 -0
- evalrx/analysis/narration.py +177 -0
- evalrx/analysis/operationalize.py +442 -0
- evalrx/analysis/plain_language.py +42 -0
- evalrx/analysis/planner.py +283 -0
- evalrx/analysis/probe_search.py +203 -0
- evalrx/analysis/profile.py +268 -0
- evalrx/analysis/prompts/__init__.py +0 -0
- evalrx/analysis/prompts/explorer.py +417 -0
- evalrx/analysis/prompts/failure_modes.py +33 -0
- evalrx/analysis/prompts/holdout.py +27 -0
- evalrx/analysis/prompts/hypothesis_agent.py +78 -0
- evalrx/analysis/prompts/run_codebase.py +47 -0
- evalrx/analysis/prompts/stats_agent.py +72 -0
- evalrx/analysis/prompts/stats_tool_generator.py +43 -0
- evalrx/analysis/result_marker.py +47 -0
- evalrx/analysis/run_codebase.py +242 -0
- evalrx/analysis/run_view.py +205 -0
- evalrx/analysis/stage_views.py +93 -0
- evalrx/analysis/stats_agent.py +944 -0
- evalrx/analysis/stats_tool_agent.py +261 -0
- evalrx/analysis/stats_tool_generator.py +415 -0
- evalrx/analysis/stats_tools.py +1153 -0
- evalrx/analysis/trajectory_records.py +193 -0
- evalrx/analysis/workbench.py +431 -0
- evalrx/analyzers/__init__.py +42 -0
- evalrx/analyzers/agent/__init__.py +25 -0
- evalrx/analyzers/agent/counterfactual.py +84 -0
- evalrx/analyzers/agent/first_error_judge.py +96 -0
- evalrx/analyzers/agent/ignored_obs.py +81 -0
- evalrx/analyzers/agent/loop_detect.py +79 -0
- evalrx/analyzers/agent/reliability.py +165 -0
- evalrx/analyzers/agent/tool_shap.py +225 -0
- evalrx/analyzers/agent/trajectory_rubric.py +168 -0
- evalrx/analyzers/attention/__init__.py +19 -0
- evalrx/analyzers/attention/relative_attn.py +610 -0
- evalrx/analyzers/attention/rollout.py +73 -0
- evalrx/analyzers/attention/sink.py +56 -0
- evalrx/analyzers/attention/summary.py +190 -0
- evalrx/analyzers/attribution/__init__.py +6 -0
- evalrx/analyzers/attribution/generic_attn.py +31 -0
- evalrx/analyzers/attribution/gradcam.py +30 -0
- evalrx/analyzers/base.py +12 -0
- evalrx/analyzers/geometry/__init__.py +6 -0
- evalrx/analyzers/geometry/cka.py +70 -0
- evalrx/analyzers/geometry/linear_probe.py +157 -0
- evalrx/analyzers/hallucination/__init__.py +9 -0
- evalrx/analyzers/hallucination/chair.py +78 -0
- evalrx/analyzers/hallucination/opera.py +29 -0
- evalrx/analyzers/hallucination/pope.py +119 -0
- evalrx/analyzers/hallucination/selfcheck.py +155 -0
- evalrx/analyzers/hallucination/vcd.py +29 -0
- evalrx/analyzers/lens/__init__.py +7 -0
- evalrx/analyzers/lens/layer_contrast.py +133 -0
- evalrx/analyzers/lens/logit_lens.py +138 -0
- evalrx/analyzers/lens/tuned_lens.py +30 -0
- evalrx/analyzers/patching/__init__.py +5 -0
- evalrx/analyzers/patching/causal_trace.py +30 -0
- evalrx/analyzers/perturbation/__init__.py +23 -0
- evalrx/analyzers/perturbation/_shapley.py +54 -0
- evalrx/analyzers/perturbation/context_shap.py +174 -0
- evalrx/analyzers/perturbation/cot_faithfulness.py +239 -0
- evalrx/analyzers/perturbation/format_sensitivity.py +237 -0
- evalrx/analyzers/perturbation/mm_shap.py +146 -0
- evalrx/analyzers/perturbation/modality_ablation.py +196 -0
- evalrx/analyzers/perturbation/perturbation_battery.py +274 -0
- evalrx/analyzers/perturbation/prompt_contrast.py +265 -0
- evalrx/analyzers/perturbation/rise.py +94 -0
- evalrx/analyzers/perturbation/vl_shap.py +102 -0
- evalrx/analyzers/reasoning/__init__.py +33 -0
- evalrx/analyzers/reasoning/_text.py +328 -0
- evalrx/analyzers/reasoning/answer_extraction_audit.py +327 -0
- evalrx/analyzers/reasoning/arith_audit.py +226 -0
- evalrx/analyzers/reasoning/contamination.py +214 -0
- evalrx/analyzers/reasoning/knowledge_split.py +253 -0
- evalrx/analyzers/reasoning/self_repair.py +246 -0
- evalrx/analyzers/reasoning/step_rollout_value.py +216 -0
- evalrx/analyzers/reasoning/termination_audit.py +258 -0
- evalrx/analyzers/uncertainty/__init__.py +18 -0
- evalrx/analyzers/uncertainty/calibration.py +174 -0
- evalrx/analyzers/uncertainty/coverage_gap.py +199 -0
- evalrx/analyzers/uncertainty/entropy.py +90 -0
- evalrx/analyzers/uncertainty/logprob_entropy.py +69 -0
- evalrx/analyzers/uncertainty/self_consistency.py +204 -0
- evalrx/analyzers/uncertainty/verbalized_conf.py +64 -0
- evalrx/cli.py +411 -0
- evalrx/config.py +77 -0
- evalrx/contract/__init__.py +179 -0
- evalrx/contract/common.py +452 -0
- evalrx/contract/emit.py +948 -0
- evalrx/contract/export.py +237 -0
- evalrx/contract/m1.py +325 -0
- evalrx/contract/m2.py +317 -0
- evalrx/contract/m3.py +165 -0
- evalrx/contract/m4.py +130 -0
- evalrx/contract/m5.py +292 -0
- evalrx/contract/methodology.py +76 -0
- evalrx/contract/pre_m1.py +58 -0
- evalrx/contract/typescript.py +140 -0
- evalrx/core/__init__.py +85 -0
- evalrx/core/analyzer.py +174 -0
- evalrx/core/capability.py +54 -0
- evalrx/core/case.py +443 -0
- evalrx/core/experiment.py +106 -0
- evalrx/core/model.py +198 -0
- evalrx/core/pipeline.py +42 -0
- evalrx/core/registry.py +142 -0
- evalrx/core/result.py +64 -0
- evalrx/core/spec.py +173 -0
- evalrx/core/tokentype.py +165 -0
- evalrx/core/tool.py +92 -0
- evalrx/datasets/__init__.py +41 -0
- evalrx/datasets/base.py +68 -0
- evalrx/datasets/gui_os.py +52 -0
- evalrx/datasets/llm_qa.py +57 -0
- evalrx/datasets/pure_qa.py +12 -0
- evalrx/datasets/vlm_qa.py +695 -0
- evalrx/datasets/web_search_qa.py +52 -0
- evalrx/eval_agent/__init__.py +341 -0
- evalrx/eval_agent/_tools.py +81 -0
- evalrx/eval_agent/ab_runner.py +50 -0
- evalrx/eval_agent/agentic/__init__.py +43 -0
- evalrx/eval_agent/agentic/actions.py +216 -0
- evalrx/eval_agent/agentic/board.py +107 -0
- evalrx/eval_agent/agentic/loop.py +190 -0
- evalrx/eval_agent/agentic/tools.py +538 -0
- evalrx/eval_agent/checkpoint.py +57 -0
- evalrx/eval_agent/cli_agent.py +59 -0
- evalrx/eval_agent/cli_skills.py +5 -0
- evalrx/eval_agent/evolution.py +396 -0
- evalrx/eval_agent/git_manager.py +215 -0
- evalrx/eval_agent/hypothesis.py +172 -0
- evalrx/eval_agent/label_quarantine.py +209 -0
- evalrx/eval_agent/legacy.py +530 -0
- evalrx/eval_agent/log_schema.py +497 -0
- evalrx/eval_agent/loop.py +2159 -0
- evalrx/eval_agent/loop_reports.py +116 -0
- evalrx/eval_agent/model_instrumentation.py +282 -0
- evalrx/eval_agent/narration.py +193 -0
- evalrx/eval_agent/nl_runner.py +460 -0
- evalrx/eval_agent/orchestrator.py +61 -0
- evalrx/eval_agent/preregister.py +93 -0
- evalrx/eval_agent/prompts/__init__.py +1 -0
- evalrx/eval_agent/prompts/agentic.py +46 -0
- evalrx/eval_agent/prompts/case_discovery.py +25 -0
- evalrx/eval_agent/prompts/diagnosis.py +125 -0
- evalrx/eval_agent/prompts/experiment_writer.py +265 -0
- evalrx/eval_agent/prompts/explore_step.py +37 -0
- evalrx/eval_agent/prompts/fix_agent.py +257 -0
- evalrx/eval_agent/prompts/hypothesis_tester.py +15 -0
- evalrx/eval_agent/prompts/nl_runner.py +38 -0
- evalrx/eval_agent/prompts/probe_agent.py +25 -0
- evalrx/eval_agent/prompts/probe_candidate_generator.py +14 -0
- evalrx/eval_agent/prompts/probe_generator.py +35 -0
- evalrx/eval_agent/prompts/whitebox_probe_generator.py +38 -0
- evalrx/eval_agent/report.py +58 -0
- evalrx/eval_agent/run_context.py +354 -0
- evalrx/eval_agent/run_log.schema.json +1215 -0
- evalrx/eval_agent/run_logger_v2.py +1764 -0
- evalrx/eval_agent/run_metadata.py +208 -0
- evalrx/eval_agent/stages/__init__.py +56 -0
- evalrx/eval_agent/stages/case_discovery.py +293 -0
- evalrx/eval_agent/stages/diagnosis.py +1017 -0
- evalrx/eval_agent/stages/experiment_writer.py +1634 -0
- evalrx/eval_agent/stages/fix_agent.py +3916 -0
- evalrx/eval_agent/stages/fix_internals.py +499 -0
- evalrx/eval_agent/stages/fix_pipeline.py +725 -0
- evalrx/eval_agent/stages/fix_tiers.py +187 -0
- evalrx/eval_agent/stages/fix_tools.py +1034 -0
- evalrx/eval_agent/stages/hypothesis_tester.py +1014 -0
- evalrx/eval_agent/stages/probe.py +439 -0
- evalrx/eval_agent/stages/probe_agent.py +1079 -0
- evalrx/eval_agent/stages/probe_candidate_generator.py +128 -0
- evalrx/eval_agent/stages/probe_generator.py +326 -0
- evalrx/eval_agent/stages/probe_search_agent.py +106 -0
- evalrx/eval_agent/stages/protocol.py +112 -0
- evalrx/eval_agent/stages/repair_catalog.py +273 -0
- evalrx/eval_agent/stages/surgery.py +524 -0
- evalrx/eval_agent/stages/whitebox_probe_generator.py +351 -0
- evalrx/eval_agent/store.py +231 -0
- evalrx/logging_utils.py +112 -0
- evalrx/models/__init__.py +161 -0
- evalrx/models/_discover.py +101 -0
- evalrx/models/agent.py +380 -0
- evalrx/models/backends/__init__.py +58 -0
- evalrx/models/backends/api.py +169 -0
- evalrx/models/backends/base.py +57 -0
- evalrx/models/backends/gemini_compat.py +579 -0
- evalrx/models/backends/hf_local.py +2074 -0
- evalrx/models/backends/openai_compat.py +301 -0
- evalrx/models/backends/vllm_offline.py +116 -0
- evalrx/models/base.py +24 -0
- evalrx/models/blackbox/__init__.py +4 -0
- evalrx/models/blackbox/agent.py +31 -0
- evalrx/models/blackbox/base.py +29 -0
- evalrx/models/blackbox/gemini.py +279 -0
- evalrx/models/blackbox/llm_api.py +17 -0
- evalrx/models/blackbox/vlm_api.py +17 -0
- evalrx/models/compose.py +66 -0
- evalrx/models/inference.py +88 -0
- evalrx/models/paper_methods/__init__.py +8 -0
- evalrx/models/paper_methods/aad.py +53 -0
- evalrx/models/paper_methods/ifcd.py +204 -0
- evalrx/models/paper_methods/pai.py +164 -0
- evalrx/models/paper_methods/tcd.py +202 -0
- evalrx/models/paper_methods/vcd.py +45 -0
- evalrx/models/paper_methods/vicrop.py +137 -0
- evalrx/models/toolcodec.py +143 -0
- evalrx/models/tools/__init__.py +20 -0
- evalrx/models/tools/perception.py +300 -0
- evalrx/models/tools/visual.py +174 -0
- evalrx/models/whitebox/__init__.py +26 -0
- evalrx/models/whitebox/agent.py +31 -0
- evalrx/models/whitebox/base.py +24 -0
- evalrx/models/whitebox/qwen.py +61 -0
- evalrx/models/whitebox/qwen2_5_omni.py +29 -0
- evalrx/models/whitebox/qwen2_audio.py +25 -0
- evalrx/models/whitebox/qwen_omni.py +53 -0
- evalrx/models/whitebox/qwen_vl.py +62 -0
- evalrx/observability/__init__.py +21 -0
- evalrx/observability/envelope.py +122 -0
- evalrx/observability/outbox.py +111 -0
- evalrx/observability/tracer.py +882 -0
- evalrx/reporting/__init__.py +28 -0
- evalrx/reporting/case_study.py +947 -0
- evalrx/reporting/compiler.py +587 -0
- evalrx/reporting/dynamic.py +1882 -0
- evalrx/reporting/html_report.py +2225 -0
- evalrx/reporting/langfuse_exporter.py +38 -0
- evalrx/reporting/langfuse_source.py +155 -0
- evalrx/reporting/model.py +151 -0
- evalrx/reporting/run_events.py +184 -0
- evalrx/reporting/server.py +557 -0
- evalrx/reporting/stages.py +58 -0
- evalrx/reporting/static_export.py +142 -0
- evalrx/reporting/web_dist/index.html +146 -0
- evalrx/specs.py +727 -0
- evalrx/stats/__init__.py +47 -0
- evalrx/stats/api.py +192 -0
- evalrx/stats/bootstrap.py +86 -0
- evalrx/stats/ebh.py +27 -0
- evalrx/stats/evalue.py +98 -0
- evalrx/stats/friedman.py +138 -0
- evalrx/stats/mcnemar.py +40 -0
- evalrx/stats/multiplicity.py +159 -0
- evalrx/stats/subset_sampling.py +55 -0
- evalrx/term_links.py +43 -0
- evalrx/viz/__init__.py +7 -0
- evalrx/viz/labels.py +77 -0
- evalrx/viz/prompts.py +39 -0
- evalrx/viz/renderer.py +590 -0
- evalrx/viz/schema.py +36 -0
- evalrx/viz/style.py +134 -0
- evalrx-0.1.2.dist-info/METADATA +532 -0
- evalrx-0.1.2.dist-info/RECORD +339 -0
- evalrx-0.1.2.dist-info/WHEEL +5 -0
- evalrx-0.1.2.dist-info/entry_points.txt +3 -0
- evalrx-0.1.2.dist-info/licenses/LICENSE +121 -0
- evalrx-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""Local FastAPI application for dynamic completed-run reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import mimetypes
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import tempfile
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from evalrx.reporting.dynamic import (
|
|
16
|
+
CATALOG_VERSION,
|
|
17
|
+
JSON_RENDER_VERSION,
|
|
18
|
+
REPORT_SCHEMA_VERSION,
|
|
19
|
+
build_report_data,
|
|
20
|
+
fallback_spec,
|
|
21
|
+
load_published_report,
|
|
22
|
+
publish_report,
|
|
23
|
+
report_is_current,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
try: # FastAPI stays optional, but a route's annotations must resolve against
|
|
27
|
+
# module globals — a function-local import of UploadFile would not be seen.
|
|
28
|
+
from fastapi import File, UploadFile
|
|
29
|
+
except ImportError: # pragma: no cover - create_app reports the missing extra
|
|
30
|
+
File = UploadFile = None # type: ignore[assignment]
|
|
31
|
+
|
|
32
|
+
# A dropped archive is a whole run directory, which for a media-carrying
|
|
33
|
+
# benchmark is mostly audio/video. The workbench default (1 GiB) is the same
|
|
34
|
+
# ceiling the upload app used, and it stays generous enough for those.
|
|
35
|
+
MAX_ARCHIVE_BYTES = 1024 * 1024 * 1024
|
|
36
|
+
_JUNK_DIRS = {"__MACOSX", ".git", "node_modules", ".venv"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ReportSession:
|
|
40
|
+
"""The run currently on screen, and where dropped archives are unpacked.
|
|
41
|
+
|
|
42
|
+
``serve`` starts either on a run directory or on nothing at all, and an
|
|
43
|
+
uploaded archive replaces whatever is loaded. Holding that in one object
|
|
44
|
+
lets every endpoint read the current run instead of closing over the one
|
|
45
|
+
that happened to exist at startup.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, run_dir: str | Path | None = None) -> None:
|
|
49
|
+
self.root: Path | None = None
|
|
50
|
+
self.data: dict[str, Any] = {}
|
|
51
|
+
self.envelope: dict[str, Any] = {}
|
|
52
|
+
self.media_by_id: dict[str, Any] = {}
|
|
53
|
+
self.label: str = ""
|
|
54
|
+
self._workspace: Path | None = None
|
|
55
|
+
if run_dir is not None:
|
|
56
|
+
self.load(run_dir)
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def loaded(self) -> bool:
|
|
60
|
+
return self.root is not None
|
|
61
|
+
|
|
62
|
+
def load(self, run_dir: str | Path) -> None:
|
|
63
|
+
root = _resolve_report_root(Path(run_dir).resolve())
|
|
64
|
+
data, envelope = _compile_report(root)
|
|
65
|
+
self.root = root
|
|
66
|
+
self.data = data
|
|
67
|
+
self.envelope = envelope
|
|
68
|
+
self.media_by_id = {str(item.get("id")): item for item in data.get("media", [])}
|
|
69
|
+
self.label = _run_label(root)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def payload(self) -> dict[str, Any]:
|
|
73
|
+
return {"data": self.data, "layout": self.envelope}
|
|
74
|
+
|
|
75
|
+
def ingest_archive(self, payload: bytes, *, filename: str = "run.zip") -> Path:
|
|
76
|
+
"""Unpack a dropped run archive and make it the report on screen."""
|
|
77
|
+
from evalrx.analysis.workbench import UploadLimits, extract_archive
|
|
78
|
+
|
|
79
|
+
if self._workspace is None:
|
|
80
|
+
self._workspace = Path(tempfile.mkdtemp(prefix="evalrx-report-"))
|
|
81
|
+
stem = re.sub(r"[^A-Za-z0-9._-]+", "-", Path(filename).stem).strip("-") or "run"
|
|
82
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
83
|
+
extracted = extract_archive(
|
|
84
|
+
payload,
|
|
85
|
+
self._workspace / f"{stem[:60]}-{stamp}",
|
|
86
|
+
limits=UploadLimits(max_archive_bytes=MAX_ARCHIVE_BYTES),
|
|
87
|
+
)
|
|
88
|
+
found = find_run_root(extracted)
|
|
89
|
+
if found is None:
|
|
90
|
+
raise ValueError(
|
|
91
|
+
"this archive has no run in it: expected a directory holding "
|
|
92
|
+
"run.json + M1..M5, or report/report_data.json"
|
|
93
|
+
)
|
|
94
|
+
self.load(found)
|
|
95
|
+
return found
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _run_label(root: Path) -> str:
|
|
99
|
+
# "logs" alone names nothing; a run written to <run>/logs is better
|
|
100
|
+
# identified by the directory the reader actually zipped up.
|
|
101
|
+
return f"{root.parent.name}/{root.name}" if root.name == "logs" else root.name
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
#: RunLoggerV2's per-stage documents (evalrx/eval_agent/run_logger_v2.py).
|
|
105
|
+
#: A run.json + at least one of these is its on-disk signature, matching
|
|
106
|
+
#: find_run_root's marker and reporting/run_events.py's resolve_v2_root.
|
|
107
|
+
_STAGES = ("M1", "M2", "M3", "M4", "M5")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _is_run_root(current: Path, filenames: list[str]) -> bool:
|
|
111
|
+
"""Whether *current* is a run's own directory."""
|
|
112
|
+
return "run.json" in filenames and any(
|
|
113
|
+
(current / stage / "log.json").is_file() for stage in _STAGES
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_mtime(root: Path) -> float | None:
|
|
118
|
+
"""Last-activity time for a run — the most recently rewritten document."""
|
|
119
|
+
candidates = (root / "run.json", *(root / stage / "log.json" for stage in _STAGES))
|
|
120
|
+
stamps = []
|
|
121
|
+
for candidate in candidates:
|
|
122
|
+
try:
|
|
123
|
+
stamps.append(candidate.stat().st_mtime)
|
|
124
|
+
except OSError:
|
|
125
|
+
continue
|
|
126
|
+
return max(stamps) if stamps else None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# Directories a run scan never descends into: version control and dependency
|
|
130
|
+
# noise, plus a found run's own internals (raw case media, the coder's
|
|
131
|
+
# sandbox, cached transcodes) — none of those hold a *further* run, and
|
|
132
|
+
# `data/` in particular can be hundreds of megabytes of dataset files.
|
|
133
|
+
_SCAN_SKIP_DIRS = _JUNK_DIRS | {"data", "sandbox", ".media_cache"}
|
|
134
|
+
|
|
135
|
+
#: Runs panel display cap, applied after sorting by recency (see /api/runs) —
|
|
136
|
+
#: distinct from discover_runs' own max_results, which is a much larger
|
|
137
|
+
#: traversal safety valve, not a display limit.
|
|
138
|
+
RUNS_DISPLAY_LIMIT = 200
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def discover_runs(scan_root: Path, *, max_depth: int = 8, max_results: int = 2000) -> list[Path]:
|
|
142
|
+
"""Find run directories under ``scan_root``, without walking into any of them.
|
|
143
|
+
|
|
144
|
+
A run directory is one holding ``run.json`` plus at least one
|
|
145
|
+
``M<n>/log.json`` (see ``_is_run_root``). Once a run is found its own
|
|
146
|
+
subtree (data, sandbox, report, the stage folders, ...) is not searched
|
|
147
|
+
further, and directories named `outputs*`/`logs` are walked through
|
|
148
|
+
since a run commonly sits a few levels under an example's output
|
|
149
|
+
directory.
|
|
150
|
+
|
|
151
|
+
``max_results`` is a safety valve for a pathological tree, not the panel's
|
|
152
|
+
display limit — hitting it means the *walk* stops, in whatever order
|
|
153
|
+
``os.walk`` reached matches, so a caller that wants "most recent N" must
|
|
154
|
+
sort the full result and slice afterward rather than rely on this cutoff
|
|
155
|
+
to have kept the newest ones.
|
|
156
|
+
"""
|
|
157
|
+
scan_root = scan_root.resolve()
|
|
158
|
+
found: list[Path] = []
|
|
159
|
+
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
160
|
+
current = Path(dirpath)
|
|
161
|
+
dirnames[:] = sorted(d for d in dirnames if d not in _SCAN_SKIP_DIRS and not d.startswith("."))
|
|
162
|
+
if _is_run_root(current, filenames):
|
|
163
|
+
found.append(current)
|
|
164
|
+
dirnames[:] = [] # a run's own subtree holds no further runs
|
|
165
|
+
if len(found) >= max_results:
|
|
166
|
+
break
|
|
167
|
+
continue
|
|
168
|
+
if len(current.relative_to(scan_root).parts) >= max_depth:
|
|
169
|
+
dirnames[:] = [] # too deep to be worth descending further
|
|
170
|
+
return found
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _run_summary(root: Path, run_id: str, scan_root: Path) -> dict[str, Any]:
|
|
174
|
+
"""Cheap, read-only metadata for the runs panel — never compiles a report."""
|
|
175
|
+
mtime = _run_mtime(root)
|
|
176
|
+
dataset = model = None
|
|
177
|
+
published = False
|
|
178
|
+
data_path = root / "report" / "report_data.json"
|
|
179
|
+
if data_path.is_file():
|
|
180
|
+
published = True
|
|
181
|
+
try:
|
|
182
|
+
setting = json.loads(data_path.read_text(encoding="utf-8")).get("setting") or {}
|
|
183
|
+
dataset, model = setting.get("dataset"), setting.get("model")
|
|
184
|
+
except (OSError, ValueError, json.JSONDecodeError):
|
|
185
|
+
pass
|
|
186
|
+
try:
|
|
187
|
+
rel_path = str(root.relative_to(scan_root))
|
|
188
|
+
except ValueError:
|
|
189
|
+
rel_path = str(root)
|
|
190
|
+
return {
|
|
191
|
+
"id": run_id,
|
|
192
|
+
# Relative to the scan root, not `_run_label`'s "<parent>/logs": two
|
|
193
|
+
# sibling experiments both nest their run under an `outputs/logs`
|
|
194
|
+
# child, and the topbar's one-run label collapses them to identical,
|
|
195
|
+
# unidentifiable rows in a list that shows several at once.
|
|
196
|
+
"path": rel_path,
|
|
197
|
+
# The resolved absolute path, matching what /api/session reports for
|
|
198
|
+
# the run currently on screen — how the panel knows which entry to
|
|
199
|
+
# highlight as active.
|
|
200
|
+
"root": str(root),
|
|
201
|
+
"dataset": dataset,
|
|
202
|
+
"model": model,
|
|
203
|
+
"published": published,
|
|
204
|
+
"modified_at": datetime.fromtimestamp(mtime, tz=timezone.utc).isoformat() if mtime else None,
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def find_run_root(base: Path) -> Path | None:
|
|
209
|
+
"""Locate the run inside an unpacked archive, shallowest candidate first.
|
|
210
|
+
|
|
211
|
+
Archives are zipped from wherever the run happened to sit, so the run log
|
|
212
|
+
can be at the top level or several directories down beside its example's
|
|
213
|
+
data. Depth is the tiebreak that matches how people zip: the run they meant
|
|
214
|
+
to share is the one nearest the surface.
|
|
215
|
+
"""
|
|
216
|
+
candidates: list[tuple[int, int, Path]] = []
|
|
217
|
+
for rank, pattern in (
|
|
218
|
+
(0, "report/report_data.json"), (1, "run.json"),
|
|
219
|
+
):
|
|
220
|
+
for hit in base.rglob(pattern.rsplit("/", 1)[-1]):
|
|
221
|
+
if rank == 0 and hit.parent.name != "report":
|
|
222
|
+
continue
|
|
223
|
+
run_root = hit.parent.parent if rank == 0 else hit.parent
|
|
224
|
+
if rank == 1 and not any(
|
|
225
|
+
(run_root / stage / "log.json").is_file() for stage in _STAGES
|
|
226
|
+
):
|
|
227
|
+
continue
|
|
228
|
+
if any(part in _JUNK_DIRS for part in run_root.relative_to(base).parts):
|
|
229
|
+
continue
|
|
230
|
+
candidates.append((len(run_root.relative_to(base).parts), rank, run_root))
|
|
231
|
+
if not candidates:
|
|
232
|
+
return None
|
|
233
|
+
return min(candidates, key=lambda item: (item[0], item[1], str(item[2])))[2]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _compile_report(root: Path) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
237
|
+
"""Publish the run's report cache when writable, else compile in memory."""
|
|
238
|
+
try:
|
|
239
|
+
if not report_is_current(root):
|
|
240
|
+
publish_report(root)
|
|
241
|
+
return load_published_report(root)
|
|
242
|
+
except PermissionError:
|
|
243
|
+
# Historical/shared runs are often intentionally read-only. Serving a
|
|
244
|
+
# report must not require mutating its evidence directory.
|
|
245
|
+
data = build_report_data(root)
|
|
246
|
+
envelope = {
|
|
247
|
+
"schema_version": REPORT_SCHEMA_VERSION,
|
|
248
|
+
"format": "json-render",
|
|
249
|
+
"json_render_version": JSON_RENDER_VERSION,
|
|
250
|
+
"catalog_version": CATALOG_VERSION,
|
|
251
|
+
"trace_id": data["trace_id"],
|
|
252
|
+
"source_event_seq": data["source_event_seq"],
|
|
253
|
+
"generated_by": {"mode": "deterministic-read-only", "model": None},
|
|
254
|
+
"spec": fallback_spec(data),
|
|
255
|
+
}
|
|
256
|
+
return data, envelope
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def create_app(
|
|
260
|
+
run_dir: str | Path | None = None, *, frontend_dir: str | Path | None = None,
|
|
261
|
+
runs_root: str | Path | None = None,
|
|
262
|
+
) -> Any:
|
|
263
|
+
"""Create the loopback report API and serve the packaged React application.
|
|
264
|
+
|
|
265
|
+
``run_dir`` may be omitted: the app then starts empty and waits for a run
|
|
266
|
+
archive to be dropped on the page.
|
|
267
|
+
|
|
268
|
+
``runs_root`` is where the runs panel looks for sibling experiments to
|
|
269
|
+
list; it defaults to ``run_dir``'s parent (so opening one run in an
|
|
270
|
+
example's ``outputs/`` surfaces the others beside it) or, with no
|
|
271
|
+
``run_dir``, the current directory.
|
|
272
|
+
"""
|
|
273
|
+
try:
|
|
274
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
275
|
+
from fastapi.responses import FileResponse
|
|
276
|
+
from fastapi.staticfiles import StaticFiles
|
|
277
|
+
except ImportError as exc:
|
|
278
|
+
raise ImportError(
|
|
279
|
+
"The dynamic UI needs `pip install evalrx[ui]`."
|
|
280
|
+
) from exc
|
|
281
|
+
|
|
282
|
+
session = ReportSession(run_dir)
|
|
283
|
+
app = FastAPI(title="EvalRX Report", docs_url="/api/docs", redoc_url=None)
|
|
284
|
+
|
|
285
|
+
# State for the runs panel: which directory it scans, and the ids handed
|
|
286
|
+
# to the browser for the last listing (an id is a hash of a resolved
|
|
287
|
+
# path, so `open` needs this to map it back — the scan itself is not
|
|
288
|
+
# repeated on open).
|
|
289
|
+
scan_state: dict[str, Path] = {
|
|
290
|
+
"root": Path(runs_root).resolve() if runs_root is not None
|
|
291
|
+
else (Path(run_dir).resolve().parent if run_dir is not None else Path.cwd())
|
|
292
|
+
}
|
|
293
|
+
run_index: dict[str, Path] = {}
|
|
294
|
+
|
|
295
|
+
def current() -> ReportSession:
|
|
296
|
+
if not session.loaded:
|
|
297
|
+
raise HTTPException(status_code=404, detail="No run is loaded; drop a run .zip to open one.")
|
|
298
|
+
return session
|
|
299
|
+
|
|
300
|
+
@app.get("/api/session")
|
|
301
|
+
def get_session() -> dict[str, Any]:
|
|
302
|
+
return {
|
|
303
|
+
"loaded": session.loaded,
|
|
304
|
+
"label": session.label,
|
|
305
|
+
"trace_id": session.data.get("trace_id") if session.loaded else None,
|
|
306
|
+
"accepts_upload": True,
|
|
307
|
+
# Lets the runs panel highlight the run already on screen (e.g.
|
|
308
|
+
# one passed on the command line) against its own listing, which
|
|
309
|
+
# keys entries by this same resolved path.
|
|
310
|
+
"root": str(session.root) if session.loaded else None,
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
@app.get("/api/runs")
|
|
314
|
+
def list_runs() -> dict[str, Any]:
|
|
315
|
+
"""List experiments found on disk under the scan root, most recent first."""
|
|
316
|
+
scan_root = scan_state["root"]
|
|
317
|
+
if not scan_root.is_dir():
|
|
318
|
+
raise HTTPException(status_code=404, detail=f"{scan_root} is not a directory")
|
|
319
|
+
found = discover_runs(scan_root)
|
|
320
|
+
# Sort the full (safety-valve-capped, not display-capped) find before
|
|
321
|
+
# truncating to what the panel actually shows — discover_runs' own
|
|
322
|
+
# cutoff stops the walk in traversal order, which is not recency.
|
|
323
|
+
found.sort(key=lambda path: _run_mtime(path) or 0, reverse=True)
|
|
324
|
+
items = []
|
|
325
|
+
for path in found[:RUNS_DISPLAY_LIMIT]:
|
|
326
|
+
run_id = hashlib.sha1(str(path).encode("utf-8")).hexdigest()[:16]
|
|
327
|
+
run_index[run_id] = path
|
|
328
|
+
items.append(_run_summary(path, run_id, scan_root))
|
|
329
|
+
return {"root": str(scan_root), "items": items, "truncated": len(found) > RUNS_DISPLAY_LIMIT}
|
|
330
|
+
|
|
331
|
+
@app.post("/api/runs/{run_id}/open")
|
|
332
|
+
def open_run(run_id: str) -> dict[str, Any]:
|
|
333
|
+
"""Load a run found by /api/runs and make it the report on screen.
|
|
334
|
+
|
|
335
|
+
Opens by id rather than by a client-supplied path, so the browser can
|
|
336
|
+
only load a run this server itself already found on a prior listing.
|
|
337
|
+
"""
|
|
338
|
+
path = run_index.get(run_id)
|
|
339
|
+
if path is None or not path.is_dir():
|
|
340
|
+
raise HTTPException(status_code=404, detail="Unknown run id; refresh the runs list and try again")
|
|
341
|
+
try:
|
|
342
|
+
session.load(path)
|
|
343
|
+
except Exception as exc: # a run mid-write or otherwise unreadable
|
|
344
|
+
raise HTTPException(status_code=422, detail=f"could not open this run: {exc}") from exc
|
|
345
|
+
return {"label": session.label, **session.payload}
|
|
346
|
+
|
|
347
|
+
@app.post("/api/upload")
|
|
348
|
+
async def upload_run(file: UploadFile = File(...)) -> dict[str, Any]:
|
|
349
|
+
"""Accept a zipped run directory and swap it in as the current report."""
|
|
350
|
+
payload = await file.read()
|
|
351
|
+
try:
|
|
352
|
+
session.ingest_archive(payload, filename=file.filename or "run.zip")
|
|
353
|
+
except ValueError as exc:
|
|
354
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
355
|
+
except Exception as exc: # a malformed run reads as a bad upload, not a crash
|
|
356
|
+
raise HTTPException(status_code=422, detail=f"could not read this run: {exc}") from exc
|
|
357
|
+
return {"label": session.label, **session.payload}
|
|
358
|
+
|
|
359
|
+
@app.get("/api/report")
|
|
360
|
+
def get_report() -> dict[str, Any]:
|
|
361
|
+
return current().payload
|
|
362
|
+
|
|
363
|
+
@app.get("/api/stages/{stage_id}")
|
|
364
|
+
def get_stage(stage_id: str) -> dict[str, Any]:
|
|
365
|
+
data = current().data
|
|
366
|
+
stage = next((item for item in data.get("stages", []) if item.get("id") == stage_id.lower()), None)
|
|
367
|
+
if stage is None:
|
|
368
|
+
raise HTTPException(status_code=404, detail="Unknown stage")
|
|
369
|
+
related = [event for event in data.get("debug", {}).get("events", []) if str(event.get("stage", "")).lower() in {stage_id.lower(), stage.get("code", "").lower()}]
|
|
370
|
+
return {"stage": stage, "events": related}
|
|
371
|
+
|
|
372
|
+
@app.get("/api/cases")
|
|
373
|
+
def get_cases(
|
|
374
|
+
status: str | None = None,
|
|
375
|
+
task: str | None = None,
|
|
376
|
+
search: str | None = None,
|
|
377
|
+
offset: int = Query(0, ge=0),
|
|
378
|
+
limit: int = Query(50, ge=1, le=200),
|
|
379
|
+
) -> dict[str, Any]:
|
|
380
|
+
rows = list(current().data.get("cases", []))
|
|
381
|
+
if status:
|
|
382
|
+
rows = [case for case in rows if str(case.get("status", "")).lower() == status.lower()]
|
|
383
|
+
if task:
|
|
384
|
+
rows = [case for case in rows if str(case.get("task", "")).lower() == task.lower()]
|
|
385
|
+
if search:
|
|
386
|
+
needle = search.lower()
|
|
387
|
+
rows = [case for case in rows if needle in " ".join(str(case.get(key, "")) for key in ("id", "prompt", "expected", "observed", "task")).lower()]
|
|
388
|
+
return {"total": len(rows), "offset": offset, "limit": limit, "items": rows[offset:offset + limit]}
|
|
389
|
+
|
|
390
|
+
@app.get("/api/cases/{case_id}")
|
|
391
|
+
def get_case(case_id: str) -> dict[str, Any]:
|
|
392
|
+
case = next((item for item in current().data.get("cases", []) if str(item.get("id")) == case_id), None)
|
|
393
|
+
if case is None:
|
|
394
|
+
raise HTTPException(status_code=404, detail="Unknown case")
|
|
395
|
+
return case
|
|
396
|
+
|
|
397
|
+
@app.get("/api/debug")
|
|
398
|
+
def get_debug(
|
|
399
|
+
event: str | None = None,
|
|
400
|
+
offset: int = Query(0, ge=0),
|
|
401
|
+
limit: int = Query(100, ge=1, le=500),
|
|
402
|
+
) -> dict[str, Any]:
|
|
403
|
+
rows = list(current().data.get("debug", {}).get("events", []))
|
|
404
|
+
if event:
|
|
405
|
+
rows = [row for row in rows if row.get("event") == event]
|
|
406
|
+
return {"total": len(rows), "offset": offset, "limit": limit, "items": rows[offset:offset + limit]}
|
|
407
|
+
|
|
408
|
+
@app.get("/api/media/{media_id}")
|
|
409
|
+
def get_media(media_id: str) -> Any:
|
|
410
|
+
item = current().media_by_id.get(media_id)
|
|
411
|
+
if item is None:
|
|
412
|
+
raise HTTPException(status_code=404, detail="Unknown media")
|
|
413
|
+
# A media id is selected from the report's fixed evidence index, not a
|
|
414
|
+
# caller-provided path. It may safely resolve into the example's
|
|
415
|
+
# adjacent data directory (where VLM images / ALLM audio conventionally
|
|
416
|
+
# live), while the generic artifact endpoint remains run-confined.
|
|
417
|
+
path = _resolve_indexed_media(session.root, str(item.get("path") or ""))
|
|
418
|
+
if path is None:
|
|
419
|
+
raise HTTPException(status_code=404, detail="Media is not available in the local cache")
|
|
420
|
+
return FileResponse(path, media_type=mimetypes.guess_type(path.name)[0])
|
|
421
|
+
|
|
422
|
+
@app.get("/api/artifact")
|
|
423
|
+
def get_artifact(path: str = Query(...)) -> Any:
|
|
424
|
+
"""Serve a report-referenced local figure, constrained to this run."""
|
|
425
|
+
resolved = _resolve_media(current().root, path)
|
|
426
|
+
if resolved is None:
|
|
427
|
+
raise HTTPException(status_code=404, detail="Artifact is not available in this run")
|
|
428
|
+
return FileResponse(resolved, media_type=mimetypes.guess_type(resolved.name)[0])
|
|
429
|
+
|
|
430
|
+
packaged = Path(frontend_dir).resolve() if frontend_dir else Path(__file__).with_name("web_dist")
|
|
431
|
+
if packaged.is_dir() and (packaged / "index.html").exists():
|
|
432
|
+
assets = packaged / "assets"
|
|
433
|
+
if assets.is_dir():
|
|
434
|
+
app.mount("/assets", StaticFiles(directory=assets), name="assets")
|
|
435
|
+
|
|
436
|
+
@app.get("/{path:path}")
|
|
437
|
+
def spa(path: str) -> Any:
|
|
438
|
+
candidate = (packaged / path).resolve()
|
|
439
|
+
if packaged in candidate.parents and candidate.is_file():
|
|
440
|
+
return FileResponse(candidate, headers={"Cache-Control": "no-store"})
|
|
441
|
+
# The packaged app is a single-file bundle. Keeping it out of the
|
|
442
|
+
# browser cache makes a restarted local report server immediately
|
|
443
|
+
# pick up new interactive behavior instead of a stale UI shell.
|
|
444
|
+
return FileResponse(packaged / "index.html", headers={"Cache-Control": "no-store"})
|
|
445
|
+
else:
|
|
446
|
+
@app.get("/")
|
|
447
|
+
def missing_frontend() -> dict[str, str]:
|
|
448
|
+
return {"message": "API ready; build evalrx/reporting/web to install the UI."}
|
|
449
|
+
|
|
450
|
+
return app
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _resolve_report_root(requested_root: Path) -> Path:
|
|
454
|
+
"""Accept either a run-log directory or its enclosing output directory.
|
|
455
|
+
|
|
456
|
+
Agentic examples conventionally write their immutable event stream in an
|
|
457
|
+
``outputs_*/logs`` child. The CLI takes the enclosing output directory so
|
|
458
|
+
that it also remains convenient; prefer the nested directory whenever it
|
|
459
|
+
contains a run log or a published report.
|
|
460
|
+
"""
|
|
461
|
+
# An explicit directory with its own event stream always wins. Some
|
|
462
|
+
# examples retain a later ``logs/`` sub-run beside an earlier successful
|
|
463
|
+
# top-level run; silently preferring it makes the UI show the wrong repair.
|
|
464
|
+
if (requested_root / "run.json").is_file():
|
|
465
|
+
return requested_root
|
|
466
|
+
nested_logs = requested_root / "logs"
|
|
467
|
+
if nested_logs.is_dir() and (
|
|
468
|
+
(nested_logs / "report" / "report_data.json").is_file()
|
|
469
|
+
or (nested_logs / "run.json").is_file()
|
|
470
|
+
):
|
|
471
|
+
return nested_logs
|
|
472
|
+
return requested_root
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def serve_dynamic_report(
|
|
476
|
+
run_dir: str | Path | None = None,
|
|
477
|
+
*,
|
|
478
|
+
host: str = "127.0.0.1",
|
|
479
|
+
port: int = 8501,
|
|
480
|
+
open_browser: bool = True,
|
|
481
|
+
runs_root: str | Path | None = None,
|
|
482
|
+
) -> int:
|
|
483
|
+
"""Publish if necessary and run the completed-report UI on loopback.
|
|
484
|
+
|
|
485
|
+
With no ``run_dir`` the server comes up empty and waits for a zipped run to
|
|
486
|
+
be dropped on the page, which is how a run that was produced on another
|
|
487
|
+
machine gets looked at here. ``runs_root`` overrides where the runs panel
|
|
488
|
+
looks for sibling experiments (see ``create_app``).
|
|
489
|
+
"""
|
|
490
|
+
try:
|
|
491
|
+
import uvicorn
|
|
492
|
+
except ImportError as exc:
|
|
493
|
+
raise ImportError("The dynamic UI needs `pip install evalrx[ui]`.") from exc
|
|
494
|
+
app = create_app(run_dir, runs_root=runs_root)
|
|
495
|
+
if open_browser:
|
|
496
|
+
import threading
|
|
497
|
+
import webbrowser
|
|
498
|
+
|
|
499
|
+
threading.Timer(0.8, lambda: webbrowser.open(f"http://{host}:{port}")).start()
|
|
500
|
+
from evalrx.term_links import hyperlink
|
|
501
|
+
|
|
502
|
+
print(f"Serving dynamic diagnostic report at {hyperlink(f'http://{host}:{port}')}")
|
|
503
|
+
if run_dir is None:
|
|
504
|
+
print("No run loaded yet — drop a zipped run directory on the page to open one.")
|
|
505
|
+
uvicorn.run(app, host=host, port=port, log_level="warning")
|
|
506
|
+
return 0
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _resolve_media(root: Path, value: str) -> Path | None:
|
|
510
|
+
candidate = Path(value)
|
|
511
|
+
allowed_roots = [root, root.parent]
|
|
512
|
+
possibilities = [candidate] if candidate.is_absolute() else [
|
|
513
|
+
root / candidate,
|
|
514
|
+
root.parent / candidate,
|
|
515
|
+
root.parent / "data" / candidate,
|
|
516
|
+
]
|
|
517
|
+
for possibility in possibilities:
|
|
518
|
+
try:
|
|
519
|
+
resolved = possibility.resolve()
|
|
520
|
+
except OSError:
|
|
521
|
+
continue
|
|
522
|
+
if not resolved.is_file():
|
|
523
|
+
continue
|
|
524
|
+
# Absolute case refs are allowed locally only when they belong to the
|
|
525
|
+
# run or its enclosing example. Langfuse caches always materialize under
|
|
526
|
+
# the run root and therefore pass the same check.
|
|
527
|
+
if any(resolved == base or base in resolved.parents for base in allowed_roots):
|
|
528
|
+
return resolved
|
|
529
|
+
return None
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _resolve_indexed_media(root: Path, value: str) -> Path | None:
|
|
533
|
+
"""Resolve report-indexed case media without opening arbitrary paths.
|
|
534
|
+
|
|
535
|
+
Case assets may live at ``<example>/data`` while a run writes its log to
|
|
536
|
+
``<example>/outputs*/logs``. The media index was built from saved case
|
|
537
|
+
records, so this endpoint permits only that nearby data directory in
|
|
538
|
+
addition to the usual run-owned roots. ``/api/artifact`` deliberately
|
|
539
|
+
does not use this broader resolver.
|
|
540
|
+
"""
|
|
541
|
+
resolved = _resolve_media(root, value)
|
|
542
|
+
if resolved is not None:
|
|
543
|
+
return resolved
|
|
544
|
+
candidate = Path(value)
|
|
545
|
+
possibilities = [candidate] if candidate.is_absolute() else [
|
|
546
|
+
root.parent / "data" / candidate,
|
|
547
|
+
root.parent.parent / "data" / candidate,
|
|
548
|
+
]
|
|
549
|
+
allowed_roots = [root.parent / "data", root.parent.parent / "data"]
|
|
550
|
+
for possibility in possibilities:
|
|
551
|
+
try:
|
|
552
|
+
path = possibility.resolve()
|
|
553
|
+
except OSError:
|
|
554
|
+
continue
|
|
555
|
+
if path.is_file() and any(base.resolve() in path.parents for base in allowed_roots if base.is_dir()):
|
|
556
|
+
return path
|
|
557
|
+
return None
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Stage semantics for EvalRX diagnostic reports."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class StageSpec:
|
|
10
|
+
id: str
|
|
11
|
+
name: str
|
|
12
|
+
question: str
|
|
13
|
+
artifacts: str
|
|
14
|
+
dashboard_role: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
STAGE_SPECS: tuple[StageSpec, ...] = (
|
|
18
|
+
StageSpec(
|
|
19
|
+
id="M1",
|
|
20
|
+
name="Measurement",
|
|
21
|
+
question="What per-case signals did the evaluators/analyzers extract?",
|
|
22
|
+
artifacts="Frozen per-case feature matrix, analyzer outputs, attention/probe artifacts.",
|
|
23
|
+
dashboard_role="Problem Setting: defines the dataset and available signals.",
|
|
24
|
+
),
|
|
25
|
+
StageSpec(
|
|
26
|
+
id="M2",
|
|
27
|
+
name="Exploratory analysis",
|
|
28
|
+
question="Which signals are worth investigating, and how do they relate to the outcome?",
|
|
29
|
+
artifacts="Effect sizes, relationship charts/tables — descriptive, no validity verdict.",
|
|
30
|
+
dashboard_role="Analysis: method, evidence, chart, and takeaway (no supported/not-supported "
|
|
31
|
+
"claim; a confirm phase with e-BH/FDR is a separate, currently out-of-scope step).",
|
|
32
|
+
),
|
|
33
|
+
StageSpec(
|
|
34
|
+
id="M3",
|
|
35
|
+
name="Hypothesis generation",
|
|
36
|
+
question="What falsifiable failure mechanisms explain the confirmed signals?",
|
|
37
|
+
artifacts="Hypotheses, failure modes, cited M2 charts/observations.",
|
|
38
|
+
dashboard_role="Hypotheses: candidate mechanisms linked back to evidence.",
|
|
39
|
+
),
|
|
40
|
+
StageSpec(
|
|
41
|
+
id="M4",
|
|
42
|
+
name="Hypothesis validation",
|
|
43
|
+
question="Does corrected statistical evidence and protocol consistency support the hypothesis?",
|
|
44
|
+
artifacts="Held-out verdicts, adjudication records, and evidence grades.",
|
|
45
|
+
dashboard_role="Validate hypotheses: the gate before intervention or repair.",
|
|
46
|
+
),
|
|
47
|
+
StageSpec(
|
|
48
|
+
id="M5",
|
|
49
|
+
name="Intervention & repair",
|
|
50
|
+
question="Does a targeted intervention repair failures without unacceptable regressions?",
|
|
51
|
+
artifacts="Intervention records, repair candidates, paired outcome comparisons.",
|
|
52
|
+
dashboard_role="Intervene & repair: causal experiments and the repair sweep after validation.",
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def stage_specs_as_dicts() -> list[dict[str, str]]:
|
|
58
|
+
return [spec.__dict__.copy() for spec in STAGE_SPECS]
|