Spaces:
Sleeping
Sleeping
Upload folder using huggingface_hub
Browse files- commercial_fci_infographic_pipeline/__init__.py +3 -0
- commercial_fci_infographic_pipeline/pipeline.py +0 -0
- commercial_fci_infographic_pipeline/run.py +249 -0
- commercial_fci_infographic_pipeline/run_baseline.py +209 -0
- commercial_fci_infographic_pipeline/run_experiments.py +294 -0
- commercial_fci_infographic_pipeline/viewer.py +1621 -0
commercial_fci_infographic_pipeline/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .pipeline import PipelineConfig, run_baseline_batch, run_baseline_single, run_batch, run_single
|
| 2 |
+
|
| 3 |
+
__all__ = ["PipelineConfig", "run_batch", "run_single", "run_baseline_batch", "run_baseline_single"]
|
commercial_fci_infographic_pipeline/pipeline.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
commercial_fci_infographic_pipeline/run.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .pipeline import DEFAULT_INPUT_DIR, PipelineConfig, run_batch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_args() -> argparse.Namespace:
|
| 10 |
+
parser = argparse.ArgumentParser(
|
| 11 |
+
description="Build foreground-conditioned infographic composites from chart PNG assets."
|
| 12 |
+
)
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--input-dir",
|
| 15 |
+
type=Path,
|
| 16 |
+
default=DEFAULT_INPUT_DIR,
|
| 17 |
+
help="Directory containing chart PNGs or a manifest.jsonl with final_png/chart_png records.",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument(
|
| 20 |
+
"--output-dir",
|
| 21 |
+
type=Path,
|
| 22 |
+
default=PipelineConfig.output_dir,
|
| 23 |
+
help="Directory for condition packages, candidates, final composites, and reports.",
|
| 24 |
+
)
|
| 25 |
+
parser.add_argument(
|
| 26 |
+
"--prompt",
|
| 27 |
+
default=PipelineConfig.user_prompt,
|
| 28 |
+
help="Target infographic scene prompt.",
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--limit",
|
| 32 |
+
type=int,
|
| 33 |
+
default=None,
|
| 34 |
+
help="Maximum number of chart assets to process.",
|
| 35 |
+
)
|
| 36 |
+
parser.add_argument(
|
| 37 |
+
"--template-filter",
|
| 38 |
+
action="append",
|
| 39 |
+
default=[],
|
| 40 |
+
help="Case-insensitive substring filter for template_key or asset_id. Can be passed multiple times.",
|
| 41 |
+
)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--sample-index",
|
| 44 |
+
action="append",
|
| 45 |
+
type=int,
|
| 46 |
+
default=[],
|
| 47 |
+
help="Only include this sample_index. Can be passed multiple times.",
|
| 48 |
+
)
|
| 49 |
+
parser.add_argument(
|
| 50 |
+
"--candidates",
|
| 51 |
+
type=int,
|
| 52 |
+
default=PipelineConfig.candidates_per_chart,
|
| 53 |
+
help="Number of prompt variants/candidates per chart.",
|
| 54 |
+
)
|
| 55 |
+
parser.add_argument(
|
| 56 |
+
"--workers",
|
| 57 |
+
type=int,
|
| 58 |
+
default=PipelineConfig.workers,
|
| 59 |
+
help="Number of chart assets to process concurrently.",
|
| 60 |
+
)
|
| 61 |
+
parser.add_argument(
|
| 62 |
+
"--canvas-width",
|
| 63 |
+
type=int,
|
| 64 |
+
default=PipelineConfig.canvas_width,
|
| 65 |
+
)
|
| 66 |
+
parser.add_argument(
|
| 67 |
+
"--canvas-height",
|
| 68 |
+
type=int,
|
| 69 |
+
default=PipelineConfig.canvas_height,
|
| 70 |
+
)
|
| 71 |
+
parser.add_argument(
|
| 72 |
+
"--max-chart-width-ratio",
|
| 73 |
+
type=float,
|
| 74 |
+
default=PipelineConfig.max_chart_width_ratio,
|
| 75 |
+
help="Maximum chart width as a fraction of canvas width.",
|
| 76 |
+
)
|
| 77 |
+
parser.add_argument(
|
| 78 |
+
"--max-chart-height-ratio",
|
| 79 |
+
type=float,
|
| 80 |
+
default=PipelineConfig.max_chart_height_ratio,
|
| 81 |
+
help="Maximum chart height as a fraction of canvas height.",
|
| 82 |
+
)
|
| 83 |
+
parser.add_argument(
|
| 84 |
+
"--foreground-top-ratio",
|
| 85 |
+
type=float,
|
| 86 |
+
default=PipelineConfig.foreground_top_ratio,
|
| 87 |
+
help="Preferred chart top offset as a fraction of canvas height.",
|
| 88 |
+
)
|
| 89 |
+
parser.add_argument(
|
| 90 |
+
"--foreground-bottom-margin-ratio",
|
| 91 |
+
type=float,
|
| 92 |
+
default=PipelineConfig.foreground_bottom_margin_ratio,
|
| 93 |
+
help="Minimum chart bottom margin as a fraction of canvas height.",
|
| 94 |
+
)
|
| 95 |
+
parser.add_argument(
|
| 96 |
+
"--protected-layout-margin",
|
| 97 |
+
type=int,
|
| 98 |
+
default=PipelineConfig.protected_layout_margin_px,
|
| 99 |
+
help="Extra no-overlap margin around the fixed foreground bbox.",
|
| 100 |
+
)
|
| 101 |
+
parser.add_argument(
|
| 102 |
+
"--alpha-mode",
|
| 103 |
+
choices=["opaque", "rgba", "background-key", "auto"],
|
| 104 |
+
default=PipelineConfig.foreground_alpha_mode,
|
| 105 |
+
help="How to derive foreground alpha from chart images.",
|
| 106 |
+
)
|
| 107 |
+
parser.add_argument(
|
| 108 |
+
"--prefer-svg",
|
| 109 |
+
action="store_true",
|
| 110 |
+
help=(
|
| 111 |
+
"Render from SVG when an SVG is available, even if a PNG exists. "
|
| 112 |
+
"Transparent SVG renders preserve alpha automatically when --alpha-mode is left as opaque."
|
| 113 |
+
),
|
| 114 |
+
)
|
| 115 |
+
parser.add_argument(
|
| 116 |
+
"--keep-best-only",
|
| 117 |
+
action="store_true",
|
| 118 |
+
help="Remove non-best candidate directories after reranking.",
|
| 119 |
+
)
|
| 120 |
+
parser.add_argument(
|
| 121 |
+
"--agent-model",
|
| 122 |
+
default=PipelineConfig.agent_model,
|
| 123 |
+
help="OpenAI text/vision model for analyzer, planner, prompt ensemble, and critic agents.",
|
| 124 |
+
)
|
| 125 |
+
parser.add_argument(
|
| 126 |
+
"--agent-reasoning-effort",
|
| 127 |
+
default=PipelineConfig.agent_reasoning_effort,
|
| 128 |
+
help="Reasoning effort passed to GPT-backed agents when supported.",
|
| 129 |
+
)
|
| 130 |
+
parser.add_argument(
|
| 131 |
+
"--generator",
|
| 132 |
+
choices=["gpt-image-2", "openai"],
|
| 133 |
+
default=PipelineConfig.generator,
|
| 134 |
+
help="Candidate generator adapter.",
|
| 135 |
+
)
|
| 136 |
+
parser.add_argument(
|
| 137 |
+
"--openai-model",
|
| 138 |
+
default=PipelineConfig.openai_model,
|
| 139 |
+
help="OpenAI image model name used by the GPT-Image adapter.",
|
| 140 |
+
)
|
| 141 |
+
parser.add_argument(
|
| 142 |
+
"--openai-quality",
|
| 143 |
+
default=PipelineConfig.openai_quality,
|
| 144 |
+
help="OpenAI image quality parameter.",
|
| 145 |
+
)
|
| 146 |
+
parser.add_argument(
|
| 147 |
+
"--openai-api-key-env",
|
| 148 |
+
default=PipelineConfig.openai_api_key_env,
|
| 149 |
+
help="Environment variable that contains the OpenAI API key.",
|
| 150 |
+
)
|
| 151 |
+
parser.add_argument(
|
| 152 |
+
"--openai-timeout",
|
| 153 |
+
type=float,
|
| 154 |
+
default=PipelineConfig.openai_timeout_seconds,
|
| 155 |
+
help="OpenAI request timeout in seconds.",
|
| 156 |
+
)
|
| 157 |
+
parser.add_argument(
|
| 158 |
+
"--openai-max-retries",
|
| 159 |
+
type=int,
|
| 160 |
+
default=PipelineConfig.openai_max_retries,
|
| 161 |
+
help="OpenAI SDK retry count for transient failures.",
|
| 162 |
+
)
|
| 163 |
+
parser.add_argument(
|
| 164 |
+
"--openai-base-url",
|
| 165 |
+
default=PipelineConfig.openai_base_url,
|
| 166 |
+
help="Optional OpenAI-compatible base URL or gateway endpoint.",
|
| 167 |
+
)
|
| 168 |
+
parser.add_argument("--experiment-id", default=PipelineConfig.experiment_id)
|
| 169 |
+
parser.add_argument("--variant-id", default=PipelineConfig.variant_id)
|
| 170 |
+
parser.add_argument(
|
| 171 |
+
"--disable-protected-sanitize",
|
| 172 |
+
action="store_true",
|
| 173 |
+
help="Ablation: do not clean the protected layout zone before restoring the chart.",
|
| 174 |
+
)
|
| 175 |
+
parser.add_argument(
|
| 176 |
+
"--disable-foreground-restore",
|
| 177 |
+
action="store_true",
|
| 178 |
+
help="Ablation: use the generated/sanitized candidate as final output without pasting back the original chart.",
|
| 179 |
+
)
|
| 180 |
+
parser.add_argument(
|
| 181 |
+
"--quality-eval",
|
| 182 |
+
action="store_true",
|
| 183 |
+
help="Run an additional GPT quality evaluator for every candidate.",
|
| 184 |
+
)
|
| 185 |
+
parser.add_argument(
|
| 186 |
+
"--quality-eval-model",
|
| 187 |
+
default=PipelineConfig.quality_eval_model,
|
| 188 |
+
help="Model for the additional quality evaluator. Defaults to --agent-model.",
|
| 189 |
+
)
|
| 190 |
+
parser.add_argument(
|
| 191 |
+
"--rank-by",
|
| 192 |
+
choices=[
|
| 193 |
+
"overall",
|
| 194 |
+
"gpt_quality_overall",
|
| 195 |
+
"chart_change_score",
|
| 196 |
+
"chart_faithfulness_score",
|
| 197 |
+
"infographic_quality_score",
|
| 198 |
+
],
|
| 199 |
+
default=PipelineConfig.rank_by,
|
| 200 |
+
help="Candidate selection score. chart_change_score selects the lowest chart change.",
|
| 201 |
+
)
|
| 202 |
+
return parser.parse_args()
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def main() -> None:
|
| 206 |
+
args = parse_args()
|
| 207 |
+
config = PipelineConfig(
|
| 208 |
+
input_dir=args.input_dir,
|
| 209 |
+
output_dir=args.output_dir,
|
| 210 |
+
user_prompt=args.prompt,
|
| 211 |
+
canvas_width=args.canvas_width,
|
| 212 |
+
canvas_height=args.canvas_height,
|
| 213 |
+
max_chart_width_ratio=args.max_chart_width_ratio,
|
| 214 |
+
max_chart_height_ratio=args.max_chart_height_ratio,
|
| 215 |
+
foreground_top_ratio=args.foreground_top_ratio,
|
| 216 |
+
foreground_bottom_margin_ratio=args.foreground_bottom_margin_ratio,
|
| 217 |
+
protected_layout_margin_px=args.protected_layout_margin,
|
| 218 |
+
foreground_alpha_mode=args.alpha_mode,
|
| 219 |
+
candidates_per_chart=args.candidates,
|
| 220 |
+
max_charts=args.limit,
|
| 221 |
+
workers=args.workers,
|
| 222 |
+
template_filters=tuple(args.template_filter),
|
| 223 |
+
sample_indices=tuple(args.sample_index),
|
| 224 |
+
prefer_svg_source=args.prefer_svg,
|
| 225 |
+
save_all_candidates=not args.keep_best_only,
|
| 226 |
+
agent_model=args.agent_model,
|
| 227 |
+
agent_reasoning_effort=args.agent_reasoning_effort,
|
| 228 |
+
generator=args.generator,
|
| 229 |
+
openai_model=args.openai_model,
|
| 230 |
+
openai_quality=args.openai_quality,
|
| 231 |
+
openai_api_key_env=args.openai_api_key_env,
|
| 232 |
+
openai_timeout_seconds=args.openai_timeout,
|
| 233 |
+
openai_max_retries=args.openai_max_retries,
|
| 234 |
+
openai_base_url=args.openai_base_url,
|
| 235 |
+
experiment_id=args.experiment_id,
|
| 236 |
+
variant_id=args.variant_id,
|
| 237 |
+
pipeline_mode="full",
|
| 238 |
+
sanitize_protected_zone=not args.disable_protected_sanitize,
|
| 239 |
+
restore_foreground_enabled=not args.disable_foreground_restore,
|
| 240 |
+
gpt_quality_eval=args.quality_eval,
|
| 241 |
+
quality_eval_model=args.quality_eval_model,
|
| 242 |
+
rank_by=args.rank_by,
|
| 243 |
+
)
|
| 244 |
+
reports = run_batch(config)
|
| 245 |
+
print(f"Completed {len(reports)} chart assets. Batch report: {config.output_dir / 'batch_report.json'}")
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
if __name__ == "__main__":
|
| 249 |
+
main()
|
commercial_fci_infographic_pipeline/run_baseline.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from .pipeline import DEFAULT_INPUT_DIR, PipelineConfig, run_baseline_batch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def parse_args() -> argparse.Namespace:
|
| 10 |
+
parser = argparse.ArgumentParser(
|
| 11 |
+
description="Baseline: send a transparent chart PNG directly to GPT-Image and restore the original chart."
|
| 12 |
+
)
|
| 13 |
+
parser.add_argument(
|
| 14 |
+
"--input-dir",
|
| 15 |
+
type=Path,
|
| 16 |
+
default=DEFAULT_INPUT_DIR,
|
| 17 |
+
help="Directory containing chart PNGs/SVGs or a manifest.jsonl.",
|
| 18 |
+
)
|
| 19 |
+
parser.add_argument(
|
| 20 |
+
"--output-dir",
|
| 21 |
+
type=Path,
|
| 22 |
+
default=Path("output/commercial_fci_baseline_outputs"),
|
| 23 |
+
help="Directory for baseline transparent inputs, candidates, final composites, and reports.",
|
| 24 |
+
)
|
| 25 |
+
parser.add_argument(
|
| 26 |
+
"--prompt",
|
| 27 |
+
default=PipelineConfig.user_prompt,
|
| 28 |
+
help="Target infographic style prompt.",
|
| 29 |
+
)
|
| 30 |
+
parser.add_argument("--limit", type=int, default=None, help="Maximum number of chart assets to process.")
|
| 31 |
+
parser.add_argument(
|
| 32 |
+
"--template-filter",
|
| 33 |
+
action="append",
|
| 34 |
+
default=[],
|
| 35 |
+
help="Case-insensitive substring filter for template_key or asset_id. Can be passed multiple times.",
|
| 36 |
+
)
|
| 37 |
+
parser.add_argument(
|
| 38 |
+
"--sample-index",
|
| 39 |
+
action="append",
|
| 40 |
+
type=int,
|
| 41 |
+
default=[],
|
| 42 |
+
help="Only include this sample_index. Can be passed multiple times.",
|
| 43 |
+
)
|
| 44 |
+
parser.add_argument(
|
| 45 |
+
"--candidates",
|
| 46 |
+
type=int,
|
| 47 |
+
default=1,
|
| 48 |
+
help="Number of direct GPT-Image candidates per chart.",
|
| 49 |
+
)
|
| 50 |
+
parser.add_argument(
|
| 51 |
+
"--workers",
|
| 52 |
+
type=int,
|
| 53 |
+
default=1,
|
| 54 |
+
help="Number of chart assets to process concurrently.",
|
| 55 |
+
)
|
| 56 |
+
parser.add_argument("--canvas-width", type=int, default=PipelineConfig.canvas_width)
|
| 57 |
+
parser.add_argument("--canvas-height", type=int, default=PipelineConfig.canvas_height)
|
| 58 |
+
parser.add_argument(
|
| 59 |
+
"--max-chart-width-ratio",
|
| 60 |
+
type=float,
|
| 61 |
+
default=PipelineConfig.max_chart_width_ratio,
|
| 62 |
+
help="Maximum chart width as a fraction of canvas width.",
|
| 63 |
+
)
|
| 64 |
+
parser.add_argument(
|
| 65 |
+
"--max-chart-height-ratio",
|
| 66 |
+
type=float,
|
| 67 |
+
default=PipelineConfig.max_chart_height_ratio,
|
| 68 |
+
help="Maximum chart height as a fraction of canvas height.",
|
| 69 |
+
)
|
| 70 |
+
parser.add_argument(
|
| 71 |
+
"--foreground-top-ratio",
|
| 72 |
+
type=float,
|
| 73 |
+
default=PipelineConfig.foreground_top_ratio,
|
| 74 |
+
help="Preferred chart top offset as a fraction of canvas height.",
|
| 75 |
+
)
|
| 76 |
+
parser.add_argument(
|
| 77 |
+
"--foreground-bottom-margin-ratio",
|
| 78 |
+
type=float,
|
| 79 |
+
default=PipelineConfig.foreground_bottom_margin_ratio,
|
| 80 |
+
help="Minimum chart bottom margin as a fraction of canvas height.",
|
| 81 |
+
)
|
| 82 |
+
parser.add_argument(
|
| 83 |
+
"--protected-layout-margin",
|
| 84 |
+
type=int,
|
| 85 |
+
default=PipelineConfig.protected_layout_margin_px,
|
| 86 |
+
help="No-overlap margin around the fixed chart bbox described to GPT-Image.",
|
| 87 |
+
)
|
| 88 |
+
parser.add_argument(
|
| 89 |
+
"--alpha-mode",
|
| 90 |
+
choices=["opaque", "rgba", "background-key", "auto"],
|
| 91 |
+
default=PipelineConfig.foreground_alpha_mode,
|
| 92 |
+
help="How to derive foreground alpha from chart images.",
|
| 93 |
+
)
|
| 94 |
+
parser.add_argument(
|
| 95 |
+
"--prefer-svg",
|
| 96 |
+
action="store_true",
|
| 97 |
+
help="Render from SVG when an SVG is available, even if a PNG exists.",
|
| 98 |
+
)
|
| 99 |
+
parser.add_argument(
|
| 100 |
+
"--keep-best-only",
|
| 101 |
+
action="store_true",
|
| 102 |
+
help="Remove non-best candidate directories after deterministic foreground scoring.",
|
| 103 |
+
)
|
| 104 |
+
parser.add_argument(
|
| 105 |
+
"--generator",
|
| 106 |
+
choices=["gpt-image-2", "openai"],
|
| 107 |
+
default=PipelineConfig.generator,
|
| 108 |
+
help="Candidate generator adapter.",
|
| 109 |
+
)
|
| 110 |
+
parser.add_argument(
|
| 111 |
+
"--openai-model",
|
| 112 |
+
default=PipelineConfig.openai_model,
|
| 113 |
+
help="OpenAI image model name used by the GPT-Image adapter.",
|
| 114 |
+
)
|
| 115 |
+
parser.add_argument(
|
| 116 |
+
"--openai-quality",
|
| 117 |
+
default=PipelineConfig.openai_quality,
|
| 118 |
+
help="OpenAI image quality parameter.",
|
| 119 |
+
)
|
| 120 |
+
parser.add_argument(
|
| 121 |
+
"--openai-api-key-env",
|
| 122 |
+
default=PipelineConfig.openai_api_key_env,
|
| 123 |
+
help="Environment variable that contains the OpenAI API key.",
|
| 124 |
+
)
|
| 125 |
+
parser.add_argument(
|
| 126 |
+
"--openai-timeout",
|
| 127 |
+
type=float,
|
| 128 |
+
default=PipelineConfig.openai_timeout_seconds,
|
| 129 |
+
help="OpenAI request timeout in seconds.",
|
| 130 |
+
)
|
| 131 |
+
parser.add_argument(
|
| 132 |
+
"--openai-max-retries",
|
| 133 |
+
type=int,
|
| 134 |
+
default=PipelineConfig.openai_max_retries,
|
| 135 |
+
help="OpenAI SDK retry count for transient failures.",
|
| 136 |
+
)
|
| 137 |
+
parser.add_argument(
|
| 138 |
+
"--openai-base-url",
|
| 139 |
+
default=PipelineConfig.openai_base_url,
|
| 140 |
+
help="Optional OpenAI-compatible base URL or gateway endpoint.",
|
| 141 |
+
)
|
| 142 |
+
parser.add_argument("--experiment-id", default=PipelineConfig.experiment_id)
|
| 143 |
+
parser.add_argument("--variant-id", default=PipelineConfig.variant_id)
|
| 144 |
+
parser.add_argument(
|
| 145 |
+
"--quality-eval",
|
| 146 |
+
action="store_true",
|
| 147 |
+
help="Run an additional GPT quality evaluator for every baseline candidate.",
|
| 148 |
+
)
|
| 149 |
+
parser.add_argument(
|
| 150 |
+
"--quality-eval-model",
|
| 151 |
+
default=PipelineConfig.quality_eval_model,
|
| 152 |
+
help="Model for the additional quality evaluator. Defaults to PipelineConfig.agent_model.",
|
| 153 |
+
)
|
| 154 |
+
parser.add_argument(
|
| 155 |
+
"--rank-by",
|
| 156 |
+
choices=[
|
| 157 |
+
"overall",
|
| 158 |
+
"gpt_quality_overall",
|
| 159 |
+
"chart_change_score",
|
| 160 |
+
"chart_faithfulness_score",
|
| 161 |
+
"infographic_quality_score",
|
| 162 |
+
],
|
| 163 |
+
default=PipelineConfig.rank_by,
|
| 164 |
+
help="Candidate selection score. chart_change_score selects the lowest chart change.",
|
| 165 |
+
)
|
| 166 |
+
return parser.parse_args()
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def main() -> None:
|
| 170 |
+
args = parse_args()
|
| 171 |
+
config = PipelineConfig(
|
| 172 |
+
input_dir=args.input_dir,
|
| 173 |
+
output_dir=args.output_dir,
|
| 174 |
+
user_prompt=args.prompt,
|
| 175 |
+
canvas_width=args.canvas_width,
|
| 176 |
+
canvas_height=args.canvas_height,
|
| 177 |
+
max_chart_width_ratio=args.max_chart_width_ratio,
|
| 178 |
+
max_chart_height_ratio=args.max_chart_height_ratio,
|
| 179 |
+
foreground_top_ratio=args.foreground_top_ratio,
|
| 180 |
+
foreground_bottom_margin_ratio=args.foreground_bottom_margin_ratio,
|
| 181 |
+
protected_layout_margin_px=args.protected_layout_margin,
|
| 182 |
+
foreground_alpha_mode=args.alpha_mode,
|
| 183 |
+
candidates_per_chart=args.candidates,
|
| 184 |
+
max_charts=args.limit,
|
| 185 |
+
workers=args.workers,
|
| 186 |
+
template_filters=tuple(args.template_filter),
|
| 187 |
+
sample_indices=tuple(args.sample_index),
|
| 188 |
+
prefer_svg_source=args.prefer_svg,
|
| 189 |
+
save_all_candidates=not args.keep_best_only,
|
| 190 |
+
generator=args.generator,
|
| 191 |
+
openai_model=args.openai_model,
|
| 192 |
+
openai_quality=args.openai_quality,
|
| 193 |
+
openai_api_key_env=args.openai_api_key_env,
|
| 194 |
+
openai_timeout_seconds=args.openai_timeout,
|
| 195 |
+
openai_max_retries=args.openai_max_retries,
|
| 196 |
+
openai_base_url=args.openai_base_url,
|
| 197 |
+
experiment_id=args.experiment_id,
|
| 198 |
+
variant_id=args.variant_id,
|
| 199 |
+
pipeline_mode="baseline_transparent_chart_direct",
|
| 200 |
+
gpt_quality_eval=args.quality_eval,
|
| 201 |
+
quality_eval_model=args.quality_eval_model,
|
| 202 |
+
rank_by=args.rank_by,
|
| 203 |
+
)
|
| 204 |
+
reports = run_baseline_batch(config)
|
| 205 |
+
print(f"Completed {len(reports)} baseline chart assets. Batch report: {config.output_dir / 'batch_report.json'}")
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
if __name__ == "__main__":
|
| 209 |
+
main()
|
commercial_fci_infographic_pipeline/run_experiments.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import json
|
| 5 |
+
import time
|
| 6 |
+
import traceback
|
| 7 |
+
from dataclasses import fields, replace
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
from .pipeline import DEFAULT_INPUT_DIR, PipelineConfig, run_baseline_batch, run_batch, write_json
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
PATH_FIELDS = {"input_dir", "output_dir"}
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def parse_args() -> argparse.Namespace:
|
| 18 |
+
parser = argparse.ArgumentParser(
|
| 19 |
+
description="Run FCI infographic ablation variants and summarize deterministic/GPT quality metrics."
|
| 20 |
+
)
|
| 21 |
+
parser.add_argument("--plan", type=Path, help="Optional JSON experiment plan.")
|
| 22 |
+
parser.add_argument(
|
| 23 |
+
"--preset",
|
| 24 |
+
choices=["baseline_vs_full", "core", "full_only", "baseline_only"],
|
| 25 |
+
default="baseline_vs_full",
|
| 26 |
+
help="Built-in variant matrix used when --plan is not provided.",
|
| 27 |
+
)
|
| 28 |
+
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_DIR)
|
| 29 |
+
parser.add_argument(
|
| 30 |
+
"--output-dir",
|
| 31 |
+
type=Path,
|
| 32 |
+
default=None,
|
| 33 |
+
help="Experiment root. Defaults to output/fci_infographic_experiments_<timestamp>.",
|
| 34 |
+
)
|
| 35 |
+
parser.add_argument("--prompt", default=PipelineConfig.user_prompt)
|
| 36 |
+
parser.add_argument("--limit", type=int, default=None)
|
| 37 |
+
parser.add_argument("--template-filter", action="append", default=[])
|
| 38 |
+
parser.add_argument("--sample-index", action="append", type=int, default=[])
|
| 39 |
+
parser.add_argument("--candidates", type=int, default=1)
|
| 40 |
+
parser.add_argument("--workers", type=int, default=1)
|
| 41 |
+
parser.add_argument("--prefer-svg", action="store_true")
|
| 42 |
+
parser.add_argument("--alpha-mode", choices=["opaque", "rgba", "background-key", "auto"], default=PipelineConfig.foreground_alpha_mode)
|
| 43 |
+
parser.add_argument("--agent-model", default=PipelineConfig.agent_model)
|
| 44 |
+
parser.add_argument("--agent-reasoning-effort", default="low")
|
| 45 |
+
parser.add_argument("--generator", choices=["gpt-image-2", "openai"], default=PipelineConfig.generator)
|
| 46 |
+
parser.add_argument("--openai-model", default=PipelineConfig.openai_model)
|
| 47 |
+
parser.add_argument("--openai-quality", default=PipelineConfig.openai_quality)
|
| 48 |
+
parser.add_argument("--openai-api-key-env", default=PipelineConfig.openai_api_key_env)
|
| 49 |
+
parser.add_argument("--openai-timeout", type=float, default=PipelineConfig.openai_timeout_seconds)
|
| 50 |
+
parser.add_argument("--openai-max-retries", type=int, default=PipelineConfig.openai_max_retries)
|
| 51 |
+
parser.add_argument("--openai-base-url", default=PipelineConfig.openai_base_url)
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--no-quality-eval",
|
| 54 |
+
action="store_true",
|
| 55 |
+
help="Disable the additional GPT quality evaluator. Deterministic Change Score is still computed.",
|
| 56 |
+
)
|
| 57 |
+
parser.add_argument(
|
| 58 |
+
"--quality-eval-model",
|
| 59 |
+
default=PipelineConfig.quality_eval_model,
|
| 60 |
+
help="Model for quality evaluation. Defaults to --agent-model.",
|
| 61 |
+
)
|
| 62 |
+
parser.add_argument(
|
| 63 |
+
"--rank-by",
|
| 64 |
+
choices=[
|
| 65 |
+
"overall",
|
| 66 |
+
"gpt_quality_overall",
|
| 67 |
+
"chart_change_score",
|
| 68 |
+
"chart_faithfulness_score",
|
| 69 |
+
"infographic_quality_score",
|
| 70 |
+
],
|
| 71 |
+
default=PipelineConfig.rank_by,
|
| 72 |
+
)
|
| 73 |
+
return parser.parse_args()
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def main() -> None:
|
| 77 |
+
args = parse_args()
|
| 78 |
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
| 79 |
+
experiment_root = args.output_dir or Path(f"output/fci_infographic_experiments_{timestamp}")
|
| 80 |
+
experiment_root.mkdir(parents=True, exist_ok=True)
|
| 81 |
+
|
| 82 |
+
plan = load_plan(args.plan) if args.plan else None
|
| 83 |
+
experiment_id = str((plan or {}).get("name") or experiment_root.name)
|
| 84 |
+
base_config = build_base_config(args, experiment_root, experiment_id)
|
| 85 |
+
if plan and isinstance(plan.get("shared"), dict):
|
| 86 |
+
base_config = apply_config_overrides(base_config, plan["shared"])
|
| 87 |
+
variants = plan.get("variants", []) if plan else preset_variants(args.preset)
|
| 88 |
+
if not variants:
|
| 89 |
+
raise ValueError("Experiment plan does not contain variants.")
|
| 90 |
+
|
| 91 |
+
results: list[dict[str, Any]] = []
|
| 92 |
+
for variant in variants:
|
| 93 |
+
variant_id = slug_variant_id(str(variant.get("id") or variant.get("pipeline") or f"variant_{len(results):02d}"))
|
| 94 |
+
pipeline = str(variant.get("pipeline") or "full")
|
| 95 |
+
overrides = variant.get("overrides") if isinstance(variant.get("overrides"), dict) else {}
|
| 96 |
+
config = apply_config_overrides(
|
| 97 |
+
replace(
|
| 98 |
+
base_config,
|
| 99 |
+
output_dir=experiment_root / variant_id,
|
| 100 |
+
variant_id=variant_id,
|
| 101 |
+
pipeline_mode=str(overrides.get("pipeline_mode") or pipeline),
|
| 102 |
+
),
|
| 103 |
+
overrides,
|
| 104 |
+
)
|
| 105 |
+
started = time.time()
|
| 106 |
+
try:
|
| 107 |
+
if pipeline in {"baseline", "direct", "baseline_transparent_chart_direct"}:
|
| 108 |
+
config = replace(config, pipeline_mode="baseline_transparent_chart_direct")
|
| 109 |
+
reports = run_baseline_batch(config)
|
| 110 |
+
elif pipeline in {"full", "commercial_fci", "fci"}:
|
| 111 |
+
reports = run_batch(config)
|
| 112 |
+
else:
|
| 113 |
+
raise ValueError(f"Unknown experiment pipeline: {pipeline}")
|
| 114 |
+
results.append(
|
| 115 |
+
{
|
| 116 |
+
"variant_id": variant_id,
|
| 117 |
+
"pipeline": pipeline,
|
| 118 |
+
"output_dir": str(config.output_dir),
|
| 119 |
+
"elapsed_seconds": round(time.time() - started, 3),
|
| 120 |
+
"completed_count": len(reports),
|
| 121 |
+
"failed": False,
|
| 122 |
+
"metrics": summarize_reports(reports),
|
| 123 |
+
"reports": [report["export"]["report"] for report in reports],
|
| 124 |
+
}
|
| 125 |
+
)
|
| 126 |
+
except Exception as exc:
|
| 127 |
+
results.append(
|
| 128 |
+
{
|
| 129 |
+
"variant_id": variant_id,
|
| 130 |
+
"pipeline": pipeline,
|
| 131 |
+
"output_dir": str(config.output_dir),
|
| 132 |
+
"elapsed_seconds": round(time.time() - started, 3),
|
| 133 |
+
"completed_count": 0,
|
| 134 |
+
"failed": True,
|
| 135 |
+
"error_type": exc.__class__.__name__,
|
| 136 |
+
"message": str(exc),
|
| 137 |
+
"traceback": traceback.format_exc(),
|
| 138 |
+
}
|
| 139 |
+
)
|
| 140 |
+
write_experiment_summary(experiment_root, experiment_id, base_config, results)
|
| 141 |
+
|
| 142 |
+
print(f"Experiment complete. Summary: {experiment_root / 'experiment_summary.json'}")
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def load_plan(path: Path) -> dict[str, Any]:
|
| 146 |
+
with path.open("r", encoding="utf-8") as handle:
|
| 147 |
+
data = json.load(handle)
|
| 148 |
+
if not isinstance(data, dict):
|
| 149 |
+
raise ValueError("Experiment plan must be a JSON object.")
|
| 150 |
+
return data
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def build_base_config(args: argparse.Namespace, experiment_root: Path, experiment_id: str) -> PipelineConfig:
|
| 154 |
+
return PipelineConfig(
|
| 155 |
+
input_dir=args.input_dir,
|
| 156 |
+
output_dir=experiment_root,
|
| 157 |
+
user_prompt=args.prompt,
|
| 158 |
+
foreground_alpha_mode=args.alpha_mode,
|
| 159 |
+
candidates_per_chart=args.candidates,
|
| 160 |
+
max_charts=args.limit,
|
| 161 |
+
workers=args.workers,
|
| 162 |
+
template_filters=tuple(args.template_filter),
|
| 163 |
+
sample_indices=tuple(args.sample_index),
|
| 164 |
+
prefer_svg_source=args.prefer_svg,
|
| 165 |
+
agent_model=args.agent_model,
|
| 166 |
+
agent_reasoning_effort=args.agent_reasoning_effort,
|
| 167 |
+
generator=args.generator,
|
| 168 |
+
openai_model=args.openai_model,
|
| 169 |
+
openai_quality=args.openai_quality,
|
| 170 |
+
openai_api_key_env=args.openai_api_key_env,
|
| 171 |
+
openai_timeout_seconds=args.openai_timeout,
|
| 172 |
+
openai_max_retries=args.openai_max_retries,
|
| 173 |
+
openai_base_url=args.openai_base_url,
|
| 174 |
+
experiment_id=experiment_id,
|
| 175 |
+
gpt_quality_eval=not args.no_quality_eval,
|
| 176 |
+
quality_eval_model=args.quality_eval_model,
|
| 177 |
+
rank_by=args.rank_by,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def preset_variants(name: str) -> list[dict[str, Any]]:
|
| 182 |
+
if name == "baseline_only":
|
| 183 |
+
return [{"id": "baseline_direct", "pipeline": "baseline"}]
|
| 184 |
+
if name == "full_only":
|
| 185 |
+
return [{"id": "full", "pipeline": "full"}]
|
| 186 |
+
if name == "baseline_vs_full":
|
| 187 |
+
return [
|
| 188 |
+
{"id": "baseline_direct", "pipeline": "baseline"},
|
| 189 |
+
{"id": "full", "pipeline": "full"},
|
| 190 |
+
]
|
| 191 |
+
return [
|
| 192 |
+
{"id": "baseline_direct", "pipeline": "baseline"},
|
| 193 |
+
{"id": "full", "pipeline": "full"},
|
| 194 |
+
{
|
| 195 |
+
"id": "full_no_sanitize",
|
| 196 |
+
"pipeline": "full",
|
| 197 |
+
"overrides": {"sanitize_protected_zone": False, "pipeline_mode": "full_no_sanitize"},
|
| 198 |
+
},
|
| 199 |
+
{
|
| 200 |
+
"id": "full_no_restore",
|
| 201 |
+
"pipeline": "full",
|
| 202 |
+
"overrides": {"restore_foreground_enabled": False, "pipeline_mode": "full_no_restore"},
|
| 203 |
+
},
|
| 204 |
+
]
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def apply_config_overrides(config: PipelineConfig, overrides: dict[str, Any]) -> PipelineConfig:
|
| 208 |
+
known = {field.name for field in fields(PipelineConfig)}
|
| 209 |
+
updates: dict[str, Any] = {}
|
| 210 |
+
for key, value in overrides.items():
|
| 211 |
+
if key not in known:
|
| 212 |
+
raise ValueError(f"Unknown PipelineConfig override: {key}")
|
| 213 |
+
updates[key] = Path(value) if key in PATH_FIELDS and value is not None else value
|
| 214 |
+
return replace(config, **updates)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def summarize_reports(reports: list[dict[str, Any]]) -> dict[str, Any]:
|
| 218 |
+
best_candidates = [report.get("best_candidate", {}) for report in reports]
|
| 219 |
+
rows = []
|
| 220 |
+
for report, candidate in zip(reports, best_candidates):
|
| 221 |
+
measurements = ((candidate.get("metadata") or {}).get("measurements") or {})
|
| 222 |
+
chart_faithfulness = ((candidate.get("metadata") or {}).get("chart_faithfulness") or {})
|
| 223 |
+
infographic_quality = ((candidate.get("metadata") or {}).get("infographic_quality") or {})
|
| 224 |
+
quality = ((candidate.get("metadata") or {}).get("quality_evaluation") or {}).get("scores") or {}
|
| 225 |
+
rows.append(
|
| 226 |
+
{
|
| 227 |
+
"task_id": report.get("task_id"),
|
| 228 |
+
"overall": (candidate.get("score") or {}).get("overall"),
|
| 229 |
+
"chart_faithfulness_score": (candidate.get("score") or {}).get("chart_faithfulness_score")
|
| 230 |
+
or chart_faithfulness.get("score")
|
| 231 |
+
or measurements.get("chart_faithfulness_score"),
|
| 232 |
+
"infographic_quality_score": (candidate.get("score") or {}).get("infographic_quality_score")
|
| 233 |
+
or infographic_quality.get("score"),
|
| 234 |
+
"gpt_quality_overall": (candidate.get("score") or {}).get("gpt_quality_overall")
|
| 235 |
+
or quality.get("overall_quality"),
|
| 236 |
+
"chart_change_score": measurements.get("chart_change_score"),
|
| 237 |
+
"chart_pixel_change_score": measurements.get("chart_pixel_change_score"),
|
| 238 |
+
"chart_deformation_score": measurements.get("chart_deformation_score"),
|
| 239 |
+
"chart_displacement_score": measurements.get("chart_displacement_score"),
|
| 240 |
+
"chart_alignment_reliable": measurements.get("chart_alignment_reliable"),
|
| 241 |
+
}
|
| 242 |
+
)
|
| 243 |
+
return {
|
| 244 |
+
"count": len(rows),
|
| 245 |
+
"averages": {
|
| 246 |
+
"overall": average(row["overall"] for row in rows),
|
| 247 |
+
"chart_faithfulness_score": average(row["chart_faithfulness_score"] for row in rows),
|
| 248 |
+
"infographic_quality_score": average(row["infographic_quality_score"] for row in rows),
|
| 249 |
+
"gpt_quality_overall": average(row["gpt_quality_overall"] for row in rows),
|
| 250 |
+
"chart_change_score": average(row["chart_change_score"] for row in rows),
|
| 251 |
+
"chart_pixel_change_score": average(row["chart_pixel_change_score"] for row in rows),
|
| 252 |
+
"chart_deformation_score": average(row["chart_deformation_score"] for row in rows),
|
| 253 |
+
"chart_displacement_score": average(row["chart_displacement_score"] for row in rows),
|
| 254 |
+
},
|
| 255 |
+
"items": rows,
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
def write_experiment_summary(
|
| 260 |
+
experiment_root: Path,
|
| 261 |
+
experiment_id: str,
|
| 262 |
+
base_config: PipelineConfig,
|
| 263 |
+
results: list[dict[str, Any]],
|
| 264 |
+
) -> None:
|
| 265 |
+
write_json(
|
| 266 |
+
experiment_root / "experiment_summary.json",
|
| 267 |
+
{
|
| 268 |
+
"experiment_id": experiment_id,
|
| 269 |
+
"output_dir": str(experiment_root),
|
| 270 |
+
"input_dir": str(base_config.input_dir),
|
| 271 |
+
"quality_eval_model": base_config.quality_eval_model or base_config.agent_model,
|
| 272 |
+
"gpt_quality_eval": base_config.gpt_quality_eval,
|
| 273 |
+
"rank_by": base_config.rank_by,
|
| 274 |
+
"variants": results,
|
| 275 |
+
},
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def average(values: Any) -> float | None:
|
| 280 |
+
numeric = [float(value) for value in values if isinstance(value, (int, float))]
|
| 281 |
+
if not numeric:
|
| 282 |
+
return None
|
| 283 |
+
return round(sum(numeric) / len(numeric), 6)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def slug_variant_id(value: str) -> str:
|
| 287 |
+
cleaned = "".join(ch.lower() if ch.isalnum() else "_" for ch in value).strip("_")
|
| 288 |
+
while "__" in cleaned:
|
| 289 |
+
cleaned = cleaned.replace("__", "_")
|
| 290 |
+
return cleaned or "variant"
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
if __name__ == "__main__":
|
| 294 |
+
main()
|
commercial_fci_infographic_pipeline/viewer.py
ADDED
|
@@ -0,0 +1,1621 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import hashlib
|
| 5 |
+
import html
|
| 6 |
+
import json
|
| 7 |
+
import mimetypes
|
| 8 |
+
import re
|
| 9 |
+
import time
|
| 10 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from urllib.parse import parse_qs, unquote, urlparse
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
WORKSPACE_ROOT = Path(__file__).resolve().parents[1]
|
| 16 |
+
|
| 17 |
+
DEFAULT_OUTPUT_ROOTS: list[Path] = []
|
| 18 |
+
|
| 19 |
+
DEFAULT_SCAN_ROOTS = [
|
| 20 |
+
WORKSPACE_ROOT / "output",
|
| 21 |
+
WORKSPACE_ROOT / "shared_output/personal/liduan",
|
| 22 |
+
]
|
| 23 |
+
_INDEX_HTML_CACHE: tuple[int, int, str, str] | None = None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def parse_args() -> argparse.Namespace:
|
| 27 |
+
parser = argparse.ArgumentParser(description="Serve a small browser UI for FCI infographic outputs.")
|
| 28 |
+
parser.add_argument("--host", default="0.0.0.0", help="Bind host. Use 0.0.0.0 on a server.")
|
| 29 |
+
parser.add_argument("--port", type=int, default=8765, help="Bind port.")
|
| 30 |
+
parser.add_argument(
|
| 31 |
+
"--output-root",
|
| 32 |
+
action="append",
|
| 33 |
+
type=Path,
|
| 34 |
+
help="Pipeline output root. Can be passed multiple times. These are re-scanned on every refresh.",
|
| 35 |
+
)
|
| 36 |
+
parser.add_argument(
|
| 37 |
+
"--scan-root",
|
| 38 |
+
action="append",
|
| 39 |
+
type=Path,
|
| 40 |
+
help="Parent directory to scan for FCI infographic output roots. Defaults to ./output and shared_output/personal/liduan.",
|
| 41 |
+
)
|
| 42 |
+
parser.add_argument(
|
| 43 |
+
"--no-auto-discover",
|
| 44 |
+
action="store_true",
|
| 45 |
+
help="Only use explicit --output-root values and built-in defaults.",
|
| 46 |
+
)
|
| 47 |
+
parser.add_argument(
|
| 48 |
+
"--show-all-roots",
|
| 49 |
+
action="store_true",
|
| 50 |
+
help="Show every discovered experiment root. By default the viewer shows only the newest discovered root.",
|
| 51 |
+
)
|
| 52 |
+
parser.add_argument(
|
| 53 |
+
"--show-legacy",
|
| 54 |
+
action="store_true",
|
| 55 |
+
help="Include legacy non-experiment output roots such as old batch_report.json runs.",
|
| 56 |
+
)
|
| 57 |
+
return parser.parse_args()
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def safe_resolve(path: Path) -> Path:
|
| 61 |
+
return path.expanduser().resolve()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class ViewerState:
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
output_roots: list[Path],
|
| 68 |
+
scan_roots: list[Path] | None = None,
|
| 69 |
+
*,
|
| 70 |
+
auto_discover: bool = True,
|
| 71 |
+
show_all_roots: bool = False,
|
| 72 |
+
include_legacy: bool = False,
|
| 73 |
+
) -> None:
|
| 74 |
+
roots = DEFAULT_OUTPUT_ROOTS + output_roots
|
| 75 |
+
self.explicit_output_roots = [safe_resolve(root) for root in roots]
|
| 76 |
+
self.scan_roots = [safe_resolve(root) for root in (scan_roots or DEFAULT_SCAN_ROOTS)]
|
| 77 |
+
self.auto_discover = auto_discover
|
| 78 |
+
self.show_all_roots = show_all_roots
|
| 79 |
+
self.include_legacy = include_legacy
|
| 80 |
+
self._roots_cache: list[Path] = []
|
| 81 |
+
self._roots_cache_until = 0.0
|
| 82 |
+
|
| 83 |
+
def output_roots(self, *, force: bool = False) -> list[Path]:
|
| 84 |
+
now = time.time()
|
| 85 |
+
if not force and now < self._roots_cache_until:
|
| 86 |
+
return list(self._roots_cache)
|
| 87 |
+
|
| 88 |
+
roots: list[Path] = []
|
| 89 |
+
seen: set[Path] = set()
|
| 90 |
+
|
| 91 |
+
def add(root: Path) -> None:
|
| 92 |
+
resolved = safe_resolve(root)
|
| 93 |
+
if resolved in seen or not resolved.is_dir():
|
| 94 |
+
return
|
| 95 |
+
seen.add(resolved)
|
| 96 |
+
roots.append(resolved)
|
| 97 |
+
|
| 98 |
+
for root in self.explicit_output_roots:
|
| 99 |
+
add(root)
|
| 100 |
+
if self.auto_discover:
|
| 101 |
+
discovered = discover_output_roots(self.scan_roots, include_legacy=self.include_legacy)
|
| 102 |
+
if not self.show_all_roots:
|
| 103 |
+
discovered = discovered[:1]
|
| 104 |
+
for root in discovered:
|
| 105 |
+
add(root)
|
| 106 |
+
self._roots_cache = roots
|
| 107 |
+
self._roots_cache_until = now + 15.0
|
| 108 |
+
return roots
|
| 109 |
+
|
| 110 |
+
def reports(self, roots: list[Path] | None = None) -> list[dict]:
|
| 111 |
+
items: list[dict] = []
|
| 112 |
+
for root in roots or self.output_roots():
|
| 113 |
+
try:
|
| 114 |
+
children = report_task_dirs(root)
|
| 115 |
+
except OSError:
|
| 116 |
+
continue
|
| 117 |
+
for task_dir in children:
|
| 118 |
+
report = read_json_file(task_dir / "report.json") or {}
|
| 119 |
+
status = read_json_file(task_dir / "task_status.json") or {}
|
| 120 |
+
error = read_json_file(task_dir / "task_error.json") or None
|
| 121 |
+
if not report and not status:
|
| 122 |
+
continue
|
| 123 |
+
items.append(task_item(root, task_dir, report, status, error))
|
| 124 |
+
items.sort(
|
| 125 |
+
key=lambda item: (int(item.get("updated_mtime_ns") or 0), item["task_dir"]),
|
| 126 |
+
reverse=True,
|
| 127 |
+
)
|
| 128 |
+
return items
|
| 129 |
+
|
| 130 |
+
def resolve_asset(self, raw_path: str) -> Path | None:
|
| 131 |
+
if not raw_path:
|
| 132 |
+
return None
|
| 133 |
+
candidate = safe_resolve(Path(unquote(raw_path)))
|
| 134 |
+
for root in self.output_roots():
|
| 135 |
+
try:
|
| 136 |
+
candidate.relative_to(root)
|
| 137 |
+
except ValueError:
|
| 138 |
+
continue
|
| 139 |
+
if candidate.is_file():
|
| 140 |
+
return candidate
|
| 141 |
+
return None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def read_json_file(path: Path) -> dict | None:
|
| 145 |
+
try:
|
| 146 |
+
data = json.loads(path.read_text(encoding="utf-8"))
|
| 147 |
+
except (OSError, json.JSONDecodeError):
|
| 148 |
+
return None
|
| 149 |
+
return data if isinstance(data, dict) else None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def report_task_dirs(root: Path) -> list[Path]:
|
| 153 |
+
if (root / "experiment_summary.json").is_file():
|
| 154 |
+
task_dirs: list[Path] = []
|
| 155 |
+
for variant_dir in sorted((path for path in root.iterdir() if path.is_dir()), reverse=True):
|
| 156 |
+
task_dirs.extend(path for path in variant_dir.iterdir() if path.is_dir())
|
| 157 |
+
return task_dirs
|
| 158 |
+
return [path for path in root.iterdir() if path.is_dir()]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def task_item(root: Path, task_dir: Path, report: dict, status: dict, error: dict | None) -> dict:
|
| 162 |
+
runtime = report.get("runtime", {})
|
| 163 |
+
input_info = report.get("input", {})
|
| 164 |
+
stages = collect_stages(task_dir, report)
|
| 165 |
+
updated_mtime_ns = task_mtime_ns(task_dir, stages)
|
| 166 |
+
return {
|
| 167 |
+
"id": str(task_dir),
|
| 168 |
+
"version": task_version(stages),
|
| 169 |
+
"updated_mtime_ns": updated_mtime_ns,
|
| 170 |
+
"root": str(root),
|
| 171 |
+
"task_dir": str(task_dir),
|
| 172 |
+
"task_id": report.get("task_id") or status.get("task_id") or task_dir.name,
|
| 173 |
+
"state": status.get("state") or ("completed" if report else "unknown"),
|
| 174 |
+
"stage": status.get("stage"),
|
| 175 |
+
"error": error or status.get("error"),
|
| 176 |
+
"pipeline_mode": runtime.get("pipeline_mode"),
|
| 177 |
+
"experiment_id": runtime.get("experiment_id"),
|
| 178 |
+
"variant_id": runtime.get("variant_id"),
|
| 179 |
+
"agent_mode": runtime.get("agent_mode"),
|
| 180 |
+
"agent_model": runtime.get("agent_model"),
|
| 181 |
+
"generator": runtime.get("generator_mode"),
|
| 182 |
+
"source_kind": input_info.get("source_kind"),
|
| 183 |
+
"effective_alpha_mode": input_info.get("effective_alpha_mode"),
|
| 184 |
+
"elapsed_seconds": runtime.get("elapsed_seconds") or status.get("elapsed_seconds"),
|
| 185 |
+
"bbox": input_info.get("foreground_bbox"),
|
| 186 |
+
"best": summarize_candidate(report.get("best_candidate", {})),
|
| 187 |
+
"candidates": [
|
| 188 |
+
summarize_candidate(candidate)
|
| 189 |
+
for candidate in report.get("all_candidates", [])
|
| 190 |
+
],
|
| 191 |
+
"images": collect_images(task_dir, report),
|
| 192 |
+
"stages": stages,
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def task_mtime_ns(task_dir: Path, stages: list[dict]) -> int:
|
| 197 |
+
latest = path_mtime_ns(task_dir)
|
| 198 |
+
for filename in ("task_status.json", "report.json", "task_error.json", "final_composite.png"):
|
| 199 |
+
latest = max(latest, path_mtime_ns(task_dir / filename))
|
| 200 |
+
for stage in stages:
|
| 201 |
+
for file in stage.get("files", []):
|
| 202 |
+
latest = max(latest, int(file.get("mtime_ns") or 0))
|
| 203 |
+
return latest
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def discover_output_roots(scan_roots: list[Path], max_depth: int = 3, *, include_legacy: bool = False) -> list[Path]:
|
| 207 |
+
roots: list[Path] = []
|
| 208 |
+
seen: set[Path] = set()
|
| 209 |
+
for scan_root in scan_roots:
|
| 210 |
+
root = safe_resolve(scan_root)
|
| 211 |
+
if not root.is_dir():
|
| 212 |
+
continue
|
| 213 |
+
for candidate in iter_likely_output_dirs(root, max_depth=max_depth):
|
| 214 |
+
if is_under_seen_root(candidate, seen) or not looks_like_output_root(candidate, include_legacy=include_legacy):
|
| 215 |
+
continue
|
| 216 |
+
seen.add(candidate)
|
| 217 |
+
roots.append(candidate)
|
| 218 |
+
roots.sort(key=lambda path: (output_root_mtime_ns(path), str(path)), reverse=True)
|
| 219 |
+
return roots
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def is_under_seen_root(path: Path, seen: set[Path]) -> bool:
|
| 223 |
+
for root in seen:
|
| 224 |
+
try:
|
| 225 |
+
path.relative_to(root)
|
| 226 |
+
except ValueError:
|
| 227 |
+
continue
|
| 228 |
+
return True
|
| 229 |
+
return False
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def path_mtime_ns(path: Path) -> int:
|
| 233 |
+
try:
|
| 234 |
+
return path.stat().st_mtime_ns
|
| 235 |
+
except OSError:
|
| 236 |
+
return 0
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def output_root_mtime_ns(path: Path) -> int:
|
| 240 |
+
latest = path_mtime_ns(path)
|
| 241 |
+
for filename in (
|
| 242 |
+
"batch_report.json",
|
| 243 |
+
"experiment_summary.json",
|
| 244 |
+
"summary.json",
|
| 245 |
+
"manifest.jsonl",
|
| 246 |
+
"comparison_summary.json",
|
| 247 |
+
):
|
| 248 |
+
latest = max(latest, path_mtime_ns(path / filename))
|
| 249 |
+
try:
|
| 250 |
+
children = [child for child in path.iterdir() if child.is_dir()]
|
| 251 |
+
except OSError:
|
| 252 |
+
return latest
|
| 253 |
+
for child in children:
|
| 254 |
+
latest = max(
|
| 255 |
+
latest,
|
| 256 |
+
path_mtime_ns(child),
|
| 257 |
+
path_mtime_ns(child / "task_status.json"),
|
| 258 |
+
path_mtime_ns(child / "report.json"),
|
| 259 |
+
path_mtime_ns(child / "final_composite.png"),
|
| 260 |
+
)
|
| 261 |
+
return latest
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
def iter_likely_output_dirs(root: Path, max_depth: int) -> list[Path]:
|
| 265 |
+
if root == Path("/tmp").resolve():
|
| 266 |
+
candidates = [root]
|
| 267 |
+
try:
|
| 268 |
+
children = list(root.iterdir())
|
| 269 |
+
except OSError:
|
| 270 |
+
return candidates
|
| 271 |
+
for child in children:
|
| 272 |
+
if child.name.startswith(".") or child.is_symlink():
|
| 273 |
+
continue
|
| 274 |
+
try:
|
| 275 |
+
is_candidate = child.is_dir() and (
|
| 276 |
+
child.name.startswith("commercial_fci") or (child / "batch_report.json").is_file()
|
| 277 |
+
)
|
| 278 |
+
except OSError:
|
| 279 |
+
continue
|
| 280 |
+
if is_candidate:
|
| 281 |
+
candidates.append(child)
|
| 282 |
+
return candidates
|
| 283 |
+
return iter_dirs(root, max_depth=max_depth)
|
| 284 |
+
|
| 285 |
+
|
| 286 |
+
def iter_dirs(root: Path, max_depth: int) -> list[Path]:
|
| 287 |
+
dirs: list[Path] = []
|
| 288 |
+
stack: list[tuple[Path, int]] = [(root, 0)]
|
| 289 |
+
while stack:
|
| 290 |
+
current, depth = stack.pop()
|
| 291 |
+
dirs.append(current)
|
| 292 |
+
if depth >= max_depth:
|
| 293 |
+
continue
|
| 294 |
+
try:
|
| 295 |
+
children = list(current.iterdir())
|
| 296 |
+
except OSError:
|
| 297 |
+
continue
|
| 298 |
+
for child in children:
|
| 299 |
+
if child.name.startswith(".") or child.is_symlink():
|
| 300 |
+
continue
|
| 301 |
+
try:
|
| 302 |
+
is_dir = child.is_dir()
|
| 303 |
+
except OSError:
|
| 304 |
+
continue
|
| 305 |
+
if is_dir:
|
| 306 |
+
stack.append((child, depth + 1))
|
| 307 |
+
return dirs
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def looks_like_output_root(path: Path, *, include_legacy: bool = False) -> bool:
|
| 311 |
+
if (path / "experiment_summary.json").is_file():
|
| 312 |
+
return True
|
| 313 |
+
if not include_legacy:
|
| 314 |
+
return False
|
| 315 |
+
if (path / "batch_report.json").is_file() and any(path.glob("*/report.json")):
|
| 316 |
+
return True
|
| 317 |
+
if path.name.startswith("commercial_fci") and any(path.glob("*/report.json")):
|
| 318 |
+
return True
|
| 319 |
+
return False
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
def summarize_candidate(candidate: dict) -> dict:
|
| 323 |
+
return {
|
| 324 |
+
"candidate_id": candidate.get("candidate_id"),
|
| 325 |
+
"variant_id": candidate.get("variant_id"),
|
| 326 |
+
"overall": candidate.get("score", {}).get("overall"),
|
| 327 |
+
"score": candidate.get("score", {}),
|
| 328 |
+
"failure_tags": candidate.get("failure_tags", []),
|
| 329 |
+
"raw_path": str(candidate.get("raw_path", "")),
|
| 330 |
+
"final_path": str(candidate.get("final_path", "")),
|
| 331 |
+
"metadata": candidate.get("metadata", {}),
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
|
| 335 |
+
def collect_images(task_dir: Path, report: dict) -> dict:
|
| 336 |
+
export = report.get("export", {})
|
| 337 |
+
masks = report.get("masks", {})
|
| 338 |
+
paths = {
|
| 339 |
+
"final": export.get("final_composite"),
|
| 340 |
+
"background": export.get("background_candidate"),
|
| 341 |
+
"foreground": export.get("foreground"),
|
| 342 |
+
"alpha": export.get("alpha"),
|
| 343 |
+
"condition": str(task_dir / "condition_canvas.png"),
|
| 344 |
+
"foreground_on_canvas": str(task_dir / "foreground_on_canvas.png"),
|
| 345 |
+
"layout_guide": str(task_dir / masks["layout_guide"]) if masks.get("layout_guide") else "",
|
| 346 |
+
"protected_layout_zone": str(task_dir / masks["protected_layout_zone"]) if masks.get("protected_layout_zone") else "",
|
| 347 |
+
"clearance_zone": str(task_dir / masks["clearance_zone"]) if masks.get("clearance_zone") else "",
|
| 348 |
+
}
|
| 349 |
+
return {key: value for key, value in paths.items() if value and Path(value).is_file()}
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def collect_stages(task_dir: Path, report: dict) -> list[dict]:
|
| 353 |
+
stages: list[dict] = []
|
| 354 |
+
|
| 355 |
+
def add(label: str, files: list[tuple[str, Path]]) -> None:
|
| 356 |
+
entries = [
|
| 357 |
+
entry
|
| 358 |
+
for file_label, path in files
|
| 359 |
+
if (entry := file_entry(file_label, path)) is not None
|
| 360 |
+
]
|
| 361 |
+
if entries:
|
| 362 |
+
stages.append({"label": label, "files": entries})
|
| 363 |
+
|
| 364 |
+
add(
|
| 365 |
+
"01 Source & Condition",
|
| 366 |
+
[
|
| 367 |
+
("Rendered SVG", task_dir / "rendered_from_svg.png"),
|
| 368 |
+
("Foreground RGBA", task_dir / "foreground_rgba.png"),
|
| 369 |
+
("Foreground On Canvas", task_dir / "foreground_on_canvas.png"),
|
| 370 |
+
("Foreground Alpha", task_dir / "foreground_alpha.png"),
|
| 371 |
+
("Baseline Transparent Chart", task_dir / "baseline_transparent_chart.png"),
|
| 372 |
+
("Condition Canvas", task_dir / "condition_canvas.png"),
|
| 373 |
+
("Task Status", task_dir / "task_status.json"),
|
| 374 |
+
],
|
| 375 |
+
)
|
| 376 |
+
add(
|
| 377 |
+
"02 Masks & Layout",
|
| 378 |
+
[
|
| 379 |
+
("Alpha Core", task_dir / "masks/alpha_core.png"),
|
| 380 |
+
("Alpha Soft", task_dir / "masks/alpha_soft.png"),
|
| 381 |
+
("Boundary Ring", task_dir / "masks/boundary_ring.png"),
|
| 382 |
+
("Chart Protect Mask", task_dir / "masks/chart_protect.png"),
|
| 383 |
+
("Background Edit Mask", task_dir / "masks/background_edit.png"),
|
| 384 |
+
("Contact Region", task_dir / "masks/contact_region.png"),
|
| 385 |
+
("Protected Layout Zone", task_dir / "masks/protected_layout_zone.png"),
|
| 386 |
+
("Clearance Zone", task_dir / "masks/clearance_zone.png"),
|
| 387 |
+
("Layout Guide", task_dir / "masks/layout_guide.png"),
|
| 388 |
+
],
|
| 389 |
+
)
|
| 390 |
+
add(
|
| 391 |
+
"03 Foreground Analyzer",
|
| 392 |
+
[
|
| 393 |
+
("Request", task_dir / "foreground_analyzer_request.json"),
|
| 394 |
+
("Response", task_dir / "foreground_analyzer_response.json"),
|
| 395 |
+
("Condition Canvas", task_dir / "condition_canvas.png"),
|
| 396 |
+
("Layout Guide", task_dir / "masks/layout_guide.png"),
|
| 397 |
+
],
|
| 398 |
+
)
|
| 399 |
+
add(
|
| 400 |
+
"04 Scene Planner",
|
| 401 |
+
[
|
| 402 |
+
("Request", task_dir / "scene_planner_request.json"),
|
| 403 |
+
("Response", task_dir / "scene_planner_response.json"),
|
| 404 |
+
("Layout Guide", task_dir / "masks/layout_guide.png"),
|
| 405 |
+
],
|
| 406 |
+
)
|
| 407 |
+
add(
|
| 408 |
+
"05 Prompt Ensemble",
|
| 409 |
+
[
|
| 410 |
+
("Request", task_dir / "prompt_ensemble_request.json"),
|
| 411 |
+
("Response", task_dir / "prompt_ensemble_response.json"),
|
| 412 |
+
],
|
| 413 |
+
)
|
| 414 |
+
candidates_dir = task_dir / "candidates"
|
| 415 |
+
if candidates_dir.exists():
|
| 416 |
+
for candidate_dir in sorted(path for path in candidates_dir.iterdir() if path.is_dir()):
|
| 417 |
+
add(
|
| 418 |
+
f"06 Candidate {candidate_dir.name}",
|
| 419 |
+
[
|
| 420 |
+
("Generation Request", candidate_dir / "generation_request.json"),
|
| 421 |
+
("OpenAI Edit Mask (transparent=edit)", candidate_dir / "openai_edit_mask.png"),
|
| 422 |
+
("Raw Candidate", candidate_dir / "raw_candidate.png"),
|
| 423 |
+
("Background For Restoration", candidate_dir / "background_for_restoration.png"),
|
| 424 |
+
("Restored Final", candidate_dir / "final_composite.png"),
|
| 425 |
+
("Aligned Original Chart Restored", candidate_dir / "restored_chart_composite.png"),
|
| 426 |
+
("Aligned Highlight Difference", candidate_dir / "highlight_difference.png"),
|
| 427 |
+
("Aligned Chart Difference Mask", candidate_dir / "chart_difference_mask.png"),
|
| 428 |
+
("Critic Request", candidate_dir / "critic_reranker_request.json"),
|
| 429 |
+
("Critic Response", candidate_dir / "critic_reranker_response.json"),
|
| 430 |
+
("Quality Eval Request", candidate_dir / "quality_evaluator_request.json"),
|
| 431 |
+
("Quality Eval Response", candidate_dir / "quality_evaluator_response.json"),
|
| 432 |
+
("Generation Error", candidate_dir / "generation_error.json"),
|
| 433 |
+
],
|
| 434 |
+
)
|
| 435 |
+
export = report.get("export", {})
|
| 436 |
+
add(
|
| 437 |
+
"07 Final Export",
|
| 438 |
+
[
|
| 439 |
+
("Final Composite", Path(export.get("final_composite") or task_dir / "final_composite.png")),
|
| 440 |
+
("Background Candidate", Path(export.get("background_candidate") or task_dir / "background_candidate.png")),
|
| 441 |
+
("Aligned Original Chart Restored", Path(export.get("restored_chart_composite") or task_dir / "restored_chart_composite.png")),
|
| 442 |
+
("Aligned Highlight Difference", Path(export.get("highlight_difference") or task_dir / "highlight_difference.png")),
|
| 443 |
+
("Aligned Chart Difference Mask", Path(export.get("chart_difference_mask") or task_dir / "chart_difference_mask.png")),
|
| 444 |
+
("Report", task_dir / "report.json"),
|
| 445 |
+
("Task Error", task_dir / "task_error.json"),
|
| 446 |
+
],
|
| 447 |
+
)
|
| 448 |
+
return stages
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def file_entry(label: str, path: Path) -> dict | None:
|
| 452 |
+
try:
|
| 453 |
+
stat = path.stat()
|
| 454 |
+
except OSError:
|
| 455 |
+
return None
|
| 456 |
+
if not path.is_file():
|
| 457 |
+
return None
|
| 458 |
+
suffix = path.suffix.lower()
|
| 459 |
+
if suffix in {".png", ".jpg", ".jpeg", ".webp", ".gif", ".svg"}:
|
| 460 |
+
file_type = "image"
|
| 461 |
+
elif suffix == ".json":
|
| 462 |
+
file_type = "json"
|
| 463 |
+
else:
|
| 464 |
+
file_type = "file"
|
| 465 |
+
return {
|
| 466 |
+
"label": label,
|
| 467 |
+
"path": str(path),
|
| 468 |
+
"type": file_type,
|
| 469 |
+
"size": stat.st_size,
|
| 470 |
+
"mtime": stat.st_mtime,
|
| 471 |
+
"mtime_ns": stat.st_mtime_ns,
|
| 472 |
+
"version": f"{stat.st_mtime_ns}-{stat.st_size}",
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
def task_version(stages: list[dict]) -> str:
|
| 477 |
+
latest_mtime_ns = 0
|
| 478 |
+
total_size = 0
|
| 479 |
+
file_count = 0
|
| 480 |
+
for stage in stages:
|
| 481 |
+
for file in stage.get("files", []):
|
| 482 |
+
latest_mtime_ns = max(latest_mtime_ns, int(file.get("mtime_ns") or 0))
|
| 483 |
+
total_size += int(file.get("size") or 0)
|
| 484 |
+
file_count += 1
|
| 485 |
+
return f"{latest_mtime_ns}-{file_count}-{total_size}"
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def current_index_html() -> str:
|
| 489 |
+
global _INDEX_HTML_CACHE
|
| 490 |
+
source_path = Path(__file__)
|
| 491 |
+
try:
|
| 492 |
+
stat = source_path.stat()
|
| 493 |
+
if _INDEX_HTML_CACHE and _INDEX_HTML_CACHE[:2] == (stat.st_mtime_ns, stat.st_size):
|
| 494 |
+
return _INDEX_HTML_CACHE[2]
|
| 495 |
+
source = source_path.read_text(encoding="utf-8")
|
| 496 |
+
match = re.search(r'\nINDEX_HTML\s*=\s*r"""(.*?)"""\n\n\ndef main\(', source, re.DOTALL)
|
| 497 |
+
if not match:
|
| 498 |
+
raise ValueError("INDEX_HTML block not found")
|
| 499 |
+
html_body = match.group(1)
|
| 500 |
+
version = hashlib.sha256(html_body.encode("utf-8")).hexdigest()[:16]
|
| 501 |
+
_INDEX_HTML_CACHE = (stat.st_mtime_ns, stat.st_size, html_body, version)
|
| 502 |
+
return html_body
|
| 503 |
+
except Exception:
|
| 504 |
+
if _INDEX_HTML_CACHE:
|
| 505 |
+
return _INDEX_HTML_CACHE[2]
|
| 506 |
+
return INDEX_HTML
|
| 507 |
+
|
| 508 |
+
|
| 509 |
+
def current_index_version() -> str:
|
| 510 |
+
current_index_html()
|
| 511 |
+
if _INDEX_HTML_CACHE:
|
| 512 |
+
return _INDEX_HTML_CACHE[3]
|
| 513 |
+
return hashlib.sha256(INDEX_HTML.encode("utf-8")).hexdigest()[:16]
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
class ViewerHandler(BaseHTTPRequestHandler):
|
| 517 |
+
state: ViewerState
|
| 518 |
+
|
| 519 |
+
def log_message(self, format: str, *args: object) -> None:
|
| 520 |
+
print(f"[viewer] {self.address_string()} - {format % args}")
|
| 521 |
+
|
| 522 |
+
def do_GET(self) -> None:
|
| 523 |
+
parsed = urlparse(self.path)
|
| 524 |
+
if parsed.path == "/":
|
| 525 |
+
self.send_html(current_index_html())
|
| 526 |
+
return
|
| 527 |
+
if parsed.path == "/api/viewer-version":
|
| 528 |
+
self.send_json({"version": current_index_version()})
|
| 529 |
+
return
|
| 530 |
+
if parsed.path == "/api/reports":
|
| 531 |
+
roots = self.state.output_roots(force=True)
|
| 532 |
+
self.send_json(
|
| 533 |
+
{
|
| 534 |
+
"roots": [str(root) for root in roots],
|
| 535 |
+
"scan_roots": [str(root) for root in self.state.scan_roots],
|
| 536 |
+
"auto_discover": self.state.auto_discover,
|
| 537 |
+
"show_all_roots": self.state.show_all_roots,
|
| 538 |
+
"include_legacy": self.state.include_legacy,
|
| 539 |
+
"reports": self.state.reports(roots),
|
| 540 |
+
}
|
| 541 |
+
)
|
| 542 |
+
return
|
| 543 |
+
if parsed.path == "/asset":
|
| 544 |
+
params = parse_qs(parsed.query)
|
| 545 |
+
asset_path = params.get("path", [""])[0]
|
| 546 |
+
resolved = self.state.resolve_asset(asset_path)
|
| 547 |
+
if resolved is None:
|
| 548 |
+
self.send_error(404, "asset not found or outside output roots")
|
| 549 |
+
return
|
| 550 |
+
self.send_file(resolved)
|
| 551 |
+
return
|
| 552 |
+
self.send_error(404)
|
| 553 |
+
|
| 554 |
+
def send_json(self, payload: dict) -> None:
|
| 555 |
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
| 556 |
+
self.send_response(200)
|
| 557 |
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
| 558 |
+
self.send_header("Content-Length", str(len(body)))
|
| 559 |
+
self.send_header("Cache-Control", "no-store")
|
| 560 |
+
self.end_headers()
|
| 561 |
+
self.wfile.write(body)
|
| 562 |
+
|
| 563 |
+
def send_html(self, body: str) -> None:
|
| 564 |
+
encoded = body.encode("utf-8")
|
| 565 |
+
self.send_response(200)
|
| 566 |
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
| 567 |
+
self.send_header("Content-Length", str(len(encoded)))
|
| 568 |
+
self.send_header("Cache-Control", "no-store")
|
| 569 |
+
self.end_headers()
|
| 570 |
+
self.wfile.write(encoded)
|
| 571 |
+
|
| 572 |
+
def send_file(self, path: Path) -> None:
|
| 573 |
+
stat = path.stat()
|
| 574 |
+
etag = f'"{stat.st_mtime_ns:x}-{stat.st_size:x}"'
|
| 575 |
+
if self.headers.get("If-None-Match") == etag:
|
| 576 |
+
self.send_response(304)
|
| 577 |
+
self.send_header("ETag", etag)
|
| 578 |
+
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
|
| 579 |
+
self.end_headers()
|
| 580 |
+
return
|
| 581 |
+
body = path.read_bytes()
|
| 582 |
+
mime = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
| 583 |
+
self.send_response(200)
|
| 584 |
+
self.send_header("Content-Type", mime)
|
| 585 |
+
self.send_header("Content-Length", str(len(body)))
|
| 586 |
+
self.send_header("ETag", etag)
|
| 587 |
+
self.send_header("Last-Modified", self.date_time_string(stat.st_mtime))
|
| 588 |
+
self.send_header("Cache-Control", "public, max-age=31536000, immutable")
|
| 589 |
+
self.end_headers()
|
| 590 |
+
self.wfile.write(body)
|
| 591 |
+
|
| 592 |
+
|
| 593 |
+
INDEX_HTML = r"""<!doctype html>
|
| 594 |
+
<html lang="en">
|
| 595 |
+
<head>
|
| 596 |
+
<meta charset="utf-8">
|
| 597 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 598 |
+
<title>FCI Infographic Viewer</title>
|
| 599 |
+
<style>
|
| 600 |
+
:root {
|
| 601 |
+
color-scheme: light;
|
| 602 |
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
| 603 |
+
background: #eef2f7;
|
| 604 |
+
color: #132033;
|
| 605 |
+
}
|
| 606 |
+
body { margin: 0; }
|
| 607 |
+
header {
|
| 608 |
+
height: 64px;
|
| 609 |
+
display: flex;
|
| 610 |
+
align-items: center;
|
| 611 |
+
justify-content: space-between;
|
| 612 |
+
padding: 0 24px;
|
| 613 |
+
background: #ffffff;
|
| 614 |
+
border-bottom: 1px solid #d9e1ec;
|
| 615 |
+
position: sticky;
|
| 616 |
+
top: 0;
|
| 617 |
+
z-index: 3;
|
| 618 |
+
}
|
| 619 |
+
h1 { font-size: 18px; margin: 0; letter-spacing: 0; }
|
| 620 |
+
.header-actions { display: flex; align-items: center; gap: 12px; }
|
| 621 |
+
.auto-refresh {
|
| 622 |
+
display: inline-flex;
|
| 623 |
+
align-items: center;
|
| 624 |
+
gap: 6px;
|
| 625 |
+
color: #506176;
|
| 626 |
+
font-size: 13px;
|
| 627 |
+
}
|
| 628 |
+
button {
|
| 629 |
+
border: 1px solid #c9d3e2;
|
| 630 |
+
background: #ffffff;
|
| 631 |
+
color: #132033;
|
| 632 |
+
border-radius: 6px;
|
| 633 |
+
padding: 8px 12px;
|
| 634 |
+
cursor: pointer;
|
| 635 |
+
font-size: 13px;
|
| 636 |
+
}
|
| 637 |
+
button.active { background: #1d4ed8; color: #ffffff; border-color: #1d4ed8; }
|
| 638 |
+
main {
|
| 639 |
+
display: grid;
|
| 640 |
+
grid-template-columns: 360px minmax(0, 1fr);
|
| 641 |
+
min-height: calc(100vh - 65px);
|
| 642 |
+
}
|
| 643 |
+
aside {
|
| 644 |
+
border-right: 1px solid #d9e1ec;
|
| 645 |
+
background: #f8fafc;
|
| 646 |
+
overflow-y: auto;
|
| 647 |
+
max-height: calc(100vh - 65px);
|
| 648 |
+
}
|
| 649 |
+
.roots { padding: 14px 16px; font-size: 12px; color: #506176; border-bottom: 1px solid #d9e1ec; }
|
| 650 |
+
.sidebar-section-title {
|
| 651 |
+
padding: 12px 16px 7px;
|
| 652 |
+
color: #64748b;
|
| 653 |
+
font-size: 11px;
|
| 654 |
+
font-weight: 800;
|
| 655 |
+
text-transform: uppercase;
|
| 656 |
+
letter-spacing: 0.03em;
|
| 657 |
+
}
|
| 658 |
+
.experiment {
|
| 659 |
+
padding: 12px 16px;
|
| 660 |
+
border-bottom: 1px solid #e3e9f2;
|
| 661 |
+
cursor: pointer;
|
| 662 |
+
background: #ffffff;
|
| 663 |
+
}
|
| 664 |
+
.experiment:hover, .experiment.selected { background: #e8f5ef; }
|
| 665 |
+
.experiment-title { font-weight: 800; font-size: 13px; margin-bottom: 6px; }
|
| 666 |
+
.task {
|
| 667 |
+
padding: 14px 16px;
|
| 668 |
+
border-bottom: 1px solid #e3e9f2;
|
| 669 |
+
cursor: pointer;
|
| 670 |
+
}
|
| 671 |
+
.task:hover, .task.selected { background: #eaf1ff; }
|
| 672 |
+
.task-title { font-weight: 700; font-size: 13px; margin-bottom: 6px; }
|
| 673 |
+
.meta { font-size: 12px; color: #506176; line-height: 1.45; }
|
| 674 |
+
.content { padding: 20px; overflow: auto; }
|
| 675 |
+
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 14px; align-items: center; }
|
| 676 |
+
.stage-bar {
|
| 677 |
+
display: flex;
|
| 678 |
+
gap: 8px;
|
| 679 |
+
overflow-x: auto;
|
| 680 |
+
padding-bottom: 8px;
|
| 681 |
+
margin-bottom: 12px;
|
| 682 |
+
}
|
| 683 |
+
.stage-bar button {
|
| 684 |
+
flex: 0 0 auto;
|
| 685 |
+
white-space: nowrap;
|
| 686 |
+
}
|
| 687 |
+
.summary {
|
| 688 |
+
display: grid;
|
| 689 |
+
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
| 690 |
+
gap: 10px;
|
| 691 |
+
margin-bottom: 16px;
|
| 692 |
+
}
|
| 693 |
+
.metric {
|
| 694 |
+
background: #ffffff;
|
| 695 |
+
border: 1px solid #d9e1ec;
|
| 696 |
+
border-radius: 8px;
|
| 697 |
+
padding: 10px 12px;
|
| 698 |
+
}
|
| 699 |
+
.metric-label { font-size: 11px; color: #64748b; margin-bottom: 4px; }
|
| 700 |
+
.metric-value { font-weight: 800; font-size: 16px; line-height: 1.2; word-break: break-word; }
|
| 701 |
+
.comparison-panel {
|
| 702 |
+
background: #ffffff;
|
| 703 |
+
border: 1px solid #d9e1ec;
|
| 704 |
+
border-radius: 8px;
|
| 705 |
+
overflow: hidden;
|
| 706 |
+
margin-bottom: 16px;
|
| 707 |
+
}
|
| 708 |
+
.comparison-panel h2 {
|
| 709 |
+
margin: 0;
|
| 710 |
+
padding: 12px 14px;
|
| 711 |
+
font-size: 13px;
|
| 712 |
+
border-bottom: 1px solid #e3e9f2;
|
| 713 |
+
}
|
| 714 |
+
.table-wrap { overflow-x: auto; }
|
| 715 |
+
table {
|
| 716 |
+
width: 100%;
|
| 717 |
+
border-collapse: collapse;
|
| 718 |
+
font-size: 12px;
|
| 719 |
+
background: #ffffff;
|
| 720 |
+
}
|
| 721 |
+
th, td {
|
| 722 |
+
padding: 9px 10px;
|
| 723 |
+
border-bottom: 1px solid #e3e9f2;
|
| 724 |
+
text-align: left;
|
| 725 |
+
vertical-align: top;
|
| 726 |
+
white-space: nowrap;
|
| 727 |
+
}
|
| 728 |
+
th {
|
| 729 |
+
color: #475569;
|
| 730 |
+
background: #f8fafc;
|
| 731 |
+
font-weight: 800;
|
| 732 |
+
position: sticky;
|
| 733 |
+
top: 0;
|
| 734 |
+
}
|
| 735 |
+
td .cell-line { color: #506176; line-height: 1.45; }
|
| 736 |
+
.compact-button { padding: 5px 8px; font-size: 12px; margin-top: 5px; }
|
| 737 |
+
.task-col { min-width: 220px; max-width: 280px; white-space: normal; }
|
| 738 |
+
.method-col { min-width: 260px; width: 320px; white-space: normal; }
|
| 739 |
+
.method-cell {
|
| 740 |
+
display: grid;
|
| 741 |
+
gap: 8px;
|
| 742 |
+
min-width: 240px;
|
| 743 |
+
}
|
| 744 |
+
.method-thumb-wrap {
|
| 745 |
+
height: 180px;
|
| 746 |
+
border: 1px solid #d9e1ec;
|
| 747 |
+
border-radius: 6px;
|
| 748 |
+
background-color: #dfe6f0;
|
| 749 |
+
background-image:
|
| 750 |
+
linear-gradient(45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%),
|
| 751 |
+
linear-gradient(-45deg, rgba(100, 116, 139, 0.18) 25%, transparent 25%),
|
| 752 |
+
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%),
|
| 753 |
+
linear-gradient(-45deg, transparent 75%, rgba(100, 116, 139, 0.18) 75%);
|
| 754 |
+
background-size: 18px 18px;
|
| 755 |
+
background-position: 0 0, 0 9px, 9px -9px, -9px 0;
|
| 756 |
+
display: flex;
|
| 757 |
+
align-items: center;
|
| 758 |
+
justify-content: center;
|
| 759 |
+
overflow: hidden;
|
| 760 |
+
}
|
| 761 |
+
.method-thumb {
|
| 762 |
+
max-width: 100%;
|
| 763 |
+
max-height: 100%;
|
| 764 |
+
object-fit: contain;
|
| 765 |
+
background: transparent;
|
| 766 |
+
}
|
| 767 |
+
.method-metrics {
|
| 768 |
+
display: grid;
|
| 769 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 770 |
+
gap: 5px;
|
| 771 |
+
}
|
| 772 |
+
.method-metric {
|
| 773 |
+
border: 1px solid #e3e9f2;
|
| 774 |
+
border-radius: 6px;
|
| 775 |
+
padding: 5px 6px;
|
| 776 |
+
background: #f8fafc;
|
| 777 |
+
}
|
| 778 |
+
.method-metric-label {
|
| 779 |
+
color: #64748b;
|
| 780 |
+
font-size: 10px;
|
| 781 |
+
line-height: 1.1;
|
| 782 |
+
}
|
| 783 |
+
.method-metric-value {
|
| 784 |
+
margin-top: 2px;
|
| 785 |
+
font-size: 12px;
|
| 786 |
+
font-weight: 800;
|
| 787 |
+
color: #132033;
|
| 788 |
+
line-height: 1.15;
|
| 789 |
+
}
|
| 790 |
+
.viewer-grid {
|
| 791 |
+
display: grid;
|
| 792 |
+
grid-template-columns: minmax(0, 1fr) 360px;
|
| 793 |
+
gap: 16px;
|
| 794 |
+
align-items: start;
|
| 795 |
+
}
|
| 796 |
+
.image-panel, .detail-panel {
|
| 797 |
+
background: #ffffff;
|
| 798 |
+
border: 1px solid #d9e1ec;
|
| 799 |
+
border-radius: 8px;
|
| 800 |
+
overflow: hidden;
|
| 801 |
+
}
|
| 802 |
+
.stage-files {
|
| 803 |
+
display: grid;
|
| 804 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 805 |
+
gap: 12px;
|
| 806 |
+
padding: 12px;
|
| 807 |
+
}
|
| 808 |
+
.stage-file {
|
| 809 |
+
border: 1px solid #e3e9f2;
|
| 810 |
+
border-radius: 8px;
|
| 811 |
+
overflow: hidden;
|
| 812 |
+
background: #ffffff;
|
| 813 |
+
}
|
| 814 |
+
.stage-file h3 {
|
| 815 |
+
margin: 0;
|
| 816 |
+
padding: 9px 10px;
|
| 817 |
+
font-size: 12px;
|
| 818 |
+
border-bottom: 1px solid #e3e9f2;
|
| 819 |
+
color: #334155;
|
| 820 |
+
}
|
| 821 |
+
.stage-file .image-wrap { min-height: 180px; padding: 8px; }
|
| 822 |
+
.stage-file img { max-height: 320px; }
|
| 823 |
+
.stage-file pre {
|
| 824 |
+
max-height: 320px;
|
| 825 |
+
white-space: pre-wrap;
|
| 826 |
+
word-break: break-word;
|
| 827 |
+
}
|
| 828 |
+
.image-panel h2, .detail-panel h2 {
|
| 829 |
+
margin: 0;
|
| 830 |
+
padding: 12px 14px;
|
| 831 |
+
font-size: 13px;
|
| 832 |
+
border-bottom: 1px solid #e3e9f2;
|
| 833 |
+
}
|
| 834 |
+
.image-wrap {
|
| 835 |
+
background-color: #dfe6f0;
|
| 836 |
+
background-image:
|
| 837 |
+
linear-gradient(45deg, rgba(100, 116, 139, 0.22) 25%, transparent 25%),
|
| 838 |
+
linear-gradient(-45deg, rgba(100, 116, 139, 0.22) 25%, transparent 25%),
|
| 839 |
+
linear-gradient(45deg, transparent 75%, rgba(100, 116, 139, 0.22) 75%),
|
| 840 |
+
linear-gradient(-45deg, transparent 75%, rgba(100, 116, 139, 0.22) 75%);
|
| 841 |
+
background-size: 20px 20px;
|
| 842 |
+
background-position: 0 0, 0 10px, 10px -10px, -10px 0;
|
| 843 |
+
padding: 12px;
|
| 844 |
+
min-height: 320px;
|
| 845 |
+
display: flex;
|
| 846 |
+
align-items: center;
|
| 847 |
+
justify-content: center;
|
| 848 |
+
}
|
| 849 |
+
img {
|
| 850 |
+
display: block;
|
| 851 |
+
max-width: 100%;
|
| 852 |
+
max-height: calc(100vh - 260px);
|
| 853 |
+
object-fit: contain;
|
| 854 |
+
background: transparent;
|
| 855 |
+
}
|
| 856 |
+
pre {
|
| 857 |
+
margin: 0;
|
| 858 |
+
padding: 12px;
|
| 859 |
+
max-height: 360px;
|
| 860 |
+
overflow: auto;
|
| 861 |
+
font-size: 12px;
|
| 862 |
+
line-height: 1.45;
|
| 863 |
+
background: #0f172a;
|
| 864 |
+
color: #dbeafe;
|
| 865 |
+
}
|
| 866 |
+
.candidate-list { display: grid; gap: 8px; padding: 12px; }
|
| 867 |
+
.candidate {
|
| 868 |
+
border: 1px solid #d9e1ec;
|
| 869 |
+
border-radius: 8px;
|
| 870 |
+
padding: 10px;
|
| 871 |
+
cursor: pointer;
|
| 872 |
+
background: #ffffff;
|
| 873 |
+
}
|
| 874 |
+
.candidate.active { border-color: #1d4ed8; box-shadow: 0 0 0 2px rgba(29, 78, 216, 0.12); }
|
| 875 |
+
.tags { margin-top: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
|
| 876 |
+
.tag { background: #fee2e2; color: #991b1b; border-radius: 4px; padding: 2px 5px; font-size: 11px; }
|
| 877 |
+
.tag.warn { background: #fef3c7; color: #92400e; }
|
| 878 |
+
.prompt-card {
|
| 879 |
+
padding: 12px;
|
| 880 |
+
border-bottom: 1px solid #e3e9f2;
|
| 881 |
+
background: #fbfdff;
|
| 882 |
+
}
|
| 883 |
+
.prompt-actions {
|
| 884 |
+
display: flex;
|
| 885 |
+
align-items: center;
|
| 886 |
+
justify-content: space-between;
|
| 887 |
+
gap: 8px;
|
| 888 |
+
margin-bottom: 10px;
|
| 889 |
+
}
|
| 890 |
+
.prompt-title { font-weight: 700; font-size: 12px; color: #334155; }
|
| 891 |
+
.prompt-text {
|
| 892 |
+
max-height: 360px;
|
| 893 |
+
overflow: auto;
|
| 894 |
+
border: 1px solid #d9e1ec;
|
| 895 |
+
border-radius: 8px;
|
| 896 |
+
background: #ffffff;
|
| 897 |
+
padding: 12px;
|
| 898 |
+
font-size: 13px;
|
| 899 |
+
line-height: 1.62;
|
| 900 |
+
color: #172033;
|
| 901 |
+
}
|
| 902 |
+
.prompt-paragraph { margin: 0 0 12px; }
|
| 903 |
+
.prompt-paragraph:last-child { margin-bottom: 0; }
|
| 904 |
+
.json-viewer .prompt-card {
|
| 905 |
+
border-bottom: 0;
|
| 906 |
+
padding: 0 0 10px;
|
| 907 |
+
background: transparent;
|
| 908 |
+
}
|
| 909 |
+
.json-viewer .prompt-text { max-height: 280px; }
|
| 910 |
+
.empty { padding: 24px; color: #64748b; }
|
| 911 |
+
@media (max-width: 980px) {
|
| 912 |
+
main { grid-template-columns: 1fr; }
|
| 913 |
+
aside { max-height: 260px; border-right: 0; border-bottom: 1px solid #d9e1ec; }
|
| 914 |
+
.viewer-grid { grid-template-columns: 1fr; }
|
| 915 |
+
.summary { grid-template-columns: repeat(2, minmax(120px, 1fr)); }
|
| 916 |
+
.stage-files { grid-template-columns: 1fr; }
|
| 917 |
+
}
|
| 918 |
+
</style>
|
| 919 |
+
</head>
|
| 920 |
+
<body>
|
| 921 |
+
<header>
|
| 922 |
+
<h1>FCI Infographic Viewer</h1>
|
| 923 |
+
<div class="header-actions">
|
| 924 |
+
<label class="auto-refresh"><input id="auto-refresh" type="checkbox" checked>Auto 5s</label>
|
| 925 |
+
<button id="refresh">Refresh</button>
|
| 926 |
+
</div>
|
| 927 |
+
</header>
|
| 928 |
+
<main>
|
| 929 |
+
<aside>
|
| 930 |
+
<div class="roots" id="roots">Loading...</div>
|
| 931 |
+
<div id="tasks"></div>
|
| 932 |
+
</aside>
|
| 933 |
+
<section class="content" id="content">
|
| 934 |
+
<div class="empty">Select a pipeline result.</div>
|
| 935 |
+
</section>
|
| 936 |
+
</main>
|
| 937 |
+
<script>
|
| 938 |
+
let reports = [];
|
| 939 |
+
let selectedReport = null;
|
| 940 |
+
let selectedCandidate = null;
|
| 941 |
+
let selectedExperimentId = null;
|
| 942 |
+
let selectedStageIndex = 0;
|
| 943 |
+
let loadingReports = false;
|
| 944 |
+
let refreshTimer = null;
|
| 945 |
+
let viewerVersion = null;
|
| 946 |
+
let lastRootsHtml = "";
|
| 947 |
+
let lastTaskListSignature = "";
|
| 948 |
+
let lastExperimentContentSignature = "";
|
| 949 |
+
const jsonCache = new Map();
|
| 950 |
+
|
| 951 |
+
const assetUrl = (path, version) => {
|
| 952 |
+
const suffix = version ? "&v=" + encodeURIComponent(version) : "";
|
| 953 |
+
return "/asset?path=" + encodeURIComponent(path) + suffix;
|
| 954 |
+
};
|
| 955 |
+
const fmt = (value) => value === undefined || value === null || value === "" ? "-" : value;
|
| 956 |
+
const fmtNum = (value) => {
|
| 957 |
+
if (value === undefined || value === null || value === "") return "-";
|
| 958 |
+
const number = Number(value);
|
| 959 |
+
if (!Number.isFinite(number)) return String(value);
|
| 960 |
+
return Math.abs(number) >= 100 ? number.toFixed(1) : number.toFixed(3).replace(/\.?0+$/, "");
|
| 961 |
+
};
|
| 962 |
+
const esc = (value) => String(value ?? "").replace(/[&<>"']/g, (ch) => ({
|
| 963 |
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
|
| 964 |
+
}[ch]));
|
| 965 |
+
const avg = (values) => {
|
| 966 |
+
const numbers = values.map(Number).filter(Number.isFinite);
|
| 967 |
+
if (!numbers.length) return null;
|
| 968 |
+
return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
|
| 969 |
+
};
|
| 970 |
+
const clampScore = (value) => Math.max(0, Math.min(100, Number(value)));
|
| 971 |
+
const fallbackFaithfulnessFromMeasurements = (measurements) => {
|
| 972 |
+
if (!measurements || measurements.chart_pixel_change_score === undefined) return undefined;
|
| 973 |
+
const pixel = clampScore(100 - 4 * Number(measurements.chart_pixel_change_score || 0));
|
| 974 |
+
const shape = clampScore(100 - 2 * Number(measurements.chart_deformation_score || 0));
|
| 975 |
+
const position = clampScore(100 - 3 * Number(measurements.chart_displacement_score || 0));
|
| 976 |
+
const coverage = clampScore(100 - 2 * Number(measurements.chart_coverage_loss_score || 0));
|
| 977 |
+
let score = 0.65 * pixel + 0.15 * shape + 0.12 * position + 0.08 * coverage;
|
| 978 |
+
if (measurements.chart_alignment_reliable === false) score = Math.min(score, 45);
|
| 979 |
+
return score;
|
| 980 |
+
};
|
| 981 |
+
const candidateMeasurements = (candidate) => ((candidate || {}).metadata || {}).measurements || {};
|
| 982 |
+
const candidateChartFaithfulness = (candidate) => {
|
| 983 |
+
const metadata = ((candidate || {}).metadata || {});
|
| 984 |
+
const score = (candidate || {}).score || {};
|
| 985 |
+
const summary = metadata.chart_faithfulness || {};
|
| 986 |
+
const measurements = metadata.measurements || {};
|
| 987 |
+
return score.chart_faithfulness_score
|
| 988 |
+
?? summary.score
|
| 989 |
+
?? measurements.chart_faithfulness_score
|
| 990 |
+
?? fallbackFaithfulnessFromMeasurements(measurements);
|
| 991 |
+
};
|
| 992 |
+
const candidateInfographicQuality = (candidate) => {
|
| 993 |
+
const metadata = ((candidate || {}).metadata || {});
|
| 994 |
+
const score = (candidate || {}).score || {};
|
| 995 |
+
const summary = metadata.infographic_quality || {};
|
| 996 |
+
const quality = candidateQualityScores(candidate);
|
| 997 |
+
if (score.infographic_quality_score !== undefined && score.infographic_quality_score !== null) return score.infographic_quality_score;
|
| 998 |
+
if (summary.score !== undefined && summary.score !== null) return summary.score;
|
| 999 |
+
if (quality.overall_quality !== undefined && quality.overall_quality !== null) return Number(quality.overall_quality) * 20;
|
| 1000 |
+
return undefined;
|
| 1001 |
+
};
|
| 1002 |
+
const candidateQualityScores = (candidate) => ((((candidate || {}).metadata || {}).quality_evaluation || {}).scores || {});
|
| 1003 |
+
const candidateQualityOverall = (candidate) => {
|
| 1004 |
+
const score = (candidate || {}).score || {};
|
| 1005 |
+
const quality = candidateQualityScores(candidate);
|
| 1006 |
+
return score.gpt_quality_overall ?? quality.overall_quality;
|
| 1007 |
+
};
|
| 1008 |
+
const reportBestMeasurements = (report) => candidateMeasurements((report || {}).best);
|
| 1009 |
+
const reportMetric = (report, key) => {
|
| 1010 |
+
if (key === "overall") return (report.best || {}).overall;
|
| 1011 |
+
if (key === "gpt_quality_overall") return candidateQualityOverall((report || {}).best);
|
| 1012 |
+
if (key === "chart_faithfulness_score") return candidateChartFaithfulness((report || {}).best);
|
| 1013 |
+
if (key === "infographic_quality_score") return candidateInfographicQuality((report || {}).best);
|
| 1014 |
+
return reportBestMeasurements(report)[key];
|
| 1015 |
+
};
|
| 1016 |
+
const experimentIdForReport = (report) => report.experiment_id || report.root || "Ungrouped";
|
| 1017 |
+
const variantIdForReport = (report) => report.variant_id || report.pipeline_mode || "variant";
|
| 1018 |
+
const buildExperimentGroups = () => {
|
| 1019 |
+
const groups = new Map();
|
| 1020 |
+
reports.forEach((report, index) => {
|
| 1021 |
+
const id = experimentIdForReport(report);
|
| 1022 |
+
if (!groups.has(id)) {
|
| 1023 |
+
groups.set(id, {id, reports: [], variants: new Map(), latest: 0});
|
| 1024 |
+
}
|
| 1025 |
+
const group = groups.get(id);
|
| 1026 |
+
group.reports.push({report, index});
|
| 1027 |
+
group.latest = Math.max(group.latest, Number(report.updated_mtime_ns || 0));
|
| 1028 |
+
const variantId = variantIdForReport(report);
|
| 1029 |
+
if (!group.variants.has(variantId)) group.variants.set(variantId, []);
|
| 1030 |
+
group.variants.get(variantId).push({report, index});
|
| 1031 |
+
});
|
| 1032 |
+
return Array.from(groups.values()).sort((a, b) => b.latest - a.latest || a.id.localeCompare(b.id));
|
| 1033 |
+
};
|
| 1034 |
+
const experimentContentSignature = (experimentId) => reports
|
| 1035 |
+
.filter((report) => experimentIdForReport(report) === experimentId)
|
| 1036 |
+
.map((report) => [
|
| 1037 |
+
report.id,
|
| 1038 |
+
report.version,
|
| 1039 |
+
report.state,
|
| 1040 |
+
report.stage,
|
| 1041 |
+
report.updated_mtime_ns,
|
| 1042 |
+
report.best?.final_path || "",
|
| 1043 |
+
report.best?.overall || "",
|
| 1044 |
+
report.best?.candidate_id || ""
|
| 1045 |
+
].join(":"))
|
| 1046 |
+
.sort()
|
| 1047 |
+
.join("|");
|
| 1048 |
+
const variantSortKey = (variantId) => {
|
| 1049 |
+
if (variantId.includes("baseline")) return "00_" + variantId;
|
| 1050 |
+
if (variantId === "full") return "01_" + variantId;
|
| 1051 |
+
return "10_" + variantId;
|
| 1052 |
+
};
|
| 1053 |
+
const variantRows = (group) => Array.from(group.variants.entries())
|
| 1054 |
+
.sort(([a], [b]) => variantSortKey(a).localeCompare(variantSortKey(b)))
|
| 1055 |
+
.map(([variantId, entries]) => ({variantId, entries}));
|
| 1056 |
+
const alignmentLine = (candidate) => {
|
| 1057 |
+
const measurements = candidateMeasurements(candidate);
|
| 1058 |
+
if (!measurements.chart_alignment_method) return "";
|
| 1059 |
+
return "align " + fmt(measurements.chart_alignment_method)
|
| 1060 |
+
+ " | reliable " + fmt(measurements.chart_alignment_reliable)
|
| 1061 |
+
+ " | scale " + fmt(measurements.chart_alignment_scale)
|
| 1062 |
+
+ " | change " + fmt(measurements.chart_change_score)
|
| 1063 |
+
+ " | deform " + fmt(measurements.chart_deformation_score);
|
| 1064 |
+
};
|
| 1065 |
+
const renderAlignmentMeta = (candidate) => {
|
| 1066 |
+
const line = alignmentLine(candidate);
|
| 1067 |
+
return line ? `<br><span class="meta">${esc(line)}</span>` : "";
|
| 1068 |
+
};
|
| 1069 |
+
const renderCandidateTags = (candidate) => {
|
| 1070 |
+
const tags = (candidate.failure_tags || []).map((tag) => `<span class="tag">${esc(tag)}</span>`);
|
| 1071 |
+
const measurements = candidateMeasurements(candidate);
|
| 1072 |
+
if (measurements.chart_alignment_reliable === false) {
|
| 1073 |
+
tags.push('<span class="tag warn">low-confidence alignment</span>');
|
| 1074 |
+
}
|
| 1075 |
+
return tags.join("");
|
| 1076 |
+
};
|
| 1077 |
+
const normalizePrompt = (value) => String(value ?? "")
|
| 1078 |
+
.replace(/\r\n/g, "\n")
|
| 1079 |
+
.replace(/\\n/g, "\n");
|
| 1080 |
+
const renderPromptBody = (prompt) => {
|
| 1081 |
+
const normalized = normalizePrompt(prompt).trim();
|
| 1082 |
+
const paragraphs = normalized.split(/\n\s*\n/g).map((part) => part.trim()).filter(Boolean);
|
| 1083 |
+
return paragraphs.map((paragraph) => {
|
| 1084 |
+
const lines = paragraph.split(/\n/g).map((line) => line.trim()).filter(Boolean);
|
| 1085 |
+
return `<p class="prompt-paragraph">${lines.map(esc).join("<br>")}</p>`;
|
| 1086 |
+
}).join("");
|
| 1087 |
+
};
|
| 1088 |
+
const renderPromptCard = (prompt, title, actionsHtml = "") => {
|
| 1089 |
+
const normalized = normalizePrompt(prompt);
|
| 1090 |
+
if (!normalized.trim()) return '<div class="prompt-card"><div class="empty">No prompt recorded.</div></div>';
|
| 1091 |
+
return `
|
| 1092 |
+
<div class="prompt-card">
|
| 1093 |
+
<div class="prompt-actions">
|
| 1094 |
+
<div class="prompt-title">${esc(title)} (${normalized.length} chars)</div>
|
| 1095 |
+
${actionsHtml}
|
| 1096 |
+
</div>
|
| 1097 |
+
<div class="prompt-text">
|
| 1098 |
+
${renderPromptBody(normalized)}
|
| 1099 |
+
</div>
|
| 1100 |
+
</div>
|
| 1101 |
+
`;
|
| 1102 |
+
};
|
| 1103 |
+
const selectedPrompt = () => normalizePrompt((selectedCandidate || selectedReport?.best || {}).prompt || "");
|
| 1104 |
+
const renderSelectedPromptCard = () => renderPromptCard(
|
| 1105 |
+
selectedPrompt(),
|
| 1106 |
+
"Selected Prompt",
|
| 1107 |
+
'<button onclick="copySelectedPrompt(event)">Copy Prompt</button>'
|
| 1108 |
+
);
|
| 1109 |
+
const extractPromptPreview = (payload) => {
|
| 1110 |
+
if (!payload || typeof payload !== "object") return "";
|
| 1111 |
+
for (const key of ["prompt", "final_prompt", "image_prompt", "instructions"]) {
|
| 1112 |
+
if (typeof payload[key] === "string" && payload[key].trim()) return payload[key];
|
| 1113 |
+
}
|
| 1114 |
+
for (const key of ["variants", "prompts", "candidates"]) {
|
| 1115 |
+
if (!Array.isArray(payload[key])) continue;
|
| 1116 |
+
const parts = payload[key].map((item, index) => {
|
| 1117 |
+
if (!item || typeof item !== "object") return "";
|
| 1118 |
+
const prompt = item.prompt || item.final_prompt || item.image_prompt || item.instructions || "";
|
| 1119 |
+
if (!String(prompt).trim()) return "";
|
| 1120 |
+
const label = item.variant_id || item.id || item.name || `variant_${index}`;
|
| 1121 |
+
return `${label}\n${prompt}`;
|
| 1122 |
+
}).filter(Boolean);
|
| 1123 |
+
if (parts.length) return parts.join("\n\n");
|
| 1124 |
+
}
|
| 1125 |
+
return "";
|
| 1126 |
+
};
|
| 1127 |
+
const renderJsonPayload = (text) => {
|
| 1128 |
+
let rendered = text;
|
| 1129 |
+
let prompt = "";
|
| 1130 |
+
try {
|
| 1131 |
+
const payload = JSON.parse(text);
|
| 1132 |
+
rendered = JSON.stringify(payload, null, 2);
|
| 1133 |
+
prompt = extractPromptPreview(payload);
|
| 1134 |
+
} catch {
|
| 1135 |
+
}
|
| 1136 |
+
return `${prompt ? renderPromptCard(prompt, "Prompt Preview") : ""}<pre>${esc(rendered)}</pre>`;
|
| 1137 |
+
};
|
| 1138 |
+
async function copySelectedPrompt(event) {
|
| 1139 |
+
event.stopPropagation();
|
| 1140 |
+
const prompt = selectedPrompt();
|
| 1141 |
+
if (!prompt) return;
|
| 1142 |
+
try {
|
| 1143 |
+
await navigator.clipboard.writeText(prompt);
|
| 1144 |
+
event.target.textContent = "Copied";
|
| 1145 |
+
setTimeout(() => { event.target.textContent = "Copy Prompt"; }, 1000);
|
| 1146 |
+
} catch {
|
| 1147 |
+
event.target.textContent = "Copy failed";
|
| 1148 |
+
setTimeout(() => { event.target.textContent = "Copy Prompt"; }, 1000);
|
| 1149 |
+
}
|
| 1150 |
+
}
|
| 1151 |
+
|
| 1152 |
+
async function loadReports(options = {}) {
|
| 1153 |
+
if (loadingReports) return;
|
| 1154 |
+
loadingReports = true;
|
| 1155 |
+
try {
|
| 1156 |
+
const forceContent = Boolean(options.forceContent);
|
| 1157 |
+
const response = await fetch("/api/reports");
|
| 1158 |
+
const data = await response.json();
|
| 1159 |
+
const previousReportId = selectedReport?.id;
|
| 1160 |
+
const previousReportVersion = selectedReport?.version;
|
| 1161 |
+
const previousCandidateId = selectedCandidate?.candidate_id;
|
| 1162 |
+
const previousExperimentId = selectedExperimentId;
|
| 1163 |
+
reports = data.reports || [];
|
| 1164 |
+
const modeLine = "latest root: " + esc(data.show_all_roots ? "off" : "on")
|
| 1165 |
+
+ " | legacy: " + esc(data.include_legacy ? "shown" : "hidden");
|
| 1166 |
+
const rootsHtml = "<b>Roots</b> (" + reports.length + " reports)<br>"
|
| 1167 |
+
+ modeLine + "<br>"
|
| 1168 |
+
+ (data.roots || []).map(esc).join("<br>");
|
| 1169 |
+
if (rootsHtml !== lastRootsHtml) {
|
| 1170 |
+
document.getElementById("roots").innerHTML = rootsHtml;
|
| 1171 |
+
lastRootsHtml = rootsHtml;
|
| 1172 |
+
}
|
| 1173 |
+
renderTasks();
|
| 1174 |
+
if (!reports.length) {
|
| 1175 |
+
selectedReport = null;
|
| 1176 |
+
selectedCandidate = null;
|
| 1177 |
+
selectedExperimentId = null;
|
| 1178 |
+
renderContent();
|
| 1179 |
+
} else if (!selectedReport && !selectedExperimentId && reports.length) {
|
| 1180 |
+
const groups = buildExperimentGroups();
|
| 1181 |
+
if (groups.length) {
|
| 1182 |
+
selectExperiment(groups[0].id);
|
| 1183 |
+
} else {
|
| 1184 |
+
selectReport(reports[0].id);
|
| 1185 |
+
}
|
| 1186 |
+
} else if (selectedExperimentId && !selectedReport) {
|
| 1187 |
+
const groups = buildExperimentGroups();
|
| 1188 |
+
if (!groups.find((group) => group.id === previousExperimentId)) {
|
| 1189 |
+
selectedExperimentId = groups[0]?.id || null;
|
| 1190 |
+
}
|
| 1191 |
+
renderTasks();
|
| 1192 |
+
const nextExperimentSignature = selectedExperimentId ? experimentContentSignature(selectedExperimentId) : "";
|
| 1193 |
+
if (forceContent || nextExperimentSignature !== lastExperimentContentSignature) {
|
| 1194 |
+
renderContent();
|
| 1195 |
+
}
|
| 1196 |
+
} else if (selectedReport) {
|
| 1197 |
+
const nextReport = reports.find((item) => item.id === previousReportId) || reports[0] || null;
|
| 1198 |
+
const contentChanged = forceContent
|
| 1199 |
+
|| !nextReport
|
| 1200 |
+
|| nextReport.id !== previousReportId
|
| 1201 |
+
|| nextReport.version !== previousReportVersion;
|
| 1202 |
+
selectedReport = nextReport;
|
| 1203 |
+
selectedCandidate = selectedReport?.candidates.find((candidate) => candidate.candidate_id === previousCandidateId)
|
| 1204 |
+
|| selectedReport?.best
|
| 1205 |
+
|| null;
|
| 1206 |
+
selectedExperimentId = selectedReport ? experimentIdForReport(selectedReport) : null;
|
| 1207 |
+
selectedStageIndex = Math.min(selectedStageIndex, Math.max(0, (selectedReport?.stages || []).length - 1));
|
| 1208 |
+
renderTasks();
|
| 1209 |
+
if (contentChanged) renderContent();
|
| 1210 |
+
}
|
| 1211 |
+
} finally {
|
| 1212 |
+
loadingReports = false;
|
| 1213 |
+
}
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
function renderTasks(force = false) {
|
| 1217 |
+
const node = document.getElementById("tasks");
|
| 1218 |
+
if (!reports.length) {
|
| 1219 |
+
if (force || lastTaskListSignature !== "empty") {
|
| 1220 |
+
node.innerHTML = '<div class="empty">No report.json files found under configured output roots.</div>';
|
| 1221 |
+
lastTaskListSignature = "empty";
|
| 1222 |
+
}
|
| 1223 |
+
return;
|
| 1224 |
+
}
|
| 1225 |
+
const signature = reports.map((report) => [
|
| 1226 |
+
report.id,
|
| 1227 |
+
report.version,
|
| 1228 |
+
report.state,
|
| 1229 |
+
report.stage,
|
| 1230 |
+
report.experiment_id,
|
| 1231 |
+
report.variant_id,
|
| 1232 |
+
selectedExperimentId === experimentIdForReport(report) ? "experiment" : "",
|
| 1233 |
+
selectedReport && selectedReport.id === report.id ? "selected" : ""
|
| 1234 |
+
].join(":")).join("|");
|
| 1235 |
+
if (!force && signature === lastTaskListSignature) return;
|
| 1236 |
+
lastTaskListSignature = signature;
|
| 1237 |
+
const groups = buildExperimentGroups();
|
| 1238 |
+
const visibleReports = selectedExperimentId
|
| 1239 |
+
? reports.map((report, index) => ({report, index})).filter(({report}) => experimentIdForReport(report) === selectedExperimentId)
|
| 1240 |
+
: reports.map((report, index) => ({report, index}));
|
| 1241 |
+
node.innerHTML = `
|
| 1242 |
+
<div class="sidebar-section-title">Experiments</div>
|
| 1243 |
+
${groups.map((group) => {
|
| 1244 |
+
const overall = avg(group.reports.map(({report}) => reportMetric(report, "overall")));
|
| 1245 |
+
const faithfulness = avg(group.reports.map(({report}) => reportMetric(report, "chart_faithfulness_score")));
|
| 1246 |
+
const quality = avg(group.reports.map(({report}) => reportMetric(report, "infographic_quality_score")));
|
| 1247 |
+
const change = avg(group.reports.map(({report}) => reportMetric(report, "chart_change_score")));
|
| 1248 |
+
return `
|
| 1249 |
+
<div class="experiment ${selectedExperimentId === group.id && !selectedReport ? "selected" : ""}" onclick="selectExperiment(${JSON.stringify(group.id).replace(/"/g, """)})">
|
| 1250 |
+
<div class="experiment-title">${esc(group.id)}</div>
|
| 1251 |
+
<div class="meta">
|
| 1252 |
+
variants: ${esc(group.variants.size)} | reports: ${esc(group.reports.length)}<br>
|
| 1253 |
+
overall ${esc(fmtNum(overall))} | faith ${esc(fmtNum(faithfulness))} | info ${esc(fmtNum(quality))} | change ${esc(fmtNum(change))}
|
| 1254 |
+
</div>
|
| 1255 |
+
</div>
|
| 1256 |
+
`;
|
| 1257 |
+
}).join("") || '<div class="empty">No experiments found.</div>'}
|
| 1258 |
+
<div class="sidebar-section-title">${selectedExperimentId ? "Reports In Experiment" : "Recent Reports"}</div>
|
| 1259 |
+
${visibleReports.map(({report, index}) => `
|
| 1260 |
+
<div class="task ${selectedReport && selectedReport.id === report.id ? "selected" : ""}" onclick="selectReportByIndex(${index})">
|
| 1261 |
+
<div class="task-title">${esc(report.task_id)}</div>
|
| 1262 |
+
<div class="meta">
|
| 1263 |
+
mode: ${esc(report.pipeline_mode)}<br>
|
| 1264 |
+
variant: ${esc(report.variant_id || "-")}<br>
|
| 1265 |
+
agent: ${esc(report.agent_mode)} / ${esc(report.agent_model)}<br>
|
| 1266 |
+
generator: ${esc(report.generator)}<br>
|
| 1267 |
+
state: ${esc(report.state)} / ${esc(report.stage)}<br>
|
| 1268 |
+
source: ${esc(report.source_kind)} / ${esc(report.effective_alpha_mode)}<br>
|
| 1269 |
+
best: ${esc(report.best.variant_id)} / ${esc(report.best.overall)}<br>
|
| 1270 |
+
${esc(report.task_dir)}
|
| 1271 |
+
</div>
|
| 1272 |
+
</div>
|
| 1273 |
+
`).join("") || '<div class="empty">No reports in this experiment.</div>'}
|
| 1274 |
+
`;
|
| 1275 |
+
}
|
| 1276 |
+
|
| 1277 |
+
function selectExperiment(id) {
|
| 1278 |
+
selectedExperimentId = id;
|
| 1279 |
+
selectedReport = null;
|
| 1280 |
+
selectedCandidate = null;
|
| 1281 |
+
selectedStageIndex = 0;
|
| 1282 |
+
lastExperimentContentSignature = "";
|
| 1283 |
+
renderTasks(true);
|
| 1284 |
+
renderContent();
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
function selectReportByIndex(index) {
|
| 1288 |
+
selectedReport = reports[index] || null;
|
| 1289 |
+
selectedCandidate = selectedReport?.best || null;
|
| 1290 |
+
selectedExperimentId = selectedReport ? experimentIdForReport(selectedReport) : null;
|
| 1291 |
+
selectedStageIndex = 0;
|
| 1292 |
+
lastExperimentContentSignature = "";
|
| 1293 |
+
renderTasks(true);
|
| 1294 |
+
renderContent();
|
| 1295 |
+
}
|
| 1296 |
+
|
| 1297 |
+
function selectReport(id) {
|
| 1298 |
+
selectedReport = reports.find((item) => item.id === id);
|
| 1299 |
+
selectedCandidate = selectedReport?.best || null;
|
| 1300 |
+
selectedExperimentId = selectedReport ? experimentIdForReport(selectedReport) : null;
|
| 1301 |
+
selectedStageIndex = 0;
|
| 1302 |
+
lastExperimentContentSignature = "";
|
| 1303 |
+
renderTasks(true);
|
| 1304 |
+
renderContent();
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
function selectCandidate(index) {
|
| 1308 |
+
selectedCandidate = selectedReport.candidates[index];
|
| 1309 |
+
renderContent();
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
function selectStage(index) {
|
| 1313 |
+
selectedStageIndex = index;
|
| 1314 |
+
renderContent();
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
function renderContent() {
|
| 1318 |
+
const node = document.getElementById("content");
|
| 1319 |
+
if (selectedExperimentId && !selectedReport) {
|
| 1320 |
+
renderExperimentContent(node);
|
| 1321 |
+
return;
|
| 1322 |
+
}
|
| 1323 |
+
if (!selectedReport) {
|
| 1324 |
+
node.innerHTML = '<div class="empty">No result selected.</div>';
|
| 1325 |
+
return;
|
| 1326 |
+
}
|
| 1327 |
+
const stages = selectedReport.stages || [];
|
| 1328 |
+
const selectedStage = stages[selectedStageIndex] || stages[0] || null;
|
| 1329 |
+
const bestMeasurements = candidateMeasurements(selectedReport.best);
|
| 1330 |
+
const bestFaithfulness = candidateChartFaithfulness(selectedReport.best);
|
| 1331 |
+
const bestInfographicQuality = candidateInfographicQuality(selectedReport.best);
|
| 1332 |
+
node.innerHTML = `
|
| 1333 |
+
<div class="summary">
|
| 1334 |
+
<div class="metric"><div class="metric-label">Best Overall</div><div class="metric-value">${esc(selectedReport.best.overall)}</div></div>
|
| 1335 |
+
<div class="metric"><div class="metric-label">Faithfulness</div><div class="metric-value">${esc(fmtNum(bestFaithfulness))}</div></div>
|
| 1336 |
+
<div class="metric"><div class="metric-label">Infographic</div><div class="metric-value">${esc(fmtNum(bestInfographicQuality))}</div></div>
|
| 1337 |
+
<div class="metric"><div class="metric-label">Alignment</div><div class="metric-value">${esc(bestMeasurements.chart_alignment_method || "-")}</div></div>
|
| 1338 |
+
<div class="metric"><div class="metric-label">Reliable / Scale</div><div class="metric-value">${esc(fmt(bestMeasurements.chart_alignment_reliable))} / ${esc(fmt(bestMeasurements.chart_alignment_scale))}</div></div>
|
| 1339 |
+
<div class="metric"><div class="metric-label">Change Score</div><div class="metric-value">${esc(fmt(bestMeasurements.chart_change_score))}</div></div>
|
| 1340 |
+
<div class="metric"><div class="metric-label">Pixel / Deform / Move</div><div class="metric-value">${esc(fmt(bestMeasurements.chart_pixel_change_score))} / ${esc(fmt(bestMeasurements.chart_deformation_score))} / ${esc(fmt(bestMeasurements.chart_displacement_score))}</div></div>
|
| 1341 |
+
<div class="metric"><div class="metric-label">State</div><div class="metric-value">${esc(selectedReport.state)} / ${esc(selectedReport.stage)}</div></div>
|
| 1342 |
+
</div>
|
| 1343 |
+
<div class="stage-bar">
|
| 1344 |
+
${stages.map((stage, index) => `<button class="${selectedStageIndex === index ? "active" : ""}" onclick="selectStage(${index});">${esc(stage.label)}</button>`).join("")}
|
| 1345 |
+
</div>
|
| 1346 |
+
<div class="viewer-grid">
|
| 1347 |
+
<div class="image-panel">
|
| 1348 |
+
<h2>${esc(selectedStage ? selectedStage.label : "Pipeline Stage")}</h2>
|
| 1349 |
+
${renderStageFiles(selectedStage)}
|
| 1350 |
+
</div>
|
| 1351 |
+
<div class="detail-panel">
|
| 1352 |
+
<h2>Candidates</h2>
|
| 1353 |
+
<div class="candidate-list">
|
| 1354 |
+
${(selectedReport.candidates || []).map((candidate, index) => `
|
| 1355 |
+
<div class="candidate ${selectedCandidate && selectedCandidate.candidate_id === candidate.candidate_id ? "active" : ""}" onclick="selectCandidate(${index})">
|
| 1356 |
+
<b>#${esc(candidate.candidate_id)} ${esc(candidate.variant_id)}</b><br>
|
| 1357 |
+
<span class="meta">overall ${esc(candidate.overall)} | faith ${esc(fmtNum(candidateChartFaithfulness(candidate)))} | info ${esc(fmtNum(candidateInfographicQuality(candidate)))}</span>
|
| 1358 |
+
${renderAlignmentMeta(candidate)}
|
| 1359 |
+
<div class="tags">${renderCandidateTags(candidate)}</div>
|
| 1360 |
+
</div>
|
| 1361 |
+
`).join("") || '<div class="empty">No candidates yet.</div>'}
|
| 1362 |
+
</div>
|
| 1363 |
+
<h2>Selected Prompt</h2>
|
| 1364 |
+
${renderSelectedPromptCard()}
|
| 1365 |
+
<h2>Selected Candidate JSON</h2>
|
| 1366 |
+
<pre>${esc(JSON.stringify(selectedCandidate || selectedReport.best, null, 2))}</pre>
|
| 1367 |
+
${selectedReport.error ? `<h2>Error</h2><pre>${esc(JSON.stringify(selectedReport.error, null, 2))}</pre>` : ""}
|
| 1368 |
+
</div>
|
| 1369 |
+
</div>
|
| 1370 |
+
`;
|
| 1371 |
+
hydrateJsonFiles();
|
| 1372 |
+
}
|
| 1373 |
+
|
| 1374 |
+
function renderExperimentContent(node) {
|
| 1375 |
+
const group = buildExperimentGroups().find((item) => item.id === selectedExperimentId);
|
| 1376 |
+
if (!group) {
|
| 1377 |
+
lastExperimentContentSignature = "";
|
| 1378 |
+
node.innerHTML = '<div class="empty">No experiment selected.</div>';
|
| 1379 |
+
return;
|
| 1380 |
+
}
|
| 1381 |
+
lastExperimentContentSignature = experimentContentSignature(selectedExperimentId);
|
| 1382 |
+
const allReports = group.reports.map(({report}) => report);
|
| 1383 |
+
const overall = avg(allReports.map((report) => reportMetric(report, "overall")));
|
| 1384 |
+
const faithfulness = avg(allReports.map((report) => reportMetric(report, "chart_faithfulness_score")));
|
| 1385 |
+
const quality = avg(allReports.map((report) => reportMetric(report, "infographic_quality_score")));
|
| 1386 |
+
const change = avg(allReports.map((report) => reportMetric(report, "chart_change_score")));
|
| 1387 |
+
node.innerHTML = `
|
| 1388 |
+
<div class="summary">
|
| 1389 |
+
<div class="metric"><div class="metric-label">Experiment</div><div class="metric-value">${esc(group.id)}</div></div>
|
| 1390 |
+
<div class="metric"><div class="metric-label">Variants / Reports</div><div class="metric-value">${esc(group.variants.size)} / ${esc(group.reports.length)}</div></div>
|
| 1391 |
+
<div class="metric"><div class="metric-label">Avg Overall</div><div class="metric-value">${esc(fmtNum(overall))}</div></div>
|
| 1392 |
+
<div class="metric"><div class="metric-label">Avg Faithfulness</div><div class="metric-value">${esc(fmtNum(faithfulness))}</div></div>
|
| 1393 |
+
<div class="metric"><div class="metric-label">Avg Infographic</div><div class="metric-value">${esc(fmtNum(quality))}</div></div>
|
| 1394 |
+
<div class="metric"><div class="metric-label">Avg Change</div><div class="metric-value">${esc(fmtNum(change))}</div></div>
|
| 1395 |
+
</div>
|
| 1396 |
+
<div class="comparison-panel">
|
| 1397 |
+
<h2>Variant Summary</h2>
|
| 1398 |
+
${renderVariantSummaryTable(group)}
|
| 1399 |
+
</div>
|
| 1400 |
+
<div class="comparison-panel">
|
| 1401 |
+
<h2>Task Comparison</h2>
|
| 1402 |
+
${renderTaskComparisonTable(group)}
|
| 1403 |
+
</div>
|
| 1404 |
+
`;
|
| 1405 |
+
}
|
| 1406 |
+
|
| 1407 |
+
function renderVariantSummaryTable(group) {
|
| 1408 |
+
const rows = variantRows(group).map(({variantId, entries}) => {
|
| 1409 |
+
const variantReports = entries.map(({report}) => report);
|
| 1410 |
+
return `
|
| 1411 |
+
<tr>
|
| 1412 |
+
<td><b>${esc(variantId)}</b></td>
|
| 1413 |
+
<td>${esc(entries.length)}</td>
|
| 1414 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "overall")))))}</td>
|
| 1415 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "chart_faithfulness_score")))))}</td>
|
| 1416 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "infographic_quality_score")))))}</td>
|
| 1417 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "chart_change_score")))))}</td>
|
| 1418 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "chart_pixel_change_score")))))}</td>
|
| 1419 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "chart_deformation_score")))))}</td>
|
| 1420 |
+
<td>${esc(fmtNum(avg(variantReports.map((report) => reportMetric(report, "chart_displacement_score")))))}</td>
|
| 1421 |
+
<td>${esc(variantReports.filter((report) => reportMetric(report, "chart_alignment_reliable") === false).length)}</td>
|
| 1422 |
+
</tr>
|
| 1423 |
+
`;
|
| 1424 |
+
}).join("");
|
| 1425 |
+
return `
|
| 1426 |
+
<div class="table-wrap">
|
| 1427 |
+
<table>
|
| 1428 |
+
<thead>
|
| 1429 |
+
<tr>
|
| 1430 |
+
<th>Variant</th>
|
| 1431 |
+
<th>Reports</th>
|
| 1432 |
+
<th>Overall</th>
|
| 1433 |
+
<th>Faithfulness</th>
|
| 1434 |
+
<th>Infographic</th>
|
| 1435 |
+
<th>Change</th>
|
| 1436 |
+
<th>Pixel</th>
|
| 1437 |
+
<th>Deform</th>
|
| 1438 |
+
<th>Move</th>
|
| 1439 |
+
<th>Low Conf</th>
|
| 1440 |
+
</tr>
|
| 1441 |
+
</thead>
|
| 1442 |
+
<tbody>${rows || '<tr><td colspan="10">No variant data.</td></tr>'}</tbody>
|
| 1443 |
+
</table>
|
| 1444 |
+
</div>
|
| 1445 |
+
`;
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
function renderTaskComparisonTable(group) {
|
| 1449 |
+
const variants = variantRows(group);
|
| 1450 |
+
const taskIds = Array.from(new Set(group.reports.map(({report}) => report.task_id))).sort();
|
| 1451 |
+
const byTaskVariant = new Map();
|
| 1452 |
+
group.reports.forEach(({report, index}) => {
|
| 1453 |
+
const taskId = report.task_id;
|
| 1454 |
+
const variantId = variantIdForReport(report);
|
| 1455 |
+
const key = taskId + "\u0000" + variantId;
|
| 1456 |
+
byTaskVariant.set(key, {report, index});
|
| 1457 |
+
});
|
| 1458 |
+
const rows = taskIds.map((taskId) => `
|
| 1459 |
+
<tr>
|
| 1460 |
+
<td class="task-col"><b>${esc(taskId)}</b></td>
|
| 1461 |
+
${variants.map(({variantId}) => {
|
| 1462 |
+
const entry = byTaskVariant.get(taskId + "\u0000" + variantId);
|
| 1463 |
+
return `<td class="method-col">${entry ? renderComparisonCell(entry) : "-"}</td>`;
|
| 1464 |
+
}).join("")}
|
| 1465 |
+
</tr>
|
| 1466 |
+
`).join("");
|
| 1467 |
+
return `
|
| 1468 |
+
<div class="table-wrap">
|
| 1469 |
+
<table>
|
| 1470 |
+
<thead>
|
| 1471 |
+
<tr>
|
| 1472 |
+
<th>Task</th>
|
| 1473 |
+
${variants.map(({variantId}) => `<th>${esc(variantId)}</th>`).join("")}
|
| 1474 |
+
</tr>
|
| 1475 |
+
</thead>
|
| 1476 |
+
<tbody>${rows || `<tr><td colspan="${variants.length + 1}">No task data.</td></tr>`}</tbody>
|
| 1477 |
+
</table>
|
| 1478 |
+
</div>
|
| 1479 |
+
`;
|
| 1480 |
+
}
|
| 1481 |
+
|
| 1482 |
+
function renderComparisonCell(entry) {
|
| 1483 |
+
const report = entry.report;
|
| 1484 |
+
const measurements = reportBestMeasurements(report);
|
| 1485 |
+
const finalPath = (report.best || {}).final_path || "";
|
| 1486 |
+
const imageHtml = finalPath
|
| 1487 |
+
? `<img class="method-thumb" loading="lazy" decoding="async" src="${assetUrl(finalPath, report.version)}">`
|
| 1488 |
+
: '<div class="empty">No final image</div>';
|
| 1489 |
+
return `
|
| 1490 |
+
<div class="method-cell">
|
| 1491 |
+
<div class="method-thumb-wrap">${imageHtml}</div>
|
| 1492 |
+
<div class="method-metrics">
|
| 1493 |
+
${renderSmallMetric("Overall", reportMetric(report, "overall"))}
|
| 1494 |
+
${renderSmallMetric("Faith", reportMetric(report, "chart_faithfulness_score"))}
|
| 1495 |
+
${renderSmallMetric("Info", reportMetric(report, "infographic_quality_score"))}
|
| 1496 |
+
${renderSmallMetric("Change", measurements.chart_change_score)}
|
| 1497 |
+
${renderSmallMetric("Pixel", measurements.chart_pixel_change_score)}
|
| 1498 |
+
${renderSmallMetric("Deform", measurements.chart_deformation_score)}
|
| 1499 |
+
</div>
|
| 1500 |
+
<div class="cell-line">move ${esc(fmtNum(measurements.chart_displacement_score))} | reliable ${esc(fmt(measurements.chart_alignment_reliable))} | scale ${esc(fmtNum(measurements.chart_alignment_scale))}</div>
|
| 1501 |
+
<button class="compact-button" onclick="selectReportByIndex(${entry.index})">Open Details</button>
|
| 1502 |
+
</div>
|
| 1503 |
+
`;
|
| 1504 |
+
}
|
| 1505 |
+
|
| 1506 |
+
function renderSmallMetric(label, value) {
|
| 1507 |
+
return `
|
| 1508 |
+
<div class="method-metric">
|
| 1509 |
+
<div class="method-metric-label">${esc(label)}</div>
|
| 1510 |
+
<div class="method-metric-value">${esc(fmtNum(value))}</div>
|
| 1511 |
+
</div>
|
| 1512 |
+
`;
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
function renderStageFiles(stage) {
|
| 1516 |
+
if (!stage || !stage.files || !stage.files.length) {
|
| 1517 |
+
return '<div class="empty">No intermediate files for this stage yet.</div>';
|
| 1518 |
+
}
|
| 1519 |
+
return `<div class="stage-files">${stage.files.map((file) => `
|
| 1520 |
+
<div class="stage-file">
|
| 1521 |
+
<h3>${esc(file.label)}</h3>
|
| 1522 |
+
${renderStageFile(file)}
|
| 1523 |
+
</div>
|
| 1524 |
+
`).join("")}</div>`;
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
function renderStageFile(file) {
|
| 1528 |
+
if (file.type === "image") {
|
| 1529 |
+
return `<div class="image-wrap"><img loading="lazy" decoding="async" src="${assetUrl(file.path, file.version)}"></div>`;
|
| 1530 |
+
}
|
| 1531 |
+
if (file.type === "json") {
|
| 1532 |
+
return `<div class="json-viewer" data-json-path="${esc(file.path)}" data-json-version="${esc(file.version)}"><pre>Loading...</pre></div>`;
|
| 1533 |
+
}
|
| 1534 |
+
return `<pre>${esc(file.path)}</pre>`;
|
| 1535 |
+
}
|
| 1536 |
+
|
| 1537 |
+
async function hydrateJsonFiles() {
|
| 1538 |
+
const nodes = Array.from(document.querySelectorAll(".json-viewer[data-json-path]"));
|
| 1539 |
+
await Promise.all(nodes.map(async (node) => {
|
| 1540 |
+
const path = node.getAttribute("data-json-path");
|
| 1541 |
+
const version = node.getAttribute("data-json-version") || "";
|
| 1542 |
+
const cacheKey = path + "::" + version;
|
| 1543 |
+
if (jsonCache.has(cacheKey)) {
|
| 1544 |
+
node.innerHTML = jsonCache.get(cacheKey);
|
| 1545 |
+
return;
|
| 1546 |
+
}
|
| 1547 |
+
try {
|
| 1548 |
+
const response = await fetch(assetUrl(path, version));
|
| 1549 |
+
const text = await response.text();
|
| 1550 |
+
const rendered = renderJsonPayload(text);
|
| 1551 |
+
jsonCache.set(cacheKey, rendered);
|
| 1552 |
+
node.innerHTML = rendered;
|
| 1553 |
+
} catch (error) {
|
| 1554 |
+
node.innerHTML = `<pre>${esc(error.message)}</pre>`;
|
| 1555 |
+
}
|
| 1556 |
+
}));
|
| 1557 |
+
}
|
| 1558 |
+
|
| 1559 |
+
function setAutoRefresh(enabled) {
|
| 1560 |
+
if (refreshTimer) clearInterval(refreshTimer);
|
| 1561 |
+
refreshTimer = enabled ? setInterval(loadReports, 5000) : null;
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
async function checkViewerVersion() {
|
| 1565 |
+
try {
|
| 1566 |
+
const response = await fetch("/api/viewer-version", {cache: "no-store"});
|
| 1567 |
+
const data = await response.json();
|
| 1568 |
+
if (!data.version) return;
|
| 1569 |
+
if (viewerVersion === null) {
|
| 1570 |
+
viewerVersion = data.version;
|
| 1571 |
+
return;
|
| 1572 |
+
}
|
| 1573 |
+
if (viewerVersion !== data.version) {
|
| 1574 |
+
window.location.reload();
|
| 1575 |
+
}
|
| 1576 |
+
} catch {
|
| 1577 |
+
}
|
| 1578 |
+
}
|
| 1579 |
+
|
| 1580 |
+
document.getElementById("refresh").addEventListener("click", () => loadReports({forceContent: true}));
|
| 1581 |
+
document.getElementById("auto-refresh").addEventListener("change", (event) => {
|
| 1582 |
+
setAutoRefresh(event.target.checked);
|
| 1583 |
+
});
|
| 1584 |
+
setAutoRefresh(true);
|
| 1585 |
+
checkViewerVersion();
|
| 1586 |
+
setInterval(checkViewerVersion, 2000);
|
| 1587 |
+
loadReports({forceContent: true}).catch((error) => {
|
| 1588 |
+
document.getElementById("content").innerHTML = '<div class="empty">' + esc(error.message) + '</div>';
|
| 1589 |
+
});
|
| 1590 |
+
</script>
|
| 1591 |
+
</body>
|
| 1592 |
+
</html>
|
| 1593 |
+
"""
|
| 1594 |
+
|
| 1595 |
+
|
| 1596 |
+
def main() -> None:
|
| 1597 |
+
args = parse_args()
|
| 1598 |
+
state = ViewerState(
|
| 1599 |
+
args.output_root or [],
|
| 1600 |
+
args.scan_root,
|
| 1601 |
+
auto_discover=not args.no_auto_discover,
|
| 1602 |
+
show_all_roots=args.show_all_roots,
|
| 1603 |
+
include_legacy=args.show_legacy,
|
| 1604 |
+
)
|
| 1605 |
+
handler = type("FCIInfographicViewerHandler", (ViewerHandler,), {"state": state})
|
| 1606 |
+
server = ThreadingHTTPServer((args.host, args.port), handler)
|
| 1607 |
+
roots = ", ".join(str(root) for root in state.output_roots()) or "(none)"
|
| 1608 |
+
scan_roots = ", ".join(str(root) for root in state.scan_roots) or "(none)"
|
| 1609 |
+
print(f"Serving FCI infographic viewer at http://{args.host}:{args.port}")
|
| 1610 |
+
print(f"Output roots: {html.escape(roots)}")
|
| 1611 |
+
print(f"Scan roots: {html.escape(scan_roots)}")
|
| 1612 |
+
try:
|
| 1613 |
+
server.serve_forever()
|
| 1614 |
+
except KeyboardInterrupt:
|
| 1615 |
+
pass
|
| 1616 |
+
finally:
|
| 1617 |
+
server.server_close()
|
| 1618 |
+
|
| 1619 |
+
|
| 1620 |
+
if __name__ == "__main__":
|
| 1621 |
+
main()
|