Add offline WO parser test suite

Cover the deterministic parser with golden-file tests over 55 real
scrubbed .eml fixtures (both comment sub-shapes, username Submitted-By,
address present/absent, br+CRLF assign addresses), fail-closed
validation-gate rules, adversarial and prompt-injection cases that must
route to ai_fallback or parse without corrupting other fields, the issue
#23 comment_id idempotency invariants, and the Bedrock-fallback dispatch
plus EMF-metric emission with a mocked invoke_model.

Extend pytest.ini testpaths to discover the co-located suite, and update
tests/conftest.load_handler to put a handler's own directory on sys.path
so the WO handler's new `from template_parser import ...` resolves under
the existing shared handler tests. Point test_local.py at the new
template-first + Bedrock flow.

Refs: #23
This commit is contained in:
Adam Moussa 2026-07-15 20:11:04 -04:00
parent a6d7e3468d
commit 89bf47c1bb
No known key found for this signature in database
64 changed files with 1625 additions and 11 deletions

View file

@ -0,0 +1,75 @@
"""Offline test helpers for the work-order email processor.
Uniquely named (not ``conftest``) so ``from _wo_parser_support import ...`` never
collides with the repo's top-level ``tests/conftest.py`` when both test roots are
collected in one pytest run.
"""
import glob
import json
import os
import sys
# Make handler.py / template_parser.py importable and give boto3 a region so the
# module-level clients construct without a NoRegionError under CI.
_HERE = os.path.dirname(__file__)
_MODULE_DIR = os.path.abspath(os.path.join(_HERE, ".."))
if _MODULE_DIR not in sys.path:
sys.path.insert(0, _MODULE_DIR)
os.environ.setdefault("AWS_DEFAULT_REGION", "us-east-1")
FIXTURES = os.path.join(_HERE, "fixtures")
def _stems(subdir):
return sorted(
os.path.splitext(os.path.basename(p))[0]
for p in glob.glob(os.path.join(FIXTURES, subdir, "*.eml"))
)
UPDATE_STEMS = _stems("update-plaintext")
ASSIGN_STEMS = _stems("assign-html")
AI_FALLBACK_STEMS = _stems("ai-fallback")
ADVERSARIAL_STEMS = _stems("adversarial")
def load_email(subdir, stem):
"""Parse a fixture .eml into the email_data dict the parser consumes."""
from handler import parse_raw_email
path = os.path.join(FIXTURES, subdir, f"{stem}.eml")
with open(path, "rb") as fh:
return parse_raw_email(fh.read())
def load_golden(stem):
with open(os.path.join(FIXTURES, "expected", f"{stem}.json")) as fh:
return json.load(fh)
class FakeTable:
"""Records put_item / update_item calls for assertions."""
def __init__(self, name):
self.name = name
self.puts = []
self.updates = []
# Simulate a single-row-per-key store to detect overwrite vs distinct.
self.store = {}
def put_item(self, Item=None, **kwargs): # noqa: N803 (boto3 kwarg name)
self.puts.append(Item)
key = (Item.get("work_order_id"), Item.get("comment_id"))
self.store[key] = Item
def update_item(self, **kwargs):
self.updates.append(kwargs)
class FakeDynamoResource:
def __init__(self):
self.tables = {}
def Table(self, name): # noqa: N802 (boto3 method name)
return self.tables.setdefault(name, FakeTable(name))

View file

@ -0,0 +1,19 @@
"""Pytest fixtures for the work-order email-processor suite.
Path/env setup and the reusable helpers live in ``_wo_parser_support`` (a
uniquely named module) so the test files never do ``from conftest import``,
which would be ambiguous against the repo's top-level ``tests/conftest.py``.
"""
import pytest
from _wo_parser_support import FakeDynamoResource
@pytest.fixture
def fake_dynamo(monkeypatch):
import handler
fake = FakeDynamoResource()
monkeypatch.setattr(handler, "dynamodb", fake)
return fake

View file

@ -0,0 +1,18 @@
{
"address": "do veniam ullamco aliqua exerc\ndo incididunt ame\ndo sit ad ullamc\nconsectetur m\nconsectetur tempor sit e",
"assigned_to": null,
"building": "HTX1",
"comment_text": null,
"comment_time": null,
"commenter": null,
"date_reported": "2026-04-14T16:20:00",
"description": "ad ullamco sed dolor eiusmod ut",
"due_date": null,
"email_type": "new_work_order",
"priority": null,
"scheduled_start": "2026-04-14",
"severity": "5",
"site_code": "HTX1",
"status": "assigned",
"work_order_id": "18073740956"
}

View file

@ -0,0 +1,18 @@
{
"address": "ut quis laboris aliqua et consectetur ipnostr\nminim ullamco nisi do i\nquis magna et",
"assigned_to": null,
"building": "HRD8",
"comment_text": null,
"comment_time": null,
"commenter": null,
"date_reported": "2026-06-23T11:05:00",
"description": "[HRD8] Short Description",
"due_date": null,
"email_type": "new_work_order",
"priority": null,
"scheduled_start": "2026-06-23",
"severity": "5",
"site_code": "HRD8",
"status": "assigned",
"work_order_id": "17187601507"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WFG1",
"comment_text": null,
"comment_time": null,
"commenter": null,
"date_reported": "2026-05-22T07:51:00",
"description": "eiusmod veniam magna et consecteenim dolore nostrud laboris do veniam ma",
"due_date": null,
"email_type": "new_work_order",
"priority": null,
"scheduled_start": "2026-07-31",
"severity": "5",
"site_code": "WFG1",
"status": "assigned",
"work_order_id": "18644082645"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WUT1",
"comment_text": null,
"comment_time": null,
"commenter": null,
"date_reported": "2026-06-26T12:47:00",
"description": "[WUT1] Power Outage",
"due_date": null,
"email_type": "new_work_order",
"priority": null,
"scheduled_start": "2026-06-26",
"severity": "2",
"site_code": "WUT1",
"status": "assigned",
"work_order_id": "17194032177"
}

View file

@ -0,0 +1,18 @@
{
"address": "ipsum enim adipsit ipsum lorem enim adipiscing dolor eiusmod ve",
"assigned_to": null,
"building": "CHI7",
"comment_text": null,
"comment_time": null,
"commenter": null,
"date_reported": "2026-04-10T15:42:00",
"description": "eiusmod sit eiusmod veniam elit nostrud laboris",
"due_date": null,
"email_type": "new_work_order",
"priority": null,
"scheduled_start": "2026-04-10",
"severity": "4",
"site_code": "CHI7",
"status": "assigned",
"work_order_id": "15481007953"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WCO0",
"comment_text": "ut amet ipsum enim exercitation sed dolor eiusmod sit eiusmod veniam elit ad ullamco aliqua et nostrud magna exercitation sed veniam elit dolor ad deiusmod sit ad adipiscing minim dolore consectetur tempor sit ipsum eiusmodo incididunt amet ad adipiscing tempor u",
"comment_time": "2026-04-27T23:51:48",
"commenter": "sit ad adipiscing dolor eiusmod sit ad ullamco nisi nisi enitempor sit ad adipiscing tem",
"date_reported": null,
"description": "consectetur tempor sit ipsum enim adipiscing doelit dolor eiusmod",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WCO0",
"status": null,
"work_order_id": "11144580730"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WIO9",
"comment_text": "incididunt amet tempor",
"comment_time": "2026-06-18T19:06:12",
"commenter": "lorem lo",
"date_reported": null,
"description": "adipiscing dolor eiusmod sit ipsum lorem lo",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WIO9",
"status": null,
"work_order_id": "17597770281"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WGC5",
"comment_text": "sit ad dolore et consectetur ipsum lore",
"comment_time": "2026-06-22T20:21:39",
"commenter": "ullamco",
"date_reported": null,
"description": "sit eiusmod ven",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WGC5",
"status": null,
"work_order_id": "11584193048"
}

View file

@ -0,0 +1,18 @@
{
"address": "sit eiusmod veniam magna consectetur minim elit minim elit",
"assigned_to": null,
"building": "UOH9",
"comment_text": "exercitatio",
"comment_time": "2026-06-05T18:46:43",
"commenter": "aliqua a",
"date_reported": null,
"description": "sit ipsum eiusmod sit ipsum enim exercitation sed incidid",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "UOH9",
"status": null,
"work_order_id": "15579542821"
}

View file

@ -0,0 +1,18 @@
{
"address": "veniam elit dolor ad adipiscing minim elit dolor eiusmod ut labore labore",
"assigned_to": null,
"building": "SWI4",
"comment_text": "dolore consectetur minim\neiusmod ut\nsit ad dolore consectet\nsit eiusmod sit ad adipiscing tempor sit eiusmod ut amet ipsum lorem enim adipiscing tempor ut quis elit minim ullamco aliqua exercitation sed dolor eiusmod ut quis laboris aliqu\nquis magna et nostrud laboris nisi enim adipiscing dolor lorem do v\nnisi enim dolore et nostrud sed incididunt amet tempor sit ipsum lorem lorem do sit\nlorem do veniam magna consectetur minim ullamco nisi enim adipiscing tempor quis magna consectetur minim elit dolor lorem",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "ad adipiscing dolor eiusmod sit ad adipiscing minim ull",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SWI4",
"status": null,
"work_order_id": "14882673235"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-05-14T19:37:34",
"commenter": "nostrud",
"date_reported": null,
"description": "amet ipsum lorem do sit ad ullamco sed inc",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SJS2",
"status": null,
"work_order_id": "15738557028"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-05-20T22:47:08",
"commenter": "sit eius",
"date_reported": null,
"description": "ut quis magna exercitation laboris do veniam ullamco aliqua et labore nostrud la",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WTX0",
"status": null,
"work_order_id": "14921957970"
}

View file

@ -0,0 +1,18 @@
{
"address": "ullamco nisi enim adipiscing\nadipiscing tempor ut amet ips\ndo sit ad ad",
"assigned_to": null,
"building": "LHO8",
"comment_text": "elit minim dolore et consectetur tempor quis magna et labore nostrud laboris nisi enim dolore consectetur tempor quis elit dolor lorem enim exercitation aliqua et consectetur ipsum lorem do sit eiusmod veniam magna consectetur minim dolore et nostrud se",
"comment_time": "2026-06-02T01:21:28",
"commenter": "ut quis elit dolor ad dolore et nostrud sed incididunt quis magna consectetur minim ullam",
"date_reported": null,
"description": "amet ipsum eiusmod veniam magna consectetur ipsum eiusmod",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "LHO8",
"status": null,
"work_order_id": "14486146471"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-05-12T13:29:16",
"commenter": "dolor l",
"date_reported": null,
"description": "aliqua adipiscing dolor lore",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "XEV9",
"status": null,
"work_order_id": "13996242160"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "HLO1",
"comment_text": "exercitation sed veniam ullamco aliqua exercitation laboris aliqua ad",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "lorem do veniam ullamco aliqua et consectetur minim elit incididunt amet ad ulla",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HLO1",
"status": null,
"work_order_id": "13401256362"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WML1",
"comment_text": "amet ipsum enim dolore nostrud magna exercitation aliqua a",
"comment_time": "2026-05-13T17:49:37",
"commenter": "nostru",
"date_reported": null,
"description": "nostrud laboris do incididunt quis magna consectetur ips",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WML1",
"status": null,
"work_order_id": "18403645991"
}

View file

@ -0,0 +1,18 @@
{
"address": "aliqua exercitation laboris\namet ipsum enim adipiscing\nut quis elit dol",
"assigned_to": null,
"building": "SJS2",
"comment_text": "aliqua et nostrud magna exercitation sed ven",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "dolor ad dolore consectetur ipsum eiusmod veniam elit incididunt quis",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SJS2",
"status": null,
"work_order_id": "17206481612"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-07-12T22:01:50",
"commenter": "dolor",
"date_reported": null,
"description": "eiusmod ut labore labore labore labore amet ipsum lorem do incididunt amet ipsum",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WTX5",
"status": null,
"work_order_id": "12701821079"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-07-14T16:51:49",
"commenter": "quis eli",
"date_reported": null,
"description": "laboris nisi nisi enim adipisc",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "LRM3",
"status": null,
"work_order_id": "12133395677"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WNM0",
"comment_text": "eiusmod sit ipsum",
"comment_time": "2026-05-29T17:40:21",
"commenter": "enim dol",
"date_reported": null,
"description": "lorem enim adipiscing minim ull",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WNM0",
"status": null,
"work_order_id": "11171293003"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WND4",
"comment_text": "nisi enim dolore nostrud laboris nisi\nnisi do veniam magna exercitation aliqua et nostrud laboris",
"comment_time": "2026-05-18T17:55:06",
"commenter": "nostrud",
"date_reported": null,
"description": "amet tempor sit eiusmod sit ipsum enim adipiscing minim dolore et",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WND4",
"status": null,
"work_order_id": "13344130918"
}

View file

@ -0,0 +1,18 @@
{
"address": "lorem do sit ad ullam\nipsum eiusmod ve\nmin",
"assigned_to": null,
"building": "DIA5",
"comment_text": "consectetur minim dolore et nostrud magna consectetur ipsum eiusmod veniamet nostrud magna consectetur tempor sit ipsum eiusmod ut amet ipsum lorem ullamco sed dolor lorem lorem enim adipiscing tempor quis elit dolor loremmagna consectetur minim dolore consectetur ipsum lorem lorem enim exercitatempor ut quis elit incididunt ut amet ipsum enim exercitation aliqua adipquis laboris aliqua et nostrud magna et labore nostrud magna consectetur tma\net consectetur ipsum lorem lorem enim exercitation laboris nisi nisi nisi do sit eius",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "et nostrud sed dolor eiusmod sit ips",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "DIA5",
"status": null,
"work_order_id": "11742436804"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-05-08T17:47:44",
"commenter": "eiusmod",
"date_reported": null,
"description": "exercitation sed",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SSC4",
"status": null,
"work_order_id": "10239975920"
}

View file

@ -0,0 +1,18 @@
{
"address": "quis laboris do veniam ullamco aliqua adipiscing mini\nullamco aliqua adi\ndolor ad adipis",
"assigned_to": null,
"building": "WMO7",
"comment_text": "enim adipiscing dolor ad adipiscing tempor sit ipsum enim adipiscing minim dolor",
"comment_time": "2026-04-17T15:58:28",
"commenter": "magna ex",
"date_reported": null,
"description": "adipiscing tempor sit ipsum",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WMO7",
"status": null,
"work_order_id": "15871272648"
}

View file

@ -0,0 +1,18 @@
{
"address": "elit minim ullamco nisi enim exercitation aliqua exercitati",
"assigned_to": null,
"building": "UPA0",
"comment_text": "nostrud magna et consectetur tempor sit ad ullamco aliqua et nostrud laboris do sit e",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "labore amet ipsum lore",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "UPA0",
"status": null,
"work_order_id": "14192386581"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WLN0",
"comment_text": "dolor eiusmod ut quis magna et nostrud laboris do sit eiusmod ut q",
"comment_time": "2026-07-15T22:55:25",
"commenter": "consecte",
"date_reported": null,
"description": "tempor quis laboris do veniam magna et l",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WLN0",
"status": null,
"work_order_id": "13332034778"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WMT3",
"comment_text": "dolore consectetur minim\nenim exerc\nipsum enim exercitation\nquis elit dolor lorem enim dolore et nostrud sed incididunt ut amet tempor quis magna et consectetur minim ullamco sed dolor eiusmod sit ad adipiscing\neiusmod ut labore amet ad ullamco nisi enim adipiscing\naliqua et labore amet ad ullamco sed incididunt ut quis elit dolor ad ullamc\nnostrud sed incididunt ut labore labore nostrud laboris aliqua et consectetur minim dolore consectetur minim dolore nostr",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "exercitation aliqua et nost",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WMT3",
"status": null,
"work_order_id": "14207299202"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-06-29T03:44:30",
"commenter": "eiusmod",
"date_reported": null,
"description": "do incididunt amet ad dolore consectetur m",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WEE1",
"status": null,
"work_order_id": "11649444950"
}

View file

@ -0,0 +1,18 @@
{
"address": "elit dolor lo\nlabore labore lab\ndolore nostrud ma",
"assigned_to": null,
"building": "USD6",
"comment_text": "et labore amet ad ullamco aliqua exercitation labori",
"comment_time": "2026-05-11T02:23:27",
"commenter": "lorem e",
"date_reported": null,
"description": "sit eiusmod ut quis elit dolor ei",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "USD6",
"status": null,
"work_order_id": "12507150701"
}

View file

@ -0,0 +1,18 @@
{
"address": "nisi nisi enim dolore et consectetur minim ullamco ni",
"assigned_to": null,
"building": "USF3",
"comment_text": "magn",
"comment_time": "2026-06-15T22:35:33",
"commenter": "veniam u",
"date_reported": null,
"description": "magna et nostrud magna exercitation aliqua exercitation aliqua exercitat",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "USF3",
"status": null,
"work_order_id": "15849582862"
}

View file

@ -0,0 +1,18 @@
{
"address": "adipiscing minim ullamco aliqua adipiscing minim dolore consectetur",
"assigned_to": null,
"building": "HNY6",
"comment_text": "minim",
"comment_time": "2026-05-19T20:08:52",
"commenter": "amet ad",
"date_reported": null,
"description": "ullamco sed incididunt amet ad ullamco aliq",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HNY6",
"status": null,
"work_order_id": "13456406719"
}

View file

@ -0,0 +1,18 @@
{
"address": "labore amet ad dolore et nostrud sed incididunt amet\nadipiscing min\nut labore nostru",
"assigned_to": null,
"building": "WTX7",
"comment_text": "aliqua et consectetur minim elit dolor ad ullamco nisi nisi nisi enim dolore",
"comment_time": "2026-04-29T21:09:55",
"commenter": "exercita",
"date_reported": null,
"description": "do veniam ullamco sed veniam",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WTX7",
"status": null,
"work_order_id": "14072812497"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-07-01T19:52:41",
"commenter": "elit mi",
"date_reported": null,
"description": "eiusmod ut quis elit incididunt amet ipsu",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HBD0",
"status": null,
"work_order_id": "14101235072"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-04-29T15:54:58",
"commenter": "dolor l",
"date_reported": null,
"description": "nisi enim dolore nostrud labori",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HDC3",
"status": null,
"work_order_id": "16912944806"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-04-26T17:01:06",
"commenter": "tempor",
"date_reported": null,
"description": "aliqua exercitation labori",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WNG3",
"status": null,
"work_order_id": "19581549387"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WLN0",
"comment_text": "do incididunt amet ipsum enim dolore et nostrud sed dolor lorem do s",
"comment_time": "2026-07-07T14:07:17",
"commenter": "eiusmod",
"date_reported": null,
"description": "exercitation se",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WLN0",
"status": null,
"work_order_id": "11911449322"
}

View file

@ -0,0 +1,18 @@
{
"address": "incididunt quis magna c\ndo veniam magna e\nut labore amet tempor",
"assigned_to": null,
"building": "SNY9",
"comment_text": "exercitation sed incididunt amet tempor ut labore labore amet ad adipiscinenim exercitation aliqua adipiscing dolor ad dolore consectetur ipsum eiusveniam elit minim ullamco nisi enim dolore nostrud magna exercitation aliqet labore amet tempor ut quis laboris nisi enim dolore nostrud sed incididmin",
"comment_time": "2026-03-16T23:19:40",
"commenter": "amet tem",
"date_reported": null,
"description": "laboris aliqua exercitation laboris do veniam uamet ad ullamco se",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SNY9",
"status": null,
"work_order_id": "11866176189"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WBM6",
"comment_text": "elit incididunt ut labore a",
"comment_time": "2026-02-23T19:09:48",
"commenter": "do venia",
"date_reported": null,
"description": "sit ad ullamco nisi nisi do sit ipsum eiusmod veniam ullamco aliqua exercitation",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WBM6",
"status": null,
"work_order_id": "18954083161"
}

View file

@ -0,0 +1,18 @@
{
"address": "enim dolore et labore a\nexercitation sed inc\nconsectetur tempor",
"assigned_to": null,
"building": "SNJ2",
"comment_text": "dolor ad adipiscing tempor q",
"comment_time": "2026-05-26T04:31:11",
"commenter": "exercita",
"date_reported": null,
"description": "aliqua et consectetur ipsum lorem do sit eiusmod ut quis laboris aliqua adipi",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "SNJ2",
"status": null,
"work_order_id": "19667240938"
}

View file

@ -0,0 +1,18 @@
{
"address": "veniam ullamco nisi do sit eiusmod ut labore labore amet ad dolore nostrud",
"assigned_to": null,
"building": "DNJ9",
"comment_text": "ipsum eiusmod ut amet tempor u",
"comment_time": "2026-04-22T00:36:53",
"commenter": "quis mag",
"date_reported": null,
"description": "tempor ut ame",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "DNJ9",
"status": null,
"work_order_id": "12085878694"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WWG0",
"comment_text": "dolore consectetur ipsum eiusmod veniam ullamco sed incidi",
"comment_time": "2026-04-16T00:17:07",
"commenter": "et nost",
"date_reported": null,
"description": "ad dolore et labore amet ad adipis",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WWG0",
"status": null,
"work_order_id": "17956754386"
}

View file

@ -0,0 +1,18 @@
{
"address": "magna exercitation sed incididunt quis magna et\nnisi enim exercit\nadipiscing minim d",
"assigned_to": null,
"building": "XCV7",
"comment_text": "nisi enim exercitation sed dolor lorem do sit eiusmod sit ad ullamco",
"comment_time": "2026-05-16T16:58:02",
"commenter": "minim",
"date_reported": null,
"description": "veniam elit incididunt amet ipsum lo",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "XCV7",
"status": null,
"work_order_id": "15442394407"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2025-09-25T09:13:39",
"commenter": "do venia",
"date_reported": null,
"description": "ullamco aliqua adipiscing dolor ad adipiscing tempor",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HMO1",
"status": null,
"work_order_id": "11626686388"
}

View file

@ -0,0 +1,18 @@
{
"address": "ad ullamco sed dolor eiusmod\nconsectetur tempor sit eiusmo\nlabore amet",
"assigned_to": null,
"building": "LHO8",
"comment_text": "lorem lorem do veniam elit minim elit incididunt ut quis elit incididunt ut labore amet tempor",
"comment_time": "2026-06-13T22:15:42",
"commenter": "lorem en",
"date_reported": null,
"description": "et consectetur minim elit incididunt amet ad dolore nostrud magna consectetur mi",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "LHO8",
"status": null,
"work_order_id": "19269813139"
}

View file

@ -0,0 +1,18 @@
{
"address": "quis laboris nisi do sit eiusmod veniam elit incididu\namet ipsum eiusmo\nincididunt quis elit mi",
"assigned_to": null,
"building": "WPR2",
"comment_text": "exercitation aliqua adipiscing tempor",
"comment_time": "2026-04-22T23:48:00",
"commenter": "nisi do",
"date_reported": null,
"description": "ad a",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WPR2",
"status": null,
"work_order_id": "17107056766"
}

View file

@ -0,0 +1,18 @@
{
"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_time": "2026-04-30T16:22:51",
"commenter": "sit eius",
"date_reported": null,
"description": "eiusmod veniam elit dolor eiusmod sit ad ad",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WTX0",
"status": null,
"work_order_id": "15379807062"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WNM1",
"comment_text": "dolore et consectetur minim elit dolor eiusmod veniam ullamco aliqua et consectetur tempor quis magna exe",
"comment_time": "2026-07-05T21:24:36",
"commenter": "dolor",
"date_reported": null,
"description": "elit dolor lorem do sit ipsum enim dolore nos",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WNM1",
"status": null,
"work_order_id": "14501248005"
}

View file

@ -0,0 +1,18 @@
{
"address": "lorem do sit ipsum lorem do incididunt quis laboris d\nenim adipiscing minim\nsit eiusmod ut amet",
"assigned_to": null,
"building": "WUT3",
"comment_text": "laboris nisi nisi nisi nisi enim dolore nostrud laboris d",
"comment_time": "2026-07-08T20:01:49",
"commenter": "magna e",
"date_reported": null,
"description": "tempor ut quis magna consec",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WUT3",
"status": null,
"work_order_id": "15311727998"
}

View file

@ -0,0 +1,18 @@
{
"address": "sit ad adipiscing tempor quis laboris nisi do incididunt amet ipsum\nmagna et labore amet temp\nquis elit inci",
"assigned_to": null,
"building": "WSP0",
"comment_text": "sed incididunt quis elit dolor lorem do veniam magna et nostrud magna consectetur tempor quis elit incididunt amet tempor ut quis lab",
"comment_time": "2026-05-31T12:05:32",
"commenter": "exercit",
"date_reported": null,
"description": "lorem do veniam elit minim elit minim elit minim elit incididunt ut qui",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WSP0",
"status": null,
"work_order_id": "12558588577"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "HRC7",
"comment_text": "adipiscing dolor ad ullamco sed dolor ad dolore consectetur tempor sit ipsum enim adipiscing dolor ad ullamco sed veniam elit dolor eiusmod sit",
"comment_time": "2026-06-24T05:24:48",
"commenter": "ut amet",
"date_reported": null,
"description": "ut quis elit dolor eiusmod veniam ullamco nisi nisi nisi nisi nisi d",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HRC7",
"status": null,
"work_order_id": "10936140019"
}

View file

@ -0,0 +1,18 @@
{
"address": "exercitation laboris aliqua et consectetur ipsum eiusmod veniam eli\nad adipiscing\nconsectetur minim u",
"assigned_to": null,
"building": "WID3",
"comment_text": "consectetur tempor sit ad adipiscing dolor lorem do veniam magna cons",
"comment_time": "2026-06-01T18:51:31",
"commenter": "magna e",
"date_reported": null,
"description": "tempor ut amet tempor sit eiusmod ut labore amet",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WID3",
"status": null,
"work_order_id": "18545122745"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WNG3",
"comment_text": "incididunt",
"comment_time": "2026-05-07T08:18:09",
"commenter": "ad adi",
"date_reported": null,
"description": "ad adipiscing tempor quis laboris nisi do",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WNG3",
"status": null,
"work_order_id": "17907063707"
}

View file

@ -0,0 +1,18 @@
{
"address": null,
"assigned_to": null,
"building": "WMT9",
"comment_text": "ullamco sed veniam ullamco aliqua et consectetur ipsum lorem enim adipiscing te",
"comment_time": "2026-05-28T15:45:57",
"commenter": "eiusmod",
"date_reported": null,
"description": "eiusmod sit ipsu",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WMT9",
"status": null,
"work_order_id": "12428713709"
}

View file

@ -0,0 +1,18 @@
{
"address": "do veniam magna et consectetur ipsum enim adipiscing dolor ad adipiscing mlorem enim exercitatio",
"assigned_to": null,
"building": "HGR0",
"comment_text": "labore amet ipsum eiusmod ut quis magna exercitation sed incididunt amet aincididunt ut quis elit minim elit incididunt amet tempor quis laboris do aliqua et nostrud sed incididunt amet ipsum eiusmod veniam elit dolor ad aconsectetur minim elit incididunt amet tempor ut quis laboris nisi do venialiqua et labore amet ipsum enim exercitation aliqua et labore amet tempordolore et labore amet ad dolore consectetur tempor quis magna et labore amminim elit dolor lorem",
"comment_time": "2026-06-30T14:30:29",
"commenter": "sit ad",
"date_reported": null,
"description": "et nostrud laboris do sit eiusmod veniam ullamcelit minim dolore et nostrud labor",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "HGR0",
"status": null,
"work_order_id": "12123384706"
}

View file

@ -0,0 +1,18 @@
{
"address": "laboris do veniam ullamco aliqua adipiscing dolor lor\nut amet ipsum eiusmod\nlorem do veniam magn",
"assigned_to": null,
"building": "WUT3",
"comment_text": "nisi nisi do incididunt am\nlabore ame\nveniam elit incididunt\nad adipiscing minim dolore et nostrud laboris nisi enim dolore nostrud laboris do sit eiusmod ut labore labore amet tempor quis e\nincididunt ut labore labore amet ipsum eiusmod sit ad dolore\nexercitation aliqua et consectetur minim ullamco nisi enim adipiscing dolor ad ullamco nisi do v\nlorem enim adipiscing dolor eiusmod veniam magna consectetur tempor quis laboris do incididunt amet ipsum lorem lorem do",
"comment_time": null,
"commenter": null,
"date_reported": null,
"description": "elit incididunt ut labore labore nostrud magn",
"due_date": null,
"email_type": "comment",
"priority": null,
"scheduled_start": null,
"severity": null,
"site_code": "WUT3",
"status": null,
"work_order_id": "13995831868"
}

View file

@ -0,0 +1,172 @@
"""End-to-end dispatch tests: the Bedrock AI extractor is invoked ONLY when the
deterministic parse misses, EMF metrics are emitted on both paths, and the
Bedrock response is decoded through the same contract."""
import json
import os
import pytest
import handler
from _wo_parser_support import FIXTURES
def _raw(subdir, stem):
with open(os.path.join(FIXTURES, subdir, f"{stem}.eml"), "rb") as fh:
return fh.read()
class FakeBody:
def __init__(self, data):
self._data = data
def read(self):
return self._data
class FakeS3:
def __init__(self, raw):
self._raw = raw
def get_object(self, Bucket, Key): # noqa: N803
return {"Body": FakeBody(self._raw)}
class FakeBedrock:
def __init__(self, payload):
self.calls = []
self._payload = payload
def invoke_model(self, modelId, body): # noqa: N803
self.calls.append({"modelId": modelId, "body": body})
text = json.dumps(self._payload)
return {"body": FakeBody(json.dumps({"content": [{"text": text}]}).encode())}
AI_17_KEY = {
"email_type": "comment",
"work_order_id": "77777777777",
"description": None,
"status": None,
"site_code": None,
"building": None,
"address": None,
"severity": None,
"priority": None,
"date_reported": None,
"scheduled_start": None,
"due_date": None,
"assigned_to": None,
"commenter": None,
"comment_text": "ai extracted",
"comment_time": None,
}
def _event():
return {
"Records": [
{
"s3": {
"bucket": {"name": "workorder-ingest-emails-x"},
"object": {"key": "inbound/o1"},
}
}
]
}
@pytest.fixture
def metric_spy(monkeypatch):
calls = []
monkeypatch.setattr(
handler,
"emit_parse_metric",
lambda *a: calls.append(a),
)
return calls
def test_template_path_skips_bedrock(fake_dynamo, metric_spy, monkeypatch):
fake_bedrock = FakeBedrock(AI_17_KEY)
monkeypatch.setattr(
handler, "s3", FakeS3(_raw("update-plaintext", "update-plaintext-01"))
)
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
handler.handler(_event(), None)
assert fake_bedrock.calls == [] # deterministic parse handled it
method, template_id, reason, wo = metric_spy[0]
assert method == "template"
assert template_id == "update_plaintext"
assert reason == "ok"
assert wo == "11144580730"
def test_fallback_path_invokes_bedrock(fake_dynamo, metric_spy, monkeypatch):
fake_bedrock = FakeBedrock(AI_17_KEY)
monkeypatch.setattr(handler, "s3", FakeS3(_raw("ai-fallback", "unknown-subject")))
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
handler.handler(_event(), None)
assert len(fake_bedrock.calls) == 1
# Uses the configured inference profile and the bedrock message contract.
call = fake_bedrock.calls[0]
assert call["modelId"] == handler.BEDROCK_MODEL_ID
body = json.loads(call["body"])
assert body["anthropic_version"] == "bedrock-2023-05-31"
assert body["max_tokens"] == 1024
method, _template_id, _reason, wo = metric_spy[0]
assert method == "ai_fallback"
assert wo == "77777777777"
# The AI result was written through to DynamoDB.
wo_table = fake_dynamo.tables[handler.WORK_ORDERS_TABLE]
assert wo_table.updates, "expected a work-order upsert from the AI path"
def test_extract_with_bedrock_returns_full_contract(monkeypatch):
fake_bedrock = FakeBedrock(AI_17_KEY)
monkeypatch.setattr(handler, "bedrock", fake_bedrock)
email_data = {
"subject": "x",
"sender": "a",
"to": "b",
"cc": "",
"date": "d",
"body": "body",
}
out = handler.extract_with_bedrock(email_data)
assert set(out.keys()) == set(AI_17_KEY.keys())
assert len(fake_bedrock.calls) == 1
def test_extract_with_bedrock_strips_markdown_fence(monkeypatch):
class FenceBedrock:
def invoke_model(self, modelId, body): # noqa: N803
fenced = "```json\n" + json.dumps(AI_17_KEY) + "\n```"
return {
"body": FakeBody(json.dumps({"content": [{"text": fenced}]}).encode())
}
monkeypatch.setattr(handler, "bedrock", FenceBedrock())
out = handler.extract_with_bedrock(
{"subject": "", "sender": "", "to": "", "cc": "", "date": "", "body": ""}
)
assert out["work_order_id"] == "77777777777"
def test_emit_parse_metric_writes_emf(capsys):
handler.emit_parse_metric("template", "update_plaintext", "ok", "123")
line = capsys.readouterr().out.strip()
emf = json.loads(line)
assert emf["ParseMethod"] == "template"
assert emf["TemplateId"] == "update_plaintext"
assert emf["ReasonCode"] == "ok"
assert emf["ParseOutcome"] == 1
dims = emf["_aws"]["CloudWatchMetrics"][0]["Dimensions"][0]
assert dims == ["ParseMethod", "TemplateId"]
assert (
emf["_aws"]["CloudWatchMetrics"][0]["Namespace"] == "Seahaven/WorkorderIngest"
)

View file

@ -0,0 +1,88 @@
"""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."""
import handler
def _parsed(wo="11144580730", comment_time="2026-04-27T23:51:48"):
return {
"work_order_id": wo,
"email_type": "comment",
"commenter": "jdoe",
"comment_text": "a comment",
"comment_time": comment_time,
}
def _comment_ids(table):
return [item["comment_id"] for item in table.puts]
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")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
# Two put_item calls with an identical key -> one logical row (overwrite).
assert ids[0] == ids[1]
assert len(table.store) == 1
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")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] != ids[1]
assert len(table.store) == 2
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")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] == ids[1] # 'nocomment' segment, not now()
assert "#nocomment#" in ids[0]
assert len(table.store) == 1
def test_event_id_independent_of_now(fake_dynamo, monkeypatch):
"""Freezing / advancing the clock must not change the range key."""
parsed = _parsed(comment_time=None)
class FrozenDT:
offset = 0
@classmethod
def now(cls, tz=None):
import datetime as _dt
return _dt.datetime(2020, 1, 1, tzinfo=tz) + _dt.timedelta(
seconds=cls.offset
)
monkeypatch.setattr(handler, "datetime", FrozenDT)
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
FrozenDT.offset = 999999 # simulate a later retry
handler.save_event(parsed, "s3://b/inbound/obj-d", "inbound/obj-d")
table = fake_dynamo.tables[handler.COMMENTS_TABLE]
ids = _comment_ids(table)
assert ids[0] == ids[1]
# created_at (display) may differ, but the KEY must not.
assert len(table.store) == 1
def test_comment_id_format_shape(fake_dynamo):
parsed = _parsed()
handler.save_event(parsed, "s3://bucket/inbound/obj-e", "inbound/obj-e")
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)

View file

@ -0,0 +1,110 @@
"""Golden-file tests for the deterministic template parser.
Positives: every scrubbed real sample must parse via the deterministic path and
match its checked-in golden exactly. Negatives: every adversarial / drift sample
must fail closed to the AI fallback (parsed is None).
"""
import pytest
from _wo_parser_support import (
ADVERSARIAL_STEMS,
AI_FALLBACK_STEMS,
ASSIGN_STEMS,
UPDATE_STEMS,
load_email,
load_golden,
)
from template_parser import try_deterministic_parse
@pytest.mark.parametrize("stem", UPDATE_STEMS)
def test_update_plaintext_matches_golden(stem):
email_data = load_email("update-plaintext", stem)
parsed, method, template_id, reason = try_deterministic_parse(email_data)
assert method == "template"
assert template_id == "update_plaintext"
assert reason == "ok"
assert parsed == load_golden(stem)
# Contract invariants for the comment shape.
assert set(parsed.keys()) == set(load_golden(stem).keys())
assert parsed["email_type"] == "comment"
assert parsed["status"] is None # never clobber a real wo_status on upsert
@pytest.mark.parametrize("stem", ASSIGN_STEMS)
def test_assign_html_matches_golden(stem):
email_data = load_email("assign-html", stem)
parsed, method, template_id, reason = try_deterministic_parse(email_data)
assert method == "template"
assert template_id == "assign_html"
assert reason == "ok"
assert parsed == load_golden(stem)
assert parsed["email_type"] == "new_work_order"
assert parsed["status"] == "assigned"
@pytest.mark.parametrize("stem", AI_FALLBACK_STEMS)
def test_ai_fallback_samples_return_none(stem):
email_data = load_email("ai-fallback", stem)
parsed, method, template_id, reason = try_deterministic_parse(email_data)
assert parsed is None, f"{stem} should have failed closed, got {parsed}"
assert method == "ai_fallback"
assert reason != "ok"
def test_positive_result_matches_ai_contract_keys():
# The deterministic result must carry EXACTLY the keys the AI fallback
# produces (the EXTRACTION_PROMPT JSON contract) -- no more, no less.
from template_parser import CONTRACT_KEYS
parsed, *_ = try_deterministic_parse(
load_email("update-plaintext", UPDATE_STEMS[0])
)
assert set(parsed.keys()) == set(CONTRACT_KEYS)
def test_extractor_exception_fails_closed(monkeypatch):
# Any exception inside the extractor must yield None, never a partial parse.
import template_parser
monkeypatch.setattr(
template_parser,
"extract_update_plaintext",
lambda *_a, **_k: (_ for _ in ()).throw(RuntimeError("boom")),
)
parsed, method, _tid, reason = template_parser.try_deterministic_parse(
load_email("update-plaintext", UPDATE_STEMS[0])
)
assert parsed is None
assert method == "ai_fallback"
assert reason == "extractor_raised"
# --- Adversarial: prompt injection / near-miss subjects ---
def test_prompt_injection_in_comment_never_corrupts_other_fields():
"""Injection text in the comment body must either fall back OR parse safely
with the real work_order_id / site_code intact and the payload confined to
comment_text (it must never steer the structured fields)."""
email_data = load_email("adversarial", "prompt-injection-comment")
parsed, method, _tid, _reason = try_deterministic_parse(email_data)
if parsed is None:
assert method == "ai_fallback"
return
# Parsed safely: the subject-derived id wins, injected id/site are ignored.
assert parsed["work_order_id"] == "11144580730"
assert parsed["site_code"] == "WCO0"
assert parsed["email_type"] == "comment"
assert "00000000000" not in (parsed["work_order_id"] or "")
assert parsed["site_code"] != "HACK99"
# The injection payload is confined to the free-text comment field.
assert "Ignore all previous instructions" in parsed["comment_text"]
@pytest.mark.parametrize("stem", ADVERSARIAL_STEMS)
def test_adversarial_samples_do_not_raise(stem):
# The parser must be total: adversarial input returns a tuple, never raises.
result = try_deterministic_parse(load_email("adversarial", stem))
assert isinstance(result, tuple) and len(result) == 4

View file

@ -0,0 +1,143 @@
"""Fail-closed validation-gate tests.
Each known drift / adversarial shape must be rejected with the expected reason
code, and direct unit tests exercise each individual gate rule.
"""
import pytest
from _wo_parser_support import load_email
from template_parser import (
CONTRACT_KEYS,
extract_update_plaintext,
try_deterministic_parse,
validate,
)
# Fixture stem -> expected fail-closed reason code.
EXPECTED_REASONS = {
"unknown-subject": "subject_no_match",
"missing-id": "subject_no_match",
"single-space-work-order": "single_space_work_order",
"nondigit-id": "missing_required_field",
"empty-new-comment": "missing_required_field",
"malformed-site-code": "malformed_site_code",
"label-bleed-comment": "label_bleed",
"unparseable-creation-time": "creation_time_unparseable",
"cancellation-t1": "missing_required_field",
"update-status-t1": "missing_required_field",
"missing-building-and-comment": "missing_required_field",
"t2-wo-id-mismatch": "wo_id_mismatch",
"t2-missing-address": "missing_required_field",
"t2-unparseable-date": "creation_time_unparseable",
}
@pytest.mark.parametrize("stem,reason", sorted(EXPECTED_REASONS.items()))
def test_gate_rejects_with_reason(stem, reason):
parsed, method, _tid, got_reason = try_deterministic_parse(
load_email("ai-fallback", stem)
)
assert parsed is None
assert method == "ai_fallback"
assert got_reason == reason
# --- Direct unit tests on validate() ---
def _good_t1_email():
return load_email("update-plaintext", "update-plaintext-01")
def _good_candidate():
return extract_update_plaintext(_good_t1_email())
def test_baseline_candidate_is_valid():
ok, reason = validate(_good_candidate(), "update_plaintext", _good_t1_email())
assert ok and reason == "ok"
def test_rule1_unknown_template():
ok, reason = validate(_good_candidate(), "unknown", _good_t1_email())
assert not ok and reason == "subject_no_match"
def test_rule2_extra_key_fails():
cand = _good_candidate()
cand["surprise"] = "x"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "key_set_mismatch"
def test_rule2_missing_key_fails():
cand = _good_candidate()
del cand["address"]
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "key_set_mismatch"
def test_rule3_nondigit_wo():
cand = _good_candidate()
cand["work_order_id"] = "12A45"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "missing_required_field"
def test_rule5_wrong_email_type():
cand = _good_candidate()
cand["email_type"] = "new_work_order" # wrong for a T1 template
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "email_type_mismatch"
def test_rule6_bad_site_code():
cand = _good_candidate()
cand["site_code"] = "workshop"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "malformed_site_code"
def test_rule7_bad_status():
cand = _good_candidate()
cand["status"] = "frobnicated"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "malformed_site_code"
def test_rule8_empty_comment_text():
cand = _good_candidate()
cand["comment_text"] = " "
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "missing_required_field"
def test_rule9_label_bleed_in_comment():
cand = _good_candidate()
cand["comment_text"] = "text that leaked Building: WCO0 into the value"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "label_bleed"
def test_rule9_separator_bleed_in_address():
cand = _good_candidate()
cand["address"] = "123 Main St ________________"
ok, reason = validate(cand, "update_plaintext", _good_t1_email())
assert not ok and reason == "label_bleed"
def test_contract_keys_match_extraction_prompt():
"""The parser's key set must be exactly the AI EXTRACTION_PROMPT contract, so
the deterministic and AI-fallback paths write identical shapes downstream."""
import re
import handler
# The prompt's JSON skeleton uses union-type pseudo-values (not strict JSON)
# and repeats some enum terms in prose bullets, so pull quoted "key": tokens
# and assert every contract key is a field the AI is asked to emit (the
# parser must never invent a key outside the AI contract).
prompt_keys = set(re.findall(r'"([a-z_]+)":', handler.EXTRACTION_PROMPT))
assert set(CONTRACT_KEYS).issubset(prompt_keys)
assert len(CONTRACT_KEYS) == 16 # current EXTRACTION_PROMPT field count

View file

@ -1,5 +1,10 @@
[pytest]
; Restrict discovery to tests/. test_local.py at the repo root matches the
; default test_*.py glob but is a manual script that imports a handler (and
; therefore creates boto3 clients) at collection time.
testpaths = tests
; Discovery is restricted to the repo's test roots. test_local.py at the repo
; root matches the default test_*.py glob but is a manual script that imports a
; handler (and therefore creates boto3 clients) at collection time, so it is
; deliberately excluded. The WO email-processor keeps a self-contained,
; offline suite (deterministic template parser + Bedrock fallback + issue #23
; comment_id) alongside its module.
testpaths =
tests
lambdas/wo/email_processor/tests

View file

@ -1,9 +1,11 @@
#!/usr/bin/env python3
"""
Local test script - parses sample emails through Claude without AWS.
Local test script - parses sample WO emails through the deterministic template
parser, falling back to Claude on Bedrock for anything the templates don't cover.
Usage:
export ANTHROPIC_API_KEY=sk-ant-...
# AWS credentials with bedrock:InvokeModel are only needed if a sample falls
# back to the AI extractor; pure-template samples parse fully offline.
python test_local.py
"""
@ -13,7 +15,8 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent / "lambdas" / "wo" / "email_processor"))
from handler import parse_raw_email, extract_with_claude
from handler import parse_raw_email, extract_with_bedrock
from template_parser import try_deterministic_parse
def main():
@ -25,9 +28,9 @@ def main():
return
for eml_path in eml_files:
print(f"\n{'='*80}")
print(f"\n{'=' * 80}")
print(f"FILE: {eml_path.name}")
print(f"{'='*80}")
print(f"{'=' * 80}")
raw = eml_path.read_bytes()
email_data = parse_raw_email(raw)
@ -38,8 +41,12 @@ def main():
print(f"Body preview: {email_data['body'][:200]}...")
print()
print("Sending to Claude for extraction...")
parsed = extract_with_claude(email_data)
parsed, method, template_id, reason = try_deterministic_parse(email_data)
if parsed is None:
print(f"Template miss ({template_id}/{reason}); sending to Bedrock...")
parsed = extract_with_bedrock(email_data)
method = "ai_fallback"
print(f"Parse method: {method} (template={template_id}, reason={reason})")
print(json.dumps(parsed, indent=2))

View file

@ -29,6 +29,11 @@ def load_handler(relative_path, module_name):
across Lambdas.
"""
path = REPO_ROOT / relative_path
# Put the handler's own directory on sys.path so its sibling-module imports
# (e.g. the WO handler's ``from template_parser import ...``) resolve.
handler_dir = str(path.parent)
if handler_dir not in sys.path:
sys.path.insert(0, handler_dir)
spec = importlib.util.spec_from_file_location(module_name, path)
module = importlib.util.module_from_spec(spec)
sys.modules[module_name] = module