Spaces:
Sleeping
Sleeping
Commit ·
461373d
1
Parent(s): 697be7d
feat: added json extractor
Browse files- api/server.py +100 -0
- extraction/keys_extractor.py +71 -0
- 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
|