Pointf5ive commited on
Commit
c262057
·
1 Parent(s): a26a57c

stage 7: revision priority queue and risk clusters

Browse files
Files changed (1) hide show
  1. app.py +379 -2
app.py CHANGED
@@ -479,6 +479,205 @@ TOTEM_CSS = """
479
  gap: var(--space-4);
480
  }
481
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
482
  .totem-shell .totem-brand {
483
  font-family: var(--font-display);
484
  font-size: 34px;
@@ -682,6 +881,12 @@ TOTEM_CSS = """
682
  .totem-lower-grid {
683
  grid-template-columns: 1fr;
684
  }
 
 
 
 
 
 
685
  .totem-signal-wrap {
686
  width: 132px;
687
  height: 132px;
@@ -712,6 +917,27 @@ TOTEM_CSS = """
712
  .totem-shell .totem-row {
713
  grid-template-columns: 1fr;
714
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  }
716
  """
717
 
@@ -1111,6 +1337,157 @@ def render_metric_cards(state: dict) -> str:
1111
  """
1112
 
1113
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1114
  def render_dashboard(state: dict) -> str:
1115
  """
1116
  Stage 3/4 static shell wrapper.
@@ -1127,8 +1504,8 @@ def render_dashboard(state: dict) -> str:
1127
  {render_status_row(s)}
1128
  {render_metric_cards(s)}
1129
  <section class="totem-lower-grid">
1130
- <div class="totem-placeholder">Revision Queue Placeholder</div>
1131
- <div class="totem-placeholder">Risk Clusters Placeholder</div>
1132
  </section>
1133
  </div>
1134
  </main>
 
479
  gap: var(--space-4);
480
  }
481
 
482
+ .totem-panel {
483
+ min-width: 0;
484
+ border-radius: var(--radius-card);
485
+ border: 1px solid rgba(174, 183, 204, 0.3);
486
+ background: linear-gradient(180deg, rgba(16, 26, 58, 0.95), rgba(8, 18, 45, 0.98));
487
+ overflow: hidden;
488
+ }
489
+
490
+ .totem-panel-title {
491
+ margin: 0;
492
+ padding: 16px 18px;
493
+ border-bottom: 1px solid rgba(174, 183, 204, 0.22);
494
+ color: rgba(246, 241, 232, 0.98);
495
+ font-family: var(--font-display);
496
+ font-size: 34px;
497
+ line-height: 1.15;
498
+ }
499
+
500
+ .totem-panel-title small {
501
+ margin-left: var(--space-2);
502
+ font-family: var(--font-ui);
503
+ font-size: 14px;
504
+ color: rgba(174, 183, 204, 0.92);
505
+ }
506
+
507
+ .totem-empty-state {
508
+ min-height: 132px;
509
+ padding: 18px;
510
+ display: grid;
511
+ align-items: center;
512
+ color: rgba(174, 183, 204, 0.95);
513
+ font-family: var(--font-ui);
514
+ font-size: 14px;
515
+ }
516
+
517
+ .totem-queue-table {
518
+ width: 100%;
519
+ }
520
+
521
+ .totem-queue-head,
522
+ .totem-queue-row {
523
+ display: grid;
524
+ grid-template-columns: 1fr 1fr .86fr .72fr 1.34fr;
525
+ gap: 10px;
526
+ align-items: start;
527
+ padding: 12px 18px;
528
+ }
529
+
530
+ .totem-queue-head {
531
+ font-family: var(--font-ui);
532
+ font-size: 12px;
533
+ text-transform: uppercase;
534
+ letter-spacing: 0.7px;
535
+ color: rgba(174, 183, 204, 0.9);
536
+ border-bottom: 1px solid rgba(174, 183, 204, 0.22);
537
+ }
538
+
539
+ .totem-queue-row {
540
+ font-family: var(--font-ui);
541
+ font-size: 13px;
542
+ color: rgba(246, 241, 232, 0.95);
543
+ border-bottom: 1px solid rgba(174, 183, 204, 0.15);
544
+ }
545
+
546
+ .totem-queue-row:last-child {
547
+ border-bottom: none;
548
+ }
549
+
550
+ .totem-queue-row span:last-child {
551
+ line-height: 1.35;
552
+ color: rgba(174, 183, 204, 0.98);
553
+ }
554
+
555
+ .totem-pill {
556
+ justify-self: start;
557
+ border-radius: 999px;
558
+ padding: 4px 10px;
559
+ border: 1px solid transparent;
560
+ font-family: var(--font-ui);
561
+ font-size: 12px;
562
+ font-weight: 600;
563
+ line-height: 1;
564
+ white-space: nowrap;
565
+ }
566
+
567
+ .totem-pill.tone-high {
568
+ color: #ffd7ea;
569
+ border-color: rgba(224, 75, 69, 0.55);
570
+ background: rgba(224, 75, 69, 0.2);
571
+ }
572
+
573
+ .totem-pill.tone-medium {
574
+ color: #ffe9b2;
575
+ border-color: rgba(242, 169, 59, 0.55);
576
+ background: rgba(242, 169, 59, 0.18);
577
+ }
578
+
579
+ .totem-pill.tone-low {
580
+ color: #c5ffeb;
581
+ border-color: rgba(57, 201, 255, 0.55);
582
+ background: rgba(57, 201, 255, 0.15);
583
+ }
584
+
585
+ .totem-risk-list {
586
+ width: 100%;
587
+ }
588
+
589
+ .totem-risk-row {
590
+ display: grid;
591
+ grid-template-columns: 54px minmax(0, 1fr) auto 150px;
592
+ gap: 12px;
593
+ align-items: center;
594
+ padding: 14px 18px;
595
+ border-bottom: 1px solid rgba(174, 183, 204, 0.15);
596
+ }
597
+
598
+ .totem-risk-row:last-child {
599
+ border-bottom: none;
600
+ }
601
+
602
+ .totem-risk-icon {
603
+ width: 46px;
604
+ height: 46px;
605
+ border-radius: 999px;
606
+ display: grid;
607
+ place-items: center;
608
+ font-family: var(--font-ui);
609
+ font-size: 18px;
610
+ font-weight: 700;
611
+ color: rgba(246, 241, 232, 0.98);
612
+ border: 1px solid rgba(174, 183, 204, 0.45);
613
+ background: rgba(7, 18, 45, 0.45);
614
+ }
615
+
616
+ .totem-risk-icon.tone-high {
617
+ border-color: rgba(224, 75, 69, 0.6);
618
+ box-shadow: 0 0 20px rgba(224, 75, 69, 0.25);
619
+ }
620
+
621
+ .totem-risk-icon.tone-medium {
622
+ border-color: rgba(242, 169, 59, 0.62);
623
+ box-shadow: 0 0 20px rgba(242, 169, 59, 0.22);
624
+ }
625
+
626
+ .totem-risk-icon.tone-low {
627
+ border-color: rgba(57, 201, 255, 0.6);
628
+ box-shadow: 0 0 20px rgba(57, 201, 255, 0.22);
629
+ }
630
+
631
+ .totem-risk-body {
632
+ min-width: 0;
633
+ }
634
+
635
+ .totem-risk-body b {
636
+ display: block;
637
+ font-family: var(--font-display);
638
+ font-size: 36px;
639
+ line-height: 1.05;
640
+ color: rgba(246, 241, 232, 0.98);
641
+ }
642
+
643
+ .totem-risk-body small {
644
+ display: block;
645
+ margin-top: 5px;
646
+ font-family: var(--font-ui);
647
+ font-size: 13px;
648
+ line-height: 1.3;
649
+ color: rgba(174, 183, 204, 0.98);
650
+ white-space: normal;
651
+ word-break: break-word;
652
+ }
653
+
654
+ .totem-sparkline {
655
+ width: 150px;
656
+ height: 40px;
657
+ display: flex;
658
+ align-items: end;
659
+ justify-content: space-between;
660
+ gap: 5px;
661
+ }
662
+
663
+ .totem-sparkline span {
664
+ flex: 1;
665
+ border-radius: 3px 3px 0 0;
666
+ background: rgba(174, 183, 204, 0.85);
667
+ }
668
+
669
+ .totem-sparkline.tone-high span {
670
+ background: linear-gradient(180deg, #ff7ea6, #e04b45);
671
+ }
672
+
673
+ .totem-sparkline.tone-medium span {
674
+ background: linear-gradient(180deg, #ffd37a, #f2a93b);
675
+ }
676
+
677
+ .totem-sparkline.tone-low span {
678
+ background: linear-gradient(180deg, #6ce6ff, #39c9ff);
679
+ }
680
+
681
  .totem-shell .totem-brand {
682
  font-family: var(--font-display);
683
  font-size: 34px;
 
881
  .totem-lower-grid {
882
  grid-template-columns: 1fr;
883
  }
884
+ .totem-panel-title {
885
+ font-size: 30px;
886
+ }
887
+ .totem-risk-body b {
888
+ font-size: 32px;
889
+ }
890
  .totem-signal-wrap {
891
  width: 132px;
892
  height: 132px;
 
917
  .totem-shell .totem-row {
918
  grid-template-columns: 1fr;
919
  }
920
+ .totem-queue-head {
921
+ display: none;
922
+ }
923
+ .totem-queue-row {
924
+ grid-template-columns: 1fr;
925
+ gap: 7px;
926
+ border-bottom: 1px solid rgba(174, 183, 204, 0.2);
927
+ }
928
+ .totem-risk-row {
929
+ grid-template-columns: 40px minmax(0, 1fr);
930
+ }
931
+ .totem-risk-row .totem-pill,
932
+ .totem-risk-row .totem-sparkline {
933
+ grid-column: 2;
934
+ }
935
+ .totem-panel-title {
936
+ font-size: 27px;
937
+ }
938
+ .totem-risk-body b {
939
+ font-size: 28px;
940
+ }
941
  }
942
  """
943
 
 
1337
  """
1338
 
1339
 
1340
+ def _risk_tone(value: str) -> tuple[str, str]:
1341
+ text = str(value or "").strip().lower()
1342
+ if "high" in text:
1343
+ return "tone-high", "High Risk"
1344
+ if "low" in text:
1345
+ return "tone-low", "Low Risk"
1346
+ return "tone-medium", "Medium Risk"
1347
+
1348
+
1349
+ def _priority_tone(value: str, gate: str) -> tuple[str, str]:
1350
+ raw = str(value or "").strip().lower()
1351
+ if not raw:
1352
+ gate_value = str(gate or "").strip().lower()
1353
+ if "hard fail" in gate_value:
1354
+ raw = "high"
1355
+ elif "soft fail" in gate_value:
1356
+ raw = "medium"
1357
+ else:
1358
+ raw = "low"
1359
+
1360
+ if raw.startswith("high"):
1361
+ return "tone-high", "High"
1362
+ if raw.startswith("low"):
1363
+ return "tone-low", "Low"
1364
+ return "tone-medium", "Medium"
1365
+
1366
+
1367
+ def _sparkline_values(values, tone_class: str) -> list[int]:
1368
+ parsed: list[int] = []
1369
+ if isinstance(values, list):
1370
+ for value in values:
1371
+ try:
1372
+ parsed.append(int(round(float(value))))
1373
+ except Exception:
1374
+ continue
1375
+
1376
+ if not parsed:
1377
+ if tone_class == "tone-high":
1378
+ parsed = [22, 30, 19, 34, 28, 37, 24, 31, 27]
1379
+ elif tone_class == "tone-low":
1380
+ parsed = [9, 13, 8, 15, 10, 14, 9, 13, 11]
1381
+ else:
1382
+ parsed = [14, 18, 12, 20, 16, 22, 14, 19, 16]
1383
+
1384
+ if len(parsed) < 8:
1385
+ parsed.extend(parsed[-1:] * (8 - len(parsed)))
1386
+ parsed = parsed[:10]
1387
+ return [max(6, min(40, v)) for v in parsed]
1388
+
1389
+
1390
+ def render_revision_queue(state: dict) -> str:
1391
+ items = state.get("revision_queue", []) if isinstance(state, dict) else []
1392
+ rows_html: list[str] = []
1393
+ if isinstance(items, list):
1394
+ for item in items[:10]:
1395
+ if not isinstance(item, dict):
1396
+ continue
1397
+ block = item.get("block") or item.get("Block") or item.get("stanza_id") or item.get("sequence") or "Block"
1398
+ weakest = (
1399
+ item.get("weakest_dimension")
1400
+ or item.get("weakestDimension")
1401
+ or item.get("metric")
1402
+ or item.get("Priority Fix")
1403
+ or "Read-aloud Flow"
1404
+ )
1405
+ gate = item.get("gate") or item.get("Gate") or "Soft Fail"
1406
+ priority_source = item.get("priority") or item.get("Priority") or ""
1407
+ tone_class, priority_label = _priority_tone(priority_source, gate)
1408
+ action = (
1409
+ item.get("recommended_action")
1410
+ or item.get("recommendedAction")
1411
+ or item.get("Next action")
1412
+ or REVISION_ACTIONS.get(str(weakest), "Revise this block before the next pass.")
1413
+ )
1414
+ rows_html.append(
1415
+ f"""
1416
+ <div class="totem-queue-row" role="row">
1417
+ <span>{esc(block)}</span>
1418
+ <span>{esc(weakest)}</span>
1419
+ <span>{esc(gate)}</span>
1420
+ <span class="totem-pill {tone_class}">{esc(priority_label)}</span>
1421
+ <span>{esc(action)}</span>
1422
+ </div>
1423
+ """
1424
+ )
1425
+
1426
+ live_count = len(rows_html)
1427
+ if not rows_html:
1428
+ body = '<div class="totem-empty-state">No priority revisions yet. Run analysis to generate the queue.</div>'
1429
+ else:
1430
+ body = f"""
1431
+ <div class="totem-queue-table" role="table" aria-label="Revision Priority Queue">
1432
+ <div class="totem-queue-head" role="row">
1433
+ <span>Block</span>
1434
+ <span>Weakest Dimension</span>
1435
+ <span>Gate</span>
1436
+ <span>Priority</span>
1437
+ <span>Recommended Action</span>
1438
+ </div>
1439
+ {''.join(rows_html)}
1440
+ </div>
1441
+ """
1442
+
1443
+ return f"""
1444
+ <section class="totem-panel">
1445
+ <h3 class="totem-panel-title">Revision Priority Queue <small>{live_count} live item(s)</small></h3>
1446
+ {body}
1447
+ </section>
1448
+ """
1449
+
1450
+
1451
+ def render_risk_clusters(state: dict) -> str:
1452
+ clusters = state.get("risk_clusters", []) if isinstance(state, dict) else []
1453
+ rows_html: list[str] = []
1454
+ if isinstance(clusters, list):
1455
+ for item in clusters[:10]:
1456
+ if not isinstance(item, dict):
1457
+ continue
1458
+ name = item.get("name") or item.get("metric") or item.get("cluster") or "Risk Cluster"
1459
+ description = item.get("description") or item.get("detail") or "No detail provided."
1460
+ tone_class, risk_label = _risk_tone(item.get("risk") or item.get("level"))
1461
+ icon = str(item.get("icon") or str(name)[:1] or "!")
1462
+ bars = _sparkline_values(item.get("sparkline"), tone_class)
1463
+ bars_html = "".join(f"<span style='height:{height}px'></span>" for height in bars)
1464
+ rows_html.append(
1465
+ f"""
1466
+ <div class="totem-risk-row">
1467
+ <span class="totem-risk-icon {tone_class}" aria-hidden="true">{esc(icon[:1].upper())}</span>
1468
+ <div class="totem-risk-body">
1469
+ <b>{esc(name)}</b>
1470
+ <small>{esc(description)}</small>
1471
+ </div>
1472
+ <span class="totem-pill {tone_class}">{esc(risk_label)}</span>
1473
+ <div class="totem-sparkline {tone_class}" aria-label="{esc(name)} sparkline">{bars_html}</div>
1474
+ </div>
1475
+ """
1476
+ )
1477
+
1478
+ if not rows_html:
1479
+ body = '<div class="totem-empty-state">No risk clusters detected.</div>'
1480
+ else:
1481
+ body = f'<div class="totem-risk-list">{"" .join(rows_html)}</div>'
1482
+
1483
+ return f"""
1484
+ <section class="totem-panel">
1485
+ <h3 class="totem-panel-title">Risk Clusters</h3>
1486
+ {body}
1487
+ </section>
1488
+ """
1489
+
1490
+
1491
  def render_dashboard(state: dict) -> str:
1492
  """
1493
  Stage 3/4 static shell wrapper.
 
1504
  {render_status_row(s)}
1505
  {render_metric_cards(s)}
1506
  <section class="totem-lower-grid">
1507
+ {render_revision_queue(s)}
1508
+ {render_risk_clusters(s)}
1509
  </section>
1510
  </div>
1511
  </main>