Praveen Kumar commited on
Commit
e1317bf
·
1 Parent(s): f2ca668

Fix: correct HuggingFace repo IDs for model download

Browse files
Files changed (5) hide show
  1. .gitignore +6 -0
  2. Dockerfile +30 -0
  3. requirements.txt +24 -0
  4. src/api.py +186 -0
  5. src/pipeline.py +636 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ data/
5
+ models/
6
+ *.db
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y \
6
+ gcc g++ curl \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir --upgrade pip && \
11
+ pip install --no-cache-dir -r requirements.txt
12
+
13
+ RUN python -m spacy download en_core_web_sm
14
+
15
+ COPY src/ ./src/
16
+
17
+ RUN mkdir -p models data
18
+
19
+ ARG HF_TOKEN
20
+ ENV HUGGING_FACE_HUB_TOKEN=$HF_TOKEN
21
+
22
+ RUN python -c "\
23
+ from huggingface_hub import snapshot_download; \
24
+ snapshot_download(repo_id='praveends/doculens-ner', local_dir='models/ner_model'); \
25
+ snapshot_download(repo_id='praveends/doculens-classifier', local_dir='models/classifier_model'); \
26
+ print('Models downloaded!')"
27
+
28
+ EXPOSE 7860
29
+
30
+ CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "7860"]
requirements.txt ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn==0.24.0
3
+ pydantic==2.5.0
4
+ python-dotenv==1.0.0
5
+ starlette>=0.40.0
6
+ requests==2.31.0
7
+ httpx==0.25.2
8
+ torch>=2.2.0
9
+ transformers>=4.40.0
10
+ sentence-transformers==2.7.0
11
+ spacy==3.7.4
12
+ langgraph==0.0.20
13
+ langchain==0.1.0
14
+ accelerate==0.25.0
15
+ sentencepiece==0.1.99
16
+ safetensors>=0.4.2
17
+ chromadb==0.4.18
18
+ pdfplumber==0.10.3
19
+ pypdf==3.17.1
20
+ pillow==10.1.0
21
+ numpy==1.26.2
22
+ pandas==2.1.3
23
+ scikit-learn==1.3.2
24
+ huggingface_hub>=0.20.0
src/api.py ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
+ from fastapi.middleware.cors import CORSMiddleware
3
+ from pydantic import BaseModel
4
+ from typing import List, Dict, Any
5
+ import sqlite3
6
+ import json
7
+ import os
8
+ import sys
9
+
10
+ sys.path.append(os.path.dirname(os.path.abspath(__file__)))
11
+ from pipeline import analyze_document, collection, embedder
12
+
13
+ # ---- App ----
14
+ app = FastAPI(
15
+ title="NLP Document Analyzer API",
16
+ description="Multi-task NLP pipeline — NER, Classification, Summarization, Semantic Search",
17
+ version="2.0.0"
18
+ )
19
+
20
+ app.add_middleware(
21
+ CORSMiddleware,
22
+ allow_origins=["*"],
23
+ allow_credentials=True,
24
+ allow_methods=["*"],
25
+ allow_headers=["*"]
26
+ )
27
+
28
+ # ---- SQLite ----
29
+ DB_PATH = "data/documents.db"
30
+
31
+ def init_db():
32
+ os.makedirs("data", exist_ok=True)
33
+ conn = sqlite3.connect(DB_PATH)
34
+ cursor = conn.cursor()
35
+ cursor.execute("""
36
+ CREATE TABLE IF NOT EXISTS documents (
37
+ id TEXT PRIMARY KEY,
38
+ text TEXT NOT NULL,
39
+ doc_type TEXT,
40
+ confidence REAL,
41
+ entities TEXT,
42
+ summary TEXT,
43
+ extracted_fields TEXT,
44
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
45
+ )
46
+ """)
47
+ conn.commit()
48
+ conn.close()
49
+
50
+ def save_document(result: Dict[str, Any], text: str):
51
+ conn = sqlite3.connect(DB_PATH)
52
+ cursor = conn.cursor()
53
+ cursor.execute("""
54
+ INSERT OR REPLACE INTO documents
55
+ (id, text, doc_type, confidence, entities, summary, extracted_fields)
56
+ VALUES (?, ?, ?, ?, ?, ?, ?)
57
+ """, (
58
+ result["doc_id"],
59
+ text,
60
+ result["doc_type"],
61
+ result["confidence"],
62
+ json.dumps(result["entities"]),
63
+ result["summary"],
64
+ json.dumps(result["extracted_fields"])
65
+ ))
66
+ conn.commit()
67
+ conn.close()
68
+
69
+ init_db()
70
+
71
+ # ---- Models ----
72
+ class DocumentRequest(BaseModel):
73
+ text: str
74
+
75
+ class EntityResponse(BaseModel):
76
+ text: str
77
+ type: str
78
+
79
+ class DocumentResponse(BaseModel):
80
+ doc_id: str
81
+ doc_type: str
82
+ confidence: float
83
+ entities: List[EntityResponse]
84
+ summary: str
85
+ extracted_fields: Dict[str, Any]
86
+
87
+ class SearchRequest(BaseModel):
88
+ query: str
89
+ n_results: int = 5
90
+
91
+ # ---- Endpoints ----
92
+
93
+ @app.get("/health")
94
+ def health():
95
+ return {"status": "healthy", "version": "2.0.0"}
96
+
97
+
98
+ @app.post("/analyze", response_model=DocumentResponse)
99
+ def analyze(request: DocumentRequest):
100
+ if not request.text.strip():
101
+ raise HTTPException(status_code=400, detail="Text cannot be empty")
102
+ if len(request.text) > 15000:
103
+ raise HTTPException(status_code=400, detail="Text too long — max 15,000 characters")
104
+ try:
105
+ result = analyze_document(request.text)
106
+ save_document(result, request.text)
107
+ return DocumentResponse(
108
+ doc_id=result["doc_id"],
109
+ doc_type=result["doc_type"],
110
+ confidence=result["confidence"],
111
+ entities=[EntityResponse(**e) for e in result["entities"]],
112
+ summary=result["summary"],
113
+ extracted_fields=result["extracted_fields"]
114
+ )
115
+ except Exception as e:
116
+ raise HTTPException(status_code=500, detail=str(e))
117
+
118
+
119
+ @app.get("/documents")
120
+ def get_documents():
121
+ try:
122
+ conn = sqlite3.connect(DB_PATH)
123
+ cursor = conn.cursor()
124
+ cursor.execute("SELECT id, text, doc_type, confidence, entities, summary, extracted_fields, created_at FROM documents ORDER BY created_at DESC")
125
+ rows = cursor.fetchall()
126
+ conn.close()
127
+ documents = []
128
+ for row in rows:
129
+ documents.append({
130
+ "doc_id": row[0],
131
+ "text_preview": row[1][:200],
132
+ "doc_type": row[2],
133
+ "confidence": row[3],
134
+ "entities": json.loads(row[4]),
135
+ "summary": row[5],
136
+ "extracted_fields": json.loads(row[6]),
137
+ "created_at": row[7]
138
+ })
139
+ return {"documents": documents, "total": len(documents)}
140
+ except Exception as e:
141
+ raise HTTPException(status_code=500, detail=str(e))
142
+
143
+
144
+ @app.post("/search")
145
+ def search(request: SearchRequest):
146
+ if not request.query.strip():
147
+ raise HTTPException(status_code=400, detail="Query cannot be empty")
148
+ try:
149
+ count = collection.count()
150
+ if count == 0:
151
+ return {"query": request.query, "results": [], "total": 0}
152
+ query_embedding = embedder.encode(request.query).tolist()
153
+ results = collection.query(
154
+ query_embeddings=[query_embedding],
155
+ n_results=min(request.n_results, count)
156
+ )
157
+ search_results = []
158
+ if results["documents"][0]:
159
+ for doc, meta in zip(results["documents"][0], results["metadatas"][0]):
160
+ search_results.append({
161
+ "text_preview": doc[:300],
162
+ "doc_type": meta.get("doc_type", ""),
163
+ "summary": meta.get("summary", "")
164
+ })
165
+ return {"query": request.query, "results": search_results, "total": len(search_results)}
166
+ except Exception as e:
167
+ raise HTTPException(status_code=500, detail=str(e))
168
+
169
+
170
+ @app.get("/stats")
171
+ def get_stats():
172
+ try:
173
+ conn = sqlite3.connect(DB_PATH)
174
+ cursor = conn.cursor()
175
+ cursor.execute("SELECT COUNT(*) FROM documents")
176
+ total = cursor.fetchone()[0]
177
+ cursor.execute("SELECT doc_type, COUNT(*) FROM documents GROUP BY doc_type ORDER BY COUNT(*) DESC")
178
+ type_counts = dict(cursor.fetchall())
179
+ conn.close()
180
+ return {
181
+ "total_documents": total,
182
+ "documents_by_type": type_counts,
183
+ "vector_store_count": collection.count()
184
+ }
185
+ except Exception as e:
186
+ raise HTTPException(status_code=500, detail=str(e))
src/pipeline.py ADDED
@@ -0,0 +1,636 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import re
3
+ import os
4
+ import json
5
+ from chromadb.config import Settings
6
+ from transformers import (
7
+ DistilBertTokenizerFast,
8
+ DistilBertForTokenClassification,
9
+ BertTokenizerFast,
10
+ BertForSequenceClassification,
11
+ pipeline as hf_pipeline
12
+ )
13
+ from langgraph.graph import StateGraph, END
14
+ from typing import TypedDict, List, Dict, Any
15
+ import chromadb
16
+ from sentence_transformers import SentenceTransformer
17
+ from dotenv import load_dotenv
18
+
19
+ load_dotenv()
20
+
21
+ # ---- Labels ----
22
+ NER_LABELS = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"]
23
+ CLASSIFIER_LABELS = ["World", "Sports", "Business", "Sci/Tech"]
24
+
25
+ # ---- Device ----
26
+ DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
27
+ print(f"Using device: {DEVICE}")
28
+
29
+ # ---- Load Models ----
30
+ print("Loading NER model...")
31
+ ner_tokenizer = DistilBertTokenizerFast.from_pretrained("models/ner_model")
32
+ ner_model = DistilBertForTokenClassification.from_pretrained("models/ner_model")
33
+ ner_model.to(DEVICE)
34
+ ner_model.eval()
35
+
36
+ print("Loading Classifier model...")
37
+ cls_tokenizer = BertTokenizerFast.from_pretrained("models/classifier_model")
38
+ cls_model = BertForSequenceClassification.from_pretrained("models/classifier_model")
39
+ cls_model.to(DEVICE)
40
+ cls_model.eval()
41
+
42
+ print("Loading Sentence Transformer...")
43
+ embedder = SentenceTransformer("all-MiniLM-L6-v2")
44
+
45
+ print("Loading DistilBART Summarizer...")
46
+ bart_summarizer = hf_pipeline(
47
+ task="summarization",
48
+ model="sshleifer/distilbart-cnn-12-6",
49
+ device=-1 # CPU
50
+ )
51
+
52
+ print("Setting up ChromaDB...")
53
+
54
+ chroma_client = chromadb.Client(
55
+ Settings(
56
+ anonymized_telemetry=False
57
+ )
58
+ )
59
+
60
+ collection = chroma_client.get_or_create_collection("documents")
61
+
62
+ print("All models loaded!")
63
+
64
+
65
+ # ================================================================
66
+ # DOCUMENT TYPE DETECTION — Rule-based first, ML fallback
67
+ # ================================================================
68
+ def detect_document_type(text: str) -> str:
69
+ text_lower = text.lower()
70
+
71
+ invoice_keywords = [
72
+ "invoice", "total due", "payment due", "invoice number",
73
+ "bill to", "ship to", "subtotal", "amount due",
74
+ "purchase order", "invoice date", "due date", "receipt",
75
+ "gstin", "hsn", "tax invoice", "proforma"
76
+ ]
77
+ email_keywords = [
78
+ "dear", "regards", "sincerely", "best regards",
79
+ "subject:", "please find", "attached", "let me know",
80
+ "thank you for", "hi ", "hello ", "greetings", "warm regards"
81
+ ]
82
+ ticket_keywords = [
83
+ "ticket", "priority", "bug", "assigned to",
84
+ "reported by", "severity", "incident", "resolve",
85
+ "support request", "status:", "issue #", "case #",
86
+ "escalation", "sla", "helpdesk"
87
+ ]
88
+
89
+ invoice_score = sum(1 for kw in invoice_keywords if kw in text_lower)
90
+ email_score = sum(1 for kw in email_keywords if kw in text_lower)
91
+ ticket_score = sum(1 for kw in ticket_keywords if kw in text_lower)
92
+
93
+ scores = {
94
+ "Invoice": invoice_score,
95
+ "Email": email_score,
96
+ "Support Ticket": ticket_score
97
+ }
98
+ max_type = max(scores, key=scores.get)
99
+ max_score = scores[max_type]
100
+
101
+ return max_type if max_score >= 2 else "General"
102
+
103
+
104
+ # ================================================================
105
+ # FIELD EXTRACTORS — per document type
106
+ # ================================================================
107
+ def extract_invoice_fields(text: str) -> dict:
108
+ fields = {}
109
+
110
+ patterns = {
111
+ "invoice_number": r'invoice\s*(?:number|#|no\.?)\s*[:\-]?\s*([A-Z]{2,10}-\d{2,6}(?:-[A-Z0-9]+)*)',
112
+ "order_number": r'order\s*(?:number|#|no\.?)?\s*[:\-]?\s*([A-Z0-9\-]{3,})',
113
+ "total_due": r'total\s*due\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.\d+|[\d,]+)',
114
+ "tax": r'\btax\b\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.?\d*)',
115
+ "sub_total": r'sub\s*total\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.?\d*)',
116
+ "invoice_date": r'invoice\s*date\s*[:\-]?\s*([A-Za-z]+\s+\d{1,2},?\s*\d{4}|\d{1,2}\s+[A-Za-z]+\s+\d{4}|\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})',
117
+ "due_date": r'due\s*date\s*[:\-]?\s*([A-Za-z]+\s+\d{1,2},?\s*\d{4}|\d{1,2}\s+[A-Za-z]+\s+\d{4}|\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})',
118
+ }
119
+
120
+ for field, pattern in patterns.items():
121
+ if field == "total_due":
122
+ matches = re.findall(pattern, text, re.IGNORECASE)
123
+ if matches:
124
+ val = matches[-1].strip()
125
+ else:
126
+ continue
127
+ else:
128
+ match = re.search(pattern, text, re.IGNORECASE)
129
+ if not match:
130
+ continue
131
+ val = match.group(1).strip()
132
+ if field in ["total_due", "tax", "sub_total"]:
133
+ val = f"₹{val}" if any(c in text for c in ["₹", "INR", "Rs", "Crore", "Lakh"]) else f"${val}"
134
+ fields[field] = val
135
+
136
+ # Vendor Extraction — smarter multiline block parsing
137
+ from_block = re.search(
138
+ r'from\s*:\s*(.*?)bill\s*to\s*:',
139
+ text,
140
+ re.IGNORECASE | re.DOTALL
141
+ )
142
+
143
+ if from_block:
144
+ block = from_block.group(1)
145
+
146
+ # Split lines and clean
147
+ lines = [
148
+ l.strip()
149
+ for l in block.split("\n")
150
+ if len(l.strip()) > 3
151
+ ]
152
+
153
+ # Look for likely company names
154
+ vendor_candidates = [
155
+ l for l in lines
156
+ if any(word in l.lower() for word in [
157
+ "limited",
158
+ "ltd",
159
+ "pvt",
160
+ "corp",
161
+ "solutions",
162
+ "technologies",
163
+ "services",
164
+ "systems"
165
+ ])
166
+ ]
167
+
168
+ if vendor_candidates:
169
+ vendor = vendor_candidates[0]
170
+
171
+ # Remove trailing invoice/order/date text
172
+ vendor = re.split(
173
+ r'invoice\s*number|order\s*number|invoice\s*date|due\s*date',
174
+ vendor,
175
+ flags=re.IGNORECASE
176
+ )[0].strip()
177
+
178
+ fields["vendor"] = vendor
179
+
180
+ # Client — line after "To:" or "Bill To:"
181
+ to_match = re.search(r'(?:^|\n)\s*(?:bill\s*to|to)\s*:\s*\n?\s*(.+)', text, re.IGNORECASE)
182
+ if to_match:
183
+ fields["client"] = to_match.group(1).strip()
184
+
185
+ # GSTIN
186
+ gstin_match = re.search(r'gstin\s*[:\-]?\s*([A-Z0-9]{15})', text, re.IGNORECASE)
187
+ if gstin_match:
188
+ fields["gstin"] = gstin_match.group(1).strip()
189
+
190
+ return fields
191
+
192
+
193
+ def extract_email_fields(text: str) -> dict:
194
+ fields = {}
195
+ lines = text.strip().split("\n")
196
+
197
+ for line in lines[:10]:
198
+ stripped = line.strip()
199
+ lower = stripped.lower()
200
+ if lower.startswith("subject:"):
201
+ fields["subject"] = stripped.split(":", 1)[1].strip()
202
+ elif lower.startswith("from:"):
203
+ fields["from"] = stripped.split(":", 1)[1].strip()
204
+ elif lower.startswith("to:"):
205
+ fields["to"] = stripped.split(":", 1)[1].strip()
206
+ elif lower.startswith("cc:"):
207
+ fields["cc"] = stripped.split(":", 1)[1].strip()
208
+ elif lower.startswith("date:"):
209
+ fields["date"] = stripped.split(":", 1)[1].strip()
210
+
211
+ text_lower = text.lower()
212
+ if any(w in text_lower for w in ["complaint", "unhappy", "disappointed", "not satisfied", "issue with", "problem with"]):
213
+ fields["intent"] = "Complaint"
214
+ elif any(w in text_lower for w in ["follow up", "following up", "checking in", "any update", "status update"]):
215
+ fields["intent"] = "Follow Up"
216
+ elif any(w in text_lower for w in ["thank you", "thanks", "appreciate", "grateful", "well received"]):
217
+ fields["intent"] = "Appreciation"
218
+ elif any(w in text_lower for w in ["please find", "attached", "quotation", "proposal", "request", "partnership"]):
219
+ fields["intent"] = "Request"
220
+ else:
221
+ fields["intent"] = "General"
222
+
223
+ return fields
224
+
225
+
226
+ def extract_ticket_fields(text: str) -> dict:
227
+ fields = {}
228
+
229
+ ticket_match = re.search(
230
+ r'(?:ticket|case|issue)\s*(?:id|number|#)?\s*[:\-]?\s*([A-Z]{1,5}-\d{2,6}(?:-\d{1,6})?)',
231
+ text,
232
+ re.IGNORECASE
233
+ )
234
+ if ticket_match:
235
+ fields["ticket_id"] = ticket_match.group(1).strip()
236
+
237
+ priority_match = re.search(
238
+ r'priority\s*(?:[:\-]?\s*)?(low|medium|high|critical|urgent|p0|p1|p2|p3)',text,re.IGNORECASE)
239
+ if priority_match:
240
+ fields["priority"] = priority_match.group(1).capitalize()
241
+ else:
242
+ text_lower = text.lower()
243
+ if any(w in text_lower for w in ["urgent", "critical", "asap", "immediately", "blocker", "p0", "p1"]):
244
+ fields["priority"] = "High"
245
+ elif any(w in text_lower for w in ["low priority", "minor", "whenever possible", "p3", "p4"]):
246
+ fields["priority"] = "Low"
247
+ else:
248
+ fields["priority"] = "Medium"
249
+
250
+ status_match = re.search(r'status\s*(?:[:\-]?\s*)?(open|closed|pending|resolved|in\s*progress)',text,re.IGNORECASE)
251
+ fields["status"] = status_match.group(1).strip().capitalize() if status_match else "Open"
252
+
253
+ text_lower = text.lower()
254
+ if any(w in text_lower for w in ["login", "password", "access", "authentication", "permission", "ldap", "sso"]):
255
+ fields["issue_type"] = "Access Issue"
256
+ elif any(w in text_lower for w in ["crash", "error", "bug", "not working", "broken", "failed", "exception"]):
257
+ fields["issue_type"] = "Bug Report"
258
+ elif any(w in text_lower for w in ["slow", "performance", "timeout", "latency", "hang", "freeze"]):
259
+ fields["issue_type"] = "Performance Issue"
260
+ elif any(w in text_lower for w in ["install", "setup", "configure", "deployment", "update", "upgrade"]):
261
+ fields["issue_type"] = "Installation Issue"
262
+ else:
263
+ fields["issue_type"] = "General Issue"
264
+
265
+ assigned_match = re.search(
266
+ r'assigned\s*to\s*[:\-]?\s*([A-Za-z\s]+?)(?:department|\n|$)',
267
+ text,
268
+ re.IGNORECASE
269
+ )
270
+ if assigned_match:
271
+ fields["assigned_to"] = assigned_match.group(1).strip()
272
+
273
+ reported_match = re.search(
274
+ r'reported\s*by\s*[:\-]?\s*([A-Za-z\s]+?)(?:date|\n|$)',
275
+ text,
276
+ re.IGNORECASE
277
+ )
278
+ if reported_match:
279
+ fields["reported_by"] = reported_match.group(1).strip()
280
+
281
+ return fields
282
+
283
+
284
+ def extract_fields(doc_type: str, text: str) -> dict:
285
+ if doc_type == "Invoice":
286
+ return extract_invoice_fields(text)
287
+ elif doc_type == "Email":
288
+ return extract_email_fields(text)
289
+ elif doc_type == "Support Ticket":
290
+ return extract_ticket_fields(text)
291
+ return {}
292
+
293
+
294
+ # ================================================================
295
+ # ENTITY CLEANING
296
+ # ================================================================
297
+ NOISE_ENTITIES = {
298
+ "office supplies", "web design", "sample", "services", "payment",
299
+ "invoice", "total", "tax", "sub", "amount", "date", "number",
300
+ "dear", "regards", "sincerely", "hello", "hi", "subject",
301
+ "attached", "please", "find", "thank", "note", "items"
302
+ }
303
+
304
+ def clean_entities(entities: list) -> list:
305
+ cleaned = []
306
+ seen = set()
307
+ for entity in entities:
308
+ text = entity["text"].strip()
309
+ if len(text) < 3:
310
+ continue
311
+ if text.startswith("##"):
312
+ continue
313
+ if text.lower() in NOISE_ENTITIES:
314
+ continue
315
+ if text.lower() in seen:
316
+ continue
317
+ # Skip pure numbers
318
+ if re.match(r'^[\d\s\.,]+$', text):
319
+ continue
320
+ seen.add(text.lower())
321
+ cleaned.append(entity)
322
+ return cleaned
323
+
324
+
325
+ # ================================================================
326
+ # SUMMARIZER
327
+ # ================================================================
328
+ def generate_structured_summary(doc_type: str, text: str, entities: list, extracted_fields: dict) -> str:
329
+ """
330
+ For Invoice, Email, Support Ticket — generate precise structured summaries.
331
+ These are deterministic and accurate. BART is NOT used here.
332
+ """
333
+ per_entities = [e["text"].title() for e in entities if e["type"] == "PER"]
334
+ org_entities = [e["text"].title() for e in entities if e["type"] == "ORG"]
335
+ loc_entities = [e["text"].title() for e in entities if e["type"] == "LOC"]
336
+
337
+ if doc_type == "Invoice":
338
+ vendor = extracted_fields.get("vendor", org_entities[0] if org_entities else "the vendor")
339
+ client = extracted_fields.get("client", "the client")
340
+ total = extracted_fields.get("total_due", "N/A")
341
+ inv_num = extracted_fields.get("invoice_number", "N/A")
342
+ due = extracted_fields.get("due_date", "N/A")
343
+ inv_date = extracted_fields.get("invoice_date", "N/A")
344
+ gstin = extracted_fields.get("gstin", "")
345
+ gstin_str = f" (GSTIN: {gstin})" if gstin else ""
346
+ return (
347
+ f"Invoice {inv_num} issued by {vendor}{gstin_str} to {client} on {inv_date}. "
348
+ f"Total amount due: {total}, payment deadline: {due}."
349
+ )
350
+
351
+ elif doc_type == "Email":
352
+ subject = extracted_fields.get("subject", "")
353
+ sender = extracted_fields.get("from", per_entities[0] if per_entities else "the sender")
354
+ intent = extracted_fields.get("intent", "General")
355
+ org = org_entities[0] if org_entities else ""
356
+ loc = loc_entities[0] if loc_entities else ""
357
+ parts = [f"{intent} email"]
358
+ if subject:
359
+ parts.append(f"regarding \"{subject}\"")
360
+ if sender:
361
+ parts.append(f"from {sender}")
362
+ if org:
363
+ parts.append(f"at {org}")
364
+ if loc:
365
+ parts.append(f"based in {loc}")
366
+ return " ".join(parts) + "."
367
+
368
+ elif doc_type == "Support Ticket":
369
+ ticket_id = extracted_fields.get("ticket_id", "")
370
+ priority = extracted_fields.get("priority", "Medium")
371
+ issue_type = extracted_fields.get("issue_type", "General Issue")
372
+ status = extracted_fields.get("status", "Open")
373
+ assigned = extracted_fields.get("assigned_to", "")
374
+ reported = extracted_fields.get("reported_by", "")
375
+ sentences = [
376
+ s.strip()
377
+ for s in re.split(r'[.\n]', text)
378
+ if len(s.strip()) > 30
379
+ ]
380
+
381
+ filtered_sentences = [
382
+ s for s in sentences
383
+ if not any(
384
+ noise in s.lower()
385
+ for noise in [
386
+ "ticket #",
387
+ "priority",
388
+ "status",
389
+ "assigned to",
390
+ "reported by",
391
+ "company",
392
+ "location"
393
+ ]
394
+ )
395
+ ]
396
+
397
+ detail = filtered_sentences[0] if filtered_sentences else ""
398
+ summary = f"{priority} priority {issue_type}"
399
+ if ticket_id:
400
+ summary += f" (#{ticket_id})"
401
+ summary += f". Status: {status}."
402
+ if assigned:
403
+ summary += f" Assigned to {assigned}."
404
+ if reported:
405
+ summary += f" Reported by {reported}."
406
+ if detail:
407
+ summary += f" {detail}."
408
+ return summary
409
+
410
+ return ""
411
+
412
+
413
+ def generate_bart_summary(text: str, entities: list) -> str:
414
+ """
415
+ For News/General documents — use DistilBART for abstractive summarization.
416
+ BART is designed for news articles and works best here.
417
+ """
418
+ # Clean text — remove very short lines and noise
419
+ clean_lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 20]
420
+ clean_text = " ".join(clean_lines)
421
+
422
+ # BART works best with 100-600 words
423
+ words = clean_text.split()
424
+ if len(words) > 500:
425
+ clean_text = " ".join(words[:500])
426
+
427
+ # If too short for BART — use extractive fallback
428
+ if len(words) < 30:
429
+ sentences = [s.strip() for s in text.split(".") if len(s.strip()) > 20]
430
+ return sentences[0] + "." if sentences else text.strip()
431
+
432
+ try:
433
+ result = bart_summarizer(
434
+ clean_text,
435
+ max_length=120,
436
+ min_length=40,
437
+ do_sample=False,
438
+ truncation=True
439
+ )
440
+ summary = result[0]["summary_text"].strip()
441
+ # Clean up spacing issues
442
+ summary = re.sub(r'\s+([.,])', r'\1', summary)
443
+ summary = re.sub(r'\s+', ' ', summary)
444
+ return summary
445
+ except Exception as e:
446
+ print(f"BART summarization failed: {e}")
447
+ # Extractive fallback
448
+ entity_names = [e["text"].lower() for e in entities]
449
+ sentences = [s.strip() for s in text.split(".") if len(s.strip()) > 25]
450
+ if not sentences:
451
+ return text[:200].strip()
452
+ scored = []
453
+ for i, sent in enumerate(sentences):
454
+ score = 4 if i == 0 else 0
455
+ for ent in entity_names:
456
+ if ent in sent.lower():
457
+ score += 2
458
+ scored.append((score, i, sent))
459
+ top = sorted(scored, reverse=True)[:2]
460
+ ordered = [s for _, _, s in sorted(top, key=lambda x: x[1])]
461
+ return ". ".join(ordered).strip() + "."
462
+
463
+
464
+ # ================================================================
465
+ # LANGGRAPH STATE
466
+ # ================================================================
467
+ class DocumentState(TypedDict):
468
+ text: str
469
+ entities: List[Dict[str, str]]
470
+ doc_type: str
471
+ confidence: float
472
+ summary: str
473
+ doc_id: str
474
+ extracted_fields: Dict[str, Any]
475
+ error: str
476
+
477
+
478
+ # ================================================================
479
+ # PIPELINE NODES
480
+ # ================================================================
481
+ def preprocess(state: DocumentState) -> DocumentState:
482
+ text = state["text"].strip()
483
+ text = re.sub(r'[ \t]+', ' ', text)
484
+ text = re.sub(r'\n{3,}', '\n\n', text)
485
+ state["text"] = text
486
+ state["doc_id"] = str(abs(hash(text)))[:8]
487
+ return state
488
+
489
+
490
+ def run_ner(state: DocumentState) -> DocumentState:
491
+ text = state["text"]
492
+ inputs = ner_tokenizer(
493
+ text,
494
+ return_tensors="pt",
495
+ truncation=True,
496
+ max_length=128,
497
+ padding=True
498
+ ).to(DEVICE)
499
+
500
+ with torch.no_grad():
501
+ outputs = ner_model(**inputs)
502
+
503
+ predictions = torch.argmax(outputs.logits, dim=-1).squeeze().tolist()
504
+ tokens = ner_tokenizer.convert_ids_to_tokens(inputs["input_ids"].squeeze().tolist())
505
+
506
+ entities = []
507
+ current_entity = None
508
+
509
+ for token, pred in zip(tokens, predictions):
510
+ if token in ["[CLS]", "[SEP]", "[PAD]"]:
511
+ continue
512
+ label = NER_LABELS[pred]
513
+ if label.startswith("B-"):
514
+ if current_entity:
515
+ entities.append(current_entity)
516
+ current_entity = {"text": token, "type": label[2:]}
517
+ elif label.startswith("I-") and current_entity:
518
+ if token.startswith("##"):
519
+ current_entity["text"] += token[2:]
520
+ else:
521
+ current_entity["text"] += " " + token
522
+ else:
523
+ if current_entity:
524
+ entities.append(current_entity)
525
+ current_entity = None
526
+
527
+ if current_entity:
528
+ entities.append(current_entity)
529
+
530
+ state["entities"] = clean_entities(entities)
531
+ return state
532
+
533
+
534
+ def run_classifier(state: DocumentState) -> DocumentState:
535
+ text = state["text"]
536
+ rule_type = detect_document_type(text)
537
+
538
+ if rule_type != "General":
539
+ state["doc_type"] = rule_type
540
+ state["confidence"] = 1.0
541
+ else:
542
+ inputs = cls_tokenizer(
543
+ text,
544
+ return_tensors="pt",
545
+ truncation=True,
546
+ max_length=256,
547
+ padding=True
548
+ ).to(DEVICE)
549
+ with torch.no_grad():
550
+ outputs = cls_model(**inputs)
551
+ probs = torch.softmax(outputs.logits, dim=-1).squeeze()
552
+ pred_id = torch.argmax(probs).item()
553
+ state["doc_type"] = CLASSIFIER_LABELS[pred_id]
554
+ state["confidence"] = round(probs[pred_id].item(), 4)
555
+
556
+ state["extracted_fields"] = extract_fields(state["doc_type"], text)
557
+ return state
558
+
559
+
560
+ def run_summarizer(state: DocumentState) -> DocumentState:
561
+ doc_type = state["doc_type"]
562
+ text = state["text"]
563
+ entities = state["entities"]
564
+ extracted_fields = state["extracted_fields"]
565
+
566
+ structured_types = ["Invoice", "Email", "Support Ticket"]
567
+
568
+ if doc_type in structured_types:
569
+ # Use precise structured summary — no BART needed
570
+ # BART would garble structured summaries
571
+ state["summary"] = generate_structured_summary(
572
+ doc_type, text, entities, extracted_fields
573
+ )
574
+ else:
575
+ # Use BART for news/general — this is what BART is designed for
576
+ state["summary"] = generate_bart_summary(text, entities)
577
+
578
+ return state
579
+
580
+
581
+ def run_vector_store(state: DocumentState) -> DocumentState:
582
+ text = state["text"]
583
+ doc_id = state["doc_id"]
584
+ embedding = embedder.encode(text).tolist()
585
+ collection.upsert(
586
+ documents=[text],
587
+ embeddings=[embedding],
588
+ ids=[doc_id],
589
+ metadatas=[{
590
+ "doc_type": state["doc_type"],
591
+ "summary": state["summary"]
592
+ }]
593
+ )
594
+ return state
595
+
596
+
597
+ # ================================================================
598
+ # BUILD AND RUN PIPELINE
599
+ # ================================================================
600
+ def build_pipeline():
601
+ graph = StateGraph(DocumentState)
602
+ graph.add_node("preprocess", preprocess)
603
+ graph.add_node("ner", run_ner)
604
+ graph.add_node("classifier", run_classifier)
605
+ graph.add_node("summarizer", run_summarizer)
606
+ graph.add_node("vector_store", run_vector_store)
607
+ graph.set_entry_point("preprocess")
608
+ graph.add_edge("preprocess", "ner")
609
+ graph.add_edge("ner", "classifier")
610
+ graph.add_edge("classifier", "summarizer")
611
+ graph.add_edge("summarizer", "vector_store")
612
+ graph.add_edge("vector_store", END)
613
+ return graph.compile()
614
+
615
+
616
+ def analyze_document(text: str) -> Dict[str, Any]:
617
+ p = build_pipeline()
618
+ initial_state = DocumentState(
619
+ text=text,
620
+ entities=[],
621
+ doc_type="",
622
+ confidence=0.0,
623
+ summary="",
624
+ doc_id="",
625
+ extracted_fields={},
626
+ error=""
627
+ )
628
+ result = p.invoke(initial_state)
629
+ return {
630
+ "doc_id": result["doc_id"],
631
+ "doc_type": result["doc_type"],
632
+ "confidence": result["confidence"],
633
+ "entities": result["entities"],
634
+ "summary": result["summary"],
635
+ "extracted_fields": result["extracted_fields"]
636
+ }