zhiyi commited on
Commit
c47f9fb
·
1 Parent(s): a9dc81e

Finalize adaptive research and deck experience

Browse files
Files changed (5) hide show
  1. README.md +4 -2
  2. app.py +122 -121
  3. static/deck.css +186 -1
  4. static/deck.js +81 -8
  5. 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, paper-backed HTML presentation. DeepSeek-V4-Flash first converts Chinese inputs into a precise English technical interpretation and expands them into historical, milestone, current, and benchmark-oriented search queries. The Space searches arXiv with OpenAlex fallback, 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: raw papers are compressed into evidence cards, then the model infers the research type, creates a topic-specific outline, and writes each section using an appropriate layout. Timelines, comparisons, equations, paper analysis, and evidence-gap pages are optional rather than globally required. Layout-aware Pydantic validation and at most two targeted repair attempts prevent empty slides while still allowing unfamiliar topics to become clearly labeled exploratory reports instead of fabricated literature reviews.
 
 
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=3, max_length=12)
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
- papers: list[dict[str, Any]] = []
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
- papers.extend(found)
318
  except Exception:
319
  warnings.append(f"arXiv timeout: {query}")
 
320
  try:
321
- papers.extend(openalex_search(query, per_query))
322
  except Exception:
323
  warnings.append(f"OpenAlex timeout: {query}")
324
- if len(deduplicate(papers)) >= total_limit:
 
 
 
 
 
 
 
 
 
325
  break
326
- return deduplicate(papers)[:total_limit], warnings
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
- sections_result = chat_json(
601
- "Write all presentation sections from the model-designed outline. Return only JSON matching the schema. "
602
- "Use the selected layout for each section and put layout-specific data in content. Text/evidence sections may use "
603
- "{points, details}; timeline uses {nodes:[{year,title,problem,innovation,limitation}]}; comparison uses "
604
- "{columns:[...], rows:[{name, values:[...]}]}; process uses {steps:[{name,description}]} for a linear flow, "
605
- "or {common_steps:[...],branches:[{name,steps:[...]}]} when methods diverge; equation uses "
606
- "{equation, symbols, interpretation}; paper-analysis uses {method,evidence,limitations}; evidence-gap uses "
607
- "{interpretation,related_topics,hypotheses,required_inputs}. Every takeaway must follow from the content. "
608
- "Distinguish sourced facts from analysis or hypotheses. Never invent sources, papers, metrics, or recency claims.",
609
- json.dumps({
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 sections.sections],
635
- "references": sorted({source_id for section in sections.sections for source_id in section.source_ids}),
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(k.replace("_", " ").title())}</h3>{render_value(v)}' for k, v in content.items()
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
- summary_html = "".join(f"<li>{esc(x)}</li>" for x in report.get("executive_summary", []))
 
 
 
 
 
 
 
 
 
891
  section_html = "".join(
892
- f'<section class="slide" id="section-{i + 1}"><p class="eyebrow">{i + 2:02d} · {esc(x.get("layout"))}</p>'
893
- f'<h2>{esc(x.get("title"))}</h2><p class="takeaway">{esc(x.get("takeaway"))}</p>{render_section_content(x)}'
894
- f'<p class="sources">来源:{", ".join(esc(pid) for pid in x.get("source_ids", [])) or "未引用外部论文"}</p></section>'
 
 
 
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><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>
906
- <main><section class="slide active" id="cover"><p class="eyebrow">{esc(report.get("research_type"))} · {esc(cutoff)}</p><h1>{title}</h1><p class="subtitle">{subtitle}</p><p>{esc(evidence_note)}</p></section>
907
- <section class="slide" id="summary"><p class="eyebrow">01</p><h2>核心结论</h2><ol>{summary_html}</ol></section>{section_html}
908
- <section class="slide references" id="references"><h2>参考文献与局限</h2><p>{esc(evidence_note)}</p><ol>{refs}</ol><ul>{limitations}</ul></section></main>
 
 
 
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:76vh;border:1px solid #30394a;border-radius:10px;background:#080b12" sandbox="allow-scripts allow-popups"></iframe>'
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
- progress(0.28, desc="DeepSeek-V4-Flash 正在筛选关键论文")
963
- selected_ids = select_papers(request, screen, budget["screen"], budget["deep"])
964
- if not selected_ids:
965
- selected_ids = [p["id"] for p in screen[: budget["deep"]]]
966
- progress(0.48, desc="正在读取关键论文")
967
- for index, paper in enumerate(screen):
968
- if paper["id"] in selected_ids:
969
- try:
970
- deep_texts[paper["id"]] = extract_pdf(paper)
971
- except Exception as exc:
972
- deep_texts[paper["id"]] = f"PDF extraction failed: {exc}"
973
- progress(0.48 + 0.16 * (index + 1) / len(screen), desc="正在提取论文证据")
 
 
 
 
 
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}.preview-column{min-width:0}.input-column{max-width:430px}
 
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.Markdown("# Algorithm Research Deck\n从经典方法到最新论文,生成可保存的交互式 HTML 技术演示。")
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:76vh;display:grid;place-items:center;color:#758099'>交互演示将在这里生成</div>")
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
- :root{font-family:Inter,"Microsoft YaHei",sans-serif;color:#eef3fb;background:#080b12;color-scheme:dark;--accent:#7aa2ff;--muted:#9aa8bd}*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 80% 10%,#17213d 0,#080b12 35%);color:#eef3fb}nav{position:fixed;z-index:10;left:0;right:0;top:0;height:56px;display:flex;align-items:center;gap:10px;padding:0 3vw;background:rgba(8,11,18,.9);backdrop-filter:blur(12px);border-bottom:1px solid #273143}nav strong{margin-right:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}nav button{border:1px solid #344158;background:#131c2b;color:#eef3fb;border-radius:7px;padding:7px 10px;cursor:pointer}.slide{display:none;min-height:100vh;padding:11vh 8vw 9vh;align-content:center;max-width:1400px;margin:auto}.slide.active{display:grid}.eyebrow{letter-spacing:.14em;color:var(--accent);font-size:12px}h1{font-size:clamp(42px,7vw,94px);max-width:1050px;line-height:1.02;margin:.25em 0}h2{font-size:clamp(30px,4vw,58px);margin:.2em 0 .6em}.subtitle,.takeaway{font-size:clamp(20px,2.4vw,34px);color:#c8d3e7;max-width:1000px}li,p,td,th{font-size:clamp(16px,1.25vw,21px);line-height:1.55}li{margin:.45em 0}.timeline{display:flex;gap:10px;overflow-x:auto;padding:12px 0}.milestone{min-width:150px;text-align:left;padding:13px;border:1px solid #344158;border-radius:9px;background:#101827;color:#eaf0fb;cursor:pointer}.milestone span{display:block;color:var(--accent);font-size:12px}.milestone[aria-pressed="true"]{border-color:var(--accent);background:#182643}#milestone-detail{margin-top:18px;padding-top:16px;border-top:1px solid #344158}.table-wrap{overflow:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;vertical-align:top;border-bottom:1px solid #344158;padding:12px}details{margin-top:20px;border-top:1px solid #344158;padding-top:14px}summary{cursor:pointer;color:var(--accent)}.sources,.references span{font-size:13px;color:var(--muted)}.references li{font-size:14px}.references a{color:#a9c3ff}.progress{position:fixed;bottom:0;left:0;right:0;height:4px;background:#1b2434}.progress span{display:block;height:100%;background:var(--accent);transition:width .25s}@media(max-width:700px){nav strong{display:none}.slide{display:block;min-height:auto;padding:90px 22px 50px;border-bottom:1px solid #273143}.slide.active{display:block}.progress{display:none}}@media print{nav,.progress{display:none}.slide{display:block;min-height:auto;break-after:page;padding:10mm;background:#fff;color:#111}.references{break-after:auto}body{background:#fff;color:#111}.subtitle,.takeaway,.sources,.references span{color:#333}a{color:#111}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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')];let current=0;
2
- const counter=document.getElementById('counter');const bar=document.getElementById('progress-bar');
3
- function show(index,push=true){current=Math.max(0,Math.min(slides.length-1,index));slides.forEach((s,i)=>s.classList.toggle('active',i===current));counter.textContent=`${current+1} / ${slides.length}`;bar.style.width=`${(current+1)/slides.length*100}%`;if(push)history.replaceState(null,'',`#${slides[current].id}`);slides[current].scrollIntoView({block:'start'});}
4
- document.getElementById('prev').onclick=()=>show(current-1);document.getElementById('next').onclick=()=>show(current+1);
5
- document.addEventListener('keydown',e=>{if(['ArrowRight','PageDown'].includes(e.key))show(current+1);if(['ArrowLeft','PageUp'].includes(e.key))show(current-1);if(e.key==='Home')show(0);if(e.key==='End')show(slides.length-1);});
6
- const initial=slides.findIndex(s=>`#${s.id}`===location.hash);show(initial>=0?initial:0,false);
7
- const lineage=document.getElementById('lineage');if(lineage){const data=JSON.parse(lineage.dataset.lineage||'[]');const detail=document.getElementById('milestone-detail');function pick(i){const x=data[i]||{};detail.innerHTML=`<h3>${escapeText(x.method||'')}</h3><p><strong>突破:</strong>${escapeText(x.innovation||'')}</p><p><strong>解决的瓶颈:</strong>${escapeText(x.bottleneck||'')}</p><p><strong>遗留限制:</strong>${escapeText(x.limitation||'')}</p>`;document.querySelectorAll('.milestone').forEach((b,j)=>b.setAttribute('aria-pressed',String(i===j)));}document.querySelectorAll('.milestone').forEach((b,i)=>b.onclick=()=>pick(i));if(data.length)pick(0);}
8
- function escapeText(value){const d=document.createElement('div');d.textContent=value;return d.innerHTML;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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):