# 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 "
No structured data extracted.
"
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] = ['']
title = structured.get("document_title")
doc_type = structured.get("document_type")
if title or doc_type:
parts.append('
")
if not page:
parts.append("
No page data available.
")
return "".join(parts)
if page.get("parse_error") and page.get("raw_text"):
parts.append(
''
"Could not parse structured JSON for this page. Showing raw extraction below."
"
"
)
parts.append(f'{_esc(page["raw_text"])}')
parts.append("")
return "".join(parts)
sections = page.get("sections") or []
if not sections:
raw = page.get("raw_text")
if raw:
parts.append(f'{_esc(raw)}')
else:
parts.append("No sections detected on this page.
")
parts.append("")
return "".join(parts)
for section in sections:
parts.append('')
parts.append(f'{_esc(section.get("title", "Section"))}
')
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''
f'
{_esc(key)}'
f'{_esc(value)}'
f""
)
else:
headers = section.get("headers") or []
rows = section.get("rows") or []
parts.append('')
if headers:
parts.append("")
for header in headers:
parts.append(f"| {_esc(header)} | ")
parts.append("
")
parts.append("")
for row in rows:
parts.append("")
for cell in row:
parts.append(f"| {_esc(cell)} | ")
parts.append("
")
parts.append("
")
parts.append("")
parts.append("")
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'p.{page}' if page else ""
chips.append(
f''
f'📄'
f'{_esc(short)}'
f"{page_tag}"
f""
)
n = len(unique)
label = f"{n} source{'s' if n != 1 else ''} cited"
return (
f''
f'📄 {_esc(label)}
'
f'{"".join(chips)}
'
f" "
)
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'{score:.1f}/10'
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"\1", escaped)
escaped = re.sub(r"(?\1", escaped)
blocks = escaped.split("\n\n")
parts = []
for block in blocks:
block = block.replace("\n", "
")
parts.append(f"{block}
")
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 = ['']
for msg in messages:
role = msg.get("role")
content = msg.get("content") or ""
if role == "user":
parts.append('
')
parts.append('
')
parts.append(_render_inline_markdown(content))
parts.append("
")
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 = '
⏳ Thinking…
'
elif clean_model_response(content):
body = _render_inline_markdown(clean_model_response(content))
elif content.strip():
body = _render_inline_markdown(content)
else:
body = '
⏳ Thinking…
'
confidence = msg.get("confidence")
sources = msg.get("sources") or []
parts.append('
')
parts.append('
')
parts.append('")
parts.append(f'
{body}
')
if sources:
parts.append(render_sources(sources))
parts.append("
")
parts.append("
")
return "".join(parts)
def render_entities(entities: dict) -> str:
parts = ['']
companies = entities.get("company_names") or []
if companies:
parts.append('
COMPANIES
')
parts.append('
')
for name in companies:
parts.append(f'{_esc(name)}')
parts.append("
")
tickers = entities.get("tickers") or []
if tickers:
parts.append('
TICKERS
')
parts.append('
')
for ticker in tickers:
parts.append(f'{_esc(ticker)}')
parts.append("
")
periods = entities.get("reporting_periods") or []
if periods:
parts.append('
REPORTING PERIODS
')
parts.append('
')
for period in periods:
parts.append(f'{_esc(period)}')
parts.append("
")
key_figures = entities.get("key_figures") or {}
figure_labels = [
("revenue", "REVENUE"),
("ebitda", "EBITDA"),
("eps", "EPS"),
("net_income", "NET INCOME"),
("margins", "MARGINS"),
]
parts.append('
KEY FIGURES
')
parts.append('
')
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'
'
f'
{label}
'
f'
{value}
'
f"
"
)
parts.append("
")
if entities.get("raw_response") and not companies and not key_figures:
parts.append(
f'
{_esc(entities["raw_response"])}'
)
parts.append("
")
return "".join(parts)
def render_doc_list(rows: list[list[str]], selected: list[str] | None = None) -> str:
selected = selected or []
if not rows:
return 'No documents indexed yet.
'
parts = ['']
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'- ')
parts.append('✓')
parts.append('📄')
parts.append('
")
parts.append("
")
return "".join(parts)