| from __future__ import annotations |
|
|
| import re |
| from dataclasses import dataclass |
|
|
| from hydradeck.core.types import ExtractedFact, Source |
|
|
| _LATEX_SPECIALS: dict[str, str] = { |
| "\\": r"\textbackslash{}", |
| "{": r"\{", |
| "}": r"\}", |
| "#": r"\#", |
| "$": r"\$", |
| "%": r"\%", |
| "&": r"\&", |
| "_": r"\_", |
| "^": r"\textasciicircum{}", |
| "~": r"\textasciitilde{}", |
| } |
|
|
|
|
| def latex_escape(s: str) -> str: |
| return "".join(_LATEX_SPECIALS.get(ch, ch) for ch in s) |
|
|
|
|
| def _bib_key(i: int) -> str: |
| return f"src{i}" |
|
|
|
|
| def _bib_escape(s: str) -> str: |
| return s.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}") |
|
|
|
|
| def render_bibtex(sources: list[Source]) -> str: |
| lines: list[str] = [] |
| for i, s in enumerate(sources, start=1): |
| key = _bib_key(i) |
| lines.append(f"@misc{{{key},") |
| lines.append(f" title = {{{_bib_escape(s.title)}}},") |
| lines.append(f" howpublished = {{\\url{{{_bib_escape(s.url)}}}}},") |
| lines.append(" note = {Accessed: 2026-03-04},") |
| lines.append("}") |
| lines.append("") |
| return "\n".join(lines).strip() + "\n" |
|
|
|
|
| def _replace_numeric_citations(text: str, max_n: int) -> str: |
| def repl(m: re.Match[str]) -> str: |
| num = int(m.group(1)) |
| if 1 <= num <= max_n: |
| return f"\\cite{{{_bib_key(num)}}}" |
| return m.group(0) |
|
|
| return re.sub(r"\[(\d{1,3})\]", repl, text) |
|
|
|
|
| def _markdown_to_latex_paragraphs(md: str, max_n: int) -> str: |
| text = md.strip() |
| text = re.sub(r"```[\s\S]*?```", "", text) |
| text = re.sub(r"^\s*[-*+]\s+", "", text, flags=re.MULTILINE) |
| text = re.sub(r"^\s*#+\s*", "", text, flags=re.MULTILINE) |
| text = re.sub(r"`([^`]+)`", r"\1", text) |
| text = re.sub(r"\*\*(.*?)\*\*", r"\1", text) |
| text = re.sub(r"\*(.*?)\*", r"\1", text) |
| text = re.sub(r"\[(.*?)\]\((.*?)\)", r"\1", text) |
| text = _replace_numeric_citations(text, max_n=max_n) |
| text = latex_escape(text) |
| text = re.sub(r"\\textbackslash\{\}cite\\\{(src\d+)\\\}", r"\\cite{\1}", text) |
| text = text.replace("\n\n", "\n\\par\n") |
| return text |
|
|
|
|
| def render_paper( |
| topic: str, |
| outline: list[str], |
| body: str, |
| facts: list[ExtractedFact], |
| sources: list[Source], |
| ) -> str: |
| topic_e = latex_escape(topic) |
| url_to_key = {s.url: _bib_key(i) for i, s in enumerate(sources, start=1)} |
|
|
| outline_items = "\n".join([f"\\item {latex_escape(x)}" for x in outline[:10]]) |
| fact_sentences: list[str] = [] |
| for f in facts[:18]: |
| key = url_to_key.get(f.url) |
| cite = f"\\cite{{{key}}}" if key else "" |
| sentence = latex_escape(f.claim.strip()) |
| if sentence and sentence[-1] not in ".!?": |
| sentence += "." |
| fact_sentences.append(sentence + (cite if cite else "")) |
| facts_paragraph = ( |
| " ".join(fact_sentences) |
| if fact_sentences |
| else "No extracted facts available." |
| ) |
|
|
| body_latex = _markdown_to_latex_paragraphs(body, max_n=len(sources)) |
|
|
| return ( |
| "\\documentclass[11pt]{article}\n" |
| "\\usepackage{geometry}\n" |
| "\\usepackage{hyperref}\n" |
| "\\usepackage{url}\n" |
| "\\usepackage{booktabs}\n" |
| "\\usepackage{longtable}\n" |
| "\\geometry{margin=1in}\n" |
| "\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n" |
| f"\\title{{{topic_e}}}\n" |
| "\\author{hydradeck}\n" |
| "\\date{\\today}\n" |
| "\\begin{document}\n" |
| "\\maketitle\n" |
| "\\begin{abstract}\n" |
| "This report presents a structured analysis with explicit traceability to sources.\n" |
| "\\end{abstract}\n\n" |
| "\\section*{1. Introduction and Background}\n" |
| + facts_paragraph |
| + "\n\n" |
| "\\section*{2. Logical Outline}\n" |
| "\\begin{itemize}\n" |
| + outline_items |
| + "\n\\end{itemize}\n\n" |
| "\\section*{3. Evidence and Key Findings}\n" |
| + body_latex |
| + "\n\n" |
| "\\section*{4. Limitations and Discussion}\n" |
| "The analysis is bounded by available public evidence and may evolve as sources update.\n\n" |
| "\\section*{5. Conclusion}\n" |
| "Conclusions are presented in a source-traceable form and should be interpreted with the\n" |
| "reported assumptions and constraints.\n\n" |
| "\\bibliographystyle{plain}\n" |
| "\\bibliography{refs}\n" |
| "\\end{document}\n" |
| ) |
|
|
|
|
| def render_report_structured( |
| topic: str, |
| section_blocks: list[dict[str, str]], |
| language: str = "en", |
| ) -> str: |
| lang = language.lower() |
| topic_e = latex_escape(topic) |
|
|
| if lang == "zh": |
| preamble = ( |
| "\\documentclass[11pt]{ctexart}\n" |
| "\\usepackage[a4paper,margin=1in]{geometry}\n" |
| "\\usepackage{hyperref}\n" |
| "\\usepackage{url}\n" |
| "\\usepackage{booktabs}\n" |
| "\\usepackage{longtable}\n" |
| "\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n" |
| f"\\title{{{topic_e}}}\n" |
| "\\author{hydradeck}\n" |
| "\\date{\\today}\n" |
| "\\begin{document}\n" |
| "\\maketitle\n" |
| ) |
| else: |
| preamble = ( |
| "\\documentclass[11pt]{article}\n" |
| "\\usepackage{geometry}\n" |
| "\\usepackage{hyperref}\n" |
| "\\usepackage{url}\n" |
| "\\usepackage{booktabs}\n" |
| "\\usepackage{longtable}\n" |
| "\\geometry{margin=1in}\n" |
| "\\hypersetup{colorlinks=true,linkcolor=black,citecolor=blue,urlcolor=blue}\n" |
| f"\\title{{{topic_e}}}\n" |
| "\\author{hydradeck}\n" |
| "\\date{\\today}\n" |
| "\\begin{document}\n" |
| "\\maketitle\n" |
| ) |
|
|
| content_parts: list[str] = [] |
| for block in section_blocks[:10]: |
| title = latex_escape(str(block.get("name", "Section")).strip() or "Section") |
| latex_body = str(block.get("latex", "")).strip() |
| latex_body = re.sub(r"\\section\*?\{[^}]*\}", "", latex_body) |
| latex_body = re.sub(r"\\subsection\*?\{[^}]*\}", "", latex_body) |
| latex_body = re.sub(r"\\cite\{[^}]*\}", "", latex_body) |
| latex_body = re.sub(r"\[(\d{1,3})\]", "", latex_body) |
| if not latex_body: |
| continue |
| content_parts.append(f"\\section*{{{title}}}\n{latex_body}\n") |
|
|
| return preamble + "\n".join(content_parts) + "\n\\end{document}\n" |
|
|
|
|
| @dataclass |
| class SlideFrame: |
| title: str |
| bullets: list[str] |
| note: str = "" |
|
|
|
|
| def render_beamer(topic: str, outline: list[str], bullets: list[str]) -> str: |
| section_blocks = [{"name": t, "latex": b} for t, b in zip(outline, bullets)] |
| if not section_blocks: |
| section_blocks = [{"name": "Summary", "latex": "Key findings and implications."}] |
| frames = build_slide_frames_from_sections(section_blocks, language="en") |
| frames = enforce_slide_density(frames, language="en") |
| return render_beamer_frames(topic, frames, language="en") |
|
|
|
|
| def render_beamer_from_report(topic: str, report_tex: str) -> str: |
| frames = build_slide_frames_from_report(report_tex, language="en") |
| frames = enforce_slide_density(frames, language="en") |
| return render_beamer_frames(topic, frames, language="en") |
|
|
|
|
| def _split_paragraph_to_bullets(text: str, language: str) -> list[str]: |
| lang = language.lower() |
| if lang == "zh": |
| parts = [x.strip() for x in re.split(r"[。!?]\s*", text) if x.strip()] |
| out: list[str] = [] |
| for p in parts: |
| if len(p) < 6: |
| continue |
| out.append(_trim_chars(_clean_text_for_slide(p), 28)) |
| return out |
|
|
| parts = [x.strip() for x in re.split(r"[.!?]\s+", text) if x.strip()] |
| out2: list[str] = [] |
| for p in parts: |
| clean = _clean_text_for_slide(p) |
| if len(clean) < 14: |
| continue |
| out2.append(_trim_words(clean, 14)) |
| return out2 |
|
|
|
|
| def build_slide_frames_from_sections( |
| section_blocks: list[dict[str, str]], |
| language: str = "en", |
| ) -> list[SlideFrame]: |
| lang = language.lower() |
| frames: list[SlideFrame] = [] |
| for block in section_blocks[:8]: |
| title = str(block.get("name", "Section")).strip() or ("章节" if lang == "zh" else "Section") |
| body = str(block.get("latex", "")) |
| body = re.sub(r"\\section\*?\{[^}]*\}", "", body) |
| body = re.sub(r"\\subsection\*?\{[^}]*\}", "", body) |
| body = re.sub(r"\\cite\{[^}]*\}", "", body) |
| body = re.sub(r"\[(\d{1,3})\]", "", body) |
| bullets = _split_paragraph_to_bullets(body, lang) |
| if not bullets: |
| continue |
|
|
| chunk = 4 if lang == "zh" else 4 |
| for i in range(0, len(bullets), chunk): |
| part = bullets[i : i + chunk] |
| if not part: |
| continue |
| if i == 0: |
| frame_title = title |
| else: |
| frame_title = f"{title}(续)" if lang == "zh" else f"{title} (cont.)" |
| frames.append(SlideFrame(title=frame_title, bullets=part)) |
|
|
| if not frames: |
| raise RuntimeError("insufficient readable section content for slides") |
| return frames |
|
|
|
|
| def enforce_slide_density( |
| frames: list[SlideFrame], |
| language: str = "en", |
| max_bullets_per_frame: int = 4, |
| max_chars_per_bullet_zh: int = 28, |
| max_words_per_bullet_en: int = 14, |
| ) -> list[SlideFrame]: |
| lang = language.lower() |
| out: list[SlideFrame] = [] |
|
|
| for fr in frames: |
| normalized: list[str] = [] |
| for b in fr.bullets: |
| clean = _clean_text_for_slide(b) |
| if not clean: |
| continue |
| if lang == "zh": |
| clean = _trim_chars(clean, max_chars_per_bullet_zh) |
| else: |
| clean = _trim_words(clean, max_words_per_bullet_en) |
| if clean: |
| normalized.append(clean) |
|
|
| if not normalized: |
| continue |
|
|
| for i in range(0, len(normalized), max_bullets_per_frame): |
| chunk = normalized[i : i + max_bullets_per_frame] |
| if not chunk: |
| continue |
| if i == 0: |
| title = fr.title |
| else: |
| title = f"{fr.title}(续)" if lang == "zh" else f"{fr.title} (cont.)" |
| out.append(SlideFrame(title=title, bullets=chunk, note=fr.note)) |
|
|
| if not out: |
| raise RuntimeError("slide density guard removed all frames") |
| return out |
|
|
|
|
| def _trim_words(text: str, max_words: int) -> str: |
| words = text.split() |
| if len(words) <= max_words: |
| return text |
| return " ".join(words[:max_words]).rstrip(" ,.;") + "..." |
|
|
|
|
| def _trim_chars(text: str, max_chars: int) -> str: |
| t = text.strip() |
| if len(t) <= max_chars: |
| return t |
| return t[: max_chars - 1].rstrip(",。,. ") + "…" |
|
|
|
|
| def _clean_text_for_slide(text: str) -> str: |
| t = text.strip() |
| t = re.sub(r"\s+", " ", t) |
| t = re.sub(r"`([^`]+)`", r"\1", t) |
| t = re.sub(r"\*\*(.*?)\*\*", r"\1", t) |
| t = re.sub(r"\*(.*?)\*", r"\1", t) |
| return t |
|
|
|
|
| def build_slide_frames_from_report(report_tex: str, language: str = "en") -> list[SlideFrame]: |
| lang = language.lower() |
| sections = re.split(r"\\section\*\{([^}]+)\}", report_tex) |
| parsed: list[tuple[str, str]] = [] |
| if len(sections) >= 3: |
| for i in range(1, len(sections), 2): |
| title = sections[i].strip() |
| body = sections[i + 1] if i + 1 < len(sections) else "" |
| parsed.append((title, body)) |
|
|
| if not parsed: |
| raise RuntimeError("cannot derive slide frames from report structure") |
|
|
| frames: list[SlideFrame] = [] |
| for title, body in parsed[:8]: |
| plain = re.sub(r"\\[a-zA-Z]+\*?(\[[^\]]*\])?(\{[^}]*\})?", " ", body) |
| chunks = [x.strip() for x in re.split(r"[。.!?]\s+", plain) if x.strip()] |
| bullets: list[str] = [] |
| for c in chunks: |
| clean = _clean_text_for_slide(c) |
| if not clean: |
| continue |
| if lang == "zh": |
| if len(clean) < 8: |
| continue |
| bullets.append(_trim_chars(clean, 30)) |
| else: |
| if len(clean) < 12: |
| continue |
| bullets.append(_trim_words(clean, 16)) |
| if len(bullets) >= 5: |
| break |
| if not bullets: |
| raise RuntimeError(f"insufficient bullet content for slide '{title}'") |
| frames.append(SlideFrame(title=title, bullets=bullets)) |
|
|
| return frames |
|
|
|
|
| def render_beamer_frames(topic: str, frames: list[SlideFrame], language: str = "en") -> str: |
| lang = language.lower() |
| topic_e = latex_escape(topic) |
| agenda_label = "目录" if lang == "zh" else "Agenda" |
| summary_title = "总结" if lang == "zh" else "Summary" |
|
|
| agenda_items = "\n".join([f"\\item {latex_escape(f.title)}" for f in frames[:8]]) |
|
|
| frame_blocks: list[str] = [] |
| for fr in frames[:10]: |
| b = "\n".join([f"\\item {latex_escape(x)}" for x in fr.bullets[:5]]) |
| frame_blocks.append( |
| "\\begin{frame}[t]{" |
| + latex_escape(fr.title) |
| + "}\n" |
| + "\\begin{itemize}\n" |
| + b |
| + "\n\\end{itemize}\n" |
| + (f"\\vspace{{0.6em}}\\footnotesize {latex_escape(fr.note)}\n" if fr.note else "") |
| + "\\end{frame}\n" |
| ) |
|
|
| summary_bullets: list[str] = [] |
| for fr in frames[:5]: |
| if fr.bullets: |
| summary_bullets.append(fr.bullets[0]) |
| if not summary_bullets: |
| summary_bullets = ["关键要点见前页。" if lang == "zh" else "Key points are summarized in previous slides."] |
| summary_items = "\n".join([f"\\item {latex_escape(x)}" for x in summary_bullets]) |
|
|
| if lang == "zh": |
| return ( |
| "\\documentclass[aspectratio=169]{ctexbeamer}\n" |
| "\\usetheme{Madrid}\n" |
| "\\usefonttheme{professionalfonts}\n" |
| "\\setbeamertemplate{navigation symbols}{}\n" |
| "\\usepackage{hyperref}\n" |
| "\\usepackage{booktabs}\n" |
| "\\definecolor{AccentBlue}{HTML}{1F4E79}\n" |
| "\\setbeamercolor{title}{fg=AccentBlue}\n" |
| "\\setbeamercolor{frametitle}{fg=AccentBlue}\n" |
| "\\setbeamerfont{title}{series=\\bfseries,size=\\Large}\n" |
| "\\setbeamerfont{frametitle}{series=\\bfseries,size=\\large}\n" |
| f"\\title{{{topic_e}}}\n" |
| "\\author{hydradeck}\n" |
| "\\date{\\today}\n" |
| "\\begin{document}\n" |
| "\\frame{\\titlepage}\n" |
| "\\begin{frame}{" |
| + latex_escape(agenda_label) |
| + "}\n" |
| "\\begin{itemize}\n" |
| + agenda_items |
| + "\n\\end{itemize}\n" |
| "\\end{frame}\n" |
| + "".join(frame_blocks) |
| + "\\begin{frame}{" |
| + latex_escape(summary_title) |
| + "}\n" |
| + "\\begin{itemize}\n" |
| + summary_items |
| + "\n\\end{itemize}\n" |
| + "\\end{frame}\n" |
| + "\\end{document}\n" |
| ) |
|
|
| return ( |
| "\\documentclass[aspectratio=169]{beamer}\n" |
| "\\usetheme{metropolis}\n" |
| "\\usefonttheme{professionalfonts}\n" |
| "\\setbeamertemplate{navigation symbols}{}\n" |
| "\\usepackage{hyperref}\n" |
| "\\usepackage{booktabs}\n" |
| "\\definecolor{AccentBlue}{HTML}{1F4E79}\n" |
| "\\setbeamercolor{title}{fg=AccentBlue}\n" |
| "\\setbeamercolor{frametitle}{fg=AccentBlue}\n" |
| "\\setbeamerfont{title}{series=\\bfseries,size=\\Large}\n" |
| "\\setbeamerfont{frametitle}{series=\\bfseries,size=\\large}\n" |
| f"\\title{{{topic_e}}}\n" |
| "\\author{hydradeck}\n" |
| "\\date{\\today}\n" |
| "\\begin{document}\n" |
| "\\frame{\\titlepage}\n" |
| "\\begin{frame}{" |
| + latex_escape(agenda_label) |
| + "}\n" |
| "\\begin{itemize}\n" |
| + agenda_items |
| + "\n\\end{itemize}\n" |
| "\\end{frame}\n" |
| + "".join(frame_blocks) |
| + "\\begin{frame}{" |
| + latex_escape(summary_title) |
| + "}\n" |
| + "\\begin{itemize}\n" |
| + summary_items |
| + "\n\\end{itemize}\n" |
| + "\\end{frame}\n" |
| + "\\end{document}\n" |
| ) |
|
|