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

Render research processes as interactive flows

Browse files
Files changed (3) hide show
  1. app.py +78 -7
  2. static/dynamic-content.css +89 -0
  3. tests/test_app.py +22 -1
app.py CHANGED
@@ -483,6 +483,33 @@ def prepare_evidence_inputs(
483
  return evidence_inputs
484
 
485
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
486
  def build_report(
487
  request: ResearchRequest,
488
  papers: list[dict[str, Any]],
@@ -574,7 +601,8 @@ def build_report(
574
  "Write all presentation sections from the model-designed outline. Return only JSON matching the schema. "
575
  "Use the selected layout for each section and put layout-specific data in content. Text/evidence sections may use "
576
  "{points, details}; timeline uses {nodes:[{year,title,problem,innovation,limitation}]}; comparison uses "
577
- "{columns:[...], rows:[{name, values:[...]}]}; process uses {steps:[...]}; equation uses "
 
578
  "{equation, symbols, interpretation}; paper-analysis uses {method,evidence,limitations}; evidence-gap uses "
579
  "{interpretation,related_topics,hypotheses,required_inputs}. Every takeaway must follow from the content. "
580
  "Distinguish sourced facts from analysis or hypotheses. Never invent sources, papers, metrics, or recency claims.",
@@ -610,8 +638,12 @@ def build_report(
610
  }
611
  allowed_ids = {paper["id"] for paper in papers}
612
  for section in assembled["sections"]:
613
- section["source_ids"] = [paper_id for paper_id in section["source_ids"] if paper_id in allowed_ids]
614
- assembled["references"] = [paper_id for paper_id in assembled["references"] if paper_id in allowed_ids]
 
 
 
 
615
  return validate_report_with_repairs(assembled, shared_context)
616
 
617
 
@@ -775,6 +807,40 @@ def normalize_comparison(content: dict[str, Any]) -> tuple[str, list[str], list[
775
  return row_label, columns, normalized_rows
776
 
777
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
778
  def render_section_content(section: dict[str, Any]) -> str:
779
  content, layout = section.get("content", {}), section.get("layout", "text")
780
  if layout == "timeline":
@@ -793,10 +859,15 @@ def render_section_content(section: dict[str, Any]) -> str:
793
  )
794
  return f'<div class="table-wrap"><table class="comparison-table"><thead><tr><th>{esc(row_label)}</th>{head}</tr></thead><tbody>{body}</tbody></table></div>'
795
  if layout == "process":
796
- return '<div class="process">' + "".join(
797
- f'<article class="process-step"><span>{i}</span>{render_value(step)}</article>'
798
- for i, step in enumerate(content.get("steps", []), 1)
799
- ) + "</div>"
 
 
 
 
 
800
  if layout == "equation":
801
  return (f'<pre class="equation">{esc(content.get("equation"))}</pre><p>{esc(content.get("interpretation"))}</p>'
802
  f'{render_value(content.get("symbols"))}')
 
483
  return evidence_inputs
484
 
485
 
486
+ def infer_section_source_ids(section: dict[str, Any], papers: list[dict[str, Any]]) -> list[str]:
487
+ """Recover omitted citations only when the section clearly names a retrieved paper or its authors."""
488
+ source_ids = list(section.get("source_ids", []))
489
+ haystack = json.dumps(
490
+ {key: section.get(key) for key in ("title", "takeaway", "content")}, ensure_ascii=False
491
+ ).lower()
492
+ compact_haystack = re.sub(r"\W+", "", haystack)
493
+ for paper in papers:
494
+ paper_id = paper.get("id")
495
+ if not paper_id or paper_id in source_ids:
496
+ continue
497
+ title = re.sub(r"\W+", "", str(paper.get("title", "")).lower())
498
+ year = str(paper.get("published", ""))[:4]
499
+ surnames = {
500
+ re.sub(r"[^a-z0-9]", "", str(author).lower().split()[-1])
501
+ for author in paper.get("authors", []) if str(author).strip()
502
+ }
503
+ mentioned_authors = [name for name in surnames if len(name) >= 4 and name in haystack]
504
+ title_match = len(title) >= 12 and title in compact_haystack
505
+ author_match = len(mentioned_authors) >= 2 or (
506
+ len(mentioned_authors) == 1 and len(mentioned_authors[0]) >= 6 and year and year in haystack
507
+ )
508
+ if title_match or author_match:
509
+ source_ids.append(paper_id)
510
+ return source_ids
511
+
512
+
513
  def build_report(
514
  request: ResearchRequest,
515
  papers: list[dict[str, Any]],
 
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.",
 
638
  }
639
  allowed_ids = {paper["id"] for paper in papers}
640
  for section in assembled["sections"]:
641
+ section["source_ids"] = [
642
+ paper_id for paper_id in infer_section_source_ids(section, papers) if paper_id in allowed_ids
643
+ ]
644
+ assembled["references"] = sorted({
645
+ paper_id for section in assembled["sections"] for paper_id in section["source_ids"]
646
+ })
647
  return validate_report_with_repairs(assembled, shared_context)
648
 
649
 
 
807
  return row_label, columns, normalized_rows
808
 
809
 
810
+ def normalize_process(content: dict[str, Any]) -> tuple[list[Any], list[dict[str, Any]]]:
811
+ if isinstance(content.get("branches"), list):
812
+ common = content.get("common_steps") or ([content["start"]] if content.get("start") else [])
813
+ return common, [branch for branch in content["branches"] if isinstance(branch, dict)]
814
+
815
+ steps = content.get("steps", [])
816
+ common: list[Any] = []
817
+ grouped: dict[str, list[Any]] = {}
818
+ for step in steps:
819
+ name = str(step.get("name", "")) if isinstance(step, dict) else str(step)
820
+ match = re.match(r"^([^::]{2,24})[::]\s*(.+)$", name)
821
+ if not match:
822
+ common.append(step)
823
+ continue
824
+ lane, short_name = match.groups()
825
+ normalized_step = dict(step) if isinstance(step, dict) else {"name": short_name}
826
+ normalized_step["name"] = short_name
827
+ grouped.setdefault(lane, []).append(normalized_step)
828
+ if len(grouped) < 2:
829
+ return [], [{"name": content.get("name", "流程"), "steps": steps}]
830
+ return common, [{"name": name, "steps": lane_steps} for name, lane_steps in grouped.items()]
831
+
832
+
833
+ def render_process_node(step: Any, index: str) -> str:
834
+ if not isinstance(step, dict):
835
+ return f'<article class="flow-node"><span>{esc(index)}</span><p>{esc(step)}</p></article>'
836
+ name = step.get("name") or step.get("title") or f"步骤 {index}"
837
+ details = {key: value for key, value in step.items() if key not in {"name", "title"}}
838
+ return (
839
+ f'<details class="flow-node"><summary><span>{esc(index)}</span><strong>{esc(name)}</strong></summary>'
840
+ f'<div class="flow-detail">{render_value(details)}</div></details>'
841
+ )
842
+
843
+
844
  def render_section_content(section: dict[str, Any]) -> str:
845
  content, layout = section.get("content", {}), section.get("layout", "text")
846
  if layout == "timeline":
 
859
  )
860
  return f'<div class="table-wrap"><table class="comparison-table"><thead><tr><th>{esc(row_label)}</th>{head}</tr></thead><tbody>{body}</tbody></table></div>'
861
  if layout == "process":
862
+ common, branches = normalize_process(content)
863
+ common_html = "".join(render_process_node(step, str(i)) for i, step in enumerate(common, 1))
864
+ branches_html = "".join(
865
+ f'<section class="flow-lane"><h3>{esc(branch.get("name") or branch.get("title") or "分支")}</h3>'
866
+ f'{"".join(render_process_node(step, f"{lane_index}.{step_index}") for step_index, step in enumerate(branch.get("steps", []), 1))}</section>'
867
+ for lane_index, branch in enumerate(branches, 1)
868
+ )
869
+ fork = '<div class="flow-fork" aria-hidden="true">分支</div>' if common and len(branches) > 1 else ""
870
+ return f'<div class="process-flow"><div class="flow-common">{common_html}</div>{fork}<div class="flow-branches">{branches_html}</div></div>'
871
  if layout == "equation":
872
  return (f'<pre class="equation">{esc(content.get("equation"))}</pre><p>{esc(content.get("interpretation"))}</p>'
873
  f'{render_value(content.get("symbols"))}')
static/dynamic-content.css CHANGED
@@ -73,3 +73,92 @@
73
  .comparison-table td > p {
74
  margin: 0;
75
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  .comparison-table td > p {
74
  margin: 0;
75
  }
76
+ .process-flow {
77
+ display: grid;
78
+ gap: 14px;
79
+ }
80
+ .flow-common {
81
+ display: grid;
82
+ gap: 12px;
83
+ max-width: 720px;
84
+ margin: 0 auto;
85
+ }
86
+ .flow-fork {
87
+ justify-self: center;
88
+ padding: 5px 14px;
89
+ border-radius: 999px;
90
+ background: #263b68;
91
+ color: #bcd0ff;
92
+ font-size: 13px;
93
+ }
94
+ .flow-branches {
95
+ display: grid;
96
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
97
+ gap: 18px;
98
+ }
99
+ .flow-lane {
100
+ display: grid;
101
+ align-content: start;
102
+ gap: 12px;
103
+ padding: 15px;
104
+ border: 1px solid #344158;
105
+ border-radius: 12px;
106
+ background: #0d1421;
107
+ }
108
+ .flow-lane h3 {
109
+ margin: 0;
110
+ color: #bcd0ff;
111
+ }
112
+ .flow-node {
113
+ position: relative;
114
+ margin: 0;
115
+ border: 1px solid #3d4c65;
116
+ border-radius: 10px;
117
+ background: #131d2d;
118
+ transition: transform .18s ease, border-color .18s ease, background .18s ease;
119
+ }
120
+ .flow-node + .flow-node::before {
121
+ content: "↓";
122
+ position: absolute;
123
+ top: -20px;
124
+ left: 50%;
125
+ color: var(--accent);
126
+ }
127
+ .flow-node:hover,
128
+ .flow-node[open] {
129
+ transform: translateY(-2px);
130
+ border-color: var(--accent);
131
+ background: #182643;
132
+ }
133
+ .flow-node summary,
134
+ .flow-node > p {
135
+ display: flex;
136
+ align-items: center;
137
+ gap: 10px;
138
+ padding: 12px 14px;
139
+ cursor: pointer;
140
+ list-style: none;
141
+ }
142
+ .flow-node summary::-webkit-details-marker {
143
+ display: none;
144
+ }
145
+ .flow-node summary span,
146
+ .flow-node > span {
147
+ display: inline-grid;
148
+ flex: 0 0 30px;
149
+ place-items: center;
150
+ width: 30px;
151
+ height: 30px;
152
+ border-radius: 50%;
153
+ background: #2b4274;
154
+ color: #fff;
155
+ font-size: 12px;
156
+ }
157
+ .flow-detail {
158
+ padding: 0 14px 14px 54px;
159
+ animation: reveal .2s ease-out;
160
+ }
161
+ @keyframes reveal {
162
+ from { opacity: 0; transform: translateY(-5px); }
163
+ to { opacity: 1; transform: translateY(0); }
164
+ }
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, normalize_comparison, parse_json_object, prepare_evidence_inputs, render_presentation, render_section_content, render_value, safe_slug
7
 
8
 
9
  def base_report(section):
@@ -105,6 +105,27 @@ class AppTests(unittest.TestCase):
105
  self.assertEqual(output.count("<th>方法维度</th>"), 1)
106
  self.assertIn("恢复场景物理辐射度图", output)
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
 
109
  if __name__ == "__main__":
110
  unittest.main()
 
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):
 
105
  self.assertEqual(output.count("<th>方法维度</th>"), 1)
106
  self.assertIn("恢复场景物理辐射度图", output)
107
 
108
+ def test_process_is_split_into_interactive_branches(self):
109
+ content = {"steps": [
110
+ {"name": "输入多曝光LDR图像序列", "description": "公共输入"},
111
+ {"name": "HDR重建:辐射度标定", "description": "恢复CRF"},
112
+ {"name": "HDR重建:权重融合", "description": "辐射域融合"},
113
+ {"name": "多曝光融合:多尺度分解", "description": "拉普拉斯金字塔"},
114
+ {"name": "多曝光融合:权重图生成与融合", "description": "质量权重"},
115
+ ]}
116
+ common, branches = normalize_process(content)
117
+ self.assertEqual(len(common), 1)
118
+ self.assertEqual([branch["name"] for branch in branches], ["HDR重建", "多曝光融合"])
119
+ output = render_section_content({"layout": "process", "content": content})
120
+ self.assertIn("flow-branches", output)
121
+ self.assertIn("<details", output)
122
+ self.assertIn("恢复CRF", output)
123
+
124
+ def test_named_retrieved_paper_recovers_missing_source_id(self):
125
+ section = {"title": "经典路线", "takeaway": "Debevec and Malik established the calibrated HDR pipeline.", "content": {"details": "The 1997 method estimates the camera response function."}, "source_ids": []}
126
+ papers = [{"id": "paper-1", "title": "Recovering High Dynamic Range Radiance Maps from Photographs", "authors": ["Paul Debevec", "Jitendra Malik"], "published": "1997-01-01"}]
127
+ self.assertEqual(infer_section_source_ids(section, papers), ["paper-1"])
128
+
129
 
130
  if __name__ == "__main__":
131
  unittest.main()