108 lines
4.3 KiB
Python
108 lines
4.3 KiB
Python
"""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:<CODE> 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<TRIAGE_MIN_CONFIDENCE:
|
|
return TRIAGE_FAIL_OPEN,Triage_Status.LOW_CONFIDENCE.value,verdict.reason_code.value
|
|
return bool(verdict.is_application),Triage_Status.CLASSIFIED.value,verdict.reason_code.value
|