"""Intake-gate configuration, the fail policy, and log-safe digests. Pure module: no FastAPI imports and no HTTPException. Non-DB config is module-level load_dotenv() + os.getenv (house style). The model / token / effort / cache knobs come from the bulk-ats Settings instead, exactly as job/candidate/plugins.get_scoring_settings does, so OPENAI_MODEL and OPENAI_MAX_OUTPUT_TOKENS keep one meaning per process. get_triage_settings() calls get_settings() lazily, never at import: it validates OPENAI_MODEL and would otherwise turn a stale env var into an import failure. """ from __future__ import annotations import hashlib import os from app.core.config import Settings, get_settings from dotenv import load_dotenv from inbox_classifier.enums import Triage_Status from inbox_classifier.prompt import PROMPT_VERSION load_dotenv() def _flag(name, default) -> bool: raw=(os.getenv(name) or "").strip().lower() if not raw: return default return raw in ("1","true","yes","on") # false restores the pre-gate behaviour exactly: every message is ingested and no # triage row is written. The rollback lever — no code revert needed. TRIAGE_ENABLED=_flag("INBOX_TRIAGE_ENABLED",True) # true: a provider outage or a missing key ingests the mail and stamps the verdict # unclassified. The app already boots without OPENAI_API_KEY (main.py logs "llm startup # skipped"), so fail-closed would silently make ingestion a no-op there. TRIAGE_FAIL_OPEN=_flag("INBOX_TRIAGE_FAIL_OPEN",True) TRIAGE_CONCURRENCY=max(int(os.getenv("INBOX_TRIAGE_CONCURRENCY") or 5),1) TRIAGE_MAX_SUBJECT_CHARS=max(int(os.getenv("INBOX_TRIAGE_MAX_SUBJECT_CHARS") or 300),1) # ~1000 tokens. Application intent is always in the first screen of a mail, and this # cap is what bounds cost and latency at 100 messages per fetch. TRIAGE_MAX_BODY_CHARS=max(int(os.getenv("INBOX_TRIAGE_MAX_BODY_CHARS") or 4000),1) # 0 disables the uncertainty branch entirely (0.0 < 0.0 is False). TRIAGE_MIN_CONFIDENCE=float(os.getenv("INBOX_TRIAGE_MIN_CONFIDENCE") or 0) # One value for the whole deployment: the cacheable prefix is the system prompt, which # does not vary per message or per batch. Versioned so a prompt edit never shares a # cache route with the old text. PROMPT_CACHE_KEY=f"inbox-triage-{PROMPT_VERSION}" UNCLASSIFIED_PREFIX="unclassified:" # For the review route's 422 check. Derived from the enum so the two never drift. TRIAGE_STATUSES=tuple(item.value for item in Triage_Status) def get_triage_settings() -> Settings: """Validated OpenAI knobs (model family, token floor, effort, cache). Reads real env vars, which load_dotenv() above has populated from the nearest .env, so OPENAI_MODEL / OPENAI_MAX_OUTPUT_TOKENS match what llm_setup uses. """ return get_settings() def triage_model_name() -> str: """The configured model, for the audit column. "" rather than raising. Reads settings, not the classifier: this is called while recording a verdict, and building a client there would turn an audit field into an ingestion failure. """ try: return get_triage_settings().openai_model except Exception: return "" def subject_digest(subject) -> str: """A stable, PII-safe handle for correlating log lines about one subject.""" return hashlib.sha256((subject or "").encode("utf-8")).hexdigest()[:16] def sender_domain(address) -> str: """Domain only. The full address is PII and must never be logged.""" address=(address or "").strip().lower() return address.rsplit("@",1)[-1] if "@" in address else "" def should_ingest(verdict, error_code="") -> tuple[bool,str,str]: """(ingest, status, reason_code) — the entire fail policy, in one place. verdict None means the model could not be consulted: no API key, invalid config, timeout, rate limit, refusal, truncation, or an email with no subject and no body to judge. INBOX_TRIAGE_FAIL_OPEN decides, and the row is stamped unclassified: so the review route can find every one of them. """ if verdict is None: reason=f"{UNCLASSIFIED_PREFIX}{error_code or 'unknown'}"[:60] return TRIAGE_FAIL_OPEN,Triage_Status.ERROR.value,reason if verdict.confidence