mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 06:03:14 +00:00
fix(wo): reject non-str AI free-text fields (#158)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
This commit is contained in:
parent
8989dbde4a
commit
d677358801
3 changed files with 74 additions and 1 deletions
|
|
@ -9,6 +9,7 @@ client is constructed (moto-before-handler invariant).
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
import boto3
|
import boto3
|
||||||
from prompts import EXTRACTION_PROMPT
|
from prompts import EXTRACTION_PROMPT
|
||||||
|
|
@ -85,4 +86,5 @@ def extract_with_bedrock(email_data: dict) -> dict:
|
||||||
if json_match:
|
if json_match:
|
||||||
response_text = json_match.group(1)
|
response_text = json_match.group(1)
|
||||||
|
|
||||||
return json.loads(response_text.strip())
|
# parse_float=Decimal is CRITICAL: DynamoDB rejects Python floats.
|
||||||
|
return json.loads(response_text.strip(), parse_float=Decimal)
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,21 @@ VALID_STATUSES = {
|
||||||
"unknown",
|
"unknown",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Free-text scalar fields that must be None or str (blocks LLM-emitted maps/
|
||||||
|
# lists from landing as DynamoDB Map/List attribute pollution, and floats that
|
||||||
|
# would crash update_item). email_type, status, work_order_id, site_code, and
|
||||||
|
# date fields are validated separately.
|
||||||
|
_AI_FREE_TEXT_STR_FIELDS = (
|
||||||
|
"description",
|
||||||
|
"building",
|
||||||
|
"address",
|
||||||
|
"severity",
|
||||||
|
"priority",
|
||||||
|
"assigned_to",
|
||||||
|
"commenter",
|
||||||
|
"comment_text",
|
||||||
|
)
|
||||||
|
|
||||||
# Subject classifiers.
|
# Subject classifiers.
|
||||||
_T1_SUBJECT = re.compile(r"^AMAZON UPDATE WO DETAILS\s+(?P<wo>\S+)\s*$")
|
_T1_SUBJECT = re.compile(r"^AMAZON UPDATE WO DETAILS\s+(?P<wo>\S+)\s*$")
|
||||||
_T2_SUBJECT = re.compile(
|
_T2_SUBJECT = re.compile(
|
||||||
|
|
@ -480,6 +495,14 @@ def validate_ai_fallback(candidate):
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False, "creation_time_unparseable"
|
return False, "creation_time_unparseable"
|
||||||
|
|
||||||
|
# Free-text scalars must be None or str. A prompt-injected float reaches
|
||||||
|
# DynamoDB as Python float and crashes update_item; a dict/list lands as
|
||||||
|
# a Map/List attribute and pollutes downstream readers (PO parity).
|
||||||
|
for field in _AI_FREE_TEXT_STR_FIELDS:
|
||||||
|
val = candidate.get(field)
|
||||||
|
if val is not None and not isinstance(val, str):
|
||||||
|
return False, "invalid_field_type"
|
||||||
|
|
||||||
return True, "ok"
|
return True, "ok"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -374,3 +374,51 @@ def test_ai_fallback_all_valid_email_types():
|
||||||
cand["email_type"] = et
|
cand["email_type"] = et
|
||||||
ok, reason = validate_ai_fallback(cand)
|
ok, reason = validate_ai_fallback(cand)
|
||||||
assert ok, f"email_type={et} should pass"
|
assert ok, f"email_type={et} should pass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_fallback_free_text_float_rejected():
|
||||||
|
# Prompt-injected JSON float in a free-text slot must fail closed before
|
||||||
|
# update_item (boto3 rejects Python floats -> async retries / DLQ).
|
||||||
|
cand = _ai_candidate()
|
||||||
|
cand["work_order_id"] = "12345"
|
||||||
|
cand["email_type"] = "update"
|
||||||
|
cand["severity"] = 1.5
|
||||||
|
ok, reason = validate_ai_fallback(cand)
|
||||||
|
assert not ok and reason == "invalid_field_type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_fallback_free_text_dict_rejected():
|
||||||
|
# LLM-emitted maps in scalar slots must be rejected (DynamoDB Map pollution).
|
||||||
|
cand = _ai_candidate()
|
||||||
|
cand["work_order_id"] = "12345"
|
||||||
|
cand["email_type"] = "update"
|
||||||
|
cand["assigned_to"] = {"a": 1}
|
||||||
|
ok, reason = validate_ai_fallback(cand)
|
||||||
|
assert not ok and reason == "invalid_field_type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_fallback_free_text_list_rejected():
|
||||||
|
# LLM-emitted lists in scalar slots must be rejected (DynamoDB List pollution).
|
||||||
|
cand = _ai_candidate()
|
||||||
|
cand["work_order_id"] = "12345"
|
||||||
|
cand["email_type"] = "update"
|
||||||
|
cand["comment_text"] = ["x"]
|
||||||
|
ok, reason = validate_ai_fallback(cand)
|
||||||
|
assert not ok and reason == "invalid_field_type"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ai_fallback_free_text_none_and_str_ok():
|
||||||
|
# None and str remain valid for every free-text field.
|
||||||
|
cand = _ai_candidate()
|
||||||
|
cand["work_order_id"] = "12345"
|
||||||
|
cand["email_type"] = "update"
|
||||||
|
ok, reason = validate_ai_fallback(cand)
|
||||||
|
assert ok and reason == "ok"
|
||||||
|
|
||||||
|
for field in template_parser._AI_FREE_TEXT_STR_FIELDS:
|
||||||
|
cand = _ai_candidate()
|
||||||
|
cand["work_order_id"] = "12345"
|
||||||
|
cand["email_type"] = "update"
|
||||||
|
cand[field] = "ok"
|
||||||
|
ok, reason = validate_ai_fallback(cand)
|
||||||
|
assert ok and reason == "ok", field
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue