File size: 16,569 Bytes
2f74c56
 
 
 
 
 
 
 
 
ada7257
 
 
 
 
 
 
 
 
2f74c56
 
 
 
4213665
2f74c56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1aa0999
 
2f74c56
 
 
 
 
 
 
1aa0999
 
 
 
2f74c56
 
 
 
1aa0999
2f74c56
 
 
1aa0999
2f74c56
1aa0999
 
 
 
 
 
 
 
 
2f74c56
 
 
1aa0999
 
 
 
 
 
 
 
 
2f74c56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4213665
 
 
2f74c56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
# Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""Streamlit investor-demo app for campus support triage.

Portions of this file were developed with assistance from OpenAI ChatGPT/Codex and reviewed/modified by the author.
"""

from __future__ import annotations

import html
import sys
from pathlib import Path

SRC_DIR = Path(__file__).resolve().parents[1]
ROOT_DIR = SRC_DIR.parent

for import_path in (SRC_DIR, ROOT_DIR):
    if str(import_path) not in sys.path:
        sys.path.insert(0, str(import_path))

import streamlit as st

from campus_triage.config import CATEGORY_LABELS, URGENCY_LABELS
from campus_triage.predict import EXAMPLE_MESSAGES, load_deployed_model, model_available, model_search_diagnostics, predict_message


CUSTOM_CSS = """
<style>
    :root {
        --ink: #111827;
        --muted: #64748b;
        --line: #d9e2ec;
        --paper: #ffffff;
        --surface: #f5f7fb;
        --teal: #0f766e;
        --navy: #172554;
        --amber: #b45309;
        --rose: #be123c;
    }
    .stApp {
        background:
            radial-gradient(circle at top left, rgba(15, 118, 110, .12), transparent 30rem),
            linear-gradient(180deg, #f8fafc 0%, #eef3f8 100%);
        color: var(--ink);
    }
    .block-container { padding-top: 1.6rem; max-width: 1240px; }
    h1, h2, h3, p { letter-spacing: 0; }
    div[data-testid="stToolbar"] { display: none; }
    .hero {
        padding: 1.65rem 1.85rem;
        border-radius: 8px;
        background: linear-gradient(135deg, #0f172a 0%, #155e75 55%, #0f766e 100%);
        color: white;
        margin-bottom: 1rem;
        box-shadow: 0 18px 45px rgba(15, 23, 42, .20);
    }
    .eyebrow {
        display: inline-flex;
        align-items: center;
        gap: .45rem;
        padding: .25rem .55rem;
        border: 1px solid rgba(255, 255, 255, .28);
        border-radius: 999px;
        color: #ccfbf1;
        font-size: .78rem;
        font-weight: 800;
        text-transform: uppercase;
        margin-bottom: .75rem;
    }
    .hero h1 { font-size: clamp(2rem, 4vw, 3.35rem); line-height: 1.02; margin: 0 0 .55rem 0; }
    .hero p { font-size: 1.05rem; max-width: 780px; margin: 0; color: #dbeafe; }
    .status-strip {
        display: grid;
        grid-template-columns: repeat(4, minmax(0, 1fr));
        gap: .7rem;
        margin: .85rem 0 1.1rem 0;
    }
    .status-item {
        background: rgba(255, 255, 255, .88);
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: .75rem .9rem;
        min-height: 78px;
    }
    .status-label { color: var(--muted); font-size: .75rem; text-transform: uppercase; font-weight: 800; }
    .status-value { color: var(--ink); font-size: 1rem; font-weight: 850; margin-top: .18rem; }
    .panel {
        background: var(--paper);
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: 1.15rem;
        box-shadow: 0 16px 34px rgba(15, 23, 42, .08);
    }
    .panel-title { margin: 0 0 .25rem 0; color: var(--ink); font-size: 1.1rem; font-weight: 850; }
    .panel-subtitle { color: var(--muted); font-size: .9rem; margin: 0 0 .9rem 0; }
    .result-card {
        background: #ffffff;
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: .95rem 1rem;
        min-height: 112px;
    }
    .result-label { color: var(--muted); font-size: .74rem; text-transform: uppercase; font-weight: 850; }
    .result-value { color: var(--ink); font-size: 1.48rem; line-height: 1.1; font-weight: 900; margin-top: .3rem; }
    .pill-row { display: flex; gap: .45rem; flex-wrap: wrap; margin-top: .65rem; }
    .pill {
        display: inline-flex;
        border-radius: 999px;
        padding: .22rem .55rem;
        font-size: .76rem;
        font-weight: 800;
        background: #e0f2fe;
        color: #075985;
    }
    .urgency-high { background: #fff1f2; color: var(--rose); border: 1px solid #fecdd3; }
    .urgency-medium { background: #fffbeb; color: var(--amber); border: 1px solid #fde68a; }
    .urgency-low { background: #ecfdf5; color: #047857; border: 1px solid #bbf7d0; }
    .recommendation {
        background: linear-gradient(90deg, #ecfdf5 0%, #eff6ff 100%);
        border: 1px solid #99f6e4;
        border-left: 6px solid var(--teal);
        border-radius: 8px;
        padding: 1rem 1.1rem;
        color: #064e3b;
        margin-top: 1rem;
    }
    .recommendation strong { color: #0f172a; }
    .explain {
        background: #f8fafc;
        border: 1px solid var(--line);
        border-radius: 8px;
        padding: .9rem 1rem;
        color: #334155;
        margin-top: .85rem;
    }
    .score-row {
        display: grid;
        grid-template-columns: minmax(120px, 190px) 1fr 46px;
        gap: .7rem;
        align-items: center;
        margin: .44rem 0;
    }
    .score-name { font-weight: 750; color: #334155; font-size: .88rem; }
    .score-track { height: .65rem; background: #e5e7eb; border-radius: 999px; overflow: hidden; }
    .score-fill { height: 100%; background: linear-gradient(90deg, #0f766e, #2563eb); border-radius: 999px; }
    .score-value { text-align: right; color: #475569; font-variant-numeric: tabular-nums; font-weight: 800; }
    .footer-note {
        color: #475569;
        font-size: .86rem;
        border-top: 1px solid var(--line);
        margin-top: 1rem;
        padding-top: .8rem;
    }
    textarea, .stTextArea textarea {
        color: #0f172a !important;
        -webkit-text-fill-color: #0f172a !important;
        background-color: #ffffff !important;
        border: 1px solid #94a3b8 !important;
        border-radius: 8px !important;
        caret-color: #0f766e !important;
        font-size: 1rem !important;
        line-height: 1.45 !important;
    }
    .stTextArea textarea::placeholder {
        color: #64748b !important;
        -webkit-text-fill-color: #64748b !important;
    }
    .stTextArea textarea:focus {
        border-color: #0f766e !important;
        box-shadow: 0 0 0 3px rgba(15, 118, 110, .16) !important;
    }
    div[data-testid="stSelectbox"] div { color: #0f172a; }
    .stButton > button {
        border-radius: 8px !important;
        font-weight: 850 !important;
        border: 1px solid #cbd5e1 !important;
        min-height: 2.75rem;
        background: #f8fafc !important;
        color: #0f172a !important;
        -webkit-text-fill-color: #0f172a !important;
    }
    .stButton > button:hover {
        background: #e0f2fe !important;
        border-color: #0284c7 !important;
        color: #075985 !important;
        -webkit-text-fill-color: #075985 !important;
    }
    .stButton > button[kind="primary"] {
        background: #0f766e !important;
        border-color: #0f766e !important;
        color: #ffffff !important;
        -webkit-text-fill-color: #ffffff !important;
    }
    .stButton > button[kind="primary"]:hover {
        background: #115e59 !important;
        border-color: #115e59 !important;
        color: #ffffff !important;
        -webkit-text-fill-color: #ffffff !important;
    }
    @media (max-width: 800px) {
        .status-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); }
        .hero { padding: 1.25rem; }
        .score-row { grid-template-columns: 1fr; gap: .25rem; }
        .score-value { text-align: left; }
    }
</style>
"""


def display_label(label: str) -> str:
    """Convert model labels into polished display text."""

    return label.replace("_", " ").title()


def confidence_band(score: float) -> str:
    """Convert confidence into a product-facing review band."""

    if score >= 0.75:
        return "Auto-route candidate"
    if score >= 0.55:
        return "Route with review"
    return "Manual review recommended"


def urgency_class(urgency: str) -> str:
    """Return the CSS class for an urgency pill."""

    return {
        "high": "urgency-high",
        "medium": "urgency-medium",
        "low": "urgency-low",
    }.get(urgency, "urgency-medium")


def render_status_strip() -> None:
    """Render investor-demo operating metrics."""

    st.markdown(
        """
        <div class="status-strip">
            <div class="status-item"><div class="status-label">Model Mode</div><div class="status-value">Inference Only</div></div>
            <div class="status-item"><div class="status-label">Routing Targets</div><div class="status-value">7 Queues</div></div>
            <div class="status-item"><div class="status-label">Urgency Tiers</div><div class="status-value">Low / Medium / High</div></div>
            <div class="status-item"><div class="status-label">Default Model</div><div class="status-value">TF-IDF Logistic Regression</div></div>
        </div>
        """,
        unsafe_allow_html=True,
    )


def render_score_table(scores: dict[str, float], labels: list[str]) -> None:
    """Render confidence scores as a compact leaderboard."""

    ordered_labels = sorted(labels, key=lambda label: scores.get(label, 0.0), reverse=True)
    for label in ordered_labels:
        score = max(0.0, min(scores.get(label, 0.0), 1.0))
        st.markdown(
            f"""
            <div class="score-row">
                <div class="score-name">{html.escape(display_label(label))}</div>
                <div class="score-track"><div class="score-fill" style="width: {score * 100:.1f}%;"></div></div>
                <div class="score-value">{score:.0%}</div>
            </div>
            """,
            unsafe_allow_html=True,
        )


def render_example_buttons() -> None:
    """Render example messages as quick-fill controls."""

    st.caption("Demo-ready examples")
    for index, example in enumerate(EXAMPLE_MESSAGES, start=1):
        label = f"Example {index}: {example[:54]}{'...' if len(example) > 54 else ''}"
        if st.button(label, key=f"example_{index}", use_container_width=True):
            st.session_state["message_text"] = example


def render_empty_decision_panel() -> None:
    """Render the pre-analysis decision panel."""

    st.markdown('<div class="panel"><p class="panel-title">Triage Decision</p><p class="panel-subtitle">Run an analysis to generate routing output, confidence, and next action.</p>', unsafe_allow_html=True)
    st.markdown(
        """
        <div class="result-card">
            <div class="result-label">Current Status</div>
            <div class="result-value">Ready</div>
            <div class="pill-row"><span class="pill">Model loaded</span><span class="pill">Synthetic POC</span></div>
        </div>
        <div class="footer-note">This proof of concept supports triage decisions. It does not replace human review, crisis response workflows, or official university policy.</div>
        """,
        unsafe_allow_html=True,
    )
    st.markdown("</div>", unsafe_allow_html=True)


def render_prediction(prediction: dict[str, object]) -> None:
    """Render prediction cards, recommendation, and confidence evidence."""

    category = str(prediction["category"])
    urgency = str(prediction["urgency"])
    category_confidence = float(prediction["category_confidence"])
    urgency_confidence = float(prediction["urgency_confidence"])
    band = confidence_band(category_confidence)

    st.markdown('<div class="panel"><p class="panel-title">Triage Decision</p><p class="panel-subtitle">Operational output for the support intake queue.</p>', unsafe_allow_html=True)
    result_columns = st.columns(3)
    with result_columns[0]:
        st.markdown(
            f"""
            <div class="result-card">
                <div class="result-label">Predicted Queue</div>
                <div class="result-value">{html.escape(display_label(category))}</div>
                <div class="pill-row"><span class="pill">{category_confidence:.0%} confidence</span></div>
            </div>
            """,
            unsafe_allow_html=True,
        )
    with result_columns[1]:
        st.markdown(
            f"""
            <div class="result-card">
                <div class="result-label">Urgency Tier</div>
                <div class="result-value">{html.escape(display_label(urgency))}</div>
                <div class="pill-row"><span class="pill {urgency_class(urgency)}">{urgency_confidence:.0%} confidence</span></div>
            </div>
            """,
            unsafe_allow_html=True,
        )
    with result_columns[2]:
        st.markdown(
            f"""
            <div class="result-card">
                <div class="result-label">Review Posture</div>
                <div class="result-value">{html.escape(band)}</div>
                <div class="pill-row"><span class="pill">Human-in-loop</span></div>
            </div>
            """,
            unsafe_allow_html=True,
        )

    st.markdown(
        f"""
        <div class="recommendation"><strong>Recommended next action:</strong> {html.escape(str(prediction["routing_recommendation"]))}</div>
        <div class="explain"><strong>Why this route:</strong> {html.escape(str(prediction["explanation"]))}</div>
        """,
        unsafe_allow_html=True,
    )
    st.markdown("</div>", unsafe_allow_html=True)

    score_columns = st.columns(2)
    with score_columns[0]:
        st.markdown('<div class="panel"><p class="panel-title">Category Confidence</p><p class="panel-subtitle">Ranked routing probabilities.</p>', unsafe_allow_html=True)
        render_score_table(prediction["category_scores"], CATEGORY_LABELS)  # type: ignore[arg-type]
        st.markdown("</div>", unsafe_allow_html=True)
    with score_columns[1]:
        st.markdown('<div class="panel"><p class="panel-title">Urgency Confidence</p><p class="panel-subtitle">Ranked urgency probabilities.</p>', unsafe_allow_html=True)
        render_score_table(prediction["urgency_scores"], URGENCY_LABELS)  # type: ignore[arg-type]
        st.markdown("</div>", unsafe_allow_html=True)


def run_app() -> None:
    """Run the Streamlit application."""

    st.set_page_config(page_title="Campus Triage Assistant", page_icon="CS", layout="wide")
    st.markdown(CUSTOM_CSS, unsafe_allow_html=True)
    st.markdown(
        """
        <section class="hero">
            <h1>Campus Support Message Triage Assistant</h1>
            <p>AI-assisted intake for university support teams: classify incoming student messages, estimate urgency, and produce routing guidance with confidence evidence.</p>
        </section>
        """,
        unsafe_allow_html=True,
    )
    render_status_strip()

    if not model_available():
        st.error("No trained model artifact was found for inference.")
        st.caption("The app checked these local and Hugging Face deployment paths:")
        st.code(model_search_diagnostics())
        st.stop()

    model = load_deployed_model()
    if "message_text" not in st.session_state:
        st.session_state["message_text"] = EXAMPLE_MESSAGES[0]

    intake_column, decision_column = st.columns([1.05, 1.15], gap="large")
    with intake_column:
        st.markdown('<div class="panel"><p class="panel-title">Message Intake</p><p class="panel-subtitle">Paste a support request or load a demo scenario.</p>', unsafe_allow_html=True)
        render_example_buttons()
        message_text = st.text_area(
            "Student message",
            key="message_text",
            height=210,
            label_visibility="collapsed",
        )
        submitted = st.button("Analyze and Route Message", type="primary", use_container_width=True)
        st.markdown(
            """
            <div class="footer-note">Proof-of-concept only. High-risk health, safety, and crisis messages require established human escalation procedures.</div>
            """,
            unsafe_allow_html=True,
        )
        st.markdown("</div>", unsafe_allow_html=True)

    with decision_column:
        if submitted and message_text.strip():
            prediction = predict_message(message_text, model=model)
            render_prediction(prediction)
        elif submitted:
            st.warning("Paste a student message before analyzing.")
            render_empty_decision_panel()
        else:
            render_empty_decision_panel()

    st.markdown(
        """
        <div class="footer-note">Model note: deployed inference uses a lightweight TF-IDF Logistic Regression model trained on synthetic course data. Use confidence thresholds and human review before any real operational deployment.</div>
        """,
        unsafe_allow_html=True,
    )


if __name__ == "__main__":
    run_app()