Claude commited on
Commit
3352a6e
·
unverified ·
1 Parent(s): 4e043b3

Deduplicate app.py's search endpoints (item 4 from review)

Browse files

Six of the seven /serp and /ops search endpoints repeated the identical
gather -> log -> filter -> flatten -> shape-a-SerpResults pattern verbatim,
differing only in which query_* function they called. Extracted into two
helpers:

- _shape_serp_results(results): flatten a gather's per-query results (or
Exceptions), surfacing the last error only when every query failed.
- _run_serp_queries(fn, params, context): run fn concurrently across every
query, log failures, and shape via _shape_serp_results.

search_patents keeps its distinct OPS-fallback loop (genuinely different
logic, not boilerplate) but now reuses _shape_serp_results for its tail.

app.py had zero test coverage before this - added tests/test_app.py first
(characterizing the endpoints' current behavior via ASGITransport, with
the query_*/ops_search functions monkeypatched rather than hitting real
search engines or needing a real Playwright browser) and confirmed those
pass against the pre-refactor code, then did the extraction under that
safety net. All 80 tests pass; app.py is 385 -> 339 lines.

Files changed (2) hide show
  1. app.py +35 -80
  2. tests/test_app.py +159 -0
app.py CHANGED
@@ -104,40 +104,46 @@ ops_router = APIRouter(prefix="/ops", tags=["EPO OPS"])
104
  # ===================== Search endpoints =====================
105
 
106
 
107
- @serp_router.post("/search_scholar")
108
- async def search_google_scholar(params: SerpQuery):
109
- """Queries google scholar for the specified query"""
110
- logging.info(f"Searching Google Scholar for queries: {params.queries}")
111
- results = await asyncio.gather(*[query_google_scholar(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
112
- log_gathered_exceptions(results, "google scholar search", params)
113
-
114
- # Filter out exceptions and flatten the results
115
  filtered_results = [r for r in results if not isinstance(r, Exception)]
116
  flattened_results = [
117
  item for sublist in filtered_results for item in sublist]
118
 
119
- # all queries failed, return the last exception
120
  if len(filtered_results) == 0:
121
  return SerpResults(results=[], error=str(results[-1]))
122
 
123
  return SerpResults(results=flattened_results, error=None)
124
 
125
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
  @serp_router.post("/search_arxiv")
127
  async def search_arxiv(params: SerpQuery):
128
  """Searches arxiv for the specified queries and returns the found documents."""
129
  logging.info(f"Searching Arxiv for queries: {params.queries}")
130
- results = await asyncio.gather(*[query_arxiv(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
131
- log_gathered_exceptions(results, "arxiv search", params)
132
-
133
- filtered_results = [r for r in results if not isinstance(r, Exception)]
134
- flattened_results = [
135
- item for sublist in filtered_results for item in sublist]
136
-
137
- if len(filtered_results) == 0:
138
- return SerpResults(results=[], error=str(results[-1]))
139
-
140
- return SerpResults(results=flattened_results, error=None)
141
 
142
 
143
  @serp_router.post("/search_patents")
@@ -162,73 +168,31 @@ async def search_patents(params: SerpQuery) -> SerpResults:
162
  except Exception as e:
163
  logging.warning(f"OPS fallback failed: {e}")
164
 
165
- # Filter out exceptions and flatten the results
166
- filtered_results = [r for r in results if not isinstance(r, Exception)]
167
- flattened_results = [
168
- item for sublist in filtered_results for item in sublist]
169
-
170
- # all queries failed, return the last exception
171
- if len(filtered_results) == 0:
172
- return SerpResults(results=[], error=str(results[-1]))
173
-
174
- return SerpResults(results=flattened_results, error=None)
175
 
176
 
177
  @serp_router.post("/search_brave")
178
  async def search_brave(params: SerpQuery) -> SerpResults:
179
  """Searches brave search for the specified queries and returns the found documents."""
180
  logging.info(f"Searching Brave Search for queries: {params.queries}")
181
- results = await asyncio.gather(*[query_brave_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
182
- log_gathered_exceptions(results, "brave search", params)
183
-
184
- # Filter out exceptions and flatten the results
185
- filtered_results = [r for r in results if not isinstance(r, Exception)]
186
- flattened_results = [
187
- item for sublist in filtered_results for item in sublist]
188
-
189
- # all queries failed, return the last exception
190
- if len(filtered_results) == 0:
191
- return SerpResults(results=[], error=str(results[-1]))
192
-
193
- return SerpResults(results=flattened_results, error=None)
194
 
195
 
196
  @serp_router.post("/search_bing")
197
  async def search_bing(params: SerpQuery) -> SerpResults:
198
  """Searches Bing search for the specified queries and returns the found documents."""
199
  logging.info(f"Searching Bing Search for queries: {params.queries}")
200
- results = await asyncio.gather(*[query_bing_search(pw_browser, q, params.n_results) for q in params.queries], return_exceptions=True)
201
- log_gathered_exceptions(results, "bing search", params)
202
-
203
- # Filter out exceptions and flatten the results
204
- filtered_results = [r for r in results if not isinstance(r, Exception)]
205
- flattened_results = [
206
- item for sublist in filtered_results for item in sublist]
207
-
208
- # all queries failed, return the last exception
209
- if len(filtered_results) == 0:
210
- return SerpResults(results=[], error=str(results[-1]))
211
-
212
- return SerpResults(results=flattened_results, error=None)
213
 
214
 
215
  @serp_router.post("/search_duck")
216
  async def search_duck(params: SerpQuery) -> SerpResults:
217
  """Searches duckduckgo for the specified queries and returns the found documents"""
218
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
219
- results = await asyncio.gather(*[query_ddg_search(q, params.n_results) for q in params.queries], return_exceptions=True)
220
- log_gathered_exceptions(results, "duckduckgo search", params)
221
-
222
- # Filter out exceptions and flatten the results
223
- filtered_results = [r for r in results if not isinstance(r, Exception)]
224
- flattened_results = [
225
- item for sublist in filtered_results for item in sublist]
226
-
227
- # all queries failed, return the last exception
228
- if len(filtered_results) == 0:
229
- return SerpResults(results=[], error=str(results[-1]))
230
-
231
- return SerpResults(results=flattened_results, error=None)
232
 
233
 
234
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
@@ -327,17 +291,8 @@ async def scrap_patents(params: ScrapPatentsRequest) -> PatentScrapBulkResponse:
327
  async def ops_keyword_search(params: SerpQuery) -> SerpResults:
328
  """Keyword-searches patents via the official EPO OPS API."""
329
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
330
- results = await asyncio.gather(*[ops_search(httpx_client, q, params.n_results) for q in params.queries], return_exceptions=True)
331
- log_gathered_exceptions(results, "OPS search", params)
332
-
333
- filtered_results = [r for r in results if not isinstance(r, Exception)]
334
- flattened_results = [
335
- item for sublist in filtered_results for item in sublist]
336
-
337
- if len(filtered_results) == 0:
338
- return SerpResults(results=[], error=str(results[-1]))
339
-
340
- return SerpResults(results=flattened_results, error=None)
341
 
342
 
343
  @ops_router.get("/scrap_patent/{patent_id}")
 
104
  # ===================== Search endpoints =====================
105
 
106
 
107
+ def _shape_serp_results(results: list) -> SerpResults:
108
+ """Flatten a list of per-query result-lists (or Exceptions, from a
109
+ `return_exceptions=True` gather) into one SerpResults, surfacing the
110
+ last error only when every query failed.
111
+ """
 
 
 
112
  filtered_results = [r for r in results if not isinstance(r, Exception)]
113
  flattened_results = [
114
  item for sublist in filtered_results for item in sublist]
115
 
 
116
  if len(filtered_results) == 0:
117
  return SerpResults(results=[], error=str(results[-1]))
118
 
119
  return SerpResults(results=flattened_results, error=None)
120
 
121
 
122
+ async def _run_serp_queries(fn, params: SerpQuery, context: str) -> SerpResults:
123
+ """Run `fn(query, n_results)` concurrently for every query in `params`,
124
+ log any failures, and shape the result the way every simple /serp and
125
+ /ops search endpoint below expects.
126
+ """
127
+ results = await asyncio.gather(
128
+ *[fn(q, params.n_results) for q in params.queries], return_exceptions=True)
129
+ log_gathered_exceptions(results, context, params)
130
+ return _shape_serp_results(results)
131
+
132
+
133
+ @serp_router.post("/search_scholar")
134
+ async def search_google_scholar(params: SerpQuery):
135
+ """Queries google scholar for the specified query"""
136
+ logging.info(f"Searching Google Scholar for queries: {params.queries}")
137
+ return await _run_serp_queries(
138
+ lambda q, n: query_google_scholar(pw_browser, q, n), params, "google scholar search")
139
+
140
+
141
  @serp_router.post("/search_arxiv")
142
  async def search_arxiv(params: SerpQuery):
143
  """Searches arxiv for the specified queries and returns the found documents."""
144
  logging.info(f"Searching Arxiv for queries: {params.queries}")
145
+ return await _run_serp_queries(
146
+ lambda q, n: query_arxiv(httpx_client, q, n), params, "arxiv search")
 
 
 
 
 
 
 
 
 
147
 
148
 
149
  @serp_router.post("/search_patents")
 
168
  except Exception as e:
169
  logging.warning(f"OPS fallback failed: {e}")
170
 
171
+ return _shape_serp_results(results)
 
 
 
 
 
 
 
 
 
172
 
173
 
174
  @serp_router.post("/search_brave")
175
  async def search_brave(params: SerpQuery) -> SerpResults:
176
  """Searches brave search for the specified queries and returns the found documents."""
177
  logging.info(f"Searching Brave Search for queries: {params.queries}")
178
+ return await _run_serp_queries(
179
+ lambda q, n: query_brave_search(pw_browser, q, n), params, "brave search")
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  @serp_router.post("/search_bing")
183
  async def search_bing(params: SerpQuery) -> SerpResults:
184
  """Searches Bing search for the specified queries and returns the found documents."""
185
  logging.info(f"Searching Bing Search for queries: {params.queries}")
186
+ return await _run_serp_queries(
187
+ lambda q, n: query_bing_search(pw_browser, q, n), params, "bing search")
 
 
 
 
 
 
 
 
 
 
 
188
 
189
 
190
  @serp_router.post("/search_duck")
191
  async def search_duck(params: SerpQuery) -> SerpResults:
192
  """Searches duckduckgo for the specified queries and returns the found documents"""
193
  logging.info(f"Searching DuckDuckGo for queries: {params.queries}")
194
+ return await _run_serp_queries(
195
+ lambda q, n: query_ddg_search(q, n), params, "duckduckgo search")
 
 
 
 
 
 
 
 
 
 
 
196
 
197
 
198
  async def _search_one(q: str, n_results: int) -> tuple[str, list[dict], Optional[str]]:
 
291
  async def ops_keyword_search(params: SerpQuery) -> SerpResults:
292
  """Keyword-searches patents via the official EPO OPS API."""
293
  logging.info(f"Searching EPO OPS for queries: {params.queries}")
294
+ return await _run_serp_queries(
295
+ lambda q, n: ops_search(httpx_client, q, n), params, "OPS search")
 
 
 
 
 
 
 
 
 
296
 
297
 
298
  @ops_router.get("/scrap_patent/{patent_id}")
tests/test_app.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Characterization tests for app.py's search endpoints.
2
+
3
+ These monkeypatch the underlying query_*/ops_search functions rather than
4
+ hitting real search engines - app.py's own job here is just wiring
5
+ (gather queries concurrently, log failures, flatten results, decide when to
6
+ report an error), which is exactly what's being tested. The app's lifespan
7
+ (which starts a real Playwright browser) is never triggered: ASGITransport
8
+ doesn't run it, and these tests don't need it since pw_browser is never
9
+ actually used once the query functions are patched out.
10
+ """
11
+
12
+ import httpx
13
+ import pytest
14
+ from httpx import ASGITransport
15
+
16
+ import app as app_module
17
+ from serp import SerpQuery
18
+
19
+
20
+ @pytest.fixture
21
+ async def client():
22
+ transport = ASGITransport(app=app_module.app)
23
+ async with httpx.AsyncClient(transport=transport, base_url="http://test") as c:
24
+ yield c
25
+
26
+
27
+ # ------------------------- _shape_serp_results / _run_serp_queries -------------------------
28
+
29
+
30
+ def test_shape_serp_results_flattens_successful_lists():
31
+ result = app_module._shape_serp_results([[{"a": 1}], [{"b": 2}, {"c": 3}]])
32
+ assert result.results == [{"a": 1}, {"b": 2}, {"c": 3}]
33
+ assert result.error is None
34
+
35
+
36
+ def test_shape_serp_results_surfaces_last_error_when_all_failed():
37
+ result = app_module._shape_serp_results([ValueError("first"), RuntimeError("second")])
38
+ assert result.results == []
39
+ assert "second" in result.error
40
+
41
+
42
+ def test_shape_serp_results_ignores_failures_when_some_succeed():
43
+ result = app_module._shape_serp_results([[{"a": 1}], RuntimeError("boom")])
44
+ assert result.results == [{"a": 1}]
45
+ assert result.error is None
46
+
47
+
48
+ async def test_run_serp_queries_calls_fn_per_query_and_shapes_result():
49
+ params = SerpQuery(queries=["x", "y"], n_results=5)
50
+ calls = []
51
+
52
+ async def fn(q, n):
53
+ calls.append((q, n))
54
+ return [{"q": q}]
55
+
56
+ result = await app_module._run_serp_queries(fn, params, "test context")
57
+
58
+ assert sorted(calls) == [("x", 5), ("y", 5)]
59
+ assert sorted(result.results, key=str) == sorted([{"q": "x"}, {"q": "y"}], key=str)
60
+
61
+
62
+ # ------------------------------------- endpoints -------------------------------------
63
+
64
+
65
+ async def test_search_arxiv_flattens_results_from_all_queries(client, monkeypatch):
66
+ async def fake_query_arxiv(client_arg, q, n):
67
+ return [{"title": f"paper about {q}", "href": "x", "body": "y", "id": "1"}]
68
+
69
+ monkeypatch.setattr(app_module, "query_arxiv", fake_query_arxiv)
70
+
71
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a", "b"], "n_results": 5})
72
+
73
+ assert resp.status_code == 200
74
+ data = resp.json()
75
+ assert data["error"] is None
76
+ assert {r["title"] for r in data["results"]} == {"paper about a", "paper about b"}
77
+
78
+
79
+ async def test_search_arxiv_all_queries_failing_returns_error(client, monkeypatch):
80
+ async def failing_query_arxiv(client_arg, q, n):
81
+ raise RuntimeError(f"boom for {q}")
82
+
83
+ monkeypatch.setattr(app_module, "query_arxiv", failing_query_arxiv)
84
+
85
+ resp = await client.post("/serp/search_arxiv", json={"queries": ["a"], "n_results": 5})
86
+
87
+ assert resp.status_code == 200
88
+ data = resp.json()
89
+ assert data["results"] == []
90
+ assert "boom for a" in data["error"]
91
+
92
+
93
+ async def test_search_patents_does_not_fall_back_when_google_patents_succeeds(client, monkeypatch):
94
+ async def fake_google_patents(browser, q, n):
95
+ return [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
96
+
97
+ ops_search_called = False
98
+
99
+ async def fake_ops_search(client_arg, q, n):
100
+ nonlocal ops_search_called
101
+ ops_search_called = True
102
+ return []
103
+
104
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
105
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
106
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
107
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
108
+
109
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
110
+
111
+ data = resp.json()
112
+ assert data["results"] == [{"id": "US1", "href": "h", "title": "t", "body": "b"}]
113
+ assert ops_search_called is False
114
+
115
+
116
+ async def test_search_patents_falls_back_to_ops_when_google_patents_empty(client, monkeypatch):
117
+ async def fake_google_patents(browser, q, n):
118
+ return []
119
+
120
+ async def fake_ops_search(client_arg, q, n):
121
+ return [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
122
+
123
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
124
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
125
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", "k")
126
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", "s")
127
+
128
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
129
+
130
+ data = resp.json()
131
+ assert data["results"] == [{"title": "OPS result", "body": "", "href": "", "id": "US123"}]
132
+
133
+
134
+ async def test_search_patents_no_fallback_when_ops_not_configured(client, monkeypatch):
135
+ async def fake_google_patents(browser, q, n):
136
+ return []
137
+
138
+ monkeypatch.setattr(app_module, "query_google_patents", fake_google_patents)
139
+ monkeypatch.setattr(app_module.ops_token_manager, "_key", None)
140
+ monkeypatch.setattr(app_module.ops_token_manager, "_secret", None)
141
+
142
+ resp = await client.post("/serp/search_patents", json={"queries": ["widget"], "n_results": 5})
143
+
144
+ data = resp.json()
145
+ assert data["results"] == []
146
+ assert data["error"] is None
147
+
148
+
149
+ async def test_ops_keyword_search_flattens_results(client, monkeypatch):
150
+ async def fake_ops_search(client_arg, q, n):
151
+ return [{"title": f"OPS {q}", "body": "", "href": "", "id": "1"}]
152
+
153
+ monkeypatch.setattr(app_module, "ops_search", fake_ops_search)
154
+
155
+ resp = await client.post("/ops/search", json={"queries": ["a", "b"], "n_results": 5})
156
+
157
+ data = resp.json()
158
+ assert data["error"] is None
159
+ assert {r["title"] for r in data["results"]} == {"OPS a", "OPS b"}