doculens-api / src /pipeline.py
Praveen Kumar
Fix: correct HuggingFace repo IDs for model download
e1317bf
Raw
History Blame Contribute Delete
22.1 kB
import torch
import re
import os
import json
from chromadb.config import Settings
from transformers import (
DistilBertTokenizerFast,
DistilBertForTokenClassification,
BertTokenizerFast,
BertForSequenceClassification,
pipeline as hf_pipeline
)
from langgraph.graph import StateGraph, END
from typing import TypedDict, List, Dict, Any
import chromadb
from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv
load_dotenv()
# ---- Labels ----
NER_LABELS = ["O", "B-PER", "I-PER", "B-ORG", "I-ORG", "B-LOC", "I-LOC"]
CLASSIFIER_LABELS = ["World", "Sports", "Business", "Sci/Tech"]
# ---- Device ----
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {DEVICE}")
# ---- Load Models ----
print("Loading NER model...")
ner_tokenizer = DistilBertTokenizerFast.from_pretrained("models/ner_model")
ner_model = DistilBertForTokenClassification.from_pretrained("models/ner_model")
ner_model.to(DEVICE)
ner_model.eval()
print("Loading Classifier model...")
cls_tokenizer = BertTokenizerFast.from_pretrained("models/classifier_model")
cls_model = BertForSequenceClassification.from_pretrained("models/classifier_model")
cls_model.to(DEVICE)
cls_model.eval()
print("Loading Sentence Transformer...")
embedder = SentenceTransformer("all-MiniLM-L6-v2")
print("Loading DistilBART Summarizer...")
bart_summarizer = hf_pipeline(
task="summarization",
model="sshleifer/distilbart-cnn-12-6",
device=-1 # CPU
)
print("Setting up ChromaDB...")
chroma_client = chromadb.Client(
Settings(
anonymized_telemetry=False
)
)
collection = chroma_client.get_or_create_collection("documents")
print("All models loaded!")
# ================================================================
# DOCUMENT TYPE DETECTION — Rule-based first, ML fallback
# ================================================================
def detect_document_type(text: str) -> str:
text_lower = text.lower()
invoice_keywords = [
"invoice", "total due", "payment due", "invoice number",
"bill to", "ship to", "subtotal", "amount due",
"purchase order", "invoice date", "due date", "receipt",
"gstin", "hsn", "tax invoice", "proforma"
]
email_keywords = [
"dear", "regards", "sincerely", "best regards",
"subject:", "please find", "attached", "let me know",
"thank you for", "hi ", "hello ", "greetings", "warm regards"
]
ticket_keywords = [
"ticket", "priority", "bug", "assigned to",
"reported by", "severity", "incident", "resolve",
"support request", "status:", "issue #", "case #",
"escalation", "sla", "helpdesk"
]
invoice_score = sum(1 for kw in invoice_keywords if kw in text_lower)
email_score = sum(1 for kw in email_keywords if kw in text_lower)
ticket_score = sum(1 for kw in ticket_keywords if kw in text_lower)
scores = {
"Invoice": invoice_score,
"Email": email_score,
"Support Ticket": ticket_score
}
max_type = max(scores, key=scores.get)
max_score = scores[max_type]
return max_type if max_score >= 2 else "General"
# ================================================================
# FIELD EXTRACTORS — per document type
# ================================================================
def extract_invoice_fields(text: str) -> dict:
fields = {}
patterns = {
"invoice_number": r'invoice\s*(?:number|#|no\.?)\s*[:\-]?\s*([A-Z]{2,10}-\d{2,6}(?:-[A-Z0-9]+)*)',
"order_number": r'order\s*(?:number|#|no\.?)?\s*[:\-]?\s*([A-Z0-9\-]{3,})',
"total_due": r'total\s*due\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.\d+|[\d,]+)',
"tax": r'\btax\b\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.?\d*)',
"sub_total": r'sub\s*total\s*[:\-]?\s*(?:rs\.?|inr|₹|\$)?\s*([\d,]+\.?\d*)',
"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})',
"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})',
}
for field, pattern in patterns.items():
if field == "total_due":
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
val = matches[-1].strip()
else:
continue
else:
match = re.search(pattern, text, re.IGNORECASE)
if not match:
continue
val = match.group(1).strip()
if field in ["total_due", "tax", "sub_total"]:
val = f"₹{val}" if any(c in text for c in ["₹", "INR", "Rs", "Crore", "Lakh"]) else f"${val}"
fields[field] = val
# Vendor Extraction — smarter multiline block parsing
from_block = re.search(
r'from\s*:\s*(.*?)bill\s*to\s*:',
text,
re.IGNORECASE | re.DOTALL
)
if from_block:
block = from_block.group(1)
# Split lines and clean
lines = [
l.strip()
for l in block.split("\n")
if len(l.strip()) > 3
]
# Look for likely company names
vendor_candidates = [
l for l in lines
if any(word in l.lower() for word in [
"limited",
"ltd",
"pvt",
"corp",
"solutions",
"technologies",
"services",
"systems"
])
]
if vendor_candidates:
vendor = vendor_candidates[0]
# Remove trailing invoice/order/date text
vendor = re.split(
r'invoice\s*number|order\s*number|invoice\s*date|due\s*date',
vendor,
flags=re.IGNORECASE
)[0].strip()
fields["vendor"] = vendor
# Client — line after "To:" or "Bill To:"
to_match = re.search(r'(?:^|\n)\s*(?:bill\s*to|to)\s*:\s*\n?\s*(.+)', text, re.IGNORECASE)
if to_match:
fields["client"] = to_match.group(1).strip()
# GSTIN
gstin_match = re.search(r'gstin\s*[:\-]?\s*([A-Z0-9]{15})', text, re.IGNORECASE)
if gstin_match:
fields["gstin"] = gstin_match.group(1).strip()
return fields
def extract_email_fields(text: str) -> dict:
fields = {}
lines = text.strip().split("\n")
for line in lines[:10]:
stripped = line.strip()
lower = stripped.lower()
if lower.startswith("subject:"):
fields["subject"] = stripped.split(":", 1)[1].strip()
elif lower.startswith("from:"):
fields["from"] = stripped.split(":", 1)[1].strip()
elif lower.startswith("to:"):
fields["to"] = stripped.split(":", 1)[1].strip()
elif lower.startswith("cc:"):
fields["cc"] = stripped.split(":", 1)[1].strip()
elif lower.startswith("date:"):
fields["date"] = stripped.split(":", 1)[1].strip()
text_lower = text.lower()
if any(w in text_lower for w in ["complaint", "unhappy", "disappointed", "not satisfied", "issue with", "problem with"]):
fields["intent"] = "Complaint"
elif any(w in text_lower for w in ["follow up", "following up", "checking in", "any update", "status update"]):
fields["intent"] = "Follow Up"
elif any(w in text_lower for w in ["thank you", "thanks", "appreciate", "grateful", "well received"]):
fields["intent"] = "Appreciation"
elif any(w in text_lower for w in ["please find", "attached", "quotation", "proposal", "request", "partnership"]):
fields["intent"] = "Request"
else:
fields["intent"] = "General"
return fields
def extract_ticket_fields(text: str) -> dict:
fields = {}
ticket_match = re.search(
r'(?:ticket|case|issue)\s*(?:id|number|#)?\s*[:\-]?\s*([A-Z]{1,5}-\d{2,6}(?:-\d{1,6})?)',
text,
re.IGNORECASE
)
if ticket_match:
fields["ticket_id"] = ticket_match.group(1).strip()
priority_match = re.search(
r'priority\s*(?:[:\-]?\s*)?(low|medium|high|critical|urgent|p0|p1|p2|p3)',text,re.IGNORECASE)
if priority_match:
fields["priority"] = priority_match.group(1).capitalize()
else:
text_lower = text.lower()
if any(w in text_lower for w in ["urgent", "critical", "asap", "immediately", "blocker", "p0", "p1"]):
fields["priority"] = "High"
elif any(w in text_lower for w in ["low priority", "minor", "whenever possible", "p3", "p4"]):
fields["priority"] = "Low"
else:
fields["priority"] = "Medium"
status_match = re.search(r'status\s*(?:[:\-]?\s*)?(open|closed|pending|resolved|in\s*progress)',text,re.IGNORECASE)
fields["status"] = status_match.group(1).strip().capitalize() if status_match else "Open"
text_lower = text.lower()
if any(w in text_lower for w in ["login", "password", "access", "authentication", "permission", "ldap", "sso"]):
fields["issue_type"] = "Access Issue"
elif any(w in text_lower for w in ["crash", "error", "bug", "not working", "broken", "failed", "exception"]):
fields["issue_type"] = "Bug Report"
elif any(w in text_lower for w in ["slow", "performance", "timeout", "latency", "hang", "freeze"]):
fields["issue_type"] = "Performance Issue"
elif any(w in text_lower for w in ["install", "setup", "configure", "deployment", "update", "upgrade"]):
fields["issue_type"] = "Installation Issue"
else:
fields["issue_type"] = "General Issue"
assigned_match = re.search(
r'assigned\s*to\s*[:\-]?\s*([A-Za-z\s]+?)(?:department|\n|$)',
text,
re.IGNORECASE
)
if assigned_match:
fields["assigned_to"] = assigned_match.group(1).strip()
reported_match = re.search(
r'reported\s*by\s*[:\-]?\s*([A-Za-z\s]+?)(?:date|\n|$)',
text,
re.IGNORECASE
)
if reported_match:
fields["reported_by"] = reported_match.group(1).strip()
return fields
def extract_fields(doc_type: str, text: str) -> dict:
if doc_type == "Invoice":
return extract_invoice_fields(text)
elif doc_type == "Email":
return extract_email_fields(text)
elif doc_type == "Support Ticket":
return extract_ticket_fields(text)
return {}
# ================================================================
# ENTITY CLEANING
# ================================================================
NOISE_ENTITIES = {
"office supplies", "web design", "sample", "services", "payment",
"invoice", "total", "tax", "sub", "amount", "date", "number",
"dear", "regards", "sincerely", "hello", "hi", "subject",
"attached", "please", "find", "thank", "note", "items"
}
def clean_entities(entities: list) -> list:
cleaned = []
seen = set()
for entity in entities:
text = entity["text"].strip()
if len(text) < 3:
continue
if text.startswith("##"):
continue
if text.lower() in NOISE_ENTITIES:
continue
if text.lower() in seen:
continue
# Skip pure numbers
if re.match(r'^[\d\s\.,]+$', text):
continue
seen.add(text.lower())
cleaned.append(entity)
return cleaned
# ================================================================
# SUMMARIZER
# ================================================================
def generate_structured_summary(doc_type: str, text: str, entities: list, extracted_fields: dict) -> str:
"""
For Invoice, Email, Support Ticket — generate precise structured summaries.
These are deterministic and accurate. BART is NOT used here.
"""
per_entities = [e["text"].title() for e in entities if e["type"] == "PER"]
org_entities = [e["text"].title() for e in entities if e["type"] == "ORG"]
loc_entities = [e["text"].title() for e in entities if e["type"] == "LOC"]
if doc_type == "Invoice":
vendor = extracted_fields.get("vendor", org_entities[0] if org_entities else "the vendor")
client = extracted_fields.get("client", "the client")
total = extracted_fields.get("total_due", "N/A")
inv_num = extracted_fields.get("invoice_number", "N/A")
due = extracted_fields.get("due_date", "N/A")
inv_date = extracted_fields.get("invoice_date", "N/A")
gstin = extracted_fields.get("gstin", "")
gstin_str = f" (GSTIN: {gstin})" if gstin else ""
return (
f"Invoice {inv_num} issued by {vendor}{gstin_str} to {client} on {inv_date}. "
f"Total amount due: {total}, payment deadline: {due}."
)
elif doc_type == "Email":
subject = extracted_fields.get("subject", "")
sender = extracted_fields.get("from", per_entities[0] if per_entities else "the sender")
intent = extracted_fields.get("intent", "General")
org = org_entities[0] if org_entities else ""
loc = loc_entities[0] if loc_entities else ""
parts = [f"{intent} email"]
if subject:
parts.append(f"regarding \"{subject}\"")
if sender:
parts.append(f"from {sender}")
if org:
parts.append(f"at {org}")
if loc:
parts.append(f"based in {loc}")
return " ".join(parts) + "."
elif doc_type == "Support Ticket":
ticket_id = extracted_fields.get("ticket_id", "")
priority = extracted_fields.get("priority", "Medium")
issue_type = extracted_fields.get("issue_type", "General Issue")
status = extracted_fields.get("status", "Open")
assigned = extracted_fields.get("assigned_to", "")
reported = extracted_fields.get("reported_by", "")
sentences = [
s.strip()
for s in re.split(r'[.\n]', text)
if len(s.strip()) > 30
]
filtered_sentences = [
s for s in sentences
if not any(
noise in s.lower()
for noise in [
"ticket #",
"priority",
"status",
"assigned to",
"reported by",
"company",
"location"
]
)
]
detail = filtered_sentences[0] if filtered_sentences else ""
summary = f"{priority} priority {issue_type}"
if ticket_id:
summary += f" (#{ticket_id})"
summary += f". Status: {status}."
if assigned:
summary += f" Assigned to {assigned}."
if reported:
summary += f" Reported by {reported}."
if detail:
summary += f" {detail}."
return summary
return ""
def generate_bart_summary(text: str, entities: list) -> str:
"""
For News/General documents — use DistilBART for abstractive summarization.
BART is designed for news articles and works best here.
"""
# Clean text — remove very short lines and noise
clean_lines = [l.strip() for l in text.split("\n") if len(l.strip()) > 20]
clean_text = " ".join(clean_lines)
# BART works best with 100-600 words
words = clean_text.split()
if len(words) > 500:
clean_text = " ".join(words[:500])
# If too short for BART — use extractive fallback
if len(words) < 30:
sentences = [s.strip() for s in text.split(".") if len(s.strip()) > 20]
return sentences[0] + "." if sentences else text.strip()
try:
result = bart_summarizer(
clean_text,
max_length=120,
min_length=40,
do_sample=False,
truncation=True
)
summary = result[0]["summary_text"].strip()
# Clean up spacing issues
summary = re.sub(r'\s+([.,])', r'\1', summary)
summary = re.sub(r'\s+', ' ', summary)
return summary
except Exception as e:
print(f"BART summarization failed: {e}")
# Extractive fallback
entity_names = [e["text"].lower() for e in entities]
sentences = [s.strip() for s in text.split(".") if len(s.strip()) > 25]
if not sentences:
return text[:200].strip()
scored = []
for i, sent in enumerate(sentences):
score = 4 if i == 0 else 0
for ent in entity_names:
if ent in sent.lower():
score += 2
scored.append((score, i, sent))
top = sorted(scored, reverse=True)[:2]
ordered = [s for _, _, s in sorted(top, key=lambda x: x[1])]
return ". ".join(ordered).strip() + "."
# ================================================================
# LANGGRAPH STATE
# ================================================================
class DocumentState(TypedDict):
text: str
entities: List[Dict[str, str]]
doc_type: str
confidence: float
summary: str
doc_id: str
extracted_fields: Dict[str, Any]
error: str
# ================================================================
# PIPELINE NODES
# ================================================================
def preprocess(state: DocumentState) -> DocumentState:
text = state["text"].strip()
text = re.sub(r'[ \t]+', ' ', text)
text = re.sub(r'\n{3,}', '\n\n', text)
state["text"] = text
state["doc_id"] = str(abs(hash(text)))[:8]
return state
def run_ner(state: DocumentState) -> DocumentState:
text = state["text"]
inputs = ner_tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=128,
padding=True
).to(DEVICE)
with torch.no_grad():
outputs = ner_model(**inputs)
predictions = torch.argmax(outputs.logits, dim=-1).squeeze().tolist()
tokens = ner_tokenizer.convert_ids_to_tokens(inputs["input_ids"].squeeze().tolist())
entities = []
current_entity = None
for token, pred in zip(tokens, predictions):
if token in ["[CLS]", "[SEP]", "[PAD]"]:
continue
label = NER_LABELS[pred]
if label.startswith("B-"):
if current_entity:
entities.append(current_entity)
current_entity = {"text": token, "type": label[2:]}
elif label.startswith("I-") and current_entity:
if token.startswith("##"):
current_entity["text"] += token[2:]
else:
current_entity["text"] += " " + token
else:
if current_entity:
entities.append(current_entity)
current_entity = None
if current_entity:
entities.append(current_entity)
state["entities"] = clean_entities(entities)
return state
def run_classifier(state: DocumentState) -> DocumentState:
text = state["text"]
rule_type = detect_document_type(text)
if rule_type != "General":
state["doc_type"] = rule_type
state["confidence"] = 1.0
else:
inputs = cls_tokenizer(
text,
return_tensors="pt",
truncation=True,
max_length=256,
padding=True
).to(DEVICE)
with torch.no_grad():
outputs = cls_model(**inputs)
probs = torch.softmax(outputs.logits, dim=-1).squeeze()
pred_id = torch.argmax(probs).item()
state["doc_type"] = CLASSIFIER_LABELS[pred_id]
state["confidence"] = round(probs[pred_id].item(), 4)
state["extracted_fields"] = extract_fields(state["doc_type"], text)
return state
def run_summarizer(state: DocumentState) -> DocumentState:
doc_type = state["doc_type"]
text = state["text"]
entities = state["entities"]
extracted_fields = state["extracted_fields"]
structured_types = ["Invoice", "Email", "Support Ticket"]
if doc_type in structured_types:
# Use precise structured summary — no BART needed
# BART would garble structured summaries
state["summary"] = generate_structured_summary(
doc_type, text, entities, extracted_fields
)
else:
# Use BART for news/general — this is what BART is designed for
state["summary"] = generate_bart_summary(text, entities)
return state
def run_vector_store(state: DocumentState) -> DocumentState:
text = state["text"]
doc_id = state["doc_id"]
embedding = embedder.encode(text).tolist()
collection.upsert(
documents=[text],
embeddings=[embedding],
ids=[doc_id],
metadatas=[{
"doc_type": state["doc_type"],
"summary": state["summary"]
}]
)
return state
# ================================================================
# BUILD AND RUN PIPELINE
# ================================================================
def build_pipeline():
graph = StateGraph(DocumentState)
graph.add_node("preprocess", preprocess)
graph.add_node("ner", run_ner)
graph.add_node("classifier", run_classifier)
graph.add_node("summarizer", run_summarizer)
graph.add_node("vector_store", run_vector_store)
graph.set_entry_point("preprocess")
graph.add_edge("preprocess", "ner")
graph.add_edge("ner", "classifier")
graph.add_edge("classifier", "summarizer")
graph.add_edge("summarizer", "vector_store")
graph.add_edge("vector_store", END)
return graph.compile()
def analyze_document(text: str) -> Dict[str, Any]:
p = build_pipeline()
initial_state = DocumentState(
text=text,
entities=[],
doc_type="",
confidence=0.0,
summary="",
doc_id="",
extracted_fields={},
error=""
)
result = p.invoke(initial_state)
return {
"doc_id": result["doc_id"],
"doc_type": result["doc_type"],
"confidence": result["confidence"],
"entities": result["entities"],
"summary": result["summary"],
"extracted_fields": result["extracted_fields"]
}