JaydeepR Claude Sonnet 4.6 commited on
Commit
aff4140
·
1 Parent(s): e67ba51

Dark/light mode compatibility: replace hardcoded colors with CSS vars and rgba

Browse files

All HTML components now use:
- var(--text-color) for all text (adapts to Streamlit theme)
- var(--secondary-background-color) for card backgrounds
- rgba(r,g,b,0.08-0.15) tints for verdict/category badge backgrounds
- rgba(128,128,128,0.1-0.2) for borders and dividers
- Vibrant accent colors (#22C55E #EF4444 #F59E0B #3B82F6) for verdict states

Removed base="light" from config.toml so dark mode works properly.
Fixed duplicate key in config.toml.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

.streamlit/config.toml CHANGED
@@ -1,9 +1,5 @@
1
  [theme]
2
- base = "light"
3
  primaryColor = "#2563EB"
4
- backgroundColor = "#F8FAFC"
5
- secondaryBackgroundColor = "#FFFFFF"
6
- textColor = "#0D1B2A"
7
  font = "sans serif"
8
 
9
  [browser]
 
1
  [theme]
 
2
  primaryColor = "#2563EB"
 
 
 
3
  font = "sans serif"
4
 
5
  [browser]
ui/components.py CHANGED
@@ -1,105 +1,82 @@
1
  import streamlit as st
2
 
3
- # ── Inline-style badge helpers ────────────────────────────────────────────────
4
- # Every function returns an HTML string with 100% inline styles.
5
- # No CSS classes — reliable across all Streamlit markdown containers.
6
 
7
  _BADGE = (
8
  'display:inline-flex;align-items:center;gap:4px;padding:3px 10px;'
9
  'border-radius:20px;font-size:0.78rem;font-weight:600;'
10
- 'white-space:nowrap;font-family:Inter,sans-serif;line-height:1.4;'
11
  )
12
 
13
 
14
  def verdict_pill(verdict: str) -> str:
15
  cfg = {
16
- "eligible": ("#D1FAE5", "#065F46", "#6EE7B7", "✅ Eligible"),
17
- "not_eligible": ("#FEE2E2", "#991B1B", "#FCA5A5", "❌ Not Eligible"),
18
- "needs_review": ("#FEF3C7", "#92400E", "#FCD34D", "⚠️ Needs Review"),
19
  }
20
- bg, fg, border, label = cfg.get(verdict, ("#F1F5F9", "#374151", "#CBD5E1", verdict))
21
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
22
- f'border:1px solid {border};">{label}</span>')
23
 
24
 
25
  def category_badge(category: str) -> str:
26
  cfg = {
27
- "financial": ("#DBEAFE", "#1E40AF", "#93C5FD", "💰 Financial"),
28
- "technical": ("#DCFCE7", "#166534", "#86EFAC", "🔧 Technical"),
29
- "compliance": ("#FEF3C7", "#92400E", "#FCD34D", "📋 Compliance"),
30
  }
31
- bg, fg, border, label = cfg.get(category, ("#F1F5F9", "#374151", "#CBD5E1", category))
32
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
33
- f'border:1px solid {border};">{label}</span>')
34
 
35
 
36
  def ocr_tier_badge(source_type: str) -> str:
37
  cfg = {
38
- "text_pdf": ("#F1F5F9", "#475569", "#CBD5E1", "📄 Typed PDF"),
39
- "tesseract": ("#F5F3FF", "#6D28D9", "#DDD6FE", "🔍 Tesseract"),
40
- "vision_llm": ("#FFF7ED", "#C2410C", "#FED7AA", "👁 Vision LLM"),
41
  }
42
- bg, fg, border, label = cfg.get(source_type, ("#F1F5F9", "#374151", "#CBD5E1", source_type))
43
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
44
- f'border:1px solid {border};">{label}</span>')
45
 
46
 
47
  def mandatory_badge(mandatory: bool) -> str:
48
  if mandatory:
49
- return (f'<span style="{_BADGE}background:#FEE2E2;color:#991B1B;'
50
- f'border:1px solid #FCA5A5;">🔴 Mandatory</span>')
51
- return (f'<span style="{_BADGE}background:#FFFBEB;color:#92400E;'
52
- f'border:1px solid #FDE68A;">🟡 Optional</span>')
53
 
54
 
55
  def confidence_bar(value: float, label: str = "Confidence") -> None:
56
  pct = min(max(value, 0.0), 1.0)
57
- if pct >= 0.80:
58
- bar_color, text_color = "#22C55E", "#166534"
59
- elif pct >= 0.55:
60
- bar_color, text_color = "#F59E0B", "#92400E"
61
- else:
62
- bar_color, text_color = "#EF4444", "#991B1B"
63
-
64
  st.markdown(
65
  f"""<div style="margin:6px 0 10px;">
66
- <div style="display:flex;justify-content:space-between;align-items:center;
67
- margin-bottom:4px;">
68
- <span style="font-size:0.72rem;font-weight:600;color:#94A3B8;
69
- text-transform:uppercase;letter-spacing:0.05em;">{label}</span>
70
- <span style="font-size:0.8rem;font-weight:700;color:{text_color};">{pct:.0%}</span>
71
  </div>
72
- <div style="background:#F1F5F9;border-radius:6px;height:6px;overflow:hidden;">
73
- <div style="width:{pct*100:.1f}%;background:{bar_color};
74
- height:100%;border-radius:6px;transition:width 0.3s ease;"></div>
75
  </div>
76
  </div>""",
77
  unsafe_allow_html=True,
78
  )
79
 
80
 
81
- def stat_card(value: str | int, label: str, color: str = "#2563EB") -> None:
82
- st.markdown(
83
- f"""<div style="background:#fff;border:1px solid #E2E8F0;border-radius:12px;
84
- padding:20px 18px;text-align:center;
85
- box-shadow:0 1px 3px rgba(0,0,0,0.06);">
86
- <div style="font-size:2rem;font-weight:800;color:{color};
87
- font-family:Inter,sans-serif;line-height:1.1;">{value}</div>
88
- <div style="font-size:0.7rem;font-weight:700;color:#94A3B8;margin-top:5px;
89
- text-transform:uppercase;letter-spacing:0.08em;">{label}</div>
90
- </div>""",
91
- unsafe_allow_html=True,
92
- )
93
-
94
-
95
- def page_header(title: str, subtitle: str = "") -> None:
96
- sub_html = (f'<p style="margin:6px 0 0;font-size:0.95rem;color:#64748B;'
97
- f'font-weight:400;">{subtitle}</p>') if subtitle else ""
98
  st.markdown(
99
- f"""<div style="margin-bottom:1.5rem;">
100
- <h1 style="margin:0;font-size:1.7rem;font-weight:800;color:#0D1B2A;
101
- font-family:Inter,sans-serif;letter-spacing:-0.02em;">{title}</h1>
102
- {sub_html}
 
 
103
  </div>""",
104
  unsafe_allow_html=True,
105
  )
 
1
  import streamlit as st
2
 
3
+ # Inline-style helpers — all colors use either Streamlit CSS vars or
4
+ # semi-transparent rgba so they render correctly in both light and dark mode.
 
5
 
6
  _BADGE = (
7
  'display:inline-flex;align-items:center;gap:4px;padding:3px 10px;'
8
  'border-radius:20px;font-size:0.78rem;font-weight:600;'
9
+ 'white-space:nowrap;line-height:1.4;'
10
  )
11
 
12
 
13
  def verdict_pill(verdict: str) -> str:
14
  cfg = {
15
+ "eligible": ("rgba(34,197,94,0.15)", "#22C55E", "✅ Eligible"),
16
+ "not_eligible": ("rgba(239,68,68,0.15)", "#EF4444", "❌ Not Eligible"),
17
+ "needs_review": ("rgba(245,158,11,0.15)", "#F59E0B", "⚠️ Needs Review"),
18
  }
19
+ bg, fg, label = cfg.get(verdict, ("rgba(128,128,128,0.1)", "var(--text-color)", verdict))
20
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
21
+ f'border:1px solid {fg}33;">{label}</span>')
22
 
23
 
24
  def category_badge(category: str) -> str:
25
  cfg = {
26
+ "financial": ("rgba(37,99,235,0.12)", "#3B82F6", "💰 Financial"),
27
+ "technical": ("rgba(34,197,94,0.12)", "#22C55E", "🔧 Technical"),
28
+ "compliance": ("rgba(245,158,11,0.12)", "#F59E0B", "📋 Compliance"),
29
  }
30
+ bg, fg, label = cfg.get(category, ("rgba(128,128,128,0.1)", "var(--text-color)", category))
31
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
32
+ f'border:1px solid {fg}33;">{label}</span>')
33
 
34
 
35
  def ocr_tier_badge(source_type: str) -> str:
36
  cfg = {
37
+ "text_pdf": ("rgba(100,116,139,0.12)", "#94A3B8", "📄 Typed PDF"),
38
+ "tesseract": ("rgba(124,58,237,0.12)", "#8B5CF6", "🔍 Tesseract"),
39
+ "vision_llm": ("rgba(234,88,12,0.12)", "#F97316", "👁 Vision LLM"),
40
  }
41
+ bg, fg, label = cfg.get(source_type, ("rgba(128,128,128,0.1)", "var(--text-color)", source_type))
42
  return (f'<span style="{_BADGE}background:{bg};color:{fg};'
43
+ f'border:1px solid {fg}33;">{label}</span>')
44
 
45
 
46
  def mandatory_badge(mandatory: bool) -> str:
47
  if mandatory:
48
+ return (f'<span style="{_BADGE}background:rgba(239,68,68,0.12);color:#EF4444;'
49
+ f'border:1px solid #EF444433;">🔴 Mandatory</span>')
50
+ return (f'<span style="{_BADGE}background:rgba(245,158,11,0.12);color:#F59E0B;'
51
+ f'border:1px solid #F59E0B33;">🟡 Optional</span>')
52
 
53
 
54
  def confidence_bar(value: float, label: str = "Confidence") -> None:
55
  pct = min(max(value, 0.0), 1.0)
56
+ color = "#22C55E" if pct >= 0.80 else "#F59E0B" if pct >= 0.55 else "#EF4444"
 
 
 
 
 
 
57
  st.markdown(
58
  f"""<div style="margin:6px 0 10px;">
59
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px;">
60
+ <span style="font-size:0.72rem;font-weight:600;color:var(--text-color);
61
+ opacity:0.5;text-transform:uppercase;letter-spacing:0.05em;">{label}</span>
62
+ <span style="font-size:0.8rem;font-weight:700;color:{color};">{pct:.0%}</span>
 
63
  </div>
64
+ <div style="background:rgba(128,128,128,0.15);border-radius:6px;height:6px;overflow:hidden;">
65
+ <div style="width:{pct*100:.1f}%;background:{color};height:100%;border-radius:6px;"></div>
 
66
  </div>
67
  </div>""",
68
  unsafe_allow_html=True,
69
  )
70
 
71
 
72
+ def stat_card(value: str | int, label: str, color: str = "#3B82F6") -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  st.markdown(
74
+ f"""<div style="background:var(--secondary-background-color);
75
+ border:1px solid rgba(128,128,128,0.2);border-radius:12px;
76
+ padding:20px 18px;text-align:center;">
77
+ <div style="font-size:2rem;font-weight:800;color:{color};line-height:1.1;">{value}</div>
78
+ <div style="font-size:0.7rem;font-weight:700;color:var(--text-color);opacity:0.45;
79
+ margin-top:5px;text-transform:uppercase;letter-spacing:0.08em;">{label}</div>
80
  </div>""",
81
  unsafe_allow_html=True,
82
  )
ui/tab_bidders.py CHANGED
@@ -7,9 +7,9 @@ from core.schemas import Criterion
7
  from ui.components import category_badge, confidence_bar, mandatory_badge, ocr_tier_badge, verdict_pill
8
 
9
  _BIDDER_META = {
10
- "bidder_a": ("Apex Constructions Pvt. Ltd.", "Clearly Eligible", "#059669"),
11
- "bidder_b": ("BuildRight Enterprises", "Ineligible — Low Turnover", "#EF4444"),
12
- "bidder_c": ("Shree Constructions & Services", "Needs Review — Scanned Cert", "#F59E0B"),
13
  }
14
 
15
 
@@ -22,18 +22,15 @@ def _overall(verdicts: list[dict], crit_map: dict) -> str:
22
  mand = [v for v in verdicts if crit_map.get(v["criterion_id"]) and
23
  crit_map[v["criterion_id"]].mandatory]
24
  src = mand or verdicts
25
- if any(v["verdict"] == "not_eligible" for v in src):
26
- return "not_eligible"
27
- if any(v["verdict"] == "needs_review" for v in src):
28
- return "needs_review"
29
  return "eligible"
30
 
31
 
32
  def render() -> None:
33
  st.markdown(
34
- '<h2 style="font-family:Inter,sans-serif;font-weight:800;font-size:1.5rem;'
35
- 'color:#0D1B2A;margin-bottom:4px;">Bidder Evaluation</h2>'
36
- '<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem;">'
37
  'Evaluate each bidder against all extracted criteria.</p>',
38
  unsafe_allow_html=True,
39
  )
@@ -45,12 +42,18 @@ def render() -> None:
45
  format_func=lambda x: _BIDDER_META.get(x, (x, "", ""))[0],
46
  )
47
 
48
- if st.button("▶ Run Evaluation", type="primary"):
 
 
 
 
 
 
 
49
  criteria = _get_criteria()
50
  prog = st.progress(0, text="Starting…")
51
  total = len(selected) * len(criteria)
52
- done = 0
53
- vd: dict = {}
54
  for bid in selected:
55
  files = sorted(f for f in (DATA_DIR / "bidders" / bid).iterdir()
56
  if f.suffix.lower() in {".pdf", ".png", ".jpg"})
@@ -61,7 +64,8 @@ def render() -> None:
61
  v = evaluator.evaluate(bid, c)
62
  vlist.append(v.model_dump())
63
  done += 1
64
- prog.progress(done / total, text=f"{c.id} · {_BIDDER_META.get(bid,(bid,'',''))[0]}")
 
65
  vd[bid] = vlist
66
  st.session_state["verdicts"] = vd
67
  prog.empty()
@@ -73,7 +77,7 @@ def render() -> None:
73
  crit_map = {c.id: c for c in criteria}
74
 
75
  if not vdata:
76
- st.info("No results yet — click **Run Evaluation** or load the demo from the Overview tab.")
77
  return
78
 
79
  if st.session_state.get("fallback_active"):
@@ -83,50 +87,45 @@ def render() -> None:
83
  if bid not in vdata:
84
  continue
85
  verdicts = vdata[bid]
86
- name, tagline, accent = _BIDDER_META.get(bid, (bid, "", "#2563EB"))
87
  ov = _overall(verdicts, crit_map)
88
- passed = sum(1 for v in verdicts if v["verdict"] == "eligible" and
89
- crit_map.get(v["criterion_id"]) and crit_map[v["criterion_id"]].mandatory)
90
  total_m = sum(1 for v in verdicts if crit_map.get(v["criterion_id"]) and
91
  crit_map[v["criterion_id"]].mandatory)
92
 
93
- st.markdown("<div style='height:12px'></div>", unsafe_allow_html=True)
94
  with st.container(border=True):
95
- # Bidder header row
96
- hcol1, hcol2 = st.columns([3, 2])
97
- with hcol1:
98
- st.markdown(
99
- f'<div style="display:flex;align-items:center;gap:10px;padding:4px 0 8px;">'
100
- f'<div style="width:42px;height:42px;border-radius:10px;'
101
- f'background:{accent}22;border:1px solid {accent}44;'
102
- f'display:flex;align-items:center;justify-content:center;'
103
- f'font-size:1.2rem;flex-shrink:0;">🏢</div>'
104
- f'<div>'
105
- f'<div style="font-weight:700;font-size:1rem;color:#0D1B2A;'
106
- f'font-family:Inter,sans-serif;">{name}</div>'
107
- f'<div style="font-size:0.78rem;color:#64748B;margin-top:2px;">{tagline}</div>'
108
- f'</div></div>',
109
- unsafe_allow_html=True,
110
- )
111
- with hcol2:
112
- st.markdown(
113
- f'<div style="display:flex;align-items:center;justify-content:flex-end;'
114
- f'gap:10px;padding:4px 0 8px;">'
115
- f'{verdict_pill(ov)}'
116
- f'<span style="font-size:0.78rem;background:#F8FAFC;color:#64748B;'
117
- f'padding:3px 10px;border-radius:20px;border:1px solid #E2E8F0;">'
118
- f'{passed}/{total_m} mandatory passed</span>'
119
- f'</div>',
120
- unsafe_allow_html=True,
121
- )
122
 
123
- # Column header row
124
  st.markdown(
125
  '<div style="display:grid;grid-template-columns:2.5fr 1.6fr 1.8fr 2.2fr 1.4fr;'
126
- 'gap:8px;padding:6px 2px;border-top:1px solid #F1F5F9;'
127
- 'border-bottom:1px solid #F1F5F9;margin-bottom:4px;">'
128
  + "".join(
129
- f'<div style="font-size:0.68rem;font-weight:700;color:#94A3B8;'
 
130
  f'text-transform:uppercase;letter-spacing:0.07em;">{h}</div>'
131
  for h in ["Criterion", "Verdict", "Extracted Value", "Source & Tier", "Category"]
132
  )
@@ -137,31 +136,38 @@ def render() -> None:
137
  for v in verdicts:
138
  crit = crit_map.get(v["criterion_id"])
139
  title = crit.title if crit else v["criterion_id"]
140
- mb = mandatory_badge(crit.mandatory if crit else True)
141
  cat = category_badge(crit.category if crit else "compliance")
142
  extracted = v.get("extracted_value") or ""
143
  src = v.get("source") or {}
144
 
145
- src_html = ""
146
  if src:
147
  tier = ocr_tier_badge(src.get("source_type", "text_pdf"))
148
  src_html = (
149
  f'<span style="font-family:monospace;font-size:0.78rem;'
150
- f'background:#F8FAFC;padding:2px 5px;border-radius:4px;'
151
- f'border:1px solid #E2E8F0;">{src.get("doc_name","")}</span>'
152
- f' <span style="font-size:0.75rem;color:#64748B;">p{src.get("page","")}</span>'
 
 
153
  f'<br><div style="margin-top:4px;">{tier}</div>'
154
  )
155
 
156
- extracted_cell = extracted if extracted else '<span style="color:#CBD5E1;">—</span>'
 
 
 
 
 
157
  st.markdown(
158
  f'<div style="display:grid;grid-template-columns:2.5fr 1.6fr 1.8fr 2.2fr 1.4fr;'
159
  f'gap:8px;padding:10px 2px;align-items:start;">'
160
- f'<div>{mb}<div style="font-size:0.85rem;font-weight:600;color:#0D1B2A;'
161
- f'margin-top:5px;">{v["criterion_id"]}: {title}</div></div>'
 
162
  f'<div style="padding-top:2px;">{verdict_pill(v["verdict"])}</div>'
163
- f'<div style="font-size:0.84rem;color:#374151;padding-top:4px;">'
164
- f'{extracted_cell}</div>'
165
  f'<div style="font-size:0.82rem;">{src_html}</div>'
166
  f'<div style="padding-top:2px;">{cat}</div>'
167
  f'</div>',
@@ -169,28 +175,30 @@ def render() -> None:
169
  )
170
  confidence_bar(v.get("combined_confidence", 0.0))
171
 
172
- reason = v.get("reason", "")
173
  snippet = (v.get("source") or {}).get("snippet", "")
174
  if reason or snippet:
175
  with st.expander("View reasoning & evidence", expanded=False):
176
  if reason:
177
  st.markdown(
178
- f'<div style="background:#F0F9FF;border-left:3px solid #2563EB;'
179
- f'padding:10px 14px;border-radius:0 8px 8px 0;'
180
- f'font-size:0.875rem;color:#1E3A5F;">'
181
- f'<strong>Reason:</strong> {reason}</div>',
182
  unsafe_allow_html=True,
183
  )
184
  if snippet:
185
  st.markdown(
186
- f'<div style="background:#FFFBEB;border-left:3px solid #F59E0B;'
187
- f'padding:10px 14px;border-radius:0 8px 8px 0;margin-top:8px;'
188
- f'font-size:0.84rem;color:#374151;font-style:italic;">'
 
189
  f'&ldquo;{snippet}&rdquo;</div>',
190
  unsafe_allow_html=True,
191
  )
192
 
193
  st.markdown(
194
- '<hr style="margin:2px 0;border:none;border-top:1px solid #F1F5F9;">',
 
195
  unsafe_allow_html=True,
196
  )
 
7
  from ui.components import category_badge, confidence_bar, mandatory_badge, ocr_tier_badge, verdict_pill
8
 
9
  _BIDDER_META = {
10
+ "bidder_a": ("Apex Constructions Pvt. Ltd.", "Clearly Eligible", "#22C55E"),
11
+ "bidder_b": ("BuildRight Enterprises", "Ineligible — Low Turnover", "#EF4444"),
12
+ "bidder_c": ("Shree Constructions & Services", "Needs Review — Scanned Cert", "#F59E0B"),
13
  }
14
 
15
 
 
22
  mand = [v for v in verdicts if crit_map.get(v["criterion_id"]) and
23
  crit_map[v["criterion_id"]].mandatory]
24
  src = mand or verdicts
25
+ if any(v["verdict"] == "not_eligible" for v in src): return "not_eligible"
26
+ if any(v["verdict"] == "needs_review" for v in src): return "needs_review"
 
 
27
  return "eligible"
28
 
29
 
30
  def render() -> None:
31
  st.markdown(
32
+ '<h2 style="font-weight:800;font-size:1.5rem;color:var(--text-color);">Bidder Evaluation</h2>'
33
+ '<p style="color:var(--text-color);opacity:0.6;font-size:0.875rem;margin-bottom:1rem;">'
 
34
  'Evaluate each bidder against all extracted criteria.</p>',
35
  unsafe_allow_html=True,
36
  )
 
42
  format_func=lambda x: _BIDDER_META.get(x, (x, "", ""))[0],
43
  )
44
 
45
+ criteria_loaded = bool(st.session_state.get("criteria"))
46
+ if not criteria_loaded:
47
+ st.info(
48
+ "**Criteria not loaded yet.** Go to **Tender Analysis** and click "
49
+ "**Extract Criteria**, or use **Load Pre-computed Demo** on the Overview tab."
50
+ )
51
+
52
+ if st.button("▶ Run Evaluation", type="primary", disabled=not criteria_loaded):
53
  criteria = _get_criteria()
54
  prog = st.progress(0, text="Starting…")
55
  total = len(selected) * len(criteria)
56
+ done, vd = 0, {}
 
57
  for bid in selected:
58
  files = sorted(f for f in (DATA_DIR / "bidders" / bid).iterdir()
59
  if f.suffix.lower() in {".pdf", ".png", ".jpg"})
 
64
  v = evaluator.evaluate(bid, c)
65
  vlist.append(v.model_dump())
66
  done += 1
67
+ prog.progress(done / total,
68
+ text=f"{c.id} · {_BIDDER_META.get(bid,(bid,'',''))[0]}")
69
  vd[bid] = vlist
70
  st.session_state["verdicts"] = vd
71
  prog.empty()
 
77
  crit_map = {c.id: c for c in criteria}
78
 
79
  if not vdata:
80
+ st.info("No results yet — click **Run Evaluation** above, or load the demo from Overview.")
81
  return
82
 
83
  if st.session_state.get("fallback_active"):
 
87
  if bid not in vdata:
88
  continue
89
  verdicts = vdata[bid]
90
+ name, tagline, accent = _BIDDER_META.get(bid, (bid, "", "#3B82F6"))
91
  ov = _overall(verdicts, crit_map)
92
+ passed = sum(1 for v in verdicts if v["verdict"] == "eligible" and
93
+ crit_map.get(v["criterion_id"]) and crit_map[v["criterion_id"]].mandatory)
94
  total_m = sum(1 for v in verdicts if crit_map.get(v["criterion_id"]) and
95
  crit_map[v["criterion_id"]].mandatory)
96
 
97
+ st.markdown("<div style='height:10px'></div>", unsafe_allow_html=True)
98
  with st.container(border=True):
99
+ # Header
100
+ st.markdown(
101
+ f'<div style="display:flex;justify-content:space-between;'
102
+ f'align-items:center;flex-wrap:wrap;gap:8px;padding:4px 0 12px;">'
103
+ f'<div style="display:flex;align-items:center;gap:10px;">'
104
+ f'<div style="width:40px;height:40px;border-radius:10px;'
105
+ f'background:{accent}22;border:1px solid {accent}44;display:flex;'
106
+ f'align-items:center;justify-content:center;font-size:1.1rem;">🏢</div>'
107
+ f'<div>'
108
+ f'<div style="font-weight:700;font-size:1rem;color:var(--text-color);">{name}</div>'
109
+ f'<div style="font-size:0.78rem;color:var(--text-color);opacity:0.5;margin-top:2px;">'
110
+ f'{tagline}</div></div></div>'
111
+ f'<div style="display:flex;align-items:center;gap:10px;">'
112
+ f'{verdict_pill(ov)}'
113
+ f'<span style="font-size:0.78rem;background:rgba(128,128,128,0.1);'
114
+ f'color:var(--text-color);opacity:0.7;padding:3px 10px;border-radius:20px;'
115
+ f'border:1px solid rgba(128,128,128,0.2);">'
116
+ f'{passed}/{total_m} mandatory passed</span>'
117
+ f'</div></div>',
118
+ unsafe_allow_html=True,
119
+ )
 
 
 
 
 
 
120
 
121
+ # Column headers
122
  st.markdown(
123
  '<div style="display:grid;grid-template-columns:2.5fr 1.6fr 1.8fr 2.2fr 1.4fr;'
124
+ 'gap:8px;padding:6px 2px;border-top:1px solid rgba(128,128,128,0.15);'
125
+ 'border-bottom:1px solid rgba(128,128,128,0.15);margin-bottom:4px;">'
126
  + "".join(
127
+ f'<div style="font-size:0.68rem;font-weight:700;'
128
+ f'color:var(--text-color);opacity:0.4;'
129
  f'text-transform:uppercase;letter-spacing:0.07em;">{h}</div>'
130
  for h in ["Criterion", "Verdict", "Extracted Value", "Source & Tier", "Category"]
131
  )
 
136
  for v in verdicts:
137
  crit = crit_map.get(v["criterion_id"])
138
  title = crit.title if crit else v["criterion_id"]
139
+ mb = mandatory_badge(crit.mandatory if crit else True)
140
  cat = category_badge(crit.category if crit else "compliance")
141
  extracted = v.get("extracted_value") or ""
142
  src = v.get("source") or {}
143
 
144
+ src_html = '<span style="color:var(--text-color);opacity:0.3;">—</span>'
145
  if src:
146
  tier = ocr_tier_badge(src.get("source_type", "text_pdf"))
147
  src_html = (
148
  f'<span style="font-family:monospace;font-size:0.78rem;'
149
+ f'background:rgba(128,128,128,0.1);padding:2px 5px;border-radius:4px;'
150
+ f'border:1px solid rgba(128,128,128,0.2);'
151
+ f'color:var(--text-color);">{src.get("doc_name","")}</span>'
152
+ f' <span style="font-size:0.75rem;color:var(--text-color);opacity:0.5;">'
153
+ f'p{src.get("page","")}</span>'
154
  f'<br><div style="margin-top:4px;">{tier}</div>'
155
  )
156
 
157
+ extracted_cell = (
158
+ f'<span style="font-size:0.84rem;color:var(--text-color);">{extracted}</span>'
159
+ if extracted else
160
+ '<span style="color:var(--text-color);opacity:0.3;">—</span>'
161
+ )
162
+
163
  st.markdown(
164
  f'<div style="display:grid;grid-template-columns:2.5fr 1.6fr 1.8fr 2.2fr 1.4fr;'
165
  f'gap:8px;padding:10px 2px;align-items:start;">'
166
+ f'<div>{mb}<div style="font-size:0.85rem;font-weight:600;'
167
+ f'color:var(--text-color);margin-top:5px;">'
168
+ f'{v["criterion_id"]}: {title}</div></div>'
169
  f'<div style="padding-top:2px;">{verdict_pill(v["verdict"])}</div>'
170
+ f'<div style="padding-top:4px;">{extracted_cell}</div>'
 
171
  f'<div style="font-size:0.82rem;">{src_html}</div>'
172
  f'<div style="padding-top:2px;">{cat}</div>'
173
  f'</div>',
 
175
  )
176
  confidence_bar(v.get("combined_confidence", 0.0))
177
 
178
+ reason = v.get("reason", "")
179
  snippet = (v.get("source") or {}).get("snippet", "")
180
  if reason or snippet:
181
  with st.expander("View reasoning & evidence", expanded=False):
182
  if reason:
183
  st.markdown(
184
+ f'<div style="background:rgba(37,99,235,0.08);'
185
+ f'border-left:3px solid #3B82F6;padding:10px 14px;'
186
+ f'border-radius:0 8px 8px 0;font-size:0.875rem;'
187
+ f'color:var(--text-color);"><strong>Reason:</strong> {reason}</div>',
188
  unsafe_allow_html=True,
189
  )
190
  if snippet:
191
  st.markdown(
192
+ f'<div style="background:rgba(245,158,11,0.08);'
193
+ f'border-left:3px solid #F59E0B;padding:10px 14px;'
194
+ f'border-radius:0 8px 8px 0;margin-top:8px;font-size:0.84rem;'
195
+ f'color:var(--text-color);font-style:italic;">'
196
  f'&ldquo;{snippet}&rdquo;</div>',
197
  unsafe_allow_html=True,
198
  )
199
 
200
  st.markdown(
201
+ '<hr style="margin:2px 0;border:none;'
202
+ 'border-top:1px solid rgba(128,128,128,0.1);">',
203
  unsafe_allow_html=True,
204
  )
ui/tab_interpretability.py CHANGED
@@ -7,17 +7,17 @@ from core.pdf_utils import render_page_to_image
7
  from core.schemas import Criterion
8
  from ui.components import confidence_bar, verdict_pill
9
 
10
- _VERDICT_STYLE = {
11
- "eligible": ("#F0FDF4", "#166534", "#22C55E", "✅ PASSED"),
12
- "not_eligible": ("#FEF2F2", "#991B1B", "#EF4444", "❌ FAILED"),
13
- "needs_review": ("#FFFBEB", "#92400E", "#F59E0B", "⚠️ NEEDS REVIEW"),
14
  }
15
 
16
  _RULE_PLAIN = {
17
- "numeric_threshold": lambda r: f"must be {r['operator']} {r['value']:,} {r.get('unit') or ''}".strip(),
18
- "count_threshold": lambda r: f"must have completed at least {int(r['value'])}",
19
  "certification_present": lambda _: "valid certificate must be present",
20
- "document_present": lambda _: "supporting document must be present",
21
  }
22
 
23
 
@@ -27,22 +27,20 @@ def _get_criteria() -> list[Criterion]:
27
 
28
 
29
  def _explain(v: dict, crit: Criterion | None) -> str:
30
- verdict = v.get("verdict", "")
31
  extracted = v.get("extracted_value", "") or ""
32
- reason = v.get("reason", "") or ""
33
  if not crit:
34
  return reason
35
  rule = crit.rule
36
  rule_desc = _RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
37
  if verdict == "eligible":
38
- val = f" Found **{extracted}**." if extracted else ""
39
- return f"{val} {reason}".strip()
40
  elif verdict == "not_eligible":
41
- val = f" Found **{extracted}** (requirement: {rule_desc})." if extracted else f" Requirement: {rule_desc}."
42
- return f"{val} {reason}".strip()
43
  else:
44
- val = f" Extracted value: **{extracted}**." if extracted else ""
45
- return f"{val} {reason}".strip()
46
 
47
 
48
  def _qa_context(bid: str, verdicts: list[dict], criteria: list[Criterion]) -> str:
@@ -64,7 +62,7 @@ def _qa_context(bid: str, verdicts: list[dict], criteria: list[Criterion]) -> st
64
  lines.append(f" Evidence: {s.get('doc_name')} page {s.get('page')} "
65
  f"[{s.get('source_type')}]")
66
  if s.get("snippet"):
67
- lines.append(f" Snippet: \"{s['snippet'][:200]}\"")
68
  lines.append("")
69
  return "\n".join(lines)
70
 
@@ -95,21 +93,20 @@ def _rule_answer(q: str, context: str) -> str:
95
  if any(w in q for w in ["turnover", "financial", "c1", "revenue"]):
96
  rel = [l.strip() for l in lines if "C1" in l or "turnover" in l.lower() or "Extracted" in l]
97
  return " ".join(rel[:4]) if rel else "Turnover information not found."
98
- return ("Live LLM is unavailable. The evaluation summary above contains the full details.")
99
 
100
 
101
  def render() -> None:
102
  st.markdown(
103
- '<h2 style="font-family:Inter,sans-serif;font-weight:800;font-size:1.5rem;'
104
- 'color:#0D1B2A;margin-bottom:4px;">Interpretability</h2>'
105
- '<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem;">'
106
  'Plain-English explanations with source citations. Ask any question about the evaluation.</p>',
107
  unsafe_allow_html=True,
108
  )
109
 
110
  vdata = st.session_state.get("verdicts", {})
111
  if not vdata:
112
- st.info("No results yet. Load the pre-computed demo from the Overview tab, or run evaluation.")
113
  return
114
 
115
  criteria = _get_criteria()
@@ -123,99 +120,93 @@ def render() -> None:
123
  return
124
 
125
  company = BIDDER_NAMES.get(bid, bid)
126
-
127
- # Overall verdict banner
128
  mand = [v for v in verdicts if crit_map.get(v["criterion_id"]) and
129
  crit_map[v["criterion_id"]].mandatory]
130
- failed = [v for v in mand if v["verdict"] == "not_eligible"]
131
- review = [v for v in mand if v["verdict"] == "needs_review"]
132
- passed = [v for v in mand if v["verdict"] == "eligible"]
133
 
134
  if failed:
135
- ov, bg, border, icon = "not_eligible", "#FEF2F2", "#FECACA", "❌"
136
- summary = (f"Failed {len(failed)} mandatory criterion/criteria. "
137
- f"Must meet all mandatory criteria to qualify.")
138
  elif review:
139
- ov, bg, border, icon = "needs_review", "#FFFBEB", "#FDE68A", "⚠️"
140
  summary = (f"Passed {len(passed)} mandatory criteria, but {len(review)} "
141
- f"could not be automatically confirmed and require officer sign-off.")
142
  else:
143
- ov, bg, border, icon = "eligible", "#F0FDF4", "#BBF7D0", "✅"
144
  summary = f"All {len(passed)} mandatory criteria satisfied."
145
 
146
- bg2, fg2, _, label = _VERDICT_STYLE.get(ov, ("#F1F5F9", "#374151", "#CBD5E1", ov))
147
  st.markdown(
148
- f'<div style="background:{bg2};border:1px solid {border};border-radius:12px;'
149
  f'padding:18px 20px;margin-bottom:1.5rem;display:flex;align-items:center;gap:14px;">'
150
  f'<div style="font-size:2rem;line-height:1;">{icon}</div>'
151
  f'<div>'
152
- f'<div style="font-weight:800;font-size:1.05rem;color:{fg2};">{company} — {label}</div>'
153
- f'<div style="font-size:0.84rem;color:{fg2};opacity:0.85;margin-top:4px;">{summary}</div>'
 
154
  f'</div></div>',
155
  unsafe_allow_html=True,
156
  )
157
 
158
- # Per-criterion breakdown
159
  st.markdown(
160
- '<div style="font-size:1rem;font-weight:700;color:#0D1B2A;margin-bottom:12px;'
161
- 'font-family:Inter,sans-serif;">Criterion-by-Criterion Breakdown</div>',
162
  unsafe_allow_html=True,
163
  )
164
 
165
  for v in verdicts:
166
- crit = crit_map.get(v["criterion_id"])
167
  verdict = v.get("verdict", "needs_review")
168
- cbg, cfg_, _, clabel = _VERDICT_STYLE.get(verdict, ("#F1F5F9", "#374151", "#CBD5E1", verdict))
169
  mand_txt = "Mandatory" if (crit and crit.mandatory) else "Optional"
170
- title = crit.title if crit else v["criterion_id"]
171
 
172
  with st.container(border=True):
173
  left, right = st.columns([1, 3])
174
  with left:
175
  st.markdown(
176
  f'<div style="background:{cbg};border-radius:8px;padding:14px;'
177
- f'text-align:center;height:100%;min-height:80px;'
178
- f'display:flex;flex-direction:column;align-items:center;'
179
- f'justify-content:center;gap:6px;">'
180
  f'<div style="font-weight:800;font-size:0.82rem;color:{cfg_};">{clabel}</div>'
181
- f'<div style="font-size:0.7rem;color:{cfg_};opacity:0.7;">{mand_txt}</div>'
182
  f'</div>',
183
  unsafe_allow_html=True,
184
  )
185
  confidence_bar(v.get("combined_confidence", 0.0), "Certainty")
186
  with right:
187
  st.markdown(
188
- f'<div style="font-weight:700;font-size:0.9rem;color:#0D1B2A;'
189
- f'font-family:Inter,sans-serif;">{v["criterion_id"]}: {title}</div>',
190
  unsafe_allow_html=True,
191
  )
192
  explanation = _explain(v, crit)
193
  if explanation:
194
  st.markdown(
195
- f'<p style="font-size:0.875rem;color:#374151;margin:8px 0;">{explanation}</p>',
 
196
  unsafe_allow_html=True,
197
  )
198
- # Source citation
199
  src = v.get("source") or {}
200
  if src:
201
- doc = src.get("doc_name", "")
202
- page = src.get("page", "")
203
  tier_labels = {"text_pdf": "typed PDF", "tesseract": "Tesseract OCR",
204
  "vision_llm": "Vision LLM"}
205
  tier = tier_labels.get(src.get("source_type", ""), "")
206
  st.markdown(
207
  f'<div style="display:inline-flex;align-items:center;gap:6px;'
208
- f'background:#F8FAFC;border:1px solid #E2E8F0;border-radius:6px;'
209
- f'padding:5px 10px;font-size:0.78rem;">'
210
  f'<span>📄</span>'
211
- f'<strong style="color:#1E40AF;">{doc}</strong>'
212
- f'<span style="color:#94A3B8;">page {page}</span>'
213
- f'<span style="color:#94A3B8;">·</span>'
214
- f'<span style="color:#64748B;">{tier}</span>'
215
  f'</div>',
216
  unsafe_allow_html=True,
217
  )
218
- # Inline page preview
219
  doc_path = DATA_DIR / "bidders" / bid / doc
220
  if doc_path.exists() and doc_path.suffix.lower() == ".pdf":
221
  with st.expander(f"View source: {doc}, page {page}", expanded=False):
@@ -226,26 +217,24 @@ def render() -> None:
226
  except Exception:
227
  st.caption("Page preview unavailable.")
228
  elif doc_path.exists() and doc_path.suffix.lower() in {".png", ".jpg"}:
229
- with st.expander(f"View source image: {doc}", expanded=False):
230
  st.image(str(doc_path), use_column_width=True)
231
 
232
  st.divider()
233
 
234
- # Q&A section
235
  st.markdown(
236
- '<div style="font-size:1rem;font-weight:700;color:#0D1B2A;margin-bottom:4px;'
237
- 'font-family:Inter,sans-serif;">Ask About This Evaluation</div>'
238
- '<p style="font-size:0.82rem;color:#64748B;margin-bottom:12px;">'
239
- 'The model answers using the evaluation data above and cites specific documents.</p>',
240
  unsafe_allow_html=True,
241
  )
242
 
243
- examples = ["Why was this bidder rejected?",
244
- "What turnover figure was found, and from which document?",
245
- "Does this bidder have a valid ISO 9001:2015 certificate?",
246
- "Why is the turnover verdict in review?"]
247
  with st.expander("Example questions", expanded=False):
248
- for e in examples:
 
 
 
249
  st.markdown(f"- _{e}_")
250
 
251
  question = st.text_input("", placeholder="Ask anything about this bidder's evaluation…",
@@ -258,12 +247,13 @@ def render() -> None:
258
  with st.spinner("Looking up the answer…"):
259
  answer = _answer(question, context)
260
  st.markdown(
261
- f'<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:10px;'
 
262
  f'padding:16px 18px;margin-top:8px;">'
263
- f'<div style="font-size:0.72rem;font-weight:700;color:#1E40AF;'
264
  f'text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Answer</div>'
265
- f'<div style="font-size:0.9rem;color:#1E3A5F;line-height:1.7;">{answer}</div>'
266
- f'</div>',
267
  unsafe_allow_html=True,
268
  )
269
  with st.expander("Full context used", expanded=False):
 
7
  from core.schemas import Criterion
8
  from ui.components import confidence_bar, verdict_pill
9
 
10
+ _VERDICT_CFG = {
11
+ "eligible": ("rgba(34,197,94,0.12)", "#22C55E", "✅ PASSED"),
12
+ "not_eligible": ("rgba(239,68,68,0.12)", "#EF4444", "❌ FAILED"),
13
+ "needs_review": ("rgba(245,158,11,0.12)", "#F59E0B", "⚠️ NEEDS REVIEW"),
14
  }
15
 
16
  _RULE_PLAIN = {
17
+ "numeric_threshold": lambda r: f"must be {r['operator']} {r['value']:,} {r.get('unit') or ''}".strip(),
18
+ "count_threshold": lambda r: f"must have completed at least {int(r['value'])}",
19
  "certification_present": lambda _: "valid certificate must be present",
20
+ "document_present": lambda _: "supporting document must be present",
21
  }
22
 
23
 
 
27
 
28
 
29
  def _explain(v: dict, crit: Criterion | None) -> str:
30
+ verdict = v.get("verdict", "")
31
  extracted = v.get("extracted_value", "") or ""
32
+ reason = v.get("reason", "") or ""
33
  if not crit:
34
  return reason
35
  rule = crit.rule
36
  rule_desc = _RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
37
  if verdict == "eligible":
38
+ return (f"Found **{extracted}**. " if extracted else "") + reason
 
39
  elif verdict == "not_eligible":
40
+ return ((f"Found **{extracted}** does not meet requirement ({rule_desc}). "
41
+ if extracted else f"Requirement: {rule_desc}. ") + reason)
42
  else:
43
+ return (f"Extracted value: **{extracted}**. " if extracted else "") + reason
 
44
 
45
 
46
  def _qa_context(bid: str, verdicts: list[dict], criteria: list[Criterion]) -> str:
 
62
  lines.append(f" Evidence: {s.get('doc_name')} page {s.get('page')} "
63
  f"[{s.get('source_type')}]")
64
  if s.get("snippet"):
65
+ lines.append(f' Snippet: "{s["snippet"][:200]}"')
66
  lines.append("")
67
  return "\n".join(lines)
68
 
 
93
  if any(w in q for w in ["turnover", "financial", "c1", "revenue"]):
94
  rel = [l.strip() for l in lines if "C1" in l or "turnover" in l.lower() or "Extracted" in l]
95
  return " ".join(rel[:4]) if rel else "Turnover information not found."
96
+ return "Live LLM is unavailable. The evaluation summary above contains the full details."
97
 
98
 
99
  def render() -> None:
100
  st.markdown(
101
+ '<h2 style="font-weight:800;font-size:1.5rem;color:var(--text-color);">Interpretability</h2>'
102
+ '<p style="color:var(--text-color);opacity:0.6;font-size:0.875rem;margin-bottom:1rem;">'
 
103
  'Plain-English explanations with source citations. Ask any question about the evaluation.</p>',
104
  unsafe_allow_html=True,
105
  )
106
 
107
  vdata = st.session_state.get("verdicts", {})
108
  if not vdata:
109
+ st.info("No results yet. Load the pre-computed demo from Overview, or run evaluation.")
110
  return
111
 
112
  criteria = _get_criteria()
 
120
  return
121
 
122
  company = BIDDER_NAMES.get(bid, bid)
 
 
123
  mand = [v for v in verdicts if crit_map.get(v["criterion_id"]) and
124
  crit_map[v["criterion_id"]].mandatory]
125
+ failed = [v for v in mand if v["verdict"] == "not_eligible"]
126
+ review = [v for v in mand if v["verdict"] == "needs_review"]
127
+ passed = [v for v in mand if v["verdict"] == "eligible"]
128
 
129
  if failed:
130
+ ov, fg, icon = "not_eligible", "#EF4444", "❌"
131
+ summary = f"Failed {len(failed)} mandatory criterion/criteria. Must meet all to qualify."
 
132
  elif review:
133
+ ov, fg, icon = "needs_review", "#F59E0B", "⚠️"
134
  summary = (f"Passed {len(passed)} mandatory criteria, but {len(review)} "
135
+ f"require officer sign-off.")
136
  else:
137
+ ov, fg, icon = "eligible", "#22C55E", "✅"
138
  summary = f"All {len(passed)} mandatory criteria satisfied."
139
 
140
+ bg, _, label = _VERDICT_CFG.get(ov, ("rgba(128,128,128,0.1)", "#888", ov))
141
  st.markdown(
142
+ f'<div style="background:{bg};border:1px solid {fg}33;border-radius:12px;'
143
  f'padding:18px 20px;margin-bottom:1.5rem;display:flex;align-items:center;gap:14px;">'
144
  f'<div style="font-size:2rem;line-height:1;">{icon}</div>'
145
  f'<div>'
146
+ f'<div style="font-weight:800;font-size:1.05rem;color:{fg};">'
147
+ f'{company}{label}</div>'
148
+ f'<div style="font-size:0.84rem;color:{fg};opacity:0.85;margin-top:4px;">{summary}</div>'
149
  f'</div></div>',
150
  unsafe_allow_html=True,
151
  )
152
 
 
153
  st.markdown(
154
+ '<div style="font-size:1rem;font-weight:700;color:var(--text-color);margin-bottom:12px;">'
155
+ 'Criterion-by-Criterion Breakdown</div>',
156
  unsafe_allow_html=True,
157
  )
158
 
159
  for v in verdicts:
160
+ crit = crit_map.get(v["criterion_id"])
161
  verdict = v.get("verdict", "needs_review")
162
+ cbg, cfg_, clabel = _VERDICT_CFG.get(verdict, ("rgba(128,128,128,0.1)", "var(--text-color)", verdict))
163
  mand_txt = "Mandatory" if (crit and crit.mandatory) else "Optional"
164
+ title = crit.title if crit else v["criterion_id"]
165
 
166
  with st.container(border=True):
167
  left, right = st.columns([1, 3])
168
  with left:
169
  st.markdown(
170
  f'<div style="background:{cbg};border-radius:8px;padding:14px;'
171
+ f'text-align:center;min-height:80px;display:flex;flex-direction:column;'
172
+ f'align-items:center;justify-content:center;gap:6px;">'
 
173
  f'<div style="font-weight:800;font-size:0.82rem;color:{cfg_};">{clabel}</div>'
174
+ f'<div style="font-size:0.7rem;color:{cfg_};opacity:0.75;">{mand_txt}</div>'
175
  f'</div>',
176
  unsafe_allow_html=True,
177
  )
178
  confidence_bar(v.get("combined_confidence", 0.0), "Certainty")
179
  with right:
180
  st.markdown(
181
+ f'<div style="font-weight:700;font-size:0.9rem;color:var(--text-color);">'
182
+ f'{v["criterion_id"]}: {title}</div>',
183
  unsafe_allow_html=True,
184
  )
185
  explanation = _explain(v, crit)
186
  if explanation:
187
  st.markdown(
188
+ f'<p style="font-size:0.875rem;color:var(--text-color);'
189
+ f'opacity:0.85;margin:8px 0;">{explanation}</p>',
190
  unsafe_allow_html=True,
191
  )
 
192
  src = v.get("source") or {}
193
  if src:
194
+ doc, page = src.get("doc_name", ""), src.get("page", "")
 
195
  tier_labels = {"text_pdf": "typed PDF", "tesseract": "Tesseract OCR",
196
  "vision_llm": "Vision LLM"}
197
  tier = tier_labels.get(src.get("source_type", ""), "")
198
  st.markdown(
199
  f'<div style="display:inline-flex;align-items:center;gap:6px;'
200
+ f'background:rgba(128,128,128,0.08);border:1px solid rgba(128,128,128,0.2);'
201
+ f'border-radius:6px;padding:5px 10px;font-size:0.78rem;">'
202
  f'<span>📄</span>'
203
+ f'<strong style="color:#3B82F6;">{doc}</strong>'
204
+ f'<span style="color:var(--text-color);opacity:0.5;">page {page}</span>'
205
+ f'<span style="color:var(--text-color);opacity:0.3;">·</span>'
206
+ f'<span style="color:var(--text-color);opacity:0.6;">{tier}</span>'
207
  f'</div>',
208
  unsafe_allow_html=True,
209
  )
 
210
  doc_path = DATA_DIR / "bidders" / bid / doc
211
  if doc_path.exists() and doc_path.suffix.lower() == ".pdf":
212
  with st.expander(f"View source: {doc}, page {page}", expanded=False):
 
217
  except Exception:
218
  st.caption("Page preview unavailable.")
219
  elif doc_path.exists() and doc_path.suffix.lower() in {".png", ".jpg"}:
220
+ with st.expander(f"View: {doc}", expanded=False):
221
  st.image(str(doc_path), use_column_width=True)
222
 
223
  st.divider()
224
 
 
225
  st.markdown(
226
+ '<div style="font-size:1rem;font-weight:700;color:var(--text-color);margin-bottom:4px;">'
227
+ 'Ask About This Evaluation</div>'
228
+ '<p style="font-size:0.82rem;color:var(--text-color);opacity:0.6;margin-bottom:12px;">'
229
+ 'Answers cite specific documents and pages.</p>',
230
  unsafe_allow_html=True,
231
  )
232
 
 
 
 
 
233
  with st.expander("Example questions", expanded=False):
234
+ for e in ["Why was this bidder rejected?",
235
+ "What turnover figure was found, and from which document?",
236
+ "Does this bidder have a valid ISO 9001:2015 certificate?",
237
+ "Why is the turnover verdict in review?"]:
238
  st.markdown(f"- _{e}_")
239
 
240
  question = st.text_input("", placeholder="Ask anything about this bidder's evaluation…",
 
247
  with st.spinner("Looking up the answer…"):
248
  answer = _answer(question, context)
249
  st.markdown(
250
+ f'<div style="background:rgba(37,99,235,0.08);'
251
+ f'border:1px solid rgba(37,99,235,0.2);border-radius:10px;'
252
  f'padding:16px 18px;margin-top:8px;">'
253
+ f'<div style="font-size:0.72rem;font-weight:700;color:#3B82F6;'
254
  f'text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Answer</div>'
255
+ f'<div style="font-size:0.9rem;color:var(--text-color);line-height:1.7;">'
256
+ f'{answer}</div></div>',
257
  unsafe_allow_html=True,
258
  )
259
  with st.expander("Full context used", expanded=False):
ui/tab_overview.py CHANGED
@@ -2,12 +2,11 @@ import streamlit as st
2
 
3
  from core import audit
4
  from core.config import BIDDER_NAMES
5
- from core.fallback import load_criteria
6
  from ui.components import stat_card
7
 
8
 
9
  def render() -> None:
10
- # Hero
11
  st.markdown(
12
  """<div style="background:linear-gradient(135deg,#0D1B2A 0%,#1E3A5F 60%,#2563EB 100%);
13
  border-radius:16px;padding:2.5rem 2.5rem 2rem;margin-bottom:1.5rem;">
@@ -15,11 +14,9 @@ def render() -> None:
15
  text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">
16
  CRPF Hackathon · Theme 3</div>
17
  <h1 style="margin:0;font-size:2.2rem;font-weight:800;color:#FFFFFF;
18
- font-family:Inter,sans-serif;letter-spacing:-0.02em;line-height:1.2;">
19
- ⚖️ TenderIQ</h1>
20
- <p style="margin:10px 0 0;font-size:1rem;color:#CBD5E1;max-width:600px;
21
- line-height:1.6;font-weight:400;">
22
- Explainable AI for Government Tender Evaluation. Automated eligibility
23
  assessment with criterion-level evidence, three-tier OCR, and a complete
24
  compliance audit trail.</p>
25
  </div>""",
@@ -33,66 +30,58 @@ def render() -> None:
33
  audit_count = len(audit.query())
34
 
35
  c1, c2, c3, c4 = st.columns(4)
36
- with c1: stat_card(criteria_count, "Criteria Extracted", "#2563EB")
37
- with c2: stat_card(len(verdicts), "Bidders Evaluated", "#059669")
38
- with c3: stat_card(checked, "Criteria Checked", "#7C3AED")
39
- with c4: stat_card(audit_count, "Audit Entries", "#D97706")
40
 
41
  st.divider()
42
 
43
  # Pipeline stages
44
  st.markdown(
45
- '<h3 style="margin:0 0 1rem;font-size:1.1rem;font-weight:700;color:#0D1B2A;'
46
- 'font-family:Inter,sans-serif;">How it works</h3>',
47
  unsafe_allow_html=True,
48
  )
49
 
50
  stages = [
51
- ("#2563EB", "#EFF6FF", "#BFDBFE", "1", "Extract Criteria",
52
  "DeepSeek reads the tender PDF and returns structured JSON for each criterion — "
53
- "category, mandatory flag, rule (threshold / count / certificate), source clause, "
54
- "and query hints for downstream retrieval."),
55
- ("#7C3AED", "#F5F3FF", "#DDD6FE", "2", "Three-Tier OCR",
56
  "📄 PyMuPDF for typed PDFs · 🔍 Tesseract for scans · 👁 DeepSeek Vision LLM "
57
- "when Tesseract confidence < 65%. Every page records its tier and confidence score. "
58
- "Results cached to avoid re-processing."),
59
- ("#059669", "#F0FDF4", "#BBF7D0", "3", "Evaluate per Criterion",
60
- "Semantic search retrieves the top-k relevant chunks from ChromaDB. DeepSeek "
61
- "produces a verdict with combined confidence. Safety rule: borderline "
62
- "not-eligible is downgraded to needs-review never silent disqualification."),
63
- ("#D97706", "#FFFBEB", "#FDE68A", "4", "Human Review & Audit",
64
- "Flagged verdicts surface in the review queue with full evidence and source "
65
- "citations. Every action — extraction, OCR, evaluation, officer decision — is "
66
- "logged to SQLite with timestamp, model version, and payload."),
67
  ]
68
 
69
  cols = st.columns(4)
70
- for col, (accent, bg, border, num, title, body) in zip(cols, stages):
71
  with col:
72
  st.markdown(
73
- f"""<div style="background:{bg};border:1px solid {border};border-radius:12px;
74
- padding:18px 16px;height:100%;">
75
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
76
  <div style="background:{accent};color:#fff;border-radius:50%;
77
  width:24px;height:24px;display:flex;align-items:center;
78
  justify-content:center;font-size:0.75rem;font-weight:700;
79
  flex-shrink:0;">{num}</div>
80
- <span style="font-weight:700;font-size:0.9rem;color:#0D1B2A;
81
- font-family:Inter,sans-serif;">{title}</span>
82
  </div>
83
- <p style="margin:0;font-size:0.82rem;color:#374151;line-height:1.6;">{body}</p>
 
84
  </div>""",
85
  unsafe_allow_html=True,
86
  )
87
 
88
  st.divider()
89
 
90
- # Quick start
91
- st.markdown(
92
- '<h3 style="margin:0 0 1rem;font-size:1.1rem;font-weight:700;color:#0D1B2A;'
93
- 'font-family:Inter,sans-serif;">Quick Start</h3>',
94
- unsafe_allow_html=True,
95
- )
96
  col1, col2 = st.columns(2)
97
  with col1:
98
  with st.container(border=True):
 
2
 
3
  from core import audit
4
  from core.config import BIDDER_NAMES
 
5
  from ui.components import stat_card
6
 
7
 
8
  def render() -> None:
9
+ # Hero — intentional dark gradient, works as a visual anchor in both modes
10
  st.markdown(
11
  """<div style="background:linear-gradient(135deg,#0D1B2A 0%,#1E3A5F 60%,#2563EB 100%);
12
  border-radius:16px;padding:2.5rem 2.5rem 2rem;margin-bottom:1.5rem;">
 
14
  text-transform:uppercase;letter-spacing:0.1em;margin-bottom:8px;">
15
  CRPF Hackathon · Theme 3</div>
16
  <h1 style="margin:0;font-size:2.2rem;font-weight:800;color:#FFFFFF;
17
+ letter-spacing:-0.02em;line-height:1.2;">⚖️ TenderIQ</h1>
18
+ <p style="margin:10px 0 0;font-size:1rem;color:#CBD5E1;max-width:600px;line-height:1.6;">
19
+ Explainable AI for Government Tender Evaluation — automated eligibility
 
 
20
  assessment with criterion-level evidence, three-tier OCR, and a complete
21
  compliance audit trail.</p>
22
  </div>""",
 
30
  audit_count = len(audit.query())
31
 
32
  c1, c2, c3, c4 = st.columns(4)
33
+ with c1: stat_card(criteria_count, "Criteria Extracted", "#3B82F6")
34
+ with c2: stat_card(len(verdicts), "Bidders Evaluated", "#22C55E")
35
+ with c3: stat_card(checked, "Criteria Checked", "#8B5CF6")
36
+ with c4: stat_card(audit_count, "Audit Entries", "#F59E0B")
37
 
38
  st.divider()
39
 
40
  # Pipeline stages
41
  st.markdown(
42
+ '<p style="font-size:1rem;font-weight:700;color:var(--text-color);">'
43
+ 'How it works</p>',
44
  unsafe_allow_html=True,
45
  )
46
 
47
  stages = [
48
+ ("#3B82F6", "rgba(37,99,235,0.08)", "1", "Extract Criteria",
49
  "DeepSeek reads the tender PDF and returns structured JSON for each criterion — "
50
+ "category, mandatory flag, rule, source clause, and query hints."),
51
+ ("#8B5CF6", "rgba(124,58,237,0.08)", "2", "Three-Tier OCR",
 
52
  "📄 PyMuPDF for typed PDFs · 🔍 Tesseract for scans · 👁 DeepSeek Vision LLM "
53
+ "when Tesseract confidence < 65%. Every page records its tier and confidence."),
54
+ ("#22C55E", "rgba(34,197,94,0.08)", "3", "Evaluate per Criterion",
55
+ "Semantic search retrieves the top-k evidence chunks. DeepSeek returns a verdict "
56
+ "with combined confidence. Safety rule: borderline not-eligible is always "
57
+ "downgraded to needs-review."),
58
+ ("#F59E0B", "rgba(245,158,11,0.08)", "4", "Human Review & Audit",
59
+ "Flagged verdicts surface with full evidence and source citations. Every action "
60
+ "is logged to SQLite with timestamp, model version, actor, and payload."),
 
 
61
  ]
62
 
63
  cols = st.columns(4)
64
+ for col, (accent, bg, num, title, body) in zip(cols, stages):
65
  with col:
66
  st.markdown(
67
+ f"""<div style="background:{bg};border:1px solid {accent}33;
68
+ border-radius:12px;padding:18px 16px;height:100%;">
69
  <div style="display:flex;align-items:center;gap:8px;margin-bottom:10px;">
70
  <div style="background:{accent};color:#fff;border-radius:50%;
71
  width:24px;height:24px;display:flex;align-items:center;
72
  justify-content:center;font-size:0.75rem;font-weight:700;
73
  flex-shrink:0;">{num}</div>
74
+ <span style="font-weight:700;font-size:0.9rem;color:var(--text-color);">
75
+ {title}</span>
76
  </div>
77
+ <p style="margin:0;font-size:0.82rem;color:var(--text-color);
78
+ opacity:0.75;line-height:1.6;">{body}</p>
79
  </div>""",
80
  unsafe_allow_html=True,
81
  )
82
 
83
  st.divider()
84
 
 
 
 
 
 
 
85
  col1, col2 = st.columns(2)
86
  with col1:
87
  with st.container(border=True):
ui/tab_review.py CHANGED
@@ -14,9 +14,9 @@ def _crit_map() -> dict[str, Criterion]:
14
 
15
  def render() -> None:
16
  st.markdown(
17
- '<h2 style="font-family:Inter,sans-serif;font-weight:800;font-size:1.5rem;'
18
- 'color:#0D1B2A;margin-bottom:4px;">Human Review Queue</h2>'
19
- '<p style="color:#64748B;font-size:0.875rem;margin-bottom:1rem;">'
20
  'Verdicts that could not be automatically confirmed require officer sign-off.</p>',
21
  unsafe_allow_html=True,
22
  )
@@ -27,27 +27,29 @@ def render() -> None:
27
  return
28
 
29
  cm = _crit_map()
30
- pending = [(bid, i, v)
31
- for bid, verdicts in vdata.items()
32
- for i, v in enumerate(verdicts)
33
- if v.get("verdict") == "needs_review" and
34
- v.get("review_status", "pending") == "pending"]
 
 
35
 
36
  if not pending:
37
  st.success("✅ All flagged verdicts have been actioned. Nothing pending.")
38
  return
39
 
40
- # Summary banner
41
  st.markdown(
42
- f'<div style="background:#FEF3C7;border:1px solid #FDE68A;border-radius:10px;'
43
- f'padding:14px 18px;margin-bottom:1.5rem;display:flex;align-items:center;gap:10px;">'
 
44
  f'<span style="font-size:1.3rem;">⚠️</span>'
45
  f'<div>'
46
- f'<div style="font-weight:700;color:#92400E;font-size:0.9rem;">'
47
  f'{len(pending)} item{"s" if len(pending) != 1 else ""} pending review</div>'
48
- f'<div style="font-size:0.8rem;color:#78350F;margin-top:2px;">'
49
- f'High certainty = the model is confident this case needs human judgment, '
50
- f'not that the bidder is likely ineligible.</div>'
51
  f'</div></div>',
52
  unsafe_allow_html=True,
53
  )
@@ -58,14 +60,14 @@ def render() -> None:
58
  company = BIDDER_NAMES.get(bid, bid)
59
 
60
  with st.container(border=True):
61
- # Header
62
  st.markdown(
63
  f'<div style="display:flex;justify-content:space-between;'
64
  f'align-items:flex-start;gap:12px;margin-bottom:10px;">'
65
  f'<div>'
66
- f'<div style="font-weight:700;font-size:0.95rem;color:#0D1B2A;">{company}</div>'
67
- f'<div style="font-size:0.82rem;color:#64748B;margin-top:2px;">'
68
- f'{v["criterion_id"]}: {crit_title}</div>'
 
69
  f'</div>'
70
  f'{verdict_pill(v["verdict"])}'
71
  f'</div>',
@@ -76,46 +78,48 @@ def render() -> None:
76
  with col_l:
77
  if v.get("extracted_value"):
78
  st.markdown(
79
- f'<div style="font-size:0.84rem;margin-bottom:8px;">'
 
80
  f'<strong>Extracted value:</strong> '
81
- f'<code style="background:#F1F5F9;padding:2px 7px;border-radius:4px;'
82
- f'font-size:0.82rem;">{v["extracted_value"]}</code></div>',
83
  unsafe_allow_html=True,
84
  )
85
  if v.get("reason"):
86
  st.markdown(
87
- f'<div style="background:#FFFBEB;border-left:3px solid #F59E0B;'
88
- f'padding:9px 13px;border-radius:0 7px 7px 0;font-size:0.84rem;'
89
- f'color:#374151;margin-bottom:8px;">'
 
90
  f'<strong>Reason:</strong> {v["reason"]}</div>',
91
  unsafe_allow_html=True,
92
  )
93
  if v.get("source") and v["source"].get("snippet"):
94
  st.markdown(
95
- f'<div style="background:#F8FAFC;border:1px solid #E2E8F0;'
96
- f'padding:9px 13px;border-radius:7px;font-size:0.82rem;'
97
- f'color:#374151;font-style:italic;">'
98
- f'&ldquo;{v["source"]["snippet"]}&rdquo;</div>',
99
  unsafe_allow_html=True,
100
  )
101
  with col_r:
102
  confidence_bar(v.get("combined_confidence", 0.0), "Certainty")
103
 
104
- # Action buttons
105
  st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
106
  kp = f"rv_{bid}_{v['criterion_id']}"
107
  bc1, bc2, bc3 = st.columns(3)
108
 
 
 
 
 
 
109
  with bc1:
110
- if st.button("✅ Approve", key=f"{kp}_approve", use_container_width=True,
111
- type="primary"):
112
  st.session_state["verdicts"][bid][idx]["review_status"] = "approved"
113
  audit.log("human_review_action", actor="officer",
114
- bidder_id=bid, criterion_id=v["criterion_id"],
115
- action_taken="approved",
116
- original_verdict=v["verdict"],
117
- original_extracted_value=v.get("extracted_value", ""),
118
- combined_confidence=v.get("combined_confidence", 0.0))
119
  st.rerun()
120
  with bc2:
121
  edited = st.text_input("Corrected value", key=f"{kp}_edit_val",
@@ -126,19 +130,11 @@ def render() -> None:
126
  if edited:
127
  st.session_state["verdicts"][bid][idx]["extracted_value"] = edited
128
  audit.log("human_review_action", actor="officer",
129
- bidder_id=bid, criterion_id=v["criterion_id"],
130
- action_taken="edited", edited_value=edited,
131
- original_verdict=v["verdict"],
132
- original_extracted_value=v.get("extracted_value", ""),
133
- combined_confidence=v.get("combined_confidence", 0.0))
134
  st.rerun()
135
  with bc3:
136
  if st.button("❌ Reject", key=f"{kp}_reject", use_container_width=True):
137
  st.session_state["verdicts"][bid][idx]["review_status"] = "rejected"
138
  audit.log("human_review_action", actor="officer",
139
- bidder_id=bid, criterion_id=v["criterion_id"],
140
- action_taken="rejected",
141
- original_verdict=v["verdict"],
142
- original_extracted_value=v.get("extracted_value", ""),
143
- combined_confidence=v.get("combined_confidence", 0.0))
144
  st.rerun()
 
14
 
15
  def render() -> None:
16
  st.markdown(
17
+ '<h2 style="font-weight:800;font-size:1.5rem;color:var(--text-color);">'
18
+ 'Human Review Queue</h2>'
19
+ '<p style="color:var(--text-color);opacity:0.6;font-size:0.875rem;margin-bottom:1rem;">'
20
  'Verdicts that could not be automatically confirmed require officer sign-off.</p>',
21
  unsafe_allow_html=True,
22
  )
 
27
  return
28
 
29
  cm = _crit_map()
30
+ pending = [
31
+ (bid, i, v)
32
+ for bid, verdicts in vdata.items()
33
+ for i, v in enumerate(verdicts)
34
+ if v.get("verdict") == "needs_review" and
35
+ v.get("review_status", "pending") == "pending"
36
+ ]
37
 
38
  if not pending:
39
  st.success("✅ All flagged verdicts have been actioned. Nothing pending.")
40
  return
41
 
 
42
  st.markdown(
43
+ f'<div style="background:rgba(245,158,11,0.1);border:1px solid rgba(245,158,11,0.3);'
44
+ f'border-radius:10px;padding:14px 18px;margin-bottom:1.5rem;'
45
+ f'display:flex;align-items:center;gap:10px;">'
46
  f'<span style="font-size:1.3rem;">⚠️</span>'
47
  f'<div>'
48
+ f'<div style="font-weight:700;color:#F59E0B;font-size:0.9rem;">'
49
  f'{len(pending)} item{"s" if len(pending) != 1 else ""} pending review</div>'
50
+ f'<div style="font-size:0.8rem;color:var(--text-color);opacity:0.6;margin-top:2px;">'
51
+ f'High certainty = model is confident this needs human judgment, '
52
+ f'not that the bidder is ineligible.</div>'
53
  f'</div></div>',
54
  unsafe_allow_html=True,
55
  )
 
60
  company = BIDDER_NAMES.get(bid, bid)
61
 
62
  with st.container(border=True):
 
63
  st.markdown(
64
  f'<div style="display:flex;justify-content:space-between;'
65
  f'align-items:flex-start;gap:12px;margin-bottom:10px;">'
66
  f'<div>'
67
+ f'<div style="font-weight:700;font-size:0.95rem;color:var(--text-color);">'
68
+ f'{company}</div>'
69
+ f'<div style="font-size:0.82rem;color:var(--text-color);opacity:0.55;'
70
+ f'margin-top:2px;">{v["criterion_id"]}: {crit_title}</div>'
71
  f'</div>'
72
  f'{verdict_pill(v["verdict"])}'
73
  f'</div>',
 
78
  with col_l:
79
  if v.get("extracted_value"):
80
  st.markdown(
81
+ f'<div style="font-size:0.84rem;margin-bottom:8px;'
82
+ f'color:var(--text-color);">'
83
  f'<strong>Extracted value:</strong> '
84
+ f'<code style="background:rgba(128,128,128,0.12);padding:2px 7px;'
85
+ f'border-radius:4px;">{v["extracted_value"]}</code></div>',
86
  unsafe_allow_html=True,
87
  )
88
  if v.get("reason"):
89
  st.markdown(
90
+ f'<div style="background:rgba(245,158,11,0.08);'
91
+ f'border-left:3px solid #F59E0B;padding:9px 13px;'
92
+ f'border-radius:0 7px 7px 0;font-size:0.84rem;'
93
+ f'color:var(--text-color);margin-bottom:8px;">'
94
  f'<strong>Reason:</strong> {v["reason"]}</div>',
95
  unsafe_allow_html=True,
96
  )
97
  if v.get("source") and v["source"].get("snippet"):
98
  st.markdown(
99
+ f'<div style="background:rgba(128,128,128,0.07);'
100
+ f'border:1px solid rgba(128,128,128,0.15);padding:9px 13px;'
101
+ f'border-radius:7px;font-size:0.82rem;color:var(--text-color);'
102
+ f'font-style:italic;">&ldquo;{v["source"]["snippet"]}&rdquo;</div>',
103
  unsafe_allow_html=True,
104
  )
105
  with col_r:
106
  confidence_bar(v.get("combined_confidence", 0.0), "Certainty")
107
 
 
108
  st.markdown("<div style='height:8px'></div>", unsafe_allow_html=True)
109
  kp = f"rv_{bid}_{v['criterion_id']}"
110
  bc1, bc2, bc3 = st.columns(3)
111
 
112
+ common = dict(bidder_id=bid, criterion_id=v["criterion_id"],
113
+ original_verdict=v["verdict"],
114
+ original_extracted_value=v.get("extracted_value", ""),
115
+ combined_confidence=v.get("combined_confidence", 0.0))
116
+
117
  with bc1:
118
+ if st.button("✅ Approve", key=f"{kp}_approve",
119
+ use_container_width=True, type="primary"):
120
  st.session_state["verdicts"][bid][idx]["review_status"] = "approved"
121
  audit.log("human_review_action", actor="officer",
122
+ action_taken="approved", **common)
 
 
 
 
123
  st.rerun()
124
  with bc2:
125
  edited = st.text_input("Corrected value", key=f"{kp}_edit_val",
 
130
  if edited:
131
  st.session_state["verdicts"][bid][idx]["extracted_value"] = edited
132
  audit.log("human_review_action", actor="officer",
133
+ action_taken="edited", edited_value=edited, **common)
 
 
 
 
134
  st.rerun()
135
  with bc3:
136
  if st.button("❌ Reject", key=f"{kp}_reject", use_container_width=True):
137
  st.session_state["verdicts"][bid][idx]["review_status"] = "rejected"
138
  audit.log("human_review_action", actor="officer",
139
+ action_taken="rejected", **common)
 
 
 
 
140
  st.rerun()