HARSH SHUKLA commited on
Commit
5cabf52
·
0 Parent(s):

Initial ProofPrint hackathon prototype

Browse files
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .streamlit/
4
+ data/proofprint.sqlite
5
+ exports/*
6
+ !exports/.gitkeep
README.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ProofPrint
2
+
3
+ ProofPrint is a local-first evidence packet builder for informal workers and advocates. It turns scattered chats, payment notes, shift logs, voice transcripts, and receipts into a structured timeline, evidence index, missing-proof checklist, risk flags, and advocate-ready complaint draft.
4
+
5
+ The project is designed for the Gemma 4 hackathon theme: high-impact local intelligence where privacy, low connectivity, and real-world utility matter.
6
+
7
+ ## Why this matters
8
+
9
+ Informal workers often lose wage, safety, retaliation, or benefit claims because their proof is scattered across screenshots, receipts, notebooks, and voice notes. Legal aid teams and NGOs spend valuable time reconstructing the facts before they can even start helping.
10
+
11
+ ProofPrint gives workers and advocates a private first step: organize the evidence, find gaps, and prepare a clean handoff packet.
12
+
13
+ ## Gemma 4 role
14
+
15
+ ProofPrint is built around local Gemma-style inference through Ollama:
16
+
17
+ - extract facts from messy evidence
18
+ - classify case type
19
+ - identify dates, amounts, people, promises, harms, and sensitivity
20
+ - generate an advocate-ready packet
21
+ - support future multimodal analysis of screenshots and document photos
22
+
23
+ Packet generation requires Ollama plus a configured Gemma 4 model. The app does not silently generate packets through fallback extraction.
24
+
25
+ ## Local setup
26
+
27
+ ```powershell
28
+ cd "H:\pro\kaggale comp\proofprint"
29
+ python -m pip install -r requirements.txt
30
+ streamlit run app.py
31
+ ```
32
+
33
+ Then open the Streamlit URL shown in the terminal.
34
+
35
+ ## Optional Ollama setup
36
+
37
+ Install Ollama and pull the competition-approved Gemma 4 model variant when available:
38
+
39
+ ```powershell
40
+ ollama pull gemma4:e2b
41
+ ```
42
+
43
+ Then run the app and set the model name in the sidebar to `gemma4:e2b`.
44
+
45
+ ## Demo flow
46
+
47
+ 1. Click `Load sample wage-theft case`.
48
+ 2. Click `Build ProofPrint packet`.
49
+ 3. Show the extracted case type, timeline, evidence table, missing evidence checklist, risk flags, and complaint draft.
50
+ 4. Download the advocate packet as Markdown.
51
+
52
+ ## Free-tier deployment
53
+
54
+ Recommended free demo options:
55
+
56
+ - Streamlit Community Cloud for a public interactive app
57
+ - Hugging Face Spaces with Streamlit
58
+ - GitHub repository for source code
59
+ - YouTube for the 3-minute video
60
+
61
+ For public deployment, use synthetic/anonymized sample evidence only.
62
+
63
+ ## Hackathon tracks
64
+
65
+ Best fit:
66
+
67
+ - Digital Equity & Inclusivity
68
+ - Safety & Trust
69
+ - Main Track
70
+
71
+ Potential technology fit:
72
+
73
+ - Ollama
74
+ - llama.cpp, if packaged with a local quantized model runner
75
+
76
+ ## Safety boundary
77
+
78
+ ProofPrint does not provide legal advice, verify evidence authenticity, or replace a lawyer or trained advocate. It organizes evidence and prepares a structured handoff packet for human review.
app.py ADDED
@@ -0,0 +1,696 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import html
4
+ import json
5
+ import sys
6
+ import uuid
7
+ from pathlib import Path
8
+
9
+ import streamlit as st
10
+
11
+ ROOT = Path(__file__).resolve().parent
12
+ if str(ROOT.parent) not in sys.path:
13
+ sys.path.insert(0, str(ROOT.parent))
14
+
15
+ from proofprint.core.analyzer import build_packet
16
+ from proofprint.core.llm import LocalGemmaClient
17
+ from proofprint.core.models import EvidenceItem
18
+ from proofprint.core.storage import load_evidence, save_evidence
19
+
20
+
21
+ SAMPLE_CASE = ROOT / "data" / "sample_unpaid_wages_case.json"
22
+ RIGHTS_GUIDE = ROOT / "data" / "worker_rights_guide.md"
23
+
24
+
25
+ st.set_page_config(
26
+ page_title="ProofPrint",
27
+ page_icon="PP",
28
+ layout="wide",
29
+ initial_sidebar_state="expanded",
30
+ )
31
+
32
+
33
+ def apply_theme() -> None:
34
+ st.markdown(
35
+ """
36
+ <style>
37
+ :root {
38
+ --ink: #121722;
39
+ --muted: #515c6b;
40
+ --soft: #7b8492;
41
+ --line: #d7dde7;
42
+ --paper: #f6f4ee;
43
+ --panel: #ffffff;
44
+ --panel-soft: #fbfaf6;
45
+ --accent: #006b5f;
46
+ --accent-dark: #00483f;
47
+ --accent-soft: #e7f4f1;
48
+ --blue: #263f73;
49
+ --warn: #9a5a12;
50
+ --risk: #9b2f3b;
51
+ --ok: #17633e;
52
+ }
53
+ .stApp { background: var(--paper); color: var(--ink); }
54
+ .block-container { padding-top: 2.6rem; padding-bottom: 3rem; max-width: 1240px; }
55
+ header[data-testid="stHeader"] {
56
+ background: #0c1117;
57
+ border-bottom: 1px solid #1d2732;
58
+ }
59
+ h1, h2, h3, h4, h5, h6, p, label, span { letter-spacing: 0; }
60
+ h1, h2, h3 { color: var(--ink); }
61
+ h2, h3 { margin-top: 0.5rem; }
62
+ section[data-testid="stSidebar"] {
63
+ background: #ffffff;
64
+ border-right: 1px solid var(--line);
65
+ }
66
+ section[data-testid="stSidebar"] * { color: var(--ink); }
67
+ section[data-testid="stSidebar"] .stCaption, section[data-testid="stSidebar"] p { color: var(--muted) !important; }
68
+ div[data-testid="stTabs"] button { font-weight: 650; }
69
+ .pp-shell {
70
+ border: 1px solid var(--line);
71
+ background: var(--panel);
72
+ border-radius: 8px;
73
+ padding: 1.45rem 1.5rem;
74
+ }
75
+ .pp-topbar {
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ gap: 1rem;
80
+ border: 1px solid var(--line);
81
+ background: #ffffff;
82
+ border-radius: 8px;
83
+ padding: 0.8rem 0.95rem;
84
+ margin: 0 0 1rem 0;
85
+ }
86
+ .pp-brand {
87
+ display: flex;
88
+ align-items: center;
89
+ gap: 0.65rem;
90
+ min-width: 0;
91
+ }
92
+ .pp-logo {
93
+ width: 2.1rem;
94
+ height: 2.1rem;
95
+ border-radius: 7px;
96
+ display: grid;
97
+ place-items: center;
98
+ background: var(--ink);
99
+ color: #ffffff;
100
+ font-weight: 800;
101
+ letter-spacing: 0;
102
+ }
103
+ .pp-brand-title { font-weight: 760; line-height: 1.05; }
104
+ .pp-brand-subtitle { color: var(--muted); font-size: 0.82rem; line-height: 1.15; }
105
+ .pp-top-actions { display: flex; gap: 0.35rem; flex-wrap: wrap; justify-content: flex-end; }
106
+ .pp-hero {
107
+ display: grid;
108
+ grid-template-columns: minmax(0, 1.35fr) minmax(300px, 0.78fr);
109
+ gap: 1rem;
110
+ align-items: stretch;
111
+ margin-bottom: 1.15rem;
112
+ }
113
+ .pp-kicker {
114
+ color: var(--accent);
115
+ font-size: 0.82rem;
116
+ font-weight: 700;
117
+ text-transform: uppercase;
118
+ letter-spacing: 0;
119
+ }
120
+ .pp-title {
121
+ font-size: 2.18rem;
122
+ line-height: 1.08;
123
+ font-weight: 760;
124
+ margin: 0.2rem 0 0.65rem 0;
125
+ max-width: 850px;
126
+ }
127
+ .pp-subtitle {
128
+ color: var(--muted);
129
+ max-width: 820px;
130
+ font-size: 0.98rem;
131
+ line-height: 1.5;
132
+ margin-bottom: 0.85rem;
133
+ }
134
+ .pp-card {
135
+ background: var(--panel);
136
+ border: 1px solid var(--line);
137
+ border-radius: 8px;
138
+ padding: 1rem;
139
+ min-height: 100%;
140
+ }
141
+ .pp-card-soft {
142
+ background: var(--panel-soft);
143
+ border: 1px solid var(--line);
144
+ border-radius: 8px;
145
+ padding: 1rem;
146
+ }
147
+ .pp-panel-dark {
148
+ background: linear-gradient(135deg, #102720 0%, #17202d 100%);
149
+ color: #f6fbf9;
150
+ border: 1px solid #203a35;
151
+ border-radius: 8px;
152
+ padding: 1.25rem;
153
+ min-height: 100%;
154
+ }
155
+ .pp-demo-title {
156
+ margin: 0.15rem 0 0.65rem 0;
157
+ color: #fff;
158
+ font-size: 1.45rem;
159
+ line-height: 1.18;
160
+ }
161
+ .pp-panel-dark strong { color: #ffffff; }
162
+ .pp-panel-dark .pp-muted { color: #b7c2bf; }
163
+ .pp-pill {
164
+ display: inline-block;
165
+ border: 1px solid var(--line);
166
+ border-radius: 999px;
167
+ padding: 0.2rem 0.58rem;
168
+ margin: 0.1rem 0.18rem 0.1rem 0;
169
+ color: var(--accent-dark);
170
+ font-size: 0.82rem;
171
+ background: var(--accent-soft);
172
+ border-color: #b9dad3;
173
+ }
174
+ .pp-pill-dark {
175
+ display: inline-block;
176
+ border: 1px solid #344554;
177
+ border-radius: 999px;
178
+ padding: 0.2rem 0.58rem;
179
+ margin: 0.12rem 0.18rem 0.12rem 0;
180
+ color: #d7e4df;
181
+ font-size: 0.82rem;
182
+ background: #17232c;
183
+ }
184
+ .pp-step {
185
+ display: flex;
186
+ gap: 0.75rem;
187
+ align-items: flex-start;
188
+ padding: 0.75rem 0;
189
+ border-bottom: 1px solid var(--line);
190
+ }
191
+ .pp-step:last-child { border-bottom: 0; }
192
+ .pp-num {
193
+ flex: 0 0 auto;
194
+ width: 1.75rem;
195
+ height: 1.75rem;
196
+ border-radius: 50%;
197
+ display: grid;
198
+ place-items: center;
199
+ color: #fff;
200
+ background: var(--accent);
201
+ font-weight: 760;
202
+ font-size: 0.86rem;
203
+ }
204
+ .pp-step-title { font-weight: 720; color: var(--ink); }
205
+ .pp-step-text { color: var(--muted); font-size: 0.93rem; line-height: 1.38; }
206
+ .pp-evidence-row {
207
+ border: 1px solid var(--line);
208
+ border-radius: 8px;
209
+ background: #fff;
210
+ padding: 0.85rem 0.95rem;
211
+ margin-bottom: 0.55rem;
212
+ }
213
+ .pp-evidence-head {
214
+ display: flex;
215
+ justify-content: space-between;
216
+ gap: 0.75rem;
217
+ align-items: baseline;
218
+ }
219
+ .pp-evidence-title { font-weight: 720; }
220
+ .pp-muted { color: var(--muted); }
221
+ .pp-small { color: var(--soft); font-size: 0.86rem; }
222
+ .pp-status {
223
+ border: 1px solid #c5d9d4;
224
+ color: var(--ok);
225
+ background: #f2faf6;
226
+ border-radius: 999px;
227
+ padding: 0.22rem 0.65rem;
228
+ font-size: 0.82rem;
229
+ display: inline-block;
230
+ font-weight: 650;
231
+ }
232
+ .pp-counter {
233
+ background: #ffffff;
234
+ border: 1px solid var(--line);
235
+ border-radius: 8px;
236
+ padding: 0.85rem 0.95rem;
237
+ min-height: 5.5rem;
238
+ }
239
+ .pp-counter-label {
240
+ color: var(--muted);
241
+ font-size: 0.82rem;
242
+ font-weight: 700;
243
+ text-transform: uppercase;
244
+ margin-bottom: 0.35rem;
245
+ }
246
+ .pp-counter-value {
247
+ color: var(--ink);
248
+ font-size: 1.35rem;
249
+ font-weight: 780;
250
+ line-height: 1.15;
251
+ }
252
+ .pp-upload-box {
253
+ background: #ffffff;
254
+ border: 1px solid var(--line);
255
+ border-radius: 8px;
256
+ padding: 1rem;
257
+ margin-top: 0.8rem;
258
+ }
259
+ .pp-risk {
260
+ border-left: 4px solid var(--risk);
261
+ padding: 0.5rem 0.75rem;
262
+ background: #fff6f5;
263
+ margin-bottom: 0.4rem;
264
+ }
265
+ .pp-missing {
266
+ border-left: 4px solid var(--warn);
267
+ padding: 0.5rem 0.75rem;
268
+ background: #fff8ef;
269
+ margin-bottom: 0.4rem;
270
+ }
271
+ div[data-testid="stDownloadButton"] button,
272
+ div[data-testid="stButton"] button {
273
+ border-radius: 7px;
274
+ font-weight: 650;
275
+ min-height: 2.55rem;
276
+ color: #ffffff !important;
277
+ }
278
+ div[data-testid="stDownloadButton"] button *,
279
+ div[data-testid="stButton"] button * {
280
+ color: #ffffff !important;
281
+ }
282
+ button,
283
+ button p,
284
+ button span,
285
+ [data-testid="stFormSubmitButton"] button,
286
+ [data-testid="stFormSubmitButton"] button p,
287
+ [data-testid="stFormSubmitButton"] button span,
288
+ [data-testid="stFileUploaderDropzone"] button,
289
+ [data-testid="stFileUploaderDropzone"] button p,
290
+ [data-testid="stFileUploaderDropzone"] button span {
291
+ color: #ffffff !important;
292
+ }
293
+ input, textarea, select {
294
+ background: #ffffff !important;
295
+ color: var(--ink) !important;
296
+ border-color: var(--line) !important;
297
+ }
298
+ textarea, input, select {
299
+ border-radius: 7px !important;
300
+ }
301
+ textarea::placeholder, input::placeholder {
302
+ color: #7b8492 !important;
303
+ opacity: 1 !important;
304
+ }
305
+ .stTextInput label, .stTextArea label, .stSelectbox label, .stFileUploader label {
306
+ color: var(--ink) !important;
307
+ font-weight: 650 !important;
308
+ }
309
+ @media (max-width: 900px) {
310
+ .pp-hero { grid-template-columns: 1fr; }
311
+ .pp-title { font-size: 1.85rem; }
312
+ .pp-topbar { align-items: flex-start; flex-direction: column; }
313
+ .pp-top-actions { justify-content: flex-start; }
314
+ }
315
+ </style>
316
+ """,
317
+ unsafe_allow_html=True,
318
+ )
319
+
320
+
321
+ def read_text_file(uploaded_file) -> str:
322
+ data = uploaded_file.getvalue()
323
+ try:
324
+ return data.decode("utf-8")
325
+ except UnicodeDecodeError:
326
+ try:
327
+ return data.decode("latin-1")
328
+ except UnicodeDecodeError:
329
+ return ""
330
+
331
+
332
+ def load_rights_guide() -> str:
333
+ return RIGHTS_GUIDE.read_text(encoding="utf-8") if RIGHTS_GUIDE.exists() else ""
334
+
335
+
336
+ def load_sample_items() -> list[EvidenceItem]:
337
+ payload = json.loads(SAMPLE_CASE.read_text(encoding="utf-8"))
338
+ return [
339
+ EvidenceItem(
340
+ id=item["id"],
341
+ title=item["title"],
342
+ source_type=item["source_type"],
343
+ raw_text=item["raw_text"],
344
+ filename=item.get("filename", ""),
345
+ created_on=item.get("created_on", "2026-05-11"),
346
+ )
347
+ for item in payload["evidence"]
348
+ ]
349
+
350
+
351
+ def init_state() -> None:
352
+ if "evidence" not in st.session_state:
353
+ st.session_state.evidence = load_evidence()
354
+ if "packet" not in st.session_state:
355
+ st.session_state.packet = None
356
+ if "worker_name" not in st.session_state:
357
+ st.session_state.worker_name = "Ravi Kumar"
358
+
359
+
360
+ def add_evidence(title: str, source_type: str, raw_text: str, filename: str = "") -> None:
361
+ if not raw_text.strip():
362
+ return
363
+ st.session_state.evidence.append(
364
+ EvidenceItem(
365
+ id=f"E{len(st.session_state.evidence) + 1:03d}",
366
+ title=title.strip() or filename or "Untitled evidence",
367
+ source_type=source_type,
368
+ raw_text=raw_text.strip(),
369
+ filename=filename,
370
+ )
371
+ )
372
+ save_evidence(st.session_state.evidence)
373
+
374
+
375
+ def packet_to_markdown(packet) -> str:
376
+ timeline = "\n".join(
377
+ f"- {event.date}: {event.event} Evidence: {', '.join(event.evidence_ids)} Amount: {event.amount or 'N/A'}"
378
+ for event in packet.timeline
379
+ )
380
+ evidence_rows = "\n".join(
381
+ f"| {row['id']} | {row['title']} | {row['type']} | {row['key_dates']} | {row['amounts']} | {row['sensitivity']} |"
382
+ for row in packet.evidence_table
383
+ )
384
+ return f"""# ProofPrint Case Packet
385
+
386
+ Worker: {packet.worker_name}
387
+ Case type: {packet.case_type}
388
+ Model status: {packet.model_status}
389
+
390
+ ## Summary
391
+ {packet.summary}
392
+
393
+ ## Timeline
394
+ {timeline or "No timeline events extracted."}
395
+
396
+ ## Evidence Table
397
+ | ID | Title | Type | Key dates | Amounts | Sensitivity |
398
+ |---|---|---|---|---|---|
399
+ {evidence_rows}
400
+
401
+ ## Missing Evidence
402
+ {chr(10).join(f"- {item}" for item in packet.missing_evidence)}
403
+
404
+ ## Risk Flags
405
+ {chr(10).join(f"- {item}" for item in packet.risk_flags)}
406
+
407
+ ## Privacy Notes
408
+ {chr(10).join(f"- {item}" for item in packet.privacy_notes)}
409
+
410
+ ## Advocate Packet
411
+ {packet.advocate_packet}
412
+
413
+ ## Complaint Draft
414
+ {packet.complaint_draft}
415
+ """
416
+
417
+
418
+ def render_header() -> None:
419
+ st.markdown(
420
+ """
421
+ <div class="pp-topbar">
422
+ <div class="pp-brand">
423
+ <div class="pp-logo">PP</div>
424
+ <div>
425
+ <div class="pp-brand-title">ProofPrint</div>
426
+ <div class="pp-brand-subtitle">Private evidence packets for worker advocates</div>
427
+ </div>
428
+ </div>
429
+ <div class="pp-top-actions">
430
+ <span class="pp-pill">Digital Equity</span>
431
+ <span class="pp-pill">Safety & Trust</span>
432
+ <span class="pp-pill">Ollama Track</span>
433
+ </div>
434
+ </div>
435
+ <div class="pp-hero">
436
+ <div class="pp-shell">
437
+ <div class="pp-kicker">Local-first justice access</div>
438
+ <div class="pp-title">ProofPrint turns scattered worker evidence into an advocate-ready packet.</div>
439
+ <div class="pp-subtitle">
440
+ A private Gemma 4 workflow for wage theft, unsafe work, retaliation, and denied benefits.
441
+ It converts chats, payment notes, shift logs, and voice transcripts into a timeline,
442
+ risk review, and human advocate handoff.
443
+ </div>
444
+ <span class="pp-pill">Gemma 4 local inference</span>
445
+ <span class="pp-pill">Ollama on device</span>
446
+ <span class="pp-pill">Evidence-grounded output</span>
447
+ <span class="pp-pill">No fallback generation</span>
448
+ </div>
449
+ <div class="pp-panel-dark">
450
+ <div class="pp-kicker" style="color:#84d7c4;">Demo case</div>
451
+ <div class="pp-demo-title">Ravi worked five shifts. Only Rs 1,500 arrived.</div>
452
+ <div class="pp-muted">
453
+ Proof is split across hiring messages, shift notes, a UPI transcript, follow-up messages,
454
+ and a voice note. ProofPrint turns it into one reviewable packet.
455
+ </div>
456
+ <div style="margin-top:0.8rem;">
457
+ <span class="pp-pill-dark">unpaid wages</span>
458
+ <span class="pp-pill-dark">retaliation risk</span>
459
+ <span class="pp-pill-dark">privacy redaction</span>
460
+ </div>
461
+ </div>
462
+ </div>
463
+ """,
464
+ unsafe_allow_html=True,
465
+ )
466
+
467
+
468
+ def render_sidebar() -> None:
469
+ with st.sidebar:
470
+ st.markdown("## ProofPrint")
471
+ st.caption("Local Gemma evidence kit")
472
+ st.divider()
473
+ st.subheader("Case setup")
474
+ st.session_state.worker_name = st.text_input("Worker name", st.session_state.worker_name)
475
+ model = st.text_input("Local Gemma model", "gemma4:e2b")
476
+ st.caption("Use a local Ollama Gemma model name. Packet generation is disabled unless Gemma is reachable.")
477
+
478
+ if st.button("Load sample case", use_container_width=True):
479
+ st.session_state.evidence = load_sample_items()
480
+ st.session_state.packet = None
481
+ save_evidence(st.session_state.evidence)
482
+ st.rerun()
483
+
484
+ if st.button("Clear evidence", use_container_width=True):
485
+ st.session_state.evidence = []
486
+ st.session_state.packet = None
487
+ save_evidence([])
488
+ st.rerun()
489
+
490
+ st.divider()
491
+ st.subheader("Engine")
492
+ st.markdown(
493
+ """
494
+ <span class="pp-pill">Gemma extraction</span>
495
+ <span class="pp-pill">timeline reasoning</span>
496
+ <span class="pp-pill">risk checks</span>
497
+ <span class="pp-pill">packet drafting</span>
498
+ """,
499
+ unsafe_allow_html=True,
500
+ )
501
+ st.divider()
502
+ st.caption("For public demos, use synthetic or anonymized evidence only.")
503
+ return model
504
+
505
+
506
+ def render_intake() -> None:
507
+ st.markdown("### Evidence Intake")
508
+ st.caption("Add the fragments a worker actually has: chats, payment notes, shift logs, letters, or transcripts.")
509
+ left, right = st.columns([1.08, 0.92], gap="large")
510
+
511
+ with left:
512
+ 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)
513
+ with st.form("manual_evidence", clear_on_submit=True):
514
+ title = st.text_input("Evidence title", placeholder="UPI payment screenshot text, shift log, employer chat...")
515
+ source_type = st.selectbox("Source type", ["chat", "payment", "shift log", "receipt", "letter", "photo note", "other"])
516
+ 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.")
517
+ submitted = st.form_submit_button("Add evidence", use_container_width=True)
518
+ if submitted:
519
+ add_evidence(title, source_type, raw_text)
520
+ st.rerun()
521
+
522
+ with right:
523
+ st.markdown(
524
+ """
525
+ <div class="pp-card-soft">
526
+ <div class="pp-step">
527
+ <div class="pp-num">1</div>
528
+ <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>
529
+ </div>
530
+ <div class="pp-step">
531
+ <div class="pp-num">2</div>
532
+ <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>
533
+ </div>
534
+ <div class="pp-step">
535
+ <div class="pp-num">3</div>
536
+ <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>
537
+ </div>
538
+ </div>
539
+ """,
540
+ unsafe_allow_html=True,
541
+ )
542
+ 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)
543
+ uploaded_files = st.file_uploader(
544
+ "Choose evidence files",
545
+ accept_multiple_files=True,
546
+ type=["txt", "md", "csv"],
547
+ )
548
+ if uploaded_files:
549
+ st.caption(f"{len(uploaded_files)} file(s) ready to import.")
550
+ if st.button("Import uploaded files", use_container_width=True):
551
+ for uploaded_file in uploaded_files:
552
+ add_evidence(uploaded_file.name, uploaded_file.type or "uploaded file", read_text_file(uploaded_file), uploaded_file.name)
553
+ st.rerun()
554
+
555
+
556
+ def render_evidence_list() -> None:
557
+ st.markdown("### Evidence Board")
558
+ if not st.session_state.evidence:
559
+ 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)
560
+ return
561
+
562
+ for item in st.session_state.evidence:
563
+ preview = html.escape(item.raw_text[:180] + ("..." if len(item.raw_text) > 180 else ""))
564
+ title = html.escape(item.title)
565
+ source_type = html.escape(item.source_type)
566
+ st.markdown(
567
+ f"""
568
+ <div class="pp-evidence-row">
569
+ <div class="pp-evidence-head">
570
+ <div><span class="pp-small">{item.id} / {source_type}</span><br/><span class="pp-evidence-title">{title}</span></div>
571
+ <span class="pp-status">stored locally</span>
572
+ </div>
573
+ <div class="pp-muted" style="margin-top:0.45rem;">{preview}</div>
574
+ </div>
575
+ """,
576
+ unsafe_allow_html=True,
577
+ )
578
+ with st.expander(f"{item.id} - {item.title}", expanded=False):
579
+ st.write(f"Type: `{item.source_type}`")
580
+ st.text_area("Raw evidence", item.raw_text, height=130, key=f"raw_{item.id}", disabled=True)
581
+
582
+
583
+ def render_packet(packet) -> None:
584
+ st.markdown("### Generated Case Packet")
585
+ top = st.columns(4)
586
+ counters = [
587
+ ("Case type", packet.case_type),
588
+ ("Evidence items", str(len(packet.evidence_table))),
589
+ ("Timeline events", str(len(packet.timeline))),
590
+ ("Missing checks", str(len(packet.missing_evidence))),
591
+ ]
592
+ for column, (label, value) in zip(top, counters):
593
+ column.markdown(
594
+ f"""
595
+ <div class="pp-counter">
596
+ <div class="pp-counter-label">{html.escape(label)}</div>
597
+ <div class="pp-counter-value">{html.escape(value)}</div>
598
+ </div>
599
+ """,
600
+ unsafe_allow_html=True,
601
+ )
602
+ st.markdown(f'<span class="pp-status">{packet.model_status}</span>', unsafe_allow_html=True)
603
+
604
+ 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)
605
+
606
+ timeline_tab, evidence_tab, risks_tab, packet_tab, draft_tab = st.tabs(
607
+ ["Timeline", "Evidence", "Risk checks", "Advocate packet", "Complaint draft"]
608
+ )
609
+
610
+ with timeline_tab:
611
+ for event in packet.timeline:
612
+ event_text = html.escape(event.event)
613
+ event_date = html.escape(str(event.date))
614
+ event_amount = html.escape(event.amount or "N/A")
615
+ evidence_ids = html.escape(", ".join(event.evidence_ids))
616
+ st.markdown(
617
+ f"""
618
+ <div class="pp-card">
619
+ <span class="pp-small">{event_date}</span><br/>
620
+ <strong>{event_text}</strong><br/>
621
+ <span class="pp-pill">Evidence: {evidence_ids}</span>
622
+ <span class="pp-pill">Amount: {event_amount}</span>
623
+ </div>
624
+ """,
625
+ unsafe_allow_html=True,
626
+ )
627
+
628
+ with evidence_tab:
629
+ st.dataframe(packet.evidence_table, use_container_width=True, hide_index=True)
630
+
631
+ with risks_tab:
632
+ st.markdown("##### Missing evidence")
633
+ for item in packet.missing_evidence:
634
+ st.markdown(f'<div class="pp-missing">{item}</div>', unsafe_allow_html=True)
635
+ st.markdown("##### Risk and privacy flags")
636
+ for item in packet.risk_flags:
637
+ st.markdown(f'<div class="pp-risk">{item}</div>', unsafe_allow_html=True)
638
+ st.markdown("##### Privacy notes")
639
+ for item in packet.privacy_notes:
640
+ st.write(f"- {item}")
641
+
642
+ with packet_tab:
643
+ st.markdown(packet.advocate_packet)
644
+
645
+ with draft_tab:
646
+ st.text_area("Draft", packet.complaint_draft, height=340)
647
+
648
+ markdown = packet_to_markdown(packet)
649
+ st.download_button(
650
+ "Download advocate packet",
651
+ data=markdown,
652
+ file_name=f"proofprint_packet_{uuid.uuid4().hex[:8]}.md",
653
+ mime="text/markdown",
654
+ use_container_width=True,
655
+ )
656
+
657
+
658
+ def main() -> None:
659
+ apply_theme()
660
+ init_state()
661
+ model_name = render_sidebar()
662
+ render_header()
663
+
664
+ stats = st.columns(3)
665
+ 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)
666
+ 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)
667
+ 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)
668
+
669
+ st.divider()
670
+ render_intake()
671
+ render_evidence_list()
672
+
673
+ st.divider()
674
+ can_generate = bool(st.session_state.evidence)
675
+ st.markdown("### Packet Builder")
676
+ st.caption("This step calls the local Gemma model. On your laptop, the sample case can take a few minutes.")
677
+ if st.button("Build advocate packet with Gemma", type="primary", disabled=not can_generate, use_container_width=True):
678
+ llm = LocalGemmaClient(model=model_name)
679
+ try:
680
+ st.session_state.packet = build_packet(
681
+ worker_name=st.session_state.worker_name,
682
+ evidence_items=st.session_state.evidence,
683
+ llm=llm,
684
+ rights_guide=load_rights_guide(),
685
+ )
686
+ save_evidence(st.session_state.evidence)
687
+ st.rerun()
688
+ except RuntimeError as exc:
689
+ st.error(str(exc))
690
+
691
+ if st.session_state.packet:
692
+ render_packet(st.session_state.packet)
693
+
694
+
695
+ if __name__ == "__main__":
696
+ main()
core/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """ProofPrint core package."""
core/analyzer.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from collections import Counter
5
+ from datetime import date
6
+ from typing import Any
7
+
8
+ from .llm import LocalGemmaClient, extract_json_block
9
+ from .models import CasePacket, EvidenceItem, TimelineEvent
10
+
11
+
12
+ DATE_PATTERN = re.compile(
13
+ r"\b(?:\d{1,2}[/-]\d{1,2}[/-]\d{2,4}|\d{4}[/-]\d{1,2}[/-]\d{1,2}|(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\.?\s+\d{1,2},?\s+\d{4})\b",
14
+ re.IGNORECASE,
15
+ )
16
+ AMOUNT_PATTERN = re.compile(r"(?:Rs\.?|INR|₹|\$)\s?[\d,]+(?:\.\d{1,2})?|\b\d{3,6}\s?(?:rupees|rs)\b", re.IGNORECASE)
17
+ PHONE_PATTERN = re.compile(r"\b(?:\+?\d[\d -]{8,}\d)\b")
18
+ EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
19
+
20
+ CASE_TYPES = {
21
+ "Unpaid wages": ["salary", "wage", "payment", "paid", "unpaid", "shift", "overtime", "invoice"],
22
+ "Unsafe work": ["injury", "unsafe", "accident", "protective", "helmet", "gloves", "hazard"],
23
+ "Harassment or retaliation": ["threat", "harass", "retaliation", "fired", "terminated", "abuse"],
24
+ "Denied benefit": ["claim", "benefit", "denied", "rejected", "eligibility", "scheme"],
25
+ }
26
+
27
+
28
+ def classify_case(text: str) -> str:
29
+ lowered = text.lower()
30
+ scores = {
31
+ case_type: sum(1 for keyword in keywords if keyword in lowered)
32
+ for case_type, keywords in CASE_TYPES.items()
33
+ }
34
+ best, score = max(scores.items(), key=lambda pair: pair[1])
35
+ return best if score else "Worker evidence packet"
36
+
37
+
38
+ def extract_entities(text: str) -> dict[str, Any]:
39
+ dates = DATE_PATTERN.findall(text)
40
+ amounts = AMOUNT_PATTERN.findall(text)
41
+ phones = PHONE_PATTERN.findall(text)
42
+ emails = EMAIL_PATTERN.findall(text)
43
+ words = re.findall(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b", text)
44
+ names = [word for word, count in Counter(words).most_common(8) if word.lower() not in {"I", "The", "This"}]
45
+ return {
46
+ "dates": sorted(set(dates)),
47
+ "amounts": sorted(set(amounts)),
48
+ "contacts": sorted(set(phones + emails)),
49
+ "possible_people_or_orgs": names,
50
+ }
51
+
52
+
53
+ def analyze_evidence_item(item: EvidenceItem, llm: LocalGemmaClient) -> tuple[EvidenceItem, str]:
54
+ prompt = f"""
55
+ You are ProofPrint, a local-first evidence organizer for worker advocates.
56
+ Extract only facts supported by the evidence. Return valid compact JSON only, with keys:
57
+ case_type, one_sentence_summary, dates, amounts, people_or_orgs, promises, harms, contradictions, sensitivity.
58
+ Use arrays for dates, amounts, people_or_orgs, promises, harms, and contradictions.
59
+
60
+ Evidence title: {item.title}
61
+ Evidence text:
62
+ {item.raw_text[:5000]}
63
+ """
64
+ response, status = llm.generate(prompt)
65
+ if not response:
66
+ raise RuntimeError(f"Gemma analysis failed for {item.id}: {status}")
67
+ parsed = extract_json_block(response)
68
+ if not parsed:
69
+ raise RuntimeError(f"Gemma did not return valid JSON for {item.id}. Raw response: {response[:400]}")
70
+
71
+ item.extracted = {
72
+ "case_type": str(parsed.get("case_type") or "Worker evidence packet"),
73
+ "one_sentence_summary": str(parsed.get("one_sentence_summary") or ""),
74
+ "dates": ensure_list(parsed.get("dates")),
75
+ "amounts": ensure_list(parsed.get("amounts")),
76
+ "people_or_orgs": ensure_list(parsed.get("people_or_orgs")),
77
+ "promises": ensure_list(parsed.get("promises")),
78
+ "harms": ensure_list(parsed.get("harms")),
79
+ "contradictions": ensure_list(parsed.get("contradictions")),
80
+ "sensitivity": str(parsed.get("sensitivity") or "unknown"),
81
+ }
82
+ return item, status
83
+
84
+
85
+ def ensure_list(value: Any) -> list[Any]:
86
+ if value is None:
87
+ return []
88
+ if isinstance(value, list):
89
+ return value
90
+ return [value]
91
+
92
+
93
+ def summarize_text(text: str) -> str:
94
+ clean = " ".join(text.split())
95
+ if len(clean) <= 220:
96
+ return clean
97
+ return clean[:217].rsplit(" ", 1)[0] + "..."
98
+
99
+
100
+ def find_lines(text: str, keywords: list[str]) -> list[str]:
101
+ lines = [line.strip() for line in re.split(r"[\n\r.]+", text) if line.strip()]
102
+ matches = []
103
+ for line in lines:
104
+ lowered = line.lower()
105
+ if any(keyword in lowered for keyword in keywords):
106
+ matches.append(line[:240])
107
+ return matches[:5]
108
+
109
+
110
+ def infer_sensitivity(text: str) -> str:
111
+ lowered = text.lower()
112
+ if EMAIL_PATTERN.search(text) or PHONE_PATTERN.search(text):
113
+ return "high: contains personal contact information"
114
+ if any(word in lowered for word in ["injury", "medical", "threat", "harass"]):
115
+ return "high: contains safety or health details"
116
+ if AMOUNT_PATTERN.search(text):
117
+ return "medium: contains payment details"
118
+ return "low"
119
+
120
+
121
+ def build_packet(
122
+ worker_name: str,
123
+ evidence_items: list[EvidenceItem],
124
+ llm: LocalGemmaClient,
125
+ rights_guide: str,
126
+ ) -> CasePacket:
127
+ if not llm.available():
128
+ raise RuntimeError(
129
+ f"Local Gemma model '{llm.model}' is not reachable through Ollama. "
130
+ "Start Ollama and pull the configured Gemma model before building a packet."
131
+ )
132
+
133
+ analyzed = []
134
+ statuses = []
135
+ for item in evidence_items:
136
+ analyzed_item, status = analyze_evidence_item(item, llm)
137
+ analyzed.append(analyzed_item)
138
+ statuses.append(status)
139
+
140
+ full_text = "\n\n".join(item.raw_text for item in analyzed)
141
+ case_type = classify_case(full_text)
142
+ timeline = build_timeline(analyzed)
143
+ evidence_table = build_evidence_table(analyzed)
144
+ missing = missing_evidence_for(case_type, analyzed)
145
+ risks = risk_flags_for(analyzed)
146
+ privacy = privacy_notes_for(analyzed)
147
+ summary = build_case_summary(worker_name, case_type, analyzed)
148
+ advocate_packet = build_advocate_packet(worker_name, case_type, summary, timeline, evidence_table, missing, risks, rights_guide)
149
+ complaint_draft = build_complaint_draft(worker_name, case_type, summary, timeline, missing)
150
+
151
+ return CasePacket(
152
+ worker_name=worker_name or "Worker",
153
+ case_type=case_type,
154
+ summary=summary,
155
+ timeline=timeline,
156
+ evidence_table=evidence_table,
157
+ missing_evidence=missing,
158
+ risk_flags=risks,
159
+ advocate_packet=advocate_packet,
160
+ complaint_draft=complaint_draft,
161
+ privacy_notes=privacy,
162
+ model_status=most_relevant_status(statuses),
163
+ )
164
+
165
+
166
+ def build_timeline(items: list[EvidenceItem]) -> list[TimelineEvent]:
167
+ events: list[TimelineEvent] = []
168
+ for item in items:
169
+ dates = item.extracted.get("dates") or [item.created_on]
170
+ amounts = item.extracted.get("amounts") or []
171
+ people = item.extracted.get("people_or_orgs") or []
172
+ summary = item.extracted.get("one_sentence_summary") or summarize_text(item.raw_text)
173
+ for event_date in dates[:3]:
174
+ events.append(
175
+ TimelineEvent(
176
+ date=str(event_date),
177
+ event=summary,
178
+ evidence_ids=[item.id],
179
+ amount=", ".join(map(str, amounts[:3])),
180
+ people=[str(person) for person in people[:4]],
181
+ )
182
+ )
183
+ return sorted(events, key=lambda event: event.date)[:12]
184
+
185
+
186
+ def build_evidence_table(items: list[EvidenceItem]) -> list[dict[str, str]]:
187
+ rows = []
188
+ for item in items:
189
+ extracted = item.extracted
190
+ rows.append(
191
+ {
192
+ "id": item.id,
193
+ "title": item.title,
194
+ "type": item.source_type,
195
+ "key_dates": ", ".join(map(str, extracted.get("dates", [])[:4])) or "Not found",
196
+ "amounts": ", ".join(map(str, extracted.get("amounts", [])[:4])) or "Not found",
197
+ "summary": str(extracted.get("one_sentence_summary", "")),
198
+ "sensitivity": str(extracted.get("sensitivity", infer_sensitivity(item.raw_text))),
199
+ }
200
+ )
201
+ return rows
202
+
203
+
204
+ def missing_evidence_for(case_type: str, items: list[EvidenceItem]) -> list[str]:
205
+ text = "\n".join(item.raw_text.lower() for item in items)
206
+ checks = {
207
+ "Unpaid wages": [
208
+ ("employment terms or hiring chat", ["hired", "contract", "joining", "rate", "salary"]),
209
+ ("work dates or shift records", ["shift", "attendance", "worked", "hours"]),
210
+ ("payment proof or bank/UPI screenshot", ["upi", "bank", "paid", "payment", "transfer"]),
211
+ ("final demand or employer response", ["asked", "request", "reply", "will pay", "refused"]),
212
+ ],
213
+ "Unsafe work": [
214
+ ("photo or record of the hazard", ["photo", "unsafe", "hazard", "broken"]),
215
+ ("injury or incident date", ["injury", "accident", "hurt", "hospital"]),
216
+ ("manager notification proof", ["reported", "informed", "manager", "supervisor"]),
217
+ ],
218
+ "Harassment or retaliation": [
219
+ ("original message or witness note", ["message", "witness", "threat", "abuse"]),
220
+ ("timeline of retaliation", ["fired", "removed", "blocked", "terminated"]),
221
+ ],
222
+ "Denied benefit": [
223
+ ("denial letter or rejection message", ["denied", "rejected", "not eligible"]),
224
+ ("eligibility proof", ["eligible", "income", "identity", "document"]),
225
+ ],
226
+ }
227
+ selected = checks.get(case_type, checks["Unpaid wages"])
228
+ missing = [label for label, keywords in selected if not any(keyword in text for keyword in keywords)]
229
+ return missing or ["No obvious missing evidence detected. Advocate should still verify authenticity and deadlines."]
230
+
231
+
232
+ def risk_flags_for(items: list[EvidenceItem]) -> list[str]:
233
+ text = "\n".join(item.raw_text.lower() for item in items)
234
+ flags = []
235
+ if "threat" in text or "abuse" in text:
236
+ flags.append("Possible retaliation or safety risk. Escalate to a trusted advocate before direct confrontation.")
237
+ if "injury" in text or "hospital" in text:
238
+ flags.append("Health or injury details detected. Preserve medical records and avoid public sharing.")
239
+ if not DATE_PATTERN.search(text):
240
+ flags.append("No clear dates found. Timeline needs stronger date evidence.")
241
+ if not AMOUNT_PATTERN.search(text):
242
+ flags.append("No clear money amounts found. Payment claim may need wage or invoice proof.")
243
+ if PHONE_PATTERN.search(text) or EMAIL_PATTERN.search(text):
244
+ flags.append("Personal contact details detected. Redact before public upload.")
245
+ return flags or ["No major risk flags detected from the provided evidence."]
246
+
247
+
248
+ def privacy_notes_for(items: list[EvidenceItem]) -> list[str]:
249
+ notes = [
250
+ "Keep original evidence files unchanged.",
251
+ "Share the generated packet only with a trusted advocate, union, NGO, or official channel.",
252
+ ]
253
+ if any("high" in str(item.extracted.get("sensitivity", "")) for item in items):
254
+ notes.append("High-sensitivity evidence detected. Redact phone numbers, addresses, and health details for public demos.")
255
+ return notes
256
+
257
+
258
+ def build_case_summary(worker_name: str, case_type: str, items: list[EvidenceItem]) -> str:
259
+ summaries = [str(item.extracted.get("one_sentence_summary", "")) for item in items if item.extracted]
260
+ name = worker_name or "The worker"
261
+ return f"{name} appears to have a {case_type.lower()} matter supported by {len(items)} evidence item(s). " + " ".join(summaries[:3])
262
+
263
+
264
+ def build_advocate_packet(
265
+ worker_name: str,
266
+ case_type: str,
267
+ summary: str,
268
+ timeline: list[TimelineEvent],
269
+ evidence_table: list[dict[str, str]],
270
+ missing: list[str],
271
+ risks: list[str],
272
+ rights_guide: str,
273
+ ) -> str:
274
+ timeline_lines = "\n".join(
275
+ f"- {event.date}: {event.event} Evidence: {', '.join(event.evidence_ids)} Amount: {event.amount or 'N/A'}"
276
+ for event in timeline
277
+ )
278
+ evidence_lines = "\n".join(
279
+ f"- {row['id']} | {row['title']} | {row['type']} | Dates: {row['key_dates']} | Amounts: {row['amounts']}"
280
+ for row in evidence_table
281
+ )
282
+ missing_lines = "\n".join(f"- {item}" for item in missing)
283
+ risk_lines = "\n".join(f"- {item}" for item in risks)
284
+ guide_excerpt = rights_guide[:900].strip()
285
+ return f"""# ProofPrint Advocate Packet
286
+
287
+ Generated: {date.today().isoformat()}
288
+ Worker: {worker_name or "Worker"}
289
+ Case type: {case_type}
290
+
291
+ ## Plain-language summary
292
+ {summary}
293
+
294
+ ## Timeline
295
+ {timeline_lines or "- No dated events found yet."}
296
+
297
+ ## Evidence index
298
+ {evidence_lines}
299
+
300
+ ## Missing evidence checklist
301
+ {missing_lines}
302
+
303
+ ## Risk and privacy flags
304
+ {risk_lines}
305
+
306
+ ## Local rights guide context
307
+ {guide_excerpt}
308
+
309
+ ## Important limitation
310
+ ProofPrint organizes evidence and drafts support material. It does not provide legal advice, verify authenticity, or replace a qualified advocate.
311
+ """
312
+
313
+
314
+ def build_complaint_draft(
315
+ worker_name: str,
316
+ case_type: str,
317
+ summary: str,
318
+ timeline: list[TimelineEvent],
319
+ missing: list[str],
320
+ ) -> str:
321
+ timeline_text = "\n".join(f"- {event.date}: {event.event}" for event in timeline[:8])
322
+ missing_text = "\n".join(f"- {item}" for item in missing)
323
+ return f"""Subject: Request for assistance with {case_type.lower()}
324
+
325
+ I am requesting help reviewing a worker rights matter.
326
+
327
+ Worker: {worker_name or "Worker"}
328
+ Issue: {case_type}
329
+
330
+ Summary:
331
+ {summary}
332
+
333
+ Timeline:
334
+ {timeline_text or "- Dates are still being collected."}
335
+
336
+ Evidence still needed:
337
+ {missing_text}
338
+
339
+ I understand this draft is only an evidence organization aid and should be reviewed by a qualified advocate before submission.
340
+ """
341
+
342
+
343
+ def most_relevant_status(statuses: list[str]) -> str:
344
+ for status in statuses:
345
+ if status.startswith("local Gemma"):
346
+ return status
347
+ return statuses[-1] if statuses else "local Gemma status unavailable"
core/llm.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import Any
9
+
10
+
11
+ DEFAULT_MODEL = os.getenv("PROOFPRINT_MODEL", "gemma4:e2b")
12
+ OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434/api/generate")
13
+
14
+
15
+ class LocalGemmaClient:
16
+ def __init__(self, model: str = DEFAULT_MODEL, url: str = OLLAMA_URL, timeout: int = 180) -> None:
17
+ self.model = model
18
+ self.url = url
19
+ self.timeout = timeout
20
+
21
+ def available(self) -> bool:
22
+ tags_url = self.url.replace("/api/generate", "/api/tags")
23
+ try:
24
+ with urllib.request.urlopen(tags_url, timeout=10) as response:
25
+ body = json.loads(response.read().decode("utf-8"))
26
+ return any(model.get("name") == self.model or model.get("model") == self.model for model in body.get("models", []))
27
+ except Exception:
28
+ return False
29
+
30
+ def generate(self, prompt: str, images: list[bytes] | None = None) -> tuple[str, str]:
31
+ payload: dict[str, Any] = {
32
+ "model": self.model,
33
+ "prompt": prompt,
34
+ "stream": False,
35
+ "options": {"temperature": 0.2, "top_p": 0.9},
36
+ }
37
+ if images:
38
+ payload["images"] = [base64.b64encode(image).decode("ascii") for image in images]
39
+
40
+ try:
41
+ request = urllib.request.Request(
42
+ self.url,
43
+ data=json.dumps(payload).encode("utf-8"),
44
+ headers={"Content-Type": "application/json"},
45
+ method="POST",
46
+ )
47
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
48
+ body = json.loads(response.read().decode("utf-8"))
49
+ return body.get("response", "").strip(), f"local Gemma via Ollama ({self.model})"
50
+ except urllib.error.HTTPError as exc:
51
+ return "", f"Ollama HTTP error: {exc.code}"
52
+ except Exception as exc:
53
+ return "", f"Gemma unavailable ({exc.__class__.__name__})"
54
+
55
+
56
+ def extract_json_block(text: str) -> dict[str, Any] | None:
57
+ start = text.find("{")
58
+ end = text.rfind("}")
59
+ if start == -1 or end == -1 or end <= start:
60
+ return None
61
+ try:
62
+ return json.loads(text[start : end + 1])
63
+ except json.JSONDecodeError:
64
+ return None
core/models.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import date
5
+ from typing import Any
6
+
7
+
8
+ @dataclass
9
+ class EvidenceItem:
10
+ id: str
11
+ title: str
12
+ source_type: str
13
+ raw_text: str
14
+ filename: str = ""
15
+ created_on: str = field(default_factory=lambda: date.today().isoformat())
16
+ extracted: dict[str, Any] = field(default_factory=dict)
17
+
18
+
19
+ @dataclass
20
+ class TimelineEvent:
21
+ date: str
22
+ event: str
23
+ evidence_ids: list[str]
24
+ amount: str = ""
25
+ people: list[str] = field(default_factory=list)
26
+ confidence: str = "medium"
27
+
28
+
29
+ @dataclass
30
+ class CasePacket:
31
+ worker_name: str
32
+ case_type: str
33
+ summary: str
34
+ timeline: list[TimelineEvent]
35
+ evidence_table: list[dict[str, str]]
36
+ missing_evidence: list[str]
37
+ risk_flags: list[str]
38
+ advocate_packet: str
39
+ complaint_draft: str
40
+ privacy_notes: list[str]
41
+ model_status: str
core/storage.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sqlite3
5
+ from pathlib import Path
6
+
7
+ from .models import EvidenceItem
8
+
9
+
10
+ DB_PATH = Path(__file__).resolve().parents[1] / "data" / "proofprint.sqlite"
11
+
12
+
13
+ def init_db(path: Path = DB_PATH) -> None:
14
+ path.parent.mkdir(parents=True, exist_ok=True)
15
+ with sqlite3.connect(path) as connection:
16
+ connection.execute(
17
+ """
18
+ CREATE TABLE IF NOT EXISTS evidence (
19
+ id TEXT PRIMARY KEY,
20
+ title TEXT NOT NULL,
21
+ source_type TEXT NOT NULL,
22
+ filename TEXT,
23
+ raw_text TEXT NOT NULL,
24
+ created_on TEXT NOT NULL,
25
+ extracted_json TEXT NOT NULL
26
+ )
27
+ """
28
+ )
29
+
30
+
31
+ def save_evidence(items: list[EvidenceItem], path: Path = DB_PATH) -> None:
32
+ init_db(path)
33
+ with sqlite3.connect(path) as connection:
34
+ connection.executemany(
35
+ """
36
+ INSERT OR REPLACE INTO evidence
37
+ (id, title, source_type, filename, raw_text, created_on, extracted_json)
38
+ VALUES (?, ?, ?, ?, ?, ?, ?)
39
+ """,
40
+ [
41
+ (
42
+ item.id,
43
+ item.title,
44
+ item.source_type,
45
+ item.filename,
46
+ item.raw_text,
47
+ item.created_on,
48
+ json.dumps(item.extracted),
49
+ )
50
+ for item in items
51
+ ],
52
+ )
53
+
54
+
55
+ def load_evidence(path: Path = DB_PATH) -> list[EvidenceItem]:
56
+ init_db(path)
57
+ with sqlite3.connect(path) as connection:
58
+ rows = connection.execute(
59
+ "SELECT id, title, source_type, filename, raw_text, created_on, extracted_json FROM evidence ORDER BY created_on, id"
60
+ ).fetchall()
61
+ return [
62
+ EvidenceItem(
63
+ id=row[0],
64
+ title=row[1],
65
+ source_type=row[2],
66
+ filename=row[3] or "",
67
+ raw_text=row[4],
68
+ created_on=row[5],
69
+ extracted=json.loads(row[6] or "{}"),
70
+ )
71
+ for row in rows
72
+ ]
data/sample_unpaid_wages_case.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "worker_name": "Ravi Kumar",
3
+ "case_type": "Unpaid wages",
4
+ "evidence": [
5
+ {
6
+ "id": "E001",
7
+ "title": "Hiring chat with promised daily wage",
8
+ "source_type": "chat",
9
+ "filename": "hiring_chat.txt",
10
+ "created_on": "2026-05-11",
11
+ "raw_text": "March 2, 2026. Supervisor Amit: Come from tomorrow for delivery sorting work. Salary will be Rs 850 per day plus overtime after 9 hours. Ravi: I can join from March 3. Supervisor Amit: Yes, keep screenshots of your shifts if app does not show attendance."
12
+ },
13
+ {
14
+ "id": "E002",
15
+ "title": "Shift log copied from notebook",
16
+ "source_type": "shift log",
17
+ "filename": "shift_log.txt",
18
+ "created_on": "2026-05-11",
19
+ "raw_text": "Ravi Kumar shift notes: March 3, 2026 worked 10 hours. March 4, 2026 worked 11 hours. March 5, 2026 worked 10 hours. March 6, 2026 worked 9 hours. March 7, 2026 worked 12 hours. No weekly payment received. Expected base amount is Rs 4,250 plus overtime."
20
+ },
21
+ {
22
+ "id": "E003",
23
+ "title": "Payment screenshot transcript",
24
+ "source_type": "payment",
25
+ "filename": "upi_screenshot_text.txt",
26
+ "created_on": "2026-05-11",
27
+ "raw_text": "UPI transaction from QuickKart Logistics to Ravi Kumar on March 10, 2026: Rs 1,500 received. Note: partial settlement. Ravi phone number visible: +91 98765 43210."
28
+ },
29
+ {
30
+ "id": "E004",
31
+ "title": "Employer follow-up message",
32
+ "source_type": "chat",
33
+ "filename": "followup_chat.txt",
34
+ "created_on": "2026-05-11",
35
+ "raw_text": "March 12, 2026. Ravi: Sir, I worked five days and got only Rs 1,500. Balance salary and overtime are pending. Supervisor Amit: Accounts will pay tomorrow. Do not complain in the group. March 15, 2026. Ravi: Still no payment. Supervisor Amit: If you keep asking, do not come for next shifts."
36
+ },
37
+ {
38
+ "id": "E005",
39
+ "title": "Voice note transcript to worker group",
40
+ "source_type": "voice transcript",
41
+ "filename": "voice_note_transcript.txt",
42
+ "created_on": "2026-05-11",
43
+ "raw_text": "Ravi voice note on March 16, 2026: I need help. I worked at QuickKart Logistics from March 3 to March 7. They promised Rs 850 per day and overtime. I only received Rs 1,500. I am missing at least Rs 2,750 plus overtime. I have chat screenshots and the UPI screenshot."
44
+ }
45
+ ]
46
+ }
data/worker_rights_guide.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Worker Rights Guide Context
2
+
3
+ This demo guide is intentionally general and should be replaced with jurisdiction-specific guidance before real deployment.
4
+
5
+ Workers and advocates should preserve original evidence, including screenshots, messages, payment records, shift records, identity documents, and written employer responses. The strongest wage claim packets usually include proof of the agreed wage, proof that work was performed, proof of partial or missing payment, and a dated request for payment.
6
+
7
+ For safety, workers should avoid public posting of private phone numbers, addresses, health information, and identity documents. If retaliation or threats are present, the matter should be reviewed by a trusted union, NGO, legal aid group, or official worker support channel before direct confrontation.
8
+
9
+ ProofPrint does not provide legal advice. It helps organize evidence, identify missing proof, and prepare a structured handoff packet for qualified human review.
docs/kaggle_submission_checklist.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ProofPrint Kaggle Submission Checklist
2
+
3
+ ## Done locally
4
+
5
+ - Working app prototype: Streamlit app in `proofprint/app.py`
6
+ - Local Gemma 4 inference: `gemma4:e2b` through Ollama
7
+ - No fallback packet generation: app requires the configured Gemma model
8
+ - Sample case: unpaid-wages worker evidence in `data/sample_unpaid_wages_case.json`
9
+ - Evidence workflow: manual intake, text upload, local storage, packet builder
10
+ - Generated outputs: timeline, evidence table, missing-evidence checks, risk flags, advocate packet, complaint draft
11
+ - Project docs: `README.md`, `docs/writeup_draft.md`, `docs/submission_strategy.md`
12
+
13
+ ## Still needed before Kaggle submission
14
+
15
+ - Public code repository
16
+ - Public live demo URL
17
+ - YouTube video under 3 minutes
18
+ - Kaggle writeup under 1,500 words
19
+ - Media Gallery cover image and screenshots
20
+ - Final Kaggle Writeup submission before the deadline
21
+
22
+ ## Recommended track choices
23
+
24
+ - Primary: Digital Equity & Inclusivity
25
+ - Secondary: Safety & Trust
26
+ - Special Technology Track: Ollama
27
+
28
+ ## Public repository checklist
29
+
30
+ - Create a GitHub repository named `proofprint`
31
+ - Push the `proofprint` folder contents
32
+ - Include clear setup instructions from `README.md`
33
+ - Do not commit real private evidence
34
+ - Keep the sample case synthetic
35
+
36
+ ## Live demo checklist
37
+
38
+ Use one of these free options:
39
+
40
+ - Streamlit Community Cloud
41
+ - Hugging Face Spaces with Streamlit
42
+
43
+ For public demo reliability, deploy with the sample synthetic case. If the free host cannot run Ollama/Gemma locally, clearly label the hosted page as a UI demo and include the local Gemma video proof. The strongest submission should still show local Gemma running in the video.
44
+
45
+ ## Video checklist
46
+
47
+ Keep it under 3 minutes:
48
+
49
+ 1. Show the problem: scattered proof from an informal worker.
50
+ 2. Open ProofPrint and load the sample case.
51
+ 3. Build the packet using `gemma4:e2b`.
52
+ 4. Show timeline, evidence index, missing proof, risk flags, and complaint draft.
53
+ 5. Explain local privacy: worker evidence stays on device.
54
+ 6. End with impact: advocates get a clean packet faster.
55
+
56
+ ## Kaggle writeup checklist
57
+
58
+ Include:
59
+
60
+ - Problem and target users
61
+ - Why Gemma 4 is central
62
+ - Architecture
63
+ - Local-first/privacy explanation
64
+ - Demo workflow
65
+ - Limitations and safety boundary
66
+ - Links to GitHub, video, and live demo
67
+
68
+ Keep the writeup under 1,500 words.
docs/submission_strategy.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ProofPrint Submission Strategy
2
+
3
+ ## One-line pitch
4
+
5
+ ProofPrint turns scattered worker evidence into a private, structured, advocate-ready packet so informal workers can get help faster.
6
+
7
+ ## Problem
8
+
9
+ Informal workers often do not have contracts, HR systems, or clean payroll records. When wages are withheld or unsafe work is ignored, their proof is spread across chats, payment screenshots, shift notes, voice messages, and receipts. Advocates must reconstruct the case manually before taking action.
10
+
11
+ ## Solution
12
+
13
+ ProofPrint gives workers and advocates a local-first evidence organizer. It extracts dates, amounts, people, promises, harms, and risks; builds a timeline; identifies missing evidence; and drafts a complaint or NGO handoff packet.
14
+
15
+ ## Why Gemma 4
16
+
17
+ Gemma 4 is valuable here because the evidence is sensitive and often messy. Local inference keeps private worker data on device. Multimodal reasoning can support screenshots, receipts, forms, and document photos. Function-style checks help turn model output into reliable structured workflow steps.
18
+
19
+ ## Architecture
20
+
21
+ - Streamlit frontend for evidence intake and packet review
22
+ - Local Python analysis pipeline
23
+ - Ollama-compatible Gemma client
24
+ - Gemma-required generation path for authentic local inference
25
+ - SQLite local evidence store
26
+ - Markdown packet export
27
+ - Local rights guide used as grounded context
28
+
29
+ ## Demo story
30
+
31
+ Ravi is a delivery sorting worker who was promised Rs 850 per day plus overtime. After five shifts, he received only Rs 1,500 and was warned not to complain. His evidence is scattered across chat screenshots, shift notes, UPI payment text, and a voice note. ProofPrint turns that material into a timeline, missing evidence checklist, privacy warning, and advocate packet.
32
+
33
+ ## Judging fit
34
+
35
+ Impact & Vision:
36
+ ProofPrint improves access to justice for workers who cannot easily prove what happened.
37
+
38
+ Video Pitch & Storytelling:
39
+ The demo has a clear human story: scattered proof becomes usable power.
40
+
41
+ Technical Depth:
42
+ The project uses local model inference, structured extraction, privacy flags, grounded drafting, and a functional packet export workflow.
43
+
44
+ ## Video outline under 3 minutes
45
+
46
+ 1. 0:00-0:25 - Show the problem: scattered screenshots, payment notes, and threats.
47
+ 2. 0:25-0:45 - Introduce ProofPrint as a local-first evidence kit.
48
+ 3. 0:45-1:40 - Run the sample case and build the packet.
49
+ 4. 1:40-2:20 - Show timeline, missing evidence, risk flags, and complaint draft.
50
+ 5. 2:20-2:50 - Explain Gemma 4 architecture and privacy value.
51
+ 6. 2:50-3:00 - Close with impact: clean evidence helps people reach real advocates.
52
+
53
+ ## What to submit
54
+
55
+ - Kaggle writeup under 1,500 words
56
+ - YouTube video under 3 minutes
57
+ - Public GitHub repository
58
+ - Live Streamlit or Hugging Face Spaces demo
59
+ - Cover image and screenshots in Media Gallery
docs/writeup_draft.md ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ProofPrint: Local Evidence Kits for Informal Workers
2
+
3
+ ## Subtitle
4
+
5
+ A private Gemma 4 app that turns scattered worker evidence into advocate-ready case packets.
6
+
7
+ ## Writeup
8
+
9
+ Informal workers often lose access to justice before anyone reviews the merits of their case. Their proof may exist, but it is scattered across WhatsApp messages, payment screenshots, handwritten shift notes, receipts, voice notes, and photos. A worker may know exactly what happened, yet still struggle to answer the questions an advocate needs first: What was promised? When did the work happen? What was paid? What is missing? Is there retaliation risk? Which evidence should be protected before it is shared?
10
+
11
+ ProofPrint is a local-first Gemma 4 application for that gap. It helps informal workers, unions, worker centers, legal-aid clinics, and NGOs turn messy evidence into a structured first-review packet. The app extracts key facts from evidence fragments, builds a timeline, creates an evidence index, identifies missing proof, flags privacy and safety risks, and drafts a complaint or advocate handoff note.
12
+
13
+ The demo follows Ravi, a delivery sorting worker who was promised Rs 850 per day plus overtime. After five shifts, he received only Rs 1,500 and was warned not to complain. His evidence is fragmented across a hiring chat, shift notes, a payment transcript, follow-up messages, and a voice note. ProofPrint turns those fragments into a packet an advocate can review quickly.
14
+
15
+ Gemma 4 is central to the product because this workflow requires reasoning over sensitive, messy, real-world material. Worker evidence can contain phone numbers, payment records, health details, threats, and personal identifiers. Sending that material to a hosted model is often inappropriate. ProofPrint runs Gemma 4 locally through Ollama, keeping the evidence workflow on device while still enabling structured extraction, summarization, timeline reasoning, and grounded packet drafting.
16
+
17
+ The current prototype uses `gemma4:e2b` with Ollama on a local laptop. The Streamlit interface handles evidence intake and packet review. The Python backend calls the local Gemma model for each evidence item and requires valid structured output before building the packet. A local analysis layer then assembles the timeline, evidence table, missing-evidence checklist, risk flags, privacy notes, advocate packet, and complaint draft. SQLite is used for local evidence storage, and the final packet can be exported as Markdown.
18
+
19
+ ProofPrint intentionally avoids silent fallback generation. If the configured Gemma model is not reachable, the app refuses to generate a packet. This keeps the demo honest: the submitted workflow depends on local Gemma inference, not a hidden substitute.
20
+
21
+ The safety boundary is also explicit. ProofPrint does not provide legal advice, verify evidence authenticity, or replace a lawyer or trained advocate. It organizes evidence and prepares a structured handoff for human review. This boundary matters because the goal is not to automate justice decisions. The goal is to help people reach real help with clearer facts, less delay, and less risk of exposing private information.
22
+
23
+ The real-world impact is practical. Worker centers and legal-aid teams often spend precious intake time reconstructing timelines from screenshots and notes. ProofPrint can reduce that first-pass burden and help workers preserve the right evidence before deadlines, retaliation, or data loss make a case harder to pursue. The same pattern can extend beyond unpaid wages to unsafe work, retaliation, denied benefits, disaster-related compensation, and other situations where people have proof but no clean way to organize it.
24
+
25
+ ProofPrint shows how local frontier intelligence can serve communities where privacy, trust, and access matter most. For informal workers, clean evidence is power. ProofPrint helps turn scattered proof into something a human advocate can act on.
exports/.gitkeep ADDED
@@ -0,0 +1 @@
 
 
1
+
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ streamlit>=1.33