# 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 title: parts.append(f'

{_esc(title)}

') if doc_type: parts.append( f'

Document type: ' f'{_esc(format_doc_type(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"") parts.append("") parts.append("") for row in rows: parts.append("") for cell in row: parts.append(f"") parts.append("") parts.append("
{_esc(header)}
{_esc(cell)}
") 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('FinSight AI') if confidence is not None: score = _confidence_score(float(confidence)) badge_cls = _confidence_badge_class(float(confidence)) parts.append( f'{score:.1f}/10' ) 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 = ['") return "".join(parts)