"""Single-file Gradio app for Multilingual ABSA.
Loads the ONNX model (INT8 → FP32) with a rule-based fallback, runs inference
via ``absa.pipeline.ABSAPipeline``, persists predictions to SQLite, and serves
a Gradio (gr.Blocks) UI. Run with ``python app.py``.
"""
from __future__ import annotations
import logging
import os
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import gradio as gr
from sqlalchemy import Column, DateTime, Float, Integer, String, create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
# Make the src-layout `absa` package importable when run directly
# (e.g. `python app.py`) without a prior `pip install -e .`. All `absa`
# imports below are lazy, so this bootstrap runs before any of them.
_SRC_DIR = Path(__file__).resolve().parent / "src"
if str(_SRC_DIR) not in sys.path:
sys.path.insert(0, str(_SRC_DIR))
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
_logger = logging.getLogger("absa.app")
# ── SQLAlchemy / SQLite ───────────────────────────────────────────────────
DB_PATH = "absa.db"
engine = create_engine(
f"sqlite:///{DB_PATH}",
connect_args={"check_same_thread": False},
)
Base = declarative_base()
Session = sessionmaker(bind=engine)
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
class Prediction(Base):
"""One aspect-sentiment pair extracted from a single review."""
__tablename__ = "predictions"
id = Column(Integer, primary_key=True)
text = Column(String, nullable=False)
language = Column(String, nullable=False)
aspect = Column(String, nullable=False)
sentiment = Column(String, nullable=False)
confidence = Column(Float, nullable=False)
created_at = Column(DateTime, default=_utcnow)
Base.metadata.create_all(engine)
# ── Lazy, failure-tolerant model loading ──────────────────────────────────
# Module-level singleton (cached for the app lifetime). If the ONNX models
# are missing the pipeline degrades to the rule-based engine — never crashes.
DEFAULT_MODEL_PATH = Path(os.getenv("MODEL_PATH", "models/onnx"))
def _load_pipeline() -> Any:
"""Import the ABSA pipeline lazily and attempt custom ONNX model load."""
try:
from absa.pipeline.absa_pipeline import ABSAPipeline
except Exception as exc: # pragma: no cover - import-time guard
_logger.error("Could not import ABSA pipeline: %s", exc)
raise
pipelin = ABSAPipeline()
try:
pipelin.load_models()
except Exception as exc:
_logger.warning("Custom model load failed (%s); using rule-based engine.", exc)
return pipelin
PIPELINE = _load_pipeline()
def _inference_mode() -> str:
"""Report which engine is active: ONNX INT8 / ONNX FP32 / rule-based."""
if PIPELINE.aspect_model is not None:
if (DEFAULT_MODEL_PATH / "aspect_extraction_int8").exists():
return "ONNX INT8"
if (DEFAULT_MODEL_PATH / "aspect_extraction").exists():
return "ONNX FP32"
return "ONNX"
return "rule-based"
MODE = _inference_mode()
def get_count() -> int:
"""Total predictions in SQLite."""
try:
session = Session()
return session.query(Prediction).count()
except Exception:
return 0
finally:
session.close()
def get_history_rows() -> list[list]:
"""Return last 50 predictions as DataFrame rows."""
try:
session = Session()
rows = session.query(Prediction).order_by(Prediction.id.desc()).limit(50).all()
return [
[
r.created_at.strftime("%Y-%m-%d %H:%M") if r.created_at else "—",
r.language or "—",
r.aspect or "—",
r.sentiment or "—",
round(r.confidence, 3) if r.confidence else 0.0,
(r.text[:60] + "...") if r.text and len(r.text) > 60 else (r.text or "—"),
]
for r in rows
]
except Exception:
return []
finally:
session.close()
def get_history_md() -> str:
return f"Showing last 50 predictions · Total: **{get_count()}**"
def clear_history() -> tuple[str, list]:
"""Wipe predictions table."""
try:
session = Session()
session.query(Prediction).delete()
session.commit()
except Exception:
pass
finally:
session.close()
return "History cleared · Total: **0**", []
def on_analyze(text: str) -> tuple[str, str, str, Any, list]:
"""Run inference, save to DB, return formatted UI outputs."""
if not text or not text.strip():
return (
'
Detected Language: —Latency: —
',
'Please enter a review to analyze.
',
'No analysis yet.
',
gr.update(visible=False, value={})
)
start = time.time()
try:
response = PIPELINE.predict(text)
aspects = response.aspects if hasattr(response, "aspects") else response.get("aspects", [])
language = response.detected_language if hasattr(response, "detected_language") else response.get("detected_language", "unknown")
elapsed_ms = (time.time() - start) * 1000
except Exception as e:
return (
'Detected Language: ErrorLatency: —
',
'Error during analysis.
',
f'Unable to analyze this review: {str(e)[:120]}
',
gr.update(visible=False, value={})
)
lang_display = {
"en": "English", "english": "English",
"hi": "Hindi", "hindi": "Hindi",
"hinglish": "Hinglish", "hi-en": "Hinglish",
}.get(str(language).lower(), str(language).title())
meta_md = f'Detected Language: {lang_display}Latency: {elapsed_ms:.0f} ms
'
pos_count = neg_count = neu_count = 0
table_rows = ""
for a in aspects:
aspect_text = a.get("aspect", "") if isinstance(a, dict) else getattr(a, "aspect", "")
sentiment = a.get("sentiment", "neutral") if isinstance(a, dict) else getattr(a, "sentiment", "neutral")
confidence = float(a.get("confidence", 0.0) if isinstance(a, dict) else getattr(a, "confidence", 0.0))
if sentiment == "positive":
pos_count += 1
sent_color = "#16A34A"
sent_label = "Positive"
elif sentiment == "negative":
neg_count += 1
sent_color = "#DC2626"
sent_label = "Negative"
else:
neu_count += 1
sent_color = "#64748B"
sent_label = "Neutral"
conf_pct = int(confidence * 100)
table_rows += f"""
| {aspect_text} |
{sent_label}
|
{conf_pct}%
|
"""
if not aspects:
results_html = 'No aspects detected in this review.
'
else:
results_html = f"""
| Aspect |
Sentiment |
Confidence |
{table_rows}
"""
total = len(aspects)
# Visualization blocks
vis_pos = f'Positive {"█" * pos_count} {pos_count}' if pos_count else 'Positive 0'
vis_neg = f'Negative {"█" * neg_count} {neg_count}' if neg_count else 'Negative 0'
vis_neu = f'Neutral {"█" * neu_count} {neu_count}' if neu_count else 'Neutral 0'
summary_md = f"""
{total} Aspects
{vis_pos}
{vis_neg}
{vis_neu}
"""
# Save to SQLite
try:
session = Session()
for a in aspects:
aspect_text = a.get("aspect", "") if isinstance(a, dict) else getattr(a, "aspect", "")
sentiment = a.get("sentiment", "neutral") if isinstance(a, dict) else getattr(a, "sentiment", "neutral")
confidence = a.get("confidence", 0.0) if isinstance(a, dict) else getattr(a, "confidence", 0.0)
session.add(Prediction(
text=text,
language=lang_display,
aspect=aspect_text,
sentiment=sentiment,
confidence=float(confidence or 0.0),
))
session.commit()
except Exception:
pass
finally:
session.close()
return (meta_md, summary_md, results_html, gr.update(visible=True, value=aspects))
# ── Gradio UI (gr.Blocks) ─────────────────────────────────────────────────
custom_theme = gr.themes.Soft(
primary_hue=gr.themes.colors.blue,
secondary_hue=gr.themes.colors.slate,
neutral_hue=gr.themes.colors.slate,
font=gr.themes.GoogleFont("Inter"),
).set(
body_background_fill="#F8FAFC",
body_background_fill_dark="#F8FAFC",
body_text_color="#0F172A",
body_text_color_dark="#0F172A",
block_background_fill="#FFFFFF",
block_background_fill_dark="#FFFFFF",
block_border_color="#E2E8F0",
block_border_color_dark="#E2E8F0",
block_label_background_fill="#FFFFFF",
block_label_background_fill_dark="#FFFFFF",
block_label_text_color="#0F172A",
block_label_text_color_dark="#0F172A",
button_primary_background_fill="#2563EB",
button_primary_background_fill_dark="#2563EB",
button_primary_text_color="#FFFFFF",
button_primary_text_color_dark="#FFFFFF",
button_primary_border_color="#2563EB",
button_primary_border_color_dark="#2563EB",
button_secondary_background_fill="#FFFFFF",
button_secondary_background_fill_dark="#FFFFFF",
button_secondary_text_color="#0F172A",
button_secondary_text_color_dark="#0F172A",
button_secondary_border_color="#CBD5E1",
button_secondary_border_color_dark="#CBD5E1",
input_background_fill="#FFFFFF",
input_background_fill_dark="#FFFFFF",
input_border_color="#CBD5E1",
input_border_color_dark="#CBD5E1",
panel_background_fill="#FFFFFF",
panel_background_fill_dark="#FFFFFF",
table_even_background_fill="#F8FAFC",
table_even_background_fill_dark="#F8FAFC",
table_odd_background_fill="#FFFFFF",
table_odd_background_fill_dark="#FFFFFF",
table_border_color="#E2E8F0",
table_border_color_dark="#E2E8F0",
border_color_primary="#E2E8F0",
border_color_primary_dark="#E2E8F0",
color_accent_soft="#EFF6FF",
color_accent_soft_dark="#EFF6FF",
)
custom_css = """
/* 60/30/10 Design System */
/* Layout & Base */
.gradio-container { max-width: 1200px !important; margin: auto; font-family: 'Inter', sans-serif; padding: 24px !important; }
/* Typography */
.section-title { font-size: 1.1rem; font-weight: 600; color: #0F172A; margin-bottom: 4px; }
.section-sub { font-size: 0.9rem; color: #64748B; margin-bottom: 16px; }
/* Top Header */
.top-header { border-bottom: 1px solid #E2E8F0; padding-bottom: 16px; margin-bottom: 24px; align-items: center; }
.header-content h1 { font-size: 1.6rem; font-weight: 600; color: #0F172A; margin: 0 0 4px 0; letter-spacing: -0.02em; }
.header-subtitle { font-size: 0.9rem; color: #64748B; }
/* Header Metrics */
.header-metrics { display: flex; gap: 24px; justify-content: flex-end; flex-wrap: wrap; }
.metric { display: flex; flex-direction: column; }
.m-label { font-size: 0.65rem; font-weight: 600; color: #64748B; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
.m-val { font-size: 0.85rem; font-weight: 500; color: #0F172A; }
.status-dot { color: #16A34A; margin-right: 4px; }
/* Panels */
.workspace-row { gap: 32px !important; align-items: flex-start !important; }
.panel { background: #FFFFFF; border: 1px solid #E2E8F0; border-radius: 8px; padding: 24px; box-shadow: 0 1px 2px rgba(0,0,0,0.02); }
/* Review Input */
.review-input textarea { border: 1px solid #CBD5E1 !important; border-radius: 6px !important; padding: 12px !important; font-size: 0.95rem !important; line-height: 1.5 !important; background: #FFFFFF !important; color: #0F172A !important; transition: border-color 0.2s; box-shadow: none !important; }
.review-input textarea:focus { border-color: #2563EB !important; ring: 1px solid #2563EB !important; }
.analyze-btn { background: #2563EB !important; color: #FFFFFF !important; font-weight: 500 !important; border-radius: 6px !important; margin-top: 16px !important; padding: 10px 0 !important; border: none !important; transition: background 0.2s !important; }
.analyze-btn:hover { background: #1D4ED8 !important; }
/* Examples */
.compact-examples .gallery { gap: 8px !important; }
.compact-examples button { border: 1px solid #E2E8F0 !important; border-radius: 6px !important; padding: 8px 12px !important; font-size: 0.8rem !important; background: #F8FAFC !important; color: #475569 !important; text-align: left !important; }
.compact-examples button:hover { background: #F1F5F9 !important; border-color: #CBD5E1 !important; }
/* Meta Badges */
.meta-badges { display: flex; gap: 12px; margin-bottom: 24px; }
.badge { font-size: 0.75rem; background: #F1F5F9; color: #475569; padding: 4px 10px; border-radius: 4px; border: 1px solid #E2E8F0; }
.badge strong { color: #0F172A; font-weight: 600; margin-left: 4px; }
/* Summary Vis */
.summary-vis { margin-bottom: 24px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 0.8rem; }
.summary-title { font-family: 'Inter', sans-serif; font-size: 0.9rem; font-weight: 600; color: #0F172A; margin-bottom: 12px; border-bottom: 1px solid #E2E8F0; padding-bottom: 8px; }
.vis-row { margin-bottom: 6px; display: flex; align-items: center; gap: 12px; color: #475569; }
.vis-block { letter-spacing: -1px; font-size: 0.7rem; }
.vis-block.pos { color: #16A34A; }
.vis-block.neg { color: #DC2626; }
.vis-block.neu { color: #64748B; }
.vis-block.empty-block { visibility: hidden; width: 10px; }
.summary-vis.empty { font-family: 'Inter', sans-serif; color: #64748B; font-style: italic; }
/* Results Table */
.results-table { width: 100%; border-collapse: collapse; margin-bottom: 16px; font-size: 0.85rem; }
.results-table th { text-align: left; padding: 8px 12px; border-bottom: 1px solid #E2E8F0; color: #64748B; font-weight: 600; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.05em; }
.results-table td { padding: 12px; border-bottom: 1px solid #F1F5F9; color: #0F172A; }
.aspect-col { font-weight: 500; }
.sentiment-col { display: flex; align-items: center; gap: 6px; }
.sent-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
/* Confidence Bar */
.conf-col { min-width: 120px; }
.conf-bar-bg { width: 60px; height: 6px; background: #E2E8F0; border-radius: 3px; display: inline-block; vertical-align: middle; margin-right: 8px; overflow: hidden; }
.conf-bar-fill { height: 100%; background: #2563EB; border-radius: 3px; }
.conf-val { font-family: ui-monospace, monospace; font-size: 0.8rem; color: #475569; }
.empty-state { padding: 32px 0; text-align: center; color: #64748B; font-size: 0.9rem; border: 1px dashed #CBD5E1; border-radius: 6px; }
/* Raw Output Accordion */
.raw-accordion { margin-top: 24px !important; border: 1px solid #E2E8F0 !important; border-radius: 6px !important; overflow: hidden !important; background: #FFFFFF !important; }
.raw-accordion .label-wrap { padding: 12px 16px !important; font-size: 0.85rem !important; font-weight: 600 !important; color: #0F172A !important; background: #F8FAFC !important; border-bottom: 1px solid #E2E8F0 !important; }
.raw-accordion .raw-json { background: #0F172A !important; padding: 16px !important; margin: 0 !important; }
.raw-accordion .raw-json * { color: #F8FAFC !important; font-family: ui-monospace, SFMono-Regular, monospace !important; font-size: 0.8rem !important; }
/* History Table */
.history-table { border: 1px solid #E2E8F0 !important; border-radius: 8px !important; overflow: hidden !important; margin-top: 16px !important; }
.history-table th { background: #F8FAFC !important; color: #475569 !important; font-size: 0.75rem !important; text-transform: uppercase !important; font-weight: 600 !important; }
/* Mobile Stacking */
@media (max-width: 768px) {
.workspace-row { flex-direction: column !important; }
.panel { width: 100% !important; }
.header-metrics { justify-content: flex-start; margin-top: 16px; }
}
"""
with gr.Blocks(title="Multilingual ABSA", theme=custom_theme, css=custom_css) as demo:
# TOP HEADER
with gr.Row(elem_classes="top-header"):
with gr.Column(scale=3, elem_classes="header-title-col"):
gr.Markdown("""
""")
with gr.Column(scale=2, elem_classes="header-metrics-col"):
gr.Markdown(f"""
""")
# WORKSPACE
with gr.Tabs(elem_classes="main-tabs"):
with gr.Tab("Workspace"):
with gr.Row(elem_classes="workspace-row"):
# LEFT PANEL
with gr.Column(scale=1, elem_classes="panel panel-left"):
gr.Markdown('Analyze Review
Enter a product review to identify aspects and their sentiment.
')
inp = gr.Textbox(
label="",
placeholder="Example: The battery life is amazing, but the camera quality is poor.",
lines=6,
max_lines=15,
elem_classes="review-input",
show_label=False,
)
btn = gr.Button("Analyze Review", variant="primary", elem_classes="analyze-btn")
gr.Markdown('Examples
')
gr.Examples(
examples=[
["The battery life is amazing but the camera quality is poor."],
["बैटरी बहुत अच्छी है लेकिन कैमरा क्वालिटी खराब है।"],
["Battery life mast hai lekin camera quality bahut bekar hai."],
],
inputs=inp,
label=""
)
# RIGHT PANEL
with gr.Column(scale=1, elem_classes="panel panel-right"):
gr.Markdown('Analysis Results
')
meta_md = gr.Markdown('Detected Language: WaitingLatency: —
')
summary_md = gr.Markdown('Waiting for analysis...
')
results_html = gr.HTML('No analysis yet.
')
with gr.Accordion("Raw Model Output", open=False, elem_classes="raw-accordion"):
json_out = gr.JSON(label="", visible=False, elem_classes="raw-json")
btn.click(
fn=lambda: (
'Detected Language: Analyzing...Latency: —
',
'Analyzing review...
',
'Analyzing...
'
),
outputs=[meta_md, summary_md, results_html]
).then(
fn=on_analyze,
inputs=inp,
outputs=[meta_md, summary_md, results_html, json_out]
)
# HISTORY TAB
with gr.Tab("History"):
with gr.Row():
refresh_btn = gr.Button("Refresh Data", size="sm", variant="secondary")
clear_btn = gr.Button("Clear History", size="sm", variant="secondary")
history_df = gr.Dataframe(
headers=["Timestamp", "Language", "Aspect", "Sentiment", "Confidence", "Review Preview"],
datatype=["str", "str", "str", "str", "number", "str"],
value=get_history_rows(),
interactive=False,
wrap=True,
elem_classes="history-table"
)
# Now we can attach the `.then` to update history_df
btn.click(fn=lambda: None).then(fn=get_history_rows, outputs=history_df)
refresh_btn.click(fn=get_history_rows, outputs=history_df)
clear_btn.click(fn=clear_history, outputs=[history_df]).then(fn=get_history_rows, outputs=history_df)
if __name__ == "__main__":
demo.launch(ssr_mode=False)