procurement-ingest/lambdas/wo/email_processor/tests/test_bedrock_fallback.py

173 lines
5 KiB
Python
Raw Normal View History

"""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"
)