Spaces:
Running on Zero
Running on Zero
zhiyi commited on
Commit ·
a9dc81e
1
Parent(s): b2e433d
Render research processes as interactive flows
Browse files- app.py +78 -7
- static/dynamic-content.css +89 -0
- 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:[
|
|
|
|
| 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"] = [
|
| 614 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()
|