FinSightAI / backend /gradio_ui /renderers.py
Aniket2003333333's picture
start
7248d39
Raw
History Blame Contribute Delete
11.9 kB
# renderers.py
"""Render structured OCR JSON as HTML for Gradio."""
from __future__ import annotations
import html
import re
from typing import Any, Dict, List, Optional
from gradio_ui.state import EMPTY_STATE_HTML
from utils.response_cleaner import clean_model_response
def _esc(value: Any) -> str:
return html.escape(str(value) if value is not None else "")
def format_doc_type(doc_type: str) -> str:
if not doc_type:
return "Unknown"
return doc_type.replace("_", " ").title()
def render_structured_ocr(structured: Dict[str, Any], page_num: int = 1) -> str:
if not structured:
return "<p>No structured data extracted.</p>"
pages: List[Dict[str, Any]] = structured.get("pages") or []
page = next((p for p in pages if p.get("page_number") == page_num), None)
if page is None and pages:
page = pages[0]
page_num = page.get("page_number", 1)
parts: List[str] = ['<div class="ocr-structured">']
title = structured.get("document_title")
doc_type = structured.get("document_type")
if title or doc_type:
parts.append('<header style="margin-bottom:1rem;">')
if title:
parts.append(f'<p style="font-weight:600;color:#111827;">{_esc(title)}</p>')
if doc_type:
parts.append(
f'<p style="font-size:0.75rem;color:#9ca3af;">Document type: '
f'<span style="color:#374151;">{_esc(format_doc_type(doc_type))}</span></p>'
)
parts.append("</header>")
if not page:
parts.append("<p>No page data available.</p></div>")
return "".join(parts)
if page.get("parse_error") and page.get("raw_text"):
parts.append(
'<div style="padding:0.75rem;border:1px solid #fcd34d;background:#fffbeb;'
'border-radius:8px;margin-bottom:1rem;font-size:0.875rem;color:#92400e;">'
"Could not parse structured JSON for this page. Showing raw extraction below."
"</div>"
)
parts.append(f'<pre class="font-data" style="white-space:pre-wrap;">{_esc(page["raw_text"])}</pre>')
parts.append("</div>")
return "".join(parts)
sections = page.get("sections") or []
if not sections:
raw = page.get("raw_text")
if raw:
parts.append(f'<pre class="font-data" style="white-space:pre-wrap;">{_esc(raw)}</pre>')
else:
parts.append("<p>No sections detected on this page.</p>")
parts.append("</div>")
return "".join(parts)
for section in sections:
parts.append('<section class="ocr-section">')
parts.append(f'<div class="ocr-section-title">{_esc(section.get("title", "Section"))}</div>')
section_type = section.get("type", "key_value")
if section_type == "key_value":
fields = section.get("fields") or {}
for key, value in fields.items():
parts.append(
f'<div class="ocr-kv-row">'
f'<dt>{_esc(key)}</dt>'
f'<dd>{_esc(value)}</dd>'
f"</div>"
)
else:
headers = section.get("headers") or []
rows = section.get("rows") or []
parts.append('<div class="ocr-table-wrap"><table class="ocr-table">')
if headers:
parts.append("<thead><tr>")
for header in headers:
parts.append(f"<th>{_esc(header)}</th>")
parts.append("</tr></thead>")
parts.append("<tbody>")
for row in rows:
parts.append("<tr>")
for cell in row:
parts.append(f"<td>{_esc(cell)}</td>")
parts.append("</tr>")
parts.append("</tbody></table></div>")
parts.append("</section>")
parts.append("</div>")
return "".join(parts)
def _truncate_name(name: str, max_len: int = 32) -> str:
"""Shorten long filenames/IDs for display."""
if len(name) <= max_len:
return name
ext = ""
if "." in name:
ext = name[name.rfind("."):]
base = name[:name.rfind(".")]
else:
base = name
keep = max_len - len(ext) - 3 # room for "..."
if keep < 8:
keep = 8
return base[:keep] + "…" + ext
def render_sources(sources: List[Dict[str, Any]]) -> str:
if not sources:
return ""
seen = set()
unique = []
for src in sources:
name = src.get("document_name") or src.get("document_id") or "Document"
page = src.get("page_number")
key = (name, page)
if key not in seen:
seen.add(key)
unique.append(src)
unique = unique[:6]
chips = []
for src in unique:
name = src.get("document_name") or src.get("document_id") or "Document"
short = _truncate_name(name)
page = src.get("page_number")
page_tag = f'<span class="src-page">p.{page}</span>' if page else ""
chips.append(
f'<span class="src-chip">'
f'<span class="src-icon">📄</span>'
f'<span class="src-name">{_esc(short)}</span>'
f"{page_tag}"
f"</span>"
)
n = len(unique)
label = f"{n} source{'s' if n != 1 else ''} cited"
return (
f'<details class="sources-cited" open>'
f'<summary><span class="src-summary-icon">📄</span> {_esc(label)}</summary>'
f'<div class="sources-chips">{"".join(chips)}</div>'
f"</details>"
)
def _confidence_score(confidence: float) -> float:
"""Modal LLM returns 1–10; older paths may use 0–1."""
if confidence <= 1.0:
return max(1.0, min(10.0, confidence * 10))
return max(1.0, min(10.0, confidence))
def render_confidence(confidence: float | None) -> str:
if confidence is None:
return ""
score = _confidence_score(confidence)
return f'<span class="conf-score-badge">{score:.1f}/10</span>'
def _confidence_badge_class(confidence: float) -> str:
score = _confidence_score(confidence)
if score >= 8:
return "conf-badge conf-badge-high"
if score >= 5:
return "conf-badge conf-badge-mid"
return "conf-badge conf-badge-low"
def _render_inline_markdown(text: str) -> str:
"""Minimal safe markdown: paragraphs, bold, italic, line breaks."""
if not text:
return ""
escaped = html.escape(text)
escaped = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", escaped)
escaped = re.sub(r"(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)", r"<em>\1</em>", escaped)
blocks = escaped.split("\n\n")
parts = []
for block in blocks:
block = block.replace("\n", "<br>")
parts.append(f"<p>{block}</p>")
return "".join(parts)
def render_chat_transcript(messages: List[Dict[str, Any]]) -> str:
"""Render full QA conversation as HTML bubbles (replaces gr.Chatbot)."""
if not messages:
return EMPTY_STATE_HTML
parts = ['<div class="fs-chat-log">']
for msg in messages:
role = msg.get("role")
content = msg.get("content") or ""
if role == "user":
parts.append('<div class="fs-chat-row fs-chat-row-user">')
parts.append('<div class="fs-chat-bubble fs-chat-bubble-user">')
parts.append(_render_inline_markdown(content))
parts.append("</div></div>")
continue
if role != "assistant":
continue
display = content
is_thinking = content == "⏳ *Thinking…*" or (
not clean_model_response(content) and "Thinking" in content
)
if is_thinking:
body = '<p class="fs-chat-thinking"><em>⏳ Thinking…</em></p>'
elif clean_model_response(content):
body = _render_inline_markdown(clean_model_response(content))
elif content.strip():
body = _render_inline_markdown(content)
else:
body = '<p class="fs-chat-thinking"><em>⏳ Thinking…</em></p>'
confidence = msg.get("confidence")
sources = msg.get("sources") or []
parts.append('<div class="fs-chat-row fs-chat-row-bot">')
parts.append('<div class="fs-chat-bubble fs-chat-bubble-bot">')
parts.append('<div class="fs-chat-bot-header">')
parts.append('<span class="fs-chat-bot-label">FinSight AI</span>')
if confidence is not None:
score = _confidence_score(float(confidence))
badge_cls = _confidence_badge_class(float(confidence))
parts.append(
f'<span class="{badge_cls}">{score:.1f}/10</span>'
)
parts.append("</div>")
parts.append(f'<div class="fs-chat-bot-body">{body}</div>')
if sources:
parts.append(render_sources(sources))
parts.append("</div></div>")
parts.append("</div>")
return "".join(parts)
def render_entities(entities: dict) -> str:
parts = ['<div class="entity-results">']
companies = entities.get("company_names") or []
if companies:
parts.append('<div class="entity-section-title">COMPANIES</div>')
parts.append('<div class="entity-pills">')
for name in companies:
parts.append(f'<span class="entity-pill">{_esc(name)}</span>')
parts.append("</div>")
tickers = entities.get("tickers") or []
if tickers:
parts.append('<div class="entity-section-title">TICKERS</div>')
parts.append('<div class="entity-pills">')
for ticker in tickers:
parts.append(f'<span class="entity-pill entity-pill-muted">{_esc(ticker)}</span>')
parts.append("</div>")
periods = entities.get("reporting_periods") or []
if periods:
parts.append('<div class="entity-section-title">REPORTING PERIODS</div>')
parts.append('<div class="entity-pills">')
for period in periods:
parts.append(f'<span class="entity-pill entity-pill-muted">{_esc(period)}</span>')
parts.append("</div>")
key_figures = entities.get("key_figures") or {}
figure_labels = [
("revenue", "REVENUE"),
("ebitda", "EBITDA"),
("eps", "EPS"),
("net_income", "NET INCOME"),
("margins", "MARGINS"),
]
parts.append('<div class="entity-section-title">KEY FIGURES</div>')
parts.append('<div class="entity-cards">')
for key, label in figure_labels:
raw = key_figures.get(key)
value = _esc(raw) if raw not in (None, "", "null") else "N/A"
parts.append(
f'<div class="entity-card">'
f'<div class="entity-card-label">{label}</div>'
f'<div class="entity-card-value">{value}</div>'
f"</div>"
)
parts.append("</div>")
if entities.get("raw_response") and not companies and not key_figures:
parts.append(
f'<pre class="entity-raw">{_esc(entities["raw_response"])}</pre>'
)
parts.append("</div>")
return "".join(parts)
def render_doc_list(rows: list[list[str]], selected: list[str] | None = None) -> str:
selected = selected or []
if not rows:
return '<p class="doc-list-empty">No documents indexed yet.</p>'
parts = ['<ul class="doc-list">']
for doc_id, name, chunks in rows:
is_selected = doc_id in selected
row_cls = "doc-row doc-row-selected" if is_selected else "doc-row"
parts.append(f'<li class="{row_cls}">')
parts.append('<span class="doc-status">✓</span>')
parts.append('<span class="doc-file-icon">📄</span>')
parts.append('<div class="doc-meta">')
parts.append(f'<div class="doc-name">{_esc(name or doc_id)}</div>')
parts.append(f'<div class="doc-chunks">{_esc(chunks)} chunks</div>')
parts.append("</div></li>")
parts.append("</ul>")
return "".join(parts)