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

Final sweep: beautiful UI, pitch deck, HF README, ARCHITECTURE.md

Browse files

UI overhaul:
- .streamlit/config.toml: Streamlit theme (navy primary, clean typography)
- ui/styles.py: comprehensive CSS — dark navy sidebar, metric cards with hover
lift, pill-style tab bar, gradient primary buttons, HTML verdict/category/OCR
tier badges, hero banner, custom confidence bars with green/amber/red coloring
- ui/components.py: HTML badge system (verdict_pill, category_badge,
ocr_tier_badge, mandatory_badge, confidence_bar with color thresholds)
- app.py: inject CSS, redesigned sidebar with glowing status dots and branding
- ui/tab_overview.py: hero banner, HTML KPI strip, styled pipeline stage cards
- ui/tab_bidders.py: HTML column headers, bidder header with pass/total count,
styled reason/snippet callout boxes
- ui/tab_review.py: HTML verdict display, friendly company names, styled cards
- ui/tab_interpretability.py: fix use_column_width for Streamlit 1.39.0

Submission:
- README.md: HuggingFace Spaces YAML frontmatter (sdk, emoji, colorFrom/To)
- ARCHITECTURE.md: full module responsibilities, OCR pipeline diagram,
confidence formula, threshold rules, audit schema, data flow
- scripts/generate_deck.py: 8-slide reportlab pitch deck
- deck/TenderIQ_Pitch.pdf: generated pitch deck

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

.streamlit/config.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ [theme]
2
+ primaryColor = "#2563EB"
3
+ backgroundColor = "#F8FAFC"
4
+ secondaryBackgroundColor = "#FFFFFF"
5
+ textColor = "#0D1B2A"
6
+ font = "sans serif"
7
+
8
+ [browser]
9
+ gatherUsageStats = false
ARCHITECTURE.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TenderIQ — Architecture
2
+
3
+ ## Overview
4
+
5
+ TenderIQ is a single-process Streamlit application that automates eligibility evaluation
6
+ of bidders against government tender criteria. All state is local: SQLite for the audit
7
+ log, ChromaDB (file-backed) for vector indices, and the filesystem for PDFs and cached
8
+ OCR results. The only external dependency is the DeepSeek API.
9
+
10
+ ```
11
+ ┌──────────────────────────────────────────────────────────────────────┐
12
+ │ Streamlit App (app.py) │
13
+ │ │
14
+ │ Tab 1: Overview Tab 2: Tender Analysis Tab 3: Bidder Evaluation │
15
+ │ Tab 4: Human Review Tab 5: Audit Log Tab 6: Interpretability │
16
+ └────────────────────────────┬─────────────────────────────────────────┘
17
+ │ calls
18
+ ┌────────────────────┼────────────────────┐
19
+ ▼ ▼ ▼
20
+ ┌────────────┐ ┌──────────────────┐ ┌──────────────┐
21
+ │ criteria_ │ │ bidder_processor │ │ evaluator │
22
+ │ extractor │ │ ocr_pipeline │ │ fallback │
23
+ └────┬───────┘ └────┬─────────────┘ └──────┬───────┘
24
+ │ │ │
25
+ ▼ ▼ ▼
26
+ ┌──────────┐ ┌─────────────┐ ┌──────────────┐
27
+ │ DeepSeek │ │ ChromaDB │ │ DeepSeek LLM │
28
+ │ LLM │ │ (vectors) │◄────────│ (evaluate) │
29
+ └──────────┘ └─────────────┘ └──────────────┘
30
+ │ ▲
31
+ │ │ index chunks
32
+ │ ┌──────┴──────┐
33
+ │ │ 3-tier OCR │
34
+ │ │ PyMuPDF │
35
+ │ │ Tesseract │
36
+ │ │ Vision LLM │
37
+ │ └─────────────┘
38
+
39
+
40
+ ┌──────────┐
41
+ │ audit │ (SQLite, append-only)
42
+ └──────────┘
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Module Responsibilities
48
+
49
+ | Module | Responsibility |
50
+ |---|---|
51
+ | `core/config.py` | Environment loading, constants, paths |
52
+ | `core/schemas.py` | Pydantic models: Criterion, Evidence, Verdict, AuditEntry |
53
+ | `core/prompts.py` | LLM prompt strings (criteria extraction, evaluation, OCR) |
54
+ | `core/llm_client.py` | DeepSeek API wrapper — `chat_json`, `chat_vision`, retry logic, `LLMUnavailable` |
55
+ | `core/pdf_utils.py` | PyMuPDF: text extraction, text-PDF detection, page rendering |
56
+ | `core/ocr_pipeline.py` | Three-tier OCR orchestrator, MD5-based result cache |
57
+ | `core/chunker.py` | Text chunking for tender and bidder documents |
58
+ | `core/vectorstore.py` | ChromaDB helpers: get/create collection, upsert, query |
59
+ | `core/criteria_extractor.py` | Stage 1: tender PDF → `list[Criterion]` |
60
+ | `core/bidder_processor.py` | Stage 2: bidder docs → chunks + evidence retrieval |
61
+ | `core/evaluator.py` | Stage 3: per-criterion verdict with combined confidence |
62
+ | `core/audit.py` | SQLite audit log: write and query |
63
+ | `core/fallback.py` | Load pre-computed JSON when LLM unavailable |
64
+
65
+ ---
66
+
67
+ ## Three-Tier OCR Pipeline
68
+
69
+ The robustness centrepiece. Handles typed PDFs, scanned PDFs, and photographs of documents.
70
+
71
+ ```
72
+ Input file
73
+
74
+ ├─ Is image (PNG/JPG)? ──────────────────────────────────┐
75
+ │ │
76
+ └─ Is PDF? ▼
77
+ │ Tier 2: Tesseract
78
+ ├─ is_text_pdf() == True │
79
+ │ │ mean_conf ≥ 0.65?
80
+ │ ▼ │
81
+ │ Tier 1: PyMuPDF Yes ──┘ No
82
+ │ confidence = 1.0 │
83
+ │ source_type = "text_pdf" ▼
84
+ │ Tier 3: DeepSeek Vision LLM
85
+ └─ is_text_pdf() == False confidence = 0.95
86
+ │ source_type = "vision_llm"
87
+
88
+ Render pages → Tier 2
89
+ ```
90
+
91
+ Each `ExtractedPage` carries: `page`, `text`, `source_type`, `confidence`, `raw_tier_results`.
92
+ Results cached under `.ocr_cache/<md5>.json`.
93
+
94
+ ---
95
+
96
+ ## Confidence & Threshold Logic
97
+
98
+ Combined confidence weights LLM certainty against OCR quality:
99
+
100
+ | OCR Tier | Formula |
101
+ |---|---|
102
+ | `text_pdf` | `combined = llm_confidence` |
103
+ | `vision_llm` | `combined = 0.7 × llm_confidence + 0.3 × 0.95` |
104
+ | `tesseract` | `combined = 0.6 × llm_confidence + 0.4 × tesseract_conf` |
105
+
106
+ Safety threshold rules (applied in order):
107
+
108
+ 1. LLM returns `needs_review` → keep (regardless of confidence)
109
+ 2. `combined ≥ 0.80` → keep LLM verdict
110
+ 3. `0.55 ≤ combined < 0.80` AND verdict is `not_eligible` → downgrade to `needs_review`
111
+ 4. `combined < 0.55` → force `needs_review`
112
+
113
+ **The core safety guarantee:** a bidder is never silently disqualified at medium or low confidence.
114
+
115
+ ---
116
+
117
+ ## Fallback Strategy
118
+
119
+ Every live LLM call is wrapped in `try/except LLMUnavailable`. On failure:
120
+
121
+ 1. `audit.log("precomputed_fallback_used", ...)` is written
122
+ 2. `st.session_state["fallback_active"] = True` triggers the amber sidebar dot
123
+ 3. Data is loaded from `data/precomputed/*.json` (committed to the repo)
124
+
125
+ This means the demo works even if the API is down, rate-limited, or the key is missing.
126
+
127
+ ---
128
+
129
+ ## Audit Log Schema
130
+
131
+ ```sql
132
+ CREATE TABLE audit_log (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ ts TEXT NOT NULL, -- UTC ISO timestamp
135
+ action TEXT NOT NULL, -- see action vocabulary below
136
+ actor TEXT NOT NULL, -- "system" or "officer"
137
+ model_version TEXT, -- e.g. "deepseek-chat@2026-05-07"
138
+ bidder_id TEXT,
139
+ criterion_id TEXT,
140
+ payload_json TEXT -- action-specific JSON payload
141
+ );
142
+ ```
143
+
144
+ Action vocabulary: `criteria_extracted`, `bidder_processed`, `criterion_evaluated`,
145
+ `human_review_action`, `precomputed_fallback_used`, `vision_ocr_invoked`.
146
+
147
+ ---
148
+
149
+ ## Data Flow (full pipeline)
150
+
151
+ ```
152
+ 1. Officer uploads tender PDF
153
+
154
+
155
+ 2. criteria_extractor.extract_criteria(pdf)
156
+ → LLM reads tender text
157
+ → Returns List[Criterion] with structured rules
158
+ → Stored in st.session_state["criteria"]
159
+
160
+
161
+ 3. bidder_processor.process_bidder(bidder_id, files)
162
+ → For each file: ocr_pipeline.extract_document()
163
+ → chunker.chunk_bidder()
164
+ → vectorstore.add_chunks("bidder_chunks", ...)
165
+
166
+
167
+ 4. evaluator.evaluate(bidder_id, criterion)
168
+ → bidder_processor.gather_evidence() — semantic search in ChromaDB
169
+ → LLM evaluates evidence against criterion rule
170
+ → combined_confidence computed
171
+ → threshold safety rules applied
172
+ → Verdict returned + audit logged
173
+
174
+
175
+ 5. Verdicts stored in st.session_state["verdicts"]
176
+ → Tab 3: evaluation matrix
177
+ → Tab 4: needs_review items surfaced for human action
178
+ → Tab 6: plain-English explanation + Q&A
179
+ → Tab 5: full audit trail
180
+ ```
README.md CHANGED
@@ -1,3 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  # TenderIQ — Explainable AI for Tender Evaluation
2
 
3
  AI-powered eligibility evaluation of bidders against government tender criteria, built for the **CRPF Hackathon, Theme 3**.
 
1
+ ---
2
+ title: TenderIQ
3
+ emoji: ⚖️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: streamlit
7
+ sdk_version: 1.39.0
8
+ app_file: app.py
9
+ pinned: true
10
+ license: agpl-3.0
11
+ short_description: Explainable AI for Government Tender Evaluation (CRPF Hackathon)
12
+ ---
13
+
14
  # TenderIQ — Explainable AI for Tender Evaluation
15
 
16
  AI-powered eligibility evaluation of bidders against government tender criteria, built for the **CRPF Hackathon, Theme 3**.
app.py CHANGED
@@ -2,6 +2,7 @@ import shutil
2
 
3
  import streamlit as st
4
 
 
5
  from ui.tab_overview import render as render_overview
6
  from ui.tab_tender import render as render_tender
7
  from ui.tab_bidders import render as render_bidders
@@ -15,6 +16,8 @@ st.set_page_config(
15
  layout="wide",
16
  )
17
 
 
 
18
 
19
  def _probe_llm() -> str:
20
  """Probe once per session; returns 'green', 'amber', or 'red'."""
@@ -36,7 +39,6 @@ def _probe_llm() -> str:
36
 
37
 
38
  def _reset_demo() -> None:
39
- """Clear session, audit DB, ChromaDB, and OCR cache for a clean demo run."""
40
  from core import audit
41
  from core.config import CHROMA_DIR, OCR_CACHE_DIR
42
  audit.clear()
@@ -49,64 +51,90 @@ def _reset_demo() -> None:
49
 
50
  # ── Sidebar ──────────────────────────────────────────────────────────────────
51
  with st.sidebar:
52
- st.markdown("## ⚖️ TenderIQ")
53
- st.caption("Explainable AI for Tender Evaluation")
 
 
 
 
 
 
 
 
 
54
  st.divider()
55
 
56
  status = _probe_llm()
57
  if status == "green":
58
- st.markdown("🟢 **DeepSeek:** connected")
 
 
 
 
 
 
59
  elif status == "amber":
60
- st.markdown("🟡 **DeepSeek:** pre-computed mode")
61
- st.warning("⚠ Pre-computed results active.")
 
 
 
 
 
 
62
  else:
63
- st.markdown("🔴 **DeepSeek:** not connected")
 
 
 
 
 
 
64
  st.caption("Using pre-computed fallback data.")
65
 
66
  st.divider()
67
 
68
- if st.button("Reset Session", use_container_width=True):
69
  for key in list(st.session_state.keys()):
70
  del st.session_state[key]
71
  st.rerun()
72
 
73
- if st.button("🗑 Reset for Demo", use_container_width=True, type="secondary"):
74
  st.session_state["confirm_demo_reset"] = True
75
 
76
  if st.session_state.get("confirm_demo_reset"):
77
- st.warning("Clears audit log, vector index, OCR cache, and session. Sure?")
78
- col1, col2 = st.columns(2)
79
- if col1.button("Yes, reset", type="primary", use_container_width=True):
80
  _reset_demo()
81
  st.rerun()
82
- if col2.button("Cancel", use_container_width=True):
83
  st.session_state.pop("confirm_demo_reset", None)
84
  st.rerun()
85
 
 
 
 
 
86
  # ── Tabs ─────────────────────────────────────────────────────────────────────
87
  tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
88
- "Overview",
89
- "Tender Analysis",
90
- "Bidder Evaluation",
91
- "Human Review",
92
- "Audit Log",
93
- "Interpretability",
94
  ])
95
 
96
  with tab1:
97
  render_overview()
98
-
99
  with tab2:
100
  render_tender()
101
-
102
  with tab3:
103
  render_bidders()
104
-
105
  with tab4:
106
  render_review()
107
-
108
  with tab5:
109
  render_audit()
110
-
111
  with tab6:
112
  render_interpretability()
 
2
 
3
  import streamlit as st
4
 
5
+ from ui.styles import CSS
6
  from ui.tab_overview import render as render_overview
7
  from ui.tab_tender import render as render_tender
8
  from ui.tab_bidders import render as render_bidders
 
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'."""
 
39
 
40
 
41
  def _reset_demo() -> None:
 
42
  from core import audit
43
  from core.config import CHROMA_DIR, OCR_CACHE_DIR
44
  audit.clear()
 
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"):
103
  st.session_state["confirm_demo_reset"] = True
104
 
105
  if st.session_state.get("confirm_demo_reset"):
106
+ st.warning("Clears audit log, vector index, OCR cache, and session.")
107
+ c1, c2 = st.columns(2)
108
+ if c1.button("Yes, reset", type="primary", use_container_width=True):
109
  _reset_demo()
110
  st.rerun()
111
+ if c2.button("Cancel", use_container_width=True):
112
  st.session_state.pop("confirm_demo_reset", None)
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()
deck/TenderIQ_Pitch.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b0b09f58d390bbd8e074811a9ddef8bd64489db204f0278041f97658e3a29c80
3
+ size 18040
scripts/generate_deck.py ADDED
@@ -0,0 +1,435 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate TenderIQ_Pitch.pdf — 8-slide pitch deck using reportlab."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ BASE_DIR = Path(__file__).resolve().parent.parent
7
+ sys.path.insert(0, str(BASE_DIR))
8
+
9
+ from reportlab.lib import colors
10
+ from reportlab.lib.pagesizes import A4
11
+ from reportlab.lib.units import cm, mm
12
+ from reportlab.pdfgen.canvas import Canvas
13
+
14
+ W, H = A4
15
+ NAVY = colors.HexColor("#0D1B2A")
16
+ BLUE = colors.HexColor("#2563EB")
17
+ LBLUE = colors.HexColor("#DBEAFE")
18
+ GOLD = colors.HexColor("#F0A500")
19
+ WHITE = colors.white
20
+ GREY = colors.HexColor("#64748B")
21
+ LGREY = colors.HexColor("#F1F5F9")
22
+ GREEN = colors.HexColor("#059669")
23
+ RED = colors.HexColor("#DC2626")
24
+ AMBER = colors.HexColor("#D97706")
25
+ BORD = colors.HexColor("#E2E8F0")
26
+
27
+
28
+ def _header_bar(c: Canvas, title: str, subtitle: str = "") -> None:
29
+ c.setFillColor(NAVY)
30
+ c.rect(0, H - 2.8*cm, W, 2.8*cm, fill=1, stroke=0)
31
+ c.setFillColor(GOLD)
32
+ c.rect(0, H - 2.85*cm, W, 0.18*cm, fill=1, stroke=0)
33
+ c.setFillColor(WHITE)
34
+ c.setFont("Helvetica-Bold", 18)
35
+ c.drawString(1.8*cm, H - 1.7*cm, title)
36
+ if subtitle:
37
+ c.setFont("Helvetica", 10)
38
+ c.setFillColor(colors.HexColor("#94A3B8"))
39
+ c.drawString(1.8*cm, H - 2.3*cm, subtitle)
40
+
41
+
42
+ def _footer(c: Canvas, page: int, total: int = 8) -> None:
43
+ c.setFillColor(LGREY)
44
+ c.rect(0, 0, W, 1.0*cm, fill=1, stroke=0)
45
+ c.setFillColor(GREY)
46
+ c.setFont("Helvetica", 8)
47
+ c.drawString(1.8*cm, 0.35*cm, "TenderIQ · CRPF Hackathon Theme 3 · Explainable AI for Government Procurement")
48
+ c.drawRightString(W - 1.8*cm, 0.35*cm, f"{page} / {total}")
49
+
50
+
51
+ def _bullet(c: Canvas, x: float, y: float, text: str,
52
+ size: int = 10, indent: float = 0.5*cm) -> float:
53
+ c.setFillColor(BLUE)
54
+ c.circle(x + 0.15*cm, y + 0.3*cm, 0.12*cm, fill=1, stroke=0)
55
+ c.setFillColor(NAVY)
56
+ c.setFont("Helvetica", size)
57
+ lines = _wrap(text, 85 - int(indent / mm))
58
+ for i, line in enumerate(lines):
59
+ c.drawString(x + indent, y - i * (size + 3) * 0.035 * cm * 28.35 / 10, line)
60
+ return y - len(lines) * (size + 4) * 0.035 * cm * 28.35 / 10
61
+
62
+
63
+ def _wrap(text: str, width: int) -> list[str]:
64
+ words = text.split()
65
+ lines, cur = [], ""
66
+ for w in words:
67
+ if len(cur) + len(w) + 1 <= width:
68
+ cur = (cur + " " + w).strip()
69
+ else:
70
+ if cur:
71
+ lines.append(cur)
72
+ cur = w
73
+ if cur:
74
+ lines.append(cur)
75
+ return lines or [""]
76
+
77
+
78
+ def _card(c: Canvas, x: float, y: float, w: float, h: float,
79
+ title: str, body: str, accent: colors.Color = BLUE) -> None:
80
+ c.setFillColor(WHITE)
81
+ c.setStrokeColor(BORD)
82
+ c.roundRect(x, y, w, h, 0.3*cm, fill=1, stroke=1)
83
+ c.setFillColor(accent)
84
+ c.roundRect(x, y + h - 0.35*cm, w, 0.35*cm, 0.3*cm, fill=1, stroke=0)
85
+ c.rect(x, y + h - 0.35*cm, w, 0.2*cm, fill=1, stroke=0)
86
+ c.setFillColor(WHITE)
87
+ c.setFont("Helvetica-Bold", 9)
88
+ c.drawString(x + 0.3*cm, y + h - 0.25*cm, title)
89
+ c.setFillColor(GREY)
90
+ c.setFont("Helvetica", 8.5)
91
+ lines = _wrap(body, int(w / (0.22*cm)))
92
+ for i, line in enumerate(lines[:5]):
93
+ c.drawString(x + 0.3*cm, y + h - 0.75*cm - i * 0.45*cm, line)
94
+
95
+
96
+ def slide_1_title(c: Canvas) -> None:
97
+ c.setFillColor(NAVY)
98
+ c.rect(0, 0, W, H, fill=1, stroke=0)
99
+ c.setFillColor(BLUE)
100
+ c.rect(0, 0, W, 0.5*cm, fill=1, stroke=0)
101
+ c.setFillColor(GOLD)
102
+ c.rect(0, 0.5*cm, W, 0.12*cm, fill=1, stroke=0)
103
+
104
+ c.setFillColor(WHITE)
105
+ c.setFont("Helvetica", 40)
106
+ c.drawCentredString(W / 2, H - 6*cm, "⚖️")
107
+ c.setFont("Helvetica-Bold", 36)
108
+ c.drawCentredString(W / 2, H - 8*cm, "TenderIQ")
109
+ c.setFont("Helvetica", 15)
110
+ c.setFillColor(colors.HexColor("#CBD5E1"))
111
+ c.drawCentredString(W / 2, H - 9.2*cm,
112
+ "Explainable AI for Government Tender Evaluation")
113
+
114
+ c.setFillColor(GOLD)
115
+ c.roundRect(W/2 - 5*cm, H - 11.5*cm, 10*cm, 1.1*cm, 0.3*cm, fill=1, stroke=0)
116
+ c.setFillColor(NAVY)
117
+ c.setFont("Helvetica-Bold", 11)
118
+ c.drawCentredString(W / 2, H - 11.0*cm, "CRPF Hackathon · Theme 3")
119
+
120
+ c.setFillColor(colors.HexColor("#64748B"))
121
+ c.setFont("Helvetica", 9)
122
+ c.drawCentredString(W / 2, H - 13.5*cm,
123
+ "Central Reserve Police Force · Ministry of Home Affairs")
124
+ c.drawCentredString(W / 2, H - 14.1*cm,
125
+ "AI-Based Tender Evaluation and Eligibility Analysis")
126
+
127
+ _footer(c, 1)
128
+
129
+
130
+ def slide_2_problem(c: Canvas) -> None:
131
+ _header_bar(c, "The Problem", "Manual tender evaluation is slow, inconsistent, and opaque")
132
+ _footer(c, 2)
133
+
134
+ y = H - 4.0*cm
135
+ c.setFont("Helvetica-Bold", 11)
136
+ c.setFillColor(NAVY)
137
+ c.drawString(1.8*cm, y, "Government procurement officers today must:")
138
+ y -= 0.6*cm
139
+
140
+ problems = [
141
+ "Manually read hundreds of pages of tender documents and bidder submissions",
142
+ "Identify eligibility criteria buried in legal language across multiple sections",
143
+ "Cross-check financial statements, certificates, and project records for each bidder",
144
+ "Handle scanned documents, photographs, and mixed-format submissions",
145
+ "Reach consistent decisions — yet two evaluators routinely disagree on the same bid",
146
+ "Produce an auditable trail for every decision, under compliance and RTI pressure",
147
+ ]
148
+ for p in problems:
149
+ y = _bullet(c, 1.8*cm, y, p)
150
+ y -= 0.25*cm
151
+
152
+ y -= 0.3*cm
153
+ c.setFillColor(LBLUE)
154
+ c.roundRect(1.8*cm, y - 1.6*cm, W - 3.6*cm, 1.6*cm, 0.3*cm, fill=1, stroke=0)
155
+ c.setFillColor(BLUE)
156
+ c.setFont("Helvetica-Bold", 11)
157
+ c.drawCentredString(W / 2, y - 0.7*cm,
158
+ "For one tender, a committee may spend 3–5 days.")
159
+ c.setFont("Helvetica", 10)
160
+ c.setFillColor(GREY)
161
+ c.drawCentredString(W / 2, y - 1.15*cm,
162
+ "TenderIQ reduces this to minutes, with full explainability.")
163
+
164
+
165
+ def slide_3_solution(c: Canvas) -> None:
166
+ _header_bar(c, "Our Solution", "TenderIQ automates evaluation while preserving human oversight")
167
+ _footer(c, 3)
168
+
169
+ pillars = [
170
+ (BLUE, "📄 Extract", "DeepSeek LLM reads the tender PDF and structures every eligibility criterion as JSON — category, rule, source clause, query hints."),
171
+ (GREEN, "🔍 OCR & Index","Three-tier pipeline handles any document: PyMuPDF → Tesseract → Vision LLM. All text indexed into ChromaDB with provenance."),
172
+ (AMBER, "⚖️ Evaluate", "Per-criterion vector search + LLM evaluation. Combined confidence score. Safety rule: never silent disqualification."),
173
+ (RED, "👤 Review", "Borderline verdicts surface in a human review queue with full evidence. Every action logged to SQLite for compliance."),
174
+ ]
175
+ cw = (W - 3.6*cm) / 2
176
+ ch = 5.0*cm
177
+ positions = [
178
+ (1.8*cm, H - 9.5*cm),
179
+ (1.8*cm + cw + 0.4*cm, H - 9.5*cm),
180
+ (1.8*cm, H - 9.5*cm - ch - 0.5*cm),
181
+ (1.8*cm + cw + 0.4*cm, H - 9.5*cm - ch - 0.5*cm),
182
+ ]
183
+ for (px, py), (color, title, body) in zip(positions, pillars):
184
+ _card(c, px, py, cw, ch, title, body, color)
185
+
186
+
187
+ def slide_4_architecture(c: Canvas) -> None:
188
+ _header_bar(c, "Architecture", "Single-process Streamlit app — no separate services")
189
+ _footer(c, 4)
190
+
191
+ boxes = [
192
+ (1.8*cm, H - 5.5*cm, 5.0*cm, 1.2*cm, "Tender PDF", LBLUE, BLUE),
193
+ (8.5*cm, H - 5.5*cm, 5.5*cm, 1.2*cm, "Criteria (JSON)", LBLUE, BLUE),
194
+ (15.5*cm, H - 5.5*cm, 4.5*cm, 1.2*cm, "ChromaDB Index", LGREY, GREY),
195
+ (1.8*cm, H - 9.0*cm, 5.0*cm, 1.2*cm, "Bidder Docs", LGREY, GREY),
196
+ (8.5*cm, H - 9.0*cm, 5.5*cm, 1.2*cm, "OCR Pipeline ×3", colors.HexColor("#FDF4FF"), colors.HexColor("#7E22CE")),
197
+ (15.5*cm, H - 9.0*cm, 4.5*cm, 1.2*cm, "Verdicts", colors.HexColor("#F0FDF4"), GREEN),
198
+ (5.5*cm, H - 13.0*cm, 10*cm, 1.2*cm, "SQLite Audit Log", colors.HexColor("#FFFBEB"), AMBER),
199
+ ]
200
+ for bx, by, bw, bh, label, fill, stroke in boxes:
201
+ c.setFillColor(fill)
202
+ c.setStrokeColor(stroke)
203
+ c.roundRect(bx, by, bw, bh, 0.25*cm, fill=1, stroke=1)
204
+ c.setFillColor(stroke)
205
+ c.setFont("Helvetica-Bold", 9)
206
+ c.drawCentredString(bx + bw / 2, by + 0.4*cm, label)
207
+
208
+ arrows = [
209
+ (6.8*cm, H - 4.95*cm, 8.5*cm, H - 4.95*cm),
210
+ (8.5*cm + 5.5*cm, H - 4.95*cm, 15.5*cm, H - 4.95*cm),
211
+ (1.8*cm + 5.0*cm, H - 8.45*cm, 8.5*cm, H - 8.45*cm),
212
+ (8.5*cm + 5.5*cm, H - 8.45*cm, 15.5*cm, H - 8.45*cm),
213
+ (15.5*cm + 2.25*cm, H - 9.0*cm, 15.5*cm + 2.25*cm, H - 5.5*cm - 1.2*cm),
214
+ (W / 2, H - 9.0*cm - 0, W / 2, H - 13.0*cm + 1.2*cm),
215
+ ]
216
+ c.setStrokeColor(GREY)
217
+ c.setLineWidth(1)
218
+ for x1, y1, x2, y2 in arrows:
219
+ c.line(x1, y1, x2, y2)
220
+
221
+ c.setFont("Helvetica", 8.5)
222
+ c.setFillColor(GREY)
223
+ c.drawCentredString(W / 2, H - 14.5*cm,
224
+ "DeepSeek API · ChromaDB (embedded) · SQLite · No external services")
225
+
226
+
227
+ def slide_5_ocr(c: Canvas) -> None:
228
+ _header_bar(c, "Three-Tier OCR Pipeline",
229
+ "Handles typed PDFs, scanned documents, and photographs")
230
+ _footer(c, 5)
231
+
232
+ tiers = [
233
+ (BLUE, "Tier 1 — PyMuPDF",
234
+ "Cost: free, instant\nTrigger: document is a typed/digital PDF\nConfidence: 1.0 (lossless)\nOutput: exact text with page numbers"),
235
+ (colors.HexColor("#7E22CE"), "Tier 2 — Tesseract OCR",
236
+ "Cost: free, fast\nTrigger: scanned PDF or image file\nConfidence: mean of per-word scores\nOutput: extracted text (quality varies)"),
237
+ (AMBER, "Tier 3 — DeepSeek Vision LLM",
238
+ "Cost: API call, slower\nTrigger: Tesseract confidence < 65%\nConfidence: 0.95\nOutput: faithfully transcribed text\nAudit: vision_ocr_invoked logged"),
239
+ ]
240
+ tw = (W - 4.0*cm) / 3
241
+ for i, (color, title, body) in enumerate(tiers):
242
+ x = 1.8*cm + i * (tw + 0.3*cm)
243
+ y = H - 9.0*cm
244
+ c.setFillColor(color)
245
+ c.roundRect(x, y, tw, 5.5*cm, 0.3*cm, fill=1, stroke=0)
246
+ c.setFillColor(WHITE)
247
+ c.setFont("Helvetica-Bold", 10)
248
+ c.drawCentredString(x + tw / 2, y + 5.0*cm, title)
249
+ c.setFont("Helvetica", 9)
250
+ for j, line in enumerate(body.split("\n")):
251
+ c.drawString(x + 0.4*cm, y + 4.2*cm - j * 0.5*cm, line)
252
+
253
+ y = H - 11.0*cm
254
+ c.setFillColor(LGREY)
255
+ c.roundRect(1.8*cm, y - 1.8*cm, W - 3.6*cm, 1.8*cm, 0.3*cm, fill=1, stroke=0)
256
+ c.setFillColor(NAVY)
257
+ c.setFont("Helvetica-Bold", 10)
258
+ c.drawCentredString(W / 2, y - 0.65*cm, "Demo: Bidder C submits a blurry, rotated CA certificate scan")
259
+ c.setFont("Helvetica", 9)
260
+ c.setFillColor(GREY)
261
+ c.drawCentredString(W / 2, y - 1.2*cm,
262
+ "Tesseract confidence ~55% → Vision LLM transcribes correctly → combined confidence 0.58 → needs_review")
263
+
264
+
265
+ def slide_6_explainability(c: Canvas) -> None:
266
+ _header_bar(c, "Explainability & Compliance",
267
+ "Every verdict is traceable to a document, page, and model decision")
268
+ _footer(c, 6)
269
+
270
+ features = [
271
+ ("Criterion-level verdicts",
272
+ "Each (bidder × criterion) pair has an independent verdict with extracted value, source document, page number, OCR tier, LLM confidence, and plain-English reason."),
273
+ ("Never silent disqualification",
274
+ "The safety threshold rule: if combined confidence is 0.55–0.80 and the LLM says not_eligible, the verdict is downgraded to needs_review and surfaced for human review."),
275
+ ("Full audit trail",
276
+ "Every action is logged to SQLite: criteria_extracted, bidder_processed, criterion_evaluated, human_review_action, vision_ocr_invoked, precomputed_fallback_used."),
277
+ ("Interpretability tab",
278
+ "Plain-English explanation of each verdict with inline PDF page previews. LLM-powered Q&A lets officers ask specific questions with source citations."),
279
+ ("Human review queue",
280
+ "Flagged verdicts show the evidence snippet, extracted value, source page, and OCR tier badge. Officers Approve / Edit & Approve / Reject with audit logging."),
281
+ ("Pre-computed fallback",
282
+ "If the API is unavailable, pre-computed JSON is served transparently. The sidebar shows an amber dot and a banner. No silent failures."),
283
+ ]
284
+ col_w = (W - 4.0*cm) / 2
285
+ for i, (title, body) in enumerate(features):
286
+ col = i % 2
287
+ row = i // 2
288
+ x = 1.8*cm + col * (col_w + 0.4*cm)
289
+ y = H - 4.5*cm - row * 2.8*cm
290
+ c.setFillColor(WHITE)
291
+ c.setStrokeColor(BORD)
292
+ c.roundRect(x, y - 2.0*cm, col_w, 2.0*cm, 0.25*cm, fill=1, stroke=1)
293
+ c.setFillColor(BLUE)
294
+ c.setFont("Helvetica-Bold", 9)
295
+ c.drawString(x + 0.3*cm, y - 0.45*cm, title)
296
+ c.setFillColor(GREY)
297
+ c.setFont("Helvetica", 8)
298
+ lines = _wrap(body, int(col_w / (0.22*cm)))
299
+ for j, line in enumerate(lines[:3]):
300
+ c.drawString(x + 0.3*cm, y - 0.9*cm - j * 0.38*cm, line)
301
+
302
+
303
+ def slide_7_demo(c: Canvas) -> None:
304
+ _header_bar(c, "Demo: Three Test Scenarios",
305
+ "Mock CRPF tender with 5 criteria evaluated against 3 realistic bidders")
306
+ _footer(c, 7)
307
+
308
+ scenarios = [
309
+ (GREEN, "✅ Bidder A — Eligible",
310
+ "Apex Constructions Pvt. Ltd.",
311
+ [
312
+ "C1 Turnover: INR 6.37 Cr avg — exceeds 5 Cr threshold",
313
+ "C2 Projects: 5 completed including CRPF barracks (2024)",
314
+ "C3 GST: GSTIN 27AABCA1234F1Z5, Active",
315
+ "C4 ISO 9001:2015: Valid through June 2027",
316
+ "C5 Paramilitary: CRPF Camp Pune project on record",
317
+ ]),
318
+ (RED, "❌ Bidder B — Not Eligible",
319
+ "BuildRight Enterprises",
320
+ [
321
+ "C1 Turnover: INR 1.5 Cr avg — BELOW 5 Cr threshold",
322
+ "C2 Projects: 4 completed — passes",
323
+ "C3 GST: GSTIN 29AABCB5678G1Z3, Active",
324
+ "C4 ISO 9001:2015: Valid through August 2027",
325
+ "C5 Paramilitary: No relevant experience",
326
+ ]),
327
+ (AMBER, "⚠️ Bidder C — Needs Review",
328
+ "Shree Constructions & Services",
329
+ [
330
+ "C1 Turnover: Scanned cert → Tesseract 55% → Vision LLM",
331
+ " INR 5.4 Cr found, but borderline — human review required",
332
+ "C2 Projects: Exactly 3 — borderline meets threshold",
333
+ "C3 GST: GSTIN 24AABCC9012H1Z1, Active",
334
+ "C4 ISO 9001:2015: Valid through September 2027",
335
+ ]),
336
+ ]
337
+ cw = (W - 4.0*cm) / 3
338
+ for i, (color, title, company, bullets) in enumerate(scenarios):
339
+ x = 1.8*cm + i * (cw + 0.3*cm)
340
+ y_top = H - 3.8*cm
341
+
342
+ c.setFillColor(color)
343
+ c.roundRect(x, y_top - 0.9*cm, cw, 0.9*cm, 0.25*cm, fill=1, stroke=0)
344
+ c.setFillColor(WHITE)
345
+ c.setFont("Helvetica-Bold", 9)
346
+ c.drawCentredString(x + cw / 2, y_top - 0.55*cm, title)
347
+
348
+ c.setFillColor(WHITE)
349
+ c.setStrokeColor(BORD)
350
+ c.roundRect(x, y_top - 8.5*cm, cw, 7.6*cm, 0.25*cm, fill=1, stroke=1)
351
+ c.rect(x, y_top - 0.9*cm, cw, 0.2*cm, fill=1, stroke=0)
352
+
353
+ c.setFillColor(GREY)
354
+ c.setFont("Helvetica-Oblique", 8)
355
+ c.drawString(x + 0.3*cm, y_top - 1.35*cm, company)
356
+
357
+ c.setFont("Helvetica", 8)
358
+ c.setFillColor(NAVY)
359
+ for j, b in enumerate(bullets):
360
+ c.drawString(x + 0.3*cm, y_top - 2.0*cm - j * 0.5*cm, b)
361
+
362
+
363
+ def slide_8_stack(c: Canvas) -> None:
364
+ _header_bar(c, "Technology Stack & Impact",
365
+ "Built for the hackathon — deployable to Streamlit Cloud or HuggingFace Spaces in minutes")
366
+ _footer(c, 8)
367
+
368
+ stack = [
369
+ ("UI & Orchestration", "Streamlit 1.39", "Single-process app, tabs, session state"),
370
+ ("LLM", "DeepSeek API", "chat_json + chat_vision (OpenAI-compatible)"),
371
+ ("OCR Tier 1", "PyMuPDF 1.24", "Lossless text extraction from digital PDFs"),
372
+ ("OCR Tier 2", "Tesseract", "Open-source OCR for scanned documents"),
373
+ ("OCR Tier 3", "DeepSeek Vision","Multimodal LLM for low-confidence scans"),
374
+ ("Vector Store", "ChromaDB 0.5", "Embedded, file-backed, all-MiniLM-L6-v2"),
375
+ ("Schemas", "Pydantic v2", "Strict validation of all LLM outputs"),
376
+ ("Audit Log", "SQLite", "Append-only, exportable as CSV"),
377
+ ]
378
+ c.setFillColor(NAVY)
379
+ c.setFont("Helvetica-Bold", 9)
380
+ col_x = [1.8*cm, 6.5*cm, 11.5*cm]
381
+ for x, lbl in zip(col_x, ["Component", "Technology", "Role"]):
382
+ c.drawString(x, H - 4.2*cm, lbl)
383
+ c.setStrokeColor(BORD)
384
+ c.line(1.8*cm, H - 4.4*cm, W - 1.8*cm, H - 4.4*cm)
385
+
386
+ for i, (comp, tech, role) in enumerate(stack):
387
+ y = H - 4.9*cm - i * 0.6*cm
388
+ if i % 2 == 0:
389
+ c.setFillColor(LGREY)
390
+ c.rect(1.8*cm, y - 0.1*cm, W - 3.6*cm, 0.55*cm, fill=1, stroke=0)
391
+ c.setFillColor(NAVY); c.setFont("Helvetica-Bold", 8.5); c.drawString(1.8*cm + 0.2*cm, y + 0.2*cm, comp)
392
+ c.setFillColor(BLUE); c.setFont("Helvetica", 8.5); c.drawString(6.5*cm + 0.2*cm, y + 0.2*cm, tech)
393
+ c.setFillColor(GREY); c.setFont("Helvetica", 8.5); c.drawString(11.5*cm + 0.2*cm, y + 0.2*cm, role)
394
+
395
+ y_impact = H - 10.5*cm
396
+ c.setFillColor(NAVY)
397
+ c.roundRect(1.8*cm, y_impact - 2.8*cm, W - 3.6*cm, 2.8*cm, 0.3*cm, fill=1, stroke=0)
398
+ c.setFillColor(GOLD)
399
+ c.setFont("Helvetica-Bold", 11)
400
+ c.drawCentredString(W / 2, y_impact - 0.7*cm, "Business Impact")
401
+ impacts = [
402
+ "⏱ Days of manual evaluation → minutes of automated processing",
403
+ "📋 Criterion-level audit trail satisfies RTI and compliance requirements",
404
+ "🔍 Every verdict traceable to a document, page, OCR tier, and model version",
405
+ ]
406
+ c.setFont("Helvetica", 9.5)
407
+ c.setFillColor(colors.HexColor("#CBD5E1"))
408
+ for j, imp in enumerate(impacts):
409
+ c.drawString(2.5*cm, y_impact - 1.35*cm - j * 0.5*cm, imp)
410
+
411
+
412
+ def main() -> None:
413
+ out = BASE_DIR / "deck" / "TenderIQ_Pitch.pdf"
414
+ out.parent.mkdir(parents=True, exist_ok=True)
415
+
416
+ c = Canvas(str(out), pagesize=A4)
417
+ slides = [
418
+ slide_1_title,
419
+ slide_2_problem,
420
+ slide_3_solution,
421
+ slide_4_architecture,
422
+ slide_5_ocr,
423
+ slide_6_explainability,
424
+ slide_7_demo,
425
+ slide_8_stack,
426
+ ]
427
+ for fn in slides:
428
+ fn(c)
429
+ c.showPage()
430
+ c.save()
431
+ print(f"Deck saved: {out} ({len(slides)} slides)")
432
+
433
+
434
+ if __name__ == "__main__":
435
+ main()
ui/components.py CHANGED
@@ -2,32 +2,64 @@ import streamlit as st
2
 
3
 
4
  def verdict_pill(verdict: str) -> str:
5
- if verdict == "eligible":
6
- return ":green[✅ Eligible]"
7
- elif verdict == "not_eligible":
8
- return ":red[❌ Not Eligible]"
9
- else:
10
- return ":orange[⚠ Needs Review]"
 
11
 
12
 
13
- def confidence_bar(value: float, label: str = "Confidence") -> None:
14
- st.progress(min(max(value, 0.0), 1.0), text=f"{label}: {value:.0%}")
 
 
 
 
 
 
15
 
16
 
17
  def ocr_tier_badge(source_type: str) -> str:
18
- icons = {
19
- "text_pdf": "📄 text_pdf",
20
- "tesseract": "🔍 tesseract",
21
- "vision_llm": "👁 vision_llm",
22
  }
23
- return icons.get(source_type, f" {source_type}")
 
24
 
25
 
26
- def category_badge(category: str) -> str:
27
- if category == "financial":
28
- return ":blue[financial]"
29
- elif category == "technical":
30
- return ":green[technical]"
31
- elif category == "compliance":
32
- return ":orange[compliance]"
33
- return category
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ )
ui/styles.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ """
ui/tab_bidders.py CHANGED
@@ -4,12 +4,20 @@ 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 category_badge, confidence_bar, ocr_tier_badge, verdict_pill
 
 
 
8
 
9
  _BIDDER_LABELS = {
10
- "bidder_a": "Bidder A — Apex Constructions Pvt. Ltd. (Clearly Eligible)",
11
- "bidder_b": "Bidder B — BuildRight Enterprises (Ineligible: Low Turnover)",
12
- "bidder_c": "Bidder C — Shree Constructions & Services (Scanned Cert: Needs Review)",
 
 
 
 
 
13
  }
14
 
15
 
@@ -21,11 +29,11 @@ def _get_criteria() -> list[Criterion]:
21
 
22
 
23
  def _overall_verdict(verdicts: list[dict], crit_map: dict) -> str:
24
- """Only mandatory criteria determine overall eligibility."""
25
- mandatory = [v for v in verdicts if crit_map.get(v["criterion_id"], None) and
26
  crit_map[v["criterion_id"]].mandatory]
27
  if not mandatory:
28
- mandatory = verdicts # fallback if crit_map is missing
29
  if any(v["verdict"] == "not_eligible" for v in mandatory):
30
  return "not_eligible"
31
  if any(v["verdict"] == "needs_review" for v in mandatory):
@@ -35,18 +43,19 @@ def _overall_verdict(verdicts: list[dict], crit_map: dict) -> str:
35
 
36
  def render() -> None:
37
  st.header("Bidder Evaluation")
 
38
 
39
  selected = st.multiselect(
40
  "Select bidders to evaluate",
41
- options=["bidder_a", "bidder_b", "bidder_c"],
42
- default=["bidder_a", "bidder_b", "bidder_c"],
43
- format_func=lambda x: _BIDDER_LABELS.get(x, x),
44
  )
45
 
46
- if st.button("Run Evaluation", type="primary"):
47
  criteria = _get_criteria()
48
  verdicts_dict: dict = {}
49
- progress = st.progress(0, text="Starting evaluation…")
50
  total = len(selected) * len(criteria)
51
  done = 0
52
  for bidder_id in selected:
@@ -54,7 +63,7 @@ def render() -> None:
54
  f for f in (DATA_DIR / "bidders" / bidder_id).iterdir()
55
  if f.suffix.lower() in {".pdf", ".png", ".jpg"}
56
  )
57
- with st.spinner(f"Processing {BIDDER_NAMES.get(bidder_id, bidder_id)} documents…"):
58
  bidder_processor.process_bidder(bidder_id, files)
59
  verdicts_list = []
60
  for c in criteria:
@@ -62,17 +71,21 @@ def render() -> None:
62
  verdicts_list.append(v.model_dump())
63
  done += 1
64
  progress.progress(done / total,
65
- text=f"Evaluated {c.id} for {BIDDER_NAMES.get(bidder_id, bidder_id)}")
66
  verdicts_dict[bidder_id] = verdicts_list
67
  st.session_state["verdicts"] = verdicts_dict
68
  progress.empty()
69
- st.success("Evaluation complete.")
70
  st.rerun()
71
 
72
  verdicts_data = st.session_state.get("verdicts", {})
73
  criteria = _get_criteria()
74
  crit_map = {c.id: c for c in criteria}
75
 
 
 
 
 
76
  if st.session_state.get("fallback_active"):
77
  st.warning("⚠ Live API unavailable — showing pre-computed results.")
78
 
@@ -81,58 +94,103 @@ def render() -> None:
81
  continue
82
  verdicts = verdicts_data[bidder_id]
83
  overall = _overall_verdict(verdicts, crit_map)
84
- overall_pill = verdict_pill(overall)
85
- friendly = BIDDER_NAMES.get(bidder_id, bidder_id)
86
- mandatory_count = sum(1 for v in verdicts
87
- if crit_map.get(v["criterion_id"]) and
88
- crit_map[v["criterion_id"]].mandatory)
89
  passed = sum(1 for v in verdicts
90
  if v["verdict"] == "eligible" and
91
  crit_map.get(v["criterion_id"]) and
92
  crit_map[v["criterion_id"]].mandatory)
 
 
 
93
 
94
  with st.container(border=True):
 
95
  st.markdown(
96
- f"#### {friendly} — Overall: {overall_pill}"
97
- f" <span style='font-size:0.85em; color:grey;'>"
98
- f"({passed}/{mandatory_count} mandatory criteria met)</span>",
 
 
 
 
 
 
 
 
 
 
 
 
99
  unsafe_allow_html=True,
100
  )
101
 
102
  # Column headers
103
- hcols = st.columns([3, 2, 2, 2, 1])
104
- hcols[0].caption("Criterion")
105
- hcols[1].caption("Verdict")
106
- hcols[2].caption("Extracted Value")
107
- hcols[3].caption("Source / OCR Tier")
108
- hcols[4].caption("Category")
109
- st.divider()
 
 
 
110
 
111
  for v in verdicts:
112
  crit = crit_map.get(v["criterion_id"])
113
  crit_title = crit.title if crit else v["criterion_id"]
114
- mandatory_tag = "🔴" if (crit and crit.mandatory) else "🟡"
115
  cat = category_badge(crit.category if crit else "compliance")
116
 
117
- cols = st.columns([3, 2, 2, 2, 1])
118
- cols[0].markdown(f"{mandatory_tag} **{v['criterion_id']}** {crit_title}")
119
- cols[1].markdown(verdict_pill(v["verdict"]))
120
- cols[2].markdown(f"{v.get('extracted_value') or '—'}")
 
 
 
 
 
 
 
 
 
 
 
121
  if v.get("source"):
122
  src = v["source"]
123
  tier = ocr_tier_badge(src["source_type"])
124
- cols[3].markdown(f"`{src['doc_name']}` p{src['page']} {tier}")
 
 
 
 
 
 
125
  else:
126
- cols[3].markdown("")
127
- cols[4].markdown(cat)
128
 
129
- conf = v.get("combined_confidence", 0.0)
130
- confidence_bar(conf)
131
 
132
  if v.get("reason") or (v.get("source") and v["source"].get("snippet")):
133
- with st.expander("Details", expanded=False):
134
  if v.get("reason"):
135
- st.markdown(f"**Reason:** {v['reason']}")
 
 
 
 
 
 
136
  if v.get("source") and v["source"].get("snippet"):
137
- st.markdown(f"**Source snippet:** _{v['source']['snippet']}_")
138
- st.divider()
 
 
 
 
 
 
 
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
 
 
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):
 
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:
 
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:
 
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
 
 
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)
ui/tab_interpretability.py CHANGED
@@ -262,13 +262,13 @@ def render() -> None:
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_container_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_container_width=True)
272
 
273
  st.divider()
274
 
 
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
 
ui/tab_overview.py CHANGED
@@ -6,14 +6,20 @@ from core.fallback import load_criteria
6
 
7
 
8
  def render() -> None:
9
- st.header("⚖️ TenderIQ — Explainable AI for Tender Evaluation")
10
  st.markdown(
11
- "Automated eligibility evaluation of bidders against government tender criteria, "
12
- "with criterion-level explainability, OCR for scanned documents, and a complete audit trail."
 
 
 
 
 
 
 
13
  )
14
- st.divider()
15
 
16
- # KPI cards
17
  criteria_count = len(st.session_state.get("criteria", load_criteria()))
18
  verdicts = st.session_state.get("verdicts", {})
19
  bidders_evaluated = len(verdicts)
@@ -24,120 +30,87 @@ def render() -> None:
24
  audit_entries = len(audit.query())
25
 
26
  c1, c2, c3, c4 = st.columns(4)
27
- c1.metric("Criteria Extracted", criteria_count)
28
- c2.metric("Bidders Evaluated", bidders_evaluated)
29
- c3.metric("Criteria Checked", mandatory_checked)
30
- c4.metric("Audit Entries", audit_entries)
 
 
 
 
 
 
 
 
 
31
 
32
  st.divider()
33
 
34
- # Architecture diagram
35
- st.subheader("System Architecture")
36
- st.markdown("""
37
- ```
38
- ┌─────────────────────────────────────────────────────────────────────┐
39
- │ TenderIQ Pipeline │
40
- └─────────────────────────────────────────────────────────────────────┘
41
-
42
- 📄 Tender PDF 📁 Bidder Documents
43
- │ (PDFs, scans, photos)
44
- │ │
45
- ▼ ▼
46
- ┌───────────┐ ┌────────────────────────┐
47
- │ DeepSeek │ │ 3-Tier OCR Pipeline │
48
- │ LLM │ │ ① PyMuPDF (typed) │
49
- │ (Stage 1) │ │ ② Tesseract (scans) │
50
- └───────────┘ │ ③ Vision LLM (poor) │
51
- │ └────────────────────────┘
52
- │ │
53
- ▼ ▼
54
- ┌───────────┐ ┌────────────────────────┐
55
- │ Criteria │ │ ChromaDB Vector │
56
- │ C1 – C5 │ │ Index (per bidder) │
57
- │ (JSON) │ └────────────────────────┘
58
- └───────────┘ │
59
- │ │ semantic search
60
- └──────────────────┬───────────────────┘
61
-
62
-
63
- ┌─────────────────────┐
64
- │ DeepSeek LLM │
65
- │ (Stage 3 eval) │
66
- │ │
67
- │ evidence → verdict │
68
- │ + confidence score │
69
- └─────────────────────┘
70
-
71
- ┌─────────────┴──────────────┐
72
- │ │
73
- ▼ ▼
74
- confidence ≥ 0.80 confidence < 0.80
75
- verdict kept downgraded to
76
- needs_review
77
-
78
-
79
- ┌─────────────────┐
80
- │ Human Review │
81
- │ Queue (Tab 4) │
82
- └─────────────────┘
83
-
84
-
85
- ┌─────────────────┐
86
- │ Audit Log │
87
- │ (every action) │
88
- └─────────────────┘
89
- ```
90
- """)
91
-
92
- st.divider()
93
 
94
- st.subheader("Pipeline Stages")
95
  col_a, col_b = st.columns(2)
96
  with col_a:
97
  st.markdown("""
98
- **① Extract Criteria**
99
- DeepSeek reads the full tender PDF and extracts each eligibility criterion as structured JSON —
100
- category, mandatory flag, rule (threshold / certification / count), source clause, and query hints
101
- for downstream retrieval.
102
-
103
- **② OCR & Index Bidder Documents**
104
- Three-tier pipeline handles any document format:
105
- PyMuPDF for typed PDFs (instant, lossless) →
106
- Tesseract for scans (free, fast) →
107
- DeepSeek Vision LLM when Tesseract confidence < 65%.
108
- All text is chunked and indexed into ChromaDB with full provenance metadata.
109
- """)
 
 
 
 
 
 
110
  with col_b:
111
  st.markdown("""
112
- **③ Evaluate per Criterion**
113
- For each (bidder × criterion) pair: semantic search retrieves the most relevant evidence chunks,
114
- DeepSeek decides eligible / not_eligible / needs_review with a combined confidence score
115
- that weights LLM certainty against OCR quality.
116
- The safety rule: never silently disqualify — borderline cases always go to human review.
117
-
118
- **④ Human Review & Audit**
119
- Flagged verdicts surface in the Review Queue with full evidence and source citations.
120
- Every action — extraction, indexing, evaluation, review — is logged to SQLite with
121
- timestamp, model version, actor, and payload.
122
- """)
 
 
 
123
 
124
  st.divider()
125
 
126
- st.subheader("Quick Start")
 
 
127
  col1, col2 = st.columns(2)
128
  with col1:
129
- if st.button("Load Pre-computed Demo", type="primary", use_container_width=True):
130
- from core.fallback import load_criteria as lc, load_evaluation
131
- criteria = lc()
132
- st.session_state["criteria"] = [c.model_dump() for c in criteria]
133
- verdicts_dict: dict = {}
134
- for bidder_id in BIDDER_NAMES:
135
- verdicts_dict[bidder_id] = [
136
- load_evaluation(bidder_id, c.id).model_dump()
137
- for c in criteria
138
- ]
139
- st.session_state["verdicts"] = verdicts_dict
140
- st.success("Pre-computed demo loaded. Navigate to the other tabs.")
141
- st.rerun()
 
 
142
  with col2:
143
- st.info("Or go to **Tender Analysis** to run the live LLM pipeline.")
 
 
 
 
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)
 
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):
98
+ st.markdown("**🚀 Pre-computed Demo**")
99
+ st.caption("Instantly load realistic results for all 3 bidders — no API key needed.")
100
+ if st.button("Load Pre-computed Demo", type="primary", use_container_width=True):
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.")
ui/tab_review.py CHANGED
@@ -1,6 +1,7 @@
1
  import streamlit as st
2
 
3
  from core import audit
 
4
  from core.fallback import load_criteria
5
  from core.schemas import Criterion
6
  from ui.components import confidence_bar, verdict_pill
@@ -46,15 +47,39 @@ def render() -> None:
46
 
47
  with st.container(border=True):
48
  col1, col2 = st.columns([3, 1])
 
49
  with col1:
50
- st.markdown(f"**{bidder_id}** — {v['criterion_id']}: {crit_title}")
51
- st.markdown(f"Verdict: {verdict_pill(v['verdict'])}")
 
 
 
 
 
 
52
  if v.get("extracted_value"):
53
- st.markdown(f"Extracted value: `{v['extracted_value']}`")
 
 
 
 
 
54
  if v.get("reason"):
55
- st.markdown(f"Reason: _{v['reason']}_")
 
 
 
 
 
 
56
  if v.get("source") and v["source"].get("snippet"):
57
- st.markdown(f"Source snippet: _{v['source']['snippet']}_")
 
 
 
 
 
 
58
  with col2:
59
  conf = v.get("combined_confidence", 0.0)
60
  confidence_bar(conf, "Certainty in assessment")
 
1
  import streamlit as st
2
 
3
  from core import audit
4
+ from core.config import BIDDER_NAMES
5
  from core.fallback import load_criteria
6
  from core.schemas import Criterion
7
  from ui.components import confidence_bar, verdict_pill
 
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")