Spaces:
Sleeping
Sleeping
Download app.py from Sumayyea/DocumentClassifier: direct link, hf CLI and curl.
- Browser
- Download file 13.1 kB
-
https://huggingface.co/spaces/Sumayyea/DocumentClassifier/resolve/main/app.py
- Command line
-
hf download hf://spaces/Sumayyea/DocumentClassifier/app.py
-
curl -L -o app.py https://huggingface.co/spaces/Sumayyea/DocumentClassifier/resolve/main/app.py
13.1 kB
| import os, re, json, time, hashlib | |
| from io import BytesIO | |
| from concurrent.futures import ThreadPoolExecutor, as_completed | |
| import pandas as pd | |
| import requests | |
| import streamlit as st | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 1. CONFIGURATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| CATEGORIES = { | |
| "ADMINISTRATIVE": {"desc": "Notifications, circulars, office orders, memos, meeting minutes, policy instructions", | |
| "keywords": ["memo", "circular", "notification", "order", "policy", "minutes", "meeting", "instruction"]}, | |
| "FINANCIAL": {"desc": "Budget, expenditure, audit, payments, bills, revenue, re-appropriation, PC-1 costing", | |
| "keywords": ["budget", "expenditure", "audit", "payment", "revenue", "bill", "fund", "grant", "rupees", "allocation"]}, | |
| "LEGAL": {"desc": "Contracts, agreements, court cases, writ petitions, judgments, legal opinions", | |
| "keywords": ["contract", "agreement", "legal", "court", "judgment", "petition", "writ", "tribunal", "litigation"]}, | |
| "HR": {"desc": "Recruitment, promotion, transfer/posting, leave, pension, disciplinary matters", | |
| "keywords": ["recruitment", "promotion", "transfer", "posting", "leave", "employee", "pension", "appointment", "seniority"]}, | |
| "TECHNICAL": {"desc": "Tenders, specifications, development projects, IT systems, infrastructure", | |
| "keywords": ["specification", "tender", "project", "development", "infrastructure", "procurement", "software", "construction"]}, | |
| "CITIZEN_SERVICE": {"desc": "Citizen applications, complaints, NOCs, requests, grievances", | |
| "keywords": ["complaint", "grievance", "noc", "objection", "applicant", "citizen", "request", "redressal"]}, | |
| } | |
| OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" | |
| DEFAULT_MODEL = "openai/gpt-4o-mini" # check current IDs at openrouter.ai/models | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 2. RULE-BASED ENGINE (optimized v1) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class RuleClassifier: | |
| """Precompiled, word-boundary regex -> no false hits ('order' in 'border').""" | |
| def __init__(self, categories): | |
| self.patterns = { | |
| cat: re.compile(r"\b(?:" + "|".join(map(re.escape, v["keywords"])) + r")\w*\b", re.IGNORECASE) | |
| for cat, v in categories.items() | |
| } | |
| def classify(self, text): | |
| hits = {cat: len(p.findall(text)) for cat, p in self.patterns.items()} | |
| total = sum(hits.values()) | |
| if total == 0: | |
| return {"category": "UNCLASSIFIED", "confidence": 0.0, "scores": hits} | |
| best = max(hits, key=hits.get) | |
| return {"category": best, "confidence": round(hits[best] / total, 2), "scores": hits} | |
| MONTHS = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*" | |
| DATE_RE = re.compile( | |
| rf"\b(?P<d>\d{{1,2}}(?P<s>[./-])\d{{1,2}}(?P=s)\d{{2,4}}" | |
| rf"|\d{{1,2}}(?:st|nd|rd|th)?\s+{MONTHS},?\s+\d{{4}}" | |
| rf"|{MONTHS}\s+\d{{1,2}},?\s+\d{{4}})\b", re.IGNORECASE) | |
| # Reference must contain a digit -> "No objection" is NOT a reference | |
| REF_RE = re.compile(r"\b(?:Ref(?:erence)?|File|Letter|Endst|No)\.?\s*(?:No\.?)?\s*[:#]?\s*" | |
| r"([A-Z0-9][\w/&().\-]*\d[\w/&().\-]*)", re.IGNORECASE) | |
| def extract_metadata(text): | |
| d, r = DATE_RE.search(text), REF_RE.search(text) | |
| return {"date": d.group("d") if d else None, "reference_no": r.group(1).rstrip(".") if r else None} | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 3. PRIVACY: redact before sending to external AI | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| PII_PATTERNS = { | |
| "CNIC": re.compile(r"\b\d{5}-?\d{7}-?\d\b"), | |
| "PHONE": re.compile(r"(?:\+92|\b0)3\d{2}[-\s]?\d{7}\b"), | |
| "EMAIL": re.compile(r"[\w.+-]+@[\w-]+\.[\w.]+"), | |
| } | |
| def redact(text): | |
| for label, pattern in PII_PATTERNS.items(): | |
| text = pattern.sub(f"[{label}]", text) | |
| return text | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 4. TEXT EXTRACTION (txt / pdf / docx) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def extract_text(name, data): | |
| ext = name.lower().rsplit(".", 1)[-1] | |
| if ext == "txt": | |
| return data.decode("utf-8", errors="ignore") | |
| if ext == "pdf": | |
| from pypdf import PdfReader | |
| return "\n".join((p.extract_text() or "") for p in PdfReader(BytesIO(data)).pages) | |
| if ext == "docx": | |
| from docx import Document | |
| return "\n".join(p.text for p in Document(BytesIO(data)).paragraphs) | |
| return "" | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 5. AI ENGINE (OpenRouter) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| CAT_LIST = "\n".join(f"- {c}: {v['desc']}" for c, v in CATEGORIES.items()) | |
| SYSTEM_PROMPT = f"""You are a records officer AI for the Government of Khyber Pakhtunkhwa, Pakistan. | |
| Classify the document into exactly ONE category: | |
| {CAT_LIST} | |
| Documents may be English, Urdu or mixed, and may contain OCR errors. | |
| Return ONLY valid JSON with these keys: | |
| {{"category": "<one of the categories>", "confidence": <0.0-1.0>, "reason": "<one sentence>", | |
| "subject": "<short subject line>", "summary": "<max 2 sentences>", | |
| "date": "<date or null>", "reference_no": "<file/letter no. or null>", | |
| "department": "<issuing department or null>", "urgency": "Immediate|High|Normal", | |
| "action_required": "<what the receiving officer must do, or null>", "language": "English|Urdu|Mixed"}}""" | |
| def parse_json(content): | |
| content = re.sub(r"```(?:json)?", "", content).strip() | |
| start, end = content.find("{"), content.rfind("}") | |
| return json.loads(content[start:end + 1]) | |
| def llm_classify(text, api_key, model, max_chars=6000): | |
| headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", | |
| "HTTP-Referer": "http://localhost:8501", "X-Title": "KP Document Classifier"} | |
| payload = { | |
| "model": model, "temperature": 0, | |
| "response_format": {"type": "json_object"}, | |
| "messages": [{"role": "system", "content": SYSTEM_PROMPT}, | |
| {"role": "user", "content": text[:max_chars]}], # truncate = faster + cheaper | |
| } | |
| for attempt in range(3): # retry on rate-limit / server error | |
| r = requests.post(OPENROUTER_URL, headers=headers, json=payload, timeout=60) | |
| if r.status_code == 429 or r.status_code >= 500: | |
| time.sleep(2 ** attempt) | |
| continue | |
| r.raise_for_status() | |
| data = parse_json(r.json()["choices"][0]["message"]["content"]) | |
| if data.get("category") not in CATEGORIES: | |
| data["category"] = "UNCLASSIFIED" | |
| data["confidence"] = max(0.0, min(1.0, float(data.get("confidence", 0)))) | |
| return data | |
| raise RuntimeError("OpenRouter busy β retried 3 times") | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 6. HYBRID PIPELINE | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def get_engine(): | |
| return RuleClassifier(CATEGORIES), {} # {} = AI response cache (survives reruns) | |
| def classify_one(name, text, cfg, rules, cache): | |
| t0 = time.perf_counter() | |
| rule, meta = rules.classify(text), extract_metadata(text) | |
| res = {"file": name, "words": len(text.split()), "method": "Rule-based", | |
| "category": rule["category"], "confidence": rule["confidence"], | |
| "rule_scores": rule["scores"], **meta} | |
| if not text.strip(): | |
| res["method"] = "Skipped (no text β run OCR Processor first)" | |
| elif cfg["use_ai"] and cfg["api_key"] and (cfg["always_ai"] or rule["confidence"] < cfg["threshold"]): | |
| safe_text = redact(text) if cfg["redact"] else text | |
| key = hashlib.sha256((cfg["model"] + safe_text).encode()).hexdigest() | |
| try: | |
| ai = cache.get(key) or llm_classify(safe_text, cfg["api_key"], cfg["model"]) | |
| cache[key] = ai | |
| res.update({k: v for k, v in ai.items() if v not in (None, "", "null")}) | |
| res["method"] = "AI (OpenRouter)" | |
| except Exception as e: | |
| res["method"], res["error"] = "Rule-based (AI failed)", str(e) | |
| res["latency_s"] = round(time.perf_counter() - t0, 2) | |
| return res | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # 7. STREAMLIT UI | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| def default_key(): | |
| try: | |
| return st.secrets["OPENROUTER_API_KEY"] | |
| except Exception: | |
| return os.getenv("OPENROUTER_API_KEY", "") | |
| st.set_page_config(page_title="AI Document Classifier v2", page_icon="π", layout="wide") | |
| st.title("π AI Document Classifier v2") | |
| st.caption("Hybrid Rule-based + AI (OpenRouter) β’ KP Digital Roadmap 2030 β Paperless Government Processes") | |
| with st.sidebar: | |
| st.header("βοΈ Settings") | |
| api_key = default_key() # loaded silently β never rendered | |
| if api_key: | |
| st.success("π API key loaded securely") | |
| else: | |
| api_key = st.text_input("OpenRouter API Key", type="password", | |
| placeholder="Paste key (not saved)") | |
| cfg = { | |
| "api_key": api_key, | |
| "model": st.text_input("Model", value=DEFAULT_MODEL), | |
| "use_ai": st.toggle("Use AI", value=True), | |
| "always_ai": st.toggle("Always use AI (else only when rules unsure)", value=False), | |
| "threshold": st.slider("Rule confidence needed to skip AI", 0.3, 1.0, 0.7, 0.05), | |
| "redact": st.toggle("π Redact CNIC / phone / email before AI", value=True), | |
| "workers": st.slider("Parallel workers", 1, 10, 4), | |
| } | |
| if cfg["use_ai"] and not cfg["api_key"]: | |
| st.warning("No API key β running rule-based only.") | |
| files = st.file_uploader("Upload documents", type=["txt", "pdf", "docx"], accept_multiple_files=True) | |
| if files and st.button("π Classify", type="primary"): | |
| rules, cache = get_engine() | |
| docs = {f.name: extract_text(f.name, f.getvalue()) for f in files} | |
| results, bar, t0 = [], st.progress(0.0, "Classifying..."), time.perf_counter() | |
| with ThreadPoolExecutor(max_workers=cfg["workers"]) as pool: | |
| futures = [pool.submit(classify_one, n, t, cfg, rules, cache) for n, t in docs.items()] | |
| for i, fut in enumerate(as_completed(futures), 1): | |
| results.append(fut.result()) | |
| bar.progress(i / len(futures), f"{i}/{len(futures)} done") | |
| total_time = time.perf_counter() - t0 | |
| df = pd.DataFrame(results) | |
| c1, c2, c3, c4 = st.columns(4) | |
| c1.metric("Documents", len(df)) | |
| c2.metric("AI calls", int(df["method"].eq("AI (OpenRouter)").sum())) | |
| c3.metric("Total time", f"{total_time:.1f}s") | |
| c4.metric("Avg confidence", f"{df['confidence'].mean():.0%}") | |
| cols = [c for c in ["file", "category", "confidence", "method", "subject", "urgency", | |
| "date", "reference_no", "department", "action_required", "latency_s"] if c in df] | |
| st.dataframe(df[cols], use_container_width=True, hide_index=True) | |
| st.bar_chart(df["category"].value_counts()) | |
| low = df[df["confidence"] < 0.6] | |
| if not low.empty: | |
| st.warning(f"π€ {len(low)} document(s) below 60% confidence β route for human review.") | |
| for r in results: | |
| with st.expander(f"π {r['file']} β {r['category']} ({r['confidence']:.0%})"): | |
| if r.get("summary"): st.info(r["summary"]) | |
| if r.get("reason"): st.caption(f"Why: {r['reason']}") | |
| if r.get("error"): st.error(r["error"]) | |
| st.json({k: v for k, v in r.items() if k not in ("summary", "reason")}) | |
| st.text_area("Text", docs[r["file"]][:3000], height=150, key=f"t_{r['file']}") | |
| st.download_button("β¬οΈ Download results (CSV)", df[cols].to_csv(index=False).encode("utf-8"), | |
| "classification_results.csv", "text/csv") |