Spaces:
Running on Zero
Running on Zero
zhiyi commited on
Commit ·
c47f9fb
1
Parent(s): a9dc81e
Finalize adaptive research and deck experience
Browse files- README.md +4 -2
- app.py +122 -121
- static/deck.css +186 -1
- static/deck.js +81 -8
- tests/test_app.py +31 -1
README.md
CHANGED
|
@@ -14,11 +14,13 @@ license: apache-2.0
|
|
| 14 |
|
| 15 |
# Algorithm Research Deck
|
| 16 |
|
| 17 |
-
Turn an algorithm research topic into an interactive,
|
| 18 |
|
| 19 |
Users can disable online paper search. In that mode the deck relies on the model's existing knowledge and visibly discloses that papers and recency were not externally verified. Download controls sit in the presentation toolbar: HTML for the standalone interactive deck, JSON for structured research data, and ZIP for both.
|
| 20 |
|
| 21 |
-
Report generation is staged:
|
|
|
|
|
|
|
| 22 |
|
| 23 |
## Space configuration
|
| 24 |
|
|
|
|
| 14 |
|
| 15 |
# Algorithm Research Deck
|
| 16 |
|
| 17 |
+
Turn an algorithm research topic into an interactive, evidence-aware HTML presentation. DeepSeek-V4-Flash first converts Chinese inputs into a precise English technical interpretation and expands them into historical, milestone, current, and evaluation-oriented search queries. Results are balanced across query intents instead of allowing the first query to dominate. The Space searches arXiv, supplements sparse or empty results with OpenAlex, uses Hugging Face Inference Providers for synthesis, previews the result, and provides HTML, JSON, and ZIP downloads.
|
| 18 |
|
| 19 |
Users can disable online paper search. In that mode the deck relies on the model's existing knowledge and visibly discloses that papers and recency were not externally verified. Download controls sit in the presentation toolbar: HTML for the standalone interactive deck, JSON for structured research data, and ZIP for both.
|
| 20 |
|
| 21 |
+
Report generation is staged: selected papers are compressed into evidence cards, then the model infers the research type, creates a topic-specific outline, and writes sections in batches of at most four. Each generated section is locked back to its approved outline identity and layout. Timelines, comparisons, processes, equations, paper analysis, and evidence-gap pages are optional rather than globally required. Layout-aware Pydantic validation, malformed-JSON recovery, citation allowlisting, and targeted repair attempts prevent empty slides and unsupported references. If academic search returns no usable result, the run visibly degrades to an exploratory model-knowledge report instead of failing or fabricating papers.
|
| 22 |
+
|
| 23 |
+
The standalone deck includes an executive-summary view, a generated research map, slide overview navigation, keyboard and touch controls, interactive process nodes, balanced comparison tables, clickable source chips, responsive layouts, and landscape print/PDF styling.
|
| 24 |
|
| 25 |
## Space configuration
|
| 26 |
|
app.py
CHANGED
|
@@ -121,7 +121,7 @@ class ReportSection(BaseModel):
|
|
| 121 |
|
| 122 |
|
| 123 |
class SectionsEnvelope(BaseModel):
|
| 124 |
-
sections: list[ReportSection] = Field(min_length=
|
| 125 |
|
| 126 |
|
| 127 |
class ResearchReport(BaseModel):
|
|
@@ -307,23 +307,34 @@ def plan_search_queries(request: ResearchRequest) -> tuple[list[str], dict[str,
|
|
| 307 |
|
| 308 |
def search_literature(queries: list[str], total_limit: int) -> tuple[list[dict[str, Any]], list[str]]:
|
| 309 |
per_query = max(5, min(20, total_limit // max(1, len(queries))))
|
| 310 |
-
|
| 311 |
warnings: list[str] = []
|
| 312 |
for query in queries:
|
|
|
|
| 313 |
try:
|
| 314 |
found = arxiv_search(query, per_query)
|
| 315 |
for paper in found:
|
| 316 |
paper["source"] = "arXiv"
|
| 317 |
-
|
| 318 |
except Exception:
|
| 319 |
warnings.append(f"arXiv timeout: {query}")
|
|
|
|
| 320 |
try:
|
| 321 |
-
|
| 322 |
except Exception:
|
| 323 |
warnings.append(f"OpenAlex timeout: {query}")
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
break
|
| 326 |
-
return deduplicate(
|
| 327 |
|
| 328 |
|
| 329 |
def generate_text(system: str, prompt: str, max_tokens: int, structured: bool = True) -> str:
|
|
@@ -597,42 +608,69 @@ def build_report(
|
|
| 597 |
3200,
|
| 598 |
)
|
| 599 |
|
| 600 |
-
|
| 601 |
-
"Write
|
| 602 |
-
"
|
| 603 |
-
"{
|
| 604 |
-
"{columns:[...],
|
| 605 |
-
"
|
| 606 |
-
"
|
| 607 |
-
"{interpretation,related_topics,hypotheses,required_inputs}.
|
| 608 |
-
"
|
| 609 |
-
|
| 610 |
-
**shared_context,
|
| 611 |
-
"outline": outline.model_dump(),
|
| 612 |
-
"requirements": {
|
| 613 |
-
"one_section_per_outline_item": True,
|
| 614 |
-
"preserve_plan_id_title_purpose_layout": True,
|
| 615 |
-
"source_ids": "Only IDs present in evidence_cards; otherwise []",
|
| 616 |
-
"schema": SectionsEnvelope.model_json_schema(),
|
| 617 |
-
},
|
| 618 |
-
}, ensure_ascii=False),
|
| 619 |
-
5200,
|
| 620 |
-
)
|
| 621 |
-
sections = validate_stage_with_repairs(
|
| 622 |
-
sections_result,
|
| 623 |
-
SectionsEnvelope,
|
| 624 |
-
"report sections",
|
| 625 |
-
{**shared_context, "outline": outline.model_dump()},
|
| 626 |
-
5200,
|
| 627 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 628 |
|
| 629 |
assembled = {
|
| 630 |
"title": outline.title,
|
| 631 |
"subtitle": outline.subtitle,
|
| 632 |
"research_type": outline.research_type,
|
| 633 |
"executive_summary": outline.executive_summary,
|
| 634 |
-
"sections": [item.model_dump() for item in
|
| 635 |
-
"references": sorted({source_id for section in
|
| 636 |
"limitations": (["Online literature verification was disabled; current-work claims may be incomplete."] if not cards else []),
|
| 637 |
"english_understanding": english_understanding,
|
| 638 |
}
|
|
@@ -679,61 +717,6 @@ def esc(value: Any) -> str:
|
|
| 679 |
return html.escape(str(value or ""))
|
| 680 |
|
| 681 |
|
| 682 |
-
def render_presentation_legacy(report: dict[str, Any], papers: list[dict[str, Any]], cutoff: str) -> str:
|
| 683 |
-
deck_css = (STATIC_DIR / "deck.css").read_text(encoding="utf-8")
|
| 684 |
-
deck_js = (STATIC_DIR / "deck.js").read_text(encoding="utf-8").replace("</script", "<\\/script")
|
| 685 |
-
citations = {p["id"]: p for p in papers}
|
| 686 |
-
lineage = report.get("lineage", [])
|
| 687 |
-
sections = report.get("sections", [])
|
| 688 |
-
summary = report.get("executive_summary", [])
|
| 689 |
-
comparisons = report.get("comparisons", [])
|
| 690 |
-
evidence_mode = (report.get("english_understanding") or {}).get("evidence_mode", "")
|
| 691 |
-
evidence_note = (
|
| 692 |
-
"未联网检索:内容来自模型已有知识,论文与最新性未经过外部核验。"
|
| 693 |
-
if evidence_mode else
|
| 694 |
-
"已联网检索并基于当前论文证据生成。"
|
| 695 |
-
)
|
| 696 |
-
|
| 697 |
-
timeline = "".join(
|
| 698 |
-
f'<button class="milestone" data-index="{i}" aria-pressed="{str(i == 0).lower()}"><span>{esc(x.get("year"))}</span>{esc(x.get("method"))}</button>'
|
| 699 |
-
for i, x in enumerate(lineage)
|
| 700 |
-
)
|
| 701 |
-
timeline_data = html.escape(json.dumps(lineage, ensure_ascii=False), quote=True)
|
| 702 |
-
summary_html = "".join(f"<li>{esc(x)}</li>" for x in summary)
|
| 703 |
-
section_html = "".join(
|
| 704 |
-
f'''<section class="slide" id="section-{i + 1}">
|
| 705 |
-
<p class="eyebrow">{i + 3:02d}</p><h2>{esc(x.get('title'))}</h2>
|
| 706 |
-
<p class="takeaway">{esc(x.get('takeaway'))}</p>
|
| 707 |
-
<ul>{''.join(f'<li>{esc(b)}</li>' for b in x.get('body', []))}</ul>
|
| 708 |
-
<details><summary>方法细节与证据</summary><p>{esc(x.get('details'))}</p><p class="sources">论文:{', '.join(esc(pid) for pid in x.get('paper_ids', []))}</p></details>
|
| 709 |
-
</section>'''
|
| 710 |
-
for i, x in enumerate(sections)
|
| 711 |
-
)
|
| 712 |
-
rows = "".join(
|
| 713 |
-
f"<tr><th>{esc(x.get('method'))}</th><td>{esc(x.get('mechanism'))}</td><td>{esc(x.get('strengths'))}</td><td>{esc(x.get('limitations'))}</td><td>{esc(x.get('engineering'))}</td></tr>"
|
| 714 |
-
for x in comparisons
|
| 715 |
-
)
|
| 716 |
-
refs = "".join(
|
| 717 |
-
f'<li id="ref-{esc(pid)}"><a href="{esc(p.get("url"))}" target="_blank" rel="noopener noreferrer">{esc(p.get("title"))}</a><br><span>{esc(", ".join(p.get("authors", [])[:6]))} · {esc((p.get("published") or "")[:10])} · arXiv:{esc(pid)}</span></li>'
|
| 718 |
-
for pid, p in citations.items()
|
| 719 |
-
)
|
| 720 |
-
open_questions = "".join(f"<li>{esc(x)}</li>" for x in report.get("open_questions", []))
|
| 721 |
-
title = esc(report.get("title", "Algorithm Research"))
|
| 722 |
-
subtitle = esc(report.get("subtitle", ""))
|
| 723 |
-
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 724 |
-
<title>{title}</title><style>{deck_css}</style></head><body>
|
| 725 |
-
<nav><strong>{title}</strong><span id="counter"></span><button id="prev" aria-label="上一页">←</button><button id="next" aria-label="下一页">→</button><button onclick="window.print()">保存 PDF</button></nav>
|
| 726 |
-
<main>
|
| 727 |
-
<section class="slide active" id="cover"><p class="eyebrow">ALGORITHM RESEARCH · {esc(cutoff)}</p><h1>{title}</h1><p class="subtitle">{subtitle}</p><p>{esc(evidence_note)}</p><p>使用方向键翻页,滚动和打印模式同样可用。</p></section>
|
| 728 |
-
<section class="slide" id="summary"><p class="eyebrow">01</p><h2>核心结论</h2><ol>{summary_html}</ol></section>
|
| 729 |
-
<section class="slide" id="lineage" data-lineage="{timeline_data}"><p class="eyebrow">02</p><h2>技术路线</h2><div class="timeline">{timeline}</div><article id="milestone-detail" aria-live="polite"></article></section>
|
| 730 |
-
{section_html}
|
| 731 |
-
<section class="slide" id="comparison"><h2>方法对比</h2><div class="table-wrap"><table><thead><tr><th>方法</th><th>机制</th><th>优势</th><th>局限</th><th>工程考虑</th></tr></thead><tbody>{rows}</tbody></table></div></section>
|
| 732 |
-
<section class="slide" id="questions"><h2>开放问题</h2><ul>{open_questions}</ul></section>
|
| 733 |
-
<section class="slide references" id="references"><h2>论文与研究截止日期</h2><p>{esc(evidence_note)}</p><p>研究截止:{esc(cutoff)}。预印本结论可能随修订发生变化。</p><ol>{refs}</ol></section>
|
| 734 |
-
</main><div class="progress"><span id="progress-bar"></span></div><script>{deck_js}</script></body></html>'''
|
| 735 |
-
|
| 736 |
-
|
| 737 |
def field_label(key: Any) -> str:
|
| 738 |
labels = {
|
| 739 |
"points": "要点", "details": "详细说明", "claim": "结论", "claims": "结论",
|
|
@@ -873,11 +856,10 @@ def render_section_content(section: dict[str, Any]) -> str:
|
|
| 873 |
f'{render_value(content.get("symbols"))}')
|
| 874 |
wrapper = "aside" if layout == "evidence-gap" else "div"
|
| 875 |
return f'<{wrapper} class="{esc(layout)}">' + "".join(
|
| 876 |
-
f'<h3>{esc(
|
| 877 |
) + f"</{wrapper}>"
|
| 878 |
|
| 879 |
|
| 880 |
-
# This later definition intentionally replaces the legacy fixed-layout renderer above.
|
| 881 |
def render_presentation(report: dict[str, Any], papers: list[dict[str, Any]], cutoff: str) -> str:
|
| 882 |
deck_css = "\n".join(
|
| 883 |
(STATIC_DIR / name).read_text(encoding="utf-8")
|
|
@@ -887,25 +869,40 @@ def render_presentation(report: dict[str, Any], papers: list[dict[str, Any]], cu
|
|
| 887 |
citations = {p["id"]: p for p in papers}
|
| 888 |
evidence_mode = (report.get("english_understanding") or {}).get("evidence_mode", "")
|
| 889 |
evidence_note = "未联网核验:内容基于模型已有知识,最新进展未经外部验证。" if evidence_mode else "已联网检索,并基于当前可用论文证据生成。"
|
| 890 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 891 |
section_html = "".join(
|
| 892 |
-
f'<section class="slide" id="section-{i + 1}"
|
| 893 |
-
f'<
|
| 894 |
-
f'<p class="
|
|
|
|
|
|
|
|
|
|
| 895 |
for i, x in enumerate(report.get("sections", []))
|
| 896 |
)
|
| 897 |
refs = "".join(
|
| 898 |
-
f'<li><a href="{esc(citations[pid].get("url"))}" target="_blank" rel="noopener noreferrer">{esc(citations[pid].get("title"))}</a>'
|
| 899 |
-
f'<br><span>{esc(", ".join(citations[pid].get("authors", [])[:6]))} · {esc((citations[pid].get("published") or "")[:10])} · {esc(pid)}</span></li>'
|
| 900 |
-
for pid in report.get("references", []) if pid in citations
|
| 901 |
)
|
| 902 |
limitations = "".join(f"<li>{esc(x)}</li>" for x in report.get("limitations", []))
|
| 903 |
title, subtitle = esc(report.get("title", "Algorithm Research")), esc(report.get("subtitle", ""))
|
| 904 |
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 905 |
-
<title>{title}</title><style>{deck_css}</style></head><body>
|
| 906 |
-
<
|
| 907 |
-
<
|
| 908 |
-
<section class="slide
|
|
|
|
|
|
|
|
|
|
| 909 |
<div class="progress"><span id="progress-bar"></span></div><script>{deck_js}</script></body></html>'''
|
| 910 |
|
| 911 |
|
|
@@ -919,7 +916,7 @@ def create_bundle(job_dir: Path, html_path: Path, report_path: Path) -> Path:
|
|
| 919 |
|
| 920 |
def preview_frame(content: str) -> str:
|
| 921 |
source = html.escape(content, quote=True)
|
| 922 |
-
return f'<iframe title="Interactive research presentation" srcdoc="{source}" style="width:100%;height:
|
| 923 |
|
| 924 |
|
| 925 |
def run_research_ui(
|
|
@@ -955,22 +952,25 @@ def run_research_ui(
|
|
| 955 |
progress(0.16, desc="正在联网检索 arXiv 和 OpenAlex")
|
| 956 |
user_papers = normalize_user_papers(request.paper_urls)
|
| 957 |
discovered_papers, search_warnings = search_literature(queries, budget["discover"])
|
| 958 |
-
if not discovered_papers and not user_papers:
|
| 959 |
-
raise gr.Error("arXiv 和 OpenAlex 均未返回论文。请稍后重试,或填写至少一个 arXiv 论文链接。")
|
| 960 |
papers = deduplicate(user_papers + discovered_papers)
|
| 961 |
screen = papers[: budget["screen"]]
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 974 |
else:
|
| 975 |
papers = []
|
| 976 |
screen = []
|
|
@@ -1004,7 +1004,8 @@ def run_research_ui(
|
|
| 1004 |
|
| 1005 |
|
| 1006 |
CSS = """
|
| 1007 |
-
.gradio-container{max-width:none!important}.
|
|
|
|
| 1008 |
.preview-toolbar{align-items:center!important;justify-content:flex-end!important;gap:6px!important;min-height:38px}
|
| 1009 |
.preview-title{margin-right:auto!important;min-width:180px}.download-chip{flex:0 0 auto!important;min-width:42px!important;max-width:52px!important;position:relative}
|
| 1010 |
.download-chip button,.download-chip a{min-width:42px!important;padding:5px 8px!important;font-size:12px!important}
|
|
@@ -1013,7 +1014,7 @@ CSS = """
|
|
| 1013 |
"""
|
| 1014 |
|
| 1015 |
with gr.Blocks(title="Algorithm Research Deck") as demo:
|
| 1016 |
-
gr.
|
| 1017 |
with gr.Row():
|
| 1018 |
with gr.Column(scale=1, elem_classes="input-column"):
|
| 1019 |
topic = gr.Textbox(label="调研主题", placeholder="例如:Diffusion Transformer")
|
|
@@ -1034,7 +1035,7 @@ with gr.Blocks(title="Algorithm Research Deck") as demo:
|
|
| 1034 |
html_file = gr.DownloadButton("HTML", size="sm", elem_classes=["download-chip", "download-html"])
|
| 1035 |
json_file = gr.DownloadButton("JSON", size="sm", elem_classes=["download-chip", "download-json"])
|
| 1036 |
zip_file = gr.DownloadButton("ZIP", size="sm", elem_classes=["download-chip", "download-zip"])
|
| 1037 |
-
preview = gr.HTML("<div style='height:
|
| 1038 |
|
| 1039 |
submit.click(
|
| 1040 |
run_research_ui,
|
|
|
|
| 121 |
|
| 122 |
|
| 123 |
class SectionsEnvelope(BaseModel):
|
| 124 |
+
sections: list[ReportSection] = Field(min_length=1, max_length=4)
|
| 125 |
|
| 126 |
|
| 127 |
class ResearchReport(BaseModel):
|
|
|
|
| 307 |
|
| 308 |
def search_literature(queries: list[str], total_limit: int) -> tuple[list[dict[str, Any]], list[str]]:
|
| 309 |
per_query = max(5, min(20, total_limit // max(1, len(queries))))
|
| 310 |
+
buckets: list[list[dict[str, Any]]] = []
|
| 311 |
warnings: list[str] = []
|
| 312 |
for query in queries:
|
| 313 |
+
query_papers: list[dict[str, Any]] = []
|
| 314 |
try:
|
| 315 |
found = arxiv_search(query, per_query)
|
| 316 |
for paper in found:
|
| 317 |
paper["source"] = "arXiv"
|
| 318 |
+
query_papers.extend(found)
|
| 319 |
except Exception:
|
| 320 |
warnings.append(f"arXiv timeout: {query}")
|
| 321 |
+
if len(query_papers) < max(2, per_query // 4):
|
| 322 |
try:
|
| 323 |
+
query_papers.extend(openalex_search(query, per_query))
|
| 324 |
except Exception:
|
| 325 |
warnings.append(f"OpenAlex timeout: {query}")
|
| 326 |
+
buckets.append(deduplicate(query_papers))
|
| 327 |
+
|
| 328 |
+
balanced: list[dict[str, Any]] = []
|
| 329 |
+
max_bucket = max((len(bucket) for bucket in buckets), default=0)
|
| 330 |
+
for index in range(max_bucket):
|
| 331 |
+
for bucket in buckets:
|
| 332 |
+
if index < len(bucket):
|
| 333 |
+
balanced.append(bucket[index])
|
| 334 |
+
balanced = deduplicate(balanced)
|
| 335 |
+
if len(balanced) >= total_limit:
|
| 336 |
break
|
| 337 |
+
return deduplicate(balanced)[:total_limit], warnings
|
| 338 |
|
| 339 |
|
| 340 |
def generate_text(system: str, prompt: str, max_tokens: int, structured: bool = True) -> str:
|
|
|
|
| 608 |
3200,
|
| 609 |
)
|
| 610 |
|
| 611 |
+
section_system = (
|
| 612 |
+
"Write presentation-ready sections for algorithm engineers. Use the layout selected in the approved plan and put "
|
| 613 |
+
"layout-specific data in content. Text/evidence uses {points,details}; timeline uses "
|
| 614 |
+
"{nodes:[{year,title,problem,innovation,limitation}]}; comparison uses "
|
| 615 |
+
"{columns:[...],rows:[{name,values:[...]}]}; process uses {steps:[{name,description}]} or "
|
| 616 |
+
"{common_steps:[...],branches:[{name,steps:[...]}]}; equation uses {equation,symbols,interpretation}; "
|
| 617 |
+
"paper-analysis uses {method,evidence,limitations}; evidence-gap uses "
|
| 618 |
+
"{interpretation,related_topics,hypotheses,required_inputs}. Write for slides, not an essay: one conclusion per "
|
| 619 |
+
"section, concise points, explicit assumptions, comparable dimensions, and no repeated background. Distinguish "
|
| 620 |
+
"retrieved facts from analysis or hypotheses. Never invent sources, papers, metrics, or recency claims."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
)
|
| 622 |
+
generated_sections: list[ReportSection] = []
|
| 623 |
+
plans = outline.section_plan
|
| 624 |
+
for batch_index in range(0, len(plans), 4):
|
| 625 |
+
batch = plans[batch_index:batch_index + 4]
|
| 626 |
+
sections_result = chat_json(
|
| 627 |
+
section_system,
|
| 628 |
+
json.dumps({
|
| 629 |
+
**shared_context,
|
| 630 |
+
"report_identity": {
|
| 631 |
+
"title": outline.title,
|
| 632 |
+
"research_type": outline.research_type,
|
| 633 |
+
"executive_summary": outline.executive_summary,
|
| 634 |
+
},
|
| 635 |
+
"approved_section_plan": [item.model_dump() for item in batch],
|
| 636 |
+
"requirements": {
|
| 637 |
+
"exactly_one_section_per_plan_item_in_order": True,
|
| 638 |
+
"preserve_plan_id_title_purpose_layout_exactly": True,
|
| 639 |
+
"source_ids": "Only IDs present in evidence_cards; otherwise []",
|
| 640 |
+
"schema": SectionsEnvelope.model_json_schema(),
|
| 641 |
+
},
|
| 642 |
+
}, ensure_ascii=False),
|
| 643 |
+
4200,
|
| 644 |
+
)
|
| 645 |
+
candidates = sections_result.get("sections", [])
|
| 646 |
+
for plan, candidate in zip(batch, candidates):
|
| 647 |
+
if isinstance(candidate, dict):
|
| 648 |
+
candidate.update({
|
| 649 |
+
"id": plan.id, "title": plan.title, "purpose": plan.purpose, "layout": plan.layout,
|
| 650 |
+
})
|
| 651 |
+
validated_batch = validate_stage_with_repairs(
|
| 652 |
+
sections_result,
|
| 653 |
+
SectionsEnvelope,
|
| 654 |
+
f"report sections batch {batch_index // 4 + 1}",
|
| 655 |
+
{**shared_context, "approved_section_plan": [item.model_dump() for item in batch]},
|
| 656 |
+
4200,
|
| 657 |
+
)
|
| 658 |
+
if len(validated_batch.sections) != len(batch):
|
| 659 |
+
raise RuntimeError("The model omitted one or more approved report sections")
|
| 660 |
+
for plan, section in zip(batch, validated_batch.sections):
|
| 661 |
+
normalized = section.model_dump()
|
| 662 |
+
normalized.update({
|
| 663 |
+
"id": plan.id, "title": plan.title, "purpose": plan.purpose, "layout": plan.layout,
|
| 664 |
+
})
|
| 665 |
+
generated_sections.append(ReportSection.model_validate(normalized))
|
| 666 |
|
| 667 |
assembled = {
|
| 668 |
"title": outline.title,
|
| 669 |
"subtitle": outline.subtitle,
|
| 670 |
"research_type": outline.research_type,
|
| 671 |
"executive_summary": outline.executive_summary,
|
| 672 |
+
"sections": [item.model_dump() for item in generated_sections],
|
| 673 |
+
"references": sorted({source_id for section in generated_sections for source_id in section.source_ids}),
|
| 674 |
"limitations": (["Online literature verification was disabled; current-work claims may be incomplete."] if not cards else []),
|
| 675 |
"english_understanding": english_understanding,
|
| 676 |
}
|
|
|
|
| 717 |
return html.escape(str(value or ""))
|
| 718 |
|
| 719 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
def field_label(key: Any) -> str:
|
| 721 |
labels = {
|
| 722 |
"points": "要点", "details": "详细说明", "claim": "结论", "claims": "结论",
|
|
|
|
| 856 |
f'{render_value(content.get("symbols"))}')
|
| 857 |
wrapper = "aside" if layout == "evidence-gap" else "div"
|
| 858 |
return f'<{wrapper} class="{esc(layout)}">' + "".join(
|
| 859 |
+
f'<h3>{esc(field_label(k))}</h3>{render_value(v)}' for k, v in content.items()
|
| 860 |
) + f"</{wrapper}>"
|
| 861 |
|
| 862 |
|
|
|
|
| 863 |
def render_presentation(report: dict[str, Any], papers: list[dict[str, Any]], cutoff: str) -> str:
|
| 864 |
deck_css = "\n".join(
|
| 865 |
(STATIC_DIR / name).read_text(encoding="utf-8")
|
|
|
|
| 869 |
citations = {p["id"]: p for p in papers}
|
| 870 |
evidence_mode = (report.get("english_understanding") or {}).get("evidence_mode", "")
|
| 871 |
evidence_note = "未联网核验:内容基于模型已有知识,最新进展未经外部验证。" if evidence_mode else "已联网检索,并基于当前可用论文证据生成。"
|
| 872 |
+
evidence_label = "模型知识 · 未联网核验" if evidence_mode else f"论文证据 · {len(report.get('references', []))} 篇引用"
|
| 873 |
+
summary_html = "".join(
|
| 874 |
+
f'<article class="summary-card"><span>{i:02d}</span><p>{esc(item)}</p></article>'
|
| 875 |
+
for i, item in enumerate(report.get("executive_summary", []), 1)
|
| 876 |
+
)
|
| 877 |
+
agenda_items = "".join(
|
| 878 |
+
f'<a href="#section-{i + 1}" data-slide-target="section-{i + 1}"><span>{i + 1:02d}</span>'
|
| 879 |
+
f'<strong>{esc(section.get("title"))}</strong><small>{esc(section.get("layout"))}</small></a>'
|
| 880 |
+
for i, section in enumerate(report.get("sections", []))
|
| 881 |
+
)
|
| 882 |
section_html = "".join(
|
| 883 |
+
f'<section class="slide content-slide" id="section-{i + 1}" data-title="{esc(x.get("title"))}">'
|
| 884 |
+
f'<header class="slide-header"><p class="eyebrow">{i + 3:02d} / {esc(x.get("layout"))}</p><h2>{esc(x.get("title"))}</h2>'
|
| 885 |
+
f'<p class="takeaway">{esc(x.get("takeaway"))}</p></header><div class="slide-content">{render_section_content(x)}</div>'
|
| 886 |
+
f'<footer class="slide-footer"><div class="source-chips">'
|
| 887 |
+
f'{"".join(f"<a href=\"#ref-{esc(pid)}\" title=\"查看参考文献\">{esc(pid)}</a>" for pid in x.get("source_ids", [])) or "<span>本页未绑定外部论文</span>"}'
|
| 888 |
+
f'</div><span>{i + 3:02d}</span></footer></section>'
|
| 889 |
for i, x in enumerate(report.get("sections", []))
|
| 890 |
)
|
| 891 |
refs = "".join(
|
| 892 |
+
f'<li id="ref-{esc(pid)}"><span class="ref-index">{i:02d}</span><div><a href="{esc(citations[pid].get("url"))}" target="_blank" rel="noopener noreferrer">{esc(citations[pid].get("title"))}</a>'
|
| 893 |
+
f'<br><span>{esc(", ".join(citations[pid].get("authors", [])[:6]))} · {esc((citations[pid].get("published") or "")[:10])} · {esc(pid)}</span></div></li>'
|
| 894 |
+
for i, pid in enumerate((pid for pid in report.get("references", []) if pid in citations), 1)
|
| 895 |
)
|
| 896 |
limitations = "".join(f"<li>{esc(x)}</li>" for x in report.get("limitations", []))
|
| 897 |
title, subtitle = esc(report.get("title", "Algorithm Research")), esc(report.get("subtitle", ""))
|
| 898 |
return f'''<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 899 |
+
<title>{title}</title><style>{deck_css}</style></head><body>
|
| 900 |
+
<nav class="deck-nav"><button id="menu" aria-label="打开目录" aria-expanded="false">☰</button><strong>{title}</strong><span id="counter"></span><button id="prev" aria-label="上一页">←</button><button id="next" aria-label="下一页">→</button><button onclick="window.print()" title="打印或保存为 PDF"><span class="print-label">保存 PDF</span> ⇩</button></nav>
|
| 901 |
+
<aside id="overview" class="overview" aria-hidden="true"><header><strong>演示目录</strong><button id="close-overview" aria-label="关闭目录">×</button></header><a data-slide-target="cover">封面</a><a data-slide-target="summary">核心结论</a><a data-slide-target="agenda">研究结构</a>{agenda_items}<a data-slide-target="references">参考文献与局限</a></aside><div id="scrim" class="scrim"></div>
|
| 902 |
+
<main><section class="slide cover active" id="cover" data-title="封面"><div class="cover-orb"></div><p class="eyebrow">ALGORITHM RESEARCH / {esc(cutoff)}</p><h1>{title}</h1><p class="subtitle">{subtitle}</p><div class="cover-meta"><span>{esc(report.get("research_type"))}</span><span>{esc(evidence_label)}</span></div><p class="evidence-note">{esc(evidence_note)}</p><p class="hint">方向键翻页 · 点击左上角查看目录 · 可打印保存</p></section>
|
| 903 |
+
<section class="slide" id="summary" data-title="核心结论"><p class="eyebrow">01 / EXECUTIVE SUMMARY</p><h2>先看结论</h2><div class="summary-grid">{summary_html}</div></section>
|
| 904 |
+
<section class="slide" id="agenda" data-title="研究结构"><p class="eyebrow">02 / RESEARCH MAP</p><h2>这份调研如何展开</h2><div class="agenda-grid">{agenda_items}</div></section>{section_html}
|
| 905 |
+
<section class="slide references" id="references" data-title="参考文献与局限"><p class="eyebrow">SOURCES / {esc(cutoff)}</p><h2>参考文献与边界</h2><p class="evidence-note">{esc(evidence_note)}</p><ol>{refs or '<li class="empty-state">本报告未绑定外部论文来源。</li>'}</ol><ul class="limitations">{limitations}</ul></section></main>
|
| 906 |
<div class="progress"><span id="progress-bar"></span></div><script>{deck_js}</script></body></html>'''
|
| 907 |
|
| 908 |
|
|
|
|
| 916 |
|
| 917 |
def preview_frame(content: str) -> str:
|
| 918 |
source = html.escape(content, quote=True)
|
| 919 |
+
return f'<iframe title="Interactive research presentation" srcdoc="{source}" style="width:100%;height:80vh;border:0;border-radius:14px;background:#071018;box-shadow:0 14px 40px #12283c2b" sandbox="allow-scripts allow-popups"></iframe>'
|
| 920 |
|
| 921 |
|
| 922 |
def run_research_ui(
|
|
|
|
| 952 |
progress(0.16, desc="正在联网检索 arXiv 和 OpenAlex")
|
| 953 |
user_papers = normalize_user_papers(request.paper_urls)
|
| 954 |
discovered_papers, search_warnings = search_literature(queries, budget["discover"])
|
|
|
|
|
|
|
| 955 |
papers = deduplicate(user_papers + discovered_papers)
|
| 956 |
screen = papers[: budget["screen"]]
|
| 957 |
+
if screen:
|
| 958 |
+
progress(0.28, desc="DeepSeek-V4-Flash 正在筛选关键论文")
|
| 959 |
+
selected_ids = select_papers(request, screen, budget["screen"], budget["deep"])
|
| 960 |
+
if not selected_ids:
|
| 961 |
+
selected_ids = [p["id"] for p in screen[: budget["deep"]]]
|
| 962 |
+
progress(0.48, desc="正在读取关键论文")
|
| 963 |
+
for index, paper in enumerate(screen):
|
| 964 |
+
if paper["id"] in selected_ids:
|
| 965 |
+
try:
|
| 966 |
+
deep_texts[paper["id"]] = extract_pdf(paper)
|
| 967 |
+
except Exception as exc:
|
| 968 |
+
deep_texts[paper["id"]] = f"PDF extraction failed: {exc}"
|
| 969 |
+
progress(0.48 + 0.16 * (index + 1) / len(screen), desc="正在提取论文证据")
|
| 970 |
+
else:
|
| 971 |
+
search_warnings.append("学术搜索未返回可用论文,已降级为模型知识模式")
|
| 972 |
+
english_understanding["evidence_mode"] = "Academic search returned no usable papers; model knowledge only."
|
| 973 |
+
progress(0.48, desc="检索无结果,已降级为模型知识模式")
|
| 974 |
else:
|
| 975 |
papers = []
|
| 976 |
screen = []
|
|
|
|
| 1004 |
|
| 1005 |
|
| 1006 |
CSS = """
|
| 1007 |
+
.gradio-container{max-width:none!important;background:linear-gradient(145deg,#f7f9fc,#eef3f8)!important}.app-hero{margin:4px 4px 18px;padding:18px 22px;border:1px solid #dce5ef;border-radius:16px;background:linear-gradient(120deg,#0c1d2d,#153651);color:#fff;box-shadow:0 12px 35px #16334d1a}.app-hero h1{margin:0 0 5px;font-size:25px}.app-hero p{margin:0;color:#bcd0e3}.app-hero span{display:inline-block;margin-top:10px;padding:4px 8px;border:1px solid #ffffff2b;border-radius:999px;color:#82ead8;font-size:11px}
|
| 1008 |
+
.preview-column{min-width:0}.input-column{max-width:430px;padding:14px!important;border:1px solid #dce5ef;border-radius:14px;background:#ffffffb8;box-shadow:0 10px 30px #1a38500d}
|
| 1009 |
.preview-toolbar{align-items:center!important;justify-content:flex-end!important;gap:6px!important;min-height:38px}
|
| 1010 |
.preview-title{margin-right:auto!important;min-width:180px}.download-chip{flex:0 0 auto!important;min-width:42px!important;max-width:52px!important;position:relative}
|
| 1011 |
.download-chip button,.download-chip a{min-width:42px!important;padding:5px 8px!important;font-size:12px!important}
|
|
|
|
| 1014 |
"""
|
| 1015 |
|
| 1016 |
with gr.Blocks(title="Algorithm Research Deck") as demo:
|
| 1017 |
+
gr.HTML("""<div class="app-hero"><h1>Algorithm Research Deck</h1><p>从问题定义、论文证据到技术判断,生成可保存的交互式研究演示。</p><span>DeepSeek-V4-Flash · arXiv · OpenAlex</span></div>""")
|
| 1018 |
with gr.Row():
|
| 1019 |
with gr.Column(scale=1, elem_classes="input-column"):
|
| 1020 |
topic = gr.Textbox(label="调研主题", placeholder="例如:Diffusion Transformer")
|
|
|
|
| 1035 |
html_file = gr.DownloadButton("HTML", size="sm", elem_classes=["download-chip", "download-html"])
|
| 1036 |
json_file = gr.DownloadButton("JSON", size="sm", elem_classes=["download-chip", "download-json"])
|
| 1037 |
zip_file = gr.DownloadButton("ZIP", size="sm", elem_classes=["download-chip", "download-zip"])
|
| 1038 |
+
preview = gr.HTML("<div style='height:80vh;display:grid;place-items:center;border-radius:14px;background:#071018;color:#758099'>交互演示将在这里生成</div>")
|
| 1039 |
|
| 1040 |
submit.click(
|
| 1041 |
run_research_ui,
|
static/deck.css
CHANGED
|
@@ -1 +1,186 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap');
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
font-family: Manrope, "Noto Sans SC", "Microsoft YaHei", sans-serif;
|
| 5 |
+
color: #edf3ff;
|
| 6 |
+
background: #071018;
|
| 7 |
+
color-scheme: dark;
|
| 8 |
+
--bg: #071018;
|
| 9 |
+
--panel: #0d1925;
|
| 10 |
+
--panel-2: #122235;
|
| 11 |
+
--line: rgba(151, 177, 207, .18);
|
| 12 |
+
--accent: #5eead4;
|
| 13 |
+
--accent-2: #7aa2ff;
|
| 14 |
+
--text: #edf3ff;
|
| 15 |
+
--muted: #97aabd;
|
| 16 |
+
--warm: #f4bc67;
|
| 17 |
+
}
|
| 18 |
+
* { box-sizing: border-box; }
|
| 19 |
+
html { scroll-behavior: smooth; }
|
| 20 |
+
body {
|
| 21 |
+
margin: 0;
|
| 22 |
+
overflow: hidden;
|
| 23 |
+
background:
|
| 24 |
+
radial-gradient(circle at 78% 12%, rgba(45, 92, 145, .28), transparent 32%),
|
| 25 |
+
radial-gradient(circle at 18% 88%, rgba(38, 127, 118, .16), transparent 28%),
|
| 26 |
+
var(--bg);
|
| 27 |
+
color: var(--text);
|
| 28 |
+
}
|
| 29 |
+
button, a { font: inherit; }
|
| 30 |
+
.deck-nav {
|
| 31 |
+
position: fixed;
|
| 32 |
+
z-index: 30;
|
| 33 |
+
inset: 0 0 auto;
|
| 34 |
+
height: 58px;
|
| 35 |
+
display: flex;
|
| 36 |
+
align-items: center;
|
| 37 |
+
gap: 9px;
|
| 38 |
+
padding: 0 24px;
|
| 39 |
+
border-bottom: 1px solid var(--line);
|
| 40 |
+
background: rgba(7, 16, 24, .78);
|
| 41 |
+
backdrop-filter: blur(18px);
|
| 42 |
+
}
|
| 43 |
+
.deck-nav strong {
|
| 44 |
+
margin-right: auto;
|
| 45 |
+
max-width: min(56vw, 760px);
|
| 46 |
+
overflow: hidden;
|
| 47 |
+
color: #dce9f8;
|
| 48 |
+
font-size: 13px;
|
| 49 |
+
text-overflow: ellipsis;
|
| 50 |
+
white-space: nowrap;
|
| 51 |
+
}
|
| 52 |
+
.deck-nav button {
|
| 53 |
+
display: grid;
|
| 54 |
+
place-items: center;
|
| 55 |
+
min-width: 34px;
|
| 56 |
+
height: 34px;
|
| 57 |
+
padding: 0 9px;
|
| 58 |
+
border: 1px solid var(--line);
|
| 59 |
+
border-radius: 9px;
|
| 60 |
+
background: rgba(18, 34, 53, .76);
|
| 61 |
+
color: var(--text);
|
| 62 |
+
cursor: pointer;
|
| 63 |
+
transition: border-color .18s ease, transform .18s ease, background .18s ease;
|
| 64 |
+
}
|
| 65 |
+
.deck-nav button:hover { transform: translateY(-1px); border-color: var(--accent); background: #172b40; }
|
| 66 |
+
#counter { min-width: 54px; color: var(--muted); font-size: 12px; text-align: center; }
|
| 67 |
+
.overview {
|
| 68 |
+
position: fixed;
|
| 69 |
+
z-index: 50;
|
| 70 |
+
inset: 0 auto 0 0;
|
| 71 |
+
width: min(390px, 88vw);
|
| 72 |
+
padding: 18px;
|
| 73 |
+
overflow-y: auto;
|
| 74 |
+
border-right: 1px solid var(--line);
|
| 75 |
+
background: rgba(8, 18, 28, .98);
|
| 76 |
+
box-shadow: 20px 0 60px rgba(0, 0, 0, .35);
|
| 77 |
+
transform: translateX(-105%);
|
| 78 |
+
transition: transform .24s ease;
|
| 79 |
+
}
|
| 80 |
+
.overview.open { transform: translateX(0); }
|
| 81 |
+
.overview header { display: flex; align-items: center; margin-bottom: 14px; }
|
| 82 |
+
.overview header strong { margin-right: auto; }
|
| 83 |
+
.overview button { border: 0; background: transparent; color: var(--muted); font-size: 26px; cursor: pointer; }
|
| 84 |
+
.overview > a {
|
| 85 |
+
display: grid;
|
| 86 |
+
grid-template-columns: 34px 1fr auto;
|
| 87 |
+
gap: 8px;
|
| 88 |
+
align-items: center;
|
| 89 |
+
margin: 5px 0;
|
| 90 |
+
padding: 11px;
|
| 91 |
+
border: 1px solid transparent;
|
| 92 |
+
border-radius: 9px;
|
| 93 |
+
color: #c8d6e6;
|
| 94 |
+
text-decoration: none;
|
| 95 |
+
cursor: pointer;
|
| 96 |
+
}
|
| 97 |
+
.overview > a:hover, .overview > a.current { border-color: var(--line); background: var(--panel-2); color: #fff; }
|
| 98 |
+
.overview small { color: var(--muted); }
|
| 99 |
+
.scrim { position: fixed; z-index: 40; inset: 0; display: none; background: rgba(0, 0, 0, .48); }
|
| 100 |
+
.scrim.open { display: block; }
|
| 101 |
+
main { height: 100vh; }
|
| 102 |
+
.slide {
|
| 103 |
+
display: none;
|
| 104 |
+
width: min(1500px, 100%);
|
| 105 |
+
height: 100vh;
|
| 106 |
+
margin: auto;
|
| 107 |
+
padding: clamp(84px, 10vh, 112px) clamp(34px, 7vw, 112px) clamp(48px, 7vh, 78px);
|
| 108 |
+
overflow: auto;
|
| 109 |
+
align-content: center;
|
| 110 |
+
animation: slide-in .24s ease-out;
|
| 111 |
+
}
|
| 112 |
+
.slide.active { display: grid; }
|
| 113 |
+
@keyframes slide-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
| 114 |
+
.eyebrow { margin: 0 0 12px; color: var(--accent); font-size: 11px; font-weight: 700; letter-spacing: .16em; text-transform: uppercase; }
|
| 115 |
+
h1 { max-width: 1120px; margin: .18em 0; font-size: clamp(44px, 6.5vw, 96px); line-height: 1.02; letter-spacing: -.045em; }
|
| 116 |
+
h2 { margin: .15em 0 .42em; font-size: clamp(31px, 4vw, 58px); line-height: 1.08; letter-spacing: -.035em; }
|
| 117 |
+
h3 { margin: .3em 0; }
|
| 118 |
+
p, li, td, th { font-size: clamp(15px, 1.12vw, 19px); line-height: 1.55; }
|
| 119 |
+
.subtitle { max-width: 920px; margin: 16px 0 28px; color: #bbcadb; font-size: clamp(19px, 2.1vw, 31px); line-height: 1.35; }
|
| 120 |
+
.takeaway { max-width: 1120px; margin: 0; padding-left: 16px; border-left: 3px solid var(--accent); color: #cfdaea; font-size: clamp(17px, 1.65vw, 24px); line-height: 1.42; }
|
| 121 |
+
.cover { position: relative; align-content: center; overflow: hidden; }
|
| 122 |
+
.cover-orb { position: absolute; z-index: -1; top: 12%; right: 4%; width: min(42vw, 580px); aspect-ratio: 1; border: 1px solid rgba(94, 234, 212, .24); border-radius: 50%; box-shadow: inset 0 0 90px rgba(65, 126, 180, .18), 0 0 100px rgba(94, 234, 212, .08); }
|
| 123 |
+
.cover-orb::before, .cover-orb::after { content: ""; position: absolute; inset: 13%; border: 1px solid rgba(122, 162, 255, .18); border-radius: 50%; }
|
| 124 |
+
.cover-orb::after { inset: 30%; background: rgba(94, 234, 212, .05); }
|
| 125 |
+
.cover-meta { display: flex; flex-wrap: wrap; gap: 8px; }
|
| 126 |
+
.cover-meta span, .source-chips a, .source-chips span { padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; background: rgba(18, 34, 53, .68); color: #b9cde1; font-size: 12px; }
|
| 127 |
+
.evidence-note { max-width: 900px; color: var(--muted); font-size: 13px; }
|
| 128 |
+
.hint { position: absolute; bottom: 34px; color: #63778b; font-size: 11px; }
|
| 129 |
+
.summary-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 13px; max-width: 1180px; }
|
| 130 |
+
.summary-card { display: grid; grid-template-columns: 38px 1fr; gap: 12px; align-items: start; padding: 17px; border: 1px solid var(--line); border-radius: 13px; background: linear-gradient(145deg, rgba(18, 34, 53, .88), rgba(11, 25, 37, .9)); }
|
| 131 |
+
.summary-card span, .agenda-grid > a span { color: var(--accent); font-size: 11px; font-weight: 700; }
|
| 132 |
+
.summary-card p { margin: 0; }
|
| 133 |
+
.agenda-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; max-width: 1120px; }
|
| 134 |
+
.agenda-grid > a { display: grid; grid-template-columns: 38px 1fr auto; gap: 10px; align-items: center; padding: 14px 16px; border: 1px solid var(--line); border-radius: 11px; background: var(--panel); color: var(--text); text-decoration: none; cursor: pointer; transition: transform .18s ease, border-color .18s ease; }
|
| 135 |
+
.agenda-grid > a:hover { transform: translateY(-2px); border-color: var(--accent); }
|
| 136 |
+
.agenda-grid small { color: var(--muted); font-size: 11px; }
|
| 137 |
+
.content-slide { grid-template-rows: auto 1fr auto; align-content: stretch; gap: 22px; }
|
| 138 |
+
.slide-header { align-self: end; }
|
| 139 |
+
.slide-content { min-height: 0; overflow: auto; padding-right: 4px; }
|
| 140 |
+
.slide-footer { display: flex; align-items: center; gap: 12px; padding-top: 10px; border-top: 1px solid var(--line); color: var(--muted); font-size: 11px; }
|
| 141 |
+
.slide-footer > span { margin-left: auto; }
|
| 142 |
+
.source-chips { display: flex; flex-wrap: wrap; gap: 5px; }
|
| 143 |
+
.source-chips a { color: #acd0ff; text-decoration: none; }
|
| 144 |
+
.source-chips a:hover { border-color: var(--accent); }
|
| 145 |
+
.timeline { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(240px, 1fr); gap: 12px; overflow-x: auto; padding: 10px 2px 18px; }
|
| 146 |
+
.milestone { min-width: 0; padding: 15px; border: 1px solid var(--line); border-top: 3px solid var(--accent-2); border-radius: 11px; background: var(--panel); color: var(--text); }
|
| 147 |
+
.milestone span { display: block; margin-bottom: 7px; color: var(--accent); font-size: 11px; }
|
| 148 |
+
.milestone h3 { font-size: 17px; }
|
| 149 |
+
.table-wrap { overflow: auto; border: 1px solid var(--line); border-radius: 12px; }
|
| 150 |
+
table { width: 100%; border-collapse: collapse; background: rgba(10, 23, 35, .7); }
|
| 151 |
+
th, td { padding: 12px 14px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
|
| 152 |
+
thead th { position: sticky; top: 0; z-index: 2; background: #14283b; color: #dcecff; }
|
| 153 |
+
tbody th { color: var(--accent); }
|
| 154 |
+
.references { align-content: start; }
|
| 155 |
+
.references ol { display: grid; gap: 8px; padding: 0; list-style: none; }
|
| 156 |
+
.references li { display: grid; grid-template-columns: 36px 1fr; gap: 10px; padding: 12px; border-bottom: 1px solid var(--line); }
|
| 157 |
+
.references a { color: #b7d2ff; font-weight: 600; text-decoration: none; }
|
| 158 |
+
.references a:hover { text-decoration: underline; }
|
| 159 |
+
.references li span { color: var(--muted); font-size: 12px; }
|
| 160 |
+
.ref-index { color: var(--accent) !important; font-weight: 700; }
|
| 161 |
+
.limitations { margin-top: 18px; padding: 14px 14px 14px 34px; border-left: 3px solid var(--warm); background: rgba(91, 61, 21, .16); }
|
| 162 |
+
.empty-state { color: var(--muted); }
|
| 163 |
+
.progress { position: fixed; z-index: 31; inset: auto 0 0; height: 3px; background: rgba(255, 255, 255, .06); }
|
| 164 |
+
.progress span { display: block; height: 100%; background: linear-gradient(90deg, var(--accent-2), var(--accent)); transition: width .25s; }
|
| 165 |
+
@media (max-width: 760px) {
|
| 166 |
+
body { overflow: auto; }
|
| 167 |
+
.deck-nav strong { display: none; }
|
| 168 |
+
.slide, .slide.active { display: block; height: auto; min-height: 100vh; padding: 84px 20px 48px; overflow: visible; border-bottom: 1px solid var(--line); }
|
| 169 |
+
.summary-grid, .agenda-grid { grid-template-columns: 1fr; }
|
| 170 |
+
.content-slide { min-height: auto; }
|
| 171 |
+
.slide-content { overflow: visible; }
|
| 172 |
+
.slide-footer { margin-top: 20px; }
|
| 173 |
+
.hint { position: static; margin-top: 38px; }
|
| 174 |
+
.progress { display: none; }
|
| 175 |
+
}
|
| 176 |
+
@media print {
|
| 177 |
+
@page { size: landscape; margin: 0; }
|
| 178 |
+
body { overflow: visible; background: #fff; color: #111; }
|
| 179 |
+
.deck-nav, .overview, .scrim, .progress, .hint { display: none !important; }
|
| 180 |
+
.slide, .slide.active { display: grid; width: 100%; height: 100vh; min-height: 0; padding: 12mm 16mm; overflow: hidden; break-after: page; background: #fff; color: #111; animation: none; }
|
| 181 |
+
.slide:last-child { break-after: auto; }
|
| 182 |
+
.takeaway { color: #263748; }
|
| 183 |
+
.slide-content { overflow: hidden; }
|
| 184 |
+
.summary-card, .agenda-grid > a, .milestone, .content-card, .content-item, .content-field, .flow-node, .flow-lane { background: #f6f8fb !important; color: #111 !important; border-color: #cfd7e1 !important; }
|
| 185 |
+
a { color: #111 !important; }
|
| 186 |
+
}
|
static/deck.js
CHANGED
|
@@ -1,8 +1,81 @@
|
|
| 1 |
-
const slides=[...document.querySelectorAll('.slide')];
|
| 2 |
-
const counter=document.getElementById('counter');
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
document.
|
| 6 |
-
const
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const slides = [...document.querySelectorAll('.slide')];
|
| 2 |
+
const counter = document.getElementById('counter');
|
| 3 |
+
const bar = document.getElementById('progress-bar');
|
| 4 |
+
const overview = document.getElementById('overview');
|
| 5 |
+
const scrim = document.getElementById('scrim');
|
| 6 |
+
const menu = document.getElementById('menu');
|
| 7 |
+
let current = 0;
|
| 8 |
+
|
| 9 |
+
function closeOverview() {
|
| 10 |
+
overview.classList.remove('open');
|
| 11 |
+
scrim.classList.remove('open');
|
| 12 |
+
overview.setAttribute('aria-hidden', 'true');
|
| 13 |
+
menu.setAttribute('aria-expanded', 'false');
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
function openOverview() {
|
| 17 |
+
overview.classList.add('open');
|
| 18 |
+
scrim.classList.add('open');
|
| 19 |
+
overview.setAttribute('aria-hidden', 'false');
|
| 20 |
+
menu.setAttribute('aria-expanded', 'true');
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function show(index, push = true) {
|
| 24 |
+
current = Math.max(0, Math.min(slides.length - 1, index));
|
| 25 |
+
slides.forEach((slide, i) => slide.classList.toggle('active', i === current));
|
| 26 |
+
counter.textContent = `${current + 1} / ${slides.length}`;
|
| 27 |
+
bar.style.width = `${(current + 1) / slides.length * 100}%`;
|
| 28 |
+
document.querySelectorAll('[data-slide-target]').forEach(link => {
|
| 29 |
+
link.classList.toggle('current', link.dataset.slideTarget === slides[current].id);
|
| 30 |
+
});
|
| 31 |
+
document.title = `${slides[current].dataset.title || ''} · ${document.querySelector('.deck-nav strong').textContent}`;
|
| 32 |
+
if (push) history.replaceState(null, '', `#${slides[current].id}`);
|
| 33 |
+
slides[current].scrollTo({ top: 0, behavior: 'instant' });
|
| 34 |
+
closeOverview();
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
function showById(id) {
|
| 38 |
+
const index = slides.findIndex(slide => slide.id === id);
|
| 39 |
+
if (index >= 0) show(index);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
document.getElementById('prev').addEventListener('click', () => show(current - 1));
|
| 43 |
+
document.getElementById('next').addEventListener('click', () => show(current + 1));
|
| 44 |
+
menu.addEventListener('click', () => overview.classList.contains('open') ? closeOverview() : openOverview());
|
| 45 |
+
document.getElementById('close-overview').addEventListener('click', closeOverview);
|
| 46 |
+
scrim.addEventListener('click', closeOverview);
|
| 47 |
+
|
| 48 |
+
document.querySelectorAll('[data-slide-target]').forEach(link => {
|
| 49 |
+
link.addEventListener('click', event => {
|
| 50 |
+
event.preventDefault();
|
| 51 |
+
showById(link.dataset.slideTarget);
|
| 52 |
+
});
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
document.querySelectorAll('.source-chips a').forEach(link => {
|
| 56 |
+
link.addEventListener('click', event => {
|
| 57 |
+
event.preventDefault();
|
| 58 |
+
const target = document.querySelector(link.getAttribute('href'));
|
| 59 |
+
showById('references');
|
| 60 |
+
if (target) requestAnimationFrame(() => target.scrollIntoView({ block: 'center' }));
|
| 61 |
+
});
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
document.addEventListener('keydown', event => {
|
| 65 |
+
if (event.key === 'Escape') return closeOverview();
|
| 66 |
+
if (['ArrowRight', 'PageDown', ' '].includes(event.key)) show(current + 1);
|
| 67 |
+
if (['ArrowLeft', 'PageUp'].includes(event.key)) show(current - 1);
|
| 68 |
+
if (event.key === 'Home') show(0);
|
| 69 |
+
if (event.key === 'End') show(slides.length - 1);
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
let touchStartX = 0;
|
| 73 |
+
document.addEventListener('touchstart', event => { touchStartX = event.changedTouches[0].screenX; }, { passive: true });
|
| 74 |
+
document.addEventListener('touchend', event => {
|
| 75 |
+
const distance = event.changedTouches[0].screenX - touchStartX;
|
| 76 |
+
if (Math.abs(distance) > 70) show(current + (distance < 0 ? 1 : -1));
|
| 77 |
+
}, { passive: true });
|
| 78 |
+
|
| 79 |
+
const hashId = location.hash.slice(1);
|
| 80 |
+
const initial = slides.findIndex(slide => slide.id === hashId);
|
| 81 |
+
show(initial >= 0 ? initial : 0, false);
|
tests/test_app.py
CHANGED
|
@@ -3,7 +3,7 @@ from unittest.mock import patch
|
|
| 3 |
|
| 4 |
from pydantic import ValidationError
|
| 5 |
|
| 6 |
-
from app import ResearchReport, chat_json, deduplicate, infer_section_source_ids, normalize_comparison, normalize_process, parse_json_object, prepare_evidence_inputs, render_presentation, render_section_content, render_value, safe_slug
|
| 7 |
|
| 8 |
|
| 9 |
def base_report(section):
|
|
@@ -31,6 +31,24 @@ class AppTests(unittest.TestCase):
|
|
| 31 |
self.assertEqual([item["id"] for item in result[:2]], ["8", "9"])
|
| 32 |
self.assertIn("selected_text_extract", result[0])
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
def test_parser_removes_invalid_string_line_continuation(self):
|
| 35 |
self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
|
| 36 |
|
|
@@ -45,6 +63,18 @@ class AppTests(unittest.TestCase):
|
|
| 45 |
output = render_presentation(base_report(section), [], "2026-01-01")
|
| 46 |
self.assertIn("<!doctype html>", output)
|
| 47 |
self.assertIn("保存 PDF", output)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
def test_empty_report_is_rejected(self):
|
| 50 |
with self.assertRaises(ValidationError):
|
|
|
|
| 3 |
|
| 4 |
from pydantic import ValidationError
|
| 5 |
|
| 6 |
+
from app import ResearchReport, chat_json, deduplicate, infer_section_source_ids, normalize_comparison, normalize_process, parse_json_object, prepare_evidence_inputs, render_presentation, render_section_content, render_value, safe_slug, search_literature
|
| 7 |
|
| 8 |
|
| 9 |
def base_report(section):
|
|
|
|
| 31 |
self.assertEqual([item["id"] for item in result[:2]], ["8", "9"])
|
| 32 |
self.assertIn("selected_text_extract", result[0])
|
| 33 |
|
| 34 |
+
@patch("app.openalex_search", return_value=[])
|
| 35 |
+
@patch("app.arxiv_search")
|
| 36 |
+
def test_search_balances_multiple_query_intents(self, mocked_arxiv, _mocked_openalex):
|
| 37 |
+
mocked_arxiv.side_effect = [
|
| 38 |
+
[{"id": f"classic-{i}", "title": f"Classic {i}"} for i in range(5)],
|
| 39 |
+
[{"id": f"recent-{i}", "title": f"Recent {i}"} for i in range(5)],
|
| 40 |
+
]
|
| 41 |
+
papers, warnings = search_literature(["classic", "recent"], 4)
|
| 42 |
+
self.assertEqual([paper["id"] for paper in papers], ["classic-0", "recent-0", "classic-1", "recent-1"])
|
| 43 |
+
self.assertEqual(warnings, [])
|
| 44 |
+
|
| 45 |
+
@patch("app.openalex_search", return_value=[{"id": "oa-1", "title": "Fallback"}])
|
| 46 |
+
@patch("app.arxiv_search", return_value=[])
|
| 47 |
+
def test_search_uses_openalex_when_arxiv_returns_empty(self, _mocked_arxiv, mocked_openalex):
|
| 48 |
+
papers, _ = search_literature(["topic"], 5)
|
| 49 |
+
self.assertEqual(papers[0]["id"], "oa-1")
|
| 50 |
+
mocked_openalex.assert_called_once()
|
| 51 |
+
|
| 52 |
def test_parser_removes_invalid_string_line_continuation(self):
|
| 53 |
self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
|
| 54 |
|
|
|
|
| 63 |
output = render_presentation(base_report(section), [], "2026-01-01")
|
| 64 |
self.assertIn("<!doctype html>", output)
|
| 65 |
self.assertIn("保存 PDF", output)
|
| 66 |
+
self.assertIn('id="overview"', output)
|
| 67 |
+
self.assertIn('id="agenda"', output)
|
| 68 |
+
self.assertIn('data-slide-target="section-1"', output)
|
| 69 |
+
|
| 70 |
+
def test_source_chip_links_to_rendered_reference(self):
|
| 71 |
+
section = {"title": "Evidence", "purpose": "Connect a claim to retrieved evidence.", "takeaway": "The cited result supports this bounded conclusion.", "layout": "evidence", "content": {"claim": "A supported claim", "conditions": ["A stated condition"]}, "source_ids": ["paper-1"]}
|
| 72 |
+
report = base_report(section)
|
| 73 |
+
report["references"] = ["paper-1"]
|
| 74 |
+
paper = {"id": "paper-1", "title": "A Retrieved Paper", "authors": ["A. Author"], "published": "2025-01-01", "url": "https://example.com/paper"}
|
| 75 |
+
output = render_presentation(report, [paper], "2026-01-01")
|
| 76 |
+
self.assertIn('href="#ref-paper-1"', output)
|
| 77 |
+
self.assertIn('id="ref-paper-1"', output)
|
| 78 |
|
| 79 |
def test_empty_report_is_rejected(self):
|
| 80 |
with self.assertRaises(ValidationError):
|