mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-10-07 16:08:54 +00:00
Compare commits
No commits in common. "d677358801f3cb030555afc541727e085cfcb8f9" and "25a2ed461cfdc40f7e2195c82dedf435e168c9ab" have entirely different histories.
d677358801
...
25a2ed461c
10 changed files with 10 additions and 269 deletions
|
|
@ -359,7 +359,7 @@ migration Phase 0 runbook):
|
|||
closed
|
||||
|
||||
1. Bootstrap CDK: `cdk bootstrap aws://{AccountId}/us-east-1`
|
||||
2. Bedrock model access (account first-use). The Bedrock **Model access** console page is retired; serverless foundation models enable on first invoke. Anthropic models may still require a one-time use-case form (`PutUseCaseForModelAccess`) before agreement APIs succeed — that is already done for Sea Haven. Marketplace-served models (including Claude Haiku 4.5) additionally need one account-wide Marketplace subscription: an admin principal with `aws-marketplace:ViewSubscriptions` / `Subscribe` must call `CreateFoundationModelAgreement` (or successfully `InvokeModel` once) for `anthropic.claude-haiku-4-5-20251001-v1:0` in `us-east-1`. After the agreement reaches `AVAILABLE` (often ~2 minutes), processor Lambdas can `InvokeModel` with only their existing `bedrock:InvokeModel` grants — do **not** put `aws-marketplace:*` on `po-email-processor` / `workorder-email-processor` unless that first-use path still AccessDenies after the account agreement is `AVAILABLE`. The CDK grants already cover the `us.*` inference profile plus cross-region foundation-model ARNs (`us-east-1` / `us-east-2` / `us-west-2`). No API key or secret to set — the processors authenticate to Bedrock via their IAM roles.
|
||||
2. Ensure the Bedrock inference profile `us.anthropic.claude-haiku-4-5-20251001-v1:0` is enabled in `us-east-1` (it is; the CDK grants cover cross-region routing to `us-east-2`/`us-west-2`). No API key or secret to set — the processors authenticate to Bedrock via their IAM roles.
|
||||
3. Create the web UI auth-gate shared secret. This secret is **imported by name**
|
||||
(`Secret.from_secret_name_v2`), not CDK-managed, so it must exist before deploy
|
||||
or the web-ui Lambdas fail closed:
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ client is constructed (moto-before-handler invariant).
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
from decimal import Decimal
|
||||
|
||||
import boto3
|
||||
from prompts import EXTRACTION_PROMPT
|
||||
|
|
@ -86,5 +85,4 @@ def extract_with_bedrock(email_data: dict) -> dict:
|
|||
if json_match:
|
||||
response_text = json_match.group(1)
|
||||
|
||||
# parse_float=Decimal is CRITICAL: DynamoDB rejects Python floats.
|
||||
return json.loads(response_text.strip(), parse_float=Decimal)
|
||||
return json.loads(response_text.strip())
|
||||
|
|
|
|||
|
|
@ -53,21 +53,6 @@ VALID_STATUSES = {
|
|||
"unknown",
|
||||
}
|
||||
|
||||
# Free-text scalar fields that must be None or str (blocks LLM-emitted maps/
|
||||
# lists from landing as DynamoDB Map/List attribute pollution, and floats that
|
||||
# would crash update_item). email_type, status, work_order_id, site_code, and
|
||||
# date fields are validated separately.
|
||||
_AI_FREE_TEXT_STR_FIELDS = (
|
||||
"description",
|
||||
"building",
|
||||
"address",
|
||||
"severity",
|
||||
"priority",
|
||||
"assigned_to",
|
||||
"commenter",
|
||||
"comment_text",
|
||||
)
|
||||
|
||||
# Subject classifiers.
|
||||
_T1_SUBJECT = re.compile(r"^AMAZON UPDATE WO DETAILS\s+(?P<wo>\S+)\s*$")
|
||||
_T2_SUBJECT = re.compile(
|
||||
|
|
@ -495,14 +480,6 @@ def validate_ai_fallback(candidate):
|
|||
except ValueError:
|
||||
return False, "creation_time_unparseable"
|
||||
|
||||
# Free-text scalars must be None or str. A prompt-injected float reaches
|
||||
# DynamoDB as Python float and crashes update_item; a dict/list lands as
|
||||
# a Map/List attribute and pollutes downstream readers (PO parity).
|
||||
for field in _AI_FREE_TEXT_STR_FIELDS:
|
||||
val = candidate.get(field)
|
||||
if val is not None and not isinstance(val, str):
|
||||
return False, "invalid_field_type"
|
||||
|
||||
return True, "ok"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -374,51 +374,3 @@ def test_ai_fallback_all_valid_email_types():
|
|||
cand["email_type"] = et
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert ok, f"email_type={et} should pass"
|
||||
|
||||
|
||||
def test_ai_fallback_free_text_float_rejected():
|
||||
# Prompt-injected JSON float in a free-text slot must fail closed before
|
||||
# update_item (boto3 rejects Python floats -> async retries / DLQ).
|
||||
cand = _ai_candidate()
|
||||
cand["work_order_id"] = "12345"
|
||||
cand["email_type"] = "update"
|
||||
cand["severity"] = 1.5
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert not ok and reason == "invalid_field_type"
|
||||
|
||||
|
||||
def test_ai_fallback_free_text_dict_rejected():
|
||||
# LLM-emitted maps in scalar slots must be rejected (DynamoDB Map pollution).
|
||||
cand = _ai_candidate()
|
||||
cand["work_order_id"] = "12345"
|
||||
cand["email_type"] = "update"
|
||||
cand["assigned_to"] = {"a": 1}
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert not ok and reason == "invalid_field_type"
|
||||
|
||||
|
||||
def test_ai_fallback_free_text_list_rejected():
|
||||
# LLM-emitted lists in scalar slots must be rejected (DynamoDB List pollution).
|
||||
cand = _ai_candidate()
|
||||
cand["work_order_id"] = "12345"
|
||||
cand["email_type"] = "update"
|
||||
cand["comment_text"] = ["x"]
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert not ok and reason == "invalid_field_type"
|
||||
|
||||
|
||||
def test_ai_fallback_free_text_none_and_str_ok():
|
||||
# None and str remain valid for every free-text field.
|
||||
cand = _ai_candidate()
|
||||
cand["work_order_id"] = "12345"
|
||||
cand["email_type"] = "update"
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert ok and reason == "ok"
|
||||
|
||||
for field in template_parser._AI_FREE_TEXT_STR_FIELDS:
|
||||
cand = _ai_candidate()
|
||||
cand["work_order_id"] = "12345"
|
||||
cand["email_type"] = "update"
|
||||
cand[field] = "ok"
|
||||
ok, reason = validate_ai_fallback(cand)
|
||||
assert ok and reason == "ok", field
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ Classification (docs/shoc-webhook-contract.md section 4):
|
|||
NEW write_origin == "shoc-write-api" -> skip (echo guard; a no-op branch
|
||||
today -- nothing writes that attribute yet -- kept so the phase-2
|
||||
write-back API never echoes SHOC's own writes back at it)
|
||||
comment_added with blank/missing text -> skip (email-processor #nocomment#
|
||||
rows; SHOC requires non-empty text and would 400-park them)
|
||||
Anything else (unknown table, comment MODIFYs) -> skip.
|
||||
"""
|
||||
|
||||
|
|
@ -125,33 +123,6 @@ def _classify(table, event_name, new_image, old_image) -> str | None:
|
|||
return None
|
||||
|
||||
|
||||
def _is_blank_comment_text(value) -> bool:
|
||||
"""True when comment text is missing, empty, or whitespace-only."""
|
||||
return value is None or (isinstance(value, str) and not value.strip())
|
||||
|
||||
|
||||
def is_blank_comment_skip(record: dict) -> bool:
|
||||
"""True when this stream record is a blank-text comment_added skip.
|
||||
|
||||
Used by the handler to emit a specific structured log for empty-comment
|
||||
skips (distinct from REMOVE / echo guard / unmappable). Safe to call on
|
||||
any record; returns False unless the record classifies as comment_added
|
||||
with blank/missing text.
|
||||
"""
|
||||
event_name = record.get("eventName")
|
||||
if event_name == "REMOVE":
|
||||
return False
|
||||
stream = record.get("dynamodb") or {}
|
||||
new_image = _deserialize_image(stream.get("NewImage") or {})
|
||||
if new_image.get("write_origin") == SHOC_WRITE_ORIGIN:
|
||||
return False
|
||||
table = _table_name(record.get("eventSourceARN") or "")
|
||||
old_image = _deserialize_image(stream.get("OldImage") or {})
|
||||
if _classify(table, event_name, new_image, old_image) != "work_order.comment_added":
|
||||
return False
|
||||
return _is_blank_comment_text(new_image.get("text"))
|
||||
|
||||
|
||||
def build_event(record: dict) -> dict | None:
|
||||
"""Map one DynamoDB stream record to a webhook envelope, or None to skip."""
|
||||
event_name = record.get("eventName")
|
||||
|
|
@ -167,8 +138,6 @@ def build_event(record: dict) -> dict | None:
|
|||
if event_type is None:
|
||||
return None
|
||||
if event_type == "work_order.comment_added":
|
||||
if _is_blank_comment_text(new_image.get("text")):
|
||||
return None
|
||||
fields = COMMENT_DATA_FIELDS
|
||||
else:
|
||||
fields = WO_DATA_FIELDS
|
||||
|
|
|
|||
|
|
@ -99,14 +99,11 @@ def _park_rejected(event: dict, status_code: int):
|
|||
def _batch_summary(records: int, emitted: int, skipped: int) -> str:
|
||||
"""Per-invocation emit/skip tally.
|
||||
|
||||
Skips (REMOVE, echo guard, blank-text comments, comment MODIFYs,
|
||||
unmappable records) return no delivery and would otherwise be invisible:
|
||||
a systemic classification break -- e.g. a table rename desyncing the
|
||||
eventSourceARN parse -- would drop 100%% of events while every batch
|
||||
still reports success. Logging the ratio makes that queryable in Logs
|
||||
Insights and alarmable. Blank-text comment skips also emit a distinct
|
||||
``shoc_skipped_empty_comment`` line so their volume stays observable
|
||||
apart from other skip reasons.
|
||||
Skips (REMOVE, echo guard, comment MODIFYs, unmappable records) return no
|
||||
delivery and would otherwise be invisible: a systemic classification break
|
||||
-- e.g. a table rename desyncing the eventSourceARN parse -- would drop
|
||||
100%% of events while every batch still reports success. Logging the ratio
|
||||
makes that queryable in Logs Insights and alarmable.
|
||||
"""
|
||||
return json.dumps(
|
||||
{
|
||||
|
|
@ -118,25 +115,6 @@ def _batch_summary(records: int, emitted: int, skipped: int) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _log_empty_comment_skip(record: dict) -> None:
|
||||
"""Structured log for a blank-text comment_added skip (Logs Insights)."""
|
||||
stream = record.get("dynamodb") or {}
|
||||
# Attribute-value encoded NewImage; only the S forms are read for the
|
||||
# log fields -- classification already decided this is a blank skip.
|
||||
new_image = stream.get("NewImage") or {}
|
||||
work_order_id = (new_image.get("work_order_id") or {}).get("S")
|
||||
comment_id = (new_image.get("comment_id") or {}).get("S")
|
||||
logger.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "shoc_skipped_empty_comment",
|
||||
"work_order_id": work_order_id,
|
||||
"comment_id": comment_id,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def handler(event, context):
|
||||
"""Lambda entry point. Triggered by the two WO-table stream ESMs."""
|
||||
records = event.get("Records", [])
|
||||
|
|
@ -146,8 +124,6 @@ def handler(event, context):
|
|||
webhook_event = envelope.build_event(record)
|
||||
if webhook_event is None:
|
||||
skipped += 1
|
||||
if envelope.is_blank_comment_skip(record):
|
||||
_log_empty_comment_skip(record)
|
||||
continue
|
||||
# Capture the sequence number BEFORE any delivery attempt so the
|
||||
# failure paths below can never KeyError on the subscript (Open SWE
|
||||
|
|
|
|||
|
|
@ -256,12 +256,6 @@ def build_state_event(item):
|
|||
|
||||
|
||||
def build_comment_event(item):
|
||||
# Skip blank/missing text the same way the live emitter does -- otherwise
|
||||
# a --since / --work-order-id replay would re-POST #nocomment# rows and
|
||||
# re-park them on the rejected queue.
|
||||
text = item.get("text")
|
||||
if text is None or (isinstance(text, str) and not text.strip()):
|
||||
return None
|
||||
occurred_at = item.get("ingested_at") or datetime.now(timezone.utc).isoformat()
|
||||
delivery_id = replay_delivery_id(
|
||||
COMMENTS_TABLE, item["work_order_id"], item["comment_id"], occurred_at
|
||||
|
|
@ -300,21 +294,8 @@ def scan_since(table, attr, since):
|
|||
kwargs["ExclusiveStartKey"] = lek
|
||||
|
||||
|
||||
def _append_comment_events(envelopes, items):
|
||||
"""Build comment envelopes, dropping blank-text skips; return skip count."""
|
||||
skipped = 0
|
||||
for item in items:
|
||||
event = build_comment_event(item)
|
||||
if event is None:
|
||||
skipped += 1
|
||||
continue
|
||||
envelopes.append(event)
|
||||
return skipped
|
||||
|
||||
|
||||
def select_by_ids(dynamodb, work_order_ids, want_state, want_comments):
|
||||
envelopes = []
|
||||
skipped_empty = 0
|
||||
if want_state:
|
||||
table = dynamodb.Table(WO_TABLE)
|
||||
for wid in work_order_ids:
|
||||
|
|
@ -325,17 +306,12 @@ def select_by_ids(dynamodb, work_order_ids, want_state, want_comments):
|
|||
if want_comments:
|
||||
table = dynamodb.Table(COMMENTS_TABLE)
|
||||
for wid in work_order_ids:
|
||||
skipped_empty += _append_comment_events(
|
||||
envelopes, query_comments(table, wid)
|
||||
)
|
||||
if skipped_empty:
|
||||
print(f"skipped {skipped_empty} empty-text comment(s)")
|
||||
envelopes.extend(build_comment_event(i) for i in query_comments(table, wid))
|
||||
return envelopes
|
||||
|
||||
|
||||
def select_since(dynamodb, since, want_state, want_comments):
|
||||
envelopes = []
|
||||
skipped_empty = 0
|
||||
if want_state:
|
||||
table = dynamodb.Table(WO_TABLE)
|
||||
items = list(scan_since(table, "updated_at", since))
|
||||
|
|
@ -350,9 +326,7 @@ def select_since(dynamodb, since, want_state, want_comments):
|
|||
f"[{COMMENTS_TABLE}] full table Scan (ingested_at >= {since}): "
|
||||
f"{len(items)} row(s)"
|
||||
)
|
||||
skipped_empty += _append_comment_events(envelopes, items)
|
||||
if skipped_empty:
|
||||
print(f"skipped {skipped_empty} empty-text comment(s)")
|
||||
envelopes.extend(build_comment_event(i) for i in items)
|
||||
return envelopes
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -190,20 +190,6 @@ def test_replay_envelopes_carry_replay_true_and_exclude_s3_key():
|
|||
assert set(comment["data"]) == set(replay.COMMENT_DATA_FIELDS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"text",
|
||||
["", " \t", None],
|
||||
ids=["empty", "whitespace-only", "missing"],
|
||||
)
|
||||
def test_blank_comment_text_returns_none(text):
|
||||
item = dict(COMMENT_ITEM)
|
||||
if text is None:
|
||||
del item["text"]
|
||||
else:
|
||||
item["text"] = text
|
||||
assert replay.build_comment_event(item) is None
|
||||
|
||||
|
||||
def test_delivery_id_is_stable_across_builds():
|
||||
first = replay.build_comment_event(dict(COMMENT_ITEM))
|
||||
second = replay.build_comment_event(dict(COMMENT_ITEM))
|
||||
|
|
|
|||
|
|
@ -150,38 +150,9 @@ def test_comment_insert_is_comment_added(envelope):
|
|||
assert set(event["data"]) == set(envelope.COMMENT_DATA_FIELDS)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"overrides",
|
||||
[
|
||||
{"text": {"S": ""}},
|
||||
{"text": {"S": " \t\n"}},
|
||||
{"_drop": "text"},
|
||||
],
|
||||
ids=["empty", "whitespace-only", "missing"],
|
||||
)
|
||||
def test_blank_comment_text_is_skipped(envelope, overrides):
|
||||
image = _comment_image()
|
||||
if overrides.get("_drop") == "text":
|
||||
del image["text"]
|
||||
else:
|
||||
image.update(overrides)
|
||||
record = _record(COMMENTS_STREAM_ARN, "INSERT", image)
|
||||
assert envelope.build_event(record) is None
|
||||
assert envelope.is_blank_comment_skip(record) is True
|
||||
|
||||
|
||||
def test_non_blank_comment_is_not_blank_skip(envelope):
|
||||
record = _record(COMMENTS_STREAM_ARN, "INSERT", _comment_image())
|
||||
assert envelope.build_event(record) is not None
|
||||
assert envelope.is_blank_comment_skip(record) is False
|
||||
|
||||
|
||||
def test_remove_is_skipped(envelope):
|
||||
assert envelope.build_event(_record(WO_STREAM_ARN, "REMOVE")) is None
|
||||
assert envelope.build_event(_record(COMMENTS_STREAM_ARN, "REMOVE")) is None
|
||||
assert (
|
||||
envelope.is_blank_comment_skip(_record(COMMENTS_STREAM_ARN, "REMOVE")) is False
|
||||
)
|
||||
|
||||
|
||||
def test_comment_modify_is_skipped(envelope):
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ report_batch_item_failures (so the ESM retries from it, in order) and later
|
|||
records are never attempted; earlier in-batch successes are not re-delivered.
|
||||
Non-retryable rejections park the full envelope on the rejected queue and the
|
||||
loop CONTINUES (a contract bug must never block the shard). Skip records
|
||||
(REMOVE / echo guard / blank-text comments) produce no delivery attempts at all.
|
||||
(REMOVE / echo guard) produce no delivery attempts at all.
|
||||
|
||||
delivery.deliver is monkeypatched on the handler's own sibling instance (the
|
||||
handler imports it by bare name); envelope.build_event runs for real, so
|
||||
|
|
@ -171,68 +171,6 @@ def test_skip_records_produce_no_delivery_calls(monkeypatch, handler_mod):
|
|||
assert fake_sqs.sent == []
|
||||
|
||||
|
||||
COMMENTS_STREAM_ARN = (
|
||||
"arn:aws:dynamodb:us-east-1:011934824531:table/WorkOrderComments"
|
||||
"/stream/2026-07-23T00:00:00.000"
|
||||
)
|
||||
|
||||
|
||||
def _comment_record(event_id, sequence_number, **image_overrides):
|
||||
image = {
|
||||
"work_order_id": {"S": f"wo-{event_id}"},
|
||||
"comment_id": {"S": f"wo-{event_id}#nocomment#abc123"},
|
||||
"record_type": {"S": "new_work_order"},
|
||||
"commenter": {"S": ""},
|
||||
"text": {"S": ""},
|
||||
"created_at": {"S": "2026-07-16T14:05:00+00:00"},
|
||||
"ingested_at": {"S": "2026-07-16T14:05:00+00:00"},
|
||||
}
|
||||
image.update(image_overrides)
|
||||
return {
|
||||
"eventID": event_id,
|
||||
"eventName": "INSERT",
|
||||
"eventSource": "aws:dynamodb",
|
||||
"eventSourceARN": COMMENTS_STREAM_ARN,
|
||||
"dynamodb": {
|
||||
"ApproximateCreationDateTime": 1784642602.0,
|
||||
"SequenceNumber": sequence_number,
|
||||
"StreamViewType": "NEW_AND_OLD_IMAGES",
|
||||
"NewImage": image,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_blank_comment_skip_logs_and_produces_no_delivery(
|
||||
monkeypatch, handler_mod, caplog
|
||||
):
|
||||
event = {
|
||||
"Records": [
|
||||
_comment_record("evt-empty", "501"),
|
||||
_wo_record("evt-wo", "502"),
|
||||
]
|
||||
}
|
||||
attempted, fake_sqs = _wire(
|
||||
monkeypatch, handler_mod, {"evt-wo": ("delivered", 200)}
|
||||
)
|
||||
|
||||
with caplog.at_level("INFO"):
|
||||
result = handler_mod.handler(event, None)
|
||||
|
||||
assert result == {"batchItemFailures": []}
|
||||
assert attempted == ["evt-wo"]
|
||||
assert fake_sqs.sent == []
|
||||
|
||||
skip_logs = [
|
||||
json.loads(rec.message)
|
||||
for rec in caplog.records
|
||||
if rec.message.startswith("{") and '"shoc_skipped_empty_comment"' in rec.message
|
||||
]
|
||||
assert len(skip_logs) == 1
|
||||
assert skip_logs[0]["event"] == "shoc_skipped_empty_comment"
|
||||
assert skip_logs[0]["work_order_id"] == "wo-evt-empty"
|
||||
assert skip_logs[0]["comment_id"] == "wo-evt-empty#nocomment#abc123"
|
||||
|
||||
|
||||
def test_unexpected_error_reports_record_not_whole_batch(monkeypatch, handler_mod):
|
||||
# An unexpected exception (here: SQS park failure on a 4xx rejection) must
|
||||
# NOT escape the loop -- that would fail the invocation and make the ESM
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue