Ray1ee01 commited on
Commit
d038835
·
verified ·
1 Parent(s): 439e630

Upload folder using huggingface_hub

Browse files
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
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
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, "&quot;")})">
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()