Spaces:
Sleeping
Sleeping
File size: 19,690 Bytes
7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef c55abce 0a367ef d618a6e 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef d618a6e c55abce 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef c55abce 0a367ef a6c05d2 0a367ef 189d6c8 0a367ef 189d6c8 0a367ef 189d6c8 0a367ef d618a6e 0a367ef d618a6e c55abce d618a6e c55abce d618a6e c55abce 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc 0a367ef 7da71bc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 | """Standalone attachment routing process.
Run with: python -m app.processor
Polls the SQLite DB for notifications with processing_status='fetched', classifies
each attachment via OCR, and uploads routable documents to Dropbox.
"""
from __future__ import annotations
import asyncio
import json
import logging
import logging.handlers
import signal
import traceback
from datetime import datetime
from pathlib import Path
from app.config import settings
from app.database import (
get_db,
get_fetched_notifications,
log_routing_result,
set_notification_routing_status,
)
from app.lib.utils.classifier import ClassifyResult, ScoringWeights, classify
from app.lib.utils.client_routing import resolve_client_name
from app.lib.utils.dropbox_client import DropboxClient
from app.lib.utils.error_handler import processing_error_handler
from app.lib.utils.heat_code import extract_heat_codes
from app.lib.utils.notifier import send_developer_alert
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [processor] %(levelname)s %(message)s",
)
logger = logging.getLogger(__name__)
_DEST_MAP = {
"po": settings.dropbox_po_path,
"invoice": settings.dropbox_invoice_path,
"mtr": settings.dropbox_mtr_path,
}
_shutdown = asyncio.Event()
def _setup_log_file() -> None:
settings.log_path.mkdir(parents=True, exist_ok=True)
handler = logging.handlers.TimedRotatingFileHandler(
filename=settings.log_path / "processor.log",
when="midnight",
interval=1,
backupCount=0,
encoding="utf-8",
)
handler.setFormatter(logging.Formatter("%(asctime)s [processor] %(levelname)s %(message)s"))
logging.getLogger().addHandler(handler)
def _handle_signal(*_: object) -> None:
logger.info("Shutdown signal received")
_shutdown.set()
def _date_prefix(received_dt: str) -> str:
try:
return datetime.fromisoformat(received_dt).strftime("%Y%m%d")
except Exception:
return "00000000"
def _sanitize_path_segment(s: str) -> str:
"""Make a string safe for use as a Dropbox path segment.
Strips surrounding whitespace and replaces any character that isn't an
ASCII letter, digit, dash, dot, or underscore with an underscore. Returns
"unknown" if the result would be empty.
"""
import re
s = (s or "").strip()
cleaned = re.sub(r"[^A-Za-z0-9._-]", "_", s)
return cleaned or "unknown"
# Phase 4 β Project brief says any attachment whose subject OR filename
# contains "MTR" (case-insensitive, word-boundary) should be routed as MTR
# regardless of OCR scoring. The classifier scores filename+OCR keywords but
# never looks at the subject, so we add this pre-classifier shortcut.
_MTR_TRIGGER_RE = __import__("re").compile(r"\bmtr\b", __import__("re").IGNORECASE)
def _is_mtr_by_subject_or_filename(subject: str, filename: str) -> bool:
if subject and _MTR_TRIGGER_RE.search(subject):
return True
if filename and _MTR_TRIGGER_RE.search(filename):
return True
return False
def _scoring_weights() -> ScoringWeights:
return ScoringWeights(
filename=settings.score_weight_filename,
ocr=settings.score_weight_ocr,
freq_multiplier=settings.score_freq_multiplier,
min_threshold=settings.score_min_threshold,
)
async def _process_notification(dbx: DropboxClient, notif: dict) -> None:
notif_id: int = notif["id"]
email_identifier: str = notif.get("email_identifier") or notif.get("message_id") or ""
storage_path = notif.get("storage_path")
# Notification-level setup: load metadata before we know filename/sender.
# On failure, error_handler logs + writes to DB, then re-raises β we catch
# that here to mark the notification as routing_error and return early.
try:
async with processing_error_handler(
settings.database_path, notif_id, email_identifier
):
if not storage_path:
raise ValueError("no storage_path recorded")
meta = json.loads(Path(storage_path).joinpath("metadata.json").read_text())
except Exception:
async with get_db(settings.database_path) as conn:
await set_notification_routing_status(conn, notif_id, "routing_error")
return
sender_email: str = meta.get("from", {}).get("address", "")
subject: str = meta.get("subject", "")
received_dt: str = meta.get("received_datetime", "")
date_prefix = _date_prefix(received_dt)
attachments: list[dict] = meta.get("attachments", [])
weights = _scoring_weights()
uploaded_count = 0
final_status = "routing_skipped"
for att in attachments:
filename: str = att.get("filename", "attachment")
content_type: str = att.get("content_type", "")
att_path = Path(storage_path) / "attachments" / filename
try:
async with processing_error_handler(
settings.database_path, notif_id, email_identifier,
sender_email, subject, filename,
):
file_bytes = att_path.read_bytes()
# Phase 4: subject-line / filename MTR trigger from the
# project brief. Bypasses keyword scoring if "MTR" appears
# as a word in either the email subject or attachment
# filename. Only applied to PDFs β non-PDFs still go
# through the classifier and get skipped on content type.
if (
content_type.startswith("application/pdf")
and _is_mtr_by_subject_or_filename(subject, filename)
):
logger.info(
"Notification %d / %s β mtr (subject/filename trigger)",
notif_id, filename,
)
result: ClassifyResult = ClassifyResult(
doc_type="mtr",
reason="routed",
scores={"mtr": 1.0},
)
else:
result = classify(filename, file_bytes, content_type, weights)
logger.info(
"Notification %d / %s β %s (reason=%s, scores=%s)",
notif_id, filename, result.doc_type, result.reason, result.scores,
)
if result.breakdown:
lines = [f" Score breakdown β {filename}:"]
for doc_type, matches in sorted(
result.breakdown.items(),
key=lambda kv: result.scores.get(kv[0], 0.0),
reverse=True,
):
lines.append(f" {doc_type} total={result.scores[doc_type]:.4f}")
for m in matches:
parts = []
if m.filename_hits:
parts.append(f"filename={m.filename_hits}Γ (+{m.filename_contrib:.4f})")
if m.ocr_hits:
parts.append(f"ocr={m.ocr_hits}Γ (+{m.ocr_contrib:.4f})")
lines.append(
f" \"{m.keyword}\" weight={m.weight:.2f} {' '.join(parts)}"
)
logger.info("\n".join(lines))
if result.reason == "non_pdf":
async with get_db(settings.database_path) as conn:
await log_routing_result(
conn, notif_id, filename, "skipped", "skipped",
email_identifier=email_identifier,
)
elif result.reason == "ambiguous":
async with get_db(settings.database_path) as conn:
from app.database import insert_daily_brief
await log_routing_result(
conn, notif_id, filename, "ambiguous", "brief_pending",
email_identifier=email_identifier,
confidence_scores=result.scores,
)
await insert_daily_brief(
conn,
email_identifier=email_identifier,
notification_id=notif_id,
sender_email=sender_email,
subject=subject,
filename=filename,
reason="ambiguous",
confidence_scores=result.scores,
)
final_status = "brief_pending"
elif result.reason in ("below_threshold", "not_routable"):
async with get_db(settings.database_path) as conn:
await log_routing_result(
conn, notif_id, filename, result.doc_type, "skipped",
email_identifier=email_identifier,
confidence_scores=result.scores,
)
else:
# result.reason == "routed"
month_folder = received_dt[:7] if received_dt else "unknown"
type_path = _DEST_MAP[result.doc_type]
if result.doc_type == "mtr":
# MTRs: extract heat code(s) and upload one copy per
# code under {mtr_path}/{heat_code}/{YYYY-MM}/...
# If no heat code can be extracted, fall back to
# {mtr_path}/_no_heat_code/{YYYY-MM}/... and flag
# the row in the daily brief for ops to manually re-file.
heat_codes = extract_heat_codes(file_bytes, filename=filename)
logger.info(
"MTR %s heat codes: %s",
filename, heat_codes or "<none β fallback>",
)
if heat_codes:
for heat_code in heat_codes:
safe_code = _sanitize_path_segment(heat_code)
dest_path = (
f"{type_path}/{safe_code}/{month_folder}/"
f"{date_prefix}_{filename}"
)
actual_path = await asyncio.to_thread(
dbx.upload, file_bytes, dest_path
)
logger.info(
"Uploaded MTR %s (heat=%s) β %s",
filename, heat_code, actual_path,
)
async with get_db(settings.database_path) as conn:
await log_routing_result(
conn, notif_id, filename, "mtr", "uploaded",
email_identifier=email_identifier,
destination_path=actual_path,
confidence_scores=result.scores,
)
uploaded_count += 1
final_status = "routed"
else:
# No heat code β fallback location + daily brief flag.
dest_path = (
f"{type_path}/_no_heat_code/{month_folder}/"
f"{date_prefix}_{filename}"
)
actual_path = await asyncio.to_thread(
dbx.upload, file_bytes, dest_path
)
logger.info(
"Uploaded MTR %s (no heat code extracted) β %s",
filename, actual_path,
)
async with get_db(settings.database_path) as conn:
from app.database import insert_daily_brief
await log_routing_result(
conn, notif_id, filename, "mtr",
"uploaded_no_heat_code",
email_identifier=email_identifier,
destination_path=actual_path,
confidence_scores=result.scores,
)
await insert_daily_brief(
conn,
email_identifier=email_identifier,
notification_id=notif_id,
sender_email=sender_email,
subject=subject,
filename=filename,
reason="mtr_no_heat_code",
confidence_scores=result.scores,
)
uploaded_count += 1
final_status = "brief_pending"
else:
# PO / Invoice β Phase 2 client folder routing.
# Look up the sender's email domain in client_domains.
# If a mapping exists, insert {client}/ between the
# type root and the YYYY-MM month folder. Otherwise,
# route to {type_path}/_unmatched/ and flag in the
# daily brief so ops can add the mapping.
async with get_db(settings.database_path) as conn:
client_name = await resolve_client_name(conn, sender_email)
if client_name:
safe_client = _sanitize_path_segment(client_name)
dest_path = (
f"{type_path}/{safe_client}/{month_folder}/"
f"{date_prefix}_{filename}"
)
actual_path = await asyncio.to_thread(
dbx.upload, file_bytes, dest_path
)
logger.info(
"Uploaded %s (client=%s) β %s",
filename, client_name, actual_path,
)
async with get_db(settings.database_path) as conn:
await log_routing_result(
conn, notif_id, filename, result.doc_type, "uploaded",
email_identifier=email_identifier,
destination_path=actual_path,
confidence_scores=result.scores,
)
uploaded_count += 1
final_status = "routed"
else:
# Unknown sender domain β fallback + daily brief.
dest_path = (
f"{type_path}/_unmatched/{month_folder}/"
f"{date_prefix}_{filename}"
)
actual_path = await asyncio.to_thread(
dbx.upload, file_bytes, dest_path
)
logger.info(
"Uploaded %s (unmatched sender=%s) β %s",
filename, sender_email, actual_path,
)
async with get_db(settings.database_path) as conn:
from app.database import insert_daily_brief
await log_routing_result(
conn, notif_id, filename, result.doc_type,
"uploaded_unmatched_client",
email_identifier=email_identifier,
destination_path=actual_path,
confidence_scores=result.scores,
)
await insert_daily_brief(
conn,
email_identifier=email_identifier,
notification_id=notif_id,
sender_email=sender_email,
subject=subject,
filename=filename,
reason="client_unmatched",
confidence_scores=result.scores,
)
uploaded_count += 1
final_status = "brief_pending"
except Exception:
# processing_error_handler already logged and wrote to DB; continue to next attachment.
final_status = "routing_error"
async with get_db(settings.database_path) as conn:
await set_notification_routing_status(conn, notif_id, final_status)
logger.info("Notification %d β %s", notif_id, final_status)
async def run() -> None:
dbx = DropboxClient(
app_key=settings.dropbox_app_key,
app_secret=settings.dropbox_app_secret.get_secret_value(),
refresh_token=settings.dropbox_refresh_token.get_secret_value(),
)
logger.info("Processor started; polling every %ds", settings.processor_poll_interval)
while not _shutdown.is_set():
try:
async with get_db(settings.database_path) as conn:
pending = await get_fetched_notifications(conn)
if pending:
logger.info("Found %d notification(s) to process", len(pending))
for notif in pending:
async with get_db(settings.database_path) as conn:
await set_notification_routing_status(conn, notif["id"], "routing_queued")
for notif in pending:
await _process_notification(dbx, notif)
except Exception:
tb = traceback.format_exc()
logger.exception("Unhandled error in processor loop")
await send_developer_alert(
settings,
subject="[RCM] Processor loop unhandled exception",
body=(
f"An unhandled exception was caught in the processor poll loop. "
f"Processing will resume after the next poll interval "
f"({settings.processor_poll_interval}s).\n\n"
f"{tb}"
),
)
try:
await asyncio.wait_for(_shutdown.wait(), timeout=settings.processor_poll_interval)
except asyncio.TimeoutError:
pass
logger.info("Processor stopped")
def main() -> None:
_setup_log_file()
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
for sig in (signal.SIGTERM, signal.SIGINT):
loop.add_signal_handler(sig, _handle_signal)
try:
loop.run_until_complete(run())
finally:
loop.close()
if __name__ == "__main__":
main()
|