Spaces:
Sleeping
Sleeping
| 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" | |