Spaces:
Running on Zero
Running on Zero
zhiyi commited on
Commit ·
4d2437d
1
Parent(s): 1534206
Render nested report content semantically
Browse files- app.py +58 -11
- static/dynamic-content.css +61 -0
- tests/test_app.py +18 -1
app.py
CHANGED
|
@@ -101,10 +101,14 @@ class ReportSection(BaseModel):
|
|
| 101 |
raise ValueError("section content must be substantive")
|
| 102 |
if self.layout == "timeline" and len(content.get("nodes", [])) < 2:
|
| 103 |
raise ValueError("timeline content requires at least two nodes")
|
|
|
|
|
|
|
| 104 |
if self.layout == "comparison" and (
|
| 105 |
len(content.get("columns", [])) < 2 or len(content.get("rows", [])) < 2
|
| 106 |
):
|
| 107 |
raise ValueError("comparison content requires columns and at least two rows")
|
|
|
|
|
|
|
| 108 |
if self.layout == "paper-analysis" and not all(
|
| 109 |
content.get(key) for key in ("method", "evidence", "limitations")
|
| 110 |
):
|
|
@@ -649,11 +653,40 @@ def render_presentation_legacy(report: dict[str, Any], papers: list[dict[str, An
|
|
| 649 |
</main><div class="progress"><span id="progress-bar"></span></div><script>{deck_js}</script></body></html>'''
|
| 650 |
|
| 651 |
|
| 652 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
if isinstance(value, list):
|
| 654 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 655 |
if isinstance(value, dict):
|
| 656 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
return f"<p>{esc(value)}</p>"
|
| 658 |
|
| 659 |
|
|
@@ -661,25 +694,36 @@ def render_section_content(section: dict[str, Any]) -> str:
|
|
| 661 |
content, layout = section.get("content", {}), section.get("layout", "text")
|
| 662 |
if layout == "timeline":
|
| 663 |
return '<div class="timeline">' + "".join(
|
| 664 |
-
f'<article class="milestone"><span>{esc(n.get("year")
|
| 665 |
-
f'<
|
|
|
|
| 666 |
for n in content.get("nodes", [])
|
| 667 |
) + "</div>"
|
| 668 |
if layout == "comparison":
|
| 669 |
columns, rows = content.get("columns", []), content.get("rows", [])
|
| 670 |
head = "".join(f"<th>{esc(x)}</th>" for x in columns)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
body = "".join(
|
| 672 |
-
f'<tr><th>{esc(row.get("name")
|
|
|
|
| 673 |
for row in rows
|
| 674 |
)
|
| 675 |
return f'<div class="table-wrap"><table><thead><tr><th>方法</th>{head}</tr></thead><tbody>{body}</tbody></table></div>'
|
| 676 |
if layout == "process":
|
| 677 |
-
return '<
|
|
|
|
|
|
|
|
|
|
| 678 |
if layout == "equation":
|
| 679 |
-
symbols = content.get("symbols", {})
|
| 680 |
-
symbol_items = symbols.items() if isinstance(symbols, dict) else []
|
| 681 |
return (f'<pre class="equation">{esc(content.get("equation"))}</pre><p>{esc(content.get("interpretation"))}</p>'
|
| 682 |
-
f'
|
| 683 |
wrapper = "aside" if layout == "evidence-gap" else "div"
|
| 684 |
return f'<{wrapper} class="{esc(layout)}">' + "".join(
|
| 685 |
f'<h3>{esc(k.replace("_", " ").title())}</h3>{render_value(v)}' for k, v in content.items()
|
|
@@ -688,7 +732,10 @@ def render_section_content(section: dict[str, Any]) -> str:
|
|
| 688 |
|
| 689 |
# This later definition intentionally replaces the legacy fixed-layout renderer above.
|
| 690 |
def render_presentation(report: dict[str, Any], papers: list[dict[str, Any]], cutoff: str) -> str:
|
| 691 |
-
deck_css =
|
|
|
|
|
|
|
|
|
|
| 692 |
deck_js = (STATIC_DIR / "deck.js").read_text(encoding="utf-8").replace("</script", "<\\/script")
|
| 693 |
citations = {p["id"]: p for p in papers}
|
| 694 |
evidence_mode = (report.get("english_understanding") or {}).get("evidence_mode", "")
|
|
|
|
| 101 |
raise ValueError("section content must be substantive")
|
| 102 |
if self.layout == "timeline" and len(content.get("nodes", [])) < 2:
|
| 103 |
raise ValueError("timeline content requires at least two nodes")
|
| 104 |
+
if self.layout == "timeline" and not all(isinstance(node, dict) for node in content.get("nodes", [])):
|
| 105 |
+
raise ValueError("timeline nodes must be objects")
|
| 106 |
if self.layout == "comparison" and (
|
| 107 |
len(content.get("columns", [])) < 2 or len(content.get("rows", [])) < 2
|
| 108 |
):
|
| 109 |
raise ValueError("comparison content requires columns and at least two rows")
|
| 110 |
+
if self.layout == "comparison" and not all(isinstance(row, dict) for row in content.get("rows", [])):
|
| 111 |
+
raise ValueError("comparison rows must be objects")
|
| 112 |
if self.layout == "paper-analysis" and not all(
|
| 113 |
content.get(key) for key in ("method", "evidence", "limitations")
|
| 114 |
):
|
|
|
|
| 653 |
</main><div class="progress"><span id="progress-bar"></span></div><script>{deck_js}</script></body></html>'''
|
| 654 |
|
| 655 |
|
| 656 |
+
def field_label(key: Any) -> str:
|
| 657 |
+
labels = {
|
| 658 |
+
"points": "要点", "details": "详细说明", "claim": "结论", "claims": "结论",
|
| 659 |
+
"conditions": "适用条件", "evidence": "证据", "limitations": "局限",
|
| 660 |
+
"method": "方法", "mechanism": "机制", "problem": "问题", "innovation": "创新",
|
| 661 |
+
"tradeoffs": "权衡", "interpretation": "解释", "related_topics": "相关主题",
|
| 662 |
+
"hypotheses": "待验证假设", "required_inputs": "所需补充信息", "findings": "发现",
|
| 663 |
+
}
|
| 664 |
+
normalized = str(key).strip().lower().replace("-", "_")
|
| 665 |
+
return labels.get(normalized, str(key).replace("_", " ").replace("-", " ").strip().title())
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
def render_value(value: Any, *, depth: int = 0) -> str:
|
| 669 |
+
"""Render arbitrary model JSON recursively instead of leaking JSON/Python repr."""
|
| 670 |
+
if value is None or value == "":
|
| 671 |
+
return ""
|
| 672 |
if isinstance(value, list):
|
| 673 |
+
if not value:
|
| 674 |
+
return ""
|
| 675 |
+
return '<div class="content-list">' + "".join(
|
| 676 |
+
f'<article class="content-card">{render_value(item, depth=depth + 1)}</article>'
|
| 677 |
+
if isinstance(item, dict) else
|
| 678 |
+
f'<div class="content-item">{render_value(item, depth=depth + 1)}</div>'
|
| 679 |
+
for item in value
|
| 680 |
+
) + "</div>"
|
| 681 |
if isinstance(value, dict):
|
| 682 |
+
if not value:
|
| 683 |
+
return ""
|
| 684 |
+
return '<div class="content-object">' + "".join(
|
| 685 |
+
f'<div class="content-field"><h4>{esc(field_label(key))}</h4>{render_value(item, depth=depth + 1)}</div>'
|
| 686 |
+
for key, item in value.items() if item is not None and item != ""
|
| 687 |
+
) + "</div>"
|
| 688 |
+
if isinstance(value, bool):
|
| 689 |
+
return f'<p>{"是" if value else "否"}</p>'
|
| 690 |
return f"<p>{esc(value)}</p>"
|
| 691 |
|
| 692 |
|
|
|
|
| 694 |
content, layout = section.get("content", {}), section.get("layout", "text")
|
| 695 |
if layout == "timeline":
|
| 696 |
return '<div class="timeline">' + "".join(
|
| 697 |
+
f'<article class="milestone"><span>{esc(n.get("year") or n.get("stage"))}</span>'
|
| 698 |
+
f'<h3>{esc(n.get("title") or n.get("method") or n.get("name"))}</h3>'
|
| 699 |
+
f'{render_value({k: v for k, v in n.items() if k not in {"year", "stage", "title", "method", "name"}})}</article>'
|
| 700 |
for n in content.get("nodes", [])
|
| 701 |
) + "</div>"
|
| 702 |
if layout == "comparison":
|
| 703 |
columns, rows = content.get("columns", []), content.get("rows", [])
|
| 704 |
head = "".join(f"<th>{esc(x)}</th>" for x in columns)
|
| 705 |
+
def cells(row: dict[str, Any]) -> list[Any]:
|
| 706 |
+
values = row.get("values")
|
| 707 |
+
if isinstance(values, dict):
|
| 708 |
+
return [values.get(column, "") for column in columns]
|
| 709 |
+
if isinstance(values, list):
|
| 710 |
+
return values
|
| 711 |
+
reserved = {"name", "method", "title"}
|
| 712 |
+
return [row.get(column, "") for column in columns] or [v for k, v in row.items() if k not in reserved]
|
| 713 |
body = "".join(
|
| 714 |
+
f'<tr><th>{esc(row.get("name") or row.get("method") or row.get("title"))}</th>'
|
| 715 |
+
f'{"".join(f"<td>{render_value(v)}</td>" for v in cells(row))}</tr>'
|
| 716 |
for row in rows
|
| 717 |
)
|
| 718 |
return f'<div class="table-wrap"><table><thead><tr><th>方法</th>{head}</tr></thead><tbody>{body}</tbody></table></div>'
|
| 719 |
if layout == "process":
|
| 720 |
+
return '<div class="process">' + "".join(
|
| 721 |
+
f'<article class="process-step"><span>{i}</span>{render_value(step)}</article>'
|
| 722 |
+
for i, step in enumerate(content.get("steps", []), 1)
|
| 723 |
+
) + "</div>"
|
| 724 |
if layout == "equation":
|
|
|
|
|
|
|
| 725 |
return (f'<pre class="equation">{esc(content.get("equation"))}</pre><p>{esc(content.get("interpretation"))}</p>'
|
| 726 |
+
f'{render_value(content.get("symbols"))}')
|
| 727 |
wrapper = "aside" if layout == "evidence-gap" else "div"
|
| 728 |
return f'<{wrapper} class="{esc(layout)}">' + "".join(
|
| 729 |
f'<h3>{esc(k.replace("_", " ").title())}</h3>{render_value(v)}' for k, v in content.items()
|
|
|
|
| 732 |
|
| 733 |
# This later definition intentionally replaces the legacy fixed-layout renderer above.
|
| 734 |
def render_presentation(report: dict[str, Any], papers: list[dict[str, Any]], cutoff: str) -> str:
|
| 735 |
+
deck_css = "\n".join(
|
| 736 |
+
(STATIC_DIR / name).read_text(encoding="utf-8")
|
| 737 |
+
for name in ("deck.css", "dynamic-content.css")
|
| 738 |
+
)
|
| 739 |
deck_js = (STATIC_DIR / "deck.js").read_text(encoding="utf-8").replace("</script", "<\\/script")
|
| 740 |
citations = {p["id"]: p for p in papers}
|
| 741 |
evidence_mode = (report.get("english_understanding") or {}).get("evidence_mode", "")
|
static/dynamic-content.css
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.content-list {
|
| 2 |
+
display: grid;
|
| 3 |
+
gap: 10px;
|
| 4 |
+
}
|
| 5 |
+
.content-item,
|
| 6 |
+
.content-card,
|
| 7 |
+
.content-field,
|
| 8 |
+
.process-step {
|
| 9 |
+
padding: 10px 14px;
|
| 10 |
+
border: 1px solid #344158;
|
| 11 |
+
border-radius: 9px;
|
| 12 |
+
background: #101827;
|
| 13 |
+
}
|
| 14 |
+
.content-card .content-object {
|
| 15 |
+
display: grid;
|
| 16 |
+
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
| 17 |
+
gap: 8px;
|
| 18 |
+
}
|
| 19 |
+
.content-field h4 {
|
| 20 |
+
margin: 0 0 4px;
|
| 21 |
+
color: var(--accent);
|
| 22 |
+
font-size: 13px;
|
| 23 |
+
}
|
| 24 |
+
.content-field p,
|
| 25 |
+
.content-item p {
|
| 26 |
+
margin: 0;
|
| 27 |
+
}
|
| 28 |
+
.process {
|
| 29 |
+
display: grid;
|
| 30 |
+
gap: 12px;
|
| 31 |
+
}
|
| 32 |
+
.process-step {
|
| 33 |
+
display: grid;
|
| 34 |
+
grid-template-columns: 38px 1fr;
|
| 35 |
+
align-items: start;
|
| 36 |
+
gap: 12px;
|
| 37 |
+
}
|
| 38 |
+
.process-step > span {
|
| 39 |
+
display: grid;
|
| 40 |
+
place-items: center;
|
| 41 |
+
width: 32px;
|
| 42 |
+
height: 32px;
|
| 43 |
+
border-radius: 50%;
|
| 44 |
+
background: #263b68;
|
| 45 |
+
color: #fff;
|
| 46 |
+
font-weight: 700;
|
| 47 |
+
}
|
| 48 |
+
.equation {
|
| 49 |
+
overflow: auto;
|
| 50 |
+
padding: 18px;
|
| 51 |
+
border: 1px solid #344158;
|
| 52 |
+
border-radius: 9px;
|
| 53 |
+
background: #101827;
|
| 54 |
+
font-size: clamp(16px, 1.6vw, 24px);
|
| 55 |
+
}
|
| 56 |
+
.evidence-gap {
|
| 57 |
+
padding: 16px;
|
| 58 |
+
border-left: 4px solid #e7ae59;
|
| 59 |
+
border-radius: 8px;
|
| 60 |
+
background: #231c12;
|
| 61 |
+
}
|
tests/test_app.py
CHANGED
|
@@ -2,7 +2,7 @@ import unittest
|
|
| 2 |
|
| 3 |
from pydantic import ValidationError
|
| 4 |
|
| 5 |
-
from app import ResearchReport, deduplicate, render_presentation, safe_slug
|
| 6 |
|
| 7 |
|
| 8 |
def base_report(section):
|
|
@@ -55,6 +55,23 @@ class AppTests(unittest.TestCase):
|
|
| 55 |
with self.assertRaises(ValidationError):
|
| 56 |
ResearchReport.model_validate(base_report(section))
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
if __name__ == "__main__":
|
| 60 |
unittest.main()
|
|
|
|
| 2 |
|
| 3 |
from pydantic import ValidationError
|
| 4 |
|
| 5 |
+
from app import ResearchReport, deduplicate, render_presentation, render_section_content, render_value, safe_slug
|
| 6 |
|
| 7 |
|
| 8 |
def base_report(section):
|
|
|
|
| 55 |
with self.assertRaises(ValidationError):
|
| 56 |
ResearchReport.model_validate(base_report(section))
|
| 57 |
|
| 58 |
+
def test_nested_json_is_rendered_as_semantic_html(self):
|
| 59 |
+
output = render_value({"findings": [{"claim": "Works well", "conditions": ["GPU", "FP16"]}]})
|
| 60 |
+
self.assertIn("<h4>", output)
|
| 61 |
+
self.assertIn("Works well", output)
|
| 62 |
+
self.assertIn("GPU", output)
|
| 63 |
+
self.assertNotIn("{"", output)
|
| 64 |
+
self.assertNotIn("['GPU'", output)
|
| 65 |
+
|
| 66 |
+
def test_timeline_keeps_nonstandard_fields(self):
|
| 67 |
+
output = render_section_content({"layout": "timeline", "content": {"nodes": [
|
| 68 |
+
{"stage": "Early", "method": "Baseline", "problem": "High cost", "tradeoffs": ["Memory", "Latency"]},
|
| 69 |
+
{"stage": "Later", "method": "Improved", "problem": "Low recall", "tradeoffs": ["Complexity"]},
|
| 70 |
+
]}})
|
| 71 |
+
self.assertIn("High cost", output)
|
| 72 |
+
self.assertIn("Memory", output)
|
| 73 |
+
self.assertNotIn("{'", output)
|
| 74 |
+
|
| 75 |
|
| 76 |
if __name__ == "__main__":
|
| 77 |
unittest.main()
|