JaydeepR Claude Sonnet 4.6 commited on
Commit
1049372
·
1 Parent(s): 01f6914

UI revamp: inline styles throughout, Inter font, reliable badge system

Browse files

Root cause of previous bad UI: CSS class selectors (.tiq-badge etc.) defined
in the global style block don't reliably cascade into Streamlit markdown
containers. Fix: 100% inline styles on every HTML component — no CSS classes.

- ui/styles.py: simplified to page-chrome only (sidebar, tabs, buttons,
metrics) — nothing that requires class inheritance
- ui/components.py: verdict_pill/category_badge/ocr_tier_badge/confidence_bar
all use pure inline styles; color-coded confidence bar (green/amber/red)
- ui/tab_overview.py: hero banner, numbered pipeline stage cards, stat_card KPIs
- ui/tab_bidders.py: CSS grid verdict table, bidder header with pass/total,
styled reason/snippet callout boxes, clean dividers
- ui/tab_review.py: amber summary banner, styled evidence cards, clean layout
- ui/tab_interpretability.py: coloured verdict status cards, inline source
citation chip, styled Q&A answer box
- app.py: glowing connection dot, cleaner sidebar branding, Inter font via
Google Fonts import in CSS

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

Files changed (7) hide show
  1. app.py +56 -70
  2. ui/components.py +73 -33
  3. ui/styles.py +59 -194
  4. ui/tab_bidders.py +135 -135
  5. ui/tab_interpretability.py +190 -231
  6. ui/tab_overview.py +78 -79
  7. ui/tab_review.py +98 -92
app.py CHANGED
@@ -1,5 +1,4 @@
1
  import shutil
2
-
3
  import streamlit as st
4
 
5
  from ui.styles import CSS
@@ -10,17 +9,11 @@ from ui.tab_review import render as render_review
10
  from ui.tab_audit import render as render_audit
11
  from ui.tab_interpretability import render as render_interpretability
12
 
13
- st.set_page_config(
14
- page_title="TenderIQ",
15
- page_icon="⚖️",
16
- layout="wide",
17
- )
18
-
19
  st.markdown(CSS, unsafe_allow_html=True)
20
 
21
 
22
  def _probe_llm() -> str:
23
- """Probe once per session; returns 'green', 'amber', or 'red'."""
24
  if st.session_state.get("fallback_active"):
25
  return "amber"
26
  if "llm_status" in st.session_state:
@@ -30,9 +23,6 @@ def _probe_llm() -> str:
30
  LLM().chat_json("Respond with valid JSON only.", '{"ping": true}')
31
  st.session_state["llm_status"] = "green"
32
  return "green"
33
- except LLMUnavailable:
34
- st.session_state["llm_status"] = "red"
35
- return "red"
36
  except Exception:
37
  st.session_state["llm_status"] = "red"
38
  return "red"
@@ -45,58 +35,54 @@ def _reset_demo() -> None:
45
  shutil.rmtree(CHROMA_DIR, ignore_errors=True)
46
  shutil.rmtree(str(OCR_CACHE_DIR), ignore_errors=True)
47
  st.cache_resource.clear()
48
- for key in list(st.session_state.keys()):
49
- del st.session_state[key]
50
 
51
 
52
- # ── Sidebar ──────────────────────────────────────────────────────────────────
53
  with st.sidebar:
 
54
  st.markdown(
55
- """<div style="padding:12px 4px 8px;text-align:center;">
56
- <div style="font-size:2.4rem;line-height:1;">⚖️</div>
57
- <div style="font-size:1.3rem;font-weight:800;color:#F1F5F9;
58
- letter-spacing:-0.01em;margin-top:6px;">TenderIQ</div>
59
- <div style="font-size:0.72rem;color:#94A3B8;margin-top:3px;
60
- text-transform:uppercase;letter-spacing:0.08em;">
 
61
  AI Tender Evaluation</div>
62
  </div>""",
63
  unsafe_allow_html=True,
64
  )
65
  st.divider()
66
 
 
67
  status = _probe_llm()
68
- if status == "green":
69
- st.markdown(
70
- '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">'
71
- '<div style="width:10px;height:10px;border-radius:50%;background:#22C55E;'
72
- 'box-shadow:0 0 6px #22C55E;flex-shrink:0;"></div>'
73
- '<span style="font-size:0.85rem;font-weight:600;">DeepSeek Connected</span></div>',
74
- unsafe_allow_html=True,
75
- )
76
- elif status == "amber":
77
- st.markdown(
78
- '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">'
79
- '<div style="width:10px;height:10px;border-radius:50%;background:#F59E0B;'
80
- 'box-shadow:0 0 6px #F59E0B;flex-shrink:0;"></div>'
81
- '<span style="font-size:0.85rem;font-weight:600;">Pre-computed Mode</span></div>',
82
- unsafe_allow_html=True,
83
- )
84
- st.warning("⚠ Showing pre-computed results.")
85
- else:
86
- st.markdown(
87
- '<div style="display:flex;align-items:center;gap:8px;padding:6px 0;">'
88
- '<div style="width:10px;height:10px;border-radius:50%;background:#EF4444;'
89
- 'box-shadow:0 0 6px #EF4444;flex-shrink:0;"></div>'
90
- '<span style="font-size:0.85rem;font-weight:600;">No API Key</span></div>',
91
- unsafe_allow_html=True,
92
- )
93
- st.caption("Using pre-computed fallback data.")
94
 
95
  st.divider()
96
-
97
  if st.button("↺ Reset Session", use_container_width=True):
98
- for key in list(st.session_state.keys()):
99
- del st.session_state[key]
100
  st.rerun()
101
 
102
  if st.button("🗑 Reset for Demo", use_container_width=True, type="secondary"):
@@ -113,28 +99,28 @@ with st.sidebar:
113
  st.rerun()
114
 
115
  st.divider()
116
- st.caption("CRPF Hackathon · Theme 3\nExplainable AI for Government Procurement")
117
-
 
 
 
 
 
 
118
 
119
- # ── Tabs ─────────────────────────────────────────────────────────────────────
120
  tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
121
- "🏠 Overview",
122
- "📄 Tender Analysis",
123
- "⚖️ Bidder Evaluation",
124
- "👤 Human Review",
125
- "📋 Audit Log",
126
- "🔍 Interpretability",
127
  ])
128
 
129
- with tab1:
130
- render_overview()
131
- with tab2:
132
- render_tender()
133
- with tab3:
134
- render_bidders()
135
- with tab4:
136
- render_review()
137
- with tab5:
138
- render_audit()
139
- with tab6:
140
- render_interpretability()
 
1
  import shutil
 
2
  import streamlit as st
3
 
4
  from ui.styles import CSS
 
9
  from ui.tab_audit import render as render_audit
10
  from ui.tab_interpretability import render as render_interpretability
11
 
12
+ st.set_page_config(page_title="TenderIQ", page_icon="⚖️", layout="wide")
 
 
 
 
 
13
  st.markdown(CSS, unsafe_allow_html=True)
14
 
15
 
16
  def _probe_llm() -> str:
 
17
  if st.session_state.get("fallback_active"):
18
  return "amber"
19
  if "llm_status" in st.session_state:
 
23
  LLM().chat_json("Respond with valid JSON only.", '{"ping": true}')
24
  st.session_state["llm_status"] = "green"
25
  return "green"
 
 
 
26
  except Exception:
27
  st.session_state["llm_status"] = "red"
28
  return "red"
 
35
  shutil.rmtree(CHROMA_DIR, ignore_errors=True)
36
  shutil.rmtree(str(OCR_CACHE_DIR), ignore_errors=True)
37
  st.cache_resource.clear()
38
+ for k in list(st.session_state.keys()):
39
+ del st.session_state[k]
40
 
41
 
42
+ # ── Sidebar ──────────────────────────────────────────────────────────────────
43
  with st.sidebar:
44
+ # Branding
45
  st.markdown(
46
+ """<div style="padding:16px 8px 12px;text-align:center;">
47
+ <div style="font-size:2.6rem;">⚖️</div>
48
+ <div style="font-size:1.4rem;font-weight:800;color:#F8FAFC;
49
+ letter-spacing:-0.02em;margin-top:4px;
50
+ font-family:Inter,sans-serif;">TenderIQ</div>
51
+ <div style="font-size:0.7rem;color:#64748B;margin-top:4px;
52
+ text-transform:uppercase;letter-spacing:0.1em;">
53
  AI Tender Evaluation</div>
54
  </div>""",
55
  unsafe_allow_html=True,
56
  )
57
  st.divider()
58
 
59
+ # Connection status
60
  status = _probe_llm()
61
+ dot_color = {"green": "#22C55E", "amber": "#F59E0B", "red": "#EF4444"}[status]
62
+ dot_shadow = {"green": "#22C55E", "amber": "#F59E0B", "red": "#EF4444"}[status]
63
+ status_label = {"green": "DeepSeek Connected", "amber": "Pre-computed Mode",
64
+ "red": "No API Key"}[status]
65
+ st.markdown(
66
+ f"""<div style="display:flex;align-items:center;gap:9px;
67
+ padding:8px 4px;margin-bottom:4px;">
68
+ <div style="width:9px;height:9px;border-radius:50%;flex-shrink:0;
69
+ background:{dot_color};
70
+ box-shadow:0 0 0 3px {dot_color}33,0 0 8px {dot_shadow}88;">
71
+ </div>
72
+ <span style="font-size:0.82rem;font-weight:600;color:#E2E8F0;">
73
+ {status_label}</span>
74
+ </div>""",
75
+ unsafe_allow_html=True,
76
+ )
77
+ if status == "amber":
78
+ st.warning("Using pre-computed results.")
79
+ elif status == "red":
80
+ st.caption("Set DEEPSEEK_API_KEY in .env to enable live mode.")
 
 
 
 
 
 
81
 
82
  st.divider()
 
83
  if st.button("↺ Reset Session", use_container_width=True):
84
+ for k in list(st.session_state.keys()):
85
+ del st.session_state[k]
86
  st.rerun()
87
 
88
  if st.button("🗑 Reset for Demo", use_container_width=True, type="secondary"):
 
99
  st.rerun()
100
 
101
  st.divider()
102
+ st.markdown(
103
+ """<div style="font-size:0.7rem;color:#334155;text-align:center;
104
+ line-height:1.6;padding:0 4px;">
105
+ CRPF Hackathon · Theme 3<br>
106
+ Explainable AI for Government Procurement
107
+ </div>""",
108
+ unsafe_allow_html=True,
109
+ )
110
 
111
+ # ── Tabs ─────────────────────────────────────────────────────────────────────
112
  tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
113
+ "🏠 Overview",
114
+ "📄 Tender Analysis",
115
+ "⚖️ Bidder Evaluation",
116
+ "👤 Human Review",
117
+ "📋 Audit Log",
118
+ "🔍 Interpretability",
119
  ])
120
 
121
+ with tab1: render_overview()
122
+ with tab2: render_tender()
123
+ with tab3: render_bidders()
124
+ with tab4: render_review()
125
+ with tab5: render_audit()
126
+ with tab6: render_interpretability()
 
 
 
 
 
 
ui/components.py CHANGED
@@ -1,65 +1,105 @@
1
  import streamlit as st
2
 
 
 
 
 
 
 
 
 
 
 
3
 
4
  def verdict_pill(verdict: str) -> str:
5
  cfg = {
6
- "eligible": ("tiq-eligible", "✅ Eligible"),
7
- "not_eligible": ("tiq-not-elig", "❌ Not Eligible"),
8
- "needs_review": ("tiq-review", "⚠️ Needs Review"),
9
  }
10
- cls, label = cfg.get(verdict, ("tiq-review", verdict))
11
- return f'<span class="tiq-badge {cls}">{label}</span>'
 
12
 
13
 
14
  def category_badge(category: str) -> str:
15
  cfg = {
16
- "financial": ("tiq-cat-fin", "💰 Financial"),
17
- "technical": ("tiq-cat-tech", "🔧 Technical"),
18
- "compliance": ("tiq-cat-comp", "📋 Compliance"),
19
  }
20
- cls, label = cfg.get(category, ("tiq-cat-comp", category))
21
- return f'<span class="tiq-badge {cls}">{label}</span>'
 
22
 
23
 
24
  def ocr_tier_badge(source_type: str) -> str:
25
  cfg = {
26
- "text_pdf": ("tiq-ocr-text", "📄 Typed PDF"),
27
- "tesseract": ("tiq-ocr-tess", "🔍 Tesseract"),
28
- "vision_llm": ("tiq-ocr-vision", "👁 Vision LLM"),
29
  }
30
- cls, label = cfg.get(source_type, ("tiq-ocr-text", source_type))
31
- return f'<span class="tiq-badge {cls}">{label}</span>'
 
32
 
33
 
34
  def mandatory_badge(mandatory: bool) -> str:
35
  if mandatory:
36
- return '<span class="tiq-badge tiq-mand">🔴 Mandatory</span>'
37
- return '<span class="tiq-badge tiq-optional">🟡 Optional</span>'
 
 
38
 
39
 
40
  def confidence_bar(value: float, label: str = "Confidence") -> None:
41
  pct = min(max(value, 0.0), 1.0)
42
- color = "#22C55E" if pct >= 0.8 else "#F59E0B" if pct >= 0.55 else "#EF4444"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  st.markdown(
44
- f"""<div style="margin:4px 0 8px;">
45
- <div style="display:flex;justify-content:space-between;
46
- font-size:0.75rem;color:#64748B;margin-bottom:3px;">
47
- <span>{label}</span><span style="font-weight:600;color:{color};">{pct:.0%}</span>
48
- </div>
49
- <div style="background:#E2E8F0;border-radius:6px;height:7px;overflow:hidden;">
50
- <div style="width:{pct*100:.1f}%;background:{color};
51
- height:100%;border-radius:6px;
52
- transition:width 0.4s ease;"></div>
53
- </div></div>""",
54
  unsafe_allow_html=True,
55
  )
56
 
57
 
58
- def section_header(title: str, subtitle: str = "") -> None:
 
 
59
  st.markdown(
60
- f'<div class="tiq-section-header">'
61
- f'<div style="font-size:1.1rem;font-weight:700;color:#0D1B2A;">{title}</div>'
62
- + (f'<div style="font-size:0.82rem;color:#64748B;margin-top:2px;">{subtitle}</div>' if subtitle else "")
63
- + "</div>",
 
64
  unsafe_allow_html=True,
65
  )
 
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
  )
ui/styles.py CHANGED
@@ -1,257 +1,122 @@
1
  CSS = """
2
  <style>
3
- /* ================================================================
4
- TenderIQ — Professional Theme
5
- ================================================================ */
6
 
7
- /* ── Global ──────────────────────────────────────────────────── */
8
- .main .block-container {
9
- padding-top: 1.5rem !important;
10
- padding-bottom: 2rem !important;
11
- max-width: 1200px;
12
  }
13
- h1 { font-weight: 800 !important; color: #0D1B2A !important; letter-spacing: -0.02em; }
14
- h2 { font-weight: 700 !important; color: #0D1B2A !important; }
15
- h3 { font-weight: 600 !important; color: #0D1B2A !important; }
16
- h4 { font-weight: 600 !important; color: #1E3A5F !important; }
17
- p { color: #374151; line-height: 1.7; }
18
- code {
19
- background: #EFF6FF !important;
20
- color: #1E40AF !important;
21
- padding: 2px 6px !important;
22
- border-radius: 4px !important;
23
- font-size: 0.85em !important;
24
- border: 1px solid #DBEAFE;
25
- }
26
- hr { border-color: #E2E8F0 !important; margin: 1.25rem 0 !important; }
27
 
28
- /* ── Sidebar ─────────────────────────────────────────────────── */
29
- [data-testid="stSidebar"] {
30
- background: linear-gradient(175deg, #0D1B2A 0%, #1E3A5F 100%) !important;
31
- border-right: 1px solid #1E3A5F;
 
 
 
32
  }
33
  [data-testid="stSidebar"] p,
34
- [data-testid="stSidebar"] span,
35
- [data-testid="stSidebar"] label,
36
- [data-testid="stSidebar"] div {
37
- color: #CBD5E1 !important;
38
- }
39
- [data-testid="stSidebar"] h1,
40
- [data-testid="stSidebar"] h2,
41
- [data-testid="stSidebar"] h3,
42
- [data-testid="stSidebar"] strong {
43
- color: #F1F5F9 !important;
44
- }
45
  [data-testid="stSidebar"] .stButton > button {
46
- background: rgba(255,255,255,0.07) !important;
47
- border: 1px solid rgba(255,255,255,0.15) !important;
48
- color: #E2E8F0 !important;
49
- border-radius: 8px !important;
50
- font-weight: 500 !important;
51
- transition: all 0.2s ease !important;
52
- width: 100%;
53
  }
54
  [data-testid="stSidebar"] .stButton > button:hover {
55
- background: rgba(255,255,255,0.14) !important;
56
- border-color: rgba(255,255,255,0.3) !important;
57
- color: #FFFFFF !important;
58
- }
59
- [data-testid="stSidebar"] [data-testid="stDivider"] { border-color: rgba(255,255,255,0.12) !important; }
60
- [data-testid="stSidebar"] .stAlert {
61
- background: rgba(245,158,11,0.15) !important;
62
- border: 1px solid rgba(245,158,11,0.3) !important;
63
- border-radius: 8px !important;
64
  }
65
- [data-testid="stSidebar"] [data-testid="stCaptionContainer"] p { color: #94A3B8 !important; }
66
 
67
- /* ── Tabs ────────────────────────────────────────────────────── */
68
  .stTabs [data-baseweb="tab-list"] {
69
- background: #F1F5F9 !important;
70
- border-radius: 12px !important;
71
- padding: 5px !important;
72
- gap: 2px !important;
73
  border: 1px solid #E2E8F0;
74
  }
75
  .stTabs [data-baseweb="tab"] {
76
- border-radius: 8px !important;
77
- padding: 8px 18px !important;
78
- font-weight: 500 !important;
79
  font-size: 0.875rem !important;
 
80
  color: #64748B !important;
81
- border: none !important;
82
  background: transparent !important;
83
- transition: all 0.15s ease !important;
84
  }
85
- .stTabs [data-baseweb="tab"]:hover { color: #1E3A5F !important; background: rgba(255,255,255,0.6) !important; }
86
  .stTabs [aria-selected="true"] {
87
  background: #FFFFFF !important;
88
  color: #0D1B2A !important;
89
- box-shadow: 0 1px 6px rgba(0,0,0,0.1) !important;
90
  font-weight: 600 !important;
 
91
  }
92
- .stTabs [data-baseweb="tab-highlight"] { display: none !important; }
93
  .stTabs [data-baseweb="tab-border"] { display: none !important; }
94
 
95
- /* ── Buttons ─────────────────────────────────────────────────── */
96
  .stButton > button {
97
  border-radius: 8px !important;
98
- font-weight: 500 !important;
99
  font-size: 0.875rem !important;
100
- transition: all 0.2s ease !important;
101
- border: 1px solid #E2E8F0 !important;
102
  }
103
  .stButton > button[kind="primary"] {
104
- background: linear-gradient(135deg, #1E3A5F 0%, #2563EB 100%) !important;
105
- color: #FFFFFF !important;
106
  border: none !important;
 
107
  box-shadow: 0 2px 8px rgba(37,99,235,0.3) !important;
108
  }
109
  .stButton > button[kind="primary"]:hover {
110
  box-shadow: 0 4px 16px rgba(37,99,235,0.45) !important;
111
  transform: translateY(-1px) !important;
112
  }
113
- .stButton > button[kind="secondary"] { color: #374151 !important; }
114
- .stButton > button[kind="secondary"]:hover { background: #F1F5F9 !important; }
115
 
116
- /* ── Metric cards ────────────────────────────────────────────── */
117
  [data-testid="metric-container"] {
118
- background: #FFFFFF !important;
119
  border: 1px solid #E2E8F0 !important;
120
  border-radius: 12px !important;
121
- padding: 20px !important;
122
- box-shadow: 0 1px 4px rgba(0,0,0,0.06) !important;
123
- transition: box-shadow 0.2s, transform 0.2s;
124
- }
125
- [data-testid="metric-container"]:hover {
126
- box-shadow: 0 4px 16px rgba(0,0,0,0.1) !important;
127
- transform: translateY(-1px);
128
  }
129
- [data-testid="stMetricValue"] { font-size: 2.2rem !important; font-weight: 800 !important; color: #0D1B2A !important; }
130
- [data-testid="stMetricLabel"] { font-size: 0.75rem !important; font-weight: 600 !important; color: #64748B !important; text-transform: uppercase; letter-spacing: 0.06em; }
131
- [data-testid="stMetricDelta"] { font-size: 0.8rem !important; }
132
 
133
- /* ── Bordered containers (cards) ─────────────────────────────── */
134
  [data-testid="stVerticalBlockBorderWrapper"] {
135
  border-radius: 12px !important;
136
- border: 1px solid #E2E8F0 !important;
137
- box-shadow: 0 2px 8px rgba(0,0,0,0.05) !important;
138
- overflow: hidden;
139
- transition: box-shadow 0.2s;
140
- }
141
- [data-testid="stVerticalBlockBorderWrapper"]:hover {
142
- box-shadow: 0 4px 16px rgba(0,0,0,0.09) !important;
143
  }
144
 
145
- /* ── Expanders ───────────────────────────────────────────────── */
146
  [data-testid="stExpander"] {
147
  border: 1px solid #E2E8F0 !important;
148
- border-radius: 10px !important;
149
- overflow: hidden !important;
150
- margin-bottom: 4px !important;
151
- }
152
- [data-testid="stExpander"] summary {
153
- font-weight: 500 !important;
154
- color: #374151 !important;
155
- padding: 10px 14px !important;
156
- background: #F8FAFC;
157
  }
158
- [data-testid="stExpander"] summary:hover { background: #F1F5F9 !important; }
159
 
160
- /* ── Alerts ──────────────────────────────────────────────────── */
161
- [data-testid="stAlert"] { border-radius: 10px !important; border: none !important; }
162
- div[data-testid="stAlert"][data-baseweb="notification"][kind="info"] {
163
- background: #EFF6FF !important; border-left: 4px solid #3B82F6 !important;
164
- }
165
- div[data-testid="stAlert"][data-baseweb="notification"][kind="success"] {
166
- background: #F0FDF4 !important; border-left: 4px solid #22C55E !important;
167
- }
168
- div[data-testid="stAlert"][data-baseweb="notification"][kind="warning"] {
169
- background: #FFFBEB !important; border-left: 4px solid #F59E0B !important;
170
- }
171
- div[data-testid="stAlert"][data-baseweb="notification"][kind="error"] {
172
- background: #FEF2F2 !important; border-left: 4px solid #EF4444 !important;
173
- }
174
 
175
- /* ── Progress bar ────────────────────────────────────────────── */
176
- .stProgress > div > div > div { border-radius: 6px !important; }
177
  .stProgress > div > div > div > div {
178
- background: linear-gradient(90deg, #1E3A5F, #2563EB) !important;
179
  border-radius: 6px !important;
180
  }
181
 
182
- /* ── Forms (inputs, selects) ─────────────────────────────────── */
183
- [data-baseweb="input"],
184
- [data-baseweb="select"],
185
- [data-baseweb="textarea"] {
186
  border-radius: 8px !important;
187
  border-color: #E2E8F0 !important;
188
  }
189
- [data-baseweb="input"]:focus-within,
190
- [data-baseweb="select"]:focus-within {
191
- border-color: #2563EB !important;
192
- box-shadow: 0 0 0 3px rgba(37,99,235,0.1) !important;
193
- }
194
-
195
- /* ── Dataframe ───────────────────────────────────────────────── */
196
- [data-testid="stDataFrame"] {
197
- border-radius: 10px !important;
198
- overflow: hidden !important;
199
- border: 1px solid #E2E8F0 !important;
200
- }
201
-
202
- /* ── Caption ─────────────────────────────────────────────────── */
203
- [data-testid="stCaptionContainer"] p { color: #94A3B8 !important; font-size: 0.8rem !important; }
204
-
205
- /* ── Spinner ─────────────────────────────────────────────────── */
206
- .stSpinner > div { border-top-color: #2563EB !important; }
207
-
208
- /* ── Hide Streamlit chrome ───────────────────────────────────── */
209
- #MainMenu { visibility: hidden !important; }
210
- footer { visibility: hidden !important; }
211
- header { visibility: hidden !important; }
212
-
213
- /* ── Verdict badge utility classes ───────────────────────────── */
214
- .tiq-badge {
215
- display: inline-flex; align-items: center; gap: 5px;
216
- padding: 4px 12px; border-radius: 20px;
217
- font-size: 0.8rem; font-weight: 600; white-space: nowrap;
218
- }
219
- .tiq-eligible { background:#D1FAE5; color:#065F46; border:1px solid #A7F3D0; }
220
- .tiq-not-elig { background:#FEE2E2; color:#991B1B; border:1px solid #FECACA; }
221
- .tiq-review { background:#FEF3C7; color:#92400E; border:1px solid #FDE68A; }
222
- .tiq-cat-fin { background:#DBEAFE; color:#1E40AF; border:1px solid #BFDBFE; }
223
- .tiq-cat-tech { background:#DCFCE7; color:#166534; border:1px solid #BBF7D0; }
224
- .tiq-cat-comp { background:#FEF3C7; color:#92400E; border:1px solid #FDE68A; }
225
- .tiq-ocr-text { background:#F1F5F9; color:#475569; border:1px solid #CBD5E1; }
226
- .tiq-ocr-tess { background:#FDF4FF; color:#7E22CE; border:1px solid #E9D5FF; }
227
- .tiq-ocr-vision { background:#FFF7ED; color:#C2410C; border:1px solid #FED7AA; }
228
- .tiq-mand { background:#FEE2E2; color:#991B1B; border:1px solid #FECACA; }
229
- .tiq-optional { background:#FFFBEB; color:#92400E; border:1px solid #FDE68A; }
230
-
231
- /* ── Hero banner ─────────────────────────────��───────────────── */
232
- .tiq-hero {
233
- background: linear-gradient(135deg, #0D1B2A 0%, #1E3A5F 50%, #2563EB 100%);
234
- border-radius: 16px; padding: 2.5rem 2rem; margin-bottom: 1.5rem;
235
- color: white;
236
- }
237
- .tiq-hero h1 { color: #FFFFFF !important; margin: 0; font-size: 2rem; }
238
- .tiq-hero p { color: #CBD5E1 !important; margin: 0.5rem 0 0; font-size: 1.05rem; }
239
-
240
- /* ── Kpi strip ───────────────────────────────────────────────── */
241
- .tiq-kpi {
242
- background: #FFFFFF; border-radius: 12px; padding: 18px 20px;
243
- border: 1px solid #E2E8F0;
244
- box-shadow: 0 1px 4px rgba(0,0,0,0.06);
245
- text-align: center;
246
- }
247
- .tiq-kpi-val { font-size: 2rem; font-weight: 800; color: #0D1B2A; }
248
- .tiq-kpi-lbl { font-size: 0.72rem; font-weight: 600; color: #64748B;
249
- text-transform: uppercase; letter-spacing: 0.07em; margin-top: 2px; }
250
-
251
- /* ── Section header ─────────────────────────────────────────── */
252
- .tiq-section-header {
253
- border-left: 4px solid #2563EB; padding-left: 12px;
254
- margin: 1.5rem 0 1rem;
255
- }
256
  </style>
257
  """
 
1
  CSS = """
2
  <style>
3
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
 
 
4
 
5
+ /* ── Font ──────────────────────────────────────────────────── */
6
+ html, body, [class*="css"], .stMarkdown, .stText, button, input, select {
7
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important;
 
 
8
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
 
10
+ /* ── Page chrome ───────────────────────────────────────────── */
11
+ #MainMenu, footer, header { visibility: hidden; }
12
+ .main .block-container { max-width: 1140px; padding-top: 1.2rem !important; }
13
+
14
+ /* ── Sidebar ───────────────────────────────────────────────── */
15
+ section[data-testid="stSidebar"] > div:first-child {
16
+ background: #0A1628 !important;
17
  }
18
  [data-testid="stSidebar"] p,
19
+ [data-testid="stSidebar"] span:not(.st-emotion-cache-hidden),
20
+ [data-testid="stSidebar"] small,
21
+ [data-testid="stSidebar"] label { color: #CBD5E1 !important; }
22
+ [data-testid="stSidebar"] strong { color: #F1F5F9 !important; }
23
+ [data-testid="stSidebar"] hr { border-color: rgba(255,255,255,0.1) !important; }
 
 
 
 
 
 
24
  [data-testid="stSidebar"] .stButton > button {
25
+ background: rgba(255,255,255,0.06) !important;
26
+ border: 1px solid rgba(255,255,255,0.12) !important;
27
+ color: #CBD5E1 !important;
28
+ transition: background 0.15s;
 
 
 
29
  }
30
  [data-testid="stSidebar"] .stButton > button:hover {
31
+ background: rgba(255,255,255,0.13) !important;
32
+ color: #fff !important;
 
 
 
 
 
 
 
33
  }
34
+ [data-testid="stSidebar"] .stAlert { border-radius: 8px !important; }
35
 
36
+ /* ── Tabs ──────────────────────────────────────────────────── */
37
  .stTabs [data-baseweb="tab-list"] {
38
+ background: #F0F4F8;
39
+ padding: 4px;
40
+ border-radius: 10px;
41
+ gap: 2px;
42
  border: 1px solid #E2E8F0;
43
  }
44
  .stTabs [data-baseweb="tab"] {
45
+ border-radius: 7px !important;
46
+ padding: 7px 16px !important;
 
47
  font-size: 0.875rem !important;
48
+ font-weight: 500 !important;
49
  color: #64748B !important;
 
50
  background: transparent !important;
 
51
  }
 
52
  .stTabs [aria-selected="true"] {
53
  background: #FFFFFF !important;
54
  color: #0D1B2A !important;
 
55
  font-weight: 600 !important;
56
+ box-shadow: 0 1px 4px rgba(0,0,0,0.1) !important;
57
  }
58
+ .stTabs [data-baseweb="tab-highlight"],
59
  .stTabs [data-baseweb="tab-border"] { display: none !important; }
60
 
61
+ /* ── Buttons ───────────────────────────────────────────────── */
62
  .stButton > button {
63
  border-radius: 8px !important;
64
+ font-weight: 600 !important;
65
  font-size: 0.875rem !important;
66
+ transition: all 0.18s ease !important;
67
+ letter-spacing: 0.01em;
68
  }
69
  .stButton > button[kind="primary"] {
70
+ background: linear-gradient(135deg, #1E40AF, #2563EB) !important;
 
71
  border: none !important;
72
+ color: #fff !important;
73
  box-shadow: 0 2px 8px rgba(37,99,235,0.3) !important;
74
  }
75
  .stButton > button[kind="primary"]:hover {
76
  box-shadow: 0 4px 16px rgba(37,99,235,0.45) !important;
77
  transform: translateY(-1px) !important;
78
  }
 
 
79
 
80
+ /* ── Metrics ───────────────────────────────────────────────── */
81
  [data-testid="metric-container"] {
82
+ background: #fff !important;
83
  border: 1px solid #E2E8F0 !important;
84
  border-radius: 12px !important;
85
+ padding: 18px !important;
86
+ box-shadow: 0 1px 3px rgba(0,0,0,0.06) !important;
 
 
 
 
 
87
  }
88
+ [data-testid="stMetricValue"] { font-size: 2rem !important; font-weight: 800 !important; }
89
+ [data-testid="stMetricLabel"] { font-size: 0.72rem !important; font-weight: 600 !important;
90
+ text-transform: uppercase; letter-spacing: 0.07em; color: #64748B !important; }
91
 
92
+ /* ── Bordered containers ───────────────────────────────────── */
93
  [data-testid="stVerticalBlockBorderWrapper"] {
94
  border-radius: 12px !important;
95
+ border-color: #E2E8F0 !important;
96
+ box-shadow: 0 1px 4px rgba(0,0,0,0.05) !important;
 
 
 
 
 
97
  }
98
 
99
+ /* ── Expanders ─────────────────────────────────────────────── */
100
  [data-testid="stExpander"] {
101
  border: 1px solid #E2E8F0 !important;
102
+ border-radius: 8px !important;
 
 
 
 
 
 
 
 
103
  }
104
+ [data-testid="stExpander"] summary { font-weight: 500 !important; font-size: 0.875rem !important; }
105
 
106
+ /* ── Alerts ────────────────────────────────────────────────── */
107
+ [data-testid="stAlert"] { border-radius: 10px !important; }
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
+ /* ── Progress ──────────────────────────────────────────────── */
 
110
  .stProgress > div > div > div > div {
111
+ background: linear-gradient(90deg, #1E40AF, #2563EB) !important;
112
  border-radius: 6px !important;
113
  }
114
 
115
+ /* ── Inputs ────────────────────────────────────────────────── */
116
+ [data-baseweb="input"] > div,
117
+ [data-baseweb="select"] > div:first-child {
 
118
  border-radius: 8px !important;
119
  border-color: #E2E8F0 !important;
120
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  </style>
122
  """
ui/tab_bidders.py CHANGED
@@ -4,193 +4,193 @@ from core import bidder_processor, evaluator
4
  from core.config import BIDDER_NAMES, DATA_DIR
5
  from core.fallback import load_criteria
6
  from core.schemas import Criterion
7
- from ui.components import (
8
- category_badge, confidence_bar, mandatory_badge,
9
- ocr_tier_badge, section_header, verdict_pill,
10
- )
11
-
12
- _BIDDER_LABELS = {
13
- "bidder_a": "Apex Constructions Pvt. Ltd.",
14
- "bidder_b": "BuildRight Enterprises",
15
- "bidder_c": "Shree Constructions & Services",
16
- }
17
- _BIDDER_SUBLABELS = {
18
- "bidder_a": "Clearly Eligible",
19
- "bidder_b": "Ineligible — Turnover Below Threshold",
20
- "bidder_c": "Needs Review — Scanned Certificate",
21
  }
22
 
23
 
24
  def _get_criteria() -> list[Criterion]:
25
  data = st.session_state.get("criteria")
26
- if data:
27
- return [Criterion(**c) for c in data]
28
- return load_criteria()
29
-
30
-
31
- def _overall_verdict(verdicts: list[dict], crit_map: dict) -> str:
32
- mandatory = [v for v in verdicts
33
- if crit_map.get(v["criterion_id"]) and
34
- crit_map[v["criterion_id"]].mandatory]
35
- if not mandatory:
36
- mandatory = verdicts
37
- if any(v["verdict"] == "not_eligible" for v in mandatory):
38
  return "not_eligible"
39
- if any(v["verdict"] == "needs_review" for v in mandatory):
40
  return "needs_review"
41
  return "eligible"
42
 
43
 
44
  def render() -> None:
45
- st.header("Bidder Evaluation")
46
- st.caption("Run the full evaluation pipeline or load pre-computed results from the Overview tab.")
 
 
 
 
 
47
 
48
  selected = st.multiselect(
49
- "Select bidders to evaluate",
50
  options=list(BIDDER_NAMES.keys()),
51
  default=list(BIDDER_NAMES.keys()),
52
- format_func=lambda x: f"{_BIDDER_LABELS.get(x, x)} — {_BIDDER_SUBLABELS.get(x, '')}",
53
  )
54
 
55
  if st.button("▶ Run Evaluation", type="primary"):
56
  criteria = _get_criteria()
57
- verdicts_dict: dict = {}
58
- progress = st.progress(0, text="Starting…")
59
  total = len(selected) * len(criteria)
60
  done = 0
61
- for bidder_id in selected:
62
- files = sorted(
63
- f for f in (DATA_DIR / "bidders" / bidder_id).iterdir()
64
- if f.suffix.lower() in {".pdf", ".png", ".jpg"}
65
- )
66
- with st.spinner(f"Processing {_BIDDER_LABELS.get(bidder_id, bidder_id)}…"):
67
- bidder_processor.process_bidder(bidder_id, files)
68
- verdicts_list = []
69
  for c in criteria:
70
- v = evaluator.evaluate(bidder_id, c)
71
- verdicts_list.append(v.model_dump())
72
  done += 1
73
- progress.progress(done / total,
74
- text=f"Evaluated {c.id} · {_BIDDER_LABELS.get(bidder_id, bidder_id)}")
75
- verdicts_dict[bidder_id] = verdicts_list
76
- st.session_state["verdicts"] = verdicts_dict
77
- progress.empty()
78
- st.success("Evaluation complete. Results saved.")
79
  st.rerun()
80
 
81
- verdicts_data = st.session_state.get("verdicts", {})
82
  criteria = _get_criteria()
83
  crit_map = {c.id: c for c in criteria}
84
 
85
- if not verdicts_data:
86
- st.info("No results yet. Click **Run Evaluation** above, or load the demo from the Overview tab.")
87
  return
88
 
89
  if st.session_state.get("fallback_active"):
90
  st.warning("⚠ Live API unavailable — showing pre-computed results.")
91
 
92
- for bidder_id in (selected or list(verdicts_data.keys())):
93
- if bidder_id not in verdicts_data:
94
  continue
95
- verdicts = verdicts_data[bidder_id]
96
- overall = _overall_verdict(verdicts, crit_map)
97
- op = verdict_pill(overall)
98
- friendly = _BIDDER_LABELS.get(bidder_id, bidder_id)
99
- sublabel = _BIDDER_SUBLABELS.get(bidder_id, "")
100
- passed = sum(1 for v in verdicts
101
- if v["verdict"] == "eligible" and
102
- crit_map.get(v["criterion_id"]) and
103
- crit_map[v["criterion_id"]].mandatory)
104
- total_mand = sum(1 for v in verdicts
105
- if crit_map.get(v["criterion_id"]) and
106
- crit_map[v["criterion_id"]].mandatory)
107
-
108
  with st.container(border=True):
109
- # Bidder header
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  st.markdown(
111
- f"""<div style="display:flex;justify-content:space-between;
112
- align-items:center;flex-wrap:wrap;gap:8px;
113
- padding:4px 0 12px;">
114
- <div>
115
- <div style="font-size:1.05rem;font-weight:700;color:#0D1B2A;">{friendly}</div>
116
- <div style="font-size:0.8rem;color:#64748B;margin-top:2px;">{sublabel}</div>
117
- </div>
118
- <div style="display:flex;align-items:center;gap:12px;">
119
- {op}
120
- <span style="font-size:0.78rem;color:#94A3B8;background:#F1F5F9;
121
- padding:3px 10px;border-radius:20px;">
122
- {passed}/{total_mand} mandatory passed
123
- </span>
124
- </div>
125
- </div>""",
126
  unsafe_allow_html=True,
127
  )
128
 
129
- # Column headers
130
- hcols = st.columns([3, 2, 2, 3, 2])
131
- for col, lbl in zip(hcols, ["Criterion", "Verdict", "Extracted Value",
132
- "Source & OCR Tier", "Category"]):
133
- col.markdown(
134
- f'<div style="font-size:0.72rem;font-weight:700;color:#94A3B8;'
135
- f'text-transform:uppercase;letter-spacing:0.06em;padding-bottom:4px;">'
136
- f'{lbl}</div>',
137
- unsafe_allow_html=True,
138
- )
139
- st.markdown('<hr style="margin:0 0 8px;border-color:#F1F5F9;">', unsafe_allow_html=True)
140
-
141
  for v in verdicts:
142
  crit = crit_map.get(v["criterion_id"])
143
- crit_title = crit.title if crit else v["criterion_id"]
144
  mb = mandatory_badge(crit.mandatory if crit else True)
145
  cat = category_badge(crit.category if crit else "compliance")
146
-
147
- cols = st.columns([3, 2, 2, 3, 2])
148
- cols[0].markdown(
149
- f'{mb} <span style="font-weight:600;font-size:0.88rem;">'
150
- f'{v["criterion_id"]}</span>'
151
- f'<div style="font-size:0.8rem;color:#374151;margin-top:2px;">{crit_title}</div>',
152
- unsafe_allow_html=True,
153
- )
154
- cols[1].markdown(verdict_pill(v["verdict"]), unsafe_allow_html=True)
155
  extracted = v.get("extracted_value") or ""
156
- extracted_html = (
157
- f'<span style="font-size:0.85rem;color:#374151;">{extracted}</span>'
158
- if extracted else
159
- '<span style="color:#9CA3AF;">—</span>'
160
- )
161
- cols[2].markdown(extracted_html, unsafe_allow_html=True)
162
- if v.get("source"):
163
- src = v["source"]
164
- tier = ocr_tier_badge(src["source_type"])
165
- cols[3].markdown(
166
- f'<span style="font-size:0.82rem;font-family:monospace;'
167
- f'background:#F8FAFC;padding:2px 6px;border-radius:4px;'
168
- f'border:1px solid #E2E8F0;">{src["doc_name"]}</span>'
169
- f' p{src["page"]}<br>{tier}',
170
- unsafe_allow_html=True,
171
  )
172
- else:
173
- cols[3].markdown('<span style="color:#9CA3AF;">—</span>', unsafe_allow_html=True)
174
- cols[4].markdown(cat, unsafe_allow_html=True)
175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  confidence_bar(v.get("combined_confidence", 0.0))
177
 
178
- if v.get("reason") or (v.get("source") and v["source"].get("snippet")):
179
- with st.expander("View details", expanded=False):
180
- if v.get("reason"):
 
 
181
  st.markdown(
182
- f'<div style="background:#F8FAFC;border-left:3px solid #3B82F6;'
183
- f'padding:10px 14px;border-radius:0 6px 6px 0;'
184
- f'font-size:0.88rem;color:#374151;">'
185
- f'<strong>Reason:</strong> {v["reason"]}</div>',
186
  unsafe_allow_html=True,
187
  )
188
- if v.get("source") and v["source"].get("snippet"):
189
  st.markdown(
190
  f'<div style="background:#FFFBEB;border-left:3px solid #F59E0B;'
191
- f'padding:10px 14px;border-radius:0 6px 6px 0;margin-top:8px;'
192
- f'font-size:0.85rem;color:#374151;font-style:italic;">'
193
- f'"{v["source"]["snippet"]}"</div>',
194
  unsafe_allow_html=True,
195
  )
196
- st.markdown('<hr style="margin:6px 0;border-color:#F1F5F9;">', unsafe_allow_html=True)
 
 
 
 
 
4
  from core.config import BIDDER_NAMES, DATA_DIR
5
  from core.fallback import load_criteria
6
  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
 
16
  def _get_criteria() -> list[Criterion]:
17
  data = st.session_state.get("criteria")
18
+ return [Criterion(**c) for c in data] if data else load_criteria()
19
+
20
+
21
+ 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
+ )
40
 
41
  selected = st.multiselect(
42
+ "Select bidders",
43
  options=list(BIDDER_NAMES.keys()),
44
  default=list(BIDDER_NAMES.keys()),
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"})
57
+ with st.spinner(f"Indexing {_BIDDER_META.get(bid,(bid,'',''))[0]}…"):
58
+ bidder_processor.process_bidder(bid, files)
59
+ vlist = []
 
60
  for c in criteria:
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()
68
+ st.success("Evaluation complete.")
 
69
  st.rerun()
70
 
71
+ vdata = st.session_state.get("verdicts", {})
72
  criteria = _get_criteria()
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"):
80
  st.warning("⚠ Live API unavailable — showing pre-computed results.")
81
 
82
+ for bid in (selected or list(vdata.keys())):
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
+ )
133
+ + "</div>",
 
 
 
 
 
 
134
  unsafe_allow_html=True,
135
  )
136
 
 
 
 
 
 
 
 
 
 
 
 
 
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>',
168
+ unsafe_allow_html=True,
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
+ )
ui/tab_interpretability.py CHANGED
@@ -1,311 +1,270 @@
1
- import json
2
-
3
  import streamlit as st
4
 
5
- from core.config import BIDDER_NAMES, DATA_DIR, MODEL_VERSION
6
  from core.fallback import load_criteria
7
  from core.llm_client import LLM, LLMUnavailable
8
  from core.pdf_utils import render_page_to_image
9
  from core.schemas import Criterion
 
10
 
11
- _VERDICT_PLAIN = {
12
- "eligible": ("", "PASSED", "green"),
13
- "not_eligible": ("", "FAILED", "red"),
14
- "needs_review": ("⚠️", "NEEDS REVIEW", "orange"),
15
  }
16
 
17
- _CRITERION_RULE_PLAIN = {
18
- "numeric_threshold": lambda r: (
19
- f"must be {r['value']:,} {r.get('unit') or ''}" if r["operator"] == ">="
20
- else f"must be ≤ {r['value']:,} {r.get('unit') or ''}"
21
- ),
22
- "count_threshold": lambda r: f"must have completed at least {int(r['value'])}",
23
  "certification_present": lambda _: "valid certificate must be present",
24
- "document_present": lambda _: "supporting document must be present",
25
  }
26
 
27
 
28
  def _get_criteria() -> list[Criterion]:
29
  data = st.session_state.get("criteria")
30
- if data:
31
- return [Criterion(**c) for c in data]
32
- return load_criteria()
33
 
34
 
35
- def _plain_explanation(v: dict, crit: Criterion | None) -> str:
36
  verdict = v.get("verdict", "")
37
- extracted = v.get("extracted_value") or ""
38
- reason = v.get("reason") or ""
39
- src = v.get("source") or {}
40
-
41
  if not crit:
42
  return reason
43
-
44
- icon, label, _ = _VERDICT_PLAIN.get(verdict, ("❓", verdict, "grey"))
45
  rule = crit.rule
46
-
47
  if verdict == "eligible":
48
- rule_desc = _CRITERION_RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
49
- val_part = f" Found: **{extracted}**." if extracted else ""
50
- return f"{icon} **{crit.title}** — {label}.{val_part} {reason}"
51
-
52
  elif verdict == "not_eligible":
53
- rule_desc = _CRITERION_RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
54
- val_part = f" Found: **{extracted}** — this does not meet the requirement ({rule_desc})." if extracted else f" Required: {rule_desc}."
55
- return f"{icon} **{crit.title}** — {label}.{val_part} {reason}"
56
-
57
- else: # needs_review
58
- val_part = f" Extracted value: **{extracted}**." if extracted else ""
59
- return f"{icon} **{crit.title}** — {label}.{val_part} {reason}"
60
-
61
-
62
- def _source_citation(v: dict) -> str | None:
63
- src = v.get("source")
64
- if not src:
65
- return None
66
- doc = src.get("doc_name", "")
67
- page = src.get("page", "")
68
- tier = src.get("source_type", "")
69
- tier_labels = {"text_pdf": "typed PDF", "tesseract": "Tesseract OCR",
70
- "vision_llm": "Vision LLM OCR"}
71
- return f"📄 **{doc}**, page {page} · read by _{tier_labels.get(tier, tier)}_"
72
 
73
 
74
- def _build_qa_context(bidder_id: str, verdicts: list[dict],
75
- criteria: list[Criterion]) -> str:
76
- crit_map = {c.id: c for c in criteria}
77
- lines = [
78
- f"BIDDER: {BIDDER_NAMES.get(bidder_id, bidder_id)} ({bidder_id})",
79
- "",
80
- "EVALUATION RESULTS:",
81
- ]
82
  for v in verdicts:
83
- crit = crit_map.get(v["criterion_id"])
84
- crit_title = crit.title if crit else v["criterion_id"]
85
- mandatory = ("Mandatory" if crit and crit.mandatory else "Optional") if crit else "Unknown"
86
- lines.append(
87
- f" {v['criterion_id']} {crit_title} [{mandatory}]: "
88
- f"{v['verdict'].upper()}"
89
- )
90
- if v.get("extracted_value"):
91
- lines.append(f" Extracted value: {v['extracted_value']}")
 
92
  if v.get("source"):
93
- src = v["source"]
94
- lines.append(
95
- f" Evidence source: {src.get('doc_name')} page {src.get('page')} "
96
- f"(read by {src.get('source_type')})"
97
- )
98
- if v.get("source") and v["source"].get("snippet"):
99
- lines.append(f" Evidence snippet: \"{v['source']['snippet'][:200]}\"")
100
- lines.append(
101
- f" Confidence: {v.get('combined_confidence', 0):.0%} | "
102
- f"Reason: {v.get('reason', '')}"
103
- )
104
- if crit:
105
- rule = crit.rule
106
- rule_desc = _CRITERION_RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
107
- lines.append(f" Requirement: {rule_desc}")
108
  lines.append("")
109
  return "\n".join(lines)
110
 
111
 
112
- def _answer_question(question: str, context: str) -> str:
113
- system = """You are a procurement compliance assistant helping an evaluation officer
114
- understand AI-generated eligibility verdicts. Answer questions about the bidder's evaluation
115
- in plain, professional English. Always cite specific document names and page numbers from the
116
- evidence. Be concise (2-4 sentences). Do not invent information not present in the context."""
117
-
118
- user = f"""{context}
119
-
120
- OFFICER'S QUESTION: {question}
121
-
122
- Answer the question based only on the evaluation results above.
123
- Cite the specific document and page number when referring to evidence."""
124
-
125
  try:
126
- llm = LLM()
127
- result = llm.chat_json(
128
- system + " Return JSON: {\"answer\": \"<your answer>\"}",
129
- user,
130
- )
131
  return result.get("answer", "")
132
  except LLMUnavailable:
133
- return _rule_based_answer(question, context)
134
 
135
 
136
- def _rule_based_answer(question: str, context: str) -> str:
137
- q = question.lower()
138
  lines = context.splitlines()
139
-
140
  if any(w in q for w in ["reject", "fail", "not eligible", "disqualif"]):
141
- fails = [l for l in lines if "NOT_ELIGIBLE" in l or "NEEDS_REVIEW" in l]
142
- if fails:
143
- return ("Based on the evaluation: " +
144
- "; ".join(f.strip() for f in fails[:3]) +
145
- ". See the Bidder Evaluation tab for full details.")
146
- return "No failing criteria were found in the evaluation."
147
-
148
- if any(w in q for w in ["pass", "eligible", "meet", "satisfy"]):
149
- passes = [l for l in lines if "ELIGIBLE" in l and "NOT_ELIGIBLE" not in l]
150
- if passes:
151
- return ("Criteria passed: " +
152
- "; ".join(f.strip() for f in passes[:3]) + ".")
153
- return "No passing criteria were found."
154
-
155
- if any(w in q for w in ["turnover", "financial", "revenue", "c1"]):
156
- relevant = [l for l in lines if "C1" in l or "turnover" in l.lower() or
157
- "Extracted value" in l]
158
- if relevant:
159
- return " ".join(l.strip() for l in relevant[:4])
160
-
161
- return ("I cannot answer that specific question without the live LLM. "
162
- "The evaluation summary above contains the full details.")
163
 
164
 
165
  def render() -> None:
166
- st.header("Interpretability")
167
- st.caption(
168
- "Plain-English explanations of why each bidder was evaluated the way it was, "
169
- "with full source citations. Ask any question about the evaluation."
 
 
170
  )
171
 
172
- verdicts_data = st.session_state.get("verdicts", {})
173
- if not verdicts_data:
174
- st.info("No evaluation results yet. Run the evaluation in Bidder Evaluation tab or "
175
- "click **Load Pre-computed Demo** in the Overview tab.")
176
  return
177
 
178
  criteria = _get_criteria()
179
  crit_map = {c.id: c for c in criteria}
180
 
181
- bidder_id = st.selectbox(
182
- "Select bidder",
183
- options=list(verdicts_data.keys()),
184
- format_func=lambda x: BIDDER_NAMES.get(x, x),
185
- )
186
-
187
- verdicts = verdicts_data.get(bidder_id, [])
188
  if not verdicts:
189
- st.warning("No verdicts available for this bidder.")
190
  return
191
 
192
- # ── Overall summary ───────────────────────────────────────────────────────
193
- mandatory_verdicts = [v for v in verdicts
194
- if crit_map.get(v["criterion_id"]) and
195
- crit_map[v["criterion_id"]].mandatory]
196
- failed = [v for v in mandatory_verdicts if v["verdict"] == "not_eligible"]
197
- review = [v for v in mandatory_verdicts if v["verdict"] == "needs_review"]
198
- passed = [v for v in mandatory_verdicts if v["verdict"] == "eligible"]
199
 
200
- friendly = BIDDER_NAMES.get(bidder_id, bidder_id)
 
 
 
 
 
201
 
202
  if failed:
203
- st.error(
204
- f"**{friendly} NOT ELIGIBLE**\n\n"
205
- f"Failed {len(failed)} mandatory criterion/criteria. "
206
- f"A bidder must meet all mandatory criteria to qualify."
207
- )
208
  elif review:
209
- st.warning(
210
- f"**{friendly} NEEDS REVIEW**\n\n"
211
- f"Passed {len(passed)} mandatory criteria, but {len(review)} could not be "
212
- f"automatically confirmed and require officer verification."
213
- )
214
  else:
215
- st.success(
216
- f"**{friendly} ELIGIBLE**\n\n"
217
- f"All {len(passed)} mandatory criteria satisfied."
218
- )
219
-
220
- st.divider()
 
 
 
 
 
 
 
 
221
 
222
- # ── Per-criterion plain-English cards ─────────────────────────────────────
223
- st.subheader("Criterion-by-Criterion Breakdown")
 
 
 
 
224
 
225
  for v in verdicts:
226
  crit = crit_map.get(v["criterion_id"])
227
- _, label, color = _VERDICT_PLAIN.get(v["verdict"], ("", v["verdict"], "grey"))
228
- mandatory_tag = "🔴 Mandatory" if (crit and crit.mandatory) else "🟡 Optional"
 
 
229
 
230
  with st.container(border=True):
231
- col_status, col_detail = st.columns([1, 4])
232
-
233
- with col_status:
234
- if color == "green":
235
- st.success(label)
236
- elif color == "red":
237
- st.error(label)
238
- else:
239
- st.warning(label)
240
- st.caption(mandatory_tag)
241
- conf = v.get("combined_confidence", 0.0)
242
- st.caption(f"Confidence: {conf:.0%}")
243
-
244
- with col_detail:
245
- explanation = _plain_explanation(v, crit)
246
- st.markdown(explanation)
247
-
248
- citation = _source_citation(v)
249
- if citation:
250
- st.markdown(citation)
251
-
252
- # Page preview
253
- src = v.get("source", {})
254
- doc_name = src.get("doc_name", "")
255
- page_no = src.get("page", 1)
256
- bidder_dir = DATA_DIR / "bidders" / bidder_id
257
- doc_path = bidder_dir / doc_name
258
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
259
  if doc_path.exists() and doc_path.suffix.lower() == ".pdf":
260
- with st.expander(f"View source page ({doc_name}, p{page_no})",
261
- expanded=False):
262
  try:
263
- img = render_page_to_image(doc_path, page_no)
264
- st.image(img, caption=f"{doc_name} — Page {page_no}",
265
  use_column_width=True)
266
  except Exception:
267
  st.caption("Page preview unavailable.")
268
  elif doc_path.exists() and doc_path.suffix.lower() in {".png", ".jpg"}:
269
- with st.expander(f"View source image ({doc_name})", expanded=False):
270
- st.image(str(doc_path), caption=doc_name,
271
- use_column_width=True)
272
 
273
  st.divider()
274
 
275
- # ── Q&A section ───────────────────────────────────────────────────────────
276
- st.subheader("Ask About This Evaluation")
277
- st.caption(
278
- "Ask any question about why this bidder was evaluated the way it was. "
279
- "Answers cite specific documents and pages."
 
 
280
  )
281
 
282
- suggestions = [
283
- "Why was this bidder rejected?",
284
- "Which criteria did this bidder fail?",
285
- "What turnover figure was found and which document was it from?",
286
- "Is this bidder ISO certified?",
287
- "Why is the turnover verdict in review?",
288
- ]
289
  with st.expander("Example questions", expanded=False):
290
- for s in suggestions:
291
- st.markdown(f"- _{s}_")
292
 
293
- question = st.text_input(
294
- "Your question",
295
- placeholder="e.g. Why was this bidder's turnover flagged for review?",
296
- key=f"qa_input_{bidder_id}",
297
- )
298
-
299
- if st.button("Get Answer", type="primary", key=f"qa_btn_{bidder_id}"):
300
  if not question.strip():
301
  st.warning("Please enter a question.")
302
  else:
303
- context = _build_qa_context(bidder_id, verdicts, criteria)
304
  with st.spinner("Looking up the answer…"):
305
- answer = _answer_question(question, context)
306
-
307
- st.markdown("**Answer:**")
308
- st.info(answer)
309
-
310
- with st.expander("Full evaluation context used to answer", expanded=False):
 
 
 
 
 
311
  st.code(context, language="text")
 
 
 
1
  import streamlit as st
2
 
3
+ from core.config import BIDDER_NAMES, DATA_DIR
4
  from core.fallback import load_criteria
5
  from core.llm_client import LLM, LLMUnavailable
6
  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
 
24
  def _get_criteria() -> list[Criterion]:
25
  data = st.session_state.get("criteria")
26
+ return [Criterion(**c) for c in data] if data else load_criteria()
 
 
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:
49
+ cm = {c.id: c for c in criteria}
50
+ lines = [f"BIDDER: {BIDDER_NAMES.get(bid, bid)}", ""]
 
 
 
 
 
51
  for v in verdicts:
52
+ c = cm.get(v["criterion_id"])
53
+ rule = _RULE_PLAIN.get(c.rule.type, lambda _: "")(c.rule.model_dump()) if c else ""
54
+ lines += [
55
+ f"{v['criterion_id']} — {c.title if c else '?'} "
56
+ f"[{'Mandatory' if c and c.mandatory else 'Optional'}]: {v['verdict'].upper()}",
57
+ f" Requirement: {rule}",
58
+ f" Extracted: {v.get('extracted_value') or 'not found'}",
59
+ f" Confidence: {v.get('combined_confidence', 0):.0%}",
60
+ f" Reason: {v.get('reason', '')}",
61
+ ]
62
  if v.get("source"):
63
+ s = v["source"]
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
 
71
 
72
+ def _answer(question: str, context: str) -> str:
73
+ system = (
74
+ "You are a procurement compliance assistant. Answer questions about an AI-generated "
75
+ "tender evaluation in plain professional English. Always cite specific document names "
76
+ "and page numbers. Be concise (2-4 sentences). Never invent information not in the context. "
77
+ 'Return JSON: {"answer": "<your answer>"}'
78
+ )
 
 
 
 
 
 
79
  try:
80
+ result = LLM().chat_json(system, f"{context}\n\nQUESTION: {question}")
 
 
 
 
81
  return result.get("answer", "")
82
  except LLMUnavailable:
83
+ return _rule_answer(question, context)
84
 
85
 
86
+ def _rule_answer(q: str, context: str) -> str:
87
+ q = q.lower()
88
  lines = context.splitlines()
 
89
  if any(w in q for w in ["reject", "fail", "not eligible", "disqualif"]):
90
+ fails = [l.strip() for l in lines if "NOT_ELIGIBLE" in l or "NEEDS_REVIEW" in l]
91
+ return ("Failing criteria: " + "; ".join(fails[:3]) + ".") if fails else "No failing criteria found."
92
+ if any(w in q for w in ["pass", "eligible", "meet"]):
93
+ passes = [l.strip() for l in lines if "ELIGIBLE" in l and "NOT_ELIGIBLE" not in l]
94
+ return ("Passing criteria: " + "; ".join(passes[:3]) + ".") if passes else "No passing criteria."
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()
116
  crit_map = {c.id: c for c in criteria}
117
 
118
+ bid = st.selectbox("Select bidder", options=list(vdata.keys()),
119
+ format_func=lambda x: BIDDER_NAMES.get(x, x))
120
+ verdicts = vdata.get(bid, [])
 
 
 
 
121
  if not verdicts:
122
+ st.warning("No verdicts for this bidder.")
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):
 
222
  try:
223
+ img = render_page_to_image(doc_path, int(page))
224
+ st.image(img, caption=f"{doc} — Page {page}",
225
  use_column_width=True)
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…",
252
+ key=f"qa_{bid}", label_visibility="collapsed")
253
+ if st.button("Get Answer", type="primary", key=f"qa_btn_{bid}"):
 
 
 
 
254
  if not question.strip():
255
  st.warning("Please enter a question.")
256
  else:
257
+ context = _qa_context(bid, verdicts, criteria)
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):
270
  st.code(context, language="text")
ui/tab_overview.py CHANGED
@@ -3,95 +3,96 @@ import streamlit as st
3
  from core import audit
4
  from core.config import BIDDER_NAMES
5
  from core.fallback import load_criteria
 
6
 
7
 
8
  def render() -> None:
9
- # Hero banner
10
  st.markdown(
11
- """<div class="tiq-hero">
12
- <h1>⚖️ TenderIQ</h1>
13
- <p>Explainable AI for Government Tender Evaluation &nbsp;·&nbsp;
14
- CRPF Hackathon Theme 3</p>
15
- <p style="font-size:0.88rem;margin-top:8px;color:#94A3B8;">
16
- Automated eligibility evaluation with criterion-level explainability,
17
- three-tier OCR for scanned documents, and a complete audit trail.</p>
 
 
 
 
 
 
18
  </div>""",
19
  unsafe_allow_html=True,
20
  )
21
 
22
- # KPI strip
23
  criteria_count = len(st.session_state.get("criteria", load_criteria()))
24
- verdicts = st.session_state.get("verdicts", {})
25
- bidders_evaluated = len(verdicts)
26
- mandatory_checked = sum(
27
- 1 for bv in verdicts.values() for v in bv
28
- if v.get("verdict") in ("eligible", "not_eligible", "needs_review")
29
- )
30
- audit_entries = len(audit.query())
31
 
32
  c1, c2, c3, c4 = st.columns(4)
33
- for col, val, lbl in [
34
- (c1, criteria_count, "Criteria Extracted"),
35
- (c2, bidders_evaluated, "Bidders Evaluated"),
36
- (c3, mandatory_checked, "Criteria Checked"),
37
- (c4, audit_entries, "Audit Entries"),
38
- ]:
39
- col.markdown(
40
- f'<div class="tiq-kpi">'
41
- f'<div class="tiq-kpi-val">{val}</div>'
42
- f'<div class="tiq-kpi-lbl">{lbl}</div>'
43
- f'</div>',
44
- unsafe_allow_html=True,
45
- )
46
 
47
  st.divider()
48
 
49
- # Architecture
50
- st.markdown('<div class="tiq-section-header"><div style="font-size:1.1rem;font-weight:700;color:#0D1B2A;">Pipeline Architecture</div></div>', unsafe_allow_html=True)
51
-
52
- col_a, col_b = st.columns(2)
53
- with col_a:
54
- st.markdown("""
55
- <div style="background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:20px;">
56
- <div style="font-weight:700;color:#1E3A5F;margin-bottom:12px;">📥 Ingestion</div>
57
-
58
- <div style="display:flex;align-items:flex-start;gap:10px;margin-bottom:10px;">
59
- <div style="background:#DBEAFE;color:#1E40AF;border-radius:6px;padding:3px 8px;font-size:0.75rem;font-weight:700;flex-shrink:0;">1</div>
60
- <div><strong>Extract Criteria</strong><br><span style="font-size:0.82rem;color:#64748B;">DeepSeek LLM reads the full tender PDF and returns structured JSON — category, mandatory flag, rule, source clause, query hints.</span></div>
61
- </div>
62
-
63
- <div style="display:flex;align-items:flex-start;gap:10px;">
64
- <div style="background:#DBEAFE;color:#1E40AF;border-radius:6px;padding:3px 8px;font-size:0.75rem;font-weight:700;flex-shrink:0;">2</div>
65
- <div><strong>Three-Tier OCR</strong><br><span style="font-size:0.82rem;color:#64748B;">
66
- 📄 PyMuPDF → 🔍 Tesseract → 👁 Vision LLM.<br>
67
- Each page records its tier and confidence score.
68
- Chunks indexed into ChromaDB with full provenance.</span></div>
69
- </div>
70
- </div>
71
- """, unsafe_allow_html=True)
72
-
73
- with col_b:
74
- st.markdown("""
75
- <div style="background:#F8FAFC;border:1px solid #E2E8F0;border-radius:12px;padding:20px;">
76
- <div style="font-weight:700;color:#1E3A5F;margin-bottom:12px;">⚖️ Evaluation & Oversight</div>
77
-
78
- <div style="display:flex;align-items:flex-start;gap:10px;margin-bottom:10px;">
79
- <div style="background:#DCFCE7;color:#166534;border-radius:6px;padding:3px 8px;font-size:0.75rem;font-weight:700;flex-shrink:0;">3</div>
80
- <div><strong>Evaluate per Criterion</strong><br><span style="font-size:0.82rem;color:#64748B;">Semantic search retrieves top-k evidence chunks. DeepSeek returns verdict + confidence. Safety rule: borderline "not eligible" is downgraded to "needs review" — never silent disqualification.</span></div>
81
- </div>
82
 
83
- <div style="display:flex;align-items:flex-start;gap:10px;">
84
- <div style="background:#FEF3C7;color:#92400E;border-radius:6px;padding:3px 8px;font-size:0.75rem;font-weight:700;flex-shrink:0;">4</div>
85
- <div><strong>Human Review & Audit</strong><br><span style="font-size:0.82rem;color:#64748B;">Flagged verdicts surface with full evidence. Every action extraction, OCR, evaluation, review is logged to SQLite with timestamp, model version, and payload.</span></div>
86
- </div>
87
- </div>
88
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
 
90
  st.divider()
91
 
92
  # Quick start
93
- st.markdown('<div class="tiq-section-header"><div style="font-size:1.1rem;font-weight:700;color:#0D1B2A;">Quick Start</div></div>', unsafe_allow_html=True)
94
-
 
 
 
95
  col1, col2 = st.columns(2)
96
  with col1:
97
  with st.container(border=True):
@@ -101,16 +102,14 @@ Chunks indexed into ChromaDB with full provenance.</span></div>
101
  from core.fallback import load_criteria as lc, load_evaluation
102
  criteria = lc()
103
  st.session_state["criteria"] = [c.model_dump() for c in criteria]
104
- verdicts_dict: dict = {}
105
- for bidder_id in BIDDER_NAMES:
106
- verdicts_dict[bidder_id] = [
107
- load_evaluation(bidder_id, c.id).model_dump() for c in criteria
108
- ]
109
- st.session_state["verdicts"] = verdicts_dict
110
- st.success("Loaded. Navigate to Bidder Evaluation or Interpretability.")
111
  st.rerun()
112
  with col2:
113
  with st.container(border=True):
114
  st.markdown("**⚡ Live Pipeline**")
115
- st.caption("Upload a tender PDF, run extraction and evaluation against the DeepSeek API.")
116
- st.info("Set `DEEPSEEK_API_KEY` in `.env`, then use the Tender Analysis tab.")
 
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;">
14
+ <div style="font-size:0.75rem;font-weight:700;color:#93C5FD;
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>""",
26
  unsafe_allow_html=True,
27
  )
28
 
29
+ # KPIs
30
  criteria_count = len(st.session_state.get("criteria", load_criteria()))
31
+ verdicts = st.session_state.get("verdicts", {})
32
+ checked = sum(1 for bv in verdicts.values() for _ in bv)
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):
 
102
  from core.fallback import load_criteria as lc, load_evaluation
103
  criteria = lc()
104
  st.session_state["criteria"] = [c.model_dump() for c in criteria]
105
+ vd: dict = {}
106
+ for bid in BIDDER_NAMES:
107
+ vd[bid] = [load_evaluation(bid, c.id).model_dump() for c in criteria]
108
+ st.session_state["verdicts"] = vd
109
+ st.success("Loaded — navigate to Bidder Evaluation or Interpretability.")
 
 
110
  st.rerun()
111
  with col2:
112
  with st.container(border=True):
113
  st.markdown("**⚡ Live Pipeline**")
114
+ st.caption("Set DEEPSEEK_API_KEY in .env, then use the Tender Analysis tab.")
115
+ st.info("Sidebar shows 🟢 when the API is reachable.")
ui/tab_review.py CHANGED
@@ -7,132 +7,138 @@ from core.schemas import Criterion
7
  from ui.components import confidence_bar, verdict_pill
8
 
9
 
10
- def _get_criteria_map() -> dict[str, Criterion]:
11
  data = st.session_state.get("criteria")
12
- if data:
13
- return {c["id"]: Criterion(**c) for c in data}
14
- return {c.id: c for c in load_criteria()}
15
 
16
 
17
  def render() -> None:
18
- st.header("Human Review Queue")
 
 
 
 
 
 
19
 
20
- verdicts_data: dict = st.session_state.get("verdicts", {})
21
- if not verdicts_data:
22
  st.info("No evaluation results yet. Run the evaluation in the Bidder Evaluation tab first.")
23
  return
24
 
25
- crit_map = _get_criteria_map()
26
- pending_items = []
27
- for bidder_id, verdicts in verdicts_data.items():
28
- for i, v in enumerate(verdicts):
29
- if v.get("verdict") == "needs_review" and v.get("review_status", "pending") == "pending":
30
- pending_items.append((bidder_id, i, v))
31
 
32
- if not pending_items:
33
- st.success("No items pending review. All flagged verdicts have been actioned.")
34
  return
35
 
36
- st.markdown(f"**{len(pending_items)} item(s) pending review**")
37
- st.caption(
38
- "These verdicts require human confirmation before being finalised. "
39
- "The certainty bar shows how confident the model is in its decision to flag the item — "
40
- "not how likely the bidder meets the criterion."
 
 
 
 
 
 
 
 
41
  )
42
- st.divider()
43
 
44
- for bidder_id, idx, v in pending_items:
45
- crit = crit_map.get(v["criterion_id"])
46
  crit_title = crit.title if crit else v["criterion_id"]
 
47
 
48
  with st.container(border=True):
49
- col1, col2 = st.columns([3, 1])
50
- friendly = BIDDER_NAMES.get(bidder_id, bidder_id)
51
- with col1:
52
- st.markdown(
53
- f'<div style="font-weight:700;font-size:1rem;color:#0D1B2A;">'
54
- f'{friendly}</div>'
55
- f'<div style="font-size:0.85rem;color:#64748B;margin-top:2px;">'
56
- f'{v["criterion_id"]}: {crit_title}</div>',
57
- unsafe_allow_html=True,
58
- )
59
- st.markdown(verdict_pill(v["verdict"]), unsafe_allow_html=True)
 
 
 
 
 
60
  if v.get("extracted_value"):
61
  st.markdown(
62
- f'<div style="margin-top:6px;font-size:0.85rem;">'
63
  f'<strong>Extracted value:</strong> '
64
- f'<code>{v["extracted_value"]}</code></div>',
 
65
  unsafe_allow_html=True,
66
  )
67
  if v.get("reason"):
68
  st.markdown(
69
  f'<div style="background:#FFFBEB;border-left:3px solid #F59E0B;'
70
- f'padding:8px 12px;border-radius:0 6px 6px 0;margin-top:8px;'
71
- f'font-size:0.85rem;color:#374151;">'
72
  f'<strong>Reason:</strong> {v["reason"]}</div>',
73
  unsafe_allow_html=True,
74
  )
75
  if v.get("source") and v["source"].get("snippet"):
76
  st.markdown(
77
  f'<div style="background:#F8FAFC;border:1px solid #E2E8F0;'
78
- f'padding:8px 12px;border-radius:6px;margin-top:6px;'
79
- f'font-size:0.82rem;color:#374151;font-style:italic;">'
80
- f'"{v["source"]["snippet"]}"</div>',
81
  unsafe_allow_html=True,
82
  )
83
- with col2:
84
- conf = v.get("combined_confidence", 0.0)
85
- confidence_bar(conf, "Certainty in assessment")
86
 
87
- btn_col1, btn_col2, btn_col3 = st.columns(3)
88
- key_prefix = f"review_{bidder_id}_{v['criterion_id']}"
 
 
89
 
90
- with btn_col1:
91
- if st.button("✅ Approve", key=f"{key_prefix}_approve", use_container_width=True):
92
- st.session_state["verdicts"][bidder_id][idx]["review_status"] = "approved"
93
- audit.log(
94
- "human_review_action",
95
- actor="officer",
96
- bidder_id=bidder_id,
97
- criterion_id=v["criterion_id"],
98
- action_taken="approved",
99
- original_verdict=v["verdict"],
100
- original_extracted_value=v.get("extracted_value", ""),
101
- combined_confidence=v.get("combined_confidence", 0.0),
102
- )
103
  st.rerun()
104
-
105
- with btn_col2:
106
- edit_val = st.text_input("Edited value", key=f"{key_prefix}_edit_val",
107
- placeholder="Enter corrected value…")
108
- if st.button("✏ Edit & Approve", key=f"{key_prefix}_edit", use_container_width=True):
109
- st.session_state["verdicts"][bidder_id][idx]["review_status"] = "edited"
110
- if edit_val:
111
- st.session_state["verdicts"][bidder_id][idx]["extracted_value"] = edit_val
112
- audit.log(
113
- "human_review_action",
114
- actor="officer",
115
- bidder_id=bidder_id,
116
- criterion_id=v["criterion_id"],
117
- action_taken="edited",
118
- original_verdict=v["verdict"],
119
- original_extracted_value=v.get("extracted_value", ""),
120
- edited_value=edit_val,
121
- combined_confidence=v.get("combined_confidence", 0.0),
122
- )
123
  st.rerun()
124
-
125
- with btn_col3:
126
- if st.button("❌ Reject", key=f"{key_prefix}_reject", use_container_width=True):
127
- st.session_state["verdicts"][bidder_id][idx]["review_status"] = "rejected"
128
- audit.log(
129
- "human_review_action",
130
- actor="officer",
131
- bidder_id=bidder_id,
132
- criterion_id=v["criterion_id"],
133
- action_taken="rejected",
134
- original_verdict=v["verdict"],
135
- original_extracted_value=v.get("extracted_value", ""),
136
- combined_confidence=v.get("combined_confidence", 0.0),
137
- )
138
  st.rerun()
 
7
  from ui.components import confidence_bar, verdict_pill
8
 
9
 
10
+ def _crit_map() -> dict[str, Criterion]:
11
  data = st.session_state.get("criteria")
12
+ return {c["id"]: Criterion(**c) for c in data} if data else {c.id: c for c in load_criteria()}
 
 
13
 
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
+ )
23
 
24
+ vdata: dict = st.session_state.get("verdicts", {})
25
+ if not vdata:
26
  st.info("No evaluation results yet. Run the evaluation in the Bidder Evaluation tab first.")
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
  )
 
54
 
55
+ for bid, idx, v in pending:
56
+ crit = cm.get(v["criterion_id"])
57
  crit_title = crit.title if crit else v["criterion_id"]
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>',
72
+ unsafe_allow_html=True,
73
+ )
74
+
75
+ col_l, col_r = st.columns([3, 1])
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",
122
+ placeholder="Optional override…",
123
+ label_visibility="collapsed")
124
+ if st.button("✏ Edit & Approve", key=f"{kp}_edit", use_container_width=True):
125
+ st.session_state["verdicts"][bid][idx]["review_status"] = "edited"
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()