mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
The fallback-rate alarm queries the ParseOutcome series keyed on ParseMethod alone, but the emitter published only the joint (ParseMethod, TemplateId) dimension set. CloudWatch materializes exactly the listed dimension sets and does not auto-aggregate, so the alarm's series never received data: it evaluated a constant 0 and could never page on template-drift coverage collapse. Publish both ["ParseMethod"] and ["ParseMethod","TemplateId"] and update the EMF regression test to assert both sets are present.
177 lines
5.3 KiB
Python
177 lines
5.3 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)
|
|
|
|
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"]
|
|
# 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"
|
|
)
|