zhiyi commited on
Commit
5b12fdd
·
1 Parent(s): 470e251

Generate and validate reports in structured stages

Browse files
Files changed (3) hide show
  1. README.md +2 -0
  2. app.py +262 -24
  3. tests/test_app.py +33 -1
README.md CHANGED
@@ -18,6 +18,8 @@ Turn an algorithm research topic into an interactive, paper-backed HTML presenta
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
  ## Space configuration
22
 
23
  Secret:
 
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 produces an outline, a causal technical lineage and method comparison, and section content. Every stage is validated with Pydantic and receives at most two targeted repair attempts before rendering, preventing valid-but-empty JSON from producing blank slides.
22
+
23
  ## Space configuration
24
 
25
  Secret:
app.py CHANGED
@@ -18,7 +18,7 @@ import gradio as gr
18
  import httpx
19
  import spaces
20
  from huggingface_hub import InferenceClient
21
- from pydantic import BaseModel, Field
22
  from pypdf import PdfReader
23
 
24
 
@@ -47,6 +47,82 @@ class ResearchRequest(BaseModel):
47
  paper_urls: list[str] = Field(default_factory=list, max_length=12)
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  DEPTHS = {
51
  "quick": {"discover": 30, "screen": 10, "deep": 2},
52
  "standard": {"discover": 60, "screen": 20, "deep": 4},
@@ -329,42 +405,201 @@ def extract_pdf(paper: dict[str, Any], max_chars: int = 14000) -> str:
329
  return "\n".join(pages)[:max_chars]
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  def build_report(
333
  request: ResearchRequest,
334
  papers: list[dict[str, Any]],
335
  deep_texts: dict[str, str],
336
  english_understanding: dict[str, Any],
337
  ) -> dict[str, Any]:
338
- evidence = []
339
  for paper in papers:
340
  item = {k: paper.get(k) for k in ("id", "title", "summary", "authors", "published", "updated", "url")}
341
  if paper["id"] in deep_texts:
342
- item["selected_full_text_extract"] = deep_texts[paper["id"]]
343
- evidence.append(item)
344
- payload = {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
345
  "request": request.model_dump(),
346
  "english_understanding": english_understanding,
347
  "research_cutoff": datetime.now(timezone.utc).date().isoformat(),
348
- "evidence": evidence,
349
- "schema": {
350
- "title": "string",
351
- "subtitle": "string",
352
- "executive_summary": ["string"],
353
- "lineage": [{"year": "string", "method": "string", "paper_id": "string", "bottleneck": "string", "innovation": "string", "limitation": "string"}],
354
- "sections": [{"title": "string", "takeaway": "string", "body": ["string"], "paper_ids": ["string"], "details": "string"}],
355
- "comparisons": [{"method": "string", "mechanism": "string", "strengths": "string", "limitations": "string", "engineering": "string"}],
356
- "open_questions": ["string"],
357
- },
358
  }
359
- report = chat_json(
360
- "You are a rigorous algorithm research lead. Return only JSON matching the supplied schema. "
361
- "Trace classical ideas to current methods, distinguish preprints, avoid incomparable benchmark claims, "
362
- "and cite only supplied paper IDs. Write concise presentation-ready content.",
363
- json.dumps(payload, ensure_ascii=False),
364
- 3800,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  )
366
- report["english_understanding"] = english_understanding
367
- return report
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
 
369
 
370
  def esc(value: Any) -> str:
@@ -494,7 +729,10 @@ def run_research_ui(
494
  english_understanding["evidence_mode"] = "Model knowledge only; online paper search was disabled by the user."
495
  progress(0.45, desc="已关闭联网检索,使用模型已有知识生成")
496
  progress(0.68, desc="DeepSeek-V4-Flash 正在生成技术路线")
497
- report = build_report(request, screen, deep_texts, english_understanding)
 
 
 
498
  cutoff = datetime.now(timezone.utc).date().isoformat()
499
  content = render_presentation(report, screen, cutoff)
500
  job_dir = DATA_DIR / uuid.uuid4().hex
 
18
  import httpx
19
  import spaces
20
  from huggingface_hub import InferenceClient
21
+ from pydantic import BaseModel, Field, ValidationError
22
  from pypdf import PdfReader
23
 
24
 
 
47
  paper_urls: list[str] = Field(default_factory=list, max_length=12)
48
 
49
 
50
+ class EvidenceCard(BaseModel):
51
+ paper_id: str = Field(min_length=1)
52
+ title: str = Field(min_length=2)
53
+ year: str = Field(min_length=1)
54
+ lineage_role: str = Field(min_length=3)
55
+ problem: str = Field(min_length=10)
56
+ core_idea: str = Field(min_length=10)
57
+ delta: str = Field(min_length=10)
58
+ evidence: list[str] = Field(min_length=1, max_length=4)
59
+ limitations: list[str] = Field(min_length=1, max_length=4)
60
+
61
+
62
+ class EvidenceCardsEnvelope(BaseModel):
63
+ cards: list[EvidenceCard] = Field(min_length=1)
64
+
65
+
66
+ class SectionPlan(BaseModel):
67
+ id: str = Field(min_length=2)
68
+ title: str = Field(min_length=2)
69
+ purpose: str = Field(min_length=10)
70
+ paper_ids: list[str] = Field(default_factory=list)
71
+
72
+
73
+ class ReportOutline(BaseModel):
74
+ title: str = Field(min_length=2)
75
+ subtitle: str = Field(min_length=2)
76
+ executive_summary: list[str] = Field(min_length=3, max_length=5)
77
+ section_plan: list[SectionPlan] = Field(min_length=5, max_length=10)
78
+ open_questions: list[str] = Field(min_length=2, max_length=5)
79
+
80
+
81
+ class LineageNode(BaseModel):
82
+ year: str = Field(min_length=1)
83
+ method: str = Field(min_length=2)
84
+ paper_id: str = ""
85
+ bottleneck: str = Field(min_length=10)
86
+ innovation: str = Field(min_length=10)
87
+ limitation: str = Field(min_length=10)
88
+
89
+
90
+ class MethodComparison(BaseModel):
91
+ method: str = Field(min_length=2)
92
+ mechanism: str = Field(min_length=10)
93
+ strengths: str = Field(min_length=10)
94
+ limitations: str = Field(min_length=10)
95
+ engineering: str = Field(min_length=10)
96
+
97
+
98
+ class TechnicalSynthesis(BaseModel):
99
+ lineage: list[LineageNode] = Field(min_length=4, max_length=9)
100
+ comparisons: list[MethodComparison] = Field(min_length=3, max_length=8)
101
+
102
+
103
+ class ReportSection(BaseModel):
104
+ title: str = Field(min_length=2)
105
+ takeaway: str = Field(min_length=10)
106
+ body: list[str] = Field(min_length=2, max_length=5)
107
+ paper_ids: list[str] = Field(default_factory=list)
108
+ details: str = Field(min_length=20)
109
+
110
+
111
+ class SectionsEnvelope(BaseModel):
112
+ sections: list[ReportSection] = Field(min_length=5, max_length=10)
113
+
114
+
115
+ class ResearchReport(BaseModel):
116
+ title: str = Field(min_length=2)
117
+ subtitle: str = Field(min_length=2)
118
+ executive_summary: list[str] = Field(min_length=3, max_length=5)
119
+ lineage: list[LineageNode] = Field(min_length=4, max_length=9)
120
+ sections: list[ReportSection] = Field(min_length=5, max_length=10)
121
+ comparisons: list[MethodComparison] = Field(min_length=3, max_length=8)
122
+ open_questions: list[str] = Field(min_length=2, max_length=5)
123
+ english_understanding: dict[str, Any] = Field(default_factory=dict)
124
+
125
+
126
  DEPTHS = {
127
  "quick": {"discover": 30, "screen": 10, "deep": 2},
128
  "standard": {"discover": 60, "screen": 20, "deep": 4},
 
405
  return "\n".join(pages)[:max_chars]
406
 
407
 
408
+ def validate_stage_with_repairs(
409
+ result: dict[str, Any],
410
+ model_type: type[BaseModel],
411
+ stage_name: str,
412
+ source_context: dict[str, Any],
413
+ max_tokens: int,
414
+ ) -> BaseModel:
415
+ candidate = result
416
+ for attempt in range(3):
417
+ try:
418
+ return model_type.model_validate(candidate)
419
+ except ValidationError as exc:
420
+ if attempt == 2:
421
+ raise RuntimeError(f"{stage_name} remained invalid after two repairs: {exc}") from exc
422
+ candidate = chat_json(
423
+ f"Repair the {stage_name} JSON. Preserve valid content, fill missing fields, and return only the complete corrected object.",
424
+ json.dumps({
425
+ "validation_errors": exc.errors(include_url=False),
426
+ "object_to_repair": candidate,
427
+ "source_context": source_context,
428
+ "schema": model_type.model_json_schema(),
429
+ }, ensure_ascii=False),
430
+ max_tokens,
431
+ )
432
+ raise RuntimeError(f"{stage_name} validation failed")
433
+
434
+
435
  def build_report(
436
  request: ResearchRequest,
437
  papers: list[dict[str, Any]],
438
  deep_texts: dict[str, str],
439
  english_understanding: dict[str, Any],
440
  ) -> dict[str, Any]:
441
+ raw_evidence = []
442
  for paper in papers:
443
  item = {k: paper.get(k) for k in ("id", "title", "summary", "authors", "published", "updated", "url")}
444
  if paper["id"] in deep_texts:
445
+ item["selected_text_extract"] = deep_texts[paper["id"]]
446
+ raw_evidence.append(item)
447
+
448
+ if raw_evidence:
449
+ card_payload = {
450
+ "request": request.model_dump(),
451
+ "papers": raw_evidence,
452
+ "requirements": {
453
+ "one_card_per_useful_paper": True,
454
+ "paraphrase_instead_of_copy": True,
455
+ "cite_only_supplied_paper_ids": True,
456
+ "schema": EvidenceCardsEnvelope.model_json_schema(),
457
+ },
458
+ }
459
+ cards_result = chat_json(
460
+ "Create compact evidence cards for an algorithm literature review. Keep experimental conditions and limitations. "
461
+ "Do not invent results, citations, or paper IDs. Return only the required JSON object and no empty fields.",
462
+ json.dumps(card_payload, ensure_ascii=False),
463
+ 3600,
464
+ )
465
+ cards = validate_stage_with_repairs(
466
+ cards_result,
467
+ EvidenceCardsEnvelope,
468
+ "evidence cards",
469
+ {"request": request.model_dump(), "papers": raw_evidence},
470
+ 3600,
471
+ ).model_dump()["cards"]
472
+ else:
473
+ cards = []
474
+
475
+ shared_context = {
476
  "request": request.model_dump(),
477
  "english_understanding": english_understanding,
478
  "research_cutoff": datetime.now(timezone.utc).date().isoformat(),
479
+ "evidence_cards": cards,
480
+ "evidence_rule": (
481
+ "Use only supplied paper IDs for citations. State evidence gaps instead of inventing support."
482
+ if cards else
483
+ "Online evidence is disabled. Do not invent paper IDs or claim that recent literature was verified."
484
+ ),
 
 
 
 
485
  }
486
+
487
+ outline_result = chat_json(
488
+ "Design a concise technical research presentation for algorithm engineers. Return only JSON matching the schema. "
489
+ "The executive summary must contain substantive conclusions. Every section needs a distinct purpose.",
490
+ json.dumps({
491
+ **shared_context,
492
+ "requirements": {
493
+ "executive_summary_items": "3 to 5",
494
+ "section_plan_items": "5 to 10",
495
+ "open_question_items": "2 to 5",
496
+ "schema": ReportOutline.model_json_schema(),
497
+ },
498
+ }, ensure_ascii=False),
499
+ 3000,
500
+ )
501
+ outline = validate_stage_with_repairs(
502
+ outline_result,
503
+ ReportOutline,
504
+ "report outline",
505
+ shared_context,
506
+ 3200,
507
+ )
508
+
509
+ synthesis_result = chat_json(
510
+ "Build a causal technical lineage and a consistent method comparison. Return only JSON matching the schema. "
511
+ "Each lineage node must connect a predecessor bottleneck to an innovation and remaining limitation. "
512
+ "Compare every method on the same engineering-relevant dimensions and avoid unsupported numeric rankings.",
513
+ json.dumps({
514
+ **shared_context,
515
+ "outline": outline.model_dump(),
516
+ "requirements": {
517
+ "lineage_nodes": "4 to 9",
518
+ "comparison_methods": "3 to 8",
519
+ "schema": TechnicalSynthesis.model_json_schema(),
520
+ },
521
+ }, ensure_ascii=False),
522
+ 3200,
523
+ )
524
+ synthesis = validate_stage_with_repairs(
525
+ synthesis_result,
526
+ TechnicalSynthesis,
527
+ "technical synthesis",
528
+ {**shared_context, "outline": outline.model_dump()},
529
+ 3600,
530
+ )
531
+
532
+ sections_result = chat_json(
533
+ "Write presentation-ready technical sections from the approved outline. Return only JSON matching the schema. "
534
+ "Explain mechanisms before benchmark claims. Each takeaway must be supported by its body. "
535
+ "Use only supplied paper IDs; use an empty paper_ids list when external evidence is disabled.",
536
+ json.dumps({
537
+ **shared_context,
538
+ "outline": outline.model_dump(),
539
+ "lineage": [item.model_dump() for item in synthesis.lineage],
540
+ "requirements": {
541
+ "one_section_per_outline_item": True,
542
+ "body_items_per_section": "2 to 5",
543
+ "schema": SectionsEnvelope.model_json_schema(),
544
+ },
545
+ }, ensure_ascii=False),
546
+ 5200,
547
+ )
548
+ sections = validate_stage_with_repairs(
549
+ sections_result,
550
+ SectionsEnvelope,
551
+ "report sections",
552
+ {**shared_context, "outline": outline.model_dump()},
553
+ 5200,
554
  )
555
+
556
+ assembled = {
557
+ "title": outline.title,
558
+ "subtitle": outline.subtitle,
559
+ "executive_summary": outline.executive_summary,
560
+ "lineage": [item.model_dump() for item in synthesis.lineage],
561
+ "sections": [item.model_dump() for item in sections.sections],
562
+ "comparisons": [item.model_dump() for item in synthesis.comparisons],
563
+ "open_questions": outline.open_questions,
564
+ "english_understanding": english_understanding,
565
+ }
566
+ allowed_ids = {paper["id"] for paper in papers}
567
+ for node in assembled["lineage"]:
568
+ if node.get("paper_id") not in allowed_ids:
569
+ node["paper_id"] = ""
570
+ for section in assembled["sections"]:
571
+ section["paper_ids"] = [paper_id for paper_id in section["paper_ids"] if paper_id in allowed_ids]
572
+ return validate_report_with_repairs(assembled, shared_context)
573
+
574
+
575
+ def validate_report_with_repairs(report: dict[str, Any], context: dict[str, Any]) -> dict[str, Any]:
576
+ candidate = report
577
+ for attempt in range(3):
578
+ try:
579
+ validated = ResearchReport.model_validate(candidate).model_dump()
580
+ allowed_ids = {card.get("paper_id") for card in context.get("evidence_cards", [])}
581
+ for node in validated["lineage"]:
582
+ if node.get("paper_id") not in allowed_ids:
583
+ node["paper_id"] = ""
584
+ for section in validated["sections"]:
585
+ section["paper_ids"] = [paper_id for paper_id in section["paper_ids"] if paper_id in allowed_ids]
586
+ return validated
587
+ except ValidationError as exc:
588
+ if attempt == 2:
589
+ raise RuntimeError(f"Report remained incomplete after two repairs: {exc}") from exc
590
+ candidate = chat_json(
591
+ "Repair an incomplete algorithm research report. Preserve valid content and fill only missing or invalid fields. "
592
+ "Return the complete report as one JSON object matching the schema. Do not invent paper IDs.",
593
+ json.dumps({
594
+ "validation_errors": exc.errors(include_url=False),
595
+ "report_to_repair": candidate,
596
+ "evidence_cards": context.get("evidence_cards", []),
597
+ "schema": ResearchReport.model_json_schema(),
598
+ }, ensure_ascii=False),
599
+ 5200,
600
+ )
601
+ candidate["english_understanding"] = context.get("english_understanding", {})
602
+ raise RuntimeError("Report validation failed")
603
 
604
 
605
  def esc(value: Any) -> str:
 
729
  english_understanding["evidence_mode"] = "Model knowledge only; online paper search was disabled by the user."
730
  progress(0.45, desc="已关闭联网检索,使用模型已有知识生成")
731
  progress(0.68, desc="DeepSeek-V4-Flash 正在生成技术路线")
732
+ try:
733
+ report = build_report(request, screen, deep_texts, english_understanding)
734
+ except (ValidationError, RuntimeError) as exc:
735
+ raise gr.Error(f"报告结构生成失败,模型在两次定向修复后仍未满足完整性要求:{exc}") from exc
736
  cutoff = datetime.now(timezone.utc).date().isoformat()
737
  content = render_presentation(report, screen, cutoff)
738
  job_dir = DATA_DIR / uuid.uuid4().hex
tests/test_app.py CHANGED
@@ -1,6 +1,8 @@
1
  import unittest
2
 
3
- from app import deduplicate, render_presentation, safe_slug
 
 
4
 
5
 
6
  class AppTests(unittest.TestCase):
@@ -17,6 +19,36 @@ class AppTests(unittest.TestCase):
17
  self.assertIn("<!doctype html>", output)
18
  self.assertIn("保存 PDF", output)
19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
 
21
  if __name__ == "__main__":
22
  unittest.main()
 
1
  import unittest
2
 
3
+ from pydantic import ValidationError
4
+
5
+ from app import ResearchReport, deduplicate, render_presentation, safe_slug
6
 
7
 
8
  class AppTests(unittest.TestCase):
 
19
  self.assertIn("<!doctype html>", output)
20
  self.assertIn("保存 PDF", output)
21
 
22
+ def test_empty_report_is_rejected(self):
23
+ with self.assertRaises(ValidationError):
24
+ ResearchReport.model_validate({})
25
+
26
+ def test_complete_report_is_accepted(self):
27
+ section = {
28
+ "title": "Method evolution",
29
+ "takeaway": "Each transition addresses a concrete bottleneck.",
30
+ "body": ["First supported point.", "Second supported point."],
31
+ "paper_ids": [],
32
+ "details": "Sufficient implementation and evidence details for validation.",
33
+ }
34
+ report = {
35
+ "title": "Test report",
36
+ "subtitle": "Validated structured output",
37
+ "executive_summary": ["Conclusion one is substantive.", "Conclusion two is substantive.", "Conclusion three is substantive."],
38
+ "lineage": [
39
+ {"year": str(2020 + i), "method": f"Method {i}", "paper_id": "", "bottleneck": "A concrete predecessor bottleneck.", "innovation": "A concrete technical innovation.", "limitation": "A remaining technical limitation."}
40
+ for i in range(4)
41
+ ],
42
+ "sections": [section for _ in range(5)],
43
+ "comparisons": [
44
+ {"method": f"Method {i}", "mechanism": "A sufficiently detailed mechanism.", "strengths": "A sufficiently detailed advantage.", "limitations": "A sufficiently detailed limitation.", "engineering": "A sufficiently detailed engineering note."}
45
+ for i in range(3)
46
+ ],
47
+ "open_questions": ["What evidence would change the choice?", "Which constraint dominates deployment?"],
48
+ "english_understanding": {},
49
+ }
50
+ self.assertEqual(ResearchReport.model_validate(report).title, "Test report")
51
+
52
 
53
  if __name__ == "__main__":
54
  unittest.main()