pryyyynz's picture
Update app.py
8e1b7a7 verified
Raw
History Blame Contribute Delete
13.7 kB
"""
Standalone Gradio dashboard for contract classification with LIME explanations.
Features:
- Upload single or multiple documents (PDF, DOCX, DOC, TXT)
- Show prediction and confidence
- Show class probability chart
- Highlight influential text via LIME HTML
- Download CSV for batch results
This app loads the same enhanced TF-IDF model used by the API if available.
For a fully standalone setup, place the model file under web/models/.
"""
import os
import io
import csv
import tempfile
import shutil
import logging
from typing import List, Dict, Any, Tuple
import mimetypes
import numpy as np
import pandas as pd
# Document processing deps
import pdfplumber
from docx import Document as DocxDocument
from PIL import Image
import pytesseract
# Optional OCR PDF rasterization
try:
import fitz # PyMuPDF
PYMUPDF_AVAILABLE = True
except Exception:
PYMUPDF_AVAILABLE = False
import gradio as gr
from explainability import ContractExplainer
import pickle
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# ------------------------------
# Model loading
# ------------------------------
MODEL = None
VECTORIZER = None
CLASS_NAMES: List[str] = []
FEATURE_SELECTOR = None
EXPLAINER: ContractExplainer | None = None
def _candidate_model_paths() -> List[str]:
return [
os.path.join(os.path.dirname(__file__), "models",
"enhanced_tfidf_gradient_boosting_model.pkl"),
os.path.join(os.path.dirname(__file__), "..", "enhanced_models_output",
"models", "enhanced_tfidf_gradient_boosting_model.pkl"),
os.path.join(os.path.dirname(__file__), "..",
"models_output", "models", "random_forest_model.pkl"),
]
def load_model_if_needed() -> Tuple[bool, str]:
global MODEL, VECTORIZER, CLASS_NAMES, FEATURE_SELECTOR, EXPLAINER
if EXPLAINER is not None:
return True, "Model already loaded"
last_error = ""
for path in _candidate_model_paths():
try:
if not os.path.exists(path):
continue
with open(path, "rb") as f:
data = pickle.load(f)
MODEL = data["classifier"]
VECTORIZER = data["vectorizer"]
CLASS_NAMES = data["class_names"]
FEATURE_SELECTOR = data.get("feature_selector")
EXPLAINER = ContractExplainer(
MODEL, VECTORIZER, CLASS_NAMES, FEATURE_SELECTOR)
logger.info(f"Loaded model from: {path}")
return True, f"Loaded model: {os.path.basename(path)}"
except Exception as e:
last_error = str(e)
logger.exception("Failed loading model")
return False, last_error or "Model file not found. Place model under web/models/."
# ------------------------------
# Text extraction
# ------------------------------
def extract_text_from_pdf(file_path: str) -> str:
text = ""
try:
with pdfplumber.open(file_path) as pdf:
for page in pdf.pages:
page_text = page.extract_text()
if page_text:
text += page_text + "\n"
except Exception as e:
logger.warning(f"pdfplumber failed: {e}")
if text.strip():
return text.strip()
# OCR fallback
if not PYMUPDF_AVAILABLE:
return text.strip()
try:
doc = fitz.open(file_path)
for page_index in range(len(doc)):
page = doc.load_page(page_index)
pix = page.get_pixmap(matrix=fitz.Matrix(2, 2))
img = Image.open(io.BytesIO(pix.tobytes("png")))
text += pytesseract.image_to_string(img, lang="eng") + "\n"
doc.close()
except Exception as e:
logger.warning(f"OCR fallback failed: {e}")
return text.strip()
def extract_text_from_docx(file_path: str) -> str:
try:
doc = DocxDocument(file_path)
return "\n".join(p.text for p in doc.paragraphs).strip()
except Exception as e:
logger.warning(f"DOCX extraction failed: {e}")
return ""
def extract_text_from_doc(file_path: str) -> str:
# Best-effort: try antiword
try:
import subprocess
result = subprocess.run(["antiword", file_path],
capture_output=True, text=True)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return ""
def preprocess_text(text: str) -> str:
if not text:
raise ValueError("Empty text")
text = text.strip()
text = " ".join(text.split())
if len(text) < 10:
raise ValueError("Text too short for classification")
return text
# ------------------------------
# Inference and explanation
# ------------------------------
def classify_text(text: str, num_features: int = 1) -> Dict[str, Any]:
ok, msg = load_model_if_needed()
if not ok:
raise RuntimeError(f"Model not available: {msg}")
text = preprocess_text(text)
explanation = EXPLAINER.explain_prediction(text, num_features=num_features)
if not explanation.get("success"):
raise RuntimeError(explanation.get("error", "Explanation failed"))
# Compute prediction using the same preprocessing as the model (no full probs for speed)
features = VECTORIZER.transform([text])
if FEATURE_SELECTOR is not None:
features = FEATURE_SELECTOR.transform(features)
probs = MODEL.predict_proba(features)[0]
# Align predicted class using model.classes_
model_classes = list(getattr(MODEL, "classes_", CLASS_NAMES))
predicted_index = int(np.argmax(probs))
explanation["prediction"] = model_classes[predicted_index]
explanation["confidence"] = float(probs[predicted_index])
return explanation
def classify_text_fast(text: str) -> Dict[str, Any]:
"""Fast prediction without LIME (used for batch)."""
ok, msg = load_model_if_needed()
if not ok:
raise RuntimeError(f"Model not available: {msg}")
text = preprocess_text(text)
features = VECTORIZER.transform([text])
if FEATURE_SELECTOR is not None:
features = FEATURE_SELECTOR.transform(features)
probs = MODEL.predict_proba(features)[0]
# Use model-provided class order to avoid misalignment
model_classes = list(getattr(MODEL, "classes_", CLASS_NAMES))
predicted_index = int(np.argmax(probs))
predicted_class = model_classes[predicted_index]
confidence = float(probs[predicted_index])
return {
"prediction": predicted_class,
"confidence": confidence,
"class_probabilities": {cls: float(probs[i]) for i, cls in enumerate(model_classes)},
"text": text[:200] + "..." if len(text) > 200 else text,
}
def classify_file(tmp_path: str, mime_type: str, num_features: int = 10) -> Dict[str, Any]:
if mime_type == "application/pdf":
text = extract_text_from_pdf(tmp_path)
elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
text = extract_text_from_docx(tmp_path)
elif mime_type == "application/msword":
text = extract_text_from_doc(tmp_path)
else:
# Treat as plain text
with open(tmp_path, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
return classify_text(text, num_features=num_features)
def _extract_key_phrase_fast(text: str) -> str:
"""Approximate influential phrase quickly using top TF-IDF term and context."""
try:
tokens = VECTORIZER.transform([text])
if hasattr(tokens, "toarray"):
arr = tokens.toarray()[0]
else:
arr = tokens.A[0]
if arr.sum() == 0:
return ""
top_idx = int(arr.argmax())
feature_names = getattr(VECTORIZER, "get_feature_names_out", None)
if feature_names is None:
return ""
feat = VECTORIZER.get_feature_names_out()[top_idx]
# Build phrase around first occurrence
words = text.split()
feat_lower = feat.lower()
for i, w in enumerate(words):
if feat_lower in w.lower():
start_idx = max(0, i - 2)
end_idx = min(len(words), i + 4)
phrase = " ".join(words[start_idx:end_idx]).strip(
'.,!?;:"()[]{}')
if len(phrase.split()) >= 3:
return phrase
# fallback to sentence-level
break
# fallback: first sentence
for sep in [". ", "\n", "? ", "! "]:
if sep in text:
return text.split(sep, 1)[0].strip()
return text[:120]
except Exception:
return ""
def classify_file_fast(tmp_path: str, mime_type: str) -> Dict[str, Any]:
if mime_type == "application/pdf":
text = extract_text_from_pdf(tmp_path)
elif mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
text = extract_text_from_docx(tmp_path)
elif mime_type == "application/msword":
text = extract_text_from_doc(tmp_path)
else:
with open(tmp_path, "r", encoding="utf-8", errors="ignore") as f:
text = f.read()
result = classify_text_fast(text)
# Add fast key phrase extraction
result["key_phrase"] = _extract_key_phrase_fast(text)
return result
# ------------------------------
# Gradio UI callbacks
# ------------------------------
def predict_single(file_path: str):
if not file_path:
return "No file uploaded", None, None, None
try:
mime, _ = mimetypes.guess_type(file_path)
mime = mime or "text/plain"
result = classify_file(file_path, mime, num_features=1)
pred = f"Prediction: {result['prediction']} (confidence: {result['confidence']:.3f})"
# One-line influential statement
top_feats = result.get("important_features", [])
key_phrase = top_feats[0][0] if top_feats else _extract_key_phrase_fast(
result.get("full_text", ""))
html = result.get("explanation_html", "")
key_line = key_phrase
return pred, html, key_line
except Exception as e:
return f"Error: {e}", None, None
def predict_batch(file_paths: List[str], num_features: int):
if not file_paths:
return None, None
rows = []
for fp in file_paths:
try:
mime, _ = mimetypes.guess_type(fp)
mime = mime or "text/plain"
# Use fast prediction (no LIME) for batch speed
result = classify_file_fast(fp, mime)
key_phrase = ""
rows.append({
"filename": os.path.basename(fp),
"prediction": result["prediction"],
"confidence": float(result["confidence"]),
"key_phrase": result.get("key_phrase", key_phrase),
})
except Exception as e:
rows.append({
"filename": os.path.basename(fp),
"prediction": "",
"confidence": 0.0,
"key_phrase": f"Error: {e}",
})
df = pd.DataFrame(rows)
# Write CSV to a temporary file and return the path for DownloadButton
tmp_csv = tempfile.NamedTemporaryFile(
delete=False, suffix="_batch_results.csv")
try:
with open(tmp_csv.name, "w", encoding="utf-8", newline="") as f:
df.to_csv(f, index=False)
finally:
pass
return df, tmp_csv.name
# ------------------------------
# Build UI
# ------------------------------
with gr.Blocks(title="Contract Classifier") as demo:
gr.Markdown("""
**Contract Classification Dashboard**
- Upload single or multiple documents
- View prediction, probabilities, and highlighted influential text
- Download CSV for batch results
""")
with gr.Tab("Single Document"):
with gr.Row():
file_in = gr.File(
label="Upload document (PDF/DOCX/DOC/TXT)", type="filepath")
with gr.Row():
predict_btn = gr.Button("Predict")
with gr.Row():
pred_out = gr.Textbox(label="Prediction", lines=1)
with gr.Row():
html_out = gr.HTML(label="LIME Explanation (highlighted text)")
with gr.Row():
preview_out = gr.Textbox(label="Key Phrase", lines=6)
predict_btn.click(
predict_single,
inputs=[file_in],
outputs=[pred_out, html_out, preview_out]
)
with gr.Tab("Batch"):
with gr.Row():
files_in = gr.File(
label="Upload multiple documents", file_count="multiple", type="filepath")
with gr.Row():
batch_btn = gr.Button("Run Batch")
with gr.Row():
table_out = gr.Dataframe(label="Batch Results", interactive=False)
with gr.Row():
download_btn = gr.DownloadButton(
label="Download CSV")
def _batch_and_prepare(files):
df, csv_path = predict_batch(files, num_features=3)
return df, gr.update(value=csv_path)
batch_btn.click(
_batch_and_prepare,
inputs=[files_in],
outputs=[table_out, download_btn]
)
# Ensure model loads at launch for quicker first prediction
def _warmup():
ok, msg = load_model_if_needed()
return f"Model: {'ready' if ok else 'not ready'}{msg}"
warmup_status = gr.Markdown()
demo.load(
_warmup,
inputs=None,
outputs=warmup_status
)
if __name__ == "__main__":
# Let Gradio pick an available port automatically
demo.launch(server_name="0.0.0.0", show_api=False)