mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
Fix WO parser advisories A1-A3 (PR #99 follow-ups)
A1 — AI-fallback comment_id nondeterminism: parsed comment_time is model output and not stable across Lambda async retries, so on the ai_fallback path the comment_id range-key time segment now derives from the email Date header (deterministic per S3 object) instead of the model's comment_time. The template path is unchanged (its comment_time is a pure function of the raw email). Bedrock invoke pins temperature 0 so retries reproduce the same extraction. Closes the #23 reopening on the AI path. A2 — EMF record now carries the spec-required _aws.Timestamp (epoch ms) so CloudWatch reliably extracts the ParseOutcome datapoint that the fallback-rate alarm depends on. A3 — T1 New Comment capture no longer truncates at the first blank line; multi-paragraph comments are captured through internal blanks and terminate at the next label/separator. 17 golden files regenerated from the real fixtures accordingly. Hardening from the sh-security-review pass on this diff: - _header_date_iso is total: OverflowError/OSError from an extreme Date header fall back to 'nocomment' instead of failing the invocation. - _capture_block trims blanks in O(n) (no pop(0)) — removes a quadratic path on a crafted large blank run. - work_order_id is enforced digits-only on BOTH parse paths before it is used as a DynamoDB key, so prompt-injected AI output cannot forge '#' range-key segments or land on an arbitrary WO.
This commit is contained in:
parent
042a03b3a4
commit
0aef18029b
22 changed files with 278 additions and 41 deletions
|
|
@ -14,6 +14,7 @@ import os
|
|||
import re
|
||||
from datetime import datetime, timezone
|
||||
from email import policy
|
||||
from email.utils import parsedate_to_datetime
|
||||
|
||||
import boto3
|
||||
from ses_auth import authenticate_inbound_email
|
||||
|
|
@ -125,6 +126,10 @@ def extract_with_bedrock(email_data: dict) -> dict:
|
|||
{
|
||||
"anthropic_version": "bedrock-2023-05-31",
|
||||
"max_tokens": 1024,
|
||||
# Greedy decoding: retries of the same email should get the
|
||||
# same extraction back (advisory A1). Not a hard guarantee of
|
||||
# determinism, so model output still never enters a table key.
|
||||
"temperature": 0,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
|
|
@ -160,6 +165,9 @@ def emit_parse_metric(method, template_id, reason_code, work_order_id):
|
|||
would receive no data unless ["ParseMethod"] is emitted explicitly."""
|
||||
emf = {
|
||||
"_aws": {
|
||||
# EMF requires Timestamp (epoch ms); without it CloudWatch may not
|
||||
# extract the metric datapoint from the log event (advisory A2).
|
||||
"Timestamp": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||||
"CloudWatchMetrics": [
|
||||
{
|
||||
"Namespace": METRIC_NAMESPACE,
|
||||
|
|
@ -244,24 +252,60 @@ def save_work_order(parsed: dict, s3_key: str):
|
|||
logger.info(f"Saved work order {work_order_id}")
|
||||
|
||||
|
||||
def save_event(parsed: dict, s3_key: str, object_key: str):
|
||||
def _header_date_iso(header_date):
|
||||
"""Parse an RFC 2822 Date header into a UTC ISO string, or None.
|
||||
|
||||
Deterministic for a given raw email, unlike model output. Total: any
|
||||
unparseable/out-of-range header (incl. OverflowError from extreme years,
|
||||
which is NOT a ValueError) yields None, never an exception -- a crafted
|
||||
Date header must not be able to fail the invocation."""
|
||||
if not header_date:
|
||||
return None
|
||||
try:
|
||||
dt = parsedate_to_datetime(header_date)
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc).isoformat()
|
||||
except (TypeError, ValueError, OverflowError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def save_event(
|
||||
parsed: dict,
|
||||
s3_key: str,
|
||||
object_key: str,
|
||||
parse_method: str,
|
||||
header_date: str | None,
|
||||
):
|
||||
"""Save an event to the events table. Every email creates an event entry.
|
||||
|
||||
Issue #23: the range key (attr name stays ``comment_id``) must be unique per
|
||||
source email AND identical across Lambda async retries of the same S3 object.
|
||||
The uniqueness suffix is a deterministic hash of the S3 object key alone (not
|
||||
the s3:// URI, so it is stable across a bucket rename), and wall-clock now()
|
||||
is kept OUT of the key. When comment_time is absent we use the literal
|
||||
is kept OUT of the key. When no time is available we use the literal
|
||||
'nocomment' segment rather than now() -- otherwise each retry would produce a
|
||||
different key and duplicate the row. Two distinct emails on the same WO map
|
||||
to distinct object keys -> distinct rows."""
|
||||
to distinct object keys -> distinct rows.
|
||||
|
||||
Advisory A1: the time segment may come from parsed comment_time ONLY on the
|
||||
template path, where it is a pure function of the raw email. On the AI path
|
||||
the model can return a different comment_time on a retry (even at
|
||||
temperature 0 determinism is not guaranteed), which would fork the key and
|
||||
duplicate the row -- so there the segment derives from the email's Date
|
||||
header instead."""
|
||||
table = dynamodb.Table(COMMENTS_TABLE)
|
||||
|
||||
work_order_id = parsed["work_order_id"]
|
||||
email_type = parsed.get("email_type", "unknown")
|
||||
key_suffix = hashlib.sha256(object_key.encode("utf-8")).hexdigest()[:12]
|
||||
comment_time = parsed.get("comment_time")
|
||||
time_part = comment_time if comment_time else "nocomment"
|
||||
if parse_method == "template":
|
||||
time_part = comment_time if comment_time else "nocomment"
|
||||
else:
|
||||
time_part = _header_date_iso(header_date) or "nocomment"
|
||||
event_id = f"{work_order_id}#{time_part}#{key_suffix}"
|
||||
# created_at is display-only; may fall back to now() without affecting the key.
|
||||
display_time = comment_time or datetime.now(timezone.utc).isoformat()
|
||||
|
|
@ -318,15 +362,24 @@ def handler(event, context):
|
|||
|
||||
emit_parse_metric(method, template_id, reason, parsed.get("work_order_id"))
|
||||
|
||||
if not parsed.get("work_order_id"):
|
||||
logger.warning(f"No work order ID found in email, skipping: {key}")
|
||||
# work_order_id becomes a DynamoDB partition key and the leading, '#'-
|
||||
# delimited segment of the comment_id range key, so it must be digits
|
||||
# only. The template path already guarantees this via validate(); the
|
||||
# AI-fallback path returns raw model output, which a prompt-injected
|
||||
# email body could steer into a non-numeric or '#'-bearing value that
|
||||
# forges key segments or lands on an arbitrary WO. Enforce the same
|
||||
# contract on both paths and skip (fail closed) on a violation.
|
||||
work_order_id = parsed.get("work_order_id")
|
||||
if not work_order_id or not re.fullmatch(r"\d+", str(work_order_id)):
|
||||
logger.warning(f"Missing or non-numeric work order ID, skipping: {key}")
|
||||
continue
|
||||
|
||||
# Always upsert the work order with any new info
|
||||
save_work_order(parsed, s3_key)
|
||||
|
||||
# Save every email as an event for history tracking. The raw object key
|
||||
# (not the s3:// URI) drives the retry-idempotent comment_id suffix.
|
||||
save_event(parsed, s3_key, key)
|
||||
# (not the s3:// URI) drives the retry-idempotent comment_id suffix;
|
||||
# the parse method decides whether comment_time may enter the key (A1).
|
||||
save_event(parsed, s3_key, key, method, email_data.get("date"))
|
||||
|
||||
return {"statusCode": 200, "body": "OK"}
|
||||
|
|
|
|||
|
|
@ -162,21 +162,35 @@ def _inline_value(line, label):
|
|||
return line[idx + len(label) :].strip()
|
||||
|
||||
|
||||
def _capture_block(lines, start_index, labels):
|
||||
"""Collect lines after start_index until a blank line, a separator run, or
|
||||
a known label. Returns list of stripped non-consumed lines (may be empty)."""
|
||||
def _capture_block(lines, start_index, labels, stop_on_blank=True):
|
||||
"""Collect lines after start_index until a blank line (unless
|
||||
stop_on_blank=False), a separator run, or a known label. Returns a list of
|
||||
stripped non-consumed lines (may be empty).
|
||||
|
||||
stop_on_blank=False is for free-text blocks that legitimately contain blank
|
||||
lines (multi-paragraph comments, advisory A3): internal blanks are kept as
|
||||
empty strings, leading/trailing blanks are trimmed."""
|
||||
collected = []
|
||||
j = start_index + 1
|
||||
while j < len(lines):
|
||||
s = lines[j].strip()
|
||||
if s == "":
|
||||
break
|
||||
if stop_on_blank:
|
||||
break
|
||||
# Leading blanks are never collected, so a long blank run cannot
|
||||
# accumulate ahead of the O(1)-per-line trim below.
|
||||
if collected:
|
||||
collected.append("")
|
||||
j += 1
|
||||
continue
|
||||
if _SEPARATOR_RE.fullmatch(s) or _SEPARATOR_RE.search(s):
|
||||
break
|
||||
if _matches_any_label(s, labels):
|
||||
break
|
||||
collected.append(s)
|
||||
j += 1
|
||||
while collected and collected[-1] == "":
|
||||
collected.pop()
|
||||
return collected
|
||||
|
||||
|
||||
|
|
@ -193,14 +207,16 @@ def extract_update_plaintext(email_data):
|
|||
candidate["work_order_id"] = subject_wo
|
||||
# status stays None on a comment upsert -- never clobber a real wo_status.
|
||||
|
||||
# --- New Comment: block up to blank / separator / next label ---
|
||||
# --- New Comment: block up to separator / next label. Blank lines do NOT
|
||||
# end the block (multi-paragraph comments, advisory A3); the next label
|
||||
# (normally Creation Time(UTC):) is the terminator. ---
|
||||
nc_idx = _find_label_index(lines, "New Comment:")
|
||||
if nc_idx >= 0:
|
||||
pieces = []
|
||||
inline = _inline_value(lines[nc_idx], "New Comment:")
|
||||
if inline:
|
||||
pieces.append(inline)
|
||||
pieces.extend(_capture_block(lines, nc_idx, _T1_LABELS))
|
||||
pieces.extend(_capture_block(lines, nc_idx, _T1_LABELS, stop_on_blank=False))
|
||||
candidate["comment_text"] = "\n".join(pieces) if pieces else None
|
||||
|
||||
# --- Creation Time(UTC): -> ISO ---
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": null,
|
||||
"assigned_to": null,
|
||||
"building": "WGC5",
|
||||
"comment_text": "sit ad dolore et consectetur ipsum lore",
|
||||
"comment_text": "sit ad dolore et consectetur ipsum lore\n\nPurchase Order: # 2D-21737240 NTE 3000.00. If the NTE will be exceeded, a quote must be submitted beforehand.\n\nPoint Of Contact: kreutzic; Ph#: (316) 570-4327; (Sun-Weds) 08:00-18:00\nrigdongr; Ph#: (801) 573-2708; (Weds-Sat) 08:00-18:00\n\nPlease provide the best time for this to be scheduled.",
|
||||
"comment_time": "2026-06-22T20:21:39",
|
||||
"commenter": "ullamco",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "sit eiusmod veniam magna consectetur minim elit minim elit",
|
||||
"assigned_to": null,
|
||||
"building": "UOH9",
|
||||
"comment_text": "exercitatio",
|
||||
"comment_text": "exercitatio\n\nReceived a confirmation from the site lead that they are not aware of idle air purifier. Please cancel the requisition and close the ticket. Thanks!",
|
||||
"comment_time": "2026-06-05T18:46:43",
|
||||
"commenter": "aliqua a",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "incididunt ut amet tempor qu\nveniam ullamco sed incididu\nnostrud magna et",
|
||||
"assigned_to": null,
|
||||
"building": "SJS2",
|
||||
"comment_text": "aliqua exercitation aliqua exercitation laboris do sit a",
|
||||
"comment_text": "aliqua exercitation aliqua exercitation laboris do sit a\n\nPlease advise.",
|
||||
"comment_time": "2026-05-14T19:37:34",
|
||||
"commenter": "nostrud",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "amet ad adipiscing tempor sit eiusmod ut amet ipsum e\namet ipsum eiusmod\nnisi do incididunt qui",
|
||||
"assigned_to": null,
|
||||
"building": "WTX0",
|
||||
"comment_text": "ut labor",
|
||||
"comment_text": "ut labor\n\nPlease provide an update and assign back to AMOC-Procurement.",
|
||||
"comment_time": "2026-05-20T22:47:08",
|
||||
"commenter": "sit eius",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "minim dolore nostrud se\net nostrud laboris\ntempor ut labore n",
|
||||
"assigned_to": null,
|
||||
"building": "XEV9",
|
||||
"comment_text": "lorem lorem lorem enim dolore et consectetur minim elit minim ullamco nisi do sit ad adipiscing minim ul",
|
||||
"comment_text": "lorem lorem lorem enim dolore et consectetur minim elit minim ullamco nisi do sit ad adipiscing minim ul\n\nAll itemized quotes have to include the per line item breakdown for the labor section\n\nLabor Details\nNo. of techs\nNo. of hours\nRate\nTotal\n\nOnce that has been revised from Sea Haven we are good to go with this uplift. Thank you",
|
||||
"comment_time": "2026-05-12T13:29:16",
|
||||
"commenter": "dolor l",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "elit minim ullamco nisi nisi do incididunt quis laboris do sit ipsu\nipsum eiusmod veni\nad ullamco aliq",
|
||||
"assigned_to": null,
|
||||
"building": "WTX5",
|
||||
"comment_text": "magna exercitation laboris aliqua adipiscing tempor sit ad ullamco aliqua",
|
||||
"comment_text": "magna exercitation laboris aliqua adipiscing tempor sit ad ullamco aliqua\n\n[RMM Mike],\n\nPlease review the attached proposal and flip back to AMOC-Procurement upon decision. Thank you kindly!",
|
||||
"comment_time": "2026-07-12T22:01:50",
|
||||
"commenter": "dolor",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "et labore labore nostru\nconsectetur min\namet ad dolore c",
|
||||
"assigned_to": null,
|
||||
"building": "LRM3",
|
||||
"comment_text": "aliqua exercitation sed incididunt ut amet ad dolore",
|
||||
"comment_text": "aliqua exercitation sed incididunt ut amet ad dolore\n\nGaby & Dariel -- Please check Worklog for Coupa Requisition Link to approve pending PO change request. Thank you.",
|
||||
"comment_time": "2026-07-14T16:51:49",
|
||||
"commenter": "quis eli",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "nisi enim exercitation aliqua adipiscing minim dolore consectetur tempor",
|
||||
"assigned_to": null,
|
||||
"building": "SSC4",
|
||||
"comment_text": "quis laboris aliqua adipiscing tempor sit a",
|
||||
"comment_text": "quis laboris aliqua adipiscing tempor sit a\n\nPurchase Order: 2D-21264750 NTE for this Purchase Order is $3000 - If the NTE will be exceeded, a quote must be submitted beforehand\n\nPoint(s) Of Contact: Nathan Gilson 724-406-5435\nAdam Granitz 717-599-9703\n\nPlease provide the best time for this to be scheduled.",
|
||||
"comment_time": "2026-05-08T17:47:44",
|
||||
"commenter": "eiusmod",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "exercitation sed dolor lorem lorem do sit eiusmod sit\nipsum lorem lorem lor\nconsectetur",
|
||||
"assigned_to": null,
|
||||
"building": "WEE1",
|
||||
"comment_text": "ad ullamco nisi nisi enim exercit",
|
||||
"comment_text": "ad ullamco nisi nisi enim exercit\n\nWEE1 - Please confirm once the tech arrives on site for expected ETA above. Following tech\u2019s departure, please update this ticket to confirm if the tech was able to complete the work the same day, or if additional repairs are needed so that we may follow up accordingly. Thank you.",
|
||||
"comment_time": "2026-06-29T03:44:30",
|
||||
"commenter": "eiusmod",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "nisi nisi enim dolore et consectetur minim ullamco ni",
|
||||
"assigned_to": null,
|
||||
"building": "USF3",
|
||||
"comment_text": "magn",
|
||||
"comment_text": "magn\n\nPlease confirm work has been completed",
|
||||
"comment_time": "2026-06-15T22:35:33",
|
||||
"commenter": "veniam u",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "adipiscing minim ullamco aliqua adipiscing minim dolore consectetur",
|
||||
"assigned_to": null,
|
||||
"building": "HNY6",
|
||||
"comment_text": "minim",
|
||||
"comment_text": "minim\n\nPlease confirm once the tech arrives on site 5/26-5/27.\n\nAs well as if the tech is able to complete the work 5/26-5/27, or if additional repairs are needed, thank you.",
|
||||
"comment_time": "2026-05-19T20:08:52",
|
||||
"commenter": "amet ad",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "veniam elit dolor lorem enim adipiscing dolor lorem lorem enim ex",
|
||||
"assigned_to": null,
|
||||
"building": "HBD0",
|
||||
"comment_text": "minim elit minim dolore nostrud laboris aliqua exercitation laboris aliqua et consectetur te",
|
||||
"comment_text": "minim elit minim dolore nostrud laboris aliqua exercitation laboris aliqua et consectetur te\n\nOnce the ETA is confirmed AMOC will update accordingly.\n\nPurchase Order: 2D-21279276 NTE for this Purchase Order is $ 16,780.00 USD\n\nPoint(s) Of Contact: Brianna Gordon- 203-233-5089- Site Lead- M-F 7am-3PM\nSelena lopjsele- slack - Area Manager- M-F 7am-3PM\nEd Wallace - RMM - 850 902 6855\n\nPlease provide the best time for this to be scheduled.",
|
||||
"comment_time": "2026-07-01T19:52:41",
|
||||
"commenter": "elit mi",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "sit eiusmod veniam magn\nquis elit minim\nut labore nostrud sed dol",
|
||||
"assigned_to": null,
|
||||
"building": "HDC3",
|
||||
"comment_text": "amet tempor quis magna et consectetur tempor ut amet tempor quis elit minim dolore nostrud m",
|
||||
"comment_text": "amet tempor quis magna et consectetur tempor ut amet tempor quis elit minim dolore nostrud m\n\nOnce the ETA is confirmed AMOC will update accordingly.\n\nPurchase Order: #2D-20867218 NTE for this Purchase Order is 6,334.30\n\nPoint(s) Of Contact:\nBase Building Management S.B.E Eric Webber 717-450-0941 and S.B.E. Robert Russell 484-513-7282\n\nWork may be done anytime Monday - Friday 7:30am - 4:00pm.",
|
||||
"comment_time": "2026-04-29T15:54:58",
|
||||
"commenter": "dolor l",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": null,
|
||||
"assigned_to": null,
|
||||
"building": "WNG3",
|
||||
"comment_text": "nisi do sit ad adipiscing tempor quis laboris nisi nisi do sit ad ullamco aliqua exercitation lab",
|
||||
"comment_text": "nisi do sit ad adipiscing tempor quis laboris nisi nisi do sit ad ullamco aliqua exercitation lab\n\nDave,\n\nPlease review attached proposal and flip back to AMOC-Procurement upon decision.\n\nThank you",
|
||||
"comment_time": "2026-04-26T17:01:06",
|
||||
"commenter": "tempor",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "amet ipsum enim exercitation laboris nisi nisi do sit eiusmo\nlaboris aliqua exe\nipsum enim adip",
|
||||
"assigned_to": null,
|
||||
"building": "HMO1",
|
||||
"comment_text": "tempo",
|
||||
"comment_text": "tempo\n\nPlease confirm once the tech arrives on site.\n\nAs well as if the tech is able to complete the work the same day, or if additional repairs are needed, thank you.",
|
||||
"comment_time": "2025-09-25T09:13:39",
|
||||
"commenter": "do venia",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": "et consectetur minim dolore et nostrud sed incididunt\nconsectetur tempor\nlaboris aliqua adipisc",
|
||||
"assigned_to": null,
|
||||
"building": "WTX0",
|
||||
"comment_text": "nisi do incididunt amet ipsum lorem enim d",
|
||||
"comment_text": "nisi do incididunt amet ipsum lorem enim d\n\nPurchase Order: 2D-21144808 NTE for this Purchase Order is $2000 - If the NTE will be exceeded, a quote must be submitted beforehand\n\nPoint(s) Of Contact: Andy Romero 214 - 587-3543\nKim Burris 701-740-9845\nMax Martinez 830-335-7757\nMelissa Medina 361-455-4369\n\nPlease provide the best time for this to be scheduled.",
|
||||
"comment_time": "2026-04-30T16:22:51",
|
||||
"commenter": "sit eius",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"address": null,
|
||||
"assigned_to": null,
|
||||
"building": "WNG3",
|
||||
"comment_text": "incididunt",
|
||||
"comment_text": "incididunt\n\nPlease confirm tech(s) arrival 5/1-5/3 and whether work is completed for verification of fix; also if any additional repairs are required.\n\nThank you!",
|
||||
"comment_time": "2026-05-07T08:18:09",
|
||||
"commenter": "ad adi",
|
||||
"date_reported": null,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ def test_fallback_path_invokes_bedrock(fake_dynamo, metric_spy, monkeypatch):
|
|||
body = json.loads(call["body"])
|
||||
assert body["anthropic_version"] == "bedrock-2023-05-31"
|
||||
assert body["max_tokens"] == 1024
|
||||
# Advisory A1: greedy decoding so retries reproduce the same extraction.
|
||||
assert body["temperature"] == 0
|
||||
method, _template_id, _reason, wo = metric_spy[0]
|
||||
assert method == "ai_fallback"
|
||||
assert wo == "77777777777"
|
||||
|
|
@ -134,6 +136,22 @@ def test_fallback_path_invokes_bedrock(fake_dynamo, metric_spy, monkeypatch):
|
|||
assert wo_table.updates, "expected a work-order upsert from the AI path"
|
||||
|
||||
|
||||
def test_ai_path_non_numeric_wo_id_is_skipped(fake_dynamo, metric_spy, monkeypatch):
|
||||
"""A prompt-injected model result whose work_order_id is not digits-only
|
||||
must fail closed before any DynamoDB write (WO-INJ-01/02 guard)."""
|
||||
injected = dict(AI_17_KEY, work_order_id="123#spoofed#deadbeef")
|
||||
monkeypatch.setattr(handler, "s3", FakeS3(_raw("ai-fallback", "unknown-subject")))
|
||||
monkeypatch.setattr(handler, "bedrock", FakeBedrock(injected))
|
||||
monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True)
|
||||
|
||||
handler.handler(_event(), None)
|
||||
|
||||
wo_table = fake_dynamo.tables.get(handler.WORK_ORDERS_TABLE)
|
||||
comments = fake_dynamo.tables.get(handler.COMMENTS_TABLE)
|
||||
assert wo_table is None or not wo_table.updates
|
||||
assert comments is None or not comments.puts
|
||||
|
||||
|
||||
def test_extract_with_bedrock_returns_full_contract(monkeypatch):
|
||||
fake_bedrock = FakeBedrock(AI_17_KEY)
|
||||
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
|
||||
|
|
@ -183,3 +201,7 @@ def test_emit_parse_metric_writes_emf(capsys):
|
|||
assert (
|
||||
emf["_aws"]["CloudWatchMetrics"][0]["Namespace"] == "Seahaven/WorkorderIngest"
|
||||
)
|
||||
# Advisory A2: EMF requires _aws.Timestamp (epoch ms) for the datapoint to
|
||||
# be extracted from the log event.
|
||||
assert isinstance(emf["_aws"]["Timestamp"], int)
|
||||
assert emf["_aws"]["Timestamp"] > 1_500_000_000_000 # ms, not seconds
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
"""Issue #23: comment_id (WorkOrderComments range key) must be unique per source
|
||||
email AND byte-identical across Lambda async retries of the same S3 object, with
|
||||
wall-clock now() kept OUT of the key."""
|
||||
wall-clock now() kept OUT of the key.
|
||||
|
||||
Advisory A1: on the ai_fallback path the parsed comment_time is model output and
|
||||
not retry-stable, so it must never enter the key -- the time segment derives
|
||||
from the (deterministic) email Date header instead.
|
||||
"""
|
||||
|
||||
import handler
|
||||
|
||||
DATE_HEADER = "Mon, 27 Apr 2026 23:57:49 +0000 (UTC)"
|
||||
|
||||
|
||||
def _parsed(wo="11144580730", comment_time="2026-04-27T23:51:48"):
|
||||
return {
|
||||
|
|
@ -21,8 +28,12 @@ def _comment_ids(table):
|
|||
|
||||
def test_same_object_key_retry_is_idempotent(fake_dynamo):
|
||||
parsed = _parsed()
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
|
||||
)
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
# Two put_item calls with an identical key -> one logical row (overwrite).
|
||||
|
|
@ -32,8 +43,12 @@ def test_same_object_key_retry_is_idempotent(fake_dynamo):
|
|||
|
||||
def test_distinct_emails_same_wo_and_time_are_distinct_rows(fake_dynamo):
|
||||
parsed = _parsed()
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a")
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-b", "inbound/obj-b")
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-a", "inbound/obj-a", "template", DATE_HEADER
|
||||
)
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-b", "inbound/obj-b", "template", DATE_HEADER
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
assert ids[0] != ids[1]
|
||||
|
|
@ -42,8 +57,12 @@ def test_distinct_emails_same_wo_and_time_are_distinct_rows(fake_dynamo):
|
|||
|
||||
def test_absent_comment_time_is_stable_across_retries(fake_dynamo):
|
||||
parsed = _parsed(comment_time=None)
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c")
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c")
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c", "template", DATE_HEADER
|
||||
)
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-c", "inbound/obj-c", "template", DATE_HEADER
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
assert ids[0] == ids[1] # 'nocomment' segment, not now()
|
||||
|
|
@ -67,9 +86,13 @@ def test_event_id_independent_of_now(fake_dynamo, monkeypatch):
|
|||
)
|
||||
|
||||
monkeypatch.setattr(handler, "datetime", FrozenDT)
|
||||
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
|
||||
handler.save_event(
|
||||
parsed, "s3://b/inbound/obj-d", "inbound/obj-d", "template", DATE_HEADER
|
||||
)
|
||||
FrozenDT.offset = 999999 # simulate a later retry
|
||||
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
|
||||
handler.save_event(
|
||||
parsed, "s3://b/inbound/obj-d", "inbound/obj-d", "template", DATE_HEADER
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
assert ids[0] == ids[1]
|
||||
|
|
@ -79,10 +102,70 @@ def test_event_id_independent_of_now(fake_dynamo, monkeypatch):
|
|||
|
||||
def test_comment_id_format_shape(fake_dynamo):
|
||||
parsed = _parsed()
|
||||
handler.save_event(parsed, "s3://bucket/inbound/obj-e", "inbound/obj-e")
|
||||
handler.save_event(
|
||||
parsed, "s3://bucket/inbound/obj-e", "inbound/obj-e", "template", DATE_HEADER
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
cid = _comment_ids(table)[0]
|
||||
wo, time_part, suffix = cid.split("#")
|
||||
assert wo == "11144580730"
|
||||
assert time_part == "2026-04-27T23:51:48"
|
||||
assert len(suffix) == 12 and all(c in "0123456789abcdef" for c in suffix)
|
||||
|
||||
|
||||
# --- Advisory A1: ai_fallback path ---
|
||||
|
||||
|
||||
def test_ai_path_retry_with_drifted_comment_time_is_idempotent(fake_dynamo):
|
||||
"""A retry where the model returns a DIFFERENT comment_time must still map
|
||||
to the same range key (the model output never enters the key)."""
|
||||
handler.save_event(
|
||||
_parsed(comment_time="2026-04-27T23:51:48"),
|
||||
"s3://bucket/inbound/obj-f",
|
||||
"inbound/obj-f",
|
||||
"ai_fallback",
|
||||
DATE_HEADER,
|
||||
)
|
||||
handler.save_event(
|
||||
_parsed(comment_time="2026-04-27T23:52:03"), # model drifted on retry
|
||||
"s3://bucket/inbound/obj-f",
|
||||
"inbound/obj-f",
|
||||
"ai_fallback",
|
||||
DATE_HEADER,
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
assert ids[0] == ids[1]
|
||||
assert len(table.store) == 1
|
||||
|
||||
|
||||
def test_ai_path_key_uses_date_header_not_model_output(fake_dynamo):
|
||||
handler.save_event(
|
||||
_parsed(),
|
||||
"s3://bucket/inbound/obj-g",
|
||||
"inbound/obj-g",
|
||||
"ai_fallback",
|
||||
DATE_HEADER,
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
cid = _comment_ids(table)[0]
|
||||
_, time_part, _ = cid.split("#")
|
||||
assert time_part == "2026-04-27T23:57:49+00:00" # Date header, UTC ISO
|
||||
assert "23:51:48" not in cid # parsed comment_time kept out of the key
|
||||
|
||||
|
||||
def test_ai_path_missing_date_header_falls_back_to_nocomment(fake_dynamo):
|
||||
# 'Fri, 31 Dec 9999' parses but overflows on UTC conversion (OverflowError,
|
||||
# not ValueError) -- _header_date_iso must swallow it, never raise.
|
||||
for header in (None, "", "not a date", "Fri, 31 Dec 9999 23:59:59 -1400"):
|
||||
handler.save_event(
|
||||
_parsed(),
|
||||
"s3://bucket/inbound/obj-h",
|
||||
"inbound/obj-h",
|
||||
"ai_fallback",
|
||||
header,
|
||||
)
|
||||
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
|
||||
ids = _comment_ids(table)
|
||||
assert all("#nocomment#" in cid for cid in ids)
|
||||
assert len(set(ids)) == 1 # stable regardless of header garbage
|
||||
|
|
|
|||
|
|
@ -81,6 +81,69 @@ def test_extractor_exception_fails_closed(monkeypatch):
|
|||
assert reason == "extractor_raised"
|
||||
|
||||
|
||||
# --- Advisory A3: multi-paragraph comments must not be truncated ---
|
||||
|
||||
|
||||
def test_multi_paragraph_comment_is_captured_in_full():
|
||||
"""A blank line inside the New Comment block is a paragraph break, not the
|
||||
end of the comment -- the block ends at the next label/separator."""
|
||||
email_data = {
|
||||
"subject": "AMAZON UPDATE WO DETAILS 11144580730",
|
||||
"sender": "noreply@hxgnsmartcloud.com",
|
||||
"to": "apm@int.seahaven.com",
|
||||
"cc": "",
|
||||
"date": "Mon, 27 Apr 2026 23:57:49 +0000 (UTC)",
|
||||
"body": (
|
||||
"New Comment:\n"
|
||||
"first paragraph line one\n"
|
||||
"first paragraph line two\n"
|
||||
"\n"
|
||||
"second paragraph after a blank line\n"
|
||||
"\n"
|
||||
"Creation Time(UTC): 2026-04-27 23:51:48\n"
|
||||
"Submitted By: jdoe\n"
|
||||
"\n"
|
||||
"_________________\n"
|
||||
"\n"
|
||||
"Work Order: 11144580730 - fix dock door\n"
|
||||
"\n"
|
||||
"Building: WCO0.\n"
|
||||
"\n"
|
||||
"\n"
|
||||
"Please review it in Amazon Portal\n"
|
||||
),
|
||||
}
|
||||
parsed, method, template_id, reason = try_deterministic_parse(email_data)
|
||||
assert method == "template"
|
||||
assert template_id == "update_plaintext"
|
||||
assert reason == "ok"
|
||||
assert parsed["comment_text"] == (
|
||||
"first paragraph line one\n"
|
||||
"first paragraph line two\n"
|
||||
"\n"
|
||||
"second paragraph after a blank line"
|
||||
)
|
||||
# Fixed-position blocks still stop at blank lines: the portal footer after
|
||||
# Building must not bleed into the address.
|
||||
assert parsed["address"] is None
|
||||
assert parsed["comment_time"] == "2026-04-27T23:51:48"
|
||||
|
||||
|
||||
def test_huge_blank_run_in_comment_parses_in_linear_time():
|
||||
"""A New Comment block padded with a massive blank-line run must not go
|
||||
quadratic in the leading/trailing-blank trim (CWE-407 guard)."""
|
||||
import time
|
||||
|
||||
from template_parser import _T1_LABELS, _capture_block
|
||||
|
||||
lines = ["New Comment:"] + [""] * 500_000 + ["x"] + [""] * 500_000
|
||||
start = time.monotonic()
|
||||
out = _capture_block(lines, 0, _T1_LABELS, stop_on_blank=False)
|
||||
elapsed = time.monotonic() - start
|
||||
assert out == ["x"]
|
||||
assert elapsed < 2.0 # quadratic trim took ~20s+ at this size
|
||||
|
||||
|
||||
# --- Adversarial: prompt injection / near-miss subjects ---
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue