File size: 12,780 Bytes
b14fc84
 
1049372
b14fc84
 
 
 
1049372
b14fc84
aff4140
 
 
 
b14fc84
 
1049372
aff4140
 
b14fc84
aff4140
b14fc84
 
 
 
 
1049372
b14fc84
 
1049372
aff4140
1049372
aff4140
b14fc84
 
 
1049372
b14fc84
aff4140
b14fc84
aff4140
 
1049372
aff4140
b14fc84
 
1049372
 
 
b14fc84
1049372
 
 
 
 
 
 
 
 
 
b14fc84
1049372
 
 
 
aff4140
b14fc84
 
 
 
1049372
 
 
 
 
 
 
b14fc84
1049372
b14fc84
 
1049372
b14fc84
 
1049372
 
b14fc84
 
1049372
 
 
 
 
 
 
 
aff4140
b14fc84
 
 
1049372
aff4140
 
1049372
 
b14fc84
 
1049372
 
aff4140
b14fc84
 
 
 
 
1049372
 
 
b14fc84
1049372
b14fc84
 
1049372
 
 
aff4140
 
 
b14fc84
 
aff4140
 
b14fc84
aff4140
1049372
aff4140
b14fc84
aff4140
1049372
 
aff4140
1049372
aff4140
1049372
 
 
aff4140
 
 
1049372
 
 
b14fc84
1049372
aff4140
 
1049372
 
b14fc84
 
aff4140
1049372
aff4140
1049372
aff4140
b14fc84
 
1049372
 
 
 
aff4140
 
1049372
aff4140
1049372
 
 
 
 
 
aff4140
 
1049372
 
 
 
 
aff4140
 
1049372
 
 
 
aff4140
1049372
 
 
 
 
aff4140
 
1049372
aff4140
 
 
 
1049372
 
 
 
b14fc84
1049372
b14fc84
1049372
 
01f6914
b14fc84
 
 
aff4140
1049372
b14fc84
 
 
1049372
aff4140
 
 
 
1049372
b14fc84
 
 
aff4140
 
 
 
1049372
b14fc84
1049372
 
 
b14fc84
 
 
1049372
b14fc84
1049372
 
aff4140
 
1049372
aff4140
1049372
aff4140
 
1049372
 
 
b14fc84
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
import streamlit as st

from core.config import BIDDER_NAMES, DATA_DIR
from core.fallback import load_criteria
from core.llm_client import LLM, LLMUnavailable
from core.pdf_utils import render_page_to_image
from core.schemas import Criterion
from ui.components import confidence_bar, verdict_pill

_VERDICT_CFG = {
    "eligible":     ("rgba(34,197,94,0.12)",  "#22C55E", "✅ PASSED"),
    "not_eligible": ("rgba(239,68,68,0.12)",   "#EF4444", "❌ FAILED"),
    "needs_review": ("rgba(245,158,11,0.12)",  "#F59E0B", "⚠️ NEEDS REVIEW"),
}

_RULE_PLAIN = {
    "numeric_threshold":    lambda r: f"must be {r['operator']} {r['value']:,} {r.get('unit') or ''}".strip(),
    "count_threshold":      lambda r: f"must have completed at least {int(r['value'])}",
    "certification_present": lambda _: "valid certificate must be present",
    "document_present":     lambda _: "supporting document must be present",
}


def _get_criteria() -> list[Criterion]:
    data = st.session_state.get("criteria")
    return [Criterion(**c) for c in data] if data else load_criteria()


def _explain(v: dict, crit: Criterion | None) -> str:
    verdict   = v.get("verdict", "")
    extracted = v.get("extracted_value", "") or ""
    reason    = v.get("reason", "") or ""
    if not crit:
        return reason
    rule = crit.rule
    rule_desc = _RULE_PLAIN.get(rule.type, lambda _: "")(rule.model_dump())
    if verdict == "eligible":
        return (f"Found **{extracted}**. " if extracted else "") + reason
    elif verdict == "not_eligible":
        return ((f"Found **{extracted}** — does not meet requirement ({rule_desc}). "
                 if extracted else f"Requirement: {rule_desc}. ") + reason)
    else:
        return (f"Extracted value: **{extracted}**. " if extracted else "") + reason


def _qa_context(bid: str, verdicts: list[dict], criteria: list[Criterion]) -> str:
    cm = {c.id: c for c in criteria}
    lines = [f"BIDDER: {BIDDER_NAMES.get(bid, bid)}", ""]
    for v in verdicts:
        c = cm.get(v["criterion_id"])
        rule = _RULE_PLAIN.get(c.rule.type, lambda _: "")(c.rule.model_dump()) if c else ""
        lines += [
            f"{v['criterion_id']}{c.title if c else '?'} "
            f"[{'Mandatory' if c and c.mandatory else 'Optional'}]: {v['verdict'].upper()}",
            f"  Requirement: {rule}",
            f"  Extracted: {v.get('extracted_value') or 'not found'}",
            f"  Confidence: {v.get('combined_confidence', 0):.0%}",
            f"  Reason: {v.get('reason', '')}",
        ]
        if v.get("source"):
            s = v["source"]
            lines.append(f"  Evidence: {s.get('doc_name')} page {s.get('page')} "
                         f"[{s.get('source_type')}]")
            if s.get("snippet"):
                lines.append(f'  Snippet: "{s["snippet"][:200]}"')
        lines.append("")
    return "\n".join(lines)


def _answer(question: str, context: str) -> str:
    system = (
        "You are a procurement compliance assistant. Answer questions about an AI-generated "
        "tender evaluation in plain professional English. Always cite specific document names "
        "and page numbers. Be concise (2-4 sentences). Never invent information not in the context. "
        'Return JSON: {"answer": "<your answer>"}'
    )
    try:
        result = LLM().chat_json(system, f"{context}\n\nQUESTION: {question}")
        return result.get("answer", "")
    except LLMUnavailable:
        return _rule_answer(question, context)


def _rule_answer(q: str, context: str) -> str:
    q = q.lower()
    lines = context.splitlines()
    if any(w in q for w in ["reject", "fail", "not eligible", "disqualif"]):
        fails = [l.strip() for l in lines if "NOT_ELIGIBLE" in l or "NEEDS_REVIEW" in l]
        return ("Failing criteria: " + "; ".join(fails[:3]) + ".") if fails else "No failing criteria found."
    if any(w in q for w in ["pass", "eligible", "meet"]):
        passes = [l.strip() for l in lines if "ELIGIBLE" in l and "NOT_ELIGIBLE" not in l]
        return ("Passing criteria: " + "; ".join(passes[:3]) + ".") if passes else "No passing criteria."
    if any(w in q for w in ["turnover", "financial", "c1", "revenue"]):
        rel = [l.strip() for l in lines if "C1" in l or "turnover" in l.lower() or "Extracted" in l]
        return " ".join(rel[:4]) if rel else "Turnover information not found."
    return "Live LLM is unavailable. The evaluation summary above contains the full details."


def render() -> None:
    st.markdown(
        '<h2 style="font-weight:800;font-size:1.5rem;color:var(--text-color);">Interpretability</h2>'
        '<p style="color:var(--text-color);opacity:0.6;font-size:0.875rem;margin-bottom:1rem;">'
        'Plain-English explanations with source citations. Ask any question about the evaluation.</p>',
        unsafe_allow_html=True,
    )

    vdata = st.session_state.get("verdicts", {})
    if not vdata:
        st.info("No results yet. Load the pre-computed demo from Overview, or run evaluation.")
        return

    criteria = _get_criteria()
    crit_map = {c.id: c for c in criteria}

    bid = st.selectbox("Select bidder", options=list(vdata.keys()),
                       format_func=lambda x: BIDDER_NAMES.get(x, x))
    verdicts = vdata.get(bid, [])
    if not verdicts:
        st.warning("No verdicts for this bidder.")
        return

    company = BIDDER_NAMES.get(bid, bid)
    mand = [v for v in verdicts if crit_map.get(v["criterion_id"]) and
            crit_map[v["criterion_id"]].mandatory]
    failed = [v for v in mand if v["verdict"] == "not_eligible"]
    review = [v for v in mand if v["verdict"] == "needs_review"]
    passed = [v for v in mand if v["verdict"] == "eligible"]

    if failed:
        ov, fg, icon = "not_eligible", "#EF4444", "❌"
        summary = f"Failed {len(failed)} mandatory criterion/criteria. Must meet all to qualify."
    elif review:
        ov, fg, icon = "needs_review", "#F59E0B", "⚠️"
        summary = (f"Passed {len(passed)} mandatory criteria, but {len(review)} "
                   f"require officer sign-off.")
    else:
        ov, fg, icon = "eligible", "#22C55E", "✅"
        summary = f"All {len(passed)} mandatory criteria satisfied."

    bg, _, label = _VERDICT_CFG.get(ov, ("rgba(128,128,128,0.1)", "#888", ov))
    st.markdown(
        f'<div style="background:{bg};border:1px solid {fg}33;border-radius:12px;'
        f'padding:18px 20px;margin-bottom:1.5rem;display:flex;align-items:center;gap:14px;">'
        f'<div style="font-size:2rem;line-height:1;">{icon}</div>'
        f'<div>'
        f'<div style="font-weight:800;font-size:1.05rem;color:{fg};">'
        f'{company}{label}</div>'
        f'<div style="font-size:0.84rem;color:{fg};opacity:0.85;margin-top:4px;">{summary}</div>'
        f'</div></div>',
        unsafe_allow_html=True,
    )

    st.markdown(
        '<div style="font-size:1rem;font-weight:700;color:var(--text-color);margin-bottom:12px;">'
        'Criterion-by-Criterion Breakdown</div>',
        unsafe_allow_html=True,
    )

    for v in verdicts:
        crit    = crit_map.get(v["criterion_id"])
        verdict = v.get("verdict", "needs_review")
        cbg, cfg_, clabel = _VERDICT_CFG.get(verdict, ("rgba(128,128,128,0.1)", "var(--text-color)", verdict))
        mand_txt = "Mandatory" if (crit and crit.mandatory) else "Optional"
        title    = crit.title if crit else v["criterion_id"]

        with st.container(border=True):
            left, right = st.columns([1, 3])
            with left:
                st.markdown(
                    f'<div style="background:{cbg};border-radius:8px;padding:14px;'
                    f'text-align:center;min-height:80px;display:flex;flex-direction:column;'
                    f'align-items:center;justify-content:center;gap:6px;">'
                    f'<div style="font-weight:800;font-size:0.82rem;color:{cfg_};">{clabel}</div>'
                    f'<div style="font-size:0.7rem;color:{cfg_};opacity:0.75;">{mand_txt}</div>'
                    f'</div>',
                    unsafe_allow_html=True,
                )
                confidence_bar(v.get("combined_confidence", 0.0), "Certainty")
            with right:
                st.markdown(
                    f'<div style="font-weight:700;font-size:0.9rem;color:var(--text-color);">'
                    f'{v["criterion_id"]}: {title}</div>',
                    unsafe_allow_html=True,
                )
                explanation = _explain(v, crit)
                if explanation:
                    st.markdown(
                        f'<p style="font-size:0.875rem;color:var(--text-color);'
                        f'opacity:0.85;margin:8px 0;">{explanation}</p>',
                        unsafe_allow_html=True,
                    )
                src = v.get("source") or {}
                if src:
                    doc, page = src.get("doc_name", ""), src.get("page", "")
                    tier_labels = {"text_pdf": "typed PDF", "tesseract": "Tesseract OCR",
                                   "vision_llm": "Vision LLM"}
                    tier = tier_labels.get(src.get("source_type", ""), "")
                    st.markdown(
                        f'<div style="display:inline-flex;align-items:center;gap:6px;'
                        f'background:rgba(128,128,128,0.08);border:1px solid rgba(128,128,128,0.2);'
                        f'border-radius:6px;padding:5px 10px;font-size:0.78rem;">'
                        f'<span>📄</span>'
                        f'<strong style="color:#3B82F6;">{doc}</strong>'
                        f'<span style="color:var(--text-color);opacity:0.5;">page {page}</span>'
                        f'<span style="color:var(--text-color);opacity:0.3;">·</span>'
                        f'<span style="color:var(--text-color);opacity:0.6;">{tier}</span>'
                        f'</div>',
                        unsafe_allow_html=True,
                    )
                    doc_path = DATA_DIR / "bidders" / bid / doc
                    if doc_path.exists() and doc_path.suffix.lower() == ".pdf":
                        with st.expander(f"View source: {doc}, page {page}", expanded=False):
                            try:
                                img = render_page_to_image(doc_path, int(page))
                                st.image(img, caption=f"{doc} — Page {page}",
                                         use_column_width=True)
                            except Exception:
                                st.caption("Page preview unavailable.")
                    elif doc_path.exists() and doc_path.suffix.lower() in {".png", ".jpg"}:
                        with st.expander(f"View: {doc}", expanded=False):
                            st.image(str(doc_path), use_column_width=True)

    st.divider()

    st.markdown(
        '<div style="font-size:1rem;font-weight:700;color:var(--text-color);margin-bottom:4px;">'
        'Ask About This Evaluation</div>'
        '<p style="font-size:0.82rem;color:var(--text-color);opacity:0.6;margin-bottom:12px;">'
        'Answers cite specific documents and pages.</p>',
        unsafe_allow_html=True,
    )

    with st.expander("Example questions", expanded=False):
        for e in ["Why was this bidder rejected?",
                  "What turnover figure was found, and from which document?",
                  "Does this bidder have a valid ISO 9001:2015 certificate?",
                  "Why is the turnover verdict in review?"]:
            st.markdown(f"- _{e}_")

    question = st.text_input("", placeholder="Ask anything about this bidder's evaluation…",
                             key=f"qa_{bid}", label_visibility="collapsed")
    if st.button("Get Answer", type="primary", key=f"qa_btn_{bid}"):
        if not question.strip():
            st.warning("Please enter a question.")
        else:
            context = _qa_context(bid, verdicts, criteria)
            with st.spinner("Looking up the answer…"):
                answer = _answer(question, context)
            st.markdown(
                f'<div style="background:rgba(37,99,235,0.08);'
                f'border:1px solid rgba(37,99,235,0.2);border-radius:10px;'
                f'padding:16px 18px;margin-top:8px;">'
                f'<div style="font-size:0.72rem;font-weight:700;color:#3B82F6;'
                f'text-transform:uppercase;letter-spacing:0.07em;margin-bottom:8px;">Answer</div>'
                f'<div style="font-size:0.9rem;color:var(--text-color);line-height:1.7;">'
                f'{answer}</div></div>',
                unsafe_allow_html=True,
            )
            with st.expander("Full context used", expanded=False):
                st.code(context, language="text")