mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-03 03:23:11 +00:00
208 lines
7.1 KiB
Python
208 lines
7.1 KiB
Python
|
|
"""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)
|
||
|
|
# This suite exercises parse dispatch, not the fail-closed SES sender-auth
|
||
|
|
# gate (INFRA-107) that now runs first in handler(); the scrubbed .eml
|
||
|
|
# fixtures carry no SES-stamped Authentication-Results header, so bypass it
|
||
|
|
# here. Authentication itself is covered by tests/test_ses_auth.py.
|
||
|
|
monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True)
|
||
|
|
|
||
|
|
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)
|
||
|
|
# See note in test_template_path_skips_bedrock: bypass the INFRA-107 sender
|
||
|
|
# auth gate so this dispatch test reaches the parse path.
|
||
|
|
monkeypatch.setattr(handler, "authenticate_inbound_email", lambda *a: True)
|
||
|
|
|
||
|
|
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
|
||
|
|
# 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"
|
||
|
|
# 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_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)
|
||
|
|
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"]
|
||
|
|
# Two dimension sets must be published: the ParseMethod-only aggregate that
|
||
|
|
# the fallback-rate alarm queries, AND the per-template breakdown. Without
|
||
|
|
# the ["ParseMethod"] set the alarm's single-dimension series never receives
|
||
|
|
# data and can never fire (regression guard for the coverage-collapse alarm).
|
||
|
|
assert ["ParseMethod"] in dims
|
||
|
|
assert ["ParseMethod", "TemplateId"] in dims
|
||
|
|
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
|