proofprint / app.py
HARSH SHUKLA
Fix Space import layout
8c64b80
Raw
History Blame Contribute Delete
25.1 kB
from __future__ import annotations
import html
import json
import uuid
from pathlib import Path
import streamlit as st
ROOT = Path(__file__).resolve().parent
from core.analyzer import build_packet
from core.llm import LocalGemmaClient
from core.models import EvidenceItem
from core.storage import load_evidence, save_evidence
SAMPLE_CASE = ROOT / "data" / "sample_unpaid_wages_case.json"
RIGHTS_GUIDE = ROOT / "data" / "worker_rights_guide.md"
st.set_page_config(
page_title="ProofPrint",
page_icon="PP",
layout="wide",
initial_sidebar_state="expanded",
)
def apply_theme() -> None:
st.markdown(
"""
<style>
:root {
--ink: #121722;
--muted: #515c6b;
--soft: #7b8492;
--line: #d7dde7;
--paper: #f6f4ee;
--panel: #ffffff;
--panel-soft: #fbfaf6;
--accent: #006b5f;
--accent-dark: #00483f;
--accent-soft: #e7f4f1;
--blue: #263f73;
--warn: #9a5a12;
--risk: #9b2f3b;
--ok: #17633e;
}
.stApp { background: var(--paper); color: var(--ink); }
.block-container { padding-top: 2.6rem; padding-bottom: 3rem; max-width: 1240px; }
header[data-testid="stHeader"] {
background: #0c1117;
border-bottom: 1px solid #1d2732;
}
h1, h2, h3, h4, h5, h6, p, label, span { letter-spacing: 0; }
h1, h2, h3 { color: var(--ink); }
h2, h3 { margin-top: 0.5rem; }
section[data-testid="stSidebar"] {
background: #ffffff;
border-right: 1px solid var(--line);
}
section[data-testid="stSidebar"] * { color: var(--ink); }
section[data-testid="stSidebar"] .stCaption, section[data-testid="stSidebar"] p { color: var(--muted) !important; }
div[data-testid="stTabs"] button { font-weight: 650; }
.pp-shell {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 8px;
padding: 1.45rem 1.5rem;
}
.pp-topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border: 1px solid var(--line);
background: #ffffff;
border-radius: 8px;
padding: 0.8rem 0.95rem;
margin: 0 0 1rem 0;
}
.pp-brand {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 0;
}
.pp-logo {
width: 2.1rem;
height: 2.1rem;
border-radius: 7px;
display: grid;
place-items: center;
background: var(--ink);
color: #ffffff;
font-weight: 800;
letter-spacing: 0;
}
.pp-brand-title { font-weight: 760; line-height: 1.05; }
.pp-brand-subtitle { color: var(--muted); font-size: 0.82rem; line-height: 1.15; }
.pp-top-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; justify-content: flex-end; }
.pp-hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.78fr);
gap: 1rem;
align-items: stretch;
margin-bottom: 1.15rem;
}
.pp-kicker {
color: var(--accent);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0;
}
.pp-title {
font-size: 2.18rem;
line-height: 1.08;
font-weight: 760;
margin: 0.2rem 0 0.65rem 0;
max-width: 850px;
}
.pp-subtitle {
color: var(--muted);
max-width: 820px;
font-size: 0.98rem;
line-height: 1.5;
margin-bottom: 0.85rem;
}
.pp-card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
min-height: 100%;
}
.pp-card-soft {
background: var(--panel-soft);
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
}
.pp-panel-dark {
background: linear-gradient(135deg, #102720 0%, #17202d 100%);
color: #f6fbf9;
border: 1px solid #203a35;
border-radius: 8px;
padding: 1.25rem;
min-height: 100%;
}
.pp-demo-title {
margin: 0.15rem 0 0.65rem 0;
color: #fff;
font-size: 1.45rem;
line-height: 1.18;
}
.pp-panel-dark strong { color: #ffffff; }
.pp-panel-dark .pp-muted { color: #b7c2bf; }
.pp-pill {
display: inline-block;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.2rem 0.58rem;
margin: 0.1rem 0.18rem 0.1rem 0;
color: var(--accent-dark);
font-size: 0.82rem;
background: var(--accent-soft);
border-color: #b9dad3;
}
.pp-pill-dark {
display: inline-block;
border: 1px solid #344554;
border-radius: 999px;
padding: 0.2rem 0.58rem;
margin: 0.12rem 0.18rem 0.12rem 0;
color: #d7e4df;
font-size: 0.82rem;
background: #17232c;
}
.pp-step {
display: flex;
gap: 0.75rem;
align-items: flex-start;
padding: 0.75rem 0;
border-bottom: 1px solid var(--line);
}
.pp-step:last-child { border-bottom: 0; }
.pp-num {
flex: 0 0 auto;
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
display: grid;
place-items: center;
color: #fff;
background: var(--accent);
font-weight: 760;
font-size: 0.86rem;
}
.pp-step-title { font-weight: 720; color: var(--ink); }
.pp-step-text { color: var(--muted); font-size: 0.93rem; line-height: 1.38; }
.pp-evidence-row {
border: 1px solid var(--line);
border-radius: 8px;
background: #fff;
padding: 0.85rem 0.95rem;
margin-bottom: 0.55rem;
}
.pp-evidence-head {
display: flex;
justify-content: space-between;
gap: 0.75rem;
align-items: baseline;
}
.pp-evidence-title { font-weight: 720; }
.pp-muted { color: var(--muted); }
.pp-small { color: var(--soft); font-size: 0.86rem; }
.pp-status {
border: 1px solid #c5d9d4;
color: var(--ok);
background: #f2faf6;
border-radius: 999px;
padding: 0.22rem 0.65rem;
font-size: 0.82rem;
display: inline-block;
font-weight: 650;
}
.pp-counter {
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 0.85rem 0.95rem;
min-height: 5.5rem;
}
.pp-counter-label {
color: var(--muted);
font-size: 0.82rem;
font-weight: 700;
text-transform: uppercase;
margin-bottom: 0.35rem;
}
.pp-counter-value {
color: var(--ink);
font-size: 1.35rem;
font-weight: 780;
line-height: 1.15;
}
.pp-upload-box {
background: #ffffff;
border: 1px solid var(--line);
border-radius: 8px;
padding: 1rem;
margin-top: 0.8rem;
}
.pp-risk {
border-left: 4px solid var(--risk);
padding: 0.5rem 0.75rem;
background: #fff6f5;
margin-bottom: 0.4rem;
}
.pp-missing {
border-left: 4px solid var(--warn);
padding: 0.5rem 0.75rem;
background: #fff8ef;
margin-bottom: 0.4rem;
}
div[data-testid="stDownloadButton"] button,
div[data-testid="stButton"] button {
border-radius: 7px;
font-weight: 650;
min-height: 2.55rem;
color: #ffffff !important;
}
div[data-testid="stDownloadButton"] button *,
div[data-testid="stButton"] button * {
color: #ffffff !important;
}
button,
button p,
button span,
[data-testid="stFormSubmitButton"] button,
[data-testid="stFormSubmitButton"] button p,
[data-testid="stFormSubmitButton"] button span,
[data-testid="stFileUploaderDropzone"] button,
[data-testid="stFileUploaderDropzone"] button p,
[data-testid="stFileUploaderDropzone"] button span {
color: #ffffff !important;
}
input, textarea, select {
background: #ffffff !important;
color: var(--ink) !important;
border-color: var(--line) !important;
}
textarea, input, select {
border-radius: 7px !important;
}
textarea::placeholder, input::placeholder {
color: #7b8492 !important;
opacity: 1 !important;
}
.stTextInput label, .stTextArea label, .stSelectbox label, .stFileUploader label {
color: var(--ink) !important;
font-weight: 650 !important;
}
@media (max-width: 900px) {
.pp-hero { grid-template-columns: 1fr; }
.pp-title { font-size: 1.85rem; }
.pp-topbar { align-items: flex-start; flex-direction: column; }
.pp-top-actions { justify-content: flex-start; }
}
</style>
""",
unsafe_allow_html=True,
)
def read_text_file(uploaded_file) -> str:
data = uploaded_file.getvalue()
try:
return data.decode("utf-8")
except UnicodeDecodeError:
try:
return data.decode("latin-1")
except UnicodeDecodeError:
return ""
def load_rights_guide() -> str:
return RIGHTS_GUIDE.read_text(encoding="utf-8") if RIGHTS_GUIDE.exists() else ""
def load_sample_items() -> list[EvidenceItem]:
payload = json.loads(SAMPLE_CASE.read_text(encoding="utf-8"))
return [
EvidenceItem(
id=item["id"],
title=item["title"],
source_type=item["source_type"],
raw_text=item["raw_text"],
filename=item.get("filename", ""),
created_on=item.get("created_on", "2026-05-11"),
)
for item in payload["evidence"]
]
def init_state() -> None:
if "evidence" not in st.session_state:
st.session_state.evidence = load_evidence()
if "packet" not in st.session_state:
st.session_state.packet = None
if "worker_name" not in st.session_state:
st.session_state.worker_name = "Ravi Kumar"
def add_evidence(title: str, source_type: str, raw_text: str, filename: str = "") -> None:
if not raw_text.strip():
return
st.session_state.evidence.append(
EvidenceItem(
id=f"E{len(st.session_state.evidence) + 1:03d}",
title=title.strip() or filename or "Untitled evidence",
source_type=source_type,
raw_text=raw_text.strip(),
filename=filename,
)
)
save_evidence(st.session_state.evidence)
def packet_to_markdown(packet) -> str:
timeline = "\n".join(
f"- {event.date}: {event.event} Evidence: {', '.join(event.evidence_ids)} Amount: {event.amount or 'N/A'}"
for event in packet.timeline
)
evidence_rows = "\n".join(
f"| {row['id']} | {row['title']} | {row['type']} | {row['key_dates']} | {row['amounts']} | {row['sensitivity']} |"
for row in packet.evidence_table
)
return f"""# ProofPrint Case Packet
Worker: {packet.worker_name}
Case type: {packet.case_type}
Model status: {packet.model_status}
## Summary
{packet.summary}
## Timeline
{timeline or "No timeline events extracted."}
## Evidence Table
| ID | Title | Type | Key dates | Amounts | Sensitivity |
|---|---|---|---|---|---|
{evidence_rows}
## Missing Evidence
{chr(10).join(f"- {item}" for item in packet.missing_evidence)}
## Risk Flags
{chr(10).join(f"- {item}" for item in packet.risk_flags)}
## Privacy Notes
{chr(10).join(f"- {item}" for item in packet.privacy_notes)}
## Advocate Packet
{packet.advocate_packet}
## Complaint Draft
{packet.complaint_draft}
"""
def render_header() -> None:
st.markdown(
"""
<div class="pp-topbar">
<div class="pp-brand">
<div class="pp-logo">PP</div>
<div>
<div class="pp-brand-title">ProofPrint</div>
<div class="pp-brand-subtitle">Private evidence packets for worker advocates</div>
</div>
</div>
<div class="pp-top-actions">
<span class="pp-pill">Digital Equity</span>
<span class="pp-pill">Safety & Trust</span>
<span class="pp-pill">Ollama Track</span>
</div>
</div>
<div class="pp-hero">
<div class="pp-shell">
<div class="pp-kicker">Local-first justice access</div>
<div class="pp-title">ProofPrint turns scattered worker evidence into an advocate-ready packet.</div>
<div class="pp-subtitle">
A private Gemma 4 workflow for wage theft, unsafe work, retaliation, and denied benefits.
It converts chats, payment notes, shift logs, and voice transcripts into a timeline,
risk review, and human advocate handoff.
</div>
<span class="pp-pill">Gemma 4 local inference</span>
<span class="pp-pill">Ollama on device</span>
<span class="pp-pill">Evidence-grounded output</span>
<span class="pp-pill">No fallback generation</span>
</div>
<div class="pp-panel-dark">
<div class="pp-kicker" style="color:#84d7c4;">Demo case</div>
<div class="pp-demo-title">Ravi worked five shifts. Only Rs 1,500 arrived.</div>
<div class="pp-muted">
Proof is split across hiring messages, shift notes, a UPI transcript, follow-up messages,
and a voice note. ProofPrint turns it into one reviewable packet.
</div>
<div style="margin-top:0.8rem;">
<span class="pp-pill-dark">unpaid wages</span>
<span class="pp-pill-dark">retaliation risk</span>
<span class="pp-pill-dark">privacy redaction</span>
</div>
</div>
</div>
""",
unsafe_allow_html=True,
)
def render_sidebar() -> None:
with st.sidebar:
st.markdown("## ProofPrint")
st.caption("Local Gemma evidence kit")
st.divider()
st.subheader("Case setup")
st.session_state.worker_name = st.text_input("Worker name", st.session_state.worker_name)
model = st.text_input("Local Gemma model", "gemma4:e2b")
st.caption("Use a local Ollama Gemma model name. Packet generation is disabled unless Gemma is reachable.")
if st.button("Load sample case", use_container_width=True):
st.session_state.evidence = load_sample_items()
st.session_state.packet = None
save_evidence(st.session_state.evidence)
st.rerun()
if st.button("Clear evidence", use_container_width=True):
st.session_state.evidence = []
st.session_state.packet = None
save_evidence([])
st.rerun()
st.divider()
st.subheader("Engine")
st.markdown(
"""
<span class="pp-pill">Gemma extraction</span>
<span class="pp-pill">timeline reasoning</span>
<span class="pp-pill">risk checks</span>
<span class="pp-pill">packet drafting</span>
""",
unsafe_allow_html=True,
)
st.divider()
st.caption("For public demos, use synthetic or anonymized evidence only.")
return model
def render_intake() -> None:
st.markdown("### Evidence Intake")
st.caption("Add the fragments a worker actually has: chats, payment notes, shift logs, letters, or transcripts.")
left, right = st.columns([1.08, 0.92], gap="large")
with left:
st.markdown('<div class="pp-card-soft"><strong>Paste evidence manually</strong><br/><span class="pp-muted">Use this for chats, OCR text, voice transcripts, or notes copied from an image.</span></div>', unsafe_allow_html=True)
with st.form("manual_evidence", clear_on_submit=True):
title = st.text_input("Evidence title", placeholder="UPI payment screenshot text, shift log, employer chat...")
source_type = st.selectbox("Source type", ["chat", "payment", "shift log", "receipt", "letter", "photo note", "other"])
raw_text = st.text_area("Paste text or transcript", height=210, placeholder="Paste OCR text, chat transcript, voice note transcript, or notes from an image.")
submitted = st.form_submit_button("Add evidence", use_container_width=True)
if submitted:
add_evidence(title, source_type, raw_text)
st.rerun()
with right:
st.markdown(
"""
<div class="pp-card-soft">
<div class="pp-step">
<div class="pp-num">1</div>
<div><div class="pp-step-title">Collect proof</div><div class="pp-step-text">Bring in the messy material exactly as it exists.</div></div>
</div>
<div class="pp-step">
<div class="pp-num">2</div>
<div><div class="pp-step-title">Run Gemma locally</div><div class="pp-step-text">Extract facts without sending worker data to a hosted model.</div></div>
</div>
<div class="pp-step">
<div class="pp-num">3</div>
<div><div class="pp-step-title">Export packet</div><div class="pp-step-text">Give advocates a timeline, risk review, missing-proof list, and draft.</div></div>
</div>
</div>
""",
unsafe_allow_html=True,
)
st.markdown('<div class="pp-upload-box"><strong>Upload text evidence</strong><br/><span class="pp-muted">Import .txt, .md, or .csv evidence files.</span></div>', unsafe_allow_html=True)
uploaded_files = st.file_uploader(
"Choose evidence files",
accept_multiple_files=True,
type=["txt", "md", "csv"],
)
if uploaded_files:
st.caption(f"{len(uploaded_files)} file(s) ready to import.")
if st.button("Import uploaded files", use_container_width=True):
for uploaded_file in uploaded_files:
add_evidence(uploaded_file.name, uploaded_file.type or "uploaded file", read_text_file(uploaded_file), uploaded_file.name)
st.rerun()
def render_evidence_list() -> None:
st.markdown("### Evidence Board")
if not st.session_state.evidence:
st.markdown('<div class="pp-card-soft"><strong>No evidence yet.</strong><br/><span class="pp-muted">Load the sample case from the sidebar or add evidence manually.</span></div>', unsafe_allow_html=True)
return
for item in st.session_state.evidence:
preview = html.escape(item.raw_text[:180] + ("..." if len(item.raw_text) > 180 else ""))
title = html.escape(item.title)
source_type = html.escape(item.source_type)
st.markdown(
f"""
<div class="pp-evidence-row">
<div class="pp-evidence-head">
<div><span class="pp-small">{item.id} / {source_type}</span><br/><span class="pp-evidence-title">{title}</span></div>
<span class="pp-status">stored locally</span>
</div>
<div class="pp-muted" style="margin-top:0.45rem;">{preview}</div>
</div>
""",
unsafe_allow_html=True,
)
with st.expander(f"{item.id} - {item.title}", expanded=False):
st.write(f"Type: `{item.source_type}`")
st.text_area("Raw evidence", item.raw_text, height=130, key=f"raw_{item.id}", disabled=True)
def render_packet(packet) -> None:
st.markdown("### Generated Case Packet")
top = st.columns(4)
counters = [
("Case type", packet.case_type),
("Evidence items", str(len(packet.evidence_table))),
("Timeline events", str(len(packet.timeline))),
("Missing checks", str(len(packet.missing_evidence))),
]
for column, (label, value) in zip(top, counters):
column.markdown(
f"""
<div class="pp-counter">
<div class="pp-counter-label">{html.escape(label)}</div>
<div class="pp-counter-value">{html.escape(value)}</div>
</div>
""",
unsafe_allow_html=True,
)
st.markdown(f'<span class="pp-status">{packet.model_status}</span>', unsafe_allow_html=True)
st.markdown(f'<div class="pp-card-soft"><strong>Case summary</strong><br/><span class="pp-muted">{html.escape(packet.summary)}</span></div>', unsafe_allow_html=True)
timeline_tab, evidence_tab, risks_tab, packet_tab, draft_tab = st.tabs(
["Timeline", "Evidence", "Risk checks", "Advocate packet", "Complaint draft"]
)
with timeline_tab:
for event in packet.timeline:
event_text = html.escape(event.event)
event_date = html.escape(str(event.date))
event_amount = html.escape(event.amount or "N/A")
evidence_ids = html.escape(", ".join(event.evidence_ids))
st.markdown(
f"""
<div class="pp-card">
<span class="pp-small">{event_date}</span><br/>
<strong>{event_text}</strong><br/>
<span class="pp-pill">Evidence: {evidence_ids}</span>
<span class="pp-pill">Amount: {event_amount}</span>
</div>
""",
unsafe_allow_html=True,
)
with evidence_tab:
st.dataframe(packet.evidence_table, use_container_width=True, hide_index=True)
with risks_tab:
st.markdown("##### Missing evidence")
for item in packet.missing_evidence:
st.markdown(f'<div class="pp-missing">{item}</div>', unsafe_allow_html=True)
st.markdown("##### Risk and privacy flags")
for item in packet.risk_flags:
st.markdown(f'<div class="pp-risk">{item}</div>', unsafe_allow_html=True)
st.markdown("##### Privacy notes")
for item in packet.privacy_notes:
st.write(f"- {item}")
with packet_tab:
st.markdown(packet.advocate_packet)
with draft_tab:
st.text_area("Draft", packet.complaint_draft, height=340)
markdown = packet_to_markdown(packet)
st.download_button(
"Download advocate packet",
data=markdown,
file_name=f"proofprint_packet_{uuid.uuid4().hex[:8]}.md",
mime="text/markdown",
use_container_width=True,
)
def main() -> None:
apply_theme()
init_state()
model_name = render_sidebar()
render_header()
stats = st.columns(3)
stats[0].markdown('<div class="pp-card"><div class="pp-kicker">Problem</div><strong>Scattered proof blocks justice.</strong><br/><span class="pp-muted">Workers often have evidence, but not a usable case packet.</span></div>', unsafe_allow_html=True)
stats[1].markdown('<div class="pp-card"><div class="pp-kicker">Gemma 4</div><strong>Private local reasoning.</strong><br/><span class="pp-muted">Facts are extracted on device through Ollama and Gemma 4 E2B.</span></div>', unsafe_allow_html=True)
stats[2].markdown('<div class="pp-card"><div class="pp-kicker">Output</div><strong>Advocate-ready handoff.</strong><br/><span class="pp-muted">Timeline, evidence index, missing proof, risk flags, and draft.</span></div>', unsafe_allow_html=True)
st.divider()
render_intake()
render_evidence_list()
st.divider()
can_generate = bool(st.session_state.evidence)
st.markdown("### Packet Builder")
st.caption("This step calls the local Gemma model. On your laptop, the sample case can take a few minutes.")
if st.button("Build advocate packet with Gemma", type="primary", disabled=not can_generate, use_container_width=True):
llm = LocalGemmaClient(model=model_name)
try:
st.session_state.packet = build_packet(
worker_name=st.session_state.worker_name,
evidence_items=st.session_state.evidence,
llm=llm,
rights_guide=load_rights_guide(),
)
save_evidence(st.session_state.evidence)
st.rerun()
except RuntimeError as exc:
st.error(str(exc))
if st.session_state.packet:
render_packet(st.session_state.packet)
if __name__ == "__main__":
main()