Avinash Nalla commited on
Commit
c55abce
·
1 Parent(s): d618a6e

Phase 2+4: PO/Invoice client folder routing and subject-line MTR trigger

Browse files

Phase 2 — Client folder routing:
- New table client_domains (domain primary key, client_name, audit fields).
- New module app/lib/utils/client_routing.py with resolve_client_name()
and add_or_update_client_domain() helpers.
- processor.py: PO/Invoice uploads now insert {client_name}/ between the
type path and the YYYY-MM folder. Unknown sender domains route to
{type_path}/_unmatched/ and add a daily_brief row with reason
'client_unmatched' so ops can add the mapping without redeploy.
- Daily brief gets a new 'Unmatched Sender Domains' section.
- Alembic migration 0002 + matching DDL in database.py.
- Seed script: scripts/seed_client_domains.{json,py} for initial mappings.

Phase 4 — Subject-line MTR trigger:
- Per project brief: any PDF attachment whose subject or filename contains
'MTR' (case-insensitive, word-boundary) routes as MTR without keyword
scoring. Heat code extraction still runs.

alembic/versions/0002_add_client_domains.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Add client_domains table — sender-domain to client-folder-name mapping.
2
+
3
+ Used by Phase 2 client routing: when a PO or Invoice is classified,
4
+ we look up the sender's email domain in this table to decide which
5
+ client-name subfolder to place the file under in Dropbox.
6
+
7
+ Domains are stored lowercase as the primary key. Unmatched senders are
8
+ recorded in the daily_brief with reason='client_unmatched' so ops can
9
+ add new mappings over time without a code change or redeploy.
10
+
11
+ Revision ID: 0002
12
+ Revises: 0001
13
+ Create Date: 2026-05-17
14
+
15
+ """
16
+ from typing import Sequence, Union
17
+
18
+ from alembic import op
19
+
20
+ revision: str = "0002"
21
+ down_revision: Union[str, None] = "0001"
22
+ branch_labels: Union[str, Sequence[str], None] = None
23
+ depends_on: Union[str, Sequence[str], None] = None
24
+
25
+
26
+ def upgrade() -> None:
27
+ op.execute("""
28
+ CREATE TABLE IF NOT EXISTS client_domains (
29
+ domain TEXT PRIMARY KEY,
30
+ client_name TEXT NOT NULL,
31
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
32
+ last_seen_at TEXT,
33
+ notes TEXT
34
+ )
35
+ """)
36
+
37
+
38
+ def downgrade() -> None:
39
+ op.execute("DROP TABLE IF EXISTS client_domains")
app/database.py CHANGED
@@ -62,6 +62,14 @@ CREATE TABLE IF NOT EXISTS daily_brief (
62
  included_in_report INTEGER NOT NULL DEFAULT 0,
63
  report_sent_at TEXT
64
  );
 
 
 
 
 
 
 
 
65
  """
66
 
67
 
 
62
  included_in_report INTEGER NOT NULL DEFAULT 0,
63
  report_sent_at TEXT
64
  );
65
+
66
+ CREATE TABLE IF NOT EXISTS client_domains (
67
+ domain TEXT PRIMARY KEY,
68
+ client_name TEXT NOT NULL,
69
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
70
+ last_seen_at TEXT,
71
+ notes TEXT
72
+ );
73
  """
74
 
75
 
app/lib/utils/brief_html.py CHANGED
@@ -21,6 +21,7 @@ def build_brief_html(items: list[dict]) -> str:
21
  """
22
  ambiguous = [i for i in items if i["reason"] == "ambiguous"]
23
  no_heat = [i for i in items if i["reason"] == "mtr_no_heat_code"]
 
24
  errors = [i for i in items if i["reason"] == "error"]
25
 
26
  sections: list[str] = []
@@ -29,6 +30,7 @@ def build_brief_html(items: list[dict]) -> str:
29
  summary_bits = []
30
  if ambiguous: summary_bits.append(f"{len(ambiguous)} ambiguous routing(s)")
31
  if no_heat: summary_bits.append(f"{len(no_heat)} MTR(s) without a heat code")
 
32
  if errors: summary_bits.append(f"{len(errors)} processing error(s)")
33
  if summary_bits:
34
  sections.append(
@@ -95,6 +97,31 @@ def build_brief_html(items: list[dict]) -> str:
95
  "</table>"
96
  )
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  if errors:
99
  rows = ""
100
  for item in errors:
 
21
  """
22
  ambiguous = [i for i in items if i["reason"] == "ambiguous"]
23
  no_heat = [i for i in items if i["reason"] == "mtr_no_heat_code"]
24
+ unmatched = [i for i in items if i["reason"] == "client_unmatched"]
25
  errors = [i for i in items if i["reason"] == "error"]
26
 
27
  sections: list[str] = []
 
30
  summary_bits = []
31
  if ambiguous: summary_bits.append(f"{len(ambiguous)} ambiguous routing(s)")
32
  if no_heat: summary_bits.append(f"{len(no_heat)} MTR(s) without a heat code")
33
+ if unmatched: summary_bits.append(f"{len(unmatched)} unmatched sender domain(s)")
34
  if errors: summary_bits.append(f"{len(errors)} processing error(s)")
35
  if summary_bits:
36
  sections.append(
 
97
  "</table>"
98
  )
99
 
100
+ if unmatched:
101
+ rows = ""
102
+ for item in unmatched:
103
+ rows += (
104
+ f"<tr>"
105
+ f"<td>{item.get('sender_email', '')}</td>"
106
+ f"<td>{item.get('subject', '')}</td>"
107
+ f"<td>{item.get('attachment_filename', '')}</td>"
108
+ f"</tr>"
109
+ )
110
+ sections.append(
111
+ "<h2>Unmatched Sender Domains</h2>"
112
+ "<p>These POs/Invoices were uploaded to "
113
+ "<code>POs/_unmatched/</code> or <code>Invoices/_unmatched/</code> "
114
+ "because the sender's email domain isn't in the client mapping. "
115
+ "Move the file under the right client folder in Dropbox, then "
116
+ "add the domain → client mapping to "
117
+ "<code>scripts/seed_client_domains.json</code> and re-run the "
118
+ "seeder so future emails route correctly.</p>"
119
+ "<table border='1' cellpadding='4' cellspacing='0'>"
120
+ "<tr><th>Sender</th><th>Subject</th><th>Attachment</th></tr>"
121
+ f"{rows}"
122
+ "</table>"
123
+ )
124
+
125
  if errors:
126
  rows = ""
127
  for item in errors:
app/lib/utils/client_routing.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolve a sender's email domain to the Dropbox client-folder name.
2
+
3
+ Used for routing Purchase Order and Invoice attachments under a per-client
4
+ subfolder. Unknown domains route to `_unmatched` and are surfaced in the
5
+ daily brief so ops can add a mapping without a redeploy.
6
+
7
+ The mapping table is seeded via `scripts/seed_client_domains.py` and can
8
+ be edited at runtime by inserting rows directly into the `client_domains`
9
+ table (no server restart needed — lookups happen per request).
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import logging
14
+ import re
15
+ from typing import Optional
16
+
17
+ import aiosqlite
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+
22
+ # Conservative domain extraction. We accept lowercase letters, digits, dot,
23
+ # and hyphen — the standard DNS character set. Anything else gets rejected
24
+ # so we don't pollute the table with junk like "foo@bar baz.com".
25
+ _DOMAIN_RE = re.compile(r"^[a-z0-9.\-]+$")
26
+
27
+
28
+ def _extract_domain(sender_email: str) -> Optional[str]:
29
+ """Return the lowercased, validated domain portion of an email, or None.
30
+
31
+ Strips angle brackets and surrounding whitespace; accepts "<a@b.com>",
32
+ " a@b.com ", "FOO@BAR.COM". Rejects malformed addresses outright.
33
+ """
34
+ if not sender_email:
35
+ return None
36
+ s = sender_email.strip().strip("<>").lower()
37
+ if "@" not in s:
38
+ return None
39
+ _local, _, domain = s.rpartition("@")
40
+ domain = domain.strip()
41
+ if not domain or not _DOMAIN_RE.match(domain):
42
+ return None
43
+ return domain
44
+
45
+
46
+ async def resolve_client_name(
47
+ conn: aiosqlite.Connection,
48
+ sender_email: str,
49
+ ) -> Optional[str]:
50
+ """Look up the client-folder name for a sender's email domain.
51
+
52
+ Returns the client name string if a mapping exists, otherwise None.
53
+ Updates `last_seen_at` on the matched row so ops can see which mappings
54
+ are actively being used.
55
+
56
+ The conn is expected to be opened with `aiosqlite.Row` as row factory
57
+ (the standard `get_db` context manager in app.database does this).
58
+ """
59
+ domain = _extract_domain(sender_email)
60
+ if domain is None:
61
+ logger.debug("resolve_client_name: rejected sender %r", sender_email)
62
+ return None
63
+
64
+ cursor = await conn.execute(
65
+ "SELECT client_name FROM client_domains WHERE domain = ?",
66
+ (domain,),
67
+ )
68
+ row = await cursor.fetchone()
69
+ if row is None:
70
+ return None
71
+
72
+ # Touch last_seen_at so ops can see mapping activity.
73
+ await conn.execute(
74
+ "UPDATE client_domains SET last_seen_at = datetime('now') WHERE domain = ?",
75
+ (domain,),
76
+ )
77
+ await conn.commit()
78
+
79
+ return row["client_name"]
80
+
81
+
82
+ async def add_or_update_client_domain(
83
+ conn: aiosqlite.Connection,
84
+ domain: str,
85
+ client_name: str,
86
+ notes: Optional[str] = None,
87
+ ) -> None:
88
+ """Upsert a domain→client mapping.
89
+
90
+ Used by the seed script and by any future admin tool. `domain` is
91
+ normalized lowercase; `client_name` is stored as-is (so case can be
92
+ preserved in the Dropbox folder name, e.g. "Westlake").
93
+ """
94
+ domain_norm = (domain or "").strip().lower()
95
+ if not _DOMAIN_RE.match(domain_norm):
96
+ raise ValueError(f"Invalid domain: {domain!r}")
97
+
98
+ await conn.execute(
99
+ """
100
+ INSERT INTO client_domains (domain, client_name, notes)
101
+ VALUES (?, ?, ?)
102
+ ON CONFLICT(domain) DO UPDATE SET
103
+ client_name = excluded.client_name,
104
+ notes = excluded.notes
105
+ """,
106
+ (domain_norm, client_name.strip(), notes),
107
+ )
108
+ await conn.commit()
app/processor.py CHANGED
@@ -24,6 +24,7 @@ from app.database import (
24
  set_notification_routing_status,
25
  )
26
  from app.lib.utils.classifier import ClassifyResult, ScoringWeights, classify
 
27
  from app.lib.utils.dropbox_client import DropboxClient
28
  from app.lib.utils.error_handler import processing_error_handler
29
  from app.lib.utils.heat_code import extract_heat_codes
@@ -82,6 +83,21 @@ def _sanitize_path_segment(s: str) -> str:
82
  return cleaned or "unknown"
83
 
84
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
85
  def _scoring_weights() -> ScoringWeights:
86
  return ScoringWeights(
87
  filename=settings.score_weight_filename,
@@ -132,12 +148,31 @@ async def _process_notification(dbx: DropboxClient, notif: dict) -> None:
132
  sender_email, subject, filename,
133
  ):
134
  file_bytes = att_path.read_bytes()
135
- result: ClassifyResult = classify(filename, file_bytes, content_type, weights)
136
 
137
- logger.info(
138
- "Notification %d / %s %s (reason=%s, scores=%s)",
139
- notif_id, filename, result.doc_type, result.reason, result.scores,
140
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
142
  if result.breakdown:
143
  lines = [f" Score breakdown — {filename}:"]
@@ -269,25 +304,71 @@ async def _process_notification(dbx: DropboxClient, notif: dict) -> None:
269
  final_status = "brief_pending"
270
 
271
  else:
272
- # po / invoicesingle upload at the type root.
273
- # (Phase 2 will insert a {client} subfolder here based
274
- # on the sender's email domain.)
275
- dest_path = (
276
- f"{type_path}/{month_folder}/{date_prefix}_{filename}"
277
- )
278
- actual_path = await asyncio.to_thread(
279
- dbx.upload, file_bytes, dest_path
280
- )
281
- logger.info("Uploaded %s → %s", filename, actual_path)
282
  async with get_db(settings.database_path) as conn:
283
- await log_routing_result(
284
- conn, notif_id, filename, result.doc_type, "uploaded",
285
- email_identifier=email_identifier,
286
- destination_path=actual_path,
287
- confidence_scores=result.scores,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  )
289
- uploaded_count += 1
290
- final_status = "routed"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  except Exception:
293
  # processing_error_handler already logged and wrote to DB; continue to next attachment.
 
24
  set_notification_routing_status,
25
  )
26
  from app.lib.utils.classifier import ClassifyResult, ScoringWeights, classify
27
+ from app.lib.utils.client_routing import resolve_client_name
28
  from app.lib.utils.dropbox_client import DropboxClient
29
  from app.lib.utils.error_handler import processing_error_handler
30
  from app.lib.utils.heat_code import extract_heat_codes
 
83
  return cleaned or "unknown"
84
 
85
 
86
+ # Phase 4 — Project brief says any attachment whose subject OR filename
87
+ # contains "MTR" (case-insensitive, word-boundary) should be routed as MTR
88
+ # regardless of OCR scoring. The classifier scores filename+OCR keywords but
89
+ # never looks at the subject, so we add this pre-classifier shortcut.
90
+ _MTR_TRIGGER_RE = __import__("re").compile(r"\bmtr\b", __import__("re").IGNORECASE)
91
+
92
+
93
+ def _is_mtr_by_subject_or_filename(subject: str, filename: str) -> bool:
94
+ if subject and _MTR_TRIGGER_RE.search(subject):
95
+ return True
96
+ if filename and _MTR_TRIGGER_RE.search(filename):
97
+ return True
98
+ return False
99
+
100
+
101
  def _scoring_weights() -> ScoringWeights:
102
  return ScoringWeights(
103
  filename=settings.score_weight_filename,
 
148
  sender_email, subject, filename,
149
  ):
150
  file_bytes = att_path.read_bytes()
 
151
 
152
+ # Phase 4: subject-line / filename MTR trigger from the
153
+ # project brief. Bypasses keyword scoring if "MTR" appears
154
+ # as a word in either the email subject or attachment
155
+ # filename. Only applied to PDFs — non-PDFs still go
156
+ # through the classifier and get skipped on content type.
157
+ if (
158
+ content_type.startswith("application/pdf")
159
+ and _is_mtr_by_subject_or_filename(subject, filename)
160
+ ):
161
+ logger.info(
162
+ "Notification %d / %s → mtr (subject/filename trigger)",
163
+ notif_id, filename,
164
+ )
165
+ result: ClassifyResult = ClassifyResult(
166
+ doc_type="mtr",
167
+ reason="routed",
168
+ scores={"mtr": 1.0},
169
+ )
170
+ else:
171
+ result = classify(filename, file_bytes, content_type, weights)
172
+ logger.info(
173
+ "Notification %d / %s → %s (reason=%s, scores=%s)",
174
+ notif_id, filename, result.doc_type, result.reason, result.scores,
175
+ )
176
 
177
  if result.breakdown:
178
  lines = [f" Score breakdown — {filename}:"]
 
304
  final_status = "brief_pending"
305
 
306
  else:
307
+ # PO / InvoicePhase 2 client folder routing.
308
+ # Look up the sender's email domain in client_domains.
309
+ # If a mapping exists, insert {client}/ between the
310
+ # type root and the YYYY-MM month folder. Otherwise,
311
+ # route to {type_path}/_unmatched/ and flag in the
312
+ # daily brief so ops can add the mapping.
 
 
 
 
313
  async with get_db(settings.database_path) as conn:
314
+ client_name = await resolve_client_name(conn, sender_email)
315
+
316
+ if client_name:
317
+ safe_client = _sanitize_path_segment(client_name)
318
+ dest_path = (
319
+ f"{type_path}/{safe_client}/{month_folder}/"
320
+ f"{date_prefix}_{filename}"
321
+ )
322
+ actual_path = await asyncio.to_thread(
323
+ dbx.upload, file_bytes, dest_path
324
+ )
325
+ logger.info(
326
+ "Uploaded %s (client=%s) → %s",
327
+ filename, client_name, actual_path,
328
+ )
329
+ async with get_db(settings.database_path) as conn:
330
+ await log_routing_result(
331
+ conn, notif_id, filename, result.doc_type, "uploaded",
332
+ email_identifier=email_identifier,
333
+ destination_path=actual_path,
334
+ confidence_scores=result.scores,
335
+ )
336
+ uploaded_count += 1
337
+ final_status = "routed"
338
+ else:
339
+ # Unknown sender domain — fallback + daily brief.
340
+ dest_path = (
341
+ f"{type_path}/_unmatched/{month_folder}/"
342
+ f"{date_prefix}_{filename}"
343
+ )
344
+ actual_path = await asyncio.to_thread(
345
+ dbx.upload, file_bytes, dest_path
346
+ )
347
+ logger.info(
348
+ "Uploaded %s (unmatched sender=%s) → %s",
349
+ filename, sender_email, actual_path,
350
  )
351
+ async with get_db(settings.database_path) as conn:
352
+ from app.database import insert_daily_brief
353
+ await log_routing_result(
354
+ conn, notif_id, filename, result.doc_type,
355
+ "uploaded_unmatched_client",
356
+ email_identifier=email_identifier,
357
+ destination_path=actual_path,
358
+ confidence_scores=result.scores,
359
+ )
360
+ await insert_daily_brief(
361
+ conn,
362
+ email_identifier=email_identifier,
363
+ notification_id=notif_id,
364
+ sender_email=sender_email,
365
+ subject=subject,
366
+ filename=filename,
367
+ reason="client_unmatched",
368
+ confidence_scores=result.scores,
369
+ )
370
+ uploaded_count += 1
371
+ final_status = "brief_pending"
372
 
373
  except Exception:
374
  # processing_error_handler already logged and wrote to DB; continue to next attachment.
scripts/preview_brief.py CHANGED
@@ -68,9 +68,31 @@ SAMPLE_ITEMS: list[dict] = [
68
  }),
69
  },
70
 
71
- # One processing error.
72
  {
73
  "id": 5,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  "sender_email": "noreply@example-vendor.com",
75
  "subject": "RCM - corrupted attachment",
76
  "attachment_filename": "scan_2026-05-10_corrupted.pdf",
 
68
  }),
69
  },
70
 
71
+ # Two unmatched sender domains.
72
  {
73
  "id": 5,
74
+ "sender_email": "purchasing@newvendor-inc.com",
75
+ "subject": "RCM - PO from a vendor we haven't seen before",
76
+ "attachment_filename": "NewVendor_PO_2026-04-22.pdf",
77
+ "reason": "client_unmatched",
78
+ "confidence_scores": json.dumps({
79
+ "po": 0.86, "invoice": 0.08, "mtr": 0.02, "quote": 0.04,
80
+ }),
81
+ },
82
+ {
83
+ "id": 6,
84
+ "sender_email": "billing@small-shop.com",
85
+ "subject": "RCM - Invoice March 2026",
86
+ "attachment_filename": "Invoice_March_2026.pdf",
87
+ "reason": "client_unmatched",
88
+ "confidence_scores": json.dumps({
89
+ "po": 0.11, "invoice": 0.84, "mtr": 0.01, "quote": 0.03,
90
+ }),
91
+ },
92
+
93
+ # One processing error.
94
+ {
95
+ "id": 7,
96
  "sender_email": "noreply@example-vendor.com",
97
  "subject": "RCM - corrupted attachment",
98
  "attachment_filename": "scan_2026-05-10_corrupted.pdf",
scripts/seed_client_domains.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "_README": "Initial seed mapping for client_domains table. Add new entries here, then run scripts/seed_client_domains.py to upsert them into the DB. Domain keys are lowercase. client_name is the exact Dropbox folder name to use.",
3
+
4
+ "westlake.com": "Westlake",
5
+ "halliburton.com": "Halliburton"
6
+ }
scripts/seed_client_domains.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed the client_domains table from scripts/seed_client_domains.json.
2
+
3
+ Idempotent — running this multiple times updates existing rows in place
4
+ instead of erroring. Safe to run after deploys or whenever you've added
5
+ new mappings to the JSON file.
6
+
7
+ Usage: python -m scripts.seed_client_domains
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ # Make repo root importable when invoked as `python scripts/seed_client_domains.py`
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from app.config import settings
20
+ from app.database import get_db
21
+ from app.lib.utils.client_routing import add_or_update_client_domain
22
+
23
+
24
+ SEED_FILE = Path(__file__).resolve().parent / "seed_client_domains.json"
25
+
26
+
27
+ async def main() -> None:
28
+ data = json.loads(SEED_FILE.read_text())
29
+ # Strip the README-style key if present.
30
+ data.pop("_README", None)
31
+
32
+ if not data:
33
+ print("Nothing to seed — JSON file has no mappings.")
34
+ return
35
+
36
+ async with get_db(settings.database_path) as conn:
37
+ for domain, client_name in data.items():
38
+ await add_or_update_client_domain(conn, domain, client_name)
39
+ print(f" {domain:40s} → {client_name}")
40
+
41
+ print(f"\nSeeded {len(data)} domain mapping(s) into {settings.database_path}.")
42
+
43
+
44
+ if __name__ == "__main__":
45
+ asyncio.run(main())