Spaces:
Sleeping
Phase 2+4: PO/Invoice client folder routing and subject-line MTR trigger
Browse filesPhase 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 +39 -0
- app/database.py +8 -0
- app/lib/utils/brief_html.py +27 -0
- app/lib/utils/client_routing.py +108 -0
- app/processor.py +103 -22
- scripts/preview_brief.py +23 -1
- scripts/seed_client_domains.json +6 -0
- scripts/seed_client_domains.py +45 -0
|
@@ -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")
|
|
@@ -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 |
|
|
@@ -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:
|
|
@@ -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()
|
|
@@ -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 |
-
|
| 138 |
-
|
| 139 |
-
|
| 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 |
-
#
|
| 273 |
-
#
|
| 274 |
-
#
|
| 275 |
-
|
| 276 |
-
|
| 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
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
)
|
| 289 |
-
|
| 290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 / Invoice — Phase 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.
|
|
@@ -68,9 +68,31 @@ SAMPLE_ITEMS: list[dict] = [
|
|
| 68 |
}),
|
| 69 |
},
|
| 70 |
|
| 71 |
-
#
|
| 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",
|
|
@@ -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 |
+
}
|
|
@@ -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())
|