File size: 13,076 Bytes
8998caa
 
 
 
 
 
a69a935
8998caa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a69a935
 
8998caa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69ff078
 
 
 
 
 
 
 
8998caa
69ff078
8998caa
 
 
 
 
 
 
 
 
69ff078
8998caa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
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
# ─────────────────────────────────────────────
@st.cache_resource
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")