validops-east-1 commited on
Commit
461373d
·
1 Parent(s): 697be7d

feat: added json extractor

Browse files
Files changed (3) hide show
  1. api/server.py +100 -0
  2. extraction/keys_extractor.py +71 -0
  3. requirements.txt +3 -0
api/server.py CHANGED
@@ -27,6 +27,9 @@ System:
27
  GET /docs Swagger UI
28
  GET /redoc ReDoc UI
29
  GET /metrics Prometheus metrics
 
 
 
30
  """
31
 
32
  from __future__ import annotations
@@ -54,6 +57,7 @@ from pydantic import BaseModel, Field, field_validator
54
 
55
  from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
56
  from extraction.generic_json_extractor import extract
 
57
  from logger import get_logger
58
  from app.api.routes import router as pdf_router
59
  from app.core.auth import require_api_key
@@ -521,12 +525,108 @@ async def batch_urls(body: BatchUrlRequest):
521
  return BatchResponse(total=len(results), succeeded=succeeded, failed=len(results) - succeeded, total_time_ms=total_ms, results=results)
522
 
523
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  # ---------------------------------------------------------------------------
525
  # Include all routers
526
  # ---------------------------------------------------------------------------
527
 
528
  app.include_router(markitdown_router, prefix="/api/v1/markitdown")
529
  app.include_router(pdf_router, prefix="/api/v1")
 
530
 
531
  # -- Mount Prometheus metrics --
532
  app.mount("/metrics", make_asgi_app())
 
27
  GET /docs Swagger UI
28
  GET /redoc ReDoc UI
29
  GET /metrics Prometheus metrics
30
+
31
+ Nested-JSON key extractor (under /api/v1/keys):
32
+ POST /api/v1/keys/extract
33
  """
34
 
35
  from __future__ import annotations
 
57
 
58
  from core import ConversionError, ConversionResult, DocumentConverter, SUPPORTED_EXTENSIONS
59
  from extraction.generic_json_extractor import extract
60
+ from extraction.keys_extractor import Extractor as KeysExtractor
61
  from logger import get_logger
62
  from app.api.routes import router as pdf_router
63
  from app.core.auth import require_api_key
 
525
  return BatchResponse(total=len(results), succeeded=succeeded, failed=len(results) - succeeded, total_time_ms=total_ms, results=results)
526
 
527
 
528
+ # ---------------------------------------------------------------------------
529
+ # Keys extractor router — nested-JSON key/value lookup
530
+ # ---------------------------------------------------------------------------
531
+
532
+ keys_router = APIRouter(dependencies=[Depends(require_api_key)])
533
+
534
+
535
+ class KeysExtractRequest(BaseModel):
536
+ data: Any = Field(..., description="JSON object or array to search")
537
+ key_names: List[str] = Field(..., min_length=1, description="Key names to look up at any depth")
538
+ result_limit: Optional[int] = Field(
539
+ None,
540
+ ge=1,
541
+ description="Maximum values to return per key. Omit (or pass null) to return the full, uncapped result.",
542
+ )
543
+
544
+ @field_validator("key_names")
545
+ @classmethod
546
+ def _validate_key_names(cls, v: List[str]) -> List[str]:
547
+ for kn in v:
548
+ if not isinstance(kn, str) or not kn:
549
+ raise ValueError("each key_name must be a non-empty string")
550
+ return v
551
+
552
+
553
+ class KeysExtractResponse(BaseModel):
554
+ success: bool
555
+ time_ms: float
556
+ data: Dict[str, List[Any]]
557
+ error_message: Optional[str] = None
558
+
559
+
560
+ @keys_router.post(
561
+ "/extract",
562
+ response_model=KeysExtractResponse,
563
+ tags=["Keys Extractor"],
564
+ summary="Extract all values for given key names from a nested JSON object",
565
+ )
566
+ async def extract_keys(body: KeysExtractRequest):
567
+ """Recursively walk any JSON object/array and return every value attached
568
+ to the supplied key names, regardless of how deeply nested they are."""
569
+ start = time.perf_counter()
570
+ logger.info(
571
+ "keys_extract | start",
572
+ key_count=len(body.key_names),
573
+ limit=body.result_limit,
574
+ )
575
+
576
+ if not isinstance(body.data, (dict, list)):
577
+ return KeysExtractResponse(
578
+ success=False,
579
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
580
+ data={},
581
+ error_message="`data` must be a JSON object or array",
582
+ )
583
+
584
+ try:
585
+ loop = asyncio.get_running_loop()
586
+ results = await loop.run_in_executor(
587
+ _thread_pool,
588
+ KeysExtractor(body.data, body.key_names, body.result_limit).extract,
589
+ )
590
+ except (TypeError, ValueError) as exc:
591
+ logger.warning("keys_extract | invalid input", error=str(exc))
592
+ return KeysExtractResponse(
593
+ success=False,
594
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
595
+ data={},
596
+ error_message=str(exc),
597
+ )
598
+ except Exception as exc:
599
+ logger.exception("keys_extract | failed")
600
+ return KeysExtractResponse(
601
+ success=False,
602
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
603
+ data={},
604
+ error_message=f"extraction failed: {exc}",
605
+ )
606
+
607
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
608
+ total_values = sum(len(v) for v in results.values())
609
+ logger.info(
610
+ "keys_extract | done",
611
+ keys=len(body.key_names),
612
+ total_values=total_values,
613
+ time_ms=elapsed,
614
+ )
615
+ return KeysExtractResponse(
616
+ success=True,
617
+ time_ms=elapsed,
618
+ data=results,
619
+ error_message=None,
620
+ )
621
+
622
+
623
  # ---------------------------------------------------------------------------
624
  # Include all routers
625
  # ---------------------------------------------------------------------------
626
 
627
  app.include_router(markitdown_router, prefix="/api/v1/markitdown")
628
  app.include_router(pdf_router, prefix="/api/v1")
629
+ app.include_router(keys_router, prefix="/api/v1/keys")
630
 
631
  # -- Mount Prometheus metrics --
632
  app.mount("/metrics", make_asgi_app())
extraction/keys_extractor.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Nested JSON key-value extractor using jsonpath-ng.
3
+
4
+ Walks any JSON-like structure (dict / list) and returns every value
5
+ attached to a given key, regardless of how deeply nested it is.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from jsonpath_ng import parse
12
+ from jsonpath_ng.exceptions import JsonPathLexerError, JsonPathParserError
13
+
14
+ from logger import get_logger
15
+
16
+ logger = get_logger(__name__)
17
+
18
+
19
+ class Extractor:
20
+ def __init__(
21
+ self,
22
+ data: dict | list,
23
+ key_names: list[str],
24
+ result_limit: int | None = 50,
25
+ ):
26
+ if not isinstance(key_names, list):
27
+ raise TypeError("key_names must be a list")
28
+ if not key_names:
29
+ raise ValueError("key_names must be a non-empty list")
30
+ for kn in key_names:
31
+ if not isinstance(kn, str) or not kn:
32
+ raise ValueError(f"each key_name must be a non-empty string, got {kn!r}")
33
+ if result_limit is not None and result_limit < 1:
34
+ raise ValueError("result_limit must be >= 1 or None")
35
+
36
+ self._data = data
37
+ self._key_names = key_names
38
+ self._result_limit = result_limit
39
+
40
+ def extract(self) -> dict[str, list[Any]]:
41
+ return {k: self._extract_single(k) for k in self._key_names}
42
+
43
+ def _extract_single(self, key_name: str) -> list[Any]:
44
+ try:
45
+ expr = _build_jp_expr(key_name)
46
+ except ValueError:
47
+ return []
48
+ vals = [m.value for m in expr.find(self._data)]
49
+ if self._result_limit is not None:
50
+ vals = vals[: self._result_limit]
51
+ return vals
52
+
53
+
54
+ def _build_jp_expr(key_name: str) -> Any:
55
+ if '"' in key_name and "'" in key_name:
56
+ raise ValueError(f"key contains both quote types: {key_name!r}")
57
+ if '"' in key_name:
58
+ expr_str = "$..['" + key_name + "']"
59
+ else:
60
+ expr_str = '$..["' + key_name + '"]'
61
+ try:
62
+ return parse(expr_str)
63
+ except (JsonPathLexerError, JsonPathParserError):
64
+ raise ValueError(f"unable to build jsonpath expression for key: {key_name!r}")
65
+
66
+
67
+ def get_nested_values(
68
+ data: dict | list,
69
+ key_name: str,
70
+ ) -> list[Any]:
71
+ return Extractor(data, [key_name], result_limit=None).extract()[key_name]
requirements.txt CHANGED
@@ -20,6 +20,9 @@ pandas>=2.0.0
20
  # spaCy NER extraction for non-tabular file formats
21
  spacy>=3.7.0
22
 
 
 
 
23
  # CLI banner
24
  pyfiglet>=1.0.2
25
  rich>=13.0.0
 
20
  # spaCy NER extraction for non-tabular file formats
21
  spacy>=3.7.0
22
 
23
+ # Recursive nested-JSON key extraction
24
+ jsonpath-ng>=1.8.0
25
+
26
  # CLI banner
27
  pyfiglet>=1.0.2
28
  rich>=13.0.0