Spaces:
Running
Running
File size: 6,486 Bytes
f81d9cb | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | """Batch amount / price parsing API.
Exposes the pure amount parser (:mod:`app.services.price_parser`) as a
batch endpoint: send up to 100 raw text fields and get every candidate amount
plus the single best one back per item, processed concurrently.
POST /api/v1/price-parser
[{"text": "I want to go home with ssja^67.00 and $896,009.0 0"}]
-> [{"success": true, "count": 2,
"amounts": [{"amount": 896009.0, "currency": "$", ...}, ...],
"best": {"amount": 896009.0, ...}, ...}]
"""
from __future__ import annotations
import asyncio
import time
from typing import Annotated, List, Optional
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.core.logger import get_logger
from app.core.thread_pool import thread_pool
from app.services.price_parser import CONFIDENCE_FLOOR, extract_amounts, parse_amount
logger = get_logger(__name__)
router = APIRouter()
MAX_BATCH_SIZE = 100
MAX_AMOUNTS_PER_ITEM = 100
class PriceParseRequest(BaseModel):
text: str = Field(
...,
min_length=1,
max_length=1_000_000,
description="Raw text field to parse amounts from (OCR output, invoice lines, emails, ...).",
)
currency_hint: Optional[str] = Field(
default=None,
description=(
"Currency to assume for values with no currency marker of their own, "
"e.g. 'USD', 'руб'. Ignored when a value already carries a currency."
),
)
default_currency: Optional[str] = Field(
default=None,
description="Currency label applied to the final result when no currency was found.",
)
limit: Optional[int] = Field(
default=None,
ge=1,
le=MAX_AMOUNTS_PER_ITEM,
description="Maximum number of candidate amounts to return for this item. Omit for all.",
)
class ParsedAmount(BaseModel):
"""One candidate amount found in a text field."""
amount: float
amount_text: str
raw: str
currency: Optional[str] = None
currency_code: Optional[str] = None
confidence: float
is_negative: bool
class PriceParseItemResponse(BaseModel):
success: bool
time_ms: float
text: str
count: int
amounts: List[ParsedAmount] = Field(
default_factory=list,
description="All candidate amounts found, ranked by confidence (best first).",
)
best: Optional[ParsedAmount] = Field(
default=None,
description="The single most likely amount, or null when none was found.",
)
error_message: Optional[str] = None
def _to_parsed(amount) -> ParsedAmount:
return ParsedAmount(
amount=amount.amount_float,
amount_text=amount.amount_text,
raw=amount.raw,
currency=amount.currency,
currency_code=amount.currency_code,
confidence=amount.confidence,
is_negative=amount.is_negative,
)
def _run_parse(text: str, limit, hint, default_currency):
"""CPU-bound parse work, executed on the shared thread pool."""
amounts = extract_amounts(text, limit=limit, currency_hint=hint)
# Only candidates that clear the confidence floor are real amounts;
# reject-word/date/quantity leftovers (confidence 0) must not count.
amounts = [a for a in amounts if a.confidence >= CONFIDENCE_FLOOR]
best = parse_amount(text, currency_hint=hint, default_currency=default_currency)
return amounts, best
def _error_response(start: float, text: str, message: str) -> PriceParseItemResponse:
return PriceParseItemResponse(
success=False,
time_ms=round((time.perf_counter() - start) * 1000, 3),
text=text,
count=0,
amounts=[],
best=None,
error_message=message,
)
async def _parse_one(index: int, body: PriceParseRequest) -> PriceParseItemResponse:
start = time.perf_counter()
try:
loop = asyncio.get_running_loop()
amounts, best = await loop.run_in_executor(
thread_pool,
_run_parse,
body.text,
body.limit,
body.currency_hint,
body.default_currency,
)
except ValueError as exc:
return _error_response(start, body.text, str(exc))
except Exception as exc: # pragma: no cover - defensive
logger.exception("price_parser item %s failed", index)
return _error_response(start, body.text, f"parsing failed: {exc}")
elapsed = round((time.perf_counter() - start) * 1000, 3)
logger.info(
"price_parser item parsed",
extra={"index": index, "count": len(amounts), "time_ms": elapsed},
)
return PriceParseItemResponse(
success=True,
time_ms=elapsed,
text=body.text,
count=len(amounts),
amounts=[_to_parsed(a) for a in amounts],
best=_to_parsed(best) if best is not None else None,
error_message=None,
)
@router.post(
"/price-parser",
response_model=List[PriceParseItemResponse],
summary="Parse monetary amounts from dirty text fields (batch of up to 100, processed concurrently)",
description=(
"Send up to 100 raw text fields as a JSON array (1-100 items). Each item "
"returns every candidate amount found (ranked by confidence) plus the "
"single best one, with currency, confidence and the matched substrings. "
"Supports US/EU/Indian number formats, OCR distortion ('$iom89.00', "
"'896,009.0 0'), magnitudes ('₹5 Cr', '1.5M'), negatives and "
"currency hints. Fields with only dates/quantities/references return "
"count=0 with an empty amounts array. Parsing is exact (Decimal) and "
"dependency-free."
),
)
async def price_parser_batch(
body: Annotated[
List[PriceParseRequest],
Field(
min_length=1,
max_length=MAX_BATCH_SIZE,
description="Array of 1-100 text fields to parse, processed concurrently.",
),
],
) -> List[PriceParseItemResponse]:
start = time.perf_counter()
logger.info("price_parser_batch | start count=%d", len(body))
results = await asyncio.gather(*[_parse_one(index, item) for index, item in enumerate(body)])
elapsed = round((time.perf_counter() - start) * 1000, 3)
succeeded = sum(1 for r in results if r.success)
logger.info(
"price_parser_batch | done total=%d succeeded=%d time_ms=%s",
len(results),
succeeded,
elapsed,
)
return results
|