UI revamp: inline styles throughout, Inter font, reliable badge system
Browse filesRoot 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>
- app.py +56 -70
- ui/components.py +73 -33
- ui/styles.py +59 -194
- ui/tab_bidders.py +135 -135
- ui/tab_interpretability.py +190 -231
- ui/tab_overview.py +78 -79
- ui/tab_review.py +98 -92
|
@@ -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
|
| 49 |
-
del st.session_state[
|
| 50 |
|
| 51 |
|
| 52 |
-
# ── Sidebar ──────────────────────────────────────────────────────────────────
|
| 53 |
with st.sidebar:
|
|
|
|
| 54 |
st.markdown(
|
| 55 |
-
"""<div style="padding:
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
| 61 |
AI Tender Evaluation</div>
|
| 62 |
</div>""",
|
| 63 |
unsafe_allow_html=True,
|
| 64 |
)
|
| 65 |
st.divider()
|
| 66 |
|
|
|
|
| 67 |
status = _probe_llm()
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 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
|
| 99 |
-
del st.session_state[
|
| 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.
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
-
# ── Tabs ─────────────────────────────────────────────────────────────────────
|
| 120 |
tab1, tab2, tab3, tab4, tab5, tab6 = st.tabs([
|
| 121 |
-
"🏠
|
| 122 |
-
"📄
|
| 123 |
-
"⚖️
|
| 124 |
-
"👤
|
| 125 |
-
"📋
|
| 126 |
-
"🔍
|
| 127 |
])
|
| 128 |
|
| 129 |
-
with tab1:
|
| 130 |
-
|
| 131 |
-
with
|
| 132 |
-
|
| 133 |
-
with
|
| 134 |
-
|
| 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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@@ -1,65 +1,105 @@
|
|
| 1 |
import streamlit as st
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
def verdict_pill(verdict: str) -> str:
|
| 5 |
cfg = {
|
| 6 |
-
"eligible": ("
|
| 7 |
-
"not_eligible": ("
|
| 8 |
-
"needs_review": ("
|
| 9 |
}
|
| 10 |
-
|
| 11 |
-
return f'<span
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
def category_badge(category: str) -> str:
|
| 15 |
cfg = {
|
| 16 |
-
"financial": ("
|
| 17 |
-
"technical": ("
|
| 18 |
-
"compliance": ("
|
| 19 |
}
|
| 20 |
-
|
| 21 |
-
return f'<span
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
def ocr_tier_badge(source_type: str) -> str:
|
| 25 |
cfg = {
|
| 26 |
-
"text_pdf": ("
|
| 27 |
-
"tesseract": ("
|
| 28 |
-
"vision_llm": ("
|
| 29 |
}
|
| 30 |
-
|
| 31 |
-
return f'<span
|
|
|
|
| 32 |
|
| 33 |
|
| 34 |
def mandatory_badge(mandatory: bool) -> str:
|
| 35 |
if mandatory:
|
| 36 |
-
return '<span
|
| 37 |
-
|
|
|
|
|
|
|
| 38 |
|
| 39 |
|
| 40 |
def confidence_bar(value: float, label: str = "Confidence") -> None:
|
| 41 |
pct = min(max(value, 0.0), 1.0)
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
st.markdown(
|
| 44 |
-
f"""<div style="
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
transition:width 0.4s ease;"></div>
|
| 53 |
-
</div></div>""",
|
| 54 |
unsafe_allow_html=True,
|
| 55 |
)
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
|
|
|
|
|
|
| 59 |
st.markdown(
|
| 60 |
-
f
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
|
|
|
| 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 |
)
|
|
@@ -1,257 +1,122 @@
|
|
| 1 |
CSS = """
|
| 2 |
<style>
|
| 3 |
-
|
| 4 |
-
TenderIQ — Professional Theme
|
| 5 |
-
================================================================ */
|
| 6 |
|
| 7 |
-
/* ──
|
| 8 |
-
.
|
| 9 |
-
|
| 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 |
-
/* ──
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
[data-testid="stSidebar"] p,
|
| 34 |
-
[data-testid="stSidebar"] span,
|
| 35 |
-
[data-testid="stSidebar"]
|
| 36 |
-
[data-testid="stSidebar"]
|
| 37 |
-
|
| 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.
|
| 47 |
-
border: 1px solid rgba(255,255,255,0.
|
| 48 |
-
color: #
|
| 49 |
-
|
| 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.
|
| 56 |
-
|
| 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"]
|
| 66 |
|
| 67 |
-
/* ── Tabs ────────────────────────────────────────────────────
|
| 68 |
.stTabs [data-baseweb="tab-list"] {
|
| 69 |
-
background: #
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
gap: 2px
|
| 73 |
border: 1px solid #E2E8F0;
|
| 74 |
}
|
| 75 |
.stTabs [data-baseweb="tab"] {
|
| 76 |
-
border-radius:
|
| 77 |
-
padding:
|
| 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"]
|
| 93 |
.stTabs [data-baseweb="tab-border"] { display: none !important; }
|
| 94 |
|
| 95 |
-
/* ── Buttons ─────────────────────────────────────────────────
|
| 96 |
.stButton > button {
|
| 97 |
border-radius: 8px !important;
|
| 98 |
-
font-weight:
|
| 99 |
font-size: 0.875rem !important;
|
| 100 |
-
transition: all 0.
|
| 101 |
-
|
| 102 |
}
|
| 103 |
.stButton > button[kind="primary"] {
|
| 104 |
-
background: linear-gradient(135deg, #
|
| 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 |
-
/* ──
|
| 117 |
[data-testid="metric-container"] {
|
| 118 |
-
background: #
|
| 119 |
border: 1px solid #E2E8F0 !important;
|
| 120 |
border-radius: 12px !important;
|
| 121 |
-
padding:
|
| 122 |
-
box-shadow: 0 1px
|
| 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:
|
| 130 |
-
[data-testid="stMetricLabel"] { font-size: 0.
|
| 131 |
-
|
| 132 |
|
| 133 |
-
/* ── Bordered containers
|
| 134 |
[data-testid="stVerticalBlockBorderWrapper"] {
|
| 135 |
border-radius: 12px !important;
|
| 136 |
-
border:
|
| 137 |
-
box-shadow: 0
|
| 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:
|
| 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
|
| 159 |
|
| 160 |
-
/* ── Alerts ──────────────────────────────────────────────────
|
| 161 |
-
[data-testid="stAlert"] { border-radius: 10px !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
|
| 176 |
-
.stProgress > div > div > div { border-radius: 6px !important; }
|
| 177 |
.stProgress > div > div > div > div {
|
| 178 |
-
background: linear-gradient(90deg, #
|
| 179 |
border-radius: 6px !important;
|
| 180 |
}
|
| 181 |
|
| 182 |
-
/* ──
|
| 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 |
"""
|
|
@@ -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 |
-
|
| 9 |
-
|
| 10 |
-
)
|
| 11 |
-
|
| 12 |
-
|
| 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 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 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
|
| 40 |
return "needs_review"
|
| 41 |
return "eligible"
|
| 42 |
|
| 43 |
|
| 44 |
def render() -> None:
|
| 45 |
-
st.
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
selected = st.multiselect(
|
| 49 |
-
"Select bidders
|
| 50 |
options=list(BIDDER_NAMES.keys()),
|
| 51 |
default=list(BIDDER_NAMES.keys()),
|
| 52 |
-
format_func=lambda x:
|
| 53 |
)
|
| 54 |
|
| 55 |
if st.button("▶ Run Evaluation", type="primary"):
|
| 56 |
criteria = _get_criteria()
|
| 57 |
-
|
| 58 |
-
progress = st.progress(0, text="Starting…")
|
| 59 |
total = len(selected) * len(criteria)
|
| 60 |
done = 0
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
verdicts_list = []
|
| 69 |
for c in criteria:
|
| 70 |
-
v = evaluator.evaluate(
|
| 71 |
-
|
| 72 |
done += 1
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
st.success("Evaluation complete. Results saved.")
|
| 79 |
st.rerun()
|
| 80 |
|
| 81 |
-
|
| 82 |
criteria = _get_criteria()
|
| 83 |
crit_map = {c.id: c for c in criteria}
|
| 84 |
|
| 85 |
-
if not
|
| 86 |
-
st.info("No results yet
|
| 87 |
return
|
| 88 |
|
| 89 |
if st.session_state.get("fallback_active"):
|
| 90 |
st.warning("⚠ Live API unavailable — showing pre-computed results.")
|
| 91 |
|
| 92 |
-
for
|
| 93 |
-
if
|
| 94 |
continue
|
| 95 |
-
verdicts =
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 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 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
<div style="font-size:
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 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 |
-
|
| 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 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
f'<
|
| 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 |
-
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
| 181 |
st.markdown(
|
| 182 |
-
f'<div style="background:#
|
| 183 |
-
f'padding:10px 14px;border-radius:0
|
| 184 |
-
f'font-size:0.
|
| 185 |
-
f'<strong>Reason:</strong> {
|
| 186 |
unsafe_allow_html=True,
|
| 187 |
)
|
| 188 |
-
if
|
| 189 |
st.markdown(
|
| 190 |
f'<div style="background:#FFFBEB;border-left:3px solid #F59E0B;'
|
| 191 |
-
f'padding:10px 14px;border-radius:0
|
| 192 |
-
f'font-size:0.
|
| 193 |
-
f'
|
| 194 |
unsafe_allow_html=True,
|
| 195 |
)
|
| 196 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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'“{snippet}”</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 |
+
)
|
|
@@ -1,311 +1,270 @@
|
|
| 1 |
-
import json
|
| 2 |
-
|
| 3 |
import streamlit as st
|
| 4 |
|
| 5 |
-
from core.config import BIDDER_NAMES, DATA_DIR
|
| 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 |
-
|
| 12 |
-
"eligible": ("
|
| 13 |
-
"not_eligible": ("
|
| 14 |
-
"needs_review": ("
|
| 15 |
}
|
| 16 |
|
| 17 |
-
|
| 18 |
-
"numeric_threshold":
|
| 19 |
-
|
| 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":
|
| 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
|
| 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 |
-
|
| 49 |
-
|
| 50 |
-
return f"{icon} **{crit.title}** — {label}.{val_part} {reason}"
|
| 51 |
-
|
| 52 |
elif verdict == "not_eligible":
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 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
|
| 75 |
-
|
| 76 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
f"
|
| 88 |
-
f"{
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
|
|
|
| 92 |
if v.get("source"):
|
| 93 |
-
|
| 94 |
-
lines.append(
|
| 95 |
-
|
| 96 |
-
|
| 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
|
| 113 |
-
system =
|
| 114 |
-
|
| 115 |
-
in plain
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 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 |
-
|
| 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
|
| 134 |
|
| 135 |
|
| 136 |
-
def
|
| 137 |
-
q =
|
| 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 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 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.
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
|
|
|
|
|
|
| 170 |
)
|
| 171 |
|
| 172 |
-
|
| 173 |
-
if not
|
| 174 |
-
st.info("No
|
| 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 |
-
|
| 182 |
-
|
| 183 |
-
|
| 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
|
| 190 |
return
|
| 191 |
|
| 192 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
if failed:
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
f"A bidder must meet all mandatory criteria to qualify."
|
| 207 |
-
)
|
| 208 |
elif review:
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
f"automatically confirmed and require officer verification."
|
| 213 |
-
)
|
| 214 |
else:
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
|
| 222 |
-
#
|
| 223 |
-
st.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
|
| 225 |
for v in verdicts:
|
| 226 |
crit = crit_map.get(v["criterion_id"])
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
| 229 |
|
| 230 |
with st.container(border=True):
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
with
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
if doc_path.exists() and doc_path.suffix.lower() == ".pdf":
|
| 260 |
-
with st.expander(f"View source
|
| 261 |
-
expanded=False):
|
| 262 |
try:
|
| 263 |
-
img = render_page_to_image(doc_path,
|
| 264 |
-
st.image(img, caption=f"{
|
| 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
|
| 270 |
-
st.image(str(doc_path),
|
| 271 |
-
use_column_width=True)
|
| 272 |
|
| 273 |
st.divider()
|
| 274 |
|
| 275 |
-
#
|
| 276 |
-
st.
|
| 277 |
-
|
| 278 |
-
"Ask
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
)
|
| 281 |
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 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
|
| 291 |
-
st.markdown(f"- _{
|
| 292 |
|
| 293 |
-
question = st.text_input(
|
| 294 |
-
|
| 295 |
-
|
| 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 =
|
| 304 |
with st.spinner("Looking up the answer…"):
|
| 305 |
-
answer =
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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")
|
|
@@ -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
|
| 10 |
st.markdown(
|
| 11 |
-
"""<div
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
</div>""",
|
| 19 |
unsafe_allow_html=True,
|
| 20 |
)
|
| 21 |
|
| 22 |
-
#
|
| 23 |
criteria_count = len(st.session_state.get("criteria", load_criteria()))
|
| 24 |
-
verdicts
|
| 25 |
-
|
| 26 |
-
|
| 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 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 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 |
-
#
|
| 50 |
-
st.markdown(
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 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 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
""",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
st.divider()
|
| 91 |
|
| 92 |
# Quick start
|
| 93 |
-
st.markdown(
|
| 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 |
-
|
| 105 |
-
for
|
| 106 |
-
|
| 107 |
-
|
| 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("
|
| 116 |
-
st.info("
|
|
|
|
| 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.")
|
|
@@ -7,132 +7,138 @@ from core.schemas import Criterion
|
|
| 7 |
from ui.components import confidence_bar, verdict_pill
|
| 8 |
|
| 9 |
|
| 10 |
-
def
|
| 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.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
-
|
| 21 |
-
if not
|
| 22 |
st.info("No evaluation results yet. Run the evaluation in the Bidder Evaluation tab first.")
|
| 23 |
return
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
|
| 32 |
-
if not
|
| 33 |
-
st.success("
|
| 34 |
return
|
| 35 |
|
| 36 |
-
|
| 37 |
-
st.
|
| 38 |
-
"
|
| 39 |
-
|
| 40 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
)
|
| 42 |
-
st.divider()
|
| 43 |
|
| 44 |
-
for
|
| 45 |
-
crit =
|
| 46 |
crit_title = crit.title if crit else v["criterion_id"]
|
|
|
|
| 47 |
|
| 48 |
with st.container(border=True):
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
)
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
if v.get("extracted_value"):
|
| 61 |
st.markdown(
|
| 62 |
-
f'<div style="
|
| 63 |
f'<strong>Extracted value:</strong> '
|
| 64 |
-
f'<code
|
|
|
|
| 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:
|
| 71 |
-
f'
|
| 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:
|
| 79 |
-
f'
|
| 80 |
-
f'
|
| 81 |
unsafe_allow_html=True,
|
| 82 |
)
|
| 83 |
-
with
|
| 84 |
-
|
| 85 |
-
confidence_bar(conf, "Certainty in assessment")
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
with
|
| 91 |
-
if st.button("✅ Approve", key=f"{
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
original_extracted_value=v.get("extracted_value", ""),
|
| 101 |
-
combined_confidence=v.get("combined_confidence", 0.0),
|
| 102 |
-
)
|
| 103 |
st.rerun()
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
if st.button("✏ Edit & Approve", key=f"{
|
| 109 |
-
st.session_state["verdicts"][
|
| 110 |
-
if
|
| 111 |
-
st.session_state["verdicts"][
|
| 112 |
-
audit.log(
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 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 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 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'“{v["source"]["snippet"]}”</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()
|