DatsuNTOYOTA commited on
Commit
c3b5900
·
verified ·
1 Parent(s): 8ef0522

Update app/frontend/app.py

Browse files
Files changed (1) hide show
  1. app/frontend/app.py +837 -837
app/frontend/app.py CHANGED
@@ -1,838 +1,838 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from datetime import datetime
5
- from typing import Any, Dict, Optional
6
-
7
- import requests
8
- import streamlit as st
9
-
10
- API_URL = "http://localhost:8000/analyze"
11
-
12
- # =========================
13
- # Page config
14
- # =========================
15
-
16
- st.set_page_config(
17
- page_title="Poster Design Analyzer",
18
- page_icon="🎨",
19
- layout="wide",
20
- )
21
-
22
- # =========================
23
- # Constants
24
- # =========================
25
-
26
- SCORE_ORDER = [
27
- "composition",
28
- "typography",
29
- "color",
30
- "message_clarity",
31
- "quality",
32
- ]
33
-
34
- SCORE_LABELS = {
35
- "composition": "Композиция",
36
- "typography": "Шрифты",
37
- "color": "Цвета",
38
- "message_clarity": "Ясность сообщения",
39
- "quality": "Качество",
40
- }
41
-
42
- # =========================
43
- # Styles
44
- # =========================
45
-
46
- st.markdown(
47
- """
48
- <style>
49
- .block-container {
50
- padding-top: 1.5rem;
51
- padding-bottom: 2rem;
52
- max-width: 1200px;
53
- }
54
-
55
- .hero-box {
56
- padding: 1.25rem 1.4rem;
57
- border-radius: 18px;
58
- background: linear-gradient(135deg, #121826 0%, #1f2a44 100%);
59
- color: white;
60
- margin-bottom: 1rem;
61
- border: 1px solid rgba(255,255,255,0.08);
62
- box-shadow: 0 12px 32px rgba(0,0,0,0.18);
63
- }
64
-
65
- .hero-title {
66
- font-size: 1.75rem;
67
- font-weight: 700;
68
- margin-bottom: 0.35rem;
69
- }
70
-
71
- .hero-subtitle {
72
- font-size: 1rem;
73
- opacity: 0.88;
74
- line-height: 1.5;
75
- }
76
-
77
- .section-card {
78
- background: rgba(255,255,255,0.04);
79
- border: 1px solid rgba(128,128,128,0.15);
80
- border-radius: 18px;
81
- padding: 1rem 1rem 0.9rem 1rem;
82
- margin-bottom: 0.9rem;
83
- }
84
-
85
- .metric-card {
86
- border-radius: 18px;
87
- padding: 1rem;
88
- background: linear-gradient(180deg, rgba(32,36,48,0.95), rgba(22,25,35,0.95));
89
- border: 1px solid rgba(255,255,255,0.06);
90
- box-shadow: 0 8px 24px rgba(0,0,0,0.12);
91
- min-height: 118px;
92
- }
93
-
94
- .metric-card-title {
95
- font-size: 0.95rem;
96
- color: #b8bfd3;
97
- margin-bottom: 0.35rem;
98
- }
99
-
100
- .metric-card-value {
101
- font-size: 1.55rem;
102
- font-weight: 700;
103
- color: #ffffff;
104
- margin-bottom: 0.25rem;
105
- }
106
-
107
- .metric-card-sub {
108
- font-size: 0.92rem;
109
- color: #d6dbeb;
110
- }
111
-
112
- .score-row {
113
- padding: 0.55rem 0 0.2rem 0;
114
- }
115
-
116
- .score-label {
117
- font-weight: 600;
118
- margin-bottom: 0.2rem;
119
- }
120
-
121
- .small-note {
122
- color: #98a2b3;
123
- font-size: 0.9rem;
124
- }
125
-
126
- .ok-badge {
127
- display: inline-block;
128
- padding: 0.25rem 0.55rem;
129
- border-radius: 999px;
130
- background: rgba(16, 185, 129, 0.15);
131
- color: #34d399;
132
- font-weight: 600;
133
- font-size: 0.85rem;
134
- border: 1px solid rgba(52, 211, 153, 0.25);
135
- }
136
-
137
- .bad-badge {
138
- display: inline-block;
139
- padding: 0.25rem 0.55rem;
140
- border-radius: 999px;
141
- background: rgba(239, 68, 68, 0.14);
142
- color: #f87171;
143
- font-weight: 600;
144
- font-size: 0.85rem;
145
- border: 1px solid rgba(248, 113, 113, 0.22);
146
- }
147
-
148
- .mid-badge {
149
- display: inline-block;
150
- padding: 0.25rem 0.55rem;
151
- border-radius: 999px;
152
- background: rgba(245, 158, 11, 0.14);
153
- color: #fbbf24;
154
- font-weight: 600;
155
- font-size: 0.85rem;
156
- border: 1px solid rgba(251, 191, 36, 0.2);
157
- }
158
- </style>
159
- """,
160
- unsafe_allow_html=True,
161
- )
162
-
163
- # =========================
164
- # Helpers
165
- # =========================
166
-
167
- def render_ai_detection_block(title: str, ai_detection: Optional[Dict[str, Any]]) -> None:
168
- st.markdown(f"### {title}")
169
-
170
- if not ai_detection:
171
- st.info("Нет данных.")
172
- return
173
-
174
- st.write(f"**Label:** {ai_detection.get('label', 'unknown')}")
175
- st.write(f"**prob_ai:** {ai_detection.get('prob_ai', 'n/a')}")
176
- st.write(f"**prob_human:** {ai_detection.get('prob_human', 'n/a')}")
177
- st.write(f"**confidence:** {ai_detection.get('confidence', 'unknown')}")
178
- st.write(f"**source:** `{ai_detection.get('source', 'unknown')}`")
179
-
180
- comment = ai_detection.get("comment")
181
- if comment:
182
- st.info(comment)
183
-
184
- votes = ai_detection.get("votes")
185
- if votes:
186
- with st.expander("Votes / ensemble details"):
187
- st.json(votes)
188
-
189
- def render_generatedness_tab(
190
- ai_detection: Optional[Dict[str, Any]],
191
- ai_detection_final: Optional[Dict[str, Any]],
192
- ai_detection_ml: Optional[Dict[str, Any]],
193
- ai_detection_llm: Optional[Dict[str, Any]],
194
- ai_detection_hf: Optional[Dict[str, Any]],
195
- ) -> None:
196
- st.markdown("### Анализ сгенерированности")
197
-
198
- top1, top2, top3, top4 = st.columns(4, gap="medium")
199
-
200
- def card_payload(ai_block: Optional[Dict[str, Any]]) -> tuple[str, str, str, Optional[str]]:
201
- if not ai_block:
202
- return "—", "Нет данных", "source: n/a", None
203
-
204
- prob_ai = ai_block.get("prob_ai", None)
205
- label = str(ai_block.get("label", "unknown"))
206
- source = str(ai_block.get("source", "unknown"))
207
-
208
- if prob_ai is None:
209
- value = "n/a"
210
- else:
211
- try:
212
- value = f"{float(prob_ai):.3f}"
213
- except Exception:
214
- value = "n/a"
215
-
216
- return value, label, f"source: {source}", label_badge(label)
217
-
218
- with top1:
219
- value, subtitle, source_text, badge = card_payload(ai_detection_final)
220
- render_metric_card(
221
- title="AI / Final Ensemble",
222
- value=value,
223
- subtitle=f"{subtitle} • {source_text}",
224
- badge_html=badge,
225
- )
226
-
227
- with top2:
228
- value, subtitle, source_text, badge = card_payload(ai_detection_ml)
229
- render_metric_card(
230
- title="AI / ML",
231
- value=value,
232
- subtitle=f"{subtitle} • {source_text}",
233
- badge_html=badge,
234
- )
235
-
236
- with top3:
237
- value, subtitle, source_text, badge = card_payload(ai_detection_llm)
238
- render_metric_card(
239
- title="AI / LLM",
240
- value=value,
241
- subtitle=f"{subtitle} • {source_text}",
242
- badge_html=badge,
243
- )
244
-
245
- with top4:
246
- value, subtitle, source_text, badge = card_payload(ai_detection_hf)
247
- render_metric_card(
248
- title="AI / HF",
249
- value=value,
250
- subtitle=f"{subtitle} • {source_text}",
251
- badge_html=badge,
252
- )
253
-
254
- st.markdown("---")
255
-
256
- left, right = st.columns(2, gap="large")
257
-
258
- with left:
259
- render_ai_detection_block("Итоговый ансамбль", ai_detection_final)
260
- render_ai_detection_block("ML detector", ai_detection_ml)
261
-
262
- with right:
263
- render_ai_detection_block("LLM / Ollama detector", ai_detection_llm)
264
- render_ai_detection_block("HF detector", ai_detection_hf)
265
-
266
- def render_dual_scores(
267
- official_scores: Dict[str, Any],
268
- ollama_scores: Dict[str, Any],
269
- ollama_score_reasons: Dict[str, Any],
270
- vlm_critic_status: Dict[str, Any],
271
- ollama_score_block_valid: bool,
272
- ollama_invalid_reason: str,
273
- ) -> None:
274
- st.markdown("### 5 официальных оценок")
275
-
276
- left, right = st.columns(2, gap="large")
277
-
278
- with left:
279
- st.markdown("#### ML / scores")
280
- for key in SCORE_ORDER:
281
- value = to_float(official_scores.get(key, 0.0))
282
- render_score_block(SCORE_LABELS[key], value)
283
-
284
- cols = st.columns(len(SCORE_ORDER))
285
- for i, key in enumerate(SCORE_ORDER):
286
- with cols[i]:
287
- st.metric(SCORE_LABELS[key], f"{to_float(official_scores.get(key, 0.0)):.2f}")
288
-
289
- with right:
290
- st.markdown("#### LLM / scores")
291
-
292
- if not ollama_scores:
293
- if not ollama_score_block_valid:
294
- st.warning("Ollama score block был отброшен как недостоверный.")
295
- if ollama_invalid_reason:
296
- st.info(ollama_invalid_reason)
297
- else:
298
- st.warning("Ollama score block не вернулся.")
299
-
300
- if vlm_critic_status:
301
- st.json(vlm_critic_status)
302
- else:
303
- st.info("Нет данных о причине сбоя Ollama.")
304
- else:
305
- for key in SCORE_ORDER:
306
- value = to_float(ollama_scores.get(key, 0.0))
307
- render_score_block(SCORE_LABELS[key], value)
308
-
309
- cols = st.columns(len(SCORE_ORDER))
310
- for i, key in enumerate(SCORE_ORDER):
311
- with cols[i]:
312
- st.metric(SCORE_LABELS[key], f"{to_float(ollama_scores.get(key, 0.0)):.2f}")
313
-
314
- with st.expander("Причины оценок от Ollama"):
315
- for key in SCORE_ORDER:
316
- reason = str(ollama_score_reasons.get(key, "") or "")
317
- st.write(f"**{SCORE_LABELS[key]}:** {reason if reason else '—'}")
318
-
319
- def safe_get(d: Any, *keys: str, default=None):
320
- cur = d
321
- for key in keys:
322
- if not isinstance(cur, dict) or key not in cur:
323
- return default
324
- cur = cur[key]
325
- return cur
326
-
327
-
328
- def to_float(value: Any, default: float = 0.0) -> float:
329
- try:
330
- if value is None:
331
- return default
332
- return float(value)
333
- except Exception:
334
- return default
335
-
336
-
337
- def normalize_score_5(value: float) -> float:
338
- return max(0.0, min(1.0, value / 5.0))
339
-
340
-
341
- def label_badge(label: str) -> str:
342
- label = str(label).lower()
343
-
344
- if label in {"good", "human_like"}:
345
- cls = "ok-badge"
346
- elif label in {"bad", "ai_generated"}:
347
- cls = "bad-badge"
348
- else:
349
- cls = "mid-badge"
350
-
351
- return f'<span class="{cls}">{label}</span>'
352
-
353
-
354
- def build_export_payload(data: Dict[str, Any]) -> bytes:
355
- return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
356
-
357
-
358
- def render_metric_card(title: str, value: str, subtitle: str, badge_html: Optional[str] = None) -> None:
359
- extra = f'<div style="margin-top:0.5rem;">{badge_html}</div>' if badge_html else ""
360
- st.markdown(
361
- f"""
362
- <div class="metric-card">
363
- <div class="metric-card-title">{title}</div>
364
- <div class="metric-card-value">{value}</div>
365
- <div class="metric-card-sub">{subtitle}</div>
366
- {extra}
367
- </div>
368
- """,
369
- unsafe_allow_html=True,
370
- )
371
-
372
-
373
- def render_score_block(label: str, value: float) -> None:
374
- st.markdown(
375
- f'<div class="score-row"><div class="score-label">{label}: {value:.2f} / 5</div></div>',
376
- unsafe_allow_html=True,
377
- )
378
- st.progress(normalize_score_5(value))
379
-
380
-
381
- def render_official_scores(official_scores: Dict[str, Any]) -> None:
382
- st.markdown("### 5 официальных оценок")
383
-
384
- for key in SCORE_ORDER:
385
- value = to_float(official_scores.get(key, 0.0))
386
- render_score_block(SCORE_LABELS[key], value)
387
-
388
- cols = st.columns(len(SCORE_ORDER))
389
- for i, key in enumerate(SCORE_ORDER):
390
- with cols[i]:
391
- st.metric(SCORE_LABELS[key], f"{to_float(official_scores.get(key, 0.0)):.2f}")
392
-
393
-
394
- def render_model_comparison(prediction: Dict[str, Any], filename: str) -> None:
395
- st.markdown("### Сравнение разных подходов анализа")
396
- st.markdown(
397
- """
398
- Здесь показаны результаты разных моделей:
399
- CLIP image-only, hybrid LogisticRegression, hybrid RandomForest и итоговый ансамбль.
400
- """
401
- )
402
-
403
- models_to_render = [
404
- (
405
- "Ансамбль",
406
- safe_get(prediction, "ensemble_label", default="unknown"),
407
- to_float(safe_get(prediction, "ensemble_prob_good", default=0.0)),
408
- ),
409
- (
410
- "CLIP image-only",
411
- safe_get(prediction, "clip_image_only", "label", default="unknown"),
412
- to_float(safe_get(prediction, "clip_image_only", "prob_good", default=0.0)),
413
- ),
414
- (
415
- "Hybrid LogisticRegression",
416
- safe_get(prediction, "clean_hybrid_logreg", "label", default="unknown"),
417
- to_float(safe_get(prediction, "clean_hybrid_logreg", "prob_good", default=0.0)),
418
- ),
419
- (
420
- "Hybrid RandomForest",
421
- safe_get(prediction, "clean_hybrid_random_forest", "label", default="unknown"),
422
- to_float(safe_get(prediction, "clean_hybrid_random_forest", "prob_good", default=0.0)),
423
- ),
424
- ]
425
-
426
- cols = st.columns(4, gap="medium")
427
- for idx, (title, label, prob) in enumerate(models_to_render):
428
- with cols[idx]:
429
- render_metric_card(
430
- title=title,
431
- value=f"{prob:.3f}",
432
- subtitle="prob_good",
433
- badge_html=label_badge(label),
434
- )
435
-
436
- st.markdown("### Интерпретация")
437
- st.write(
438
- f"""
439
- - Если ансамбль согласуется с CLIP и Hybrid LogisticRegression, результат обычно стабильнее.
440
- - Если RandomForest заметно расходится с остальными, это индикатор спорного кейса.
441
- - Для файла **{filename}** итоговая метка ансамбля: **{safe_get(prediction, "ensemble_label", default="unknown")}**.
442
- """
443
- )
444
-
445
-
446
- def render_ai_detection(ai_detection: Optional[Dict[str, Any]]) -> None:
447
- st.markdown("### AI detection")
448
-
449
- if not ai_detection:
450
- st.info("AI detector не подключен или не вернул результат.")
451
- return
452
-
453
- prob_ai = ai_detection.get("prob_ai", None)
454
- prob_human = ai_detection.get("prob_human", None)
455
-
456
- st.write(f"**Label:** {ai_detection.get('label', 'unknown')}")
457
- st.write(f"**prob_ai:** {prob_ai if prob_ai is not None else 'n/a'}")
458
- st.write(f"**prob_human:** {prob_human if prob_human is not None else 'n/a'}")
459
- st.write(f"**confidence:** {ai_detection.get('confidence', 'unknown')}")
460
- st.write(f"**source:** `{ai_detection.get('source', 'unknown')}`")
461
-
462
- comment = ai_detection.get("comment")
463
- if comment:
464
- st.info(comment)
465
-
466
-
467
- def render_vlm_critic(vlm_critic: Optional[Dict[str, Any]]) -> None:
468
- st.markdown("### VLM critic")
469
- if vlm_critic:
470
- st.json(vlm_critic)
471
- else:
472
- st.info("Локальный vision-LLM critic не подключен.")
473
-
474
-
475
- def render_diagnostics(
476
- diagnostic_metrics: Dict[str, Any],
477
- metric_sources: Dict[str, Any],
478
- image_features: Dict[str, Any],
479
- prediction: Dict[str, Any],
480
- ai_detection: Optional[Dict[str, Any]],
481
- ai_detection_ml: Optional[Dict[str, Any]],
482
- ai_detection_llm: Optional[Dict[str, Any]],
483
- ai_detection_hf: Optional[Dict[str, Any]],
484
- ai_detection_final: Optional[Dict[str, Any]],
485
- vlm_critic: Optional[Dict[str, Any]],
486
- show_debug: bool,
487
- show_image_features: bool,
488
- ) -> None:
489
- left, right = st.columns(2, gap="large")
490
-
491
- with left:
492
- st.markdown("### Диагностические метрики")
493
- if diagnostic_metrics:
494
- for k, v in diagnostic_metrics.items():
495
- src = metric_sources.get(k, "unknown")
496
- st.write(f"**{k}**: {v} \nИсточник: `{src}`")
497
- else:
498
- st.info("Диагностические метрики отсутствуют.")
499
-
500
- render_ai_detection_block("AI detection / final", ai_detection_final or ai_detection)
501
- render_ai_detection_block("AI detection / ML", ai_detection_ml)
502
- render_ai_detection_block("AI detection / LLM", ai_detection_llm)
503
- render_ai_detection_block("AI detection / HF", ai_detection_hf)
504
- render_vlm_critic(vlm_critic)
505
-
506
- with right:
507
- if show_image_features:
508
- st.markdown("### Image features")
509
- if image_features:
510
- st.json(image_features)
511
- else:
512
- st.info("Image features отсутствуют.")
513
-
514
- if show_debug:
515
- st.markdown("### Technical / prediction block")
516
- if prediction:
517
- st.json(prediction)
518
- else:
519
- st.info("Prediction block отсутствует.")
520
-
521
-
522
- def call_backend(api_url: str, uploaded_file) -> Dict[str, Any]:
523
- files = {
524
- "file": (
525
- uploaded_file.name,
526
- uploaded_file.getvalue(),
527
- uploaded_file.type or "application/octet-stream",
528
- )
529
- }
530
-
531
- response = requests.post(api_url, files=files, timeout=180)
532
-
533
- if response.status_code != 200:
534
- try:
535
- payload = response.json()
536
- except Exception:
537
- payload = {"raw_text": response.text}
538
-
539
- raise RuntimeError(
540
- json.dumps(
541
- {
542
- "status_code": response.status_code,
543
- "response": payload,
544
- },
545
- ensure_ascii=False,
546
- indent=2,
547
- )
548
- )
549
-
550
- return response.json()
551
-
552
- def render_chip_row(title: str, items: list[str]) -> None:
553
- st.markdown(f"### {title}")
554
- if not items:
555
- st.info("Нет данных.")
556
- return
557
-
558
- html = []
559
- for item in items:
560
- html.append(
561
- f"""
562
- <span style="
563
- display:inline-block;
564
- padding:0.28rem 0.6rem;
565
- margin:0.12rem 0.25rem 0.12rem 0;
566
- border-radius:999px;
567
- background:rgba(59,130,246,0.12);
568
- border:1px solid rgba(59,130,246,0.22);
569
- color:#dbeafe;
570
- font-size:0.88rem;
571
- font-weight:600;
572
- ">{item}</span>
573
- """
574
- )
575
- st.markdown("".join(html), unsafe_allow_html=True)
576
-
577
- def render_findings_block(title: str, items: list[str]) -> None:
578
- st.markdown(f"### {title}")
579
- if not items:
580
- st.info("Нет данных.")
581
- return
582
- for item in items:
583
- st.write(f"- {item}")
584
-
585
- def render_verdict_block(verdict: dict[str, Any]) -> None:
586
- st.markdown("### Вердикт")
587
- if not verdict:
588
- st.info("Нет данных.")
589
- return
590
-
591
- level = verdict.get("level", "unknown")
592
- summary = verdict.get("summary", "")
593
- takeaway = verdict.get("takeaway", "")
594
-
595
- st.write(f"**Уровень:** {level}")
596
- if summary:
597
- st.write(f"**Summary:** {summary}")
598
- if takeaway:
599
- st.write(f"**Takeaway:** {takeaway}")
600
-
601
-
602
-
603
- # =========================
604
- # Header
605
- # =========================
606
-
607
- st.markdown(
608
- """
609
- <div class="hero-box">
610
- <div class="hero-title">🎨 Анализ дизайна постера</div>
611
- <div class="hero-subtitle">
612
- Загрузите изображение, и система выполнит анализ по 5 ключевым осям:
613
- композиция, шрифты, цвета, ясность сообщения и качество.
614
- Отдельно показываются результаты разных моделей и AI/VLM diagnostic block.
615
- </div>
616
- </div>
617
- """,
618
- unsafe_allow_html=True,
619
- )
620
-
621
- # =========================
622
- # Sidebar
623
- # =========================
624
-
625
- with st.sidebar:
626
- st.header("Настройки")
627
- api_url = st.text_input("URL backend API", value=API_URL)
628
-
629
- st.markdown(
630
- """
631
- <div class="small-note">
632
- Backend должен поддерживать POST <code>/analyze</code> и возвращать JSON анализа.
633
- </div>
634
- """,
635
- unsafe_allow_html=True,
636
- )
637
-
638
- show_debug = st.checkbox("Показывать technical/debug blocks", value=True)
639
- show_image_features = st.checkbox("Показывать image features", value=True)
640
- show_raw_json = st.checkbox("Показывать raw JSON", value=False)
641
-
642
- # =========================
643
- # Upload block
644
- # =========================
645
-
646
- left_col, right_col = st.columns([1.1, 1.0], gap="large")
647
-
648
- with left_col:
649
- st.markdown('<div class="section-card">', unsafe_allow_html=True)
650
- uploaded_file = st.file_uploader(
651
- "Перетащи файл сюда или выбери изображение",
652
- type=["jpg", "jpeg", "png", "webp", "bmp"],
653
- )
654
- st.markdown("</div>", unsafe_allow_html=True)
655
-
656
- analyze_clicked = st.button("Анализировать", type="primary", use_container_width=True)
657
-
658
- with right_col:
659
- st.markdown('<div class="section-card">', unsafe_allow_html=True)
660
- if uploaded_file is not None:
661
- st.image(uploaded_file, caption="Загруженное изображение", use_container_width=True)
662
- else:
663
- st.info("Здесь будет превью изображения.")
664
- st.markdown("</div>", unsafe_allow_html=True)
665
-
666
- # =========================
667
- # Main flow
668
- # =========================
669
-
670
- if uploaded_file is not None and analyze_clicked:
671
- with st.spinner("Выполняется анализ..."):
672
- try:
673
- data = call_backend(api_url, uploaded_file)
674
-
675
- final_design = safe_get(data, "final_design", default={}) or {}
676
- official_scores = safe_get(data, "official_scores", default={}) or {}
677
- prediction = safe_get(data, "prediction", default={}) or {}
678
- diagnostic_metrics = safe_get(data, "diagnostic_metrics", default={}) or {}
679
- metric_sources = safe_get(data, "metric_sources", default={}) or {}
680
- image_features = safe_get(data, "image_features", default={}) or {}
681
- ai_detection_ml = safe_get(data, "ai_detection_ml", default=None)
682
- ai_detection_llm = safe_get(data, "ai_detection_llm", default=None)
683
- ai_detection_hf = safe_get(data, "ai_detection_hf", default=None)
684
- ai_detection_final = safe_get(data, "ai_detection_final", default=None)
685
- ai_detection = ai_detection_final or ai_detection_ml or ai_detection_llm or ai_detection_hf
686
- ollama_actions = data.get("ollama_actions", {}) or {}
687
- vlm_critic = safe_get(data, "vlm_critic", default=None)
688
- comment = data.get("comment", "")
689
- ollama_invalid_reason = safe_get(data, "vlm_critic", "invalid_reason", default="")
690
- ollama_score_block_valid = safe_get(data, "vlm_critic", "score_block_valid", default=True)
691
- filename = data.get("filename", uploaded_file.name)
692
- ollama_scores = data.get("ollama_scores", {}) or {}
693
- ollama_score_reasons = data.get("ollama_score_reasons", {}) or {}
694
- vlm_critic_status = data.get("vlm_critic_status", {}) or {}
695
- tags = data.get("tags", []) or []
696
- pins = data.get("pins", []) or []
697
- strengths = data.get("strengths", []) or []
698
- weaknesses = data.get("weaknesses", []) or []
699
- recommendations = data.get("recommendations", []) or []
700
- verdict = data.get("verdict", {}) or {}
701
-
702
-
703
- st.markdown("---")
704
- st.subheader("Результат анализа")
705
-
706
- card1, card2, card3, card4 = st.columns(4, gap="medium")
707
-
708
- with card1:
709
- render_metric_card(
710
- title="Итоговая метка",
711
- value=str(safe_get(final_design, "label", default="unknown")),
712
- subtitle="Финальное решение бинарного ансамбля",
713
- )
714
-
715
- with card2:
716
- render_metric_card(
717
- title="Итоговый score",
718
- value=f"{to_float(safe_get(final_design, 'score', default=0.0)):.3f}",
719
- subtitle="Диагностический дизайн-скор по 5 осям",
720
- )
721
-
722
- with card3:
723
- render_metric_card(
724
- title="Уверенность ансамбля",
725
- value=f"{to_float(safe_get(prediction, 'ensemble_prob_good', default=0.0)):.3f}",
726
- subtitle="Вероятность good по ансамблю",
727
- )
728
-
729
- with card4:
730
- if ai_detection:
731
- ai_prob = ai_detection.get("prob_ai", None)
732
- ai_prob_value = to_float(ai_prob, 0.0) if ai_prob is not None else 0.0
733
- render_metric_card(
734
- title="AI check",
735
- value=f"{ai_prob_value:.3f}" if ai_prob is not None else "n/a",
736
- subtitle="Вероятность AI-generated",
737
- badge_html=label_badge(ai_detection.get("label", "unknown")),
738
- )
739
- else:
740
- render_metric_card(
741
- title="AI check",
742
- value="—",
743
- subtitle="Локальная модель не подключена",
744
- )
745
-
746
- tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(
747
- ["5 оценок", "Сравнение подходов", "Диагностика", "Отчёт", "Экспорт", "AI / Generatedness"]
748
- )
749
-
750
- with tab1:
751
- render_dual_scores(
752
- official_scores=official_scores,
753
- ollama_scores=ollama_scores,
754
- ollama_score_reasons=ollama_score_reasons,
755
- vlm_critic_status=vlm_critic_status,
756
- ollama_score_block_valid=ollama_score_block_valid,
757
- ollama_invalid_reason=ollama_invalid_reason,
758
- )
759
-
760
- if comment:
761
- st.markdown("### Краткий комментарий")
762
- st.info(comment)
763
-
764
- with tab2:
765
- render_model_comparison(prediction, filename)
766
-
767
- with tab3:
768
- render_diagnostics(
769
- diagnostic_metrics=diagnostic_metrics,
770
- metric_sources=metric_sources,
771
- image_features=image_features,
772
- prediction=prediction,
773
- ai_detection=ai_detection,
774
- ai_detection_ml=ai_detection_ml,
775
- ai_detection_llm=ai_detection_llm,
776
- ai_detection_hf=ai_detection_hf,
777
- ai_detection_final=ai_detection_final,
778
- vlm_critic=vlm_critic,
779
- show_debug=show_debug,
780
- show_image_features=show_image_features,
781
- )
782
-
783
- with tab4:
784
- left_rep, right_rep = st.columns(2, gap="large")
785
-
786
- with left_rep:
787
- render_chip_row("Tags", tags)
788
- render_chip_row("Pins", pins)
789
-
790
- with right_rep:
791
- render_verdict_block(verdict)
792
-
793
- st.markdown("---")
794
- f1, f2, f3 = st.columns(3, gap="large")
795
-
796
- with f1:
797
- render_findings_block("Strengths", strengths)
798
- with f2:
799
- render_findings_block("Weaknesses", weaknesses)
800
- with f3:
801
- render_findings_block("Recommendations", recommendations)
802
-
803
- with tab5:
804
- st.markdown("### Экспорт отчёта")
805
- st.write("Можно скачать полный JSON-отчёт со всеми анализами.")
806
-
807
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
808
- export_name = f"poster_report_{timestamp}.json"
809
-
810
- st.download_button(
811
- label="Скачать отчёт JSON",
812
- data=build_export_payload(data),
813
- file_name=export_name,
814
- mime="application/json",
815
- use_container_width=True,
816
- )
817
-
818
- if show_raw_json:
819
- st.markdown("### Raw JSON")
820
- st.json(data)
821
- with tab6:
822
- render_generatedness_tab(
823
- ai_detection_final=ai_detection_final,
824
- ai_detection_ml=ai_detection_ml,
825
- ai_detection_llm=ai_detection_llm,
826
- ai_detection_hf=ai_detection_hf,
827
- ai_detection=ai_detection_final or ai_detection_ml or ai_detection_llm or ai_detection_hf
828
- )
829
-
830
- except requests.exceptions.ConnectionError:
831
- st.error("Не удалось подключиться к backend. Убедись, что FastAPI запущен на localhost:8000.")
832
- except requests.exceptions.Timeout:
833
- st.error("Таймаут запроса к backend.")
834
- except RuntimeError as e:
835
- st.error("Ошибка API / backend")
836
- st.code(str(e), language="json")
837
- except Exception as e:
838
  st.error(f"Непредвиденная ошибка: {e}")
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Optional
6
+
7
+ import requests
8
+ import streamlit as st
9
+
10
+ API_URL = "http://127.0.0.1:8000/analyze"
11
+
12
+ # =========================
13
+ # Page config
14
+ # =========================
15
+
16
+ st.set_page_config(
17
+ page_title="Poster Design Analyzer",
18
+ page_icon="🎨",
19
+ layout="wide",
20
+ )
21
+
22
+ # =========================
23
+ # Constants
24
+ # =========================
25
+
26
+ SCORE_ORDER = [
27
+ "composition",
28
+ "typography",
29
+ "color",
30
+ "message_clarity",
31
+ "quality",
32
+ ]
33
+
34
+ SCORE_LABELS = {
35
+ "composition": "Композиция",
36
+ "typography": "Шрифты",
37
+ "color": "Цвета",
38
+ "message_clarity": "Ясность сообщения",
39
+ "quality": "Качество",
40
+ }
41
+
42
+ # =========================
43
+ # Styles
44
+ # =========================
45
+
46
+ st.markdown(
47
+ """
48
+ <style>
49
+ .block-container {
50
+ padding-top: 1.5rem;
51
+ padding-bottom: 2rem;
52
+ max-width: 1200px;
53
+ }
54
+
55
+ .hero-box {
56
+ padding: 1.25rem 1.4rem;
57
+ border-radius: 18px;
58
+ background: linear-gradient(135deg, #121826 0%, #1f2a44 100%);
59
+ color: white;
60
+ margin-bottom: 1rem;
61
+ border: 1px solid rgba(255,255,255,0.08);
62
+ box-shadow: 0 12px 32px rgba(0,0,0,0.18);
63
+ }
64
+
65
+ .hero-title {
66
+ font-size: 1.75rem;
67
+ font-weight: 700;
68
+ margin-bottom: 0.35rem;
69
+ }
70
+
71
+ .hero-subtitle {
72
+ font-size: 1rem;
73
+ opacity: 0.88;
74
+ line-height: 1.5;
75
+ }
76
+
77
+ .section-card {
78
+ background: rgba(255,255,255,0.04);
79
+ border: 1px solid rgba(128,128,128,0.15);
80
+ border-radius: 18px;
81
+ padding: 1rem 1rem 0.9rem 1rem;
82
+ margin-bottom: 0.9rem;
83
+ }
84
+
85
+ .metric-card {
86
+ border-radius: 18px;
87
+ padding: 1rem;
88
+ background: linear-gradient(180deg, rgba(32,36,48,0.95), rgba(22,25,35,0.95));
89
+ border: 1px solid rgba(255,255,255,0.06);
90
+ box-shadow: 0 8px 24px rgba(0,0,0,0.12);
91
+ min-height: 118px;
92
+ }
93
+
94
+ .metric-card-title {
95
+ font-size: 0.95rem;
96
+ color: #b8bfd3;
97
+ margin-bottom: 0.35rem;
98
+ }
99
+
100
+ .metric-card-value {
101
+ font-size: 1.55rem;
102
+ font-weight: 700;
103
+ color: #ffffff;
104
+ margin-bottom: 0.25rem;
105
+ }
106
+
107
+ .metric-card-sub {
108
+ font-size: 0.92rem;
109
+ color: #d6dbeb;
110
+ }
111
+
112
+ .score-row {
113
+ padding: 0.55rem 0 0.2rem 0;
114
+ }
115
+
116
+ .score-label {
117
+ font-weight: 600;
118
+ margin-bottom: 0.2rem;
119
+ }
120
+
121
+ .small-note {
122
+ color: #98a2b3;
123
+ font-size: 0.9rem;
124
+ }
125
+
126
+ .ok-badge {
127
+ display: inline-block;
128
+ padding: 0.25rem 0.55rem;
129
+ border-radius: 999px;
130
+ background: rgba(16, 185, 129, 0.15);
131
+ color: #34d399;
132
+ font-weight: 600;
133
+ font-size: 0.85rem;
134
+ border: 1px solid rgba(52, 211, 153, 0.25);
135
+ }
136
+
137
+ .bad-badge {
138
+ display: inline-block;
139
+ padding: 0.25rem 0.55rem;
140
+ border-radius: 999px;
141
+ background: rgba(239, 68, 68, 0.14);
142
+ color: #f87171;
143
+ font-weight: 600;
144
+ font-size: 0.85rem;
145
+ border: 1px solid rgba(248, 113, 113, 0.22);
146
+ }
147
+
148
+ .mid-badge {
149
+ display: inline-block;
150
+ padding: 0.25rem 0.55rem;
151
+ border-radius: 999px;
152
+ background: rgba(245, 158, 11, 0.14);
153
+ color: #fbbf24;
154
+ font-weight: 600;
155
+ font-size: 0.85rem;
156
+ border: 1px solid rgba(251, 191, 36, 0.2);
157
+ }
158
+ </style>
159
+ """,
160
+ unsafe_allow_html=True,
161
+ )
162
+
163
+ # =========================
164
+ # Helpers
165
+ # =========================
166
+
167
+ def render_ai_detection_block(title: str, ai_detection: Optional[Dict[str, Any]]) -> None:
168
+ st.markdown(f"### {title}")
169
+
170
+ if not ai_detection:
171
+ st.info("Нет данных.")
172
+ return
173
+
174
+ st.write(f"**Label:** {ai_detection.get('label', 'unknown')}")
175
+ st.write(f"**prob_ai:** {ai_detection.get('prob_ai', 'n/a')}")
176
+ st.write(f"**prob_human:** {ai_detection.get('prob_human', 'n/a')}")
177
+ st.write(f"**confidence:** {ai_detection.get('confidence', 'unknown')}")
178
+ st.write(f"**source:** `{ai_detection.get('source', 'unknown')}`")
179
+
180
+ comment = ai_detection.get("comment")
181
+ if comment:
182
+ st.info(comment)
183
+
184
+ votes = ai_detection.get("votes")
185
+ if votes:
186
+ with st.expander("Votes / ensemble details"):
187
+ st.json(votes)
188
+
189
+ def render_generatedness_tab(
190
+ ai_detection: Optional[Dict[str, Any]],
191
+ ai_detection_final: Optional[Dict[str, Any]],
192
+ ai_detection_ml: Optional[Dict[str, Any]],
193
+ ai_detection_llm: Optional[Dict[str, Any]],
194
+ ai_detection_hf: Optional[Dict[str, Any]],
195
+ ) -> None:
196
+ st.markdown("### Анализ сгенерированности")
197
+
198
+ top1, top2, top3, top4 = st.columns(4, gap="medium")
199
+
200
+ def card_payload(ai_block: Optional[Dict[str, Any]]) -> tuple[str, str, str, Optional[str]]:
201
+ if not ai_block:
202
+ return "—", "Нет данных", "source: n/a", None
203
+
204
+ prob_ai = ai_block.get("prob_ai", None)
205
+ label = str(ai_block.get("label", "unknown"))
206
+ source = str(ai_block.get("source", "unknown"))
207
+
208
+ if prob_ai is None:
209
+ value = "n/a"
210
+ else:
211
+ try:
212
+ value = f"{float(prob_ai):.3f}"
213
+ except Exception:
214
+ value = "n/a"
215
+
216
+ return value, label, f"source: {source}", label_badge(label)
217
+
218
+ with top1:
219
+ value, subtitle, source_text, badge = card_payload(ai_detection_final)
220
+ render_metric_card(
221
+ title="AI / Final Ensemble",
222
+ value=value,
223
+ subtitle=f"{subtitle} • {source_text}",
224
+ badge_html=badge,
225
+ )
226
+
227
+ with top2:
228
+ value, subtitle, source_text, badge = card_payload(ai_detection_ml)
229
+ render_metric_card(
230
+ title="AI / ML",
231
+ value=value,
232
+ subtitle=f"{subtitle} • {source_text}",
233
+ badge_html=badge,
234
+ )
235
+
236
+ with top3:
237
+ value, subtitle, source_text, badge = card_payload(ai_detection_llm)
238
+ render_metric_card(
239
+ title="AI / LLM",
240
+ value=value,
241
+ subtitle=f"{subtitle} • {source_text}",
242
+ badge_html=badge,
243
+ )
244
+
245
+ with top4:
246
+ value, subtitle, source_text, badge = card_payload(ai_detection_hf)
247
+ render_metric_card(
248
+ title="AI / HF",
249
+ value=value,
250
+ subtitle=f"{subtitle} • {source_text}",
251
+ badge_html=badge,
252
+ )
253
+
254
+ st.markdown("---")
255
+
256
+ left, right = st.columns(2, gap="large")
257
+
258
+ with left:
259
+ render_ai_detection_block("Итоговый ансамбль", ai_detection_final)
260
+ render_ai_detection_block("ML detector", ai_detection_ml)
261
+
262
+ with right:
263
+ render_ai_detection_block("LLM / Ollama detector", ai_detection_llm)
264
+ render_ai_detection_block("HF detector", ai_detection_hf)
265
+
266
+ def render_dual_scores(
267
+ official_scores: Dict[str, Any],
268
+ ollama_scores: Dict[str, Any],
269
+ ollama_score_reasons: Dict[str, Any],
270
+ vlm_critic_status: Dict[str, Any],
271
+ ollama_score_block_valid: bool,
272
+ ollama_invalid_reason: str,
273
+ ) -> None:
274
+ st.markdown("### 5 официальных оценок")
275
+
276
+ left, right = st.columns(2, gap="large")
277
+
278
+ with left:
279
+ st.markdown("#### ML / scores")
280
+ for key in SCORE_ORDER:
281
+ value = to_float(official_scores.get(key, 0.0))
282
+ render_score_block(SCORE_LABELS[key], value)
283
+
284
+ cols = st.columns(len(SCORE_ORDER))
285
+ for i, key in enumerate(SCORE_ORDER):
286
+ with cols[i]:
287
+ st.metric(SCORE_LABELS[key], f"{to_float(official_scores.get(key, 0.0)):.2f}")
288
+
289
+ with right:
290
+ st.markdown("#### LLM / scores")
291
+
292
+ if not ollama_scores:
293
+ if not ollama_score_block_valid:
294
+ st.warning("Ollama score block был отброшен как недостоверный.")
295
+ if ollama_invalid_reason:
296
+ st.info(ollama_invalid_reason)
297
+ else:
298
+ st.warning("Ollama score block не вернулся.")
299
+
300
+ if vlm_critic_status:
301
+ st.json(vlm_critic_status)
302
+ else:
303
+ st.info("Нет данных о причине сбоя Ollama.")
304
+ else:
305
+ for key in SCORE_ORDER:
306
+ value = to_float(ollama_scores.get(key, 0.0))
307
+ render_score_block(SCORE_LABELS[key], value)
308
+
309
+ cols = st.columns(len(SCORE_ORDER))
310
+ for i, key in enumerate(SCORE_ORDER):
311
+ with cols[i]:
312
+ st.metric(SCORE_LABELS[key], f"{to_float(ollama_scores.get(key, 0.0)):.2f}")
313
+
314
+ with st.expander("Причины оценок от Ollama"):
315
+ for key in SCORE_ORDER:
316
+ reason = str(ollama_score_reasons.get(key, "") or "")
317
+ st.write(f"**{SCORE_LABELS[key]}:** {reason if reason else '—'}")
318
+
319
+ def safe_get(d: Any, *keys: str, default=None):
320
+ cur = d
321
+ for key in keys:
322
+ if not isinstance(cur, dict) or key not in cur:
323
+ return default
324
+ cur = cur[key]
325
+ return cur
326
+
327
+
328
+ def to_float(value: Any, default: float = 0.0) -> float:
329
+ try:
330
+ if value is None:
331
+ return default
332
+ return float(value)
333
+ except Exception:
334
+ return default
335
+
336
+
337
+ def normalize_score_5(value: float) -> float:
338
+ return max(0.0, min(1.0, value / 5.0))
339
+
340
+
341
+ def label_badge(label: str) -> str:
342
+ label = str(label).lower()
343
+
344
+ if label in {"good", "human_like"}:
345
+ cls = "ok-badge"
346
+ elif label in {"bad", "ai_generated"}:
347
+ cls = "bad-badge"
348
+ else:
349
+ cls = "mid-badge"
350
+
351
+ return f'<span class="{cls}">{label}</span>'
352
+
353
+
354
+ def build_export_payload(data: Dict[str, Any]) -> bytes:
355
+ return json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8")
356
+
357
+
358
+ def render_metric_card(title: str, value: str, subtitle: str, badge_html: Optional[str] = None) -> None:
359
+ extra = f'<div style="margin-top:0.5rem;">{badge_html}</div>' if badge_html else ""
360
+ st.markdown(
361
+ f"""
362
+ <div class="metric-card">
363
+ <div class="metric-card-title">{title}</div>
364
+ <div class="metric-card-value">{value}</div>
365
+ <div class="metric-card-sub">{subtitle}</div>
366
+ {extra}
367
+ </div>
368
+ """,
369
+ unsafe_allow_html=True,
370
+ )
371
+
372
+
373
+ def render_score_block(label: str, value: float) -> None:
374
+ st.markdown(
375
+ f'<div class="score-row"><div class="score-label">{label}: {value:.2f} / 5</div></div>',
376
+ unsafe_allow_html=True,
377
+ )
378
+ st.progress(normalize_score_5(value))
379
+
380
+
381
+ def render_official_scores(official_scores: Dict[str, Any]) -> None:
382
+ st.markdown("### 5 официальных оценок")
383
+
384
+ for key in SCORE_ORDER:
385
+ value = to_float(official_scores.get(key, 0.0))
386
+ render_score_block(SCORE_LABELS[key], value)
387
+
388
+ cols = st.columns(len(SCORE_ORDER))
389
+ for i, key in enumerate(SCORE_ORDER):
390
+ with cols[i]:
391
+ st.metric(SCORE_LABELS[key], f"{to_float(official_scores.get(key, 0.0)):.2f}")
392
+
393
+
394
+ def render_model_comparison(prediction: Dict[str, Any], filename: str) -> None:
395
+ st.markdown("### Сравнение разных подходов анализа")
396
+ st.markdown(
397
+ """
398
+ Здесь показаны результаты разных моделей:
399
+ CLIP image-only, hybrid LogisticRegression, hybrid RandomForest и итоговый ансамбль.
400
+ """
401
+ )
402
+
403
+ models_to_render = [
404
+ (
405
+ "Ансамбль",
406
+ safe_get(prediction, "ensemble_label", default="unknown"),
407
+ to_float(safe_get(prediction, "ensemble_prob_good", default=0.0)),
408
+ ),
409
+ (
410
+ "CLIP image-only",
411
+ safe_get(prediction, "clip_image_only", "label", default="unknown"),
412
+ to_float(safe_get(prediction, "clip_image_only", "prob_good", default=0.0)),
413
+ ),
414
+ (
415
+ "Hybrid LogisticRegression",
416
+ safe_get(prediction, "clean_hybrid_logreg", "label", default="unknown"),
417
+ to_float(safe_get(prediction, "clean_hybrid_logreg", "prob_good", default=0.0)),
418
+ ),
419
+ (
420
+ "Hybrid RandomForest",
421
+ safe_get(prediction, "clean_hybrid_random_forest", "label", default="unknown"),
422
+ to_float(safe_get(prediction, "clean_hybrid_random_forest", "prob_good", default=0.0)),
423
+ ),
424
+ ]
425
+
426
+ cols = st.columns(4, gap="medium")
427
+ for idx, (title, label, prob) in enumerate(models_to_render):
428
+ with cols[idx]:
429
+ render_metric_card(
430
+ title=title,
431
+ value=f"{prob:.3f}",
432
+ subtitle="prob_good",
433
+ badge_html=label_badge(label),
434
+ )
435
+
436
+ st.markdown("### Интерпретация")
437
+ st.write(
438
+ f"""
439
+ - Если ансамбль согласуется с CLIP и Hybrid LogisticRegression, результат обычно стабильнее.
440
+ - Если RandomForest заметно расходится с остальными, это индикатор спорного кейса.
441
+ - Для файла **{filename}** итоговая метка ансамбля: **{safe_get(prediction, "ensemble_label", default="unknown")}**.
442
+ """
443
+ )
444
+
445
+
446
+ def render_ai_detection(ai_detection: Optional[Dict[str, Any]]) -> None:
447
+ st.markdown("### AI detection")
448
+
449
+ if not ai_detection:
450
+ st.info("AI detector не подключен или не вернул результат.")
451
+ return
452
+
453
+ prob_ai = ai_detection.get("prob_ai", None)
454
+ prob_human = ai_detection.get("prob_human", None)
455
+
456
+ st.write(f"**Label:** {ai_detection.get('label', 'unknown')}")
457
+ st.write(f"**prob_ai:** {prob_ai if prob_ai is not None else 'n/a'}")
458
+ st.write(f"**prob_human:** {prob_human if prob_human is not None else 'n/a'}")
459
+ st.write(f"**confidence:** {ai_detection.get('confidence', 'unknown')}")
460
+ st.write(f"**source:** `{ai_detection.get('source', 'unknown')}`")
461
+
462
+ comment = ai_detection.get("comment")
463
+ if comment:
464
+ st.info(comment)
465
+
466
+
467
+ def render_vlm_critic(vlm_critic: Optional[Dict[str, Any]]) -> None:
468
+ st.markdown("### VLM critic")
469
+ if vlm_critic:
470
+ st.json(vlm_critic)
471
+ else:
472
+ st.info("Локальный vision-LLM critic не подключен.")
473
+
474
+
475
+ def render_diagnostics(
476
+ diagnostic_metrics: Dict[str, Any],
477
+ metric_sources: Dict[str, Any],
478
+ image_features: Dict[str, Any],
479
+ prediction: Dict[str, Any],
480
+ ai_detection: Optional[Dict[str, Any]],
481
+ ai_detection_ml: Optional[Dict[str, Any]],
482
+ ai_detection_llm: Optional[Dict[str, Any]],
483
+ ai_detection_hf: Optional[Dict[str, Any]],
484
+ ai_detection_final: Optional[Dict[str, Any]],
485
+ vlm_critic: Optional[Dict[str, Any]],
486
+ show_debug: bool,
487
+ show_image_features: bool,
488
+ ) -> None:
489
+ left, right = st.columns(2, gap="large")
490
+
491
+ with left:
492
+ st.markdown("### Диагностические метрики")
493
+ if diagnostic_metrics:
494
+ for k, v in diagnostic_metrics.items():
495
+ src = metric_sources.get(k, "unknown")
496
+ st.write(f"**{k}**: {v} \nИсточник: `{src}`")
497
+ else:
498
+ st.info("Диагностические метрики отсутствуют.")
499
+
500
+ render_ai_detection_block("AI detection / final", ai_detection_final or ai_detection)
501
+ render_ai_detection_block("AI detection / ML", ai_detection_ml)
502
+ render_ai_detection_block("AI detection / LLM", ai_detection_llm)
503
+ render_ai_detection_block("AI detection / HF", ai_detection_hf)
504
+ render_vlm_critic(vlm_critic)
505
+
506
+ with right:
507
+ if show_image_features:
508
+ st.markdown("### Image features")
509
+ if image_features:
510
+ st.json(image_features)
511
+ else:
512
+ st.info("Image features отсутствуют.")
513
+
514
+ if show_debug:
515
+ st.markdown("### Technical / prediction block")
516
+ if prediction:
517
+ st.json(prediction)
518
+ else:
519
+ st.info("Prediction block отсутствует.")
520
+
521
+
522
+ def call_backend(api_url: str, uploaded_file) -> Dict[str, Any]:
523
+ files = {
524
+ "file": (
525
+ uploaded_file.name,
526
+ uploaded_file.getvalue(),
527
+ uploaded_file.type or "application/octet-stream",
528
+ )
529
+ }
530
+
531
+ response = requests.post(api_url, files=files, timeout=600)
532
+
533
+ if response.status_code != 200:
534
+ try:
535
+ payload = response.json()
536
+ except Exception:
537
+ payload = {"raw_text": response.text}
538
+
539
+ raise RuntimeError(
540
+ json.dumps(
541
+ {
542
+ "status_code": response.status_code,
543
+ "response": payload,
544
+ },
545
+ ensure_ascii=False,
546
+ indent=2,
547
+ )
548
+ )
549
+
550
+ return response.json()
551
+
552
+ def render_chip_row(title: str, items: list[str]) -> None:
553
+ st.markdown(f"### {title}")
554
+ if not items:
555
+ st.info("Нет данных.")
556
+ return
557
+
558
+ html = []
559
+ for item in items:
560
+ html.append(
561
+ f"""
562
+ <span style="
563
+ display:inline-block;
564
+ padding:0.28rem 0.6rem;
565
+ margin:0.12rem 0.25rem 0.12rem 0;
566
+ border-radius:999px;
567
+ background:rgba(59,130,246,0.12);
568
+ border:1px solid rgba(59,130,246,0.22);
569
+ color:#dbeafe;
570
+ font-size:0.88rem;
571
+ font-weight:600;
572
+ ">{item}</span>
573
+ """
574
+ )
575
+ st.markdown("".join(html), unsafe_allow_html=True)
576
+
577
+ def render_findings_block(title: str, items: list[str]) -> None:
578
+ st.markdown(f"### {title}")
579
+ if not items:
580
+ st.info("Нет данных.")
581
+ return
582
+ for item in items:
583
+ st.write(f"- {item}")
584
+
585
+ def render_verdict_block(verdict: dict[str, Any]) -> None:
586
+ st.markdown("### Вердикт")
587
+ if not verdict:
588
+ st.info("Нет данных.")
589
+ return
590
+
591
+ level = verdict.get("level", "unknown")
592
+ summary = verdict.get("summary", "")
593
+ takeaway = verdict.get("takeaway", "")
594
+
595
+ st.write(f"**Уровень:** {level}")
596
+ if summary:
597
+ st.write(f"**Summary:** {summary}")
598
+ if takeaway:
599
+ st.write(f"**Takeaway:** {takeaway}")
600
+
601
+
602
+
603
+ # =========================
604
+ # Header
605
+ # =========================
606
+
607
+ st.markdown(
608
+ """
609
+ <div class="hero-box">
610
+ <div class="hero-title">🎨 Анализ дизайна постера</div>
611
+ <div class="hero-subtitle">
612
+ Загрузите изображение, и система выполнит анализ по 5 ключевым осям:
613
+ композиция, шрифты, цвета, ясность сообщения и качество.
614
+ Отдельно показываются результаты разных моделей и AI/VLM diagnostic block.
615
+ </div>
616
+ </div>
617
+ """,
618
+ unsafe_allow_html=True,
619
+ )
620
+
621
+ # =========================
622
+ # Sidebar
623
+ # =========================
624
+
625
+ with st.sidebar:
626
+ st.header("Настройки")
627
+ api_url = st.text_input("URL backend API", value=API_URL)
628
+
629
+ st.markdown(
630
+ """
631
+ <div class="small-note">
632
+ Backend должен поддерживать POST <code>/analyze</code> и возвращать JSON анализа.
633
+ </div>
634
+ """,
635
+ unsafe_allow_html=True,
636
+ )
637
+
638
+ show_debug = st.checkbox("Показывать technical/debug blocks", value=True)
639
+ show_image_features = st.checkbox("Показывать image features", value=True)
640
+ show_raw_json = st.checkbox("Показывать raw JSON", value=False)
641
+
642
+ # =========================
643
+ # Upload block
644
+ # =========================
645
+
646
+ left_col, right_col = st.columns([1.1, 1.0], gap="large")
647
+
648
+ with left_col:
649
+ st.markdown('<div class="section-card">', unsafe_allow_html=True)
650
+ uploaded_file = st.file_uploader(
651
+ "Перетащи файл сюда или выбери изображение",
652
+ type=["jpg", "jpeg", "png", "webp", "bmp"],
653
+ )
654
+ st.markdown("</div>", unsafe_allow_html=True)
655
+
656
+ analyze_clicked = st.button("Анализировать", type="primary", use_container_width=True)
657
+
658
+ with right_col:
659
+ st.markdown('<div class="section-card">', unsafe_allow_html=True)
660
+ if uploaded_file is not None:
661
+ st.image(uploaded_file, caption="Загруженное изображение", use_container_width=True)
662
+ else:
663
+ st.info("Здесь будет превью изображения.")
664
+ st.markdown("</div>", unsafe_allow_html=True)
665
+
666
+ # =========================
667
+ # Main flow
668
+ # =========================
669
+
670
+ if uploaded_file is not None and analyze_clicked:
671
+ with st.spinner("Выполняется анализ..."):
672
+ try:
673
+ data = call_backend(api_url, uploaded_file)
674
+
675
+ final_design = safe_get(data, "final_design", default={}) or {}
676
+ official_scores = safe_get(data, "official_scores", default={}) or {}
677
+ prediction = safe_get(data, "prediction", default={}) or {}
678
+ diagnostic_metrics = safe_get(data, "diagnostic_metrics", default={}) or {}
679
+ metric_sources = safe_get(data, "metric_sources", default={}) or {}
680
+ image_features = safe_get(data, "image_features", default={}) or {}
681
+ ai_detection_ml = safe_get(data, "ai_detection_ml", default=None)
682
+ ai_detection_llm = safe_get(data, "ai_detection_llm", default=None)
683
+ ai_detection_hf = safe_get(data, "ai_detection_hf", default=None)
684
+ ai_detection_final = safe_get(data, "ai_detection_final", default=None)
685
+ ai_detection = ai_detection_final or ai_detection_ml or ai_detection_llm or ai_detection_hf
686
+ ollama_actions = data.get("ollama_actions", {}) or {}
687
+ vlm_critic = safe_get(data, "vlm_critic", default=None)
688
+ comment = data.get("comment", "")
689
+ ollama_invalid_reason = safe_get(data, "vlm_critic", "invalid_reason", default="")
690
+ ollama_score_block_valid = safe_get(data, "vlm_critic", "score_block_valid", default=True)
691
+ filename = data.get("filename", uploaded_file.name)
692
+ ollama_scores = data.get("ollama_scores", {}) or {}
693
+ ollama_score_reasons = data.get("ollama_score_reasons", {}) or {}
694
+ vlm_critic_status = data.get("vlm_critic_status", {}) or {}
695
+ tags = data.get("tags", []) or []
696
+ pins = data.get("pins", []) or []
697
+ strengths = data.get("strengths", []) or []
698
+ weaknesses = data.get("weaknesses", []) or []
699
+ recommendations = data.get("recommendations", []) or []
700
+ verdict = data.get("verdict", {}) or {}
701
+
702
+
703
+ st.markdown("---")
704
+ st.subheader("Результат анализа")
705
+
706
+ card1, card2, card3, card4 = st.columns(4, gap="medium")
707
+
708
+ with card1:
709
+ render_metric_card(
710
+ title="Итоговая метка",
711
+ value=str(safe_get(final_design, "label", default="unknown")),
712
+ subtitle="Финальное решение бинарного ансамбля",
713
+ )
714
+
715
+ with card2:
716
+ render_metric_card(
717
+ title="Итоговый score",
718
+ value=f"{to_float(safe_get(final_design, 'score', default=0.0)):.3f}",
719
+ subtitle="Диагностический дизайн-скор по 5 осям",
720
+ )
721
+
722
+ with card3:
723
+ render_metric_card(
724
+ title="Уверенность ансамбля",
725
+ value=f"{to_float(safe_get(prediction, 'ensemble_prob_good', default=0.0)):.3f}",
726
+ subtitle="Вероятность good по ансамблю",
727
+ )
728
+
729
+ with card4:
730
+ if ai_detection:
731
+ ai_prob = ai_detection.get("prob_ai", None)
732
+ ai_prob_value = to_float(ai_prob, 0.0) if ai_prob is not None else 0.0
733
+ render_metric_card(
734
+ title="AI check",
735
+ value=f"{ai_prob_value:.3f}" if ai_prob is not None else "n/a",
736
+ subtitle="Вероятность AI-generated",
737
+ badge_html=label_badge(ai_detection.get("label", "unknown")),
738
+ )
739
+ else:
740
+ render_metric_card(
741
+ title="AI check",
742
+ value="—",
743
+ subtitle="Локальная модель не подключена",
744
+ )
745
+
746
+ tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs(
747
+ ["5 оценок", "Сравнение подходов", "Диагностика", "Отчёт", "Экспорт", "AI / Generatedness"]
748
+ )
749
+
750
+ with tab1:
751
+ render_dual_scores(
752
+ official_scores=official_scores,
753
+ ollama_scores=ollama_scores,
754
+ ollama_score_reasons=ollama_score_reasons,
755
+ vlm_critic_status=vlm_critic_status,
756
+ ollama_score_block_valid=ollama_score_block_valid,
757
+ ollama_invalid_reason=ollama_invalid_reason,
758
+ )
759
+
760
+ if comment:
761
+ st.markdown("### Краткий комментарий")
762
+ st.info(comment)
763
+
764
+ with tab2:
765
+ render_model_comparison(prediction, filename)
766
+
767
+ with tab3:
768
+ render_diagnostics(
769
+ diagnostic_metrics=diagnostic_metrics,
770
+ metric_sources=metric_sources,
771
+ image_features=image_features,
772
+ prediction=prediction,
773
+ ai_detection=ai_detection,
774
+ ai_detection_ml=ai_detection_ml,
775
+ ai_detection_llm=ai_detection_llm,
776
+ ai_detection_hf=ai_detection_hf,
777
+ ai_detection_final=ai_detection_final,
778
+ vlm_critic=vlm_critic,
779
+ show_debug=show_debug,
780
+ show_image_features=show_image_features,
781
+ )
782
+
783
+ with tab4:
784
+ left_rep, right_rep = st.columns(2, gap="large")
785
+
786
+ with left_rep:
787
+ render_chip_row("Tags", tags)
788
+ render_chip_row("Pins", pins)
789
+
790
+ with right_rep:
791
+ render_verdict_block(verdict)
792
+
793
+ st.markdown("---")
794
+ f1, f2, f3 = st.columns(3, gap="large")
795
+
796
+ with f1:
797
+ render_findings_block("Strengths", strengths)
798
+ with f2:
799
+ render_findings_block("Weaknesses", weaknesses)
800
+ with f3:
801
+ render_findings_block("Recommendations", recommendations)
802
+
803
+ with tab5:
804
+ st.markdown("### Экспорт отчёта")
805
+ st.write("Можно скачать полный JSON-отчёт со всеми анализами.")
806
+
807
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
808
+ export_name = f"poster_report_{timestamp}.json"
809
+
810
+ st.download_button(
811
+ label="Скачать отчёт JSON",
812
+ data=build_export_payload(data),
813
+ file_name=export_name,
814
+ mime="application/json",
815
+ use_container_width=True,
816
+ )
817
+
818
+ if show_raw_json:
819
+ st.markdown("### Raw JSON")
820
+ st.json(data)
821
+ with tab6:
822
+ render_generatedness_tab(
823
+ ai_detection_final=ai_detection_final,
824
+ ai_detection_ml=ai_detection_ml,
825
+ ai_detection_llm=ai_detection_llm,
826
+ ai_detection_hf=ai_detection_hf,
827
+ ai_detection=ai_detection_final or ai_detection_ml or ai_detection_llm or ai_detection_hf
828
+ )
829
+
830
+ except requests.exceptions.ConnectionError:
831
+ st.error("Не удалось подключиться к backend. Убедись, что FastAPI запущен на localhost:8000.")
832
+ except requests.exceptions.Timeout:
833
+ st.error("Таймаут запроса к backend.")
834
+ except RuntimeError as e:
835
+ st.error("Ошибка API / backend")
836
+ st.code(str(e), language="json")
837
+ except Exception as e:
838
  st.error(f"Непредвиденная ошибка: {e}")