agent_search_gateway.paper_search_parser
Strict restricted-format parser for LLM academic paper search results.
1"""Strict restricted-format parser for LLM academic paper search results.""" 2 3from __future__ import annotations 4 5from datetime import date 6 7from .errors import InputFailure, ParserFailure 8from .providers.contracts import PaperSearchHit 9from .url_normalization import normalize_url 10 11_HEADING = "## Paper" 12_FIELDS = ( 13 "Title", 14 "Authors", 15 "Abstract", 16 "DOI", 17 "arXiv", 18 "Published", 19 "Updated", 20 "URL", 21 "PDF", 22 "Venue", 23 "Topics", 24 "Citations", 25 "Open Access", 26 "OA Status", 27 "License", 28) 29_BLOCK_LINES = 1 + len(_FIELDS) 30 31 32def parse_paper_markdown(markdown: str, *, provider: str) -> list[PaperSearchHit]: 33 """Parse only exact repeated ``## Paper`` blocks into provider candidates.""" 34 35 if not isinstance(markdown, str) or not markdown.strip(): 36 raise ParserFailure("LLM paper response contained no Paper blocks") 37 provider_name = provider.strip() if isinstance(provider, str) else "" 38 if not provider_name: 39 raise ParserFailure("LLM paper provider name is invalid") 40 41 lines = markdown.strip().splitlines() 42 if len(lines) % _BLOCK_LINES != 0: 43 raise ParserFailure("LLM paper response does not match the required block grammar") 44 45 hits: list[PaperSearchHit] = [] 46 for start in range(0, len(lines), _BLOCK_LINES): 47 block = lines[start : start + _BLOCK_LINES] 48 hits.append(_parse_block(block, provider_name)) 49 return hits 50 51 52def _parse_block(lines: list[str], provider: str) -> PaperSearchHit: 53 if len(lines) != _BLOCK_LINES or lines[0] != _HEADING: 54 raise ParserFailure("Paper block heading is invalid") 55 56 values: dict[str, str] = {} 57 for index, field in enumerate(_FIELDS, start=1): 58 prefix = f"{field}:" 59 line = lines[index] 60 if not line.startswith(prefix): 61 raise ParserFailure("Paper block fields do not match the required grammar") 62 values[field] = line[len(prefix) :].strip() 63 64 title = values["Title"] 65 if not title: 66 raise ParserFailure("Paper block title is empty") 67 url = _parse_required_url(values["URL"]) 68 pdf_url = _parse_optional_url(values["PDF"]) 69 published = _parse_optional_date(values["Published"], "published date") 70 updated = _parse_optional_date(values["Updated"], "updated date") 71 citations = _parse_optional_citations(values["Citations"]) 72 is_open_access = _parse_open_access(values["Open Access"]) 73 74 return PaperSearchHit( 75 source=f"llm:{provider}", 76 source_id=str(url), 77 title=title, 78 authors=_split_semicolon(values["Authors"]), 79 abstract=values["Abstract"], 80 doi=values["DOI"], 81 arxiv_id=values["arXiv"], 82 published_date=published, 83 updated_date=updated, 84 url=str(url), 85 pdf_url=str(pdf_url) if pdf_url is not None else "", 86 venue=values["Venue"], 87 topics=_split_semicolon(values["Topics"]), 88 citation_count=citations, 89 is_open_access=is_open_access, 90 oa_status=values["OA Status"], 91 license=values["License"], 92 ) 93 94 95def _split_semicolon(value: str) -> tuple[str, ...]: 96 if not value: 97 return () 98 items = tuple(item.strip() for item in value.split(";") if item.strip()) 99 if not items: 100 return () 101 return items 102 103 104def _parse_required_url(value: str) -> str: 105 if not value: 106 raise ParserFailure("Paper block URL is empty") 107 try: 108 return str(normalize_url(value)) 109 except InputFailure as exc: 110 raise ParserFailure("Paper block URL is invalid") from exc 111 112 113def _parse_optional_url(value: str) -> str | None: 114 if not value: 115 return None 116 try: 117 return str(normalize_url(value)) 118 except InputFailure as exc: 119 raise ParserFailure("Paper block PDF URL is invalid") from exc 120 121 122def _parse_optional_date(value: str, label: str) -> date | None: 123 if not value: 124 return None 125 if len(value) != 10: 126 raise ParserFailure(f"Paper block {label} is invalid") 127 try: 128 parsed = date.fromisoformat(value) 129 except ValueError as exc: 130 raise ParserFailure(f"Paper block {label} is invalid") from exc 131 if parsed.isoformat() != value: 132 raise ParserFailure(f"Paper block {label} is invalid") 133 return parsed 134 135 136def _parse_optional_citations(value: str) -> int | None: 137 if not value: 138 return None 139 if not value.isascii() or not value.isdecimal(): 140 raise ParserFailure("Paper block citations value is invalid") 141 return int(value) 142 143 144def _parse_open_access(value: str) -> bool | None: 145 normalized = value.casefold() 146 if normalized in {"", "unknown"}: 147 return None 148 if normalized == "true": 149 return True 150 if normalized == "false": 151 return False 152 raise ParserFailure("Paper block open-access value is invalid")
def
parse_paper_markdown( markdown: str, *, provider: str) -> list[agent_search_gateway.providers.contracts.PaperSearchHit]:
33def parse_paper_markdown(markdown: str, *, provider: str) -> list[PaperSearchHit]: 34 """Parse only exact repeated ``## Paper`` blocks into provider candidates.""" 35 36 if not isinstance(markdown, str) or not markdown.strip(): 37 raise ParserFailure("LLM paper response contained no Paper blocks") 38 provider_name = provider.strip() if isinstance(provider, str) else "" 39 if not provider_name: 40 raise ParserFailure("LLM paper provider name is invalid") 41 42 lines = markdown.strip().splitlines() 43 if len(lines) % _BLOCK_LINES != 0: 44 raise ParserFailure("LLM paper response does not match the required block grammar") 45 46 hits: list[PaperSearchHit] = [] 47 for start in range(0, len(lines), _BLOCK_LINES): 48 block = lines[start : start + _BLOCK_LINES] 49 hits.append(_parse_block(block, provider_name)) 50 return hits
Parse only exact repeated ## Paper blocks into provider candidates.