proofprint / core /analyzer.py
HARSH SHUKLA
Initial ProofPrint hackathon prototype
5cabf52
Raw
History Blame Contribute Delete
13.6 kB
from __future__ import annotations
import re
from collections import Counter
from datetime import date
from typing import Any
from .llm import LocalGemmaClient, extract_json_block
from .models import CasePacket, EvidenceItem, TimelineEvent
DATE_PATTERN = re.compile(
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",
re.IGNORECASE,
)
AMOUNT_PATTERN = re.compile(r"(?:Rs\.?|INR|₹|\$)\s?[\d,]+(?:\.\d{1,2})?|\b\d{3,6}\s?(?:rupees|rs)\b", re.IGNORECASE)
PHONE_PATTERN = re.compile(r"\b(?:\+?\d[\d -]{8,}\d)\b")
EMAIL_PATTERN = re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.IGNORECASE)
CASE_TYPES = {
"Unpaid wages": ["salary", "wage", "payment", "paid", "unpaid", "shift", "overtime", "invoice"],
"Unsafe work": ["injury", "unsafe", "accident", "protective", "helmet", "gloves", "hazard"],
"Harassment or retaliation": ["threat", "harass", "retaliation", "fired", "terminated", "abuse"],
"Denied benefit": ["claim", "benefit", "denied", "rejected", "eligibility", "scheme"],
}
def classify_case(text: str) -> str:
lowered = text.lower()
scores = {
case_type: sum(1 for keyword in keywords if keyword in lowered)
for case_type, keywords in CASE_TYPES.items()
}
best, score = max(scores.items(), key=lambda pair: pair[1])
return best if score else "Worker evidence packet"
def extract_entities(text: str) -> dict[str, Any]:
dates = DATE_PATTERN.findall(text)
amounts = AMOUNT_PATTERN.findall(text)
phones = PHONE_PATTERN.findall(text)
emails = EMAIL_PATTERN.findall(text)
words = re.findall(r"\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+){0,2}\b", text)
names = [word for word, count in Counter(words).most_common(8) if word.lower() not in {"I", "The", "This"}]
return {
"dates": sorted(set(dates)),
"amounts": sorted(set(amounts)),
"contacts": sorted(set(phones + emails)),
"possible_people_or_orgs": names,
}
def analyze_evidence_item(item: EvidenceItem, llm: LocalGemmaClient) -> tuple[EvidenceItem, str]:
prompt = f"""
You are ProofPrint, a local-first evidence organizer for worker advocates.
Extract only facts supported by the evidence. Return valid compact JSON only, with keys:
case_type, one_sentence_summary, dates, amounts, people_or_orgs, promises, harms, contradictions, sensitivity.
Use arrays for dates, amounts, people_or_orgs, promises, harms, and contradictions.
Evidence title: {item.title}
Evidence text:
{item.raw_text[:5000]}
"""
response, status = llm.generate(prompt)
if not response:
raise RuntimeError(f"Gemma analysis failed for {item.id}: {status}")
parsed = extract_json_block(response)
if not parsed:
raise RuntimeError(f"Gemma did not return valid JSON for {item.id}. Raw response: {response[:400]}")
item.extracted = {
"case_type": str(parsed.get("case_type") or "Worker evidence packet"),
"one_sentence_summary": str(parsed.get("one_sentence_summary") or ""),
"dates": ensure_list(parsed.get("dates")),
"amounts": ensure_list(parsed.get("amounts")),
"people_or_orgs": ensure_list(parsed.get("people_or_orgs")),
"promises": ensure_list(parsed.get("promises")),
"harms": ensure_list(parsed.get("harms")),
"contradictions": ensure_list(parsed.get("contradictions")),
"sensitivity": str(parsed.get("sensitivity") or "unknown"),
}
return item, status
def ensure_list(value: Any) -> list[Any]:
if value is None:
return []
if isinstance(value, list):
return value
return [value]
def summarize_text(text: str) -> str:
clean = " ".join(text.split())
if len(clean) <= 220:
return clean
return clean[:217].rsplit(" ", 1)[0] + "..."
def find_lines(text: str, keywords: list[str]) -> list[str]:
lines = [line.strip() for line in re.split(r"[\n\r.]+", text) if line.strip()]
matches = []
for line in lines:
lowered = line.lower()
if any(keyword in lowered for keyword in keywords):
matches.append(line[:240])
return matches[:5]
def infer_sensitivity(text: str) -> str:
lowered = text.lower()
if EMAIL_PATTERN.search(text) or PHONE_PATTERN.search(text):
return "high: contains personal contact information"
if any(word in lowered for word in ["injury", "medical", "threat", "harass"]):
return "high: contains safety or health details"
if AMOUNT_PATTERN.search(text):
return "medium: contains payment details"
return "low"
def build_packet(
worker_name: str,
evidence_items: list[EvidenceItem],
llm: LocalGemmaClient,
rights_guide: str,
) -> CasePacket:
if not llm.available():
raise RuntimeError(
f"Local Gemma model '{llm.model}' is not reachable through Ollama. "
"Start Ollama and pull the configured Gemma model before building a packet."
)
analyzed = []
statuses = []
for item in evidence_items:
analyzed_item, status = analyze_evidence_item(item, llm)
analyzed.append(analyzed_item)
statuses.append(status)
full_text = "\n\n".join(item.raw_text for item in analyzed)
case_type = classify_case(full_text)
timeline = build_timeline(analyzed)
evidence_table = build_evidence_table(analyzed)
missing = missing_evidence_for(case_type, analyzed)
risks = risk_flags_for(analyzed)
privacy = privacy_notes_for(analyzed)
summary = build_case_summary(worker_name, case_type, analyzed)
advocate_packet = build_advocate_packet(worker_name, case_type, summary, timeline, evidence_table, missing, risks, rights_guide)
complaint_draft = build_complaint_draft(worker_name, case_type, summary, timeline, missing)
return CasePacket(
worker_name=worker_name or "Worker",
case_type=case_type,
summary=summary,
timeline=timeline,
evidence_table=evidence_table,
missing_evidence=missing,
risk_flags=risks,
advocate_packet=advocate_packet,
complaint_draft=complaint_draft,
privacy_notes=privacy,
model_status=most_relevant_status(statuses),
)
def build_timeline(items: list[EvidenceItem]) -> list[TimelineEvent]:
events: list[TimelineEvent] = []
for item in items:
dates = item.extracted.get("dates") or [item.created_on]
amounts = item.extracted.get("amounts") or []
people = item.extracted.get("people_or_orgs") or []
summary = item.extracted.get("one_sentence_summary") or summarize_text(item.raw_text)
for event_date in dates[:3]:
events.append(
TimelineEvent(
date=str(event_date),
event=summary,
evidence_ids=[item.id],
amount=", ".join(map(str, amounts[:3])),
people=[str(person) for person in people[:4]],
)
)
return sorted(events, key=lambda event: event.date)[:12]
def build_evidence_table(items: list[EvidenceItem]) -> list[dict[str, str]]:
rows = []
for item in items:
extracted = item.extracted
rows.append(
{
"id": item.id,
"title": item.title,
"type": item.source_type,
"key_dates": ", ".join(map(str, extracted.get("dates", [])[:4])) or "Not found",
"amounts": ", ".join(map(str, extracted.get("amounts", [])[:4])) or "Not found",
"summary": str(extracted.get("one_sentence_summary", "")),
"sensitivity": str(extracted.get("sensitivity", infer_sensitivity(item.raw_text))),
}
)
return rows
def missing_evidence_for(case_type: str, items: list[EvidenceItem]) -> list[str]:
text = "\n".join(item.raw_text.lower() for item in items)
checks = {
"Unpaid wages": [
("employment terms or hiring chat", ["hired", "contract", "joining", "rate", "salary"]),
("work dates or shift records", ["shift", "attendance", "worked", "hours"]),
("payment proof or bank/UPI screenshot", ["upi", "bank", "paid", "payment", "transfer"]),
("final demand or employer response", ["asked", "request", "reply", "will pay", "refused"]),
],
"Unsafe work": [
("photo or record of the hazard", ["photo", "unsafe", "hazard", "broken"]),
("injury or incident date", ["injury", "accident", "hurt", "hospital"]),
("manager notification proof", ["reported", "informed", "manager", "supervisor"]),
],
"Harassment or retaliation": [
("original message or witness note", ["message", "witness", "threat", "abuse"]),
("timeline of retaliation", ["fired", "removed", "blocked", "terminated"]),
],
"Denied benefit": [
("denial letter or rejection message", ["denied", "rejected", "not eligible"]),
("eligibility proof", ["eligible", "income", "identity", "document"]),
],
}
selected = checks.get(case_type, checks["Unpaid wages"])
missing = [label for label, keywords in selected if not any(keyword in text for keyword in keywords)]
return missing or ["No obvious missing evidence detected. Advocate should still verify authenticity and deadlines."]
def risk_flags_for(items: list[EvidenceItem]) -> list[str]:
text = "\n".join(item.raw_text.lower() for item in items)
flags = []
if "threat" in text or "abuse" in text:
flags.append("Possible retaliation or safety risk. Escalate to a trusted advocate before direct confrontation.")
if "injury" in text or "hospital" in text:
flags.append("Health or injury details detected. Preserve medical records and avoid public sharing.")
if not DATE_PATTERN.search(text):
flags.append("No clear dates found. Timeline needs stronger date evidence.")
if not AMOUNT_PATTERN.search(text):
flags.append("No clear money amounts found. Payment claim may need wage or invoice proof.")
if PHONE_PATTERN.search(text) or EMAIL_PATTERN.search(text):
flags.append("Personal contact details detected. Redact before public upload.")
return flags or ["No major risk flags detected from the provided evidence."]
def privacy_notes_for(items: list[EvidenceItem]) -> list[str]:
notes = [
"Keep original evidence files unchanged.",
"Share the generated packet only with a trusted advocate, union, NGO, or official channel.",
]
if any("high" in str(item.extracted.get("sensitivity", "")) for item in items):
notes.append("High-sensitivity evidence detected. Redact phone numbers, addresses, and health details for public demos.")
return notes
def build_case_summary(worker_name: str, case_type: str, items: list[EvidenceItem]) -> str:
summaries = [str(item.extracted.get("one_sentence_summary", "")) for item in items if item.extracted]
name = worker_name or "The worker"
return f"{name} appears to have a {case_type.lower()} matter supported by {len(items)} evidence item(s). " + " ".join(summaries[:3])
def build_advocate_packet(
worker_name: str,
case_type: str,
summary: str,
timeline: list[TimelineEvent],
evidence_table: list[dict[str, str]],
missing: list[str],
risks: list[str],
rights_guide: str,
) -> str:
timeline_lines = "\n".join(
f"- {event.date}: {event.event} Evidence: {', '.join(event.evidence_ids)} Amount: {event.amount or 'N/A'}"
for event in timeline
)
evidence_lines = "\n".join(
f"- {row['id']} | {row['title']} | {row['type']} | Dates: {row['key_dates']} | Amounts: {row['amounts']}"
for row in evidence_table
)
missing_lines = "\n".join(f"- {item}" for item in missing)
risk_lines = "\n".join(f"- {item}" for item in risks)
guide_excerpt = rights_guide[:900].strip()
return f"""# ProofPrint Advocate Packet
Generated: {date.today().isoformat()}
Worker: {worker_name or "Worker"}
Case type: {case_type}
## Plain-language summary
{summary}
## Timeline
{timeline_lines or "- No dated events found yet."}
## Evidence index
{evidence_lines}
## Missing evidence checklist
{missing_lines}
## Risk and privacy flags
{risk_lines}
## Local rights guide context
{guide_excerpt}
## Important limitation
ProofPrint organizes evidence and drafts support material. It does not provide legal advice, verify authenticity, or replace a qualified advocate.
"""
def build_complaint_draft(
worker_name: str,
case_type: str,
summary: str,
timeline: list[TimelineEvent],
missing: list[str],
) -> str:
timeline_text = "\n".join(f"- {event.date}: {event.event}" for event in timeline[:8])
missing_text = "\n".join(f"- {item}" for item in missing)
return f"""Subject: Request for assistance with {case_type.lower()}
I am requesting help reviewing a worker rights matter.
Worker: {worker_name or "Worker"}
Issue: {case_type}
Summary:
{summary}
Timeline:
{timeline_text or "- Dates are still being collected."}
Evidence still needed:
{missing_text}
I understand this draft is only an evidence organization aid and should be reviewed by a qualified advocate before submission.
"""
def most_relevant_status(statuses: list[str]) -> str:
for status in statuses:
if status.startswith("local Gemma"):
return status
return statuses[-1] if statuses else "local Gemma status unavailable"