zhiyi commited on
Commit
de12820
·
1 Parent(s): 613e06a

Recover missing sections and bound search failures

Browse files
Files changed (2) hide show
  1. app.py +89 -19
  2. tests/test_app.py +21 -1
app.py CHANGED
@@ -182,7 +182,7 @@ def arxiv_search(topic: str, limit: int) -> list[dict[str, Any]]:
182
  "&sortBy=submittedDate&sortOrder=descending"
183
  )
184
  headers = {"User-Agent": f"algorithm-research-deck/1.0 ({os.getenv('CONTACT_EMAIL', 'contact-not-set')})"}
185
- response = http_get_with_retries(url, headers=headers, read_timeout=90, attempts=3)
186
  ns = {"a": "http://www.w3.org/2005/Atom"}
187
  root = ElementTree.fromstring(response.text)
188
  papers = []
@@ -207,7 +207,7 @@ def openalex_search(query: str, limit: int) -> list[dict[str, Any]]:
207
  url = f"https://api.openalex.org/works?search={quote_plus(query)}&per-page={min(limit, 100)}"
208
  if email:
209
  url += f"&mailto={quote_plus(email)}"
210
- response = http_get_with_retries(url, read_timeout=60, attempts=3)
211
  results = response.json().get("results", [])
212
  papers = []
213
  for work in results:
@@ -305,26 +305,46 @@ def plan_search_queries(request: ResearchRequest) -> tuple[list[str], dict[str,
305
  return queries[:6], english_understanding
306
 
307
 
 
 
 
 
 
 
 
 
 
 
308
  def search_literature(queries: list[str], total_limit: int) -> tuple[list[dict[str, Any]], list[str]]:
309
  per_query = max(5, min(20, total_limit // max(1, len(queries))))
310
  buckets: list[list[dict[str, Any]]] = []
311
  warnings: list[str] = []
 
312
  for query in queries:
313
  query_papers: list[dict[str, Any]] = []
314
- try:
315
- found = arxiv_search(query, per_query)
316
- for paper in found:
317
- paper["source"] = "arXiv"
318
- query_papers.extend(found)
319
- except Exception:
320
- warnings.append(f"arXiv timeout: {query}")
321
- if len(query_papers) < max(2, per_query // 4):
 
 
 
322
  try:
323
  query_papers.extend(openalex_search(query, per_query))
324
- except Exception:
325
- warnings.append(f"OpenAlex timeout: {query}")
 
 
326
  buckets.append(deduplicate(query_papers))
327
 
 
 
 
 
328
  balanced: list[dict[str, Any]] = []
329
  max_bucket = max((len(bucket) for bucket in buckets), default=0)
330
  for index in range(max_bucket):
@@ -484,6 +504,31 @@ def validate_stage_with_repairs(
484
  raise RuntimeError(f"{stage_name} validation failed")
485
 
486
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
487
  def prepare_evidence_inputs(
488
  papers: list[dict[str, Any]], deep_texts: dict[str, str], limit: int
489
  ) -> list[dict[str, Any]]:
@@ -648,12 +693,7 @@ def build_report(
648
  }, ensure_ascii=False),
649
  4200,
650
  )
651
- candidates = sections_result.get("sections", [])
652
- for plan, candidate in zip(batch, candidates):
653
- if isinstance(candidate, dict):
654
- candidate.update({
655
- "id": plan.id, "title": plan.title, "purpose": plan.purpose, "layout": plan.layout,
656
- })
657
  validated_batch = validate_stage_with_repairs(
658
  sections_result,
659
  SectionsEnvelope,
@@ -662,7 +702,37 @@ def build_report(
662
  4200,
663
  )
664
  if len(validated_batch.sections) != len(batch):
665
- raise RuntimeError("The model omitted one or more approved report sections")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
666
  for plan, section in zip(batch, validated_batch.sections):
667
  normalized = section.model_dump()
668
  normalized.update({
 
182
  "&sortBy=submittedDate&sortOrder=descending"
183
  )
184
  headers = {"User-Agent": f"algorithm-research-deck/1.0 ({os.getenv('CONTACT_EMAIL', 'contact-not-set')})"}
185
+ response = http_get_with_retries(url, headers=headers, read_timeout=35, attempts=2)
186
  ns = {"a": "http://www.w3.org/2005/Atom"}
187
  root = ElementTree.fromstring(response.text)
188
  papers = []
 
207
  url = f"https://api.openalex.org/works?search={quote_plus(query)}&per-page={min(limit, 100)}"
208
  if email:
209
  url += f"&mailto={quote_plus(email)}"
210
+ response = http_get_with_retries(url, read_timeout=35, attempts=2)
211
  results = response.json().get("results", [])
212
  papers = []
213
  for work in results:
 
305
  return queries[:6], english_understanding
306
 
307
 
308
+ def search_error_summary(exc: Exception) -> str:
309
+ if exc.__cause__ is not None and isinstance(exc.__cause__, Exception):
310
+ return search_error_summary(exc.__cause__)
311
+ if isinstance(exc, httpx.HTTPStatusError):
312
+ return f"HTTP {exc.response.status_code}"
313
+ if isinstance(exc, httpx.TimeoutException) or "timed out" in str(exc).lower():
314
+ return "请求超时"
315
+ return type(exc).__name__
316
+
317
+
318
  def search_literature(queries: list[str], total_limit: int) -> tuple[list[dict[str, Any]], list[str]]:
319
  per_query = max(5, min(20, total_limit // max(1, len(queries))))
320
  buckets: list[list[dict[str, Any]]] = []
321
  warnings: list[str] = []
322
+ failures = {"arXiv": 0, "OpenAlex": 0}
323
  for query in queries:
324
  query_papers: list[dict[str, Any]] = []
325
+ if failures["arXiv"] < 2:
326
+ try:
327
+ found = arxiv_search(query, per_query)
328
+ for paper in found:
329
+ paper["source"] = "arXiv"
330
+ query_papers.extend(found)
331
+ failures["arXiv"] = 0
332
+ except Exception as exc:
333
+ failures["arXiv"] += 1
334
+ warnings.append(f"arXiv {search_error_summary(exc)}:{query}")
335
+ if len(query_papers) < max(2, per_query // 4) and failures["OpenAlex"] < 2:
336
  try:
337
  query_papers.extend(openalex_search(query, per_query))
338
+ failures["OpenAlex"] = 0
339
+ except Exception as exc:
340
+ failures["OpenAlex"] += 1
341
+ warnings.append(f"OpenAlex {search_error_summary(exc)}:{query}")
342
  buckets.append(deduplicate(query_papers))
343
 
344
+ for provider, count in failures.items():
345
+ if count >= 2:
346
+ warnings.append(f"{provider} 连续失败,已停止剩余重复请求")
347
+
348
  balanced: list[dict[str, Any]] = []
349
  max_bucket = max((len(bucket) for bucket in buckets), default=0)
350
  for index in range(max_bucket):
 
504
  raise RuntimeError(f"{stage_name} validation failed")
505
 
506
 
507
+ def align_sections_to_plan(result: dict[str, Any], plans: list[SectionPlan]) -> dict[str, Any]:
508
+ """Order model sections by approved IDs and expose omissions to schema repair."""
509
+ candidates = [item for item in result.get("sections", []) if isinstance(item, dict)]
510
+ by_id = {str(item.get("id")): item for item in candidates if item.get("id")}
511
+ plan_ids = {plan.id for plan in plans}
512
+ unused = [item for item in candidates if str(item.get("id", "")) not in plan_ids]
513
+ aligned = []
514
+ for plan in plans:
515
+ candidate = by_id.get(plan.id)
516
+ if candidate is None and unused:
517
+ candidate = unused.pop(0)
518
+ candidate = dict(candidate or {})
519
+ candidate.update({
520
+ "id": plan.id,
521
+ "title": plan.title,
522
+ "purpose": plan.purpose,
523
+ "layout": plan.layout,
524
+ })
525
+ candidate.setdefault("takeaway", "")
526
+ candidate.setdefault("content", {})
527
+ candidate.setdefault("source_ids", [])
528
+ aligned.append(candidate)
529
+ return {"sections": aligned}
530
+
531
+
532
  def prepare_evidence_inputs(
533
  papers: list[dict[str, Any]], deep_texts: dict[str, str], limit: int
534
  ) -> list[dict[str, Any]]:
 
693
  }, ensure_ascii=False),
694
  4200,
695
  )
696
+ sections_result = align_sections_to_plan(sections_result, batch)
 
 
 
 
 
697
  validated_batch = validate_stage_with_repairs(
698
  sections_result,
699
  SectionsEnvelope,
 
702
  4200,
703
  )
704
  if len(validated_batch.sections) != len(batch):
705
+ recovered_sections: list[ReportSection] = []
706
+ for plan in batch:
707
+ one_result = chat_json(
708
+ section_system,
709
+ json.dumps({
710
+ **shared_context,
711
+ "report_identity": {
712
+ "title": outline.title,
713
+ "research_type": outline.research_type,
714
+ "executive_summary": outline.executive_summary,
715
+ },
716
+ "approved_section_plan": [plan.model_dump()],
717
+ "requirements": {
718
+ "return_exactly_one_section": True,
719
+ "preserve_plan_identity_and_layout": True,
720
+ "source_ids": "Only IDs present in evidence_cards; otherwise []",
721
+ "schema": SectionsEnvelope.model_json_schema(),
722
+ },
723
+ }, ensure_ascii=False),
724
+ 2600,
725
+ )
726
+ one_result = align_sections_to_plan(one_result, [plan])
727
+ recovered = validate_stage_with_repairs(
728
+ one_result,
729
+ SectionsEnvelope,
730
+ f"missing report section {plan.id}",
731
+ {**shared_context, "approved_section_plan": [plan.model_dump()]},
732
+ 2800,
733
+ )
734
+ recovered_sections.append(recovered.sections[0])
735
+ validated_batch = SectionsEnvelope(sections=recovered_sections)
736
  for plan, section in zip(batch, validated_batch.sections):
737
  normalized = section.model_dump()
738
  normalized.update({
tests/test_app.py CHANGED
@@ -4,7 +4,7 @@ from unittest.mock import patch
4
 
5
  from pydantic import ValidationError
6
 
7
- from app import ResearchReport, chat_json, deduplicate, infer_section_source_ids, normalize_comparison, normalize_process, parse_json_object, prepare_evidence_inputs, preview_frame, render_presentation, render_section_content, render_value, safe_slug, search_literature, serializable_validation_errors
8
 
9
 
10
  def base_report(section):
@@ -50,6 +50,26 @@ class AppTests(unittest.TestCase):
50
  self.assertEqual(papers[0]["id"], "oa-1")
51
  mocked_openalex.assert_called_once()
52
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  def test_parser_removes_invalid_string_line_continuation(self):
54
  self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
55
 
 
4
 
5
  from pydantic import ValidationError
6
 
7
+ from app import ResearchReport, SectionPlan, align_sections_to_plan, chat_json, deduplicate, infer_section_source_ids, normalize_comparison, normalize_process, parse_json_object, prepare_evidence_inputs, preview_frame, render_presentation, render_section_content, render_value, safe_slug, search_literature, serializable_validation_errors
8
 
9
 
10
  def base_report(section):
 
50
  self.assertEqual(papers[0]["id"], "oa-1")
51
  mocked_openalex.assert_called_once()
52
 
53
+ @patch("app.openalex_search", side_effect=TimeoutError("offline"))
54
+ @patch("app.arxiv_search", side_effect=TimeoutError("offline"))
55
+ def test_search_stops_repeating_failed_providers(self, mocked_arxiv, mocked_openalex):
56
+ papers, warnings = search_literature([f"query-{i}" for i in range(5)], 20)
57
+ self.assertEqual(papers, [])
58
+ self.assertEqual(mocked_arxiv.call_count, 2)
59
+ self.assertEqual(mocked_openalex.call_count, 2)
60
+ self.assertTrue(any("已停止剩余重复请求" in warning for warning in warnings))
61
+
62
+ def test_missing_standard_and_deep_sections_are_exposed_for_repair(self):
63
+ plans = [
64
+ SectionPlan(id=f"section-{i}", title=f"Section {i}", purpose="Explain one distinct technical decision.", layout="text")
65
+ for i in range(4)
66
+ ]
67
+ partial = {"sections": [{"id": "section-0", "takeaway": "Only the first section was returned.", "content": {"points": ["A substantive point"]}}]}
68
+ aligned = align_sections_to_plan(partial, plans)
69
+ self.assertEqual(len(aligned["sections"]), 4)
70
+ self.assertEqual([item["id"] for item in aligned["sections"]], [plan.id for plan in plans])
71
+ self.assertEqual(aligned["sections"][1]["content"], {})
72
+
73
  def test_parser_removes_invalid_string_line_continuation(self):
74
  self.assertEqual(parse_json_object('{"title":"A\\\nB","value":1}'), {"title": "AB", "value": 1})
75