diff --git a/apps/api/src/synthora/api/export.py b/apps/api/src/synthora/api/export.py index 32eefa4..eb28b07 100644 --- a/apps/api/src/synthora/api/export.py +++ b/apps/api/src/synthora/api/export.py @@ -14,8 +14,14 @@ body {{ font-family: Georgia, serif; max-width: 46rem; margin: 3rem auto; line-height: 1.6; color: #1c2a30; padding: 0 1.5rem; }} h1, h2, h3 {{ font-family: Georgia, serif; letter-spacing: -0.01em; }} code {{ font-family: ui-monospace, monospace; background: #f3f1ea; padding: 0.1em 0.3em; border-radius: 3px; }} + pre {{ background: #f3f1ea; padding: 0.75rem 1rem; overflow-x: auto; border-radius: 4px; }} + pre code {{ background: transparent; padding: 0; }} a {{ color: #0e6f6a; }} blockquote {{ border-left: 3px solid #0e6f6a; margin-left: 0; padding-left: 1rem; color: #3d5059; }} + table {{ border-collapse: collapse; width: 100%; margin: 1rem 0; }} + th, td {{ border: 1px solid #d8d2c4; padding: 0.35rem 0.6rem; text-align: left; }} + th {{ background: #f3f1ea; }} + hr {{ border: none; border-top: 1px solid #d8d2c4; margin: 1.5rem 0; }} @media print {{ body {{ margin: 0.5in; max-width: none; }} }} @@ -27,38 +33,114 @@ def markdown_to_html(markdown: str) -> str: - """Minimal dependency-free Markdown -> HTML for report export. + """Dependency-free Markdown -> HTML for report export. - Covers the subset our writers emit: headings, paragraphs, lists, - bold/italic, inline code, and links. + Covers headings, paragraphs, bullet/ordered lists, blockquotes, fenced + code blocks, tables, bold/italic, inline code, and links. """ lines = markdown.splitlines() out: list[str] = [] - in_list = False - for line in lines: + i = 0 + list_kind: str | None = None # "ul" | "ol" + + def close_list() -> None: + nonlocal list_kind + if list_kind: + out.append(f"") + list_kind = None + + while i < len(lines): + line = lines[i] stripped = line.strip() + + if stripped.startswith("```"): + close_list() + fence = stripped[3:].strip() + i += 1 + code_lines: list[str] = [] + while i < len(lines) and not lines[i].strip().startswith("```"): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + code = html_lib.escape("\n".join(code_lines)) + lang = html_lib.escape(fence) if fence else "" + cls = f' class="language-{lang}"' if lang else "" + out.append(f"
{code}
") + continue + + if stripped.startswith("|") and "|" in stripped[1:]: + close_list() + table_rows: list[str] = [] + while i < len(lines) and lines[i].strip().startswith("|"): + table_rows.append(lines[i].strip()) + i += 1 + if len(table_rows) >= 2 and re.match(r"^\|[-: |]+\|$", table_rows[1]): + table_rows.pop(1) + if table_rows: + out.append("") + for row_idx, row in enumerate(table_rows): + cells = [c.strip() for c in row.strip("|").split("|")] + tag = "th" if row_idx == 0 else "td" + out.append("") + for cell in cells: + out.append(f"<{tag}>{_inline(cell)}") + out.append("") + out.append("
") + continue + heading = re.match(r"^(#{1,6})\s+(.*)$", stripped) if heading: - if in_list: - out.append("") - in_list = False + close_list() level = len(heading.group(1)) out.append(f"{_inline(heading.group(2))}") + i += 1 + continue + + if re.match(r"^[-*]{3,}$", stripped): + close_list() + out.append("
") + i += 1 + continue + + if stripped.startswith(">"): + close_list() + quote_lines: list[str] = [] + while i < len(lines) and lines[i].strip().startswith(">"): + quote_lines.append(lines[i].strip().lstrip(">").lstrip()) + i += 1 + out.append( + f"

{_inline(' '.join(quote_lines))}

" + ) + continue + + ordered = re.match(r"^(\d+)\.\s+(.*)$", stripped) + if ordered: + if list_kind != "ol": + close_list() + out.append("
    ") + list_kind = "ol" + out.append(f"
  1. {_inline(ordered.group(2))}
  2. ") + i += 1 continue + if stripped.startswith(("- ", "* ")): - if not in_list: + if list_kind != "ul": + close_list() out.append("") - in_list = False + + close_list() if not stripped: + i += 1 continue out.append(f"

    {_inline(stripped)}

    ") - if in_list: - out.append("") + i += 1 + + close_list() return "\n".join(out) @@ -79,26 +161,17 @@ def render_html_document(markdown: str, *, title: str) -> str: ) -def _plain_from_markdown(markdown: str) -> str: - """Strip common markdown markers for PDF core fonts.""" - text = re.sub(r"^#{1,6}\s+", "", markdown, flags=re.MULTILINE) - text = re.sub(r"\*\*(.+?)\*\*", r"\1", text) - text = re.sub(r"(? str: return text.encode("latin-1", "replace").decode("latin-1") def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes: - """Render markdown to PDF bytes using fpdf2 (no network).""" + """Render markdown to PDF bytes using fpdf2 HTML layout (no network).""" from fpdf import FPDF from fpdf.enums import XPos, YPos pdf = FPDF() + pdf.set_compression(False) pdf.set_auto_page_break(auto=True, margin=15) pdf.set_margins(15, 15, 15) pdf.add_page() @@ -112,17 +185,8 @@ def markdown_to_pdf_bytes(markdown: str, *, title: str = "Report") -> bytes: ) pdf.ln(4) pdf.set_font("Helvetica", size=11) - body = _plain_from_markdown(markdown or "") - for line in body.splitlines() or [""]: - if not line.strip(): - pdf.ln(6) - continue - pdf.multi_cell( - 0, - 6, - _pdf_safe(line), - new_x=XPos.LMARGIN, - new_y=YPos.NEXT, - ) + body_html = markdown_to_html(markdown or "") + if body_html.strip(): + pdf.write_html(_pdf_safe(body_html)) out = pdf.output() return bytes(out) diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 540a2f6..efe7298 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -456,6 +456,19 @@ export const api = { body: JSON.stringify({ query, max_results: maxResults }), }, ), + + health: () => request<{ status: string }>("/health"), + ready: () => request<{ status: string }>("/ready"), + mcpToolsList: () => + request<{ tools: Array> }>( + "/api/v1/mcp/tools/list", + { method: "POST", body: "{}" }, + ), + mcpToolsCall: (name: string, args: Record = {}) => + request<{ content: string }>("/api/v1/mcp/tools/call", { + method: "POST", + body: JSON.stringify({ name, arguments: args }), + }), }; export function eventsSocketUrl(runId: string): string { diff --git a/apps/web/src/components/History.tsx b/apps/web/src/components/History.tsx index 298e81b..39bb140 100644 --- a/apps/web/src/components/History.tsx +++ b/apps/web/src/components/History.tsx @@ -4,14 +4,22 @@ import { api, RunSummary, SessionSummary } from "../api"; export function History({ onOpen }: { onOpen: (runId: string) => void }) { const [runs, setRuns] = useState([]); const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState( + null, + ); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); - const load = useCallback(() => { + const loadRuns = useCallback((sessionFilter?: string | null) => { + const filter = + sessionFilter !== undefined ? sessionFilter : selectedSessionId; api - .listRuns() + .listRuns(filter || undefined) .then(setRuns) .catch((e) => setError(String(e))); + }, [selectedSessionId]); + + const loadSessions = useCallback(() => { api .listSessions() .then(setSessions) @@ -21,8 +29,9 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) { }, []); useEffect(() => { - load(); - }, [load]); + loadRuns(); + loadSessions(); + }, [loadRuns, loadSessions]); async function handleDelete(runId: string, e: MouseEvent) { e.stopPropagation(); @@ -46,6 +55,10 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) { try { await api.deleteSession(sessionId); setSessions((prev) => prev.filter((s) => s.id !== sessionId)); + if (selectedSessionId === sessionId) { + setSelectedSessionId(null); + loadRuns(null); + } } catch (err) { setError(String(err)); } @@ -65,6 +78,22 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) { } } + async function handleSelectSession(sessionId: string) { + setError(null); + if (selectedSessionId === sessionId) { + setSelectedSessionId(null); + loadRuns(null); + return; + } + setSelectedSessionId(sessionId); + try { + const detail = await api.getSession(sessionId); + setRuns(detail.runs); + } catch (err) { + setError(String(err)); + } + } + function sessionTitle(id: string | null): string { if (!id) return "—"; const match = sessions.find((s) => s.id === id); @@ -75,6 +104,18 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) {

    Research history

    + {selectedSessionId && ( + + )} {runs.length > 0 && ( )}
    + {selectedSessionId && ( +

    + Showing runs for session:{" "} + {sessionTitle(selectedSessionId)} +

    + )} {error &&

    {error}

    } {runs.length === 0 && !error &&

    No research yet.

    } {runs.length > 0 && ( @@ -143,7 +190,14 @@ export function History({ onOpen }: { onOpen: (runId: string) => void }) { {sessions.map((s) => (
  3. - {s.title} + {" "} · {s.id.slice(0, 8)}… diff --git a/docs/feature-parity.md b/docs/feature-parity.md index df825f2..63d6614 100644 --- a/docs/feature-parity.md +++ b/docs/feature-parity.md @@ -59,7 +59,7 @@ See also [parity-audit.md](parity-audit.md). | Search strategy abstraction (5 strategies + aliases) | ✅ | `strategy_registry` | | Search engine abstraction (full catalog) | ✅ | 29 registered engines | | LLM provider abstraction + think-tag handling | ✅ | 11 providers | -| Research history + export md/html/pdf | ✅ | API + web buttons | +| Research history + export md/html/pdf | ✅ | API + web buttons; structured HTML/PDF export | | Docker Compose self-host | ✅ | `docker-compose.yml` | | Python SDK | ✅ | `packages/sdk` — full REST mirror incl. upload, export download, news GET | | Document library + RAG (`collection` engine) | ✅ | documents API + `document_index` | @@ -125,6 +125,11 @@ bytes), ``get_news_subscription``, ``search_documents(max_results=...)``, ``health``/``ready``; web client ``getNewsSubscription`` and search ``max_results``; streamable MCP workspace isolation test. +Closed on ``feat/export-and-web-parity``: full markdown export subset (fenced +code, blockquotes, ordered lists, tables, rules); PDF via fpdf2 ``write_html`` +(preserves structure); web ``health``/``ready``/MCP REST wrappers; History +session drill-down via ``getSession``. + No known functional gaps remain beyond explicit non-goals below. Chat remains session-scoped ``fast_research`` with prior-report memory — diff --git a/tests/test_export.py b/tests/test_export.py index aed86fd..c9e18e8 100644 --- a/tests/test_export.py +++ b/tests/test_export.py @@ -18,6 +18,27 @@ ## Sources """ +RICH = """# Rich export + +> A quoted insight + +```python +def hello(): + return "world" +``` + +1. ordered one +2. ordered two + +| Col A | Col B | +| --- | --- | +| a1 | b1 | + +--- + +Final **note**. +""" + def test_markdown_to_html_structure(): html = markdown_to_html(SAMPLE) @@ -30,6 +51,15 @@ def test_markdown_to_html_structure(): assert 'a link' in html +def test_markdown_to_html_rich_features(): + html = markdown_to_html(RICH) + assert "
    " in html + assert "
    " in html and "
  4. ordered one
  5. " in html + assert "" in html and "" in html + assert "alert" not in doc @@ -46,3 +76,12 @@ def test_markdown_to_pdf_bytes(): pdf = markdown_to_pdf_bytes(SAMPLE, title="Report Title") assert pdf[:4] == b"%PDF" assert len(pdf) > 100 + + +def test_markdown_to_pdf_preserves_structure(): + pdf = markdown_to_pdf_bytes(RICH, title="Rich export") + assert pdf[:4] == b"%PDF" + # fpdf2 embeds literal text from headings/list items in the PDF stream. + blob = pdf.decode("latin-1", "replace") + assert "Rich export" in blob + assert "ordered one" in blob
    Col A