| |
| """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 |
| 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) |
|
|