zhiyi commited on
Commit
4d2437d
·
1 Parent(s): 1534206

Render nested report content semantically

Browse files
Files changed (3) hide show
  1. app.py +58 -11
  2. static/dynamic-content.css +61 -0
  3. 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 render_value(value: Any) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  if isinstance(value, list):
654
- return "<ul>" + "".join(f"<li>{esc(x) if not isinstance(x, dict) else esc(json.dumps(x, ensure_ascii=False))}</li>" for x in value) + "</ul>"
 
 
 
 
 
 
 
655
  if isinstance(value, dict):
656
- return "<dl>" + "".join(f"<dt>{esc(k)}</dt><dd>{esc(v)}</dd>" for k, v in value.items()) + "</dl>"
 
 
 
 
 
 
 
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"))}</span><h3>{esc(n.get("title"))}</h3>'
665
- f'<p>{esc(n.get("innovation") or n.get("description"))}</p><small>{esc(n.get("limitation"))}</small></article>'
 
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"))}</th>{"".join(f"<td>{esc(v)}</td>" for v in row.get("values", []))}</tr>'
 
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 '<ol class="process">' + "".join(f"<li>{esc(x)}</li>" for x in content.get("steps", [])) + "</ol>"
 
 
 
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'<dl>{"".join(f"<dt>{esc(k)}</dt><dd>{esc(v)}</dd>" for k, v in symbol_items)}</dl>')
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 = (STATIC_DIR / "deck.css").read_text(encoding="utf-8")
 
 
 
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("{&quot;", 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()