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