Spaces:
Running on Zero
Running on Zero
zhiyi commited on
Commit ·
de12820
1
Parent(s): 613e06a
Recover missing sections and bound search failures
Browse files- app.py +89 -19
- 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=
|
| 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=
|
| 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 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
paper
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
|
|
|
|
|
|
|
|
|
| 322 |
try:
|
| 323 |
query_papers.extend(openalex_search(query, per_query))
|
| 324 |
-
|
| 325 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|