light-infer-chat commited on
Commit
79879d4
·
1 Parent(s): faefb1f

feat: key extractor

Browse files
app/api/v1/keys_extract.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ from fastapi import APIRouter, Depends
8
+ from pydantic import BaseModel, Field, field_validator
9
+
10
+ from app.api.deps import require_auth
11
+ from app.core.logger import get_logger
12
+ from app.core.thread_pool import thread_pool
13
+ from app.services.keys_extractor_service import KeysExtractor
14
+
15
+ logger = get_logger(__name__)
16
+
17
+ router = APIRouter()
18
+
19
+
20
+ class KeysExtractRequest(BaseModel):
21
+ data: Any = Field(..., description="JSON object or array to search")
22
+ key_names: Optional[List[str]] = Field(
23
+ default=None,
24
+ description=(
25
+ "Key names to look up at any depth. Mutually exclusive with `query`; "
26
+ "provide one of the two."
27
+ ),
28
+ )
29
+ query: Optional[str] = Field(
30
+ default=None,
31
+ description=(
32
+ "Extended JSONPath expression (parsed with `jsonpath_ng.ext`) used to "
33
+ "select values. Supports filters, arithmetic and the union operator, e.g. "
34
+ "`$.store.book[?price > 10].title`. Each match is returned with its "
35
+ "full path inside the source document. Mutually exclusive with `key_names`."
36
+ ),
37
+ )
38
+ result_limit: Optional[int] = Field(
39
+ None,
40
+ ge=1,
41
+ description=(
42
+ "Maximum results to return. For `key_names` mode this caps values per "
43
+ "key; for `query` mode it caps the number of matches returned. "
44
+ "Omit (or pass null) to return the full, uncapped result."
45
+ ),
46
+ )
47
+
48
+ @field_validator("key_names")
49
+ @classmethod
50
+ def _validate_key_names(cls, v: Optional[List[str]]) -> Optional[List[str]]:
51
+ if v is None:
52
+ return v
53
+ if not v:
54
+ raise ValueError("key_names must be a non-empty list (omit it or supply a `query`)")
55
+ for kn in v:
56
+ if not isinstance(kn, str) or not kn:
57
+ raise ValueError("each key_name must be a non-empty string")
58
+ return v
59
+
60
+ @field_validator("query")
61
+ @classmethod
62
+ def _validate_query(cls, v: Optional[str]) -> Optional[str]:
63
+ if v is None:
64
+ return v
65
+ if not isinstance(v, str) or not v.strip():
66
+ raise ValueError("query must be a non-empty string")
67
+ return v
68
+
69
+
70
+ class KeysExtractResponse(BaseModel):
71
+ success: bool
72
+ time_ms: float
73
+ data: Dict[str, Any]
74
+ error_message: Optional[str] = None
75
+
76
+
77
+ class KeysExtractBatchRequest(BaseModel):
78
+ requests: List[KeysExtractRequest] = Field(
79
+ ...,
80
+ min_length=1,
81
+ max_length=50,
82
+ description="List of extraction requests (1-50) to process concurrently",
83
+ )
84
+
85
+
86
+ class KeysExtractBatchResponse(BaseModel):
87
+ success: bool
88
+ time_ms: float
89
+ results: List[KeysExtractResponse]
90
+
91
+
92
+ def _error_response(start: float, message: str) -> KeysExtractResponse:
93
+ return KeysExtractResponse(
94
+ success=False,
95
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
96
+ data={},
97
+ error_message=message,
98
+ )
99
+
100
+
101
+ async def _extract_single(body: KeysExtractRequest) -> KeysExtractResponse:
102
+ start = time.perf_counter()
103
+ has_key_names = bool(body.key_names)
104
+ has_query = body.query is not None
105
+
106
+ if has_key_names and has_query:
107
+ return _error_response(start, "provide either `key_names` or `query`, not both")
108
+ if not has_key_names and not has_query:
109
+ return _error_response(start, "either `key_names` or `query` must be provided")
110
+ if not isinstance(body.data, (dict, list)):
111
+ return _error_response(start, "`data` must be a JSON object or array")
112
+
113
+ try:
114
+ loop = asyncio.get_running_loop()
115
+ results = await loop.run_in_executor(
116
+ thread_pool,
117
+ KeysExtractor(body.data, body.key_names, body.result_limit, body.query).extract,
118
+ )
119
+ except (TypeError, ValueError) as exc:
120
+ return _error_response(start, str(exc))
121
+ except Exception as exc:
122
+ return _error_response(start, f"extraction failed: {exc}")
123
+
124
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
125
+ return KeysExtractResponse(
126
+ success=True,
127
+ time_ms=elapsed,
128
+ data=results,
129
+ error_message=None,
130
+ )
131
+
132
+
133
+ @router.post(
134
+ "/keys/extract",
135
+ response_model=KeysExtractBatchResponse,
136
+ dependencies=[Depends(require_auth)],
137
+ tags=["Keys Extractor"],
138
+ summary="Extract values from nested JSON objects (batch of up to 50 requests processed concurrently)",
139
+ )
140
+ async def extract_keys_batch(body: KeysExtractBatchRequest):
141
+ """Process up to 50 extraction requests concurrently.
142
+
143
+ Each request supports two modes:
144
+
145
+ * **key_names** (default) -- recursively walk any JSON object/array and return
146
+ every value attached to the supplied key names, regardless of how deeply
147
+ nested they are. Returns ``{key_name: [values...]}``.
148
+
149
+ * **query** -- run an extended JSONPath expression and return each match with
150
+ its full path inside the source document. Returns
151
+ ``{"matches": [{"path": "users.[0].role", "value": "admin"}, ...]}``.
152
+ """
153
+ start = time.perf_counter()
154
+ logger.info("keys_extract_batch | start", count=len(body.requests))
155
+ tasks = [_extract_single(req) for req in body.requests]
156
+ results = await asyncio.gather(*tasks)
157
+ elapsed = round((time.perf_counter() - start) * 1000, 3)
158
+ all_ok = all(r.success for r in results)
159
+ logger.info(
160
+ "keys_extract_batch | done",
161
+ total=len(results),
162
+ succeeded=sum(1 for r in results if r.success),
163
+ time_ms=elapsed,
164
+ )
165
+ return KeysExtractBatchResponse(success=all_ok, time_ms=elapsed, results=results)
app/api/v1/router.py CHANGED
@@ -12,6 +12,7 @@ from app.api.v1 import (
12
  database,
13
  embeddings,
14
  json_extract,
 
15
  qr_decoder,
16
  qr_generator,
17
  reconcile,
@@ -49,6 +50,7 @@ api_v1_router.include_router(vector_stores.router, tags=["Vector Stores"])
49
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
50
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
51
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
 
52
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
53
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
54
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
 
12
  database,
13
  embeddings,
14
  json_extract,
15
+ keys_extract,
16
  qr_decoder,
17
  qr_generator,
18
  reconcile,
 
50
  api_v1_router.include_router(webhook_socket.router, tags=["Webhook / Socket"])
51
  api_v1_router.include_router(csv_analysis.router, tags=["CSV Analysis"])
52
  api_v1_router.include_router(json_extract.router, tags=["JSON Extractor"])
53
+ api_v1_router.include_router(keys_extract.router, prefix="/json", tags=["Keys Extractor"])
54
  api_v1_router.include_router(qr_decoder.router, tags=["QR Decoder"])
55
  api_v1_router.include_router(qr_generator.router, tags=["QR Generator"])
56
  api_v1_router.include_router(url_shortener.router, tags=["URL Shortener"])
app/services/keys_extractor_service.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, List, Optional
4
+
5
+ from jsonpath_ng import parse
6
+ from jsonpath_ng.ext import parse as ext_parse
7
+ from jsonpath_ng.exceptions import JsonPathLexerError, JsonPathParserError
8
+
9
+ from app.core.logger import get_logger
10
+
11
+ logger = get_logger(__name__)
12
+
13
+
14
+ class KeysExtractor:
15
+ """Nested JSON extractor using jsonpath-ng.
16
+
17
+ Supports two modes:
18
+ 1. key_names — recursive lookup by key name at any depth
19
+ 2. query — extended JSONPath expression with full path output
20
+ """
21
+
22
+ def __init__(
23
+ self,
24
+ data: dict | list,
25
+ key_names: Optional[List[str]] = None,
26
+ result_limit: Optional[int] = 50,
27
+ query: Optional[str] = None,
28
+ ):
29
+ if query is None and not key_names:
30
+ raise ValueError("either `query` or `key_names` must be provided")
31
+
32
+ if key_names is not None:
33
+ if not isinstance(key_names, list):
34
+ raise TypeError("key_names must be a list")
35
+ for kn in key_names:
36
+ if not isinstance(kn, str) or not kn:
37
+ raise ValueError(f"each key_name must be a non-empty string, got {kn!r}")
38
+
39
+ if query is not None:
40
+ if not isinstance(query, str) or not query.strip():
41
+ raise ValueError("query must be a non-empty string")
42
+
43
+ if result_limit is not None and result_limit < 1:
44
+ raise ValueError("result_limit must be >= 1 or None")
45
+
46
+ self._data = data
47
+ self._key_names = key_names or []
48
+ self._result_limit = result_limit
49
+ self._query = query
50
+
51
+ def extract(self) -> dict[str, Any]:
52
+ if self._query is not None:
53
+ return {"matches": self._extract_by_query()}
54
+ return {k: self._extract_single(k) for k in self._key_names}
55
+
56
+ def _extract_by_query(self) -> List[dict[str, Any]]:
57
+ try:
58
+ expr = ext_parse(self._query)
59
+ except (JsonPathLexerError, JsonPathParserError) as exc:
60
+ raise ValueError(f"invalid jsonpath query {self._query!r}: {exc}") from exc
61
+
62
+ matches: List[dict[str, Any]] = [
63
+ {"path": str(m.full_path), "value": m.value}
64
+ for m in expr.find(self._data)
65
+ ]
66
+
67
+ if self._result_limit is not None:
68
+ matches = matches[: self._result_limit]
69
+
70
+ return matches
71
+
72
+ def _extract_single(self, key_name: str) -> List[Any]:
73
+ try:
74
+ expr = _build_jp_expr(key_name)
75
+ except ValueError:
76
+ return []
77
+ vals = [m.value for m in expr.find(self._data)]
78
+ if self._result_limit is not None:
79
+ vals = vals[: self._result_limit]
80
+ return vals
81
+
82
+
83
+ def _build_jp_expr(key_name: str) -> Any:
84
+ if '"' in key_name and "'" in key_name:
85
+ raise ValueError(f"key contains both quote types: {key_name!r}")
86
+ if '"' in key_name:
87
+ expr_str = "$..['" + key_name + "']"
88
+ else:
89
+ expr_str = '$..["' + key_name + '"]'
90
+ try:
91
+ return parse(expr_str)
92
+ except (JsonPathLexerError, JsonPathParserError):
93
+ raise ValueError(f"unable to build jsonpath expression for key: {key_name!r}")
94
+
95
+
96
+ def get_nested_values(data: dict | list, key_name: str) -> List[Any]:
97
+ return KeysExtractor(data, key_names=[key_name], result_limit=None).extract()[key_name]
requirements.txt CHANGED
@@ -62,5 +62,8 @@ Unidecode>=1.3.8
62
  # JSON Schema validation
63
  jsonschema>=4.21.0
64
 
 
 
 
65
  # QR code generation (testing)
66
  qrcode[pil]>=8.0
 
62
  # JSON Schema validation
63
  jsonschema>=4.21.0
64
 
65
+ # JSONPath extraction
66
+ jsonpath-ng>=1.7.0
67
+
68
  # QR code generation (testing)
69
  qrcode[pil]>=8.0