HR-ATS-Portal/backend/inbox_classifier/plugins.py

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