validops-east-1 commited on
Commit
ebf3f60
·
1 Parent(s): f81d9cb

fix: rework monetary_fields as in-place feature-extract config with status report

Browse files

Replace the auto-discovered monetary_fields array (removed from /json/extract)
with an optional per-item request config on /json/feature-extract
({object_name, field_names}) that price-parses the declared fields in place
inside the extracted data. The response now carries a per-field status/error
report (parsed / not_found / not_parsable / skipped) so callers can see what
happened to every declared monetary field.

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>

app/api/v1/json_extract.py CHANGED
@@ -12,7 +12,7 @@ from app.core.logger import get_logger
12
  from app.core.thread_pool import run_in_executor
13
  from app.services.gliner_service import gliner_service
14
  from app.services.json_service import extract_json
15
- from app.services.monetary_field_service import MonetaryField, find_monetary_fields
16
 
17
  logger = get_logger(__name__)
18
 
@@ -46,13 +46,6 @@ class ExtractJsonResponse(BaseModel):
46
  data: Any = None
47
  count: int = 0
48
  error_message: Optional[str] = None
49
- monetary_fields: List[MonetaryField] = Field(
50
- default_factory=list,
51
- description=(
52
- "Monetary values found inside the extracted data (via the JSON key "
53
- "finder + amount parser). Empty when none are found."
54
- ),
55
- )
56
 
57
 
58
  @router.post(
@@ -113,8 +106,6 @@ async def extract_json_endpoint(
113
 
114
  response_data = result.data[0] if body.mode == "first" else result.data
115
 
116
- monetary = find_monetary_fields(response_data)
117
-
118
  logger.info(
119
  "JSON extraction successful",
120
  extra={
@@ -131,7 +122,6 @@ async def extract_json_endpoint(
131
  data=response_data,
132
  count=result.total_extracted,
133
  error_message=None,
134
- monetary_fields=monetary,
135
  )
136
 
137
 
@@ -171,6 +161,32 @@ class NoAiExtractRequest(BaseModel):
171
  le=1.0,
172
  description="Confidence threshold (0.0-1.0). Lower includes more candidates.",
173
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
 
175
 
176
  class NoAiExtractResponse(BaseModel):
@@ -180,11 +196,13 @@ class NoAiExtractResponse(BaseModel):
180
  data: Any = None
181
  count: int = 0
182
  error_message: Optional[str] = None
183
- monetary_fields: List[MonetaryField] = Field(
184
  default_factory=list,
185
  description=(
186
- "Monetary values found inside this item's extracted data (via the "
187
- "JSON key finder + amount parser). Empty when none are found."
 
 
188
  ),
189
  )
190
 
@@ -212,7 +230,9 @@ def _count_extracted(result: Any) -> int:
212
  "for private or invoice/OCR data. mode='json' uses a structure schema to "
213
  "pull named fields; mode='entities' detects a flat list of entity types. "
214
  "Each item reports its own success/error. "
215
- "Returns HTTP 503 if the model failed to load or is disabled."
 
 
216
  ),
217
  )
218
  async def no_ai_extract_endpoint(
@@ -312,14 +332,27 @@ async def no_ai_extract_endpoint(
312
  )
313
 
314
  elapsed = round((time.perf_counter() - start) * 1000, 3)
315
- monetary = find_monetary_fields(result)
 
 
 
 
 
 
 
 
 
 
 
 
 
316
  logger.info(
317
  "Feature-extract item extracted",
318
  extra={
319
  "index": index,
320
  "mode": item.mode,
321
  "count": _count_extracted(result),
322
- "monetary_fields": len(monetary),
323
  "time_ms": elapsed,
324
  },
325
  )
@@ -330,7 +363,7 @@ async def no_ai_extract_endpoint(
330
  data=result,
331
  count=_count_extracted(result),
332
  error_message=None,
333
- monetary_fields=monetary,
334
  )
335
 
336
  results = await asyncio.gather(
 
12
  from app.core.thread_pool import run_in_executor
13
  from app.services.gliner_service import gliner_service
14
  from app.services.json_service import extract_json
15
+ from app.services.monetary_field_service import MonetaryFieldStatus, apply_monetary_fields
16
 
17
  logger = get_logger(__name__)
18
 
 
46
  data: Any = None
47
  count: int = 0
48
  error_message: Optional[str] = None
 
 
 
 
 
 
 
49
 
50
 
51
  @router.post(
 
106
 
107
  response_data = result.data[0] if body.mode == "first" else result.data
108
 
 
 
109
  logger.info(
110
  "JSON extraction successful",
111
  extra={
 
122
  data=response_data,
123
  count=result.total_extracted,
124
  error_message=None,
 
125
  )
126
 
127
 
 
161
  le=1.0,
162
  description="Confidence threshold (0.0-1.0). Lower includes more candidates.",
163
  )
164
+ monetary_fields: Optional["MonetaryFieldsConfig"] = Field(
165
+ default=None,
166
+ description=(
167
+ "OPTIONAL. Declare which extracted fields hold monetary values so they "
168
+ "are price-parsed in place: `object_name` selects the container key in "
169
+ "the extracted `data` (e.g. 'invoice') and `field_names` lists the "
170
+ "monetary fields inside each object (e.g. ['total']). Found values are "
171
+ "replaced with their parsed numeric amount; missing/blank/unparseable "
172
+ "values are skipped. Omit to leave the extracted data untouched."
173
+ ),
174
+ )
175
+
176
+
177
+ class MonetaryFieldsConfig(BaseModel):
178
+ """Declares which extracted fields are monetary so they are price-parsed."""
179
+
180
+ object_name: str = Field(
181
+ ...,
182
+ min_length=1,
183
+ description="Key in the extracted `data` whose value holds the objects to process, e.g. 'invoice'.",
184
+ )
185
+ field_names: List[str] = Field(
186
+ ...,
187
+ min_length=1,
188
+ description="Monetary field names inside each object, e.g. ['total', 'cgst'].",
189
+ )
190
 
191
 
192
  class NoAiExtractResponse(BaseModel):
 
196
  data: Any = None
197
  count: int = 0
198
  error_message: Optional[str] = None
199
+ monetary_fields: List[MonetaryFieldStatus] = Field(
200
  default_factory=list,
201
  description=(
202
+ "Per-field outcome of the optional `monetary_fields` config: status "
203
+ "is 'parsed', 'not_found', 'not_parsable' or 'skipped', with the "
204
+ "parsed amount and a human-readable error. Empty when no config "
205
+ "was sent."
206
  ),
207
  )
208
 
 
230
  "for private or invoice/OCR data. mode='json' uses a structure schema to "
231
  "pull named fields; mode='entities' detects a flat list of entity types. "
232
  "Each item reports its own success/error. "
233
+ "Returns HTTP 503 if the model failed to load or is disabled. "
234
+ "OPTIONAL per-item `monetary_fields` ({\"object_name\", \"field_names\"}) "
235
+ "price-parses the declared fields in place inside the extracted data."
236
  ),
237
  )
238
  async def no_ai_extract_endpoint(
 
332
  )
333
 
334
  elapsed = round((time.perf_counter() - start) * 1000, 3)
335
+
336
+ # OPTIONAL post-processing: price-parse the declared monetary fields
337
+ # in place inside the extracted data (e.g. "1250.75" -> 1250.75) and
338
+ # report the per-field outcome so callers can understand any failures.
339
+ monetary_report: List[MonetaryFieldStatus] = []
340
+ parsed_monetary = 0
341
+ if item.monetary_fields is not None:
342
+ monetary_report = apply_monetary_fields(
343
+ result,
344
+ item.monetary_fields.object_name,
345
+ item.monetary_fields.field_names,
346
+ )
347
+ parsed_monetary = sum(1 for s in monetary_report if s.status == "parsed")
348
+
349
  logger.info(
350
  "Feature-extract item extracted",
351
  extra={
352
  "index": index,
353
  "mode": item.mode,
354
  "count": _count_extracted(result),
355
+ "monetary_fields_parsed": parsed_monetary,
356
  "time_ms": elapsed,
357
  },
358
  )
 
363
  data=result,
364
  count=_count_extracted(result),
365
  error_message=None,
366
+ monetary_fields=monetary_report,
367
  )
368
 
369
  results = await asyncio.gather(
app/services/monetary_field_service.py CHANGED
@@ -1,22 +1,26 @@
1
- """Monetary-field detection for feature-extraction results.
2
-
3
- Given the ``data`` payload of a JSON / feature-extraction response, locate the
4
- values of known monetary field names (``total``, ``subtotal``, ``cgst``,
5
- ``balance`` ...) at any nesting depth using the recursive JSON key finder
6
- (:mod:`app.services.keys_extractor_service`), then normalise each found value
7
- with the amount parser (:mod:`app.services.price_parser`).
8
-
9
- Usage
10
- -----
11
- >>> from app.services.monetary_field_service import find_monetary_fields
12
- >>> data = {"invoice": [{"total": "INR 572,300.00", "vendor": "Acme"}]}
13
- >>> find_monetary_fields(data)
14
- [MonetaryField(key='total', raw='INR 572,300.00', amount=572300.0,
15
- currency='INR', currency_code='INR', confidence=1.0)]
16
-
17
- When no monetary field is found (or none of the found values parse as an
18
- amount) the result is an empty list — callers should treat that as
19
- "no monetary fields found".
 
 
 
 
20
  """
21
 
22
  from __future__ import annotations
@@ -25,103 +29,152 @@ from typing import Any, List, Optional
25
 
26
  from pydantic import BaseModel, Field
27
 
28
- from app.services.keys_extractor_service import KeysExtractor
29
  from app.services.price_parser import parse_amount
30
 
31
- #: Field names that typically hold monetary values, in priority order.
32
- #: Matched by exact key name at any depth (e.g. ``data["invoice"][0]["total"]``).
33
- DEFAULT_MONETARY_KEYS: List[str] = [
34
- "grand_total",
35
- "total_amount",
36
- "amount_due",
37
- "total",
38
- "subtotal",
39
- "sub_total",
40
- "balance",
41
- "payable",
42
- "amount",
43
- "price",
44
- "unit_price",
45
- "unitprice",
46
- "rate",
47
- "cgst",
48
- "sgst",
49
- "igst",
50
- "total_tax",
51
- "tax",
52
- "vat",
53
- "gst",
54
- "discount",
55
- "shipping",
56
- "freight",
57
- "fee",
58
- "deposit",
59
- "paid",
60
- "due",
61
- "net",
62
- "gross",
63
- "value",
64
- "sum",
65
- ]
66
-
67
-
68
- class MonetaryField(BaseModel):
69
- """One parsed monetary field found inside an extraction result."""
70
-
71
- key: str
72
- """JSON key name the value was found under (exact match, any depth)."""
73
- raw: Optional[str] = None
74
- """The value as it appeared in the extraction result (string form)."""
75
  amount: Optional[float] = None
76
- """Normalised numeric amount, or ``None`` when the value did not parse."""
77
  currency: Optional[str] = None
78
- """Currency marker found in the value (symbol, code or word)."""
79
  currency_code: Optional[str] = None
80
- """ISO 4217 code when determinable, else ``None``."""
81
- confidence: float = Field(default=0.0, ge=0.0, le=1.0)
82
- """Parser confidence in [0, 1] higher is more likely a real amount."""
 
 
 
 
 
 
 
83
 
 
 
84
 
85
- def find_monetary_fields(
86
- data: Any,
87
- key_names: Optional[List[str]] = None,
88
- ) -> List[MonetaryField]:
89
- """Return parsed monetary fields found in ``data`` (empty list if none).
90
 
91
- ``data`` may be a ``dict`` or a ``list`` (any nesting depth). Only values
92
- that parse as amounts are returned; found-but-unparseable values are
93
- skipped. Invalid ``data`` types simply yield an empty list.
 
 
 
94
  """
95
- keys = key_names or DEFAULT_MONETARY_KEYS
96
- if not isinstance(data, (dict, list)):
97
- return []
98
-
99
- try:
100
- extractor = KeysExtractor(data, key_names=keys, result_limit=None)
101
- found = extractor.extract()
102
- except (TypeError, ValueError):
103
- return []
104
-
105
- results: List[MonetaryField] = []
106
- for key in keys:
107
- for value in found.get(key, []):
108
- if value is None or isinstance(value, bool):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  continue
110
  parsed = parse_amount(value)
111
  if parsed is None:
 
 
 
 
 
 
 
 
 
 
112
  continue
113
- raw = parsed.raw if isinstance(value, str) else str(value)
114
- results.append(
115
- MonetaryField(
116
- key=key,
117
- raw=raw,
 
 
 
118
  amount=parsed.amount_float,
119
  currency=parsed.currency,
120
  currency_code=parsed.currency_code,
121
  confidence=parsed.confidence,
122
  )
123
  )
124
- return results
125
 
126
 
127
- __all__ = ["MonetaryField", "find_monetary_fields", "DEFAULT_MONETARY_KEYS"]
 
1
+ """In-place monetary-field parsing for the feature-extraction pipeline.
2
+
3
+ When a feature-extract request declares ``monetary_fields`` an object name
4
+ plus a list of field names the extracted ``data`` payload is post-processed
5
+ so those fields become real numbers instead of raw strings, and a per-field
6
+ status report is returned so callers can see exactly what happened to each
7
+ declared field.
8
+
9
+ Example
10
+ -------
11
+ Request config::
12
+
13
+ {"object_name": "invoice", "field_names": ["total", "invoice_no"]}
14
+
15
+ Extracted data::
16
+
17
+ {"invoice": [{"number": "INV-2026-00125", "total": "1250.75"}]}
18
+
19
+ After :func:`apply_monetary_fields` the same dict has ``"total": 1250.75``
20
+ (price-parsed) and the returned report explains each field:
21
+
22
+ * ``total`` -> status ``parsed``, amount 1250.75
23
+ * ``invoice_no`` -> status ``not_found`` (no such field in the object)
24
  """
25
 
26
  from __future__ import annotations
 
29
 
30
  from pydantic import BaseModel, Field
31
 
 
32
  from app.services.price_parser import parse_amount
33
 
34
+
35
+ class MonetaryFieldStatus(BaseModel):
36
+ """Outcome of one declared monetary field, per extracted object item."""
37
+
38
+ object_name: str
39
+ """Container key the field was looked up in (from the request config)."""
40
+ field: str
41
+ """Monetary field name (from the request config)."""
42
+ item_index: int = 0
43
+ """Index of the object inside the container this report refers to."""
44
+ status: str = Field(
45
+ default="not_found",
46
+ description="One of: parsed, not_found, not_parsable, skipped.",
47
+ )
48
+ value: Optional[str] = None
49
+ """The raw value as extracted (string form), when present."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  amount: Optional[float] = None
51
+ """Parsed numeric amount when status == 'parsed'."""
52
  currency: Optional[str] = None
53
+ """Currency marker found in the value, when parsed."""
54
  currency_code: Optional[str] = None
55
+ """ISO 4217 code when determinable, else None."""
56
+ confidence: float = 0.0
57
+ """Parser confidence in [0, 1] when parsed."""
58
+ error: Optional[str] = None
59
+ """Human-readable reason when the field was not parsed."""
60
+
61
+
62
+ def _status(object_name: str, field: str, item_index: int, **kw) -> MonetaryFieldStatus:
63
+ return MonetaryFieldStatus(object_name=object_name, field=field, item_index=item_index, **kw)
64
+
65
 
66
+ def apply_monetary_fields(data: Any, object_name: str, field_names: List[str]) -> List[MonetaryFieldStatus]:
67
+ """Price-parse the configured monetary fields inside ``data``, in place.
68
 
69
+ ``object_name`` selects the container key in ``data`` (e.g. ``"invoice"``);
70
+ its value may be a list of objects or a single object. For every listed
71
+ ``field_names`` entry inside each object:
 
 
72
 
73
+ * value missing -> status ``not_found`` (error ``field 'x' not found``)
74
+ * value ``None`` / blank -> status ``skipped`` (error ``value is empty``)
75
+ * value that does not parse -> status ``not_parsable`` with the raw value
76
+ * value that parses -> replaced with the numeric amount, status ``parsed``
77
+
78
+ Returns one :class:`MonetaryFieldStatus` per (object item, field).
79
  """
80
+ report: List[MonetaryFieldStatus] = []
81
+
82
+ if not isinstance(data, dict) or not object_name:
83
+ for field in field_names:
84
+ report.append(
85
+ _status(
86
+ object_name,
87
+ field,
88
+ 0,
89
+ status="not_found",
90
+ error=f"object '{object_name}' not found in extracted data",
91
+ )
92
+ )
93
+ return report
94
+
95
+ container = data.get(object_name)
96
+ if isinstance(container, list):
97
+ items: List[Any] = container
98
+ elif isinstance(container, dict):
99
+ items = [container]
100
+ else:
101
+ for field in field_names:
102
+ report.append(
103
+ _status(
104
+ object_name,
105
+ field,
106
+ 0,
107
+ status="not_found",
108
+ error=f"object '{object_name}' not found in extracted data",
109
+ )
110
+ )
111
+ return report
112
+
113
+ for idx, item in enumerate(items):
114
+ if not isinstance(item, dict):
115
+ for field in field_names:
116
+ report.append(
117
+ _status(
118
+ object_name,
119
+ field,
120
+ idx,
121
+ status="not_found",
122
+ error="item is not an object",
123
+ )
124
+ )
125
+ continue
126
+ for field in field_names:
127
+ if field not in item:
128
+ report.append(
129
+ _status(
130
+ object_name,
131
+ field,
132
+ idx,
133
+ status="not_found",
134
+ error=f"field '{field}' not found",
135
+ )
136
+ )
137
+ continue
138
+ value = item[field]
139
+ if value is None or (isinstance(value, str) and not value.strip()):
140
+ report.append(
141
+ _status(
142
+ object_name,
143
+ field,
144
+ idx,
145
+ status="skipped",
146
+ error="value is empty",
147
+ )
148
+ )
149
  continue
150
  parsed = parse_amount(value)
151
  if parsed is None:
152
+ report.append(
153
+ _status(
154
+ object_name,
155
+ field,
156
+ idx,
157
+ status="not_parsable",
158
+ value=str(value),
159
+ error=f"value is non-parsable: {value}",
160
+ )
161
+ )
162
  continue
163
+ item[field] = parsed.amount_float
164
+ report.append(
165
+ _status(
166
+ object_name,
167
+ field,
168
+ idx,
169
+ status="parsed",
170
+ value=str(value),
171
  amount=parsed.amount_float,
172
  currency=parsed.currency,
173
  currency_code=parsed.currency_code,
174
  confidence=parsed.confidence,
175
  )
176
  )
177
+ return report
178
 
179
 
180
+ __all__ = ["MonetaryFieldStatus", "apply_monetary_fields"]