mirror of
https://github.com/Sea-Haven-Industries/procurement-ingest.git
synced 2026-09-30 06:03:14 +00:00
feat: Python derived-field classifier with shadow telemetry (PO PR 2) (#106)
Some checks are pending
Deploy / deploy (push) Waiting to run
Some checks are pending
Deploy / deploy (push) Waiting to run
* feat: Python derived-field classifier with shadow telemetry for PO ingest Port the site_code/trade/fiscal_year rules from EXTRACTION_PROMPT into a pure, total derived_fields module applied in the shared enrich_parsed() post-stage. Python fills gaps on both parse paths (the template path has no LLM values, closing the derived-field gap opened by the PR #105 two-PR split) and never overwrites a non-null LLM value; on ai_fallback a DerivedFieldAgreement EMF record per field shadows Python against the LLM during the bake. Rules hardened against a full-corpus backtest (3,422 real emails vs the LLM-written baseline): site_code 99.4% with zero Python-wrong cases, fiscal_year 100%, trade 96.8% ex-deliberate. Also: quantity/price now declared numeric in the prompt, and derived_fields.py added to the po_stack bundling copy (deploy-time ImportError otherwise). * fix: security-review hardening — EMF value length clamp, aggregate trade CPU budget sh-security-review (4 detectors + proof-or-kill verifier): PASS, 0 confirmed critical/high. Fixes the one confirmed low (unbounded LLM-value str() into the DerivedFieldAgreement EMF log line, clamped to 64 chars) and adds the verifier-recommended defense-in-depth aggregate character budget across line items in derive_trade (per-item caps alone allowed ~10s full-core on a pathological direct-call input; unreachable through the deployed handler but cheap to bound). Bundling cp list now carries a warning comment (GPT-4.1 cross-review FIX).
This commit is contained in:
parent
dc17de4026
commit
8d52cfadef
10 changed files with 1699 additions and 32 deletions
23
README.md
23
README.md
|
|
@ -17,7 +17,7 @@ Coupa PO emails are received at `amazon_po@int.seahaven.com`, parsed **determini
|
|||
2. SES (`INBOUND_MAIL` rule set) drops the raw MIME into `s3://po-ingest-emails-{AccountId}/inbound/`.
|
||||
3. S3 `ObjectCreated` triggers the `po-email-processor` Lambda.
|
||||
4. Fail-closed sender authentication (INFRA-107): the SES-stamped `Authentication-Results` header must show `dkim=pass` for `amazon.coupahost.com` (see [Sender authentication](#sender-authentication-infra-107)); otherwise the email is logged and dropped.
|
||||
5. **Parse:** a pure, offline template parser (`template_parser.py`) tries the two known Coupa templates first — `coupa_new_po` (95.5% of traffic) and `coupa_cancellation` (2.9%) — behind a strict fail-closed validation gate. Only on a miss/invalid result does the Lambda fall back to the Claude-on-Bedrock AI extractor (`InvokeModel`), which extracts the identical structured-JSON contract (PO number, status, supplier, nested ship-to, line items). Numeric amounts are `Decimal` on both paths (the AI decode uses `parse_float=Decimal`; DynamoDB rejects floats). The derived classification fields (`site_code`, `trade`, `fiscal_year`) stay LLM-computed for now: the template parser leaves them `null`, and the shared `enrich_parsed()` post-stage (zip padding, `ship_to_raw`/`state` promotion, metadata) runs identically on both paths so downstream shapes are byte-identical.
|
||||
5. **Parse:** a pure, offline template parser (`template_parser.py`) tries the two known Coupa templates first — `coupa_new_po` (95.5% of traffic) and `coupa_cancellation` (2.9%) — behind a strict fail-closed validation gate. Only on a miss/invalid result does the Lambda fall back to the Claude-on-Bedrock AI extractor (`InvokeModel`), which extracts the identical structured-JSON contract (PO number, status, supplier, nested ship-to, line items). Numeric amounts are `Decimal` on both paths (the AI decode uses `parse_float=Decimal`; DynamoDB rejects floats). The derived classification fields (`site_code`, `trade`, `fiscal_year`) are computed by a deterministic Python classifier in the shared `enrich_parsed()` post-stage (zip padding, `ship_to_raw`/`state` promotion, metadata) which runs identically on both paths — Python-authoritative on the template path and an LLM-authoritative-with-Python-shadow bake on the AI-fallback path (see the **Derived fields** note below).
|
||||
6. Merge write to DynamoDB:
|
||||
- `new_po` — merge insert. Creates the PO, or backfills data into a pre-existing `Cancelled` skeleton left by an out-of-order cancellation (preserving the `Cancelled` status). No longer silently dropped when a record already exists.
|
||||
- `revision` — field-level merge (`update_item` SETs only the fields present in the revision). A revision that omits `line_items`/`supplier` no longer deletes them. Will not un-cancel a `Cancelled` PO.
|
||||
|
|
@ -28,6 +28,27 @@ Coupa PO emails are received at `amazon_po@int.seahaven.com`, parsed **determini
|
|||
|
||||
**Deterministic template parser.** `template_parser.try_deterministic_parse()` classifies by exact subject regex, extracts the nested contract (`supplier{}`, `ship_to{}` — 8 keys, `line_items[]` — 10 keys per item), and returns a parsed result **only if** it passes a fail-closed validation gate: recursive exact key-set at every nesting level; `email_type` emitted **only** on the exact new-PO subject *and* a confirmed-safe `Status` (never defaulted — a cancellation misrouted as `new_po` would defeat the sticky-`Cancelled` guard); `po_number` shape + byte-equality with the subject, the body `PO ID`, the `Amazon Purchase Order #` heading, and the `orders/<id>` URL; duplicate-label anchor integrity (`Supplier`/`Shipping`/`Total` each appear twice, the first `Shipping` must be the literal `None` placeholder); money fidelity (every `Decimal` re-serializes byte-identically to its source token with a digit/comma border check — the thousands-separator-truncation kill switch — plus `sum(line amounts) == total`, proven against **both** `Total` blocks); bullet-metadata label discipline (closed label set, assigned by leading label, never ordinal — immune to the optional `Part Number` segment); USPS address shape on the raw pre-enrichment value; and rejection of any unparseable sentinel or residual `\r`/`\xa0` artifact. Multi-line-item (0.18%) and non-USD (0 observed) new-POs, comment emails, and anything else falls back to the AI extractor. **Data is never corrupted; only the fallback rate rises.** Every record emits one CloudWatch EMF metric (see below).
|
||||
|
||||
**Derived fields (`site_code`, `trade`, `fiscal_year`).** These three are **classified**, not verbatim-extracted, so neither parse path emits them directly — instead a deterministic Python classifier (`derived_fields.derive_all()`) computes them inside the shared `enrich_parsed()` post-stage, which runs identically on both paths. `trade` is a closed label set (the same one the AI prompt enumerates): `Plumbing - PM`, `Plumbing - Reactive`, `Electrical`, `HVAC`, `Dock Doors`, `Doors`, `Signage`, `Carpentry`, `Fencing/Gates`, `Conveyance/MHE`, `Painting`, `Flooring`, `Janitorial`, `Fire/Life Safety`, `Landscaping/Yard`, `Roofing`, `Security/Locksmith`, `Snow Removal`, `PO Uplift`, `General Building - Emergency`, `General Building - Handyman`, `General Building - Project`, `General Building`. Authority differs by path:
|
||||
|
||||
- **Template path** — the parser leaves all three `null`, so Python is authoritative: it fills them.
|
||||
- **AI-fallback path** — the LLM value stays **authoritative during the bake** (Python fills only a gap the LLM left `null`, never overwrites), and Python additionally runs in **shadow mode**: `enrich_parsed()` emits one `DerivedFieldAgreement` EMF record per field comparing the two.
|
||||
|
||||
**`DerivedFieldAgreement` metric** — namespace `Seahaven/PoIngest`, metric name `DerivedFieldAgreement` (Unit Count, value 1), dimensioned **only** by `Field` × `Agreement` (cardinality fixed at 3 × 4). `Agreement` ∈ {`agree` (both non-null, equal after str-strip), `disagree` (both non-null, different), `llm_null_python_filled` (LLM null, Python supplied a value), `python_null` (LLM non-null, Python null)}; the record is skipped entirely when both are null. `po_number`, `PythonValue`, and `LlmValue` ride along as non-dimensioned Logs-Insights properties so a disagreement can be reviewed by example without inflating cardinality. Only the **AI-fallback** path emits these — the template path has no LLM value to shadow.
|
||||
|
||||
Review disagreements during the bake with this CloudWatch Logs Insights query over `/aws/lambda/po-email-processor`:
|
||||
|
||||
```
|
||||
fields @timestamp, Field, Agreement, LlmValue, PythonValue, po_number
|
||||
| filter ispresent(DerivedFieldAgreement)
|
||||
| filter Agreement = "disagree"
|
||||
| sort @timestamp desc
|
||||
| limit 200
|
||||
```
|
||||
|
||||
Or aggregate overall agreement per field: `| filter ispresent(DerivedFieldAgreement) | stats count(*) by Field, Agreement`.
|
||||
|
||||
> **Post-bake follow-up:** once agreement is acceptable, make Python authoritative on the AI-fallback path too (stop keeping the LLM value) and **drop the `site_code`/`trade`/`fiscal_year` rule sections from `EXTRACTION_PROMPT`** — the LLM stays authoritative on the fallback path only until then.
|
||||
|
||||
**Lambdas** (`lambdas/po/`):
|
||||
| Function | Trigger | Purpose |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -247,7 +247,12 @@ class PoIngestStack(Stack):
|
|||
"-c",
|
||||
"pip install --platform manylinux2014_aarch64 --only-binary=:all: "
|
||||
"-r requirements.txt -t /asset-output && "
|
||||
"cp handler.py ses_auth.py template_parser.py /asset-output/",
|
||||
# NOTE: every module handler.py imports as a sibling
|
||||
# MUST be listed here or the deploy ships a Lambda that
|
||||
# ImportErrors at runtime (bit us for template_parser
|
||||
# in PR #105 and nearly for derived_fields in PR #2).
|
||||
"cp handler.py ses_auth.py template_parser.py "
|
||||
"derived_fields.py /asset-output/",
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# PO Template-First Parser — Design, Investigation & Progress
|
||||
|
||||
> **Status:** PR #1 implemented (extraction + gate + wiring + alarm + tests); PR #2 (derived-classifier factoring) not started · **Branch:** `feat/po-template-parser` (stacked on PR #99) · **Last updated:** 2026-07-16
|
||||
> **Status:** PR #1 implemented (extraction + gate + wiring + alarm + tests); PR #2 (derived-classifier factoring — shadow mode) implemented · **Branch:** `feat/po-template-parser` (stacked on PR #99) · **Last updated:** 2026-07-16
|
||||
>
|
||||
> Living document for making the Coupa **purchase-order** email parser *template-first with LLM fallback*, mirroring the work-order (WO) processor's PR #99. Captures the investigation, the data we gathered, the decisions made, the current scaffold, and everything still to do.
|
||||
|
||||
|
|
@ -208,6 +208,18 @@ Plus `missing_required_field` for the required labeled fields (per-item metadata
|
|||
- [x] Committed sanitized `.eml` fixture corpus (header + body scrub, per-file digit cipher, narrow `.gitignore` exception scoped to `lambdas/po/email_processor/tests/fixtures/`).
|
||||
- [x] README updated (PO flow, parser section, alarm numbers + justification, tests, repo layout).
|
||||
|
||||
### PR #2 implementation notes (2026-07-16)
|
||||
|
||||
- **Shared classifier factoring.** `trade`/`site_code`/`fiscal_year` are computed by the pure, total `derived_fields.derive_all(parsed) -> {site_code, trade, fiscal_year}` and applied inside the shared `enrich_parsed()` post-stage, so both parse paths run identical classification.
|
||||
- **Shadow semantics — fill gaps, never overwrite.** For each field: if the incoming value is `null` and Python has a value, Python fills it (both paths); if a value is already present (only possible from the LLM on the ai_fallback path) it is **kept** — the LLM stays authoritative during the bake. The template path always arrives with all three `null` (the gate enforces the parser leaves them unset), so it is effectively Python-authoritative.
|
||||
- **EMF metric shape.** On the **ai_fallback path only**, one `DerivedFieldAgreement` record per field is emitted (namespace `Seahaven/PoIngest`, value 1, dims `[["Field","Agreement"]]`). `Agreement` ∈ `agree` | `disagree` | `llm_null_python_filled` | `python_null`; skipped when both values are `null`. `po_number`/`PythonValue`/`LlmValue` are non-dimensioned ride-alongs (cardinality fixed at Field × Agreement). The template path emits **no** agreement metric. The whole block is wrapped defensively so no classification/telemetry error can fail this S3-async invocation (an uncaught exception → retry storm → DLQ).
|
||||
- **`quantity`/`price` prompt tweak (deferred from PR #1) — done.** `EXTRACTION_PROMPT` now declares line-item `quantity`/`price` as `"number or null"` (was `"string or null"`); `parse_float=Decimal` already handles numerics and the `enrich_parsed` Decimal coercion stays as the safety net for a non-conforming model. The `site_code`/`fiscal_year`/`trade` **rule sections of the prompt are left intact** — the LLM stays authoritative on the fallback path until the post-bake follow-up.
|
||||
- **Backtest agreement stats (final, 2026-07-16).** Harness: every harvested real inbound email (3,422 objects; corpus is the rolling ~90-day S3 window) → template parse → `derive_all()` → per-field compare against the LLM-written values in `purchase-orders` (17,605-record scan). Population: 3,190 template-parsed new_po / 100 cancellations / 132 fallbacks (matches PR #1 triage). The `llm_null_python_filled` buckets (478/577/564 per field) are all records processed pre-May 2026 with no `data_source` attribute — an older prompt/writer era; Python filling those is strictly additive. On the modern-era comparable set:
|
||||
- `site_code` — 2,695/2,712 **99.4%**, and **zero Python-wrong**: all 17 misses are LLM errors (16× PO-prefix `2D` stored as a site code, 1× `4101` garbage vs Python's correct `DRN5`).
|
||||
- `fiscal_year` — 2,626/2,626 **100%**.
|
||||
- `trade` — 2,188/2,613 raw (83.7%); **96.8% ex-deliberate**. The 425 mismatches decompose: 316 deliberate prompt-faithful `General Building - General Building Project` → `General Building - Project` (the LLM ignored the prompt's own BBM rule — biggest intentional behavior shift, ~10% of POs); 14 bollard → `Fencing/Gates` (explicit prompt keyword); 7 `General Building Technician` → Handyman (prompt rule); 16 LLM off-menu labels (`Electrical - Emergency`, `Plumbing - Project`, `General Building - Locksmith` — closed label set wins); 18 BBM plumbing rows where the LLM is internally inconsistent (the ladder follows the per-row LLM majority, e.g. Technician→PM 116:9, fixture-repair→Reactive ~140:2); ~54 residual free-text one-offs (2.1%).
|
||||
- Corpus-driven rules beyond the prompt (recorded here as the authoritative spec deltas): site-code shapes for code-first dash names (`WPT2 - Amazon.com Services LLC`), leading token (`QDE1 (Co-located inside WDE1)`), mid-name token with digit (`Amazon Fresh UVA5 Non-Inv (Prime)`), free description token 4-5 chars w/ digit (`... aqt DSF7`), rightmost-token parens/ATTN resolution, ≥2-alphabetic-char token floor (kills `B187`, `2D`, `4101`), skip-list additions LLC/INC/CORP/LTD/ATTN; trade BBM plumbing 5-rung ladder and `conveyance` keyword.
|
||||
|
||||
---
|
||||
|
||||
## 9. TODO / Path Forward
|
||||
|
|
@ -224,8 +236,8 @@ Plus `missing_required_field` for the required labeled fields (per-item metadata
|
|||
- [x] `ruff check` + `ruff format` + `pytest` green locally (354 tests; CI must confirm).
|
||||
|
||||
**PR #2 — derived-classifier factoring (follow-up)**
|
||||
- [ ] Move `trade`/`site_code`/`fiscal_year` into shared `enrich_parsed()`.
|
||||
- [ ] Run in **shadow mode**: compute Python values, log Python-vs-LLM disagreement via EMF, keep LLM authoritative during a bake period.
|
||||
- [x] Move `trade`/`site_code`/`fiscal_year` into shared `enrich_parsed()` (via `derived_fields.derive_all()`).
|
||||
- [x] Run in **shadow mode**: compute Python values, log Python-vs-LLM disagreement via the `DerivedFieldAgreement` EMF metric, keep LLM authoritative during a bake period.
|
||||
- [ ] Harden `site_code` (7-shape + skip-list, incl. multi-hop ATTN like `CBRE - RME - DLI6`) and `trade` against the full sample with per-shape fixtures.
|
||||
- [ ] Only after acceptable agreement: make Python authoritative and drop the fields from `EXTRACTION_PROMPT`.
|
||||
|
||||
|
|
|
|||
676
lambdas/po/email_processor/derived_fields.py
Normal file
676
lambdas/po/email_processor/derived_fields.py
Normal file
|
|
@ -0,0 +1,676 @@
|
|||
"""Deterministic derived-field classifiers for Coupa purchase order emails.
|
||||
|
||||
Pure module: stdlib only -- no boto3, no network, no imports from handler or
|
||||
template_parser. Computes the three DERIVED_KEYS (site_code, fiscal_year, trade)
|
||||
that the template parser and the LLM path both leave for a shared post-stage to
|
||||
fill identically (see template_parser.py DERIVED_KEYS / enrich_parsed).
|
||||
|
||||
This is a FAITHFUL v1 port of the English rules in the handler's
|
||||
EXTRACTION_PROMPT sections "## site_code extraction", "## fiscal_year" and
|
||||
"## trade classification". A corpus backtest will harden the thresholds later;
|
||||
this module intentionally invents no rule beyond those three sections.
|
||||
|
||||
TOTALITY (hard requirement): this runs on the untrusted email path in an
|
||||
S3-async Lambda, where an uncaught exception means infinite retry -> DLQ ->
|
||||
silent data loss. Every public function therefore mirrors
|
||||
template_parser.try_deterministic_parse: it wraps its whole body in a broad
|
||||
try/except and NEVER raises -- a bad/hostile input yields None (or an all-None
|
||||
dict for derive_all), never a traceback. Inputs are treated as hostile: wrong
|
||||
types, None, non-dict line items and multi-megabyte strings are all tolerated.
|
||||
Scans are length-capped and every regex is linear (no nested quantifiers, no
|
||||
catastrophic backtracking).
|
||||
|
||||
Public API:
|
||||
derive_site_code(parsed) -> str | None
|
||||
derive_fiscal_year(parsed) -> str | None
|
||||
derive_trade(parsed) -> str | None
|
||||
derive_all(parsed) -> {"site_code":..., "trade":..., "fiscal_year":...}
|
||||
|
||||
`parsed` is the post-extraction contract dict from either path. Relevant inputs:
|
||||
parsed["ship_to"]["name"], parsed["ship_to"]["attn"],
|
||||
parsed["line_items"][*]["description"|"amount"|"need_by"], parsed["order_date"].
|
||||
"""
|
||||
|
||||
import re
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
# --- scan caps (hostile-input blast-radius limits) ---------------------------
|
||||
_MAX_ITEMS = 500 # line_items scanned at most
|
||||
_MAX_NAME = 4096 # ship_to name/attn chars scanned for a site code
|
||||
_MAX_DESC = 4096 # description chars scanned for a bracketed/standalone code
|
||||
_MAX_DATE = 1024 # date-string chars scanned for a year
|
||||
_MAX_TRADE = 100_000 # description chars scanned for trade keywords (per item)
|
||||
_MAX_TRADE_TOTAL = 200_000 # aggregate description chars classified per PO
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# site_code
|
||||
# ---------------------------------------------------------------------------
|
||||
# Token shape: 3-5 chars, uppercase A-Z0-9, MUST start with a letter. Codes may
|
||||
# be all letters (e.g. KLAL) -- a digit is NOT required.
|
||||
_STRICT_CODE_RE = re.compile(r"[A-Z][A-Z0-9]{2,4}")
|
||||
# Same shape, but only as a standalone token (not embedded in a longer
|
||||
# alphanumeric run): '–WKY3' matches, 'Station' does not, 'DLI6X99' does not.
|
||||
_CODE_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9])[A-Z][A-Z0-9]{2,4}(?![A-Za-z0-9])")
|
||||
_PAREN_RE = re.compile(r"\(([^()\n]{1,40})\)")
|
||||
_BRACKET_CODE_RE = re.compile(r"\[([A-Z][A-Z0-9]{2,4})\]")
|
||||
# Description prefix: leading code then a dash/en-dash/em-dash separator.
|
||||
_PREFIX_CODE_RE = re.compile(r"\s*([A-Z][A-Z0-9]{2,4})\s*[-–—]")
|
||||
# Leading code-shaped token that opens the ship-to name and is delimited by a
|
||||
# space, dash or '(' -- e.g. 'WPT2 - Amazon...' / 'QDE1 (Co-located ...)'.
|
||||
_LEADING_CODE_RE = re.compile(r"([A-Z][A-Z0-9]{2,4})(?=[\s\-–—(])")
|
||||
# Free-token code in a description: standalone, length 4-5, letter-first.
|
||||
_FREE_CODE_TOKEN_RE = re.compile(r"(?<![A-Za-z0-9])[A-Z][A-Z0-9]{3,4}(?![A-Za-z0-9])")
|
||||
_DASHES = "-–—"
|
||||
_DASH_SPLIT_RE = re.compile(r"[-–—]")
|
||||
|
||||
# Never a site_code (from the prompt's "Not site codes" list). If the only
|
||||
# candidate for a shape is skip-listed, that shape yields None and evaluation
|
||||
# falls through to the next lower-priority shape.
|
||||
_SKIP = frozenset(
|
||||
{
|
||||
"RME",
|
||||
"BBM",
|
||||
"JLL",
|
||||
"PARAG",
|
||||
"ERIK",
|
||||
"HVAC",
|
||||
"LED",
|
||||
"PVC",
|
||||
"ADA",
|
||||
"OSHA",
|
||||
"EMR",
|
||||
"BMS",
|
||||
"DDC",
|
||||
"MRO",
|
||||
"NTE",
|
||||
"EST",
|
||||
# Corporate-suffix / addressing tokens -- never a site code.
|
||||
"LLC",
|
||||
"INC",
|
||||
"CORP",
|
||||
"LTD",
|
||||
"ATTN",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _is_valid_code(tok):
|
||||
"""A code-shaped token is a real site code only if it is not skip-listed AND
|
||||
carries >=2 alphabetic characters.
|
||||
|
||||
Measured over all 1,063 distinct LLM-extracted codes: 999 have 3 letters, 7
|
||||
have 4, 3 have 5, and NONE have fewer than 2. Sub-2-letter tokens (e.g.
|
||||
'B187') are PO-prefix-style garbage, so every site-code shape rejects them.
|
||||
"""
|
||||
return tok not in _SKIP and sum(c.isalpha() for c in tok) >= 2
|
||||
|
||||
|
||||
def _last_valid_code(text):
|
||||
"""Rightmost valid code-shaped token in `text`.
|
||||
|
||||
This is the multi-hop resolver: an ATTN like 'CBRE - RME - DLI6' yields the
|
||||
rightmost code that passes _is_valid_code (DLI6), skipping the RME hop.
|
||||
"""
|
||||
for tok in reversed(_CODE_TOKEN_RE.findall(text)):
|
||||
if _is_valid_code(tok):
|
||||
return tok
|
||||
return None
|
||||
|
||||
|
||||
def _code_name_leading(name):
|
||||
"""Leading shape (highest priority): the name OPENS with a standalone
|
||||
code-shaped token containing >=1 digit, delimited by space/dash/'(' --
|
||||
'WPT2 - Amazon...' -> WPT2, 'QDE1 (Co-located inside WDE1)' -> QDE1.
|
||||
|
||||
The digit requirement + leading position outrank the parens shape, so a
|
||||
'QDE1 (... WDE1)' resolves to the opener QDE1, not the host building WDE1.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
m = _LEADING_CODE_RE.match(name.strip()[:_MAX_NAME])
|
||||
if not m:
|
||||
return None
|
||||
tok = m.group(1)
|
||||
if _is_valid_code(tok) and any(c.isdigit() for c in tok):
|
||||
return tok
|
||||
return None
|
||||
|
||||
|
||||
def _code_name_parens(name):
|
||||
"""Shape 1: ship-to name in parentheses -- 'Services LLC (KLAL)' -> KLAL.
|
||||
|
||||
Uses the rightmost-non-skip resolver (same as ATTN) so parens content like
|
||||
'(ATTN: Wagon Wheel DS Station -WKY3)' resolves to WKY3, not the skip-listed
|
||||
ATTN hop.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
for content in _PAREN_RE.findall(name[:_MAX_NAME]):
|
||||
code = _last_valid_code(content)
|
||||
if code:
|
||||
return code
|
||||
return None
|
||||
|
||||
|
||||
def _code_name_after_dash(name):
|
||||
"""Shape 2: ship-to name with a dash.
|
||||
|
||||
Real ship-to names carry the code on EITHER side of the dash -- 'Amazon...
|
||||
LLC - SNY5' (after) and 'WPT2 - Amazon... LLC' (before). First prefer any
|
||||
segment that IS exactly a code-shaped token (leftmost such segment wins);
|
||||
only if no whole segment is an exact code, fall back to scanning the tail
|
||||
after the last dash (so a code-shaped word before the dash, e.g. 'LLC',
|
||||
can never leak in when the real after-dash code is skip-listed).
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
s = name[:_MAX_NAME]
|
||||
idx = max(s.rfind(d) for d in _DASHES)
|
||||
if idx == -1:
|
||||
return None
|
||||
for seg in _DASH_SPLIT_RE.split(s):
|
||||
tok = seg.strip()
|
||||
if _STRICT_CODE_RE.fullmatch(tok) and _is_valid_code(tok):
|
||||
return tok
|
||||
return _last_valid_code(s[idx + 1 :])
|
||||
|
||||
|
||||
def _code_name_midtoken(name):
|
||||
"""Mid-name shape (lower priority): a standalone uppercase code-shaped token
|
||||
with >=1 digit anywhere in the name -- 'Amazon Fresh UVA5 Non-Inv (Prime)'
|
||||
-> UVA5. The digit requirement means all-letter words (FRESH, ...) can never
|
||||
match; mixed-case words never match the uppercase token shape at all.
|
||||
"""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
for tok in _CODE_TOKEN_RE.findall(name[:_MAX_NAME]):
|
||||
if _is_valid_code(tok) and any(c.isdigit() for c in tok):
|
||||
return tok
|
||||
return None
|
||||
|
||||
|
||||
def _code_from_attn(attn):
|
||||
"""Shapes 3 & 4: ship-to ATTN line (with dash/en-dash, or direct).
|
||||
|
||||
Unified: the rightmost non-skip code-shaped token across the whole ATTN
|
||||
value. Covers 'Wagon Wheel DS Station -WKY3' -> WKY3, 'HJX1' -> HJX1, and
|
||||
the multi-hop 'CBRE - RME - DLI6' -> DLI6.
|
||||
"""
|
||||
if not isinstance(attn, str):
|
||||
return None
|
||||
return _last_valid_code(attn[:_MAX_NAME])
|
||||
|
||||
|
||||
def _code_name_is(name):
|
||||
"""Shape 5: the ship-to name IS the code -- 'DBU2' -> DBU2."""
|
||||
if not isinstance(name, str):
|
||||
return None
|
||||
n = name.strip()
|
||||
if _STRICT_CODE_RE.fullmatch(n) and _is_valid_code(n):
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def _code_desc_prefix(desc):
|
||||
"""Shape 6: line-item description prefix -- 'DYO1 - ... - ...' -> DYO1."""
|
||||
if not isinstance(desc, str):
|
||||
return None
|
||||
m = _PREFIX_CODE_RE.match(desc[:80])
|
||||
if m and _is_valid_code(m.group(1)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _code_desc_bracket(desc):
|
||||
"""Shape 7: line-item description in brackets -- '[HMK4] ...' -> HMK4."""
|
||||
if not isinstance(desc, str):
|
||||
return None
|
||||
for m in _BRACKET_CODE_RE.finditer(desc[:_MAX_DESC]):
|
||||
if _is_valid_code(m.group(1)):
|
||||
return m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _code_desc_freetoken(desc):
|
||||
"""Shape 8 (lowest priority): a standalone uppercase token, length 4-5,
|
||||
letter-first, with >=1 digit, anywhere in a description -- 'Need Sea Haven
|
||||
to pump out waste water aqt DSF7' -> DSF7. The length-4 floor (vs the 3-char
|
||||
floor of other shapes) and the digit requirement keep this loose free-scan
|
||||
from grabbing 3-letter uppercase words or all-letter tokens mid-sentence.
|
||||
"""
|
||||
if not isinstance(desc, str):
|
||||
return None
|
||||
for tok in _FREE_CODE_TOKEN_RE.findall(desc[:_MAX_DESC]):
|
||||
if _is_valid_code(tok) and any(c.isdigit() for c in tok):
|
||||
return tok
|
||||
return None
|
||||
|
||||
|
||||
def _iter_items(parsed):
|
||||
"""Yield up to _MAX_ITEMS dict line items from parsed; tolerant of junk."""
|
||||
items = parsed.get("line_items")
|
||||
if not isinstance(items, list):
|
||||
return
|
||||
for item in items[:_MAX_ITEMS]:
|
||||
if isinstance(item, dict):
|
||||
yield item
|
||||
|
||||
|
||||
def derive_site_code(parsed):
|
||||
"""Derive the Amazon facility site_code, or None. NEVER raises.
|
||||
|
||||
Shapes are tried in strict priority order. A skip-listed sole candidate for
|
||||
a shape yields None for that shape and evaluation continues to the next:
|
||||
|
||||
leading -- name opens with a digit-bearing code ('WPT2 - ...')
|
||||
1 parens -- code inside '(...)'
|
||||
2 dash -- exact code-shaped segment either side of a dash
|
||||
3/4 attn -- rightmost non-skip code in the ATTN line
|
||||
5 name-is-- the name IS the code
|
||||
midtoken -- a digit-bearing code anywhere in the name
|
||||
6 prefix -- description leading code
|
||||
7 bracket-- description '[CODE]'
|
||||
8 free -- a length 4-5 digit-bearing code anywhere in a description
|
||||
"""
|
||||
try:
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
ship_to = parsed.get("ship_to")
|
||||
if not isinstance(ship_to, dict):
|
||||
ship_to = {}
|
||||
name = ship_to.get("name")
|
||||
attn = ship_to.get("attn")
|
||||
|
||||
for shape in (
|
||||
_code_name_leading(name), # leading (highest)
|
||||
_code_name_parens(name), # 1
|
||||
_code_name_after_dash(name), # 2
|
||||
_code_from_attn(attn), # 3 & 4
|
||||
_code_name_is(name), # 5
|
||||
_code_name_midtoken(name), # mid-name
|
||||
):
|
||||
if shape:
|
||||
return shape
|
||||
|
||||
for item in _iter_items(parsed): # 6
|
||||
code = _code_desc_prefix(item.get("description"))
|
||||
if code:
|
||||
return code
|
||||
for item in _iter_items(parsed): # 7
|
||||
code = _code_desc_bracket(item.get("description"))
|
||||
if code:
|
||||
return code
|
||||
for item in _iter_items(parsed): # 8
|
||||
code = _code_desc_freetoken(item.get("description"))
|
||||
if code:
|
||||
return code
|
||||
return None
|
||||
except Exception: # noqa: BLE001 -- totality: never raise on the email path
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fiscal_year
|
||||
# ---------------------------------------------------------------------------
|
||||
# A standalone 4-digit 20xx year. Word-bounded so it never matches digits buried
|
||||
# inside a longer run (e.g. the '2062' inside a PO id '18206023').
|
||||
_YEAR_RE = re.compile(r"\b(20\d{2})\b")
|
||||
# MM/DD/YY -> the trailing 2-digit year is expanded to 20YY.
|
||||
_SHORT_DATE_RE = re.compile(r"\b\d{1,2}/\d{1,2}/(\d{2})\b")
|
||||
|
||||
|
||||
def _year_from_date(value):
|
||||
"""Extract a 4-digit year from a date string: a literal 20xx, else a
|
||||
MM/DD/YY whose YY expands to 20YY. Returns the 4-digit string or None."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
s = value[:_MAX_DATE]
|
||||
m = _YEAR_RE.search(s)
|
||||
if m:
|
||||
return m.group(1)
|
||||
m = _SHORT_DATE_RE.search(s)
|
||||
if m:
|
||||
return "20" + m.group(1)
|
||||
return None
|
||||
|
||||
|
||||
def _year_from_text(value):
|
||||
"""A standalone 20xx year anywhere in free text, or None."""
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
m = _YEAR_RE.search(value[:_MAX_DESC])
|
||||
return m.group(1) if m else None
|
||||
|
||||
|
||||
def derive_fiscal_year(parsed):
|
||||
"""Derive the 4-digit fiscal_year string, or None. NEVER raises.
|
||||
|
||||
Fallback order: (1) order_date year, (2) any line-item need_by year, (3) a
|
||||
standalone 20xx year in any line-item description.
|
||||
"""
|
||||
try:
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
year = _year_from_date(parsed.get("order_date"))
|
||||
if year:
|
||||
return year
|
||||
for item in _iter_items(parsed):
|
||||
year = _year_from_date(item.get("need_by"))
|
||||
if year:
|
||||
return year
|
||||
for item in _iter_items(parsed):
|
||||
year = _year_from_text(item.get("description"))
|
||||
if year:
|
||||
return year
|
||||
return None
|
||||
except Exception: # noqa: BLE001 -- totality: never raise on the email path
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# trade
|
||||
# ---------------------------------------------------------------------------
|
||||
# Keyword matching interpretation (judgment call, documented): each keyword is
|
||||
# matched with a LEADING word boundary and no trailing boundary -- i.e. it hits
|
||||
# the keyword as a whole word OR as the prefix of a longer word. This makes
|
||||
# 'sign' match 'signage', 'dock door' match 'dock doors', and 'roof' match
|
||||
# 'roofing' (desired), while NOT matching a keyword buried mid-word ('ice' in
|
||||
# 'service'/'price', 'lock' in 'block'/'clock', 'gate' in 'mitigate') -- the
|
||||
# catastrophic false positives a bare substring test would produce. All matching
|
||||
# is done on a lower-cased, length-capped copy of the description.
|
||||
|
||||
|
||||
def _kw(*words):
|
||||
"""Compile a linear leading-boundary alternation of literal keywords."""
|
||||
body = "|".join(re.escape(w) for w in words)
|
||||
return re.compile(r"\b(?:" + body + r")")
|
||||
|
||||
|
||||
_PM_RE = _kw(
|
||||
"plumbing pm",
|
||||
"plumbing preventative",
|
||||
"plumbing maintenance",
|
||||
"plumbing - backflow",
|
||||
"plumbing - water heater",
|
||||
)
|
||||
_PLUMBING_RE = _kw("plumbing")
|
||||
# BBM-structured 'Plumbing - <Fixture> - <Action> - BBM' row: 'plumbing' opening
|
||||
# the description immediately followed by a dash. Resolved by a measured ladder
|
||||
# (see _classify_desc) that matches the LLM baseline over the full corpus.
|
||||
_BBM_PLUMBING_RE = re.compile(r"plumbing\s*[-–—]")
|
||||
# Ladder rungs for a BBM plumbing row, checked in order:
|
||||
# (a) emergency/reactive -> Reactive
|
||||
_BBM_PLUMBING_REACTIVE_WORD_RE = _kw("reactive", "emergency")
|
||||
# (b) technician -> PM
|
||||
_BBM_PLUMBING_TECH_RE = _kw("technician")
|
||||
# (c) water heater / backflow / water fountain -> PM (before (d), so a
|
||||
# 'water heater repair' hits PM here and not Reactive on 'repair')
|
||||
_BBM_PLUMBING_PM_RE = _kw("water heater", "backflow", "water fountain")
|
||||
# (d) clog/unclog/leak/repair/sewer/drain -> Reactive ('clog'/'leak' prefixes
|
||||
# also catch 'clogged'/'leaking')
|
||||
_BBM_PLUMBING_REACTIVE_RE = _kw("clog", "unclog", "leak", "repair", "sewer", "drain")
|
||||
# (e) else (project, install, misc) -> PM
|
||||
# Plumbing-specific qualifiers -- these indicate Plumbing - Reactive on their
|
||||
# OWN, without the word 'plumbing' present ('CLOGGED PIT AUGER' -> Reactive).
|
||||
_PLUMBING_SPECIFIC_RE = _kw(
|
||||
"clog",
|
||||
"unclog",
|
||||
"sewer",
|
||||
"drain",
|
||||
"grease trap",
|
||||
"jetter",
|
||||
"toilet",
|
||||
"faucet",
|
||||
"urinal",
|
||||
)
|
||||
# Generic reactive qualifiers -- these require the word 'plumbing' to be present
|
||||
# before they route to Plumbing - Reactive.
|
||||
_PLUMBING_GENERIC_RE = _kw(
|
||||
"reactive",
|
||||
"emergency",
|
||||
"repair",
|
||||
"leak",
|
||||
"flood",
|
||||
"water line",
|
||||
"pipe",
|
||||
)
|
||||
_ELECTRICAL_RE = _kw(
|
||||
"electrical",
|
||||
"lighting",
|
||||
"ballast",
|
||||
"outlet",
|
||||
"circuit",
|
||||
"panel",
|
||||
"generator",
|
||||
"transformer",
|
||||
"conduit",
|
||||
)
|
||||
_HVAC_RE = _kw(
|
||||
"hvac",
|
||||
"heating",
|
||||
"cooling",
|
||||
"air conditioning",
|
||||
"rtu",
|
||||
"ahu",
|
||||
"vav",
|
||||
"refrigerant",
|
||||
"thermostat",
|
||||
"ductwork",
|
||||
)
|
||||
_DOCK_DOORS_RE = _kw(
|
||||
"dock door",
|
||||
"dock leveler",
|
||||
"dock plate",
|
||||
"dock seal",
|
||||
"dock bumper",
|
||||
)
|
||||
_DOORS_RE = _kw("door", "overhead door", "roll-up", "automatic door", "access door")
|
||||
_SIGNAGE_RE = _kw("sign", "banner", "wayfinding", "marquee", "directional")
|
||||
_CARPENTRY_RE = _kw("carpentry", "cabinet", "millwork", "trim", "shelving", "framing")
|
||||
_FENCE_RE = _kw("fence", "fencing", "bollard")
|
||||
_GATE_RE = _kw("gate")
|
||||
_CONVEYANCE_RE = _kw("conveyor", "conveyance", "mhe", "material handling", "sortation")
|
||||
_PAINTING_RE = _kw("paint", "painting", "primer", "coating", "touch-up")
|
||||
_FLOORING_RE = _kw("floor", "tile", "carpet", "epoxy", "polishing")
|
||||
_JANITORIAL_RE = _kw(
|
||||
"janitorial", "cleaning", "custodial", "pressure wash", "power wash"
|
||||
)
|
||||
_FIRE_RE = _kw("fire", "sprinkler", "extinguisher", "fire alarm", "suppression")
|
||||
_LANDSCAPING_RE = _kw("landscape", "lawn", "tree", "yard", "mowing", "irrigation")
|
||||
_ROOFING_RE = _kw("roof", "roofing", "gutter", "downspout")
|
||||
# Security/Locksmith: 'lock' and 'key' match as EXACT whole words only (so
|
||||
# 'Locker' is not 'lock' and 'keyboard' is not 'key'); the multi-word terms keep
|
||||
# the leading-boundary/prefix behavior. The phrase 'key box' is stripped before
|
||||
# this test (see _classify_desc) so a Key Box fixture never triggers Locksmith.
|
||||
_SECURITY_RE = re.compile(r"\b(?:lock|key)\b|\b(?:access control|camera|security|cctv)")
|
||||
_SNOW_RE = _kw("snow", "ice", "salt", "de-ice", "plow")
|
||||
_EMERGENCY_RE = _kw("emergency")
|
||||
# General Building catch-all sub-shapes.
|
||||
_HANDYMAN_RE = re.compile(r"\bhandyman\b")
|
||||
_PROJECT_RE = re.compile(r"\bproject\b")
|
||||
|
||||
TRADE_UPLIFT = "PO Uplift"
|
||||
TRADE_GENERAL_BUILDING = "General Building"
|
||||
|
||||
# Simple (single-regex) trades, in strict priority order. The compound and
|
||||
# exception-bearing trades (Plumbing, Electrical, Fencing/Gates, the General
|
||||
# Building family, PO Uplift) are handled inline in _classify_desc.
|
||||
_SIMPLE_TRADES = (
|
||||
(_HVAC_RE, "HVAC"),
|
||||
(_DOCK_DOORS_RE, "Dock Doors"),
|
||||
(_DOORS_RE, "Doors"),
|
||||
(_SIGNAGE_RE, "Signage"),
|
||||
(_CARPENTRY_RE, "Carpentry"),
|
||||
)
|
||||
# Simple trades that follow Fencing/Gates in the priority table. Security and
|
||||
# Snow are handled explicitly after this loop (Security needs the 'key box'
|
||||
# exclusion applied to its match target, so it can't share the generic loop).
|
||||
_SIMPLE_TRADES_TAIL = (
|
||||
(_CONVEYANCE_RE, "Conveyance/MHE"),
|
||||
(_PAINTING_RE, "Painting"),
|
||||
(_FLOORING_RE, "Flooring"),
|
||||
(_JANITORIAL_RE, "Janitorial"),
|
||||
(_FIRE_RE, "Fire/Life Safety"),
|
||||
(_LANDSCAPING_RE, "Landscaping/Yard"),
|
||||
(_ROOFING_RE, "Roofing"),
|
||||
)
|
||||
|
||||
|
||||
def _classify_desc(desc):
|
||||
"""Classify a single non-empty description into one trade label.
|
||||
|
||||
Returns a label for any non-empty description -- 'General Building' is the
|
||||
catch-all when nothing else matches. Callers must not pass empty/blank
|
||||
descriptions (that case is handled upstream so it can return None).
|
||||
"""
|
||||
low = desc[:_MAX_TRADE].lower()
|
||||
stripped = low.strip()
|
||||
|
||||
# PO Uplift: exactly or primarily "PO Uplift".
|
||||
if stripped == "po uplift" or stripped.startswith("po uplift"):
|
||||
return TRADE_UPLIFT
|
||||
|
||||
# BBM-structured 'Plumbing - ...' row: resolved by a measured ladder tuned to
|
||||
# the LLM baseline over the full corpus (first rung wins).
|
||||
if _BBM_PLUMBING_RE.match(stripped):
|
||||
if _BBM_PLUMBING_REACTIVE_WORD_RE.search(low): # (a) emergency/reactive
|
||||
return "Plumbing - Reactive"
|
||||
if _BBM_PLUMBING_TECH_RE.search(low): # (b) technician
|
||||
return "Plumbing - PM"
|
||||
if _BBM_PLUMBING_PM_RE.search(low): # (c) heater/backflow/fountain
|
||||
return "Plumbing - PM"
|
||||
if _BBM_PLUMBING_REACTIVE_RE.search(low): # (d) clog/leak/repair/...
|
||||
return "Plumbing - Reactive"
|
||||
return "Plumbing - PM" # (e) project/install/misc
|
||||
# Plumbing - PM (before Reactive) for non-BBM free text.
|
||||
if _PM_RE.search(low):
|
||||
return "Plumbing - PM"
|
||||
# Plumbing - Reactive: a plumbing-specific qualifier ALONE (clog, drain,
|
||||
# toilet, ...), or the word 'plumbing' PLUS a generic reactive qualifier.
|
||||
if _PLUMBING_SPECIFIC_RE.search(low):
|
||||
return "Plumbing - Reactive"
|
||||
if _PLUMBING_RE.search(low) and _PLUMBING_GENERIC_RE.search(low):
|
||||
return "Plumbing - Reactive"
|
||||
|
||||
# Electrical -- but electrical keywords do NOT match in a dock-door context.
|
||||
if _ELECTRICAL_RE.search(low) and "dock door" not in low:
|
||||
return "Electrical"
|
||||
|
||||
for regex, label in _SIMPLE_TRADES:
|
||||
if regex.search(low):
|
||||
return label
|
||||
|
||||
# Fencing/Gates: fence/fencing/bollard anywhere, or 'gate' but NOT 'dock
|
||||
# gate' (the dock-gate occurrences are removed before the gate test).
|
||||
if _FENCE_RE.search(low) or _GATE_RE.search(low.replace("dock gate", " ")):
|
||||
return "Fencing/Gates"
|
||||
|
||||
for regex, label in _SIMPLE_TRADES_TAIL:
|
||||
if regex.search(low):
|
||||
return label
|
||||
|
||||
# Security/Locksmith: 'lock'/'key' as whole words, with 'key box' stripped
|
||||
# first (mirrors the 'dock gate' exclusion) so a Key Box fixture is not one.
|
||||
if _SECURITY_RE.search(low.replace("key box", " ")):
|
||||
return "Security/Locksmith"
|
||||
if _SNOW_RE.search(low):
|
||||
return "Snow Removal"
|
||||
|
||||
# General Building family (catch-alls, in priority order).
|
||||
if stripped.startswith("emer") or _EMERGENCY_RE.search(low):
|
||||
return "General Building - Emergency"
|
||||
if _HANDYMAN_RE.search(low) or "general building technician" in low:
|
||||
return "General Building - Handyman"
|
||||
if "general building project" in low or _PROJECT_RE.search(low):
|
||||
return "General Building - Project"
|
||||
# Keyword-less BBM 'General Building - <Fixture> - ...' rows (parking lot,
|
||||
# fan, ceiling, television, locker, key box, ...) fall through here to the
|
||||
# plain catch-all -- that matches the measured LLM majority for such rows.
|
||||
return TRADE_GENERAL_BUILDING
|
||||
|
||||
|
||||
def _to_amount(value):
|
||||
"""Coerce a line amount (Decimal | str | int/float | junk) to a Decimal for
|
||||
comparison. Anything unparseable becomes Decimal(0) -- defensive, never
|
||||
raises, so an item with a bad amount simply cannot win the highest-value
|
||||
tie-break."""
|
||||
if isinstance(value, bool):
|
||||
return Decimal(0)
|
||||
if isinstance(value, Decimal):
|
||||
return value if value.is_finite() else Decimal(0)
|
||||
if isinstance(value, int):
|
||||
return Decimal(value)
|
||||
if isinstance(value, float):
|
||||
try:
|
||||
d = Decimal(str(value))
|
||||
return d if d.is_finite() else Decimal(0)
|
||||
except InvalidOperation:
|
||||
return Decimal(0)
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
d = Decimal(value.replace(",", "").strip())
|
||||
return d if d.is_finite() else Decimal(0)
|
||||
except (InvalidOperation, ValueError):
|
||||
return Decimal(0)
|
||||
return Decimal(0)
|
||||
|
||||
|
||||
def derive_trade(parsed):
|
||||
"""Derive the PO's primary trade label, or None. NEVER raises.
|
||||
|
||||
Each line item with a usable description is classified. The PO trade is the
|
||||
primary non-"PO Uplift" trade; when several distinct non-uplift trades are
|
||||
present, the trade of the highest-`amount` non-uplift item wins. If every
|
||||
described item is PO Uplift, returns "PO Uplift". With no line items or no
|
||||
usable descriptions at all, returns None ("General Building" is only ever
|
||||
returned for a description that matched nothing).
|
||||
"""
|
||||
try:
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
classified = [] # (trade_label, amount_decimal)
|
||||
# Aggregate CPU budget across ALL items: the per-item _MAX_TRADE cap
|
||||
# alone still allows _MAX_ITEMS x _MAX_TRADE x ~25 regex passes
|
||||
# (~10s full-core, minutes under the 256MB Lambda's CPU throttle ->
|
||||
# timeout -> async retry -> DLQ). Real POs total well under 10k chars
|
||||
# of description text; stop classifying once the budget is spent and
|
||||
# decide from what was classified (sh-security-review
|
||||
# PO-DERIVED-AVAIL-001 defense-in-depth).
|
||||
budget = _MAX_TRADE_TOTAL
|
||||
for item in _iter_items(parsed):
|
||||
desc = item.get("description")
|
||||
if not isinstance(desc, str) or not desc.strip():
|
||||
continue
|
||||
if budget <= 0:
|
||||
break
|
||||
desc = desc[:budget]
|
||||
budget -= len(desc)
|
||||
classified.append((_classify_desc(desc), _to_amount(item.get("amount"))))
|
||||
if not classified:
|
||||
return None
|
||||
|
||||
non_uplift = [(t, a) for (t, a) in classified if t != TRADE_UPLIFT]
|
||||
if not non_uplift:
|
||||
return TRADE_UPLIFT
|
||||
if len({t for (t, _) in non_uplift}) == 1:
|
||||
return non_uplift[0][0]
|
||||
best_trade, best_amount = non_uplift[0]
|
||||
for trade, amount in non_uplift[1:]:
|
||||
if amount > best_amount:
|
||||
best_trade, best_amount = trade, amount
|
||||
return best_trade
|
||||
except Exception: # noqa: BLE001 -- totality: never raise on the email path
|
||||
return None
|
||||
|
||||
|
||||
def derive_all(parsed):
|
||||
"""All three derived fields as a dict. NEVER raises: on any failure returns
|
||||
an all-None dict so the caller always gets the three keys."""
|
||||
try:
|
||||
return {
|
||||
"site_code": derive_site_code(parsed),
|
||||
"trade": derive_trade(parsed),
|
||||
"fiscal_year": derive_fiscal_year(parsed),
|
||||
}
|
||||
except Exception: # noqa: BLE001 -- totality: never raise on the email path
|
||||
return {"site_code": None, "trade": None, "fiscal_year": None}
|
||||
|
|
@ -17,6 +17,7 @@ from decimal import Decimal, InvalidOperation
|
|||
from email import policy
|
||||
|
||||
import boto3
|
||||
from derived_fields import derive_all
|
||||
from ses_auth import authenticate_inbound_email
|
||||
from template_parser import try_deterministic_parse
|
||||
|
||||
|
|
@ -37,6 +38,18 @@ BEDROCK_MODEL_ID = os.environ.get(
|
|||
METRIC_NAMESPACE = "Seahaven/PoIngest"
|
||||
METRIC_NAME = "ParseOutcome"
|
||||
|
||||
# Shadow telemetry for the Python-derived classifier bake. One EMF record per
|
||||
# derived field per email, emitted ONLY on the ai_fallback path (the template
|
||||
# path has no LLM value to compare against). Dimensioned by Field x Agreement
|
||||
# only -- PythonValue/LlmValue/po_number ride along as Logs-Insights-queryable
|
||||
# properties so the cardinality stays fixed at (3 fields x 4 categories).
|
||||
DERIVED_METRIC_NAME = "DerivedFieldAgreement"
|
||||
|
||||
# The three classifier outputs derive_all() computes. Python fills these when
|
||||
# the extraction path left them null; on ai_fallback the LLM value (if any)
|
||||
# stays authoritative during the bake and Python only shadows it.
|
||||
DERIVED_FIELDS = ("site_code", "trade", "fiscal_year")
|
||||
|
||||
# "Cancelled" is a sticky, authoritative status: once a PO reaches it, a later
|
||||
# new_po/revision may enrich other fields but must never move it back to a
|
||||
# non-cancelled status.
|
||||
|
|
@ -92,9 +105,9 @@ Analyze the following email and extract structured data. Return ONLY valid JSON
|
|||
"category": "string or null",
|
||||
"account_code": "string or null",
|
||||
"period": "string or null",
|
||||
"quantity": "string or null",
|
||||
"quantity": "number or null",
|
||||
"unit": "string or null",
|
||||
"price": "string or null"
|
||||
"price": "number or null"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -328,6 +341,65 @@ def _emit_parse_method_metric(method, template_id, reason_code, po_number):
|
|||
print(json.dumps(emf))
|
||||
|
||||
|
||||
def _derived_agreement(llm_value, python_value) -> str | None:
|
||||
"""Classify Python-vs-LLM agreement for one derived field.
|
||||
|
||||
Returns None when both values are None (nothing to compare -- the caller
|
||||
then skips emission). Categories:
|
||||
* ``agree`` -- both non-None and equal after str-strip
|
||||
* ``disagree`` -- both non-None but different
|
||||
* ``llm_null_python_filled``-- LLM None, Python supplied a value
|
||||
* ``python_null`` -- LLM non-None, Python None
|
||||
"""
|
||||
if llm_value is None and python_value is None:
|
||||
return None
|
||||
if llm_value is None:
|
||||
return "llm_null_python_filled"
|
||||
if python_value is None:
|
||||
return "python_null"
|
||||
if str(llm_value).strip() == str(python_value).strip():
|
||||
return "agree"
|
||||
return "disagree"
|
||||
|
||||
|
||||
def _emit_derived_agreement_metric(field, llm_value, python_value, po_number):
|
||||
"""Emit one CloudWatch EMF line shadowing the Python-derived classifier
|
||||
against the LLM value for a single derived field (ai_fallback path only).
|
||||
|
||||
Mirrors ``_emit_parse_method_metric``: zero-latency (no PutMetricData; the
|
||||
role already has logs:PutLogEvents), Field x Agreement the only promoted
|
||||
dimension set (cardinality 3x4). PythonValue/LlmValue/po_number ride along
|
||||
as Logs-Insights-queryable properties so a disagreement can be reviewed by
|
||||
example without inflating metric cardinality. No emission when both values
|
||||
are None -- there is nothing to compare."""
|
||||
agreement = _derived_agreement(llm_value, python_value)
|
||||
if agreement is None:
|
||||
return
|
||||
emf = {
|
||||
"_aws": {
|
||||
"Timestamp": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||||
"CloudWatchMetrics": [
|
||||
{
|
||||
"Namespace": METRIC_NAMESPACE,
|
||||
"Dimensions": [["Field", "Agreement"]],
|
||||
"Metrics": [{"Name": DERIVED_METRIC_NAME, "Unit": "Count"}],
|
||||
}
|
||||
],
|
||||
},
|
||||
"Field": field,
|
||||
"Agreement": agreement,
|
||||
"po_number": po_number or "",
|
||||
# Length-clamped: Python values are regex/enum-bounded by construction,
|
||||
# but the LLM value is schema-unvalidated model output -- a hallucinated
|
||||
# free-text field must not land unbounded in a 2-month log line
|
||||
# (sh-security-review PO-DC-02, confirmed low).
|
||||
"PythonValue": "" if python_value is None else str(python_value)[:64],
|
||||
"LlmValue": "" if llm_value is None else str(llm_value)[:64],
|
||||
DERIVED_METRIC_NAME: 1,
|
||||
}
|
||||
print(json.dumps(emf))
|
||||
|
||||
|
||||
def pad_zip(zip_code: str | None) -> str | None:
|
||||
if not zip_code:
|
||||
return zip_code
|
||||
|
|
@ -337,8 +409,13 @@ def pad_zip(zip_code: str | None) -> str | None:
|
|||
return zip_code
|
||||
|
||||
|
||||
def enrich_parsed(parsed: dict, s3_key: str, email_subject: str):
|
||||
"""Add metadata and promote nested fields to top level."""
|
||||
def enrich_parsed(parsed: dict, s3_key: str, email_subject: str, *, parse_method: str):
|
||||
"""Add metadata and promote nested fields to top level.
|
||||
|
||||
``parse_method`` ("template" | "ai_fallback") selects the derived-field
|
||||
shadow behavior below: agreement telemetry is emitted only on ai_fallback,
|
||||
where an LLM value exists to compare the Python classifier against.
|
||||
"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
parsed["raw_s3_key"] = s3_key
|
||||
parsed["processed_at"] = now
|
||||
|
|
@ -378,6 +455,37 @@ def enrich_parsed(parsed: dict, s3_key: str, email_subject: str):
|
|||
# but a bare JSON int would slip through as Python int; coerce
|
||||
# so both paths emit one canonical Decimal type (cross-review FIX).
|
||||
item[field] = Decimal(str(value))
|
||||
|
||||
# Derived-field classification (site_code, trade, fiscal_year). Python
|
||||
# derivation FILLS GAPS on BOTH paths but NEVER OVERWRITES: an LLM-supplied
|
||||
# value (only possible on the ai_fallback path) stays authoritative during
|
||||
# the bake period. On ai_fallback we additionally emit one shadow EMF record
|
||||
# per field comparing the Python value to the LLM value, so agreement can be
|
||||
# measured before Python becomes authoritative and the rules are dropped
|
||||
# from EXTRACTION_PROMPT (a post-bake follow-up).
|
||||
#
|
||||
# The whole block is wrapped defensively: derive_all() is total and pure,
|
||||
# but this is an S3-async Lambda where any uncaught exception means a retry
|
||||
# storm -> DLQ, so no classification/telemetry error may ever fail the
|
||||
# invocation.
|
||||
try:
|
||||
python_vals = derive_all(parsed)
|
||||
for field in DERIVED_FIELDS:
|
||||
llm_value = parsed.get(field)
|
||||
python_value = python_vals.get(field)
|
||||
if llm_value is None and python_value is not None:
|
||||
# Python fills the gap on both paths.
|
||||
parsed[field] = python_value
|
||||
# else: a non-None LLM value (ai_fallback only) is kept as-is.
|
||||
if parse_method == "ai_fallback":
|
||||
_emit_derived_agreement_metric(
|
||||
field, llm_value, python_value, parsed.get("po_number")
|
||||
)
|
||||
except Exception: # noqa: BLE001 - telemetry must never fail the invocation
|
||||
logger.exception(
|
||||
"derived-field classification/telemetry failed; continuing without it"
|
||||
)
|
||||
|
||||
return parsed
|
||||
|
||||
|
||||
|
|
@ -570,7 +678,9 @@ def handler(event, context):
|
|||
logger.warning(f"No PO number found in email, skipping: {key}")
|
||||
continue
|
||||
|
||||
parsed = enrich_parsed(parsed, s3_key, email_data["subject"])
|
||||
parsed = enrich_parsed(
|
||||
parsed, s3_key, email_data["subject"], parse_method=parse_method
|
||||
)
|
||||
|
||||
email_type = parsed.get("email_type")
|
||||
if email_type == "cancellation":
|
||||
|
|
|
|||
|
|
@ -69,6 +69,9 @@ def load_po_handler():
|
|||
siblings = {
|
||||
"template_parser": load_template_parser(),
|
||||
"ses_auth": _load_module("ses_auth.py", f"{_HANDLER_NAME}__ses_auth"),
|
||||
"derived_fields": _load_module(
|
||||
"derived_fields.py", f"{_HANDLER_NAME}__derived_fields"
|
||||
),
|
||||
}
|
||||
saved = {name: sys.modules.get(name) for name in siblings}
|
||||
sys.modules.update(siblings)
|
||||
|
|
|
|||
|
|
@ -205,11 +205,18 @@ def test_two_path_parity_through_enrich_and_save(fake_dynamo):
|
|||
off the table stream).
|
||||
|
||||
NON-CIRCULAR: the LLM side is a PROMPT-SHAPED payload, not the
|
||||
parser-derived golden verbatim -- EXTRACTION_PROMPT declares quantity and
|
||||
price as JSON strings, so a prompt-obedient Bedrock response arrives with
|
||||
str where the template path has Decimal. The shared enrich_parsed must
|
||||
converge both to Decimal (DynamoDB Number) or the two paths write
|
||||
different attribute types for the same email."""
|
||||
parser-derived golden verbatim. EXTRACTION_PROMPT now declares quantity and
|
||||
price as numbers, but the shared enrich_parsed Decimal coercion remains a
|
||||
safety net for a non-conforming model that still returns them as strings --
|
||||
which is exactly the case this test feeds. The shared enrich_parsed must
|
||||
converge both to Decimal (DynamoDB Number) or the two paths write different
|
||||
attribute types for the same email.
|
||||
|
||||
Derived classification (site_code/trade/fiscal_year) now runs in the shared
|
||||
enrich_parsed on BOTH paths, but with different authority: the template path
|
||||
takes the Python value; the ai_fallback path KEEPS the LLM value (the bake).
|
||||
Those fields are normalized out of the stream-parity comparison below, which
|
||||
guards the quantity/price type convergence + non-derived shape parity."""
|
||||
stem = NEW_PO_STEMS[0]
|
||||
email_data = load_email("new-po", stem)
|
||||
s3_key = "s3://po-ingest-emails-x/inbound/parity"
|
||||
|
|
@ -230,20 +237,22 @@ def test_two_path_parity_through_enrich_and_save(fake_dynamo):
|
|||
if item["price"] is not None:
|
||||
item["price"] = format(item["price"], ",.2f")
|
||||
assert isinstance(llm_parsed["line_items"][0]["quantity"], str)
|
||||
# Derived classification (site_code/trade/fiscal_year) stays LLM-only in
|
||||
# PR #1: the LLM fills it, the template parser leaves it None (doc §3.6).
|
||||
# A prompt-obedient LLM supplies the derived classification on the fallback
|
||||
# path; enrich_parsed KEEPS this LLM value (authoritative during the bake).
|
||||
llm_parsed["site_code"] = "DYO1"
|
||||
|
||||
enriched_a = po_handler.enrich_parsed(
|
||||
template_parsed, s3_key, email_data["subject"]
|
||||
template_parsed, s3_key, email_data["subject"], parse_method="template"
|
||||
)
|
||||
enriched_b = po_handler.enrich_parsed(
|
||||
llm_parsed, s3_key, email_data["subject"], parse_method="ai_fallback"
|
||||
)
|
||||
enriched_b = po_handler.enrich_parsed(llm_parsed, s3_key, email_data["subject"])
|
||||
enriched_b["processed_at"] = enriched_a["processed_at"] # only wall-clock differs
|
||||
|
||||
# enrich_parsed passes the LLM-derived site_code through untouched...
|
||||
assert enriched_a["site_code"] is None
|
||||
# The ai_fallback path keeps the LLM-supplied site_code (not overwritten by
|
||||
# the Python classifier during the bake).
|
||||
assert enriched_b["site_code"] == "DYO1"
|
||||
# ...and converges quantity/price to Decimal on the prompt-shaped side.
|
||||
# enrich_parsed converges quantity/price to Decimal on the prompt-shaped side.
|
||||
from decimal import Decimal
|
||||
|
||||
item_a = enriched_a["line_items"][0]
|
||||
|
|
@ -252,15 +261,19 @@ def test_two_path_parity_through_enrich_and_save(fake_dynamo):
|
|||
assert isinstance(item_a["quantity"], Decimal)
|
||||
assert isinstance(item_b["quantity"], Decimal)
|
||||
assert isinstance(item_b["price"], Decimal)
|
||||
# Everything except the deliberately-LLM-only derived fields is identical.
|
||||
# Everything except the derived fields (which now diverge by design -- Python
|
||||
# on the template path, LLM on the fallback path) is identical.
|
||||
derived = set(template_parser.DERIVED_KEYS)
|
||||
parity_a = {k: v for k, v in enriched_a.items() if k not in derived}
|
||||
parity_b = {k: v for k, v in enriched_b.items() if k not in derived}
|
||||
assert parity_a == parity_b
|
||||
|
||||
# Like-for-like DynamoDB item shapes through save_new_po's None-dropping
|
||||
# logic (derived fields normalized so the comparison isolates types).
|
||||
enriched_b["site_code"] = None
|
||||
# logic (derived fields normalized on BOTH sides so the comparison isolates
|
||||
# the quantity/price attribute types, not the deliberate derived divergence).
|
||||
for key in template_parser.DERIVED_KEYS:
|
||||
enriched_a[key] = None
|
||||
enriched_b[key] = None
|
||||
po_handler.save_new_po(enriched_a)
|
||||
po_handler.save_new_po(enriched_b)
|
||||
update_a, update_b = fake_dynamo.tables[po_handler.PO_TABLE].updates
|
||||
|
|
@ -272,17 +285,18 @@ def test_enrich_parsed_pads_short_zip_identically_for_llm_path():
|
|||
pad_zip then repairs them inside the SHARED enrich_parsed, so whichever
|
||||
path produced the dict, downstream sees the same padded zip."""
|
||||
parsed = {"ship_to": {"address": "x", "state": "MA", "zip": "2149"}}
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj")
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj", parse_method="template")
|
||||
assert parsed["ship_to"]["zip"] == "02149"
|
||||
assert parsed["ship_to_raw"] == "x"
|
||||
assert parsed["state"] == "MA"
|
||||
|
||||
|
||||
def test_enrich_parsed_coerces_prompt_string_quantity_price():
|
||||
"""EXTRACTION_PROMPT asks for quantity/price as strings; the shared
|
||||
enrich_parsed coerces them to Decimal so the LLM path writes the same
|
||||
DynamoDB attribute type (Number) as the template path. Non-numeric
|
||||
strings are left verbatim rather than dropped."""
|
||||
"""EXTRACTION_PROMPT now declares quantity/price as numbers, but the shared
|
||||
enrich_parsed Decimal coercion is retained as a SAFETY NET: a model that
|
||||
still returns them as strings is coerced so the LLM path writes the same
|
||||
DynamoDB attribute type (Number) as the template path. Non-numeric strings
|
||||
are left verbatim rather than dropped."""
|
||||
from decimal import Decimal
|
||||
|
||||
parsed = {
|
||||
|
|
@ -291,7 +305,7 @@ def test_enrich_parsed_coerces_prompt_string_quantity_price():
|
|||
{"quantity": "N/A", "price": None},
|
||||
]
|
||||
}
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj")
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj", parse_method="template")
|
||||
assert parsed["line_items"][0]["quantity"] == Decimal("1.0")
|
||||
assert parsed["line_items"][0]["price"] == Decimal("55206.00")
|
||||
assert parsed["line_items"][1]["quantity"] == "N/A" # left verbatim
|
||||
|
|
|
|||
605
lambdas/po/email_processor/tests/test_po_derived_fields.py
Normal file
605
lambdas/po/email_processor/tests/test_po_derived_fields.py
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
"""Unit tests for the deterministic derived-field classifiers.
|
||||
|
||||
derived_fields is a pure stdlib module with no sibling imports, so it is loaded
|
||||
directly by file path under a unique module name (mirroring the module-collision
|
||||
guard in _po_parser_support): a bare ``import derived_fields`` could collide with
|
||||
another test root in the same pytest session.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import time
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
_HERE = os.path.dirname(__file__)
|
||||
_MODULE_DIR = os.path.abspath(os.path.join(_HERE, ".."))
|
||||
|
||||
|
||||
def _load_derived_fields():
|
||||
name = "po_email_processor__derived_fields"
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name, os.path.join(_MODULE_DIR, "derived_fields.py")
|
||||
)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
df = _load_derived_fields()
|
||||
|
||||
|
||||
def _ship(name=None, attn=None):
|
||||
return {"ship_to": {"name": name, "attn": attn}}
|
||||
|
||||
|
||||
def _items(*descs_amounts):
|
||||
"""Build parsed with line items. Each arg is desc or (desc, amount)."""
|
||||
items = []
|
||||
for entry in descs_amounts:
|
||||
if isinstance(entry, tuple):
|
||||
desc, amount = entry
|
||||
else:
|
||||
desc, amount = entry, "1.00"
|
||||
items.append({"description": desc, "amount": amount})
|
||||
return {"line_items": items}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# site_code -- shapes 1-7
|
||||
# ===========================================================================
|
||||
def test_site_code_shape1_name_parens():
|
||||
assert df.derive_site_code(_ship(name="Amazon.com Services LLC (KLAL)")) == "KLAL"
|
||||
|
||||
|
||||
def test_site_code_shape2_name_after_dash():
|
||||
assert df.derive_site_code(_ship(name="Amazon.com Services LLC - SNY5")) == "SNY5"
|
||||
|
||||
|
||||
def test_site_code_shape3_attn_en_dash():
|
||||
parsed = _ship(attn="Wagon Wheel DS Station –WKY3")
|
||||
assert df.derive_site_code(parsed) == "WKY3"
|
||||
|
||||
|
||||
def test_site_code_shape4_attn_direct():
|
||||
assert df.derive_site_code(_ship(attn="HJX1")) == "HJX1"
|
||||
|
||||
|
||||
def test_site_code_shape5_name_is_code():
|
||||
assert df.derive_site_code(_ship(name="DBU2")) == "DBU2"
|
||||
|
||||
|
||||
def test_site_code_shape6_desc_prefix():
|
||||
parsed = _items("DYO1 - Sea Haven Ind - Plumbing Repairs")
|
||||
assert df.derive_site_code(parsed) == "DYO1"
|
||||
|
||||
|
||||
def test_site_code_shape7_desc_bracket():
|
||||
parsed = _items("[HMK4] Assemble 3 Wire Security Cages")
|
||||
assert df.derive_site_code(parsed) == "HMK4"
|
||||
|
||||
|
||||
def test_site_code_all_letters_code():
|
||||
# Codes may be all letters (no digit required).
|
||||
assert df.derive_site_code(_ship(name="Amazon (KLAL)")) == "KLAL"
|
||||
|
||||
|
||||
def test_site_code_leading_token_dash():
|
||||
# Code-first dash layout: opener wins.
|
||||
assert df.derive_site_code(_ship(name="WPT2 - Amazon.com Services LLC")) == "WPT2"
|
||||
|
||||
|
||||
def test_site_code_leading_token_parens_beats_paren_content():
|
||||
# Leading digit-bearing opener outranks the parens shape: QDE1, not the host
|
||||
# building WDE1 mentioned inside the parens.
|
||||
assert df.derive_site_code(_ship(name="QDE1 (Co-located inside WDE1)")) == "QDE1"
|
||||
|
||||
|
||||
def test_site_code_leading_requires_digit():
|
||||
# A leading all-letter code does NOT trigger the leading shape; it falls to
|
||||
# the parens shape, which resolves the parenthesized code.
|
||||
assert df.derive_site_code(_ship(name="KLAL (SNY5)")) == "SNY5"
|
||||
|
||||
|
||||
def test_site_code_dash_after_side():
|
||||
# Documented after-dash layout still works (exact segment after the dash).
|
||||
assert df.derive_site_code(_ship(name="Amazon.com Services LLC - SNY5")) == "SNY5"
|
||||
|
||||
|
||||
def test_site_code_parens_attn_hop_resolves_to_code():
|
||||
# Parens content uses the rightmost-non-skip resolver: skip ATTN, take WKY3.
|
||||
parsed = _ship(name="(ATTN: Wagon Wheel DS Station –WKY3)")
|
||||
assert df.derive_site_code(parsed) == "WKY3"
|
||||
|
||||
|
||||
def test_site_code_midname_token_with_digit():
|
||||
# A digit-bearing code mid-name is found; all-letter words never match.
|
||||
assert (
|
||||
df.derive_site_code(_ship(name="Amazon Fresh UVA5 Non-Inv (Prime)")) == "UVA5"
|
||||
)
|
||||
|
||||
|
||||
def test_site_code_desc_freetoken():
|
||||
# Free-token shape: a length 4-5 digit-bearing code anywhere in a desc.
|
||||
parsed = _items("Need Sea Haven to pump out waste water aqt DSF7")
|
||||
assert df.derive_site_code(parsed) == "DSF7"
|
||||
|
||||
|
||||
def test_site_code_desc_freetoken_requires_len4_and_digit():
|
||||
# 3-char uppercase tokens and all-letter tokens do NOT match the free shape.
|
||||
assert df.derive_site_code(_items("call the ABC crew for HELLO work")) is None
|
||||
|
||||
|
||||
def test_site_code_two_letter_minimum_po_prefix_rejected():
|
||||
# 'B187' is a 1-letter PO-prefix-style token: the leading shape rejects it
|
||||
# (needs >=2 letters), and the parens shape yields the real code POR3.
|
||||
assert df.derive_site_code(_ship(name="B187 (POR3)")) == "POR3"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["KLAL", "DYO1", "USF4", "QDE1"])
|
||||
def test_site_code_two_letter_minimum_accepts_real_codes(code):
|
||||
# Legitimate codes (>=2 alphabetic chars) are still accepted as the name.
|
||||
assert df.derive_site_code(_ship(name=code)) == code
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["B187", "X23", "N456"])
|
||||
def test_site_code_one_letter_token_never_accepted(bad):
|
||||
# A code-shaped token with fewer than 2 alphabetic chars is never a code,
|
||||
# across every shape (name-is, ATTN, parens, bracket).
|
||||
assert df.derive_site_code(_ship(name=bad)) is None
|
||||
assert df.derive_site_code(_ship(attn=bad)) is None
|
||||
assert df.derive_site_code(_ship(name=f"Amazon LLC ({bad})")) is None
|
||||
assert df.derive_site_code(_items(f"[{bad}] work")) is None
|
||||
|
||||
|
||||
def test_site_code_multi_hop_attn():
|
||||
# "CBRE - RME - DLI6" -> rightmost non-skip code -> DLI6 (RME hop skipped).
|
||||
assert df.derive_site_code(_ship(attn="CBRE - RME - DLI6")) == "DLI6"
|
||||
|
||||
|
||||
def test_site_code_priority_parens_beats_dash():
|
||||
parsed = _ship(name="Amazon (KLAL) - SNY5")
|
||||
assert df.derive_site_code(parsed) == "KLAL"
|
||||
|
||||
|
||||
def test_site_code_attn_beats_name_is_code():
|
||||
parsed = {"ship_to": {"name": "DBU2", "attn": "HJX1"}}
|
||||
assert df.derive_site_code(parsed) == "HJX1"
|
||||
|
||||
|
||||
def test_site_code_prefix_beats_bracket():
|
||||
parsed = {
|
||||
"line_items": [
|
||||
{"description": "Assemble [HMK4] cages"},
|
||||
{"description": "DYO1 - repairs"},
|
||||
]
|
||||
}
|
||||
# Shape 6 (prefix) is scanned across all items before shape 7 (bracket).
|
||||
assert df.derive_site_code(parsed) == "DYO1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"token",
|
||||
[
|
||||
"RME",
|
||||
"BBM",
|
||||
"JLL",
|
||||
"PARAG",
|
||||
"ERIK",
|
||||
"HVAC",
|
||||
"LED",
|
||||
"PVC",
|
||||
"ADA",
|
||||
"OSHA",
|
||||
"EMR",
|
||||
"BMS",
|
||||
"DDC",
|
||||
"MRO",
|
||||
"NTE",
|
||||
"EST",
|
||||
"LLC",
|
||||
"INC",
|
||||
"CORP",
|
||||
"LTD",
|
||||
"ATTN",
|
||||
],
|
||||
)
|
||||
def test_site_code_skip_list_sole_candidate_is_none(token):
|
||||
# Skip-listed token as the sole candidate for every shape -> None.
|
||||
assert df.derive_site_code(_ship(name=token)) is None
|
||||
assert df.derive_site_code(_ship(name=f"Amazon LLC ({token})")) is None
|
||||
assert df.derive_site_code(_ship(name=f"Amazon LLC - {token}")) is None
|
||||
assert df.derive_site_code(_ship(attn=token)) is None
|
||||
assert df.derive_site_code(_items(f"[{token}] work")) is None
|
||||
assert df.derive_site_code(_items(f"{token} - work")) is None
|
||||
|
||||
|
||||
def test_site_code_skip_then_lower_priority_shape_wins():
|
||||
# ATTN skip-listed only, but a line item carries a real prefix code.
|
||||
parsed = {
|
||||
"ship_to": {"name": None, "attn": "RME"},
|
||||
"line_items": [{"description": "DYO1 - Plumbing Repairs"}],
|
||||
}
|
||||
assert df.derive_site_code(parsed) == "DYO1"
|
||||
|
||||
|
||||
def test_site_code_lowercase_rejected():
|
||||
assert df.derive_site_code(_ship(name="Amazon (klal)")) is None
|
||||
|
||||
|
||||
def test_site_code_no_dash_no_leak_of_llc():
|
||||
# 'LLC' is code-shaped but must not be extracted from a plain name.
|
||||
assert df.derive_site_code(_ship(name="Amazon.com Services LLC")) is None
|
||||
|
||||
|
||||
def test_site_code_after_dash_skip_does_not_leak_prefix_token():
|
||||
# 'LLC' before the dash must not leak when the after-dash code is skip-listed.
|
||||
assert df.derive_site_code(_ship(name="Amazon.com Services LLC - RME")) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("garbage", ["AB", "A1", "TOOLONG6", "1BCD", "", "amazon"])
|
||||
def test_site_code_garbage_shapes_rejected(garbage):
|
||||
# Too short, digit-first, too long, lowercase -> not a code.
|
||||
assert df.derive_site_code(_ship(name=garbage)) is None
|
||||
|
||||
|
||||
def test_site_code_none_when_nothing():
|
||||
assert df.derive_site_code({"ship_to": {"name": "Some Warehouse"}}) is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# trade
|
||||
# ===========================================================================
|
||||
@pytest.mark.parametrize(
|
||||
"desc,expected",
|
||||
[
|
||||
("Plumbing PM Quarterly", "Plumbing - PM"),
|
||||
("Plumbing - Backflow test", "Plumbing - PM"),
|
||||
("Plumbing leak repair", "Plumbing - Reactive"),
|
||||
("Plumbing unclog toilet", "Plumbing - Reactive"),
|
||||
("Lighting ballast replacement", "Electrical"),
|
||||
("Circuit panel upgrade", "Electrical"),
|
||||
("RTU refrigerant recharge", "HVAC"),
|
||||
("Air conditioning ductwork", "HVAC"),
|
||||
("Dock door leveler repair", "Dock Doors"),
|
||||
("Dock seal replacement", "Dock Doors"),
|
||||
("Overhead door spring", "Doors"),
|
||||
("Automatic door operator", "Doors"),
|
||||
("Wayfinding sign install", "Signage"),
|
||||
("Cabinet millwork build", "Carpentry"),
|
||||
("Perimeter fence repair", "Fencing/Gates"),
|
||||
("Bollard install", "Fencing/Gates"),
|
||||
("Conveyor belt repair", "Conveyance/MHE"),
|
||||
("Interior painting touch-up", "Painting"),
|
||||
("Tile flooring replacement", "Flooring"),
|
||||
("Custodial cleaning service", "Janitorial"),
|
||||
("Fire sprinkler inspection", "Fire/Life Safety"),
|
||||
("Lawn mowing service", "Landscaping/Yard"),
|
||||
("Roof gutter repair", "Roofing"),
|
||||
("Access control camera install", "Security/Locksmith"),
|
||||
("Snow plowing and salt", "Snow Removal"),
|
||||
(
|
||||
"General Building - General Building Technician",
|
||||
"General Building - Handyman",
|
||||
),
|
||||
("General Building - General Building Project", "General Building - Project"),
|
||||
# Conveyance keyword (BBM taxonomy) routes to Conveyance/MHE, not the
|
||||
# General Building - Emergency / General Building catch-alls.
|
||||
("Conveyance - Emergency - Opex CAR", "Conveyance/MHE"),
|
||||
("Conveyance - Conveyance Technician - BBM", "Conveyance/MHE"),
|
||||
# BBM-structured 'Plumbing - ...' rows resolve via the measured ladder:
|
||||
# (a) emergency/reactive -> Reactive, (b) technician -> PM, (c) water
|
||||
# heater/backflow/fountain -> PM, (d) clog/leak/repair/... -> Reactive,
|
||||
# (e) else -> PM.
|
||||
("Plumbing - Plumbing Technician - BBM", "Plumbing - PM"), # (b)
|
||||
("Plumbing - Emergency Callout - BBM", "Plumbing - Reactive"), # (a)
|
||||
("Plumbing - Toilet - Toilet Repair - BBM", "Plumbing - Reactive"), # (d)
|
||||
("Plumbing - Sink - Sink Clogged - BBM", "Plumbing - Reactive"), # (d)
|
||||
# (c) beats (d): water heater/fountain 'repair' stays PM.
|
||||
(
|
||||
"Plumbing - Water Heater - Water Heater Repair - BBM",
|
||||
"Plumbing - PM",
|
||||
),
|
||||
(
|
||||
"Plumbing - Water Fountain - Water Fountain Repair - BBM",
|
||||
"Plumbing - PM",
|
||||
),
|
||||
("Plumbing - Plumbing Project - Medium", "Plumbing - PM"), # (e)
|
||||
# Plumbing-specific qualifiers indicate Reactive even without 'plumbing'.
|
||||
("EPO FOR SEA HAVEN (CLOGGED PIT AUGER)", "Plumbing - Reactive"),
|
||||
("Unclog the floor drain in restroom", "Plumbing - Reactive"),
|
||||
# General Building - Project widening: a standalone 'project' segment.
|
||||
("General Building - Project - BBM", "General Building - Project"),
|
||||
("Ops Driven Defect - Project - Large", "General Building - Project"),
|
||||
# Handyman widening: only a standalone 'handyman' word (5a) and the
|
||||
# 'General Building Technician' prompt row -> Handyman.
|
||||
("Handyman services for the facility", "General Building - Handyman"),
|
||||
# Keyword-less BBM 'General Building - <Fixture> - ...' rows land on the
|
||||
# plain catch-all (measured LLM majority; fix 5b reverted).
|
||||
("General Building - Locker - Install - BBM", "General Building"),
|
||||
("General Building - Key Box - Repair - BBM", "General Building"),
|
||||
("General Building - Fan - Fan Install - BBM", "General Building"),
|
||||
("General Building - Bike Rack - Removal - BBM", "General Building"),
|
||||
# Bollard stays Fencing/Gates (prompt rule; unchanged).
|
||||
("General Building - Bollard - Install - BBM", "Fencing/Gates"),
|
||||
("Facility maintenance misc work", "General Building"),
|
||||
],
|
||||
)
|
||||
def test_trade_single_keyword(desc, expected):
|
||||
assert df.derive_trade(_items(desc)) == expected
|
||||
|
||||
|
||||
def test_trade_pm_beats_reactive():
|
||||
# "plumbing pm" (priority 1) outranks the "leak" reactive qualifier.
|
||||
assert df.derive_trade(_items("Plumbing PM inspection incl leak check")) == (
|
||||
"Plumbing - PM"
|
||||
)
|
||||
|
||||
|
||||
def test_trade_dock_door_suppresses_electrical():
|
||||
# Electrical keyword present, but a dock-door context routes to Dock Doors.
|
||||
parsed = _items("Dock door electrical panel repair")
|
||||
assert df.derive_trade(parsed) == "Dock Doors"
|
||||
|
||||
|
||||
def test_trade_dock_door_beats_doors():
|
||||
assert df.derive_trade(_items("Dock door seal replacement")) == "Dock Doors"
|
||||
|
||||
|
||||
def test_trade_dock_gate_not_fencing():
|
||||
parsed = _items("Dock gate maintenance")
|
||||
assert df.derive_trade(parsed) != "Fencing/Gates"
|
||||
assert df.derive_trade(parsed) == "General Building"
|
||||
|
||||
|
||||
def test_trade_locker_is_not_locksmith():
|
||||
# 'Locker' must NOT match the whole word 'lock' -> not Security/Locksmith.
|
||||
parsed = _items("Locker replacement in break room")
|
||||
assert df.derive_trade(parsed) != "Security/Locksmith"
|
||||
assert df.derive_trade(parsed) == "General Building"
|
||||
|
||||
|
||||
def test_trade_key_box_is_not_locksmith():
|
||||
# 'Key Box' is stripped before the Security test -> not Security/Locksmith.
|
||||
parsed = _items("Key Box installation at dock office")
|
||||
assert df.derive_trade(parsed) != "Security/Locksmith"
|
||||
|
||||
|
||||
def test_trade_lock_whole_word_still_locksmith():
|
||||
# A real whole-word 'lock' still routes to Security/Locksmith.
|
||||
assert df.derive_trade(_items("Broken lock needs replacement")) == (
|
||||
"Security/Locksmith"
|
||||
)
|
||||
|
||||
|
||||
def test_trade_emergency_prefix():
|
||||
assert (
|
||||
df.derive_trade(_items("EMER site response")) == "General Building - Emergency"
|
||||
)
|
||||
|
||||
|
||||
def test_trade_emergency_word():
|
||||
parsed = _items("Emergency board-up of facility")
|
||||
assert df.derive_trade(parsed) == "General Building - Emergency"
|
||||
|
||||
|
||||
def test_trade_uplift_only():
|
||||
parsed = _items("PO Uplift", "PO Uplift adjustment")
|
||||
assert df.derive_trade(parsed) == "PO Uplift"
|
||||
|
||||
|
||||
def test_trade_uplift_plus_single_trade():
|
||||
# One non-uplift trade + uplift -> the non-uplift trade is primary.
|
||||
parsed = _items(("PO Uplift", "10.00"), ("Plumbing leak repair", "500.00"))
|
||||
assert df.derive_trade(parsed) == "Plumbing - Reactive"
|
||||
|
||||
|
||||
def test_trade_mixed_highest_value_wins():
|
||||
parsed = _items(
|
||||
("PO Uplift", "9999.00"), # excluded from being primary
|
||||
("Plumbing leak repair", "500.00"),
|
||||
("Lighting circuit repair", "800.00"),
|
||||
)
|
||||
assert df.derive_trade(parsed) == "Electrical"
|
||||
|
||||
|
||||
def test_trade_mixed_decimal_amounts():
|
||||
parsed = {
|
||||
"line_items": [
|
||||
{"description": "Roof gutter repair", "amount": Decimal("120.00")},
|
||||
{"description": "Fire sprinkler inspection", "amount": Decimal("900.00")},
|
||||
]
|
||||
}
|
||||
assert df.derive_trade(parsed) == "Fire/Life Safety"
|
||||
|
||||
|
||||
def test_trade_no_line_items_is_none():
|
||||
assert df.derive_trade({"line_items": []}) is None
|
||||
assert df.derive_trade({}) is None
|
||||
|
||||
|
||||
def test_trade_no_descriptions_is_none():
|
||||
assert df.derive_trade({"line_items": [{"amount": "10.00"}]}) is None
|
||||
assert df.derive_trade({"line_items": [{"description": " "}]}) is None
|
||||
|
||||
|
||||
def test_trade_general_building_only_with_a_description():
|
||||
# A described-but-unmatched item -> General Building (the catch-all).
|
||||
assert df.derive_trade(_items("Misc facility task")) == "General Building"
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# fiscal_year
|
||||
# ===========================================================================
|
||||
def test_fiscal_year_order_date_full_year():
|
||||
assert df.derive_fiscal_year({"order_date": "06/15/2025"}) == "2025"
|
||||
|
||||
|
||||
def test_fiscal_year_order_date_iso():
|
||||
assert df.derive_fiscal_year({"order_date": "2024-11-02"}) == "2024"
|
||||
|
||||
|
||||
def test_fiscal_year_order_date_yy_expansion():
|
||||
assert df.derive_fiscal_year({"order_date": "06/15/25"}) == "2025"
|
||||
|
||||
|
||||
def test_fiscal_year_need_by_fallback():
|
||||
parsed = {
|
||||
"order_date": None,
|
||||
"line_items": [{"need_by": "2024-03-01"}],
|
||||
}
|
||||
assert df.derive_fiscal_year(parsed) == "2024"
|
||||
|
||||
|
||||
def test_fiscal_year_need_by_yy_expansion():
|
||||
parsed = {"order_date": None, "line_items": [{"need_by": "3/1/23"}]}
|
||||
assert df.derive_fiscal_year(parsed) == "2023"
|
||||
|
||||
|
||||
def test_fiscal_year_description_fallback():
|
||||
parsed = {
|
||||
"order_date": None,
|
||||
"line_items": [{"need_by": None, "description": "HVB2 - 2025 - Plumbing PM"}],
|
||||
}
|
||||
assert df.derive_fiscal_year(parsed) == "2025"
|
||||
|
||||
|
||||
def test_fiscal_year_order_date_precedence():
|
||||
parsed = {
|
||||
"order_date": "2022-01-01",
|
||||
"line_items": [{"need_by": "2025-01-01", "description": "2030 work"}],
|
||||
}
|
||||
assert df.derive_fiscal_year(parsed) == "2022"
|
||||
|
||||
|
||||
def test_fiscal_year_none_when_nothing():
|
||||
parsed = {"order_date": "no date here", "line_items": [{"description": "work"}]}
|
||||
assert df.derive_fiscal_year(parsed) is None
|
||||
|
||||
|
||||
def test_fiscal_year_ignores_digits_inside_po_id():
|
||||
# '18206023' must not yield a bogus year via an embedded '2062' run.
|
||||
assert df.derive_fiscal_year({"order_date": "PO 2D-18206023"}) is None
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# derive_all
|
||||
# ===========================================================================
|
||||
def test_derive_all_shape_and_values():
|
||||
parsed = {
|
||||
"ship_to": {"name": "Amazon.com Services LLC (KLAL)"},
|
||||
"order_date": "06/15/2025",
|
||||
"line_items": [{"description": "Plumbing leak repair", "amount": "500.00"}],
|
||||
}
|
||||
assert df.derive_all(parsed) == {
|
||||
"site_code": "KLAL",
|
||||
"trade": "Plumbing - Reactive",
|
||||
"fiscal_year": "2025",
|
||||
}
|
||||
|
||||
|
||||
def test_derive_all_keys_always_present():
|
||||
result = df.derive_all(None)
|
||||
assert set(result) == {"site_code", "trade", "fiscal_year"}
|
||||
assert result == {"site_code": None, "trade": None, "fiscal_year": None}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Totality fuzz -- nothing raises, everything returns None-ish, fast
|
||||
# ===========================================================================
|
||||
_HOSTILE = [
|
||||
None,
|
||||
{},
|
||||
"not a dict",
|
||||
123,
|
||||
[],
|
||||
{"ship_to": "garbage-string"},
|
||||
{"ship_to": None},
|
||||
{"ship_to": ["a", "b"]},
|
||||
{"ship_to": {"name": 123, "attn": ["x"]}},
|
||||
{"line_items": None},
|
||||
{"line_items": {"a": 1}},
|
||||
{"line_items": "string"},
|
||||
{"line_items": ["foo", "bar", 42, None]},
|
||||
{"line_items": [None, 1, {"description": None}, {"description": 5}]},
|
||||
{"order_date": 12345},
|
||||
{"order_date": ["2025"]},
|
||||
{"line_items": [{"description": "x", "amount": object()}]},
|
||||
{"line_items": [{"description": "PO Uplift", "amount": "not-a-number"}]},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parsed", _HOSTILE)
|
||||
def test_totality_no_raise(parsed):
|
||||
# Every public function tolerates hostile input without raising.
|
||||
assert df.derive_site_code(parsed) is None or isinstance(
|
||||
df.derive_site_code(parsed), str
|
||||
)
|
||||
assert df.derive_trade(parsed) is None or isinstance(df.derive_trade(parsed), str)
|
||||
assert df.derive_fiscal_year(parsed) is None or isinstance(
|
||||
df.derive_fiscal_year(parsed), str
|
||||
)
|
||||
result = df.derive_all(parsed)
|
||||
assert set(result) == {"site_code", "trade", "fiscal_year"}
|
||||
|
||||
|
||||
def test_totality_huge_strings_fast():
|
||||
huge = "x" * 1_000_000
|
||||
parsed = {
|
||||
"ship_to": {"name": huge, "attn": huge},
|
||||
"order_date": huge,
|
||||
"line_items": [{"description": huge, "amount": "1.00"}],
|
||||
}
|
||||
start = time.time()
|
||||
site = df.derive_site_code(parsed)
|
||||
trade = df.derive_trade(parsed)
|
||||
year = df.derive_fiscal_year(parsed)
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 2.0
|
||||
assert site is None
|
||||
# A million 'x' is a non-empty description that matches nothing.
|
||||
assert trade == "General Building"
|
||||
assert year is None
|
||||
|
||||
|
||||
def test_totality_huge_bracket_code_scan_capped():
|
||||
# A code past the scan cap must not be found (and must not hang).
|
||||
desc = ("." * 10_000) + "[HMK4] work"
|
||||
start = time.time()
|
||||
result = df.derive_site_code(_items(desc))
|
||||
assert time.time() - start < 2.0
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAggregateTradeBudget:
|
||||
"""derive_trade must bound AGGREGATE work across items, not just per-item."""
|
||||
|
||||
def test_adversarial_many_large_items_completes_fast(self):
|
||||
import time
|
||||
|
||||
parsed = {
|
||||
"line_items": [
|
||||
{"description": ("air " * 25000)[:100000], "amount": "1"}
|
||||
for _ in range(500)
|
||||
]
|
||||
}
|
||||
start = time.perf_counter()
|
||||
result = df.derive_trade(parsed)
|
||||
elapsed = time.perf_counter() - start
|
||||
# Budget-capped: only ~2 of the 500 items are classified; the rest are
|
||||
# skipped. Must complete in well under a second even on slow hardware.
|
||||
assert elapsed < 2.0
|
||||
assert result == "General Building"
|
||||
|
||||
def test_budget_never_degrades_realistic_multi_item_pos(self):
|
||||
# Real multi-line POs total well under the budget: every item is
|
||||
# classified and the highest-value non-uplift rule still applies.
|
||||
parsed = {
|
||||
"line_items": [
|
||||
{"description": "PO Uplift", "amount": "9000"},
|
||||
{"description": "dock door repair", "amount": "100"},
|
||||
{"description": "electrical panel replacement", "amount": "500"},
|
||||
]
|
||||
}
|
||||
assert df.derive_trade(parsed) == "Electrical"
|
||||
221
lambdas/po/email_processor/tests/test_po_derived_wiring.py
Normal file
221
lambdas/po/email_processor/tests/test_po_derived_wiring.py
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
"""Wiring tests for the Python derived-field classifier in enrich_parsed.
|
||||
|
||||
These cover the SHADOW semantics only -- how the handler fills / keeps
|
||||
site_code/trade/fiscal_year and emits the DerivedFieldAgreement EMF metric --
|
||||
NOT the derive_all classifier itself (that is owned by
|
||||
test_po_derived_fields.py). derive_all is exercised through its public seam on
|
||||
the handler module (``po_handler.derive_all``), monkeypatched to drive each
|
||||
agreement category deterministically, plus one real-integration smoke.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from _po_parser_support import (
|
||||
NEW_PO_STEMS,
|
||||
load_email,
|
||||
po_handler,
|
||||
template_parser,
|
||||
)
|
||||
|
||||
|
||||
def _emf_lines(capsys):
|
||||
"""Parse the EMF records enrich_parsed printed to stdout, keyed by Field."""
|
||||
out = capsys.readouterr().out
|
||||
records = [json.loads(ln) for ln in out.splitlines() if ln.strip()]
|
||||
return {rec["Field"]: rec for rec in records}
|
||||
|
||||
|
||||
def _patch_derive_all(monkeypatch, **vals):
|
||||
result = {"site_code": None, "trade": None, "fiscal_year": None}
|
||||
result.update(vals)
|
||||
monkeypatch.setattr(po_handler, "derive_all", lambda parsed: result)
|
||||
|
||||
|
||||
def _parsed(**overrides):
|
||||
base = {
|
||||
"po_number": "2D-70000001",
|
||||
"site_code": None,
|
||||
"trade": None,
|
||||
"fiscal_year": None,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
# --- template path -----------------------------------------------------------
|
||||
|
||||
|
||||
def test_template_path_fills_none_fields_and_emits_no_emf(monkeypatch, capsys):
|
||||
"""On the template path every field arrives None (the gate proves the parser
|
||||
leaves them unset); Python fills all three and NO agreement EMF is emitted
|
||||
(there is no LLM value to shadow)."""
|
||||
_patch_derive_all(
|
||||
monkeypatch,
|
||||
site_code="HMK4",
|
||||
trade="Plumbing - Reactive",
|
||||
fiscal_year="2025",
|
||||
)
|
||||
parsed = _parsed()
|
||||
out = po_handler.enrich_parsed(parsed, "s3://b/k", "subj", parse_method="template")
|
||||
assert out["site_code"] == "HMK4"
|
||||
assert out["trade"] == "Plumbing - Reactive"
|
||||
assert out["fiscal_year"] == "2025"
|
||||
assert _emf_lines(capsys) == {} # no agreement EMF on the template path
|
||||
|
||||
|
||||
# --- ai_fallback path: fill / keep + agreement categories --------------------
|
||||
|
||||
|
||||
def test_ai_fallback_keeps_llm_value_on_disagree(monkeypatch, capsys):
|
||||
"""LLM value stays authoritative during the bake even when Python disagrees;
|
||||
the shadow EMF records 'disagree' carrying BOTH values as properties."""
|
||||
_patch_derive_all(monkeypatch, site_code="HMK4")
|
||||
parsed = _parsed(site_code="DYO1")
|
||||
out = po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", "subj", parse_method="ai_fallback"
|
||||
)
|
||||
assert out["site_code"] == "DYO1" # LLM survives, not overwritten
|
||||
|
||||
rec = _emf_lines(capsys)["site_code"]
|
||||
assert rec["Agreement"] == "disagree"
|
||||
assert rec["LlmValue"] == "DYO1"
|
||||
assert rec["PythonValue"] == "HMK4"
|
||||
assert rec["po_number"] == "2D-70000001"
|
||||
assert rec[po_handler.DERIVED_METRIC_NAME] == 1
|
||||
metric = rec["_aws"]["CloudWatchMetrics"][0]
|
||||
assert metric["Namespace"] == "Seahaven/PoIngest"
|
||||
assert metric["Dimensions"] == [["Field", "Agreement"]]
|
||||
assert metric["Metrics"] == [
|
||||
{"Name": po_handler.DERIVED_METRIC_NAME, "Unit": "Count"}
|
||||
]
|
||||
|
||||
|
||||
def test_ai_fallback_agree_after_strip(monkeypatch, capsys):
|
||||
"""Equality is str-strip normalized; equal values record 'agree' and the LLM
|
||||
value is kept verbatim (Python's whitespace-variant does not overwrite)."""
|
||||
_patch_derive_all(monkeypatch, site_code=" DYO1 ")
|
||||
parsed = _parsed(site_code="DYO1")
|
||||
out = po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", "subj", parse_method="ai_fallback"
|
||||
)
|
||||
assert out["site_code"] == "DYO1"
|
||||
assert _emf_lines(capsys)["site_code"]["Agreement"] == "agree"
|
||||
|
||||
|
||||
def test_ai_fallback_llm_null_python_filled(monkeypatch, capsys):
|
||||
"""LLM None + Python non-None: Python fills the gap and the record is
|
||||
categorized 'llm_null_python_filled'."""
|
||||
_patch_derive_all(monkeypatch, trade="HVAC")
|
||||
parsed = _parsed()
|
||||
out = po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", "subj", parse_method="ai_fallback"
|
||||
)
|
||||
assert out["trade"] == "HVAC" # gap filled
|
||||
|
||||
rec = _emf_lines(capsys)["trade"]
|
||||
assert rec["Agreement"] == "llm_null_python_filled"
|
||||
assert rec["LlmValue"] == ""
|
||||
assert rec["PythonValue"] == "HVAC"
|
||||
|
||||
|
||||
def test_ai_fallback_python_null(monkeypatch, capsys):
|
||||
"""LLM non-None + Python None: LLM value kept, record categorized
|
||||
'python_null'."""
|
||||
_patch_derive_all(monkeypatch) # all None
|
||||
parsed = _parsed(fiscal_year="2025")
|
||||
out = po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", "subj", parse_method="ai_fallback"
|
||||
)
|
||||
assert out["fiscal_year"] == "2025"
|
||||
|
||||
rec = _emf_lines(capsys)["fiscal_year"]
|
||||
assert rec["Agreement"] == "python_null"
|
||||
assert rec["PythonValue"] == ""
|
||||
assert rec["LlmValue"] == "2025"
|
||||
|
||||
|
||||
def test_ai_fallback_both_none_emits_nothing(monkeypatch, capsys):
|
||||
"""Both None: nothing to compare, so NO EMF record is emitted for that
|
||||
field (cardinality is not spent on empty comparisons)."""
|
||||
_patch_derive_all(monkeypatch) # all None
|
||||
parsed = _parsed() # all None
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj", parse_method="ai_fallback")
|
||||
assert _emf_lines(capsys) == {}
|
||||
|
||||
|
||||
def test_ai_fallback_emits_one_record_per_comparable_field(monkeypatch, capsys):
|
||||
"""Cardinality check: exactly one record per field that has something to
|
||||
compare -- here two fields, the both-None third is skipped."""
|
||||
_patch_derive_all(monkeypatch, site_code="HMK4", trade="HVAC")
|
||||
parsed = _parsed(site_code="DYO1") # fiscal_year both-None -> skipped
|
||||
po_handler.enrich_parsed(parsed, "s3://b/k", "subj", parse_method="ai_fallback")
|
||||
recs = _emf_lines(capsys)
|
||||
assert set(recs) == {"site_code", "trade"}
|
||||
assert recs["site_code"]["Agreement"] == "disagree"
|
||||
assert recs["trade"]["Agreement"] == "llm_null_python_filled"
|
||||
|
||||
|
||||
# --- totality: telemetry/classification never fails the invocation -----------
|
||||
|
||||
|
||||
def test_derive_all_exception_cannot_propagate(monkeypatch):
|
||||
"""derive_all is total, but the whole block is defensively wrapped anyway:
|
||||
a raising classifier must NOT propagate (this S3-async Lambda would retry ->
|
||||
DLQ). enrich_parsed still returns, earlier enrichment (zip padding) is
|
||||
intact, and the derived fields are left untouched."""
|
||||
|
||||
def boom(parsed):
|
||||
raise RuntimeError("classifier blew up")
|
||||
|
||||
monkeypatch.setattr(po_handler, "derive_all", boom)
|
||||
parsed = _parsed(ship_to={"address": "x", "state": "MA", "zip": "2149"})
|
||||
out = po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", "subj", parse_method="ai_fallback"
|
||||
)
|
||||
assert out is parsed # returned normally, no exception
|
||||
assert out["ship_to"]["zip"] == "02149" # pre-derive enrichment applied
|
||||
assert out["site_code"] is None # derived block bailed cleanly
|
||||
|
||||
|
||||
def test_emit_derived_agreement_metric_skips_when_both_none(capsys):
|
||||
"""The helper itself is the both-None gate: no line is printed."""
|
||||
po_handler._emit_derived_agreement_metric("site_code", None, None, "2D-1")
|
||||
assert capsys.readouterr().out == ""
|
||||
|
||||
|
||||
# --- EXTRACTION_PROMPT numeric declaration -----------------------------------
|
||||
|
||||
|
||||
def test_extraction_prompt_declares_quantity_price_numeric():
|
||||
"""The deferred PR #1 prompt tweak: line-item quantity/price are declared
|
||||
numeric, not 'string or null'. The enrich_parsed Decimal coercion stays as a
|
||||
safety net (test_enrich_parsed_coerces_prompt_string_quantity_price)."""
|
||||
prompt = po_handler.EXTRACTION_PROMPT
|
||||
assert '"quantity": "number or null"' in prompt
|
||||
assert '"price": "number or null"' in prompt
|
||||
assert '"quantity": "string or null"' not in prompt
|
||||
assert '"price": "string or null"' not in prompt
|
||||
|
||||
|
||||
# --- real derive_all integration smoke ---------------------------------------
|
||||
|
||||
|
||||
def test_template_path_real_derive_all_is_wired(monkeypatch):
|
||||
"""End-to-end against the real derive_all: a template-parsed new_po (derived
|
||||
fields None) runs through enrich_parsed and each derived field ends up
|
||||
consistent with what the real classifier yields for the final dict -- proving
|
||||
the classifier is actually invoked and its output applied (no monkeypatch)."""
|
||||
email_data = load_email("new-po", NEW_PO_STEMS[0])
|
||||
parsed, method, _t, reason = template_parser.try_deterministic_parse(email_data)
|
||||
assert (method, reason) == ("template", "ok")
|
||||
for key in template_parser.DERIVED_KEYS:
|
||||
assert parsed[key] is None # parser leaves them unset
|
||||
|
||||
po_handler.enrich_parsed(
|
||||
parsed, "s3://b/k", email_data["subject"], parse_method="template"
|
||||
)
|
||||
# derive_all is pure and the fill does not feed back into its inputs, so the
|
||||
# filled values must equal a recomputation over the enriched dict.
|
||||
recomputed = po_handler.derive_all(parsed)
|
||||
for key in template_parser.DERIVED_KEYS:
|
||||
assert parsed[key] == recomputed[key]
|
||||
|
|
@ -27,7 +27,7 @@ REPO_ROOT = Path(__file__).resolve().parents[1]
|
|||
# be bound to the right pipeline's file around each handler exec -- relying on
|
||||
# sys.path ordering (or on whatever a previously collected suite left in
|
||||
# sys.modules) silently binds a handler to the OTHER pipeline's sibling.
|
||||
_SIBLING_MODULES = ("ses_auth", "template_parser")
|
||||
_SIBLING_MODULES = ("ses_auth", "template_parser", "derived_fields")
|
||||
|
||||
|
||||
def _load_module(path, module_name):
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue