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

feat: infer/validate monetary_fields object_name from structure, slim report

Browse files

object_name in the optional monetary_fields config is now optional for
mode=json: inferred from the single structure parent key and validated
against the structure when provided (clear per-item errors on mismatch or
when ambiguous). Slim the per-field report to generic fields only
(object_name/field/item_index/status/value/parsed_value/error) — drop
currency/currency_code/confidence and rename amount to parsed_value. The
flow is fully user-driven with no hardcoded field names.

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

app/api/v1/json_extract.py CHANGED
@@ -165,11 +165,13 @@ class NoAiExtractRequest(BaseModel):
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
 
@@ -177,10 +179,14 @@ class NoAiExtractRequest(BaseModel):
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
  ...,
@@ -201,8 +207,8 @@ class NoAiExtractResponse(BaseModel):
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
 
@@ -311,6 +317,55 @@ async def no_ai_extract_endpoint(
311
  ),
312
  )
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  try:
315
  if item.mode == "json":
316
  result = await run_in_executor(
@@ -341,7 +396,7 @@ async def no_ai_extract_endpoint(
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")
 
165
  default=None,
166
  description=(
167
  "OPTIONAL. Declare which extracted fields hold monetary values so they "
168
+ "are price-parsed in place: `field_names` lists the monetary fields "
169
+ "inside each object (e.g. ['total']) and `object_name` selects the "
170
+ "container key in the extracted `data` -- it is inferred from the "
171
+ "`structure` parent key when omitted (mode='json') and validated "
172
+ "against the structure when provided. Found values are replaced with "
173
+ "their parsed numeric amount; missing/blank/unparseable values are "
174
+ "reported per field. Omit to leave the extracted data untouched."
175
  ),
176
  )
177
 
 
179
  class MonetaryFieldsConfig(BaseModel):
180
  """Declares which extracted fields are monetary so they are price-parsed."""
181
 
182
+ object_name: Optional[str] = Field(
183
+ default=None,
184
+ description=(
185
+ "Container key in the extracted `data` whose value holds the objects to "
186
+ "process, e.g. 'invoice'. OPTIONAL for mode='json': inferred from the "
187
+ "single parent key of `structure`. When provided it must match a "
188
+ "structure object name. Required for mode='entities'."
189
+ ),
190
  )
191
  field_names: List[str] = Field(
192
  ...,
 
207
  description=(
208
  "Per-field outcome of the optional `monetary_fields` config: status "
209
  "is 'parsed', 'not_found', 'not_parsable' or 'skipped', with the "
210
+ "raw `value`, the `parsed_value` and a human-readable error. Empty "
211
+ "when no config was sent."
212
  ),
213
  )
214
 
 
317
  ),
318
  )
319
 
320
+ # Resolve the monetary-fields target object name. It is optional for
321
+ # mode='json' (inferred from the single `structure` parent key) and
322
+ # validated against the structure when provided.
323
+ monetary_config = item.monetary_fields
324
+ object_name: Optional[str] = None
325
+ if monetary_config is not None:
326
+ object_name = monetary_config.object_name
327
+ if item.mode == "json" and isinstance(item.structure, dict):
328
+ structure_names = [k for k in item.structure if isinstance(k, str)]
329
+ if object_name is None:
330
+ if len(structure_names) == 1:
331
+ object_name = structure_names[0]
332
+ else:
333
+ return NoAiExtractResponse(
334
+ success=False,
335
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
336
+ mode=item.mode,
337
+ data=None,
338
+ count=0,
339
+ error_message=(
340
+ "monetary_fields.object_name is required when the structure "
341
+ "has multiple object names: " + ", ".join(structure_names)
342
+ ),
343
+ )
344
+ elif object_name not in structure_names:
345
+ return NoAiExtractResponse(
346
+ success=False,
347
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
348
+ mode=item.mode,
349
+ data=None,
350
+ count=0,
351
+ error_message=(
352
+ f"monetary_fields.object_name '{object_name}' does not match "
353
+ "the structure object name(s): " + ", ".join(structure_names)
354
+ ),
355
+ )
356
+ elif item.mode == "entities" and object_name is None:
357
+ return NoAiExtractResponse(
358
+ success=False,
359
+ time_ms=round((time.perf_counter() - start) * 1000, 3),
360
+ mode=item.mode,
361
+ data=None,
362
+ count=0,
363
+ error_message=(
364
+ "monetary_fields.object_name is required for mode='entities' "
365
+ "(no structure to infer it from)"
366
+ ),
367
+ )
368
+
369
  try:
370
  if item.mode == "json":
371
  result = await run_in_executor(
 
396
  if item.monetary_fields is not None:
397
  monetary_report = apply_monetary_fields(
398
  result,
399
+ object_name or "",
400
  item.monetary_fields.field_names,
401
  )
402
  parsed_monetary = sum(1 for s in monetary_report if s.status == "parsed")
app/services/monetary_field_service.py CHANGED
@@ -19,7 +19,7 @@ Extracted data::
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
 
@@ -47,14 +47,8 @@ class MonetaryFieldStatus(BaseModel):
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
 
@@ -168,10 +162,7 @@ def apply_monetary_fields(data: Any, object_name: str, field_names: List[str]) -
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
 
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``, parsed_value 1250.75
23
  * ``invoice_no`` -> status ``not_found`` (no such field in the object)
24
  """
25
 
 
47
  )
48
  value: Optional[str] = None
49
  """The raw value as extracted (string form), when present."""
50
+ parsed_value: Optional[float] = None
51
  """Parsed numeric amount when status == 'parsed'."""
 
 
 
 
 
 
52
  error: Optional[str] = None
53
  """Human-readable reason when the field was not parsed."""
54
 
 
162
  idx,
163
  status="parsed",
164
  value=str(value),
165
+ parsed_value=parsed.amount_float,
 
 
 
166
  )
167
  )
168
  return report