Daniel Wiesmann commited on
Commit
34bbe35
·
1 Parent(s): d55fc29

Unify /search and /search/fuzzy under a mode param; add OpenAPI response models

Browse files

GET /search and /search/stream now take mode=nl (default, unchanged
LLM pipeline) or mode=fuzzy (the direct name-match logic previously
only reachable via GET /search/fuzzy). /search/fuzzy is kept as a
deprecated thin alias for backward compatibility - Streamlit is
unaffected since it never sets mode and defaults to nl.

Also replaces response_model=None / bare dict[str, Any] returns with
real Pydantic models (FeatureCollection, NLSearchResult, FuzzyIdsResult,
HealthStatus, SourceInfo) and per-parameter Query() descriptions, so
the generated OpenAPI schema at /openapi.json is actually useful for
agents calling this API without reading the source.

Files changed (3) hide show
  1. src/gazet/api.py +229 -59
  2. src/gazet/schemas.py +65 -0
  3. tests/test_api.py +51 -0
src/gazet/api.py CHANGED
@@ -3,22 +3,84 @@ import logging
3
  import uuid
4
  from collections.abc import AsyncIterator, Awaitable, Callable, Generator
5
  from contextlib import asynccontextmanager
6
- from typing import Any
7
 
8
  import duckdb
9
  import pandas as pd
10
- from fastapi import FastAPI, HTTPException, Request
11
  from fastapi.responses import Response, StreamingResponse
12
 
13
  from .export import to_feature_collection
14
  from .geometry import normalize_geometry_to_geojson
15
  from .lm import extract, generate_places
16
- from .schemas import Place
 
 
 
 
 
 
 
 
 
17
  from .search import get_by_id, search_candidates
18
  from .sql import run_geo_sql_dspy, run_geo_sql_gguf
19
 
20
  _FUZZY_SOURCES = ("divisions_area", "natural_earth")
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  @asynccontextmanager
24
  async def lifespan(app: FastAPI) -> AsyncIterator[None]:
@@ -33,7 +95,17 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
33
  con.close()
34
 
35
 
36
- app = FastAPI(lifespan=lifespan)
 
 
 
 
 
 
 
 
 
 
37
 
38
  logger = logging.getLogger(__name__)
39
 
@@ -181,7 +253,7 @@ def _run_stream(
181
  con.close()
182
 
183
 
184
- @app.get("/health")
185
  def health(request: Request) -> dict[str, Any]:
186
  """Health check — DuckDB connection alive + llama-server status."""
187
  con = request.app.state.duckdb_con
@@ -210,7 +282,7 @@ def health(request: Request) -> dict[str, Any]:
210
  }
211
 
212
 
213
- @app.get("/sources")
214
  def sources(request: Request) -> dict[str, Any]:
215
  """List available data sources with row counts and name ranges."""
216
  con = request.app.state.duckdb_con
@@ -236,28 +308,15 @@ def sources(request: Request) -> dict[str, Any]:
236
  return info
237
 
238
 
239
- @app.get("/search/stream")
240
- def search_stream(request: Request, q: str, backend: str = "gguf") -> StreamingResponse:
241
- """Stream search progress as NDJSON (one JSON object per line)."""
242
- con = request.app.state.duckdb_con
243
- return StreamingResponse(
244
- _run_stream(con, q, backend), media_type="application/x-ndjson"
245
- )
246
-
247
-
248
- @app.get("/search", response_model=None)
249
- def search(request: Request, q: str, backend: str = "gguf") -> dict[str, Any]:
250
- """Run geo search for natural-language query (non-streaming).
251
-
252
- Returns GeoJSON FeatureCollection, the executed SQL, and the identified
253
- dataframes (candidates) as JSON-serializable records.
254
- """
255
  places: dict = {}
256
  candidates: list = []
257
  sql = ""
258
  geojson: dict | None = None
259
 
260
- con = request.app.state.duckdb_con
261
  for line in _run_stream(con, q, backend):
262
  if not line.strip():
263
  continue
@@ -275,40 +334,34 @@ def search(request: Request, q: str, backend: str = "gguf") -> dict[str, Any]:
275
  if geojson is None:
276
  raise HTTPException(status_code=404, detail="No result")
277
 
278
- return {
279
- "geojson": geojson,
280
- "sql": sql,
281
- "places": places,
282
- "dataframes": {"candidates": candidates},
283
- }
284
 
285
 
286
- @app.get("/search/fuzzy", response_model=None)
287
- def search_fuzzy(
288
- request: Request,
289
  q: str,
290
  limit: int = 5,
291
  simplify: bool = True,
292
  sources: str | None = None,
293
  ids_only: bool = False,
294
- ) -> dict[str, Any]:
295
  """Pure fuzzy-name search with geometry, no LLM involved.
296
 
297
- ``q`` is a place-name string (not a natural-language query) — this
298
- endpoint has no place-extraction step. Matches are ranked by
299
- Jaro-Winkler similarity across the requested ``sources`` (comma-separated
300
- subset of divisions_area/natural_earth; defaults to both), combined and
301
- truncated to the top ``limit``. Returns a GeoJSON FeatureCollection.
302
 
303
  Pass ``ids_only=true`` to skip fetching full geometry and get back
304
- ``{"ids": [{"source", "id", "name", "country", "subtype", "admin_level", "bbox"}, ...]}``
305
- instead — ``country``/``subtype``/``admin_level`` disambiguate same-named
306
- places (e.g. multiple real-world "Loja"s across Ecuador and Spain, or
307
- Ecuador's "Loja" region vs. its nested "Loja" county — same ``subtype``
308
- can occur at different ``admin_level``s, and locality-type subtypes have
309
- no ``admin_level`` at all). ``bbox`` is ``[minx, miny, maxx, maxy]``
310
- computed via ST_XMin/YMin/XMax/YMax, a much smaller payload than full
311
- geometry, giving minimal spatial context before fetching the full
312
  geometry for one candidate via ``GET /geometry/{id}``.
313
  """
314
  requested_sources = (
@@ -320,14 +373,14 @@ def search_fuzzy(
320
  status_code=400, detail=f"Unknown source(s): {sorted(invalid)}"
321
  )
322
 
323
- con = request.app.state.duckdb_con.cursor()
324
 
325
  try:
326
  # Fetch a pool larger than `limit` per source so the combined,
327
  # similarity-ranked top-`limit` isn't skewed by per-source cutoffs.
328
  per_source_limit = max(limit * 3, 15)
329
  candidate_dfs = search_candidates(
330
- con,
331
  Place(place=q),
332
  limit=per_source_limit,
333
  include_geometry=not ids_only,
@@ -335,7 +388,7 @@ def search_fuzzy(
335
  sources=requested_sources,
336
  )
337
  if not candidate_dfs:
338
- return to_feature_collection(pd.DataFrame())
339
 
340
  candidates_df = (
341
  pd.concat(candidate_dfs, ignore_index=True)
@@ -352,25 +405,142 @@ def search_fuzzy(
352
  ids_df["bbox"] = candidates_df["bbox"].apply(
353
  lambda arr: [float(x) for x in arr] if arr is not None else None
354
  )
355
- return {
356
- "geojson": {"type": "FeatureCollection", "features": []},
357
- "ids": ids_df.to_dict(orient="records"),
358
- }
359
 
360
  if simplify:
361
- candidates_df = normalize_geometry_to_geojson(con, candidates_df)
362
 
363
- return to_feature_collection(candidates_df)
364
  finally:
365
- con.close()
366
 
367
 
368
- @app.get("/geometry/{id}", response_model=None)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
  def get_geometry(
370
  request: Request,
371
  id: str,
372
- source: str | None = None,
373
- simplify: bool = True,
 
 
 
374
  ) -> dict[str, Any]:
375
  """Fetch a single feature's geometry directly by ID — no fuzzy matching, no LLM.
376
 
 
3
  import uuid
4
  from collections.abc import AsyncIterator, Awaitable, Callable, Generator
5
  from contextlib import asynccontextmanager
6
+ from typing import Any, Literal
7
 
8
  import duckdb
9
  import pandas as pd
10
+ from fastapi import FastAPI, HTTPException, Query, Request
11
  from fastapi.responses import Response, StreamingResponse
12
 
13
  from .export import to_feature_collection
14
  from .geometry import normalize_geometry_to_geojson
15
  from .lm import extract, generate_places
16
+ from .schemas import (
17
+ Feature,
18
+ FeatureCollection,
19
+ FuzzyIdItem,
20
+ FuzzyIdsResult,
21
+ HealthStatus,
22
+ NLSearchResult,
23
+ Place,
24
+ SourceInfo,
25
+ )
26
  from .search import get_by_id, search_candidates
27
  from .sql import run_geo_sql_dspy, run_geo_sql_gguf
28
 
29
  _FUZZY_SOURCES = ("divisions_area", "natural_earth")
30
 
31
+ SearchMode = Literal["nl", "fuzzy"]
32
+
33
+ _Q_QUERY = Query(
34
+ description=(
35
+ "Search query: a natural-language sentence for mode=nl, "
36
+ "a bare place name for mode=fuzzy."
37
+ )
38
+ )
39
+ _Q_MODE = Query(
40
+ "nl",
41
+ description=(
42
+ "'nl': LLM place-extraction + SQL synthesis over the query. "
43
+ "'fuzzy': direct Jaro-Winkler name match, no LLM."
44
+ ),
45
+ )
46
+ _Q_BACKEND = Query(
47
+ "gguf",
48
+ description="LLM backend for mode=nl ('gguf' or 'dspy'); ignored for mode=fuzzy.",
49
+ )
50
+ _Q_LIMIT = Query(5, description="Max results for mode=fuzzy; ignored for mode=nl.")
51
+ _Q_SIMPLIFY = Query(
52
+ True,
53
+ description="Simplify geometry to GeoJSON for mode=fuzzy; ignored for mode=nl.",
54
+ )
55
+ _Q_SOURCES = Query(
56
+ None,
57
+ description=(
58
+ "Comma-separated subset of divisions_area,natural_earth for mode=fuzzy "
59
+ "(defaults to both); ignored for mode=nl."
60
+ ),
61
+ )
62
+ _Q_IDS_ONLY = Query(
63
+ False,
64
+ description=(
65
+ "For mode=fuzzy: return lightweight id/bbox records instead of full "
66
+ "geometry; ignored for mode=nl."
67
+ ),
68
+ )
69
+
70
+ _TAGS_METADATA = [
71
+ {
72
+ "name": "search",
73
+ "description": (
74
+ "`mode=nl` (default) runs the full natural-language pipeline: "
75
+ "LLM place-extraction, fuzzy candidate matching, then LLM-generated "
76
+ "SQL. `mode=fuzzy` skips the LLM entirely and does a direct "
77
+ "Jaro-Winkler name match — `q` is a place name, not a sentence."
78
+ ),
79
+ },
80
+ {"name": "geometry", "description": "Direct, non-fuzzy geometry lookups by ID."},
81
+ {"name": "meta", "description": "Health and dataset introspection."},
82
+ ]
83
+
84
 
85
  @asynccontextmanager
86
  async def lifespan(app: FastAPI) -> AsyncIterator[None]:
 
95
  con.close()
96
 
97
 
98
+ app = FastAPI(
99
+ title="Gazet API",
100
+ description=(
101
+ "Lean natural-language geocoder with GIS operations over Overture "
102
+ "and Natural Earth parquet datasets. See `GET /search` for the main "
103
+ "entrypoint (natural-language or fuzzy-name modes)."
104
+ ),
105
+ version="0.1.0",
106
+ lifespan=lifespan,
107
+ openapi_tags=_TAGS_METADATA,
108
+ )
109
 
110
  logger = logging.getLogger(__name__)
111
 
 
253
  con.close()
254
 
255
 
256
+ @app.get("/health", response_model=HealthStatus, tags=["meta"])
257
  def health(request: Request) -> dict[str, Any]:
258
  """Health check — DuckDB connection alive + llama-server status."""
259
  con = request.app.state.duckdb_con
 
282
  }
283
 
284
 
285
+ @app.get("/sources", response_model=dict[str, SourceInfo], tags=["meta"])
286
  def sources(request: Request) -> dict[str, Any]:
287
  """List available data sources with row counts and name ranges."""
288
  con = request.app.state.duckdb_con
 
308
  return info
309
 
310
 
311
+ def _nl_search(
312
+ con: duckdb.DuckDBPyConnection, q: str, backend: str = "gguf"
313
+ ) -> NLSearchResult:
314
+ """Run the natural-language pipeline (LLM extraction + SQL) to completion."""
 
 
 
 
 
 
 
 
 
 
 
 
315
  places: dict = {}
316
  candidates: list = []
317
  sql = ""
318
  geojson: dict | None = None
319
 
 
320
  for line in _run_stream(con, q, backend):
321
  if not line.strip():
322
  continue
 
334
  if geojson is None:
335
  raise HTTPException(status_code=404, detail="No result")
336
 
337
+ return NLSearchResult(
338
+ geojson=FeatureCollection(**geojson),
339
+ sql=sql,
340
+ places=places,
341
+ dataframes={"candidates": candidates},
342
+ )
343
 
344
 
345
+ def _fuzzy_search(
346
+ con: duckdb.DuckDBPyConnection,
 
347
  q: str,
348
  limit: int = 5,
349
  simplify: bool = True,
350
  sources: str | None = None,
351
  ids_only: bool = False,
352
+ ) -> FeatureCollection | FuzzyIdsResult:
353
  """Pure fuzzy-name search with geometry, no LLM involved.
354
 
355
+ ``q`` is a place-name string (not a natural-language query) — no
356
+ place-extraction step. Matches are ranked by Jaro-Winkler similarity
357
+ across the requested ``sources`` (comma-separated subset of
358
+ divisions_area/natural_earth; defaults to both), combined and truncated
359
+ to the top ``limit``.
360
 
361
  Pass ``ids_only=true`` to skip fetching full geometry and get back
362
+ lightweight candidates (id/name/country/subtype/admin_level/bbox)
363
+ instead — enough to disambiguate same-named places (e.g. multiple
364
+ real-world "Loja"s across Ecuador and Spain) before fetching the full
 
 
 
 
 
365
  geometry for one candidate via ``GET /geometry/{id}``.
366
  """
367
  requested_sources = (
 
373
  status_code=400, detail=f"Unknown source(s): {sorted(invalid)}"
374
  )
375
 
376
+ cur = con.cursor()
377
 
378
  try:
379
  # Fetch a pool larger than `limit` per source so the combined,
380
  # similarity-ranked top-`limit` isn't skewed by per-source cutoffs.
381
  per_source_limit = max(limit * 3, 15)
382
  candidate_dfs = search_candidates(
383
+ cur,
384
  Place(place=q),
385
  limit=per_source_limit,
386
  include_geometry=not ids_only,
 
388
  sources=requested_sources,
389
  )
390
  if not candidate_dfs:
391
+ return FeatureCollection()
392
 
393
  candidates_df = (
394
  pd.concat(candidate_dfs, ignore_index=True)
 
405
  ids_df["bbox"] = candidates_df["bbox"].apply(
406
  lambda arr: [float(x) for x in arr] if arr is not None else None
407
  )
408
+ return FuzzyIdsResult(
409
+ geojson=FeatureCollection(),
410
+ ids=[FuzzyIdItem(**r) for r in _df_to_records(ids_df)],
411
+ )
412
 
413
  if simplify:
414
+ candidates_df = normalize_geometry_to_geojson(cur, candidates_df)
415
 
416
+ return FeatureCollection(**to_feature_collection(candidates_df))
417
  finally:
418
+ cur.close()
419
 
420
 
421
+ def _run_fuzzy_stream(
422
+ con: duckdb.DuckDBPyConnection,
423
+ q: str,
424
+ limit: int,
425
+ simplify: bool,
426
+ sources: str | None,
427
+ ids_only: bool,
428
+ ) -> Generator[str]:
429
+ """Wrap ``_fuzzy_search`` in the same NDJSON event contract as ``_run_stream``,
430
+ for streaming clients that don't want to branch on ``mode``."""
431
+ try:
432
+ result = _fuzzy_search(
433
+ con, q, limit=limit, simplify=simplify, sources=sources, ids_only=ids_only
434
+ )
435
+ except HTTPException as e:
436
+ yield json.dumps({"type": "error", "data": e.detail}) + "\n"
437
+ return
438
+
439
+ event_type = "ids" if isinstance(result, FuzzyIdsResult) else "geojson"
440
+ yield json.dumps({"type": event_type, "data": result.model_dump()}) + "\n"
441
+
442
+
443
+ @app.get("/search/stream", tags=["search"])
444
+ def search_stream(
445
+ request: Request,
446
+ q: str = _Q_QUERY,
447
+ mode: SearchMode = _Q_MODE,
448
+ backend: str = _Q_BACKEND,
449
+ limit: int = _Q_LIMIT,
450
+ simplify: bool = _Q_SIMPLIFY,
451
+ sources: str | None = _Q_SOURCES,
452
+ ids_only: bool = _Q_IDS_ONLY,
453
+ ) -> StreamingResponse:
454
+ """Stream search progress as NDJSON (one JSON object per line).
455
+
456
+ ``mode=nl`` (default) streams each pipeline stage (``places``,
457
+ ``candidates``, ``sql_attempt``, ``geojson``, ...) using ``backend``.
458
+ ``mode=fuzzy`` has no pipeline stages — it emits a single ``geojson`` or
459
+ ``ids`` event using ``limit``/``simplify``/``sources``/``ids_only``.
460
+ """
461
+ con = request.app.state.duckdb_con
462
+ generator = (
463
+ _run_fuzzy_stream(con, q, limit, simplify, sources, ids_only)
464
+ if mode == "fuzzy"
465
+ else _run_stream(con, q, backend)
466
+ )
467
+ return StreamingResponse(generator, media_type="application/x-ndjson")
468
+
469
+
470
+ @app.get(
471
+ "/search",
472
+ response_model=NLSearchResult | FeatureCollection | FuzzyIdsResult,
473
+ tags=["search"],
474
+ )
475
+ def search(
476
+ request: Request,
477
+ q: str = _Q_QUERY,
478
+ mode: SearchMode = _Q_MODE,
479
+ backend: str = _Q_BACKEND,
480
+ limit: int = _Q_LIMIT,
481
+ simplify: bool = _Q_SIMPLIFY,
482
+ sources: str | None = _Q_SOURCES,
483
+ ids_only: bool = _Q_IDS_ONLY,
484
+ ) -> NLSearchResult | FeatureCollection | FuzzyIdsResult:
485
+ """Run a search, non-streaming.
486
+
487
+ ``mode=nl`` (default): natural-language query → LLM place-extraction →
488
+ fuzzy candidate matching → LLM-generated SQL. Uses ``backend``. Returns
489
+ ``{geojson, sql, places, dataframes}``.
490
+
491
+ ``mode=fuzzy``: direct place-name fuzzy match, no LLM — see
492
+ ``GET /search/fuzzy`` (this is the same logic, callable without the
493
+ separate path). Uses ``limit``/``simplify``/``sources``/``ids_only``.
494
+ """
495
+ con = request.app.state.duckdb_con
496
+ if mode == "fuzzy":
497
+ return _fuzzy_search(
498
+ con, q, limit=limit, simplify=simplify, sources=sources, ids_only=ids_only
499
+ )
500
+ return _nl_search(con, q, backend=backend)
501
+
502
+
503
+ @app.get(
504
+ "/search/fuzzy",
505
+ response_model=FeatureCollection | FuzzyIdsResult,
506
+ deprecated=True,
507
+ tags=["search"],
508
+ )
509
+ def search_fuzzy(
510
+ request: Request,
511
+ q: str = Query(description="Bare place name to fuzzy-match, e.g. 'Lima'."),
512
+ limit: int = Query(5, description="Max results to return."),
513
+ simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
514
+ sources: str | None = Query(
515
+ None,
516
+ description="Comma-separated subset of divisions_area,natural_earth (defaults to both).",
517
+ ),
518
+ ids_only: bool = Query(
519
+ False,
520
+ description="Return lightweight id/bbox records instead of full geometry.",
521
+ ),
522
+ ) -> FeatureCollection | FuzzyIdsResult:
523
+ """Deprecated — use ``GET /search?mode=fuzzy`` instead. Kept for backward
524
+ compatibility; identical behavior."""
525
+ return _fuzzy_search(
526
+ request.app.state.duckdb_con,
527
+ q,
528
+ limit=limit,
529
+ simplify=simplify,
530
+ sources=sources,
531
+ ids_only=ids_only,
532
+ )
533
+
534
+
535
+ @app.get("/geometry/{id}", response_model=Feature, tags=["geometry"])
536
  def get_geometry(
537
  request: Request,
538
  id: str,
539
+ source: str | None = Query(
540
+ None,
541
+ description="Restrict lookup to 'divisions_area' or 'natural_earth'; inferred from id if omitted.",
542
+ ),
543
+ simplify: bool = Query(True, description="Simplify geometry to GeoJSON."),
544
  ) -> dict[str, Any]:
545
  """Fetch a single feature's geometry directly by ID — no fuzzy matching, no LLM.
546
 
src/gazet/schemas.py CHANGED
@@ -1,3 +1,5 @@
 
 
1
  from pydantic import BaseModel, Field
2
 
3
 
@@ -15,3 +17,66 @@ class Place(BaseModel):
15
 
16
  class PlacesResult(BaseModel):
17
  places: list[Place]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Literal
2
+
3
  from pydantic import BaseModel, Field
4
 
5
 
 
17
 
18
  class PlacesResult(BaseModel):
19
  places: list[Place]
20
+
21
+
22
+ class Feature(BaseModel):
23
+ type: Literal["Feature"] = "Feature"
24
+ geometry: dict[str, Any] | None = Field(
25
+ default=None, description="GeoJSON geometry object"
26
+ )
27
+ properties: dict[str, Any] = Field(default_factory=dict)
28
+
29
+
30
+ class FeatureCollection(BaseModel):
31
+ type: Literal["FeatureCollection"] = "FeatureCollection"
32
+ features: list[Feature] = Field(default_factory=list)
33
+
34
+
35
+ class FuzzyIdItem(BaseModel):
36
+ """One candidate from a ``mode=fuzzy&ids_only=true`` search.
37
+
38
+ ``country``/``subtype``/``admin_level`` disambiguate same-named places
39
+ (e.g. multiple real-world "Loja"s across Ecuador and Spain, or Ecuador's
40
+ "Loja" region vs. its nested "Loja" county). ``bbox`` is
41
+ ``[minx, miny, maxx, maxy]``, a much smaller payload than full geometry —
42
+ fetch the full geometry for one candidate via ``GET /geometry/{id}``.
43
+ """
44
+
45
+ source: str
46
+ id: str
47
+ name: str | None = None
48
+ country: str | None = None
49
+ subtype: str | None = None
50
+ admin_level: int | None = None
51
+ bbox: list[float] | None = None
52
+
53
+
54
+ class FuzzyIdsResult(BaseModel):
55
+ geojson: FeatureCollection
56
+ ids: list[FuzzyIdItem]
57
+
58
+
59
+ class NLSearchResult(BaseModel):
60
+ """Result of a ``mode=nl`` (natural-language) search."""
61
+
62
+ geojson: FeatureCollection
63
+ sql: str = Field(description="The SQL query executed to produce the result")
64
+ places: dict[str, Any] = Field(
65
+ description="Place names extracted from the query by the LLM"
66
+ )
67
+ dataframes: dict[str, Any] = Field(
68
+ description="Intermediate dataframes (e.g. fuzzy-matched candidates)"
69
+ )
70
+
71
+
72
+ class HealthStatus(BaseModel):
73
+ status: Literal["ok", "degraded", "unhealthy"]
74
+ duckdb: Literal["ok", "error"]
75
+ llama_server: Literal["ok", "unavailable"]
76
+
77
+
78
+ class SourceInfo(BaseModel):
79
+ path: str
80
+ row_count: int | None = None
81
+ name_range: list[str | None] | None = None
82
+ error: str | None = None
tests/test_api.py CHANGED
@@ -1,5 +1,7 @@
1
  """Tests for gazet.api — FastAPI endpoints and helpers."""
2
 
 
 
3
  import pandas as pd
4
  import pytest
5
  from fastapi.testclient import TestClient
@@ -147,6 +149,55 @@ class TestSearchFuzzy:
147
  pass
148
 
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  class TestGeometryById:
151
  def test_get_geometry_by_id(self, client):
152
  # Get a valid ID first
 
1
  """Tests for gazet.api — FastAPI endpoints and helpers."""
2
 
3
+ import json
4
+
5
  import pandas as pd
6
  import pytest
7
  from fastapi.testclient import TestClient
 
149
  pass
150
 
151
 
152
+ class TestSearchUnifiedMode:
153
+ """GET /search?mode=fuzzy should behave identically to the deprecated
154
+ GET /search/fuzzy, since the latter is now a thin wrapper around the same
155
+ helper."""
156
+
157
+ def test_mode_fuzzy_matches_legacy_endpoint(self, client):
158
+ try:
159
+ unified = client.get("/search", params={"q": "India", "mode": "fuzzy"})
160
+ legacy = client.get("/search/fuzzy", params={"q": "India"})
161
+ assert unified.status_code == legacy.status_code == 200
162
+ assert unified.json() == legacy.json()
163
+ except ValueError:
164
+ pytest.skip("Known limitation: nan in JSON encoding")
165
+
166
+ def test_mode_fuzzy_ids_only(self, client):
167
+ resp = client.get(
168
+ "/search", params={"q": "India", "mode": "fuzzy", "ids_only": "true"}
169
+ )
170
+ assert resp.status_code == 200
171
+ data = resp.json()
172
+ assert "ids" in data
173
+ for item in data["ids"]:
174
+ assert "id" in item
175
+ assert "source" in item
176
+
177
+ def test_mode_fuzzy_invalid_source(self, client):
178
+ resp = client.get(
179
+ "/search", params={"q": "India", "mode": "fuzzy", "sources": "invalid"}
180
+ )
181
+ assert resp.status_code == 400
182
+
183
+ def test_mode_defaults_to_nl(self, client):
184
+ # Omitting `mode` should attempt the LLM pipeline, not silently
185
+ # behave like mode=fuzzy. Skip if no llama-server is reachable.
186
+ try:
187
+ resp = client.get("/search", params={"q": "India"})
188
+ assert resp.status_code in (200, 404)
189
+ except Exception:
190
+ pytest.skip("llama-server not available for nl-mode test")
191
+
192
+ def test_stream_mode_fuzzy_emits_single_event(self, client):
193
+ resp = client.get("/search/stream", params={"q": "India", "mode": "fuzzy"})
194
+ assert resp.status_code == 200
195
+ lines = [line for line in resp.text.splitlines() if line.strip()]
196
+ assert len(lines) == 1
197
+ event = json.loads(lines[0])
198
+ assert event["type"] in ("geojson", "ids", "error")
199
+
200
+
201
  class TestGeometryById:
202
  def test_get_geometry_by_id(self, client):
203
  # Get a valid ID first