AI Mail Analyzer¶
The classifier code in Analyzers/AIMailAnalyzer/ and the Django side that manages
its model versions. For how the pieces are deployed and operated see
AI / ML Analyzers; for the Django app's models and
endpoints see ai_model.
Classifier modules¶
mail_analysis
¶
MODEL_SPECS = (ModelSpec('safe_suspicious_30_epochs_model.pth', 2, 'main'), ModelSpec('spam_dangerous_30_epochs_model.pth', 2, 'main'), ModelSpec('safe_30_epochs_model.pth', 2, 'sub'), ModelSpec('unwanted_30_epochs_model.pth', 2, 'sub'), ModelSpec('dangerous_30_epochs_model.pth', 4, 'sub'))
module-attribute
¶
SUB_MODEL_WIDTHS = (2, 2, 4)
module-attribute
¶
ClassificationName
¶
SubClassificationName
¶
Bases: Enum
INTERNAL = 0
class-attribute
instance-attribute
¶
EXTERNAL = 1
class-attribute
instance-attribute
¶
SPAM = 2
class-attribute
instance-attribute
¶
NEWSLETTER = 3
class-attribute
instance-attribute
¶
CLASSIC_PHISHING = 4
class-attribute
instance-attribute
¶
CLONE = 5
class-attribute
instance-attribute
¶
BLACKMAIL = 6
class-attribute
instance-attribute
¶
WHALING = 7
class-attribute
instance-attribute
¶
ModelSpec
¶
getMainClassificationProbabilities(device, safe_suspicious_model, spam_dangerous_model, email_embedding)
¶
getMainClassificationInfo(global_probabilities)
¶
getSubClassificationProbabilities(device, models, email_embedding, main_classification_probabilities)
¶
getSubClassificationInfo(global_sub_probabilities)
¶
get_classification_breakdown(global_probabilities)
¶
Label the raw main-classification array by enum name, e.g. {"SAFE": 0.83, "UNWANTED": 0.12, "DANGEROUS": 0.05}.
get_sub_classification_breakdown(global_sub_probabilities)
¶
Label the raw sub-classification array by enum name.
split_into_sentences(mail_body, max_sentences=40)
¶
Split a mail body into sentence-ish chunks for occlusion analysis.
Caps at max_sentences so a pathologically long body can't turn the per-sentence re-embedding pass in get_contributing_phrases into an unbounded number of model calls.
rank_phrase_impacts(sentences, impacts, top_n=5)
¶
Pair sentences with their occlusion impact, keep only sentences whose removal reduced confidence in the predicted class (impact > 0), and return the top_n by impact descending.
get_contributing_phrases(device, safe_suspicious_model, spam_dangerous_model, vectorizer, mail_body, classification_index, baseline_probability, top_n=5)
¶
Sentence-level occlusion: re-embed the body with each sentence removed and measure how much confidence in the predicted class drops. Sentences whose removal drops confidence the most are the ones driving the classification.
mail_archive
¶
ResNetMLP
¶
ResNetMLP
¶
Bases: Module
fc1 = nn.Linear(input_dim, input_dim)
instance-attribute
¶
fc2 = nn.Linear(input_dim, int(input_dim * 2 / 3))
instance-attribute
¶
fc3 = nn.Linear(int(input_dim * 2 / 3), int(input_dim * 1 / 3))
instance-attribute
¶
fc4 = nn.Linear(int(input_dim * 1 / 3), output_dim)
instance-attribute
¶
relu = nn.ReLU()
instance-attribute
¶
dropout = nn.Dropout(0.5)
instance-attribute
¶
residual_transform = nn.Linear(input_dim, int(input_dim * 1 / 3))
instance-attribute
¶
forward(x)
¶
Inference server¶
inference_server.server is the FastAPI application; inference_server.v2 is the
token-protected /v2 API (/v2/classify, /v2/embed, /v2/train, /v2/reload,
health and model endpoints) the ai_model connector calls.
inference_server.server
¶
Persistent inference server for AIMailAnalyzer.
Loads the vectorizer and every model variant ONCE at startup and keeps them resident in memory (a full SentenceTransformer + 5 ResNetMLP checkpoints). The ai_model connector POSTs each mail body to the token-protected /v2 API (v2.py) over suspicious_net, as http://ai_inference:8090.
One variant is loaded: "personal" (models/, trained on this deployment's own labeled mail), the one /v2 classifies with.
Weights stay fresh: the monthly retrain pipeline overwrites models/*.pth in place, so each variant's files are re-checked (mtime) every INFERENCE_RELOAD_CHECK_SECONDS and hot-swapped when they change. POST /reload forces the same check immediately. A file that fails to load (half-copied, corrupt) never replaces a working model.
logger = logging.getLogger('inference_server')
module-attribute
¶
VECTORIZER_PATH = os.environ.get('VECTORIZER_PATH', '/worker/AIMailAnalyzer/vectorizers/paraphrase-multilingual-mpnet-base-v2')
module-attribute
¶
MODEL_DIRS = {'personal': os.environ.get('MODELS_DIR_PERSONAL', '/worker/AIMailAnalyzer/models')}
module-attribute
¶
RELOAD_CHECK_SECONDS = float(os.environ.get('INFERENCE_RELOAD_CHECK_SECONDS', '30'))
module-attribute
¶
MAX_CONCURRENCY = max(1, int(os.environ.get('INFERENCE_MAX_CONCURRENCY', '2')))
module-attribute
¶
MAX_MAIL_CHARS = int(os.environ.get('INFERENCE_MAX_MAIL_CHARS', '200000'))
module-attribute
¶
OPTIONAL_MODELS = frozenset({'unwanted_30_epochs_model.pth'})
module-attribute
¶
REQUIRED_MODELS = tuple(spec.filename for spec in MODEL_SPECS if spec.filename not in OPTIONAL_MODELS)
module-attribute
¶
device = torch.device('cpu')
module-attribute
¶
store = ModelStore(MODEL_DIRS)
module-attribute
¶
app = FastAPI(title='AIMailAnalyzer inference server', lifespan=lifespan)
module-attribute
¶
trainer = JobController(encoder_hash=store.encoder_hash)
module-attribute
¶
holder = ModelHolder()
module-attribute
¶
Slot
¶
ModelStore
¶
Vectorizer + per-variant model slots, with thread-safe hot reload.
model_dirs = model_dirs
instance-attribute
¶
vectorizer = None
instance-attribute
¶
vectorizer_error = None
instance-attribute
¶
vectorizer_path = None
instance-attribute
¶
slots = {variant: {spec.filename: Slot(None, None, None) for spec in MODEL_SPECS} for variant in model_dirs}
instance-attribute
¶
load_vectorizer(path)
¶
encoder_hash()
¶
encoder_digest of the loaded vectorizer's directory, computed at first use and cached (a load resets it). None while no vectorizer is loaded. Lazy rather than at load, so a start or a test client that never trains or reloads does not read ~1 GB of weights.
refresh(force=False)
¶
(Re)load every model file that is new or changed on disk.
Returns {variant: [filenames reloaded]}. A load failure keeps the previous model (if any) and records the error; the file is retried on the next check because its mtime is only committed on success.
maybe_refresh()
¶
models(variant)
¶
Snapshot {filename: model-or-None}; safe against a concurrent swap.
missing_required(variant)
¶
variant_ready(variant)
¶
unavailable(variant)
¶
The 503 detail for a classify call that cannot be served, else None. Used by the /v2 classify route.
ready()
¶
Serving requires the vectorizer and the "personal" variant.
health_payload()
¶
FairSemaphore
¶
A bounded semaphore served in arrival order. threading.Semaphore lets a thread that releases and asks again take the slot back before a waiting thread wakes, so a request that loops over texts (one slot per text, /v2/embed) could starve a classify. Here a new acquire queues behind the waiters.
lifespan(_app)
async
¶
health()
¶
reload_models()
¶
Re-read weights from disk now (e.g. right after the retrain pipeline promotes new ones) instead of waiting for the periodic check.
inference_server.v2
¶
Token-protected /v2 API of the inference server.
logger = logging.getLogger('inference_server.v2')
module-attribute
¶
MAX_CLASSIFY_BODY = 32 * 2 ** 20
module-attribute
¶
ACTIVE_VERSION = bundles.LEGACY_VERSION
module-attribute
¶
CaseId = Annotated[int, Field(strict=True, ge=0, le=2 ** 63 - 1)]
module-attribute
¶
SHADOW_TIMEOUT = float(os.environ.get('AI_SHADOW_TIMEOUT', '8'))
module-attribute
¶
ALLOWED_VERSIONS = (['active'], ['active', 'shadow'], ['shadow'])
module-attribute
¶
DUAL_BUDGET = 18.0
module-attribute
¶
LIVE_SHADOW_WAIT = 1.0
module-attribute
¶
SHADOW_ONLY_WAIT = 15.0
module-attribute
¶
NO_TOKENIZER = 'token chunking is not available with this encoder'
module-attribute
¶
Busy
¶
Bases: Exception
The shadow slot is taken by another shadow pass.
TokenizerUnavailable
¶
Bases: ValueError
ClassifyRequest
¶
EmbedRequest
¶
shadow_skip_after()
¶
Seconds of active pass after which the shadow is skipped (10 s with the default 8 s timeout; a timeout of 18 s or more skips the shadow always).
make_v2_router(*, store, classify, slots, max_chars, trainer, holder, shadow_slot=None)
¶
classify is server._classify(models, mail_body), which returns the legacy result dict; slots is the server's concurrency semaphore; trainer is the training_job.JobController (training runs in its own process, outside slots); shadow_slot is the shadow's one-pass lock (a new one when None); holder is the bundles.ModelHolder (the loaded bundles, None = legacy weights).
inference_server.bundles
¶
Bundle reload (POST /v2/reload) and bundle-aware classify (Plan E, Task 6b).
A reload names, per role (active, shadow), a version id, one presigned URL per bundle file
(training.bundle.BUNDLE_FILES plus manifest.json) and the manifest's SHA-256 pinned by Django
(Ruling E14). Every file is fetched with Task 6's downloader and URL rules (allow-list, no
redirects, no proxy, Content-Length required, raw body, the bundle's per-file and total caps
checked before anything is written) into a private 0700 directory under its fixed name, then read
once with read_bundle, the only bundle reader. The directory is removed on every exit path.
Then the server checks what read_bundle cannot know: the manifest's version id is the one asked
for, its preprocessing_version is one this server can apply, its input_dim is the loaded
encoder's width and, for token chunks, the encoder has a tokenizer. Any failure gives 422 and
leaves the current models untouched; both roles load before either is swapped in.
The loaded models live in one tuple (active, shadow), replaced under a lock: a classify reads
the tuple once and finishes on the objects it read. active None means the legacy v1 weights in
the operator-owned models/ directory (rollback to version 0). shadow is stored and reported but
not used by classify yet (Plan F).
logger = logging.getLogger('inference_server.bundles')
module-attribute
¶
PREPROCESSING_LEGACY = 'legacy-first128'
module-attribute
¶
KNOWN_PREPROCESSING = (PREPROCESSING_LEGACY, PREPROCESSING_VERSION_TOKENS, PREPROCESSING_VERSION_WORDS90)
module-attribute
¶
CAMPAIGN_EMBEDDING_VERSION = 'cmp-e1'
module-attribute
¶
RELOAD_PREFIX = 'ai-reload-'
module-attribute
¶
LEGACY_VERSION = {'id': 'v0', 'preprocessing': PREPROCESSING_LEGACY, 'campaign_embedding_version': CAMPAIGN_EMBEDDING_VERSION, 'calibrated': False}
module-attribute
¶
LEGACY_FILE = dict(zip(HEADS, (spec.filename for spec in MODEL_SPECS)))
module-attribute
¶
ReloadError
¶
Bases: Exception
A 422 reason: names the role, file or rule, never a URL.
ReloadBusy
¶
Bases: Exception
ReloadFailed
¶
Bases: Exception
An unexpected error while loading: a 500 with a generic body (logged by class name).
BundleSpec
¶
Bases: BaseModel
ReloadRequest
¶
LoadedModel
dataclass
¶
version_id
instance-attribute
¶
preprocessing_version
instance-attribute
¶
manifest_sha256
instance-attribute
¶
heads
instance-attribute
¶
knn
instance-attribute
¶
calibration
instance-attribute
¶
describe()
¶
legacy_models()
¶
The heads keyed as the legacy classify core expects (legacy-first128 bundles).
ModelHolder
¶
tier_of(p, cutoffs)
¶
The confidence tier of a calibrated probability: High at or above cutoffs["high"], Medium at or above cutoffs["medium"], else Low (spec section 9).
classify_bundle(model, vectorizer, body, slots, exclude_case_ids=None)
¶
A chunk-mean bundle's answer, in the legacy classify core's dict shape. The heads see the
chunk-mean embedding in the bundle's form; the campaign embedding stays the plain first-128
pass (cmp-e1). Main probabilities are training.recipe.predict's calibrated blend; sub-type
probabilities are weighted by the main probability as the legacy path does. Class, score and
confidence come from the calibrated probabilities through the legacy mapping (a class only
at 0.5 or above), and tier from the top one and the bundle's tier_cutoffs. Contributing
phrases are not computed (an occlusion pass would cost up to 40 chunked re-embeddings).
Raises training.recipe.NoNeighbours when exclude_case_ids leaves no neighbour.
inference_server.training_job
¶
Single-job training controller behind POST /v2/train (Plan E, Task 6).
One job at a time, state in memory only (a restart forgets it; Django then sees 404 and fails the
row). Per job, a monitor thread:
1. makes a private 0700 directory (mkdtemp) and downloads the dataset into it;
2. starts python -m inference_server.training_worker <dir> with subprocess.Popen in a new
session, an explicit minimal environment (see popen_launcher) and the validated params as JSON
on stdin: no URL, no token, no storage key. The worker runs as the same UID, so this keeps
secrets out of it by construction but is not a boundary against code running inside it (see
training_worker's docstring);
3. reads the worker's progress file, enforces cancel and the wall-time limit (SIGTERM to the
worker's process group, then SIGKILL after a grace period), reads the result file;
4. PUTs each bundle file to its own presigned URL, manifest last;
5. removes the directory, on every exit path. Leftovers of a crashed server are swept at start.
A recheck job (params.recheck, Ruling F11) runs the gate only: the worker writes no bundle, step 4
is skipped (bundle_urls may be omitted and are ignored) and the report is the gate summary plus the
version id rechecked.
URLs (Ruling E9): http or https, no userinfo, no fragment, printable ASCII only, and the host:port (as parsed, the scheme's default port when none is given) must be in AI_BUNDLE_HOSTS. The allow-list is the control: names are compared as strings, nothing is resolved, so an allowed name points wherever the deployment's DNS says. Internationalised host names never pass (fails closed): a non-ASCII URL is refused outright, and for a punycode host (xn--...) httpx reports the decoded name, which differs from urlsplit's, so the parsers disagree; use an ASCII name or an IP address. Redirects are never followed, proxies and .netrc are ignored, the dataset must announce its Content-Length, be at most 200 MB and match it exactly, and is read raw (no content decoding). Errors name the step, never a URL or query string.
logger = logging.getLogger('inference_server.training')
module-attribute
¶
JOB_ID_PATTERN = '^[A-Za-z0-9_-]{1,64}$'
module-attribute
¶
JOB_ID_RE = re.compile(JOB_ID_PATTERN)
module-attribute
¶
LABEL_PATTERN = '^[A-Za-z0-9._:/+-]{1,128}$'
module-attribute
¶
UPLOAD_NAMES = (*BUNDLE_FILES, MANIFEST)
module-attribute
¶
MAX_DOWNLOAD = 200 * 2 ** 20
module-attribute
¶
MAX_BODY = 64 * 1024
module-attribute
¶
MAX_URL = 4096
module-attribute
¶
MAX_RESULT = 1024 * 1024
module-attribute
¶
MAX_JOBS_KEPT = 50
module-attribute
¶
TMP_PREFIX = 'ai-train-'
module-attribute
¶
TERMINAL = frozenset({'succeeded', 'failed', 'cancelled'})
module-attribute
¶
DEFAULT_PORTS = {'http': 80, 'https': 443}
module-attribute
¶
TIMEOUT = httpx.Timeout(connect=5.0, read=10.0, write=60.0, pool=5.0)
module-attribute
¶
Label = Field(pattern=LABEL_PATTERN)
module-attribute
¶
MAX_ENCODER_BYTES = 4 << 30
module-attribute
¶
WORKER_MODULE = 'inference_server.training_worker'
module-attribute
¶
PACKAGE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
module-attribute
¶
THREAD_ENV = ('OMP_NUM_THREADS', 'OPENBLAS_NUM_THREADS', 'MKL_NUM_THREADS', 'NUMEXPR_NUM_THREADS')
module-attribute
¶
WORKER_LOG = 'worker.err'
module-attribute
¶
RedactUrls
¶
Bases: Filter
Rewrites a record whose message or traceback holds a URL query string or an X-Amz-* parameter. Never drops a record.
filter(record)
¶
JobError
¶
Bases: Exception
A failure reason safe to store and return: never a URL, query string or data.
TrainParams
¶
Bases: BaseModel
Everything the worker gets besides the dataset. Strings land in the manifest. The server adds encoder_hash itself (encoder_digest of its own encoder); a caller cannot send one.
model_config = ConfigDict(extra='forbid', strict=True)
class-attribute
instance-attribute
¶
version_id = Field(pattern=JOB_ID_PATTERN)
class-attribute
instance-attribute
¶
seed = Field(ge=0, le=2 ** 31 - 1)
class-attribute
instance-attribute
¶
repeats = Field(default=3, ge=1, le=5)
class-attribute
instance-attribute
¶
epochs = Field(default=30, ge=1, le=100)
class-attribute
instance-attribute
¶
baseline
instance-attribute
¶
preprocessing_version = PREPROCESSING_VERSION_TOKENS
class-attribute
instance-attribute
¶
parent_version = Field(default=None, pattern=JOB_ID_PATTERN)
class-attribute
instance-attribute
¶
ledger_digest = Label
class-attribute
instance-attribute
¶
campaign_refs_digest = Label
class-attribute
instance-attribute
¶
encoder_id = Label
class-attribute
instance-attribute
¶
trainer_revision = Label
class-attribute
instance-attribute
¶
campaign_profile
instance-attribute
¶
recheck = False
class-attribute
instance-attribute
¶
TrainRequest
¶
Bases: BaseModel
model_config = ConfigDict(extra='forbid', strict=True)
class-attribute
instance-attribute
¶
job_id = Field(pattern=JOB_ID_PATTERN)
class-attribute
instance-attribute
¶
dataset_url = Field(max_length=MAX_URL)
class-attribute
instance-attribute
¶
bundle_urls = None
class-attribute
instance-attribute
¶
params
instance-attribute
¶
PopenHandle
¶
multiprocessing.Process-like view of the worker. Signals go to its whole process group (start_new_session=True made it the leader), so anything it started dies with it. exitcode is negative for a signal, like multiprocessing's.
Job
dataclass
¶
job_id
instance-attribute
¶
state = 'queued'
class-attribute
instance-attribute
¶
progress = 0.0
class-attribute
instance-attribute
¶
report = None
class-attribute
instance-attribute
¶
error = None
class-attribute
instance-attribute
¶
cancel = field(default_factory=threading.Event)
class-attribute
instance-attribute
¶
started = field(default_factory=time.monotonic)
class-attribute
instance-attribute
¶
view()
¶
JobController
¶
launcher = launcher
instance-attribute
¶
encoder_hash = encoder_hash or (lambda: None)
instance-attribute
¶
transport = transport
instance-attribute
¶
tmp_base = tmp_base
instance-attribute
¶
grace_seconds = grace_seconds
instance-attribute
¶
poll_seconds = poll_seconds
instance-attribute
¶
jobs = {}
instance-attribute
¶
base_dir()
¶
start(req, encoder_hash=None)
¶
encoder_hash: the caller computes it (self.encoder_hash) outside the event loop.
get(job_id)
¶
cancel(job_id)
¶
redact(text)
¶
install_log_redaction()
¶
Idempotent: the filter on the httpx/httpcore loggers and on the root handlers present now (called at import and again when a job starts, after the server configured logging).
encoder_digest(path, *, cap=MAX_ENCODER_BYTES)
¶
SHA-256 over every regular file under path (symlinks are not followed), in sorted relative POSIX name order: name, NUL, size, NUL, bytes. None when the path is missing or unreadable or the files exceed cap. The training job records it as the manifest's encoder_hash and reload compares it, so a bundle trained with another encoder is refused.
allowed_hosts()
¶
AI_BUNDLE_HOSTS: comma separated host:port entries; anything else is ignored. Empty or unset refuses every URL.
check_url(url, hosts, what)
¶
Validate with both urlsplit and httpx (the parser that sends the request): they must agree on scheme, host and port, so a parser trick cannot pass one check and reach another host.
worker_env(workdir, threads, max_memory_mb)
¶
The worker's whole environment. Nothing is inherited from the server (no token, no storage or database secret, no proxy, no PYTHONPATH or LD_PRELOAD); TMPDIR keeps scratch files inside the job directory, which is removed at the end.
popen_launcher(workdir, params, threads, max_memory_mb)
¶
Default launcher. No shell; the module name is a constant and the params travel as JSON on stdin (at most training_worker.MAX_PARAMS bytes); stdout and stderr go to a 0600 file in the job directory.
default_base_dir()
¶
Where job and reload directories go: AI_TRAIN_TMPDIR, else the system temp dir (read at each use, not at import).
sweep(base, prefix)
¶
Remove leftovers named prefix* under base: only real directories owned by this uid, never through a symlink. Call it only where no live directory of that prefix can exist.
make_client(transport=None)
¶
The only HTTP client for presigned URLs: no redirects, no proxy or .netrc from the environment, short timeouts.
download(client, url, path, *, cap, what, check=lambda: None)
¶
GET url (an httpx.URL from check_url) into a new 0600 file at path. The response must be a
200 with an identity encoding and a Content-Length of at most cap, and the raw body must match
it exactly; the cap is also enforced while streaming. check() runs after each chunk (cancel,
deadline). Errors are JobError naming what, never the URL.
Training¶
The recipe, bundle format and promotion gate the training job runs.
training.recipe
¶
The retrain recipe (spec section 5), ported from the test bed (lib_spike.py, exp_e_combined.py, exp_a2_calibrate.py): five ResNetMLP heads with class weights only. The main decision is the mean of two components, the head hierarchy and a distance-weighted cosine 5-NN vote, each smoothed (eps 0.05) and calibrated by its own temperature, as in exp_e_combined.py (Ruling E12).
HEADS = ('safe_suspicious', 'spam_dangerous', 'safe', 'unwanted', 'dangerous')
module-attribute
¶
NUM_OUT = {'safe_suspicious': 2, 'spam_dangerous': 2, 'safe': 2, 'unwanted': 2, 'dangerous': 4}
module-attribute
¶
DANGEROUS_ORDER = {'2_CLASSIC_PHISHING': 0, '2_CLONE_PHISHING': 1, '2_BLACKMAILING_PHISHING': 2, '2_WHALING_PHISHING': 3}
module-attribute
¶
SUB_HEADS = ('safe', 'unwanted', 'dangerous')
module-attribute
¶
K = 5
module-attribute
¶
TRUNCATION_LIMIT = 0.05
module-attribute
¶
PREPROCESSING_VERSION_WORDS90 = 'chunkmean-words90-v1'
module-attribute
¶
PREPROCESSING_VERSION_TOKENS = 'chunkmean-tokens-v1'
module-attribute
¶
PREPROCESSING_VERSION = PREPROCESSING_VERSION_TOKENS
module-attribute
¶
EPS = 0.05
module-attribute
¶
T_BOUNDS = (0.2, 30)
module-attribute
¶
COMPONENTS = ('hierarchy', 'knn')
module-attribute
¶
NoNeighbours
¶
Bases: ValueError
The neighbour index is empty, or exclude_case_ids left no row.
Knn
dataclass
¶
embeddings
instance-attribute
¶
labels
instance-attribute
¶
case_ids = None
class-attribute
instance-attribute
¶
build(X, folders, case_ids=None)
classmethod
¶
vote(X, *, exclude_case_ids=None)
¶
Distance-weighted cosine 5-NN class probabilities (sklearn's weights="distance": 1/d, and exact matches take the whole vote). exclude_case_ids: rows whose case id is listed are left out (a case already in the ledger is not its own neighbour); ignored for an index without case ids.
Predictions
dataclass
¶
head_target(head, folder)
¶
Target class of a folder for a head, None when the head does not use the row.
main_class(folder)
¶
0 Safe, 1 Unwanted, 2 Dangerous: the folder's digit prefix.
chunk_by_tokens(vectorizer, text, *, max_chunks=8)
¶
Greedy word chunks that fit the encoder window (max_seq_length, special tokens included). A single word longer than the window gets a chunk of its own (the encoder truncates it).
chunk_mean_embedding(vectorizer, text, *, words=90, max_chunks=8, by_tokens=True)
¶
Mean of the embeddings of the text's chunks, at most max_chunks of them.
Default (by_tokens=True, Ruling E16): chunks sized to the encoder window, PREPROCESSING_VERSION_TOKENS. by_tokens=False: 90-word chunks, PREPROCESSING_VERSION_WORDS90, the form the spec's evidence was measured on. The caller records the matching version in the manifest.
truncation_rate(vectorizer, texts, words=90)
¶
Share of word chunks whose token count (special tokens included) exceeds the window.
fit_heads(X, folders, *, seed, epochs=30, threads=2)
¶
Train the five heads on the rows each head uses. Class weights only, no resampling. The minimum-samples rule is the gate's (check 4); here a head without rows is an error.
smooth(P, eps=EPS)
¶
exp_e_combined.smooth: mix with the uniform distribution, so a one-hot vote has finite logits.
predict(heads, knn, X, *, temperature=None, exclude_case_ids=None)
¶
temperature: {"hierarchy": T, "knn": T}; None means 1.0 for both (uncalibrated). exclude_case_ids is passed to Knn.vote.
fit_temperature(logits, labels)
¶
One temperature minimising the NLL of softmax(logits / T) over T_BOUNDS (Ruling E5). Fit it on held-out groups only. T > 0, so the argmax never changes.
fit_temperatures(predictions, labels)
¶
One temperature per component (Ruling E12), from held-out predictions.
temperature_at_bound(t)
¶
True when a fitted temperature sits on a search bound (the fit is then not trustworthy).
training.bundle
¶
The model bundle (spec section 3): five heads, calibration, kNN index, campaign profile and a manifest holding a SHA-256 per file. The bytes come from object storage through a presigned URL, so they are untrusted: read_bundle reads every file once into memory, verifies all hashes, and only then parses the same bytes (no TOCTOU). Heads are npz files (Ruling E15: no pickle and no torch deserialiser on bundle bytes, because torch's zip reader and Python's disagree on archive layout): float32 arrays that must match a freshly built ResNetMLP key for key and shape. Errors are a single BundleError naming the file or field, never file contents.
MANIFEST = 'manifest.json'
module-attribute
¶
BUNDLE_FILES = (*(f'heads/{h}.npz' for h in HEADS), 'calibration.json', 'knn_index.npz', 'campaign_profile.json')
module-attribute
¶
MAX_TOTAL = 100 * 2 ** 20
module-attribute
¶
MAX_FILE = 50 * 2 ** 20
module-attribute
¶
JSON_CAPS = {MANIFEST: 512 * 1024, 'calibration.json': 64 * 1024, 'campaign_profile.json': 256 * 1024}
module-attribute
¶
MAX_JSON_DEPTH = 32
module-attribute
¶
MAX_JSON_NODES = 20000
module-attribute
¶
MAX_ZIP_MEMBERS = 64
module-attribute
¶
ZIP_SLACK = 64 * 1024
module-attribute
¶
TEMPERATURE_RANGE = tuple(float(x) for x in T_BOUNDS)
module-attribute
¶
MAX_INPUT_DIM = 4096
module-attribute
¶
TEMPERATURE_KEYS = ('hierarchy', 'knn')
module-attribute
¶
MANIFEST_FIELDS = ('version_id', 'recipe_id', 'preprocessing_version', 'encoder_id', 'encoder_hash', 'ledger_digest', 'campaign_refs_digest', 'metrics', 'gate', 'parent_version', 'trainer_revision', 'input_dim')
module-attribute
¶
MAX_CD_BYTES = 64 * 1024
module-attribute
¶
BundleError
¶
Bases: Exception
Bundle
dataclass
¶
write_bundle(dir, *, heads, calibration, knn, campaign_profile, manifest)
¶
Write a bundle atomically: build it in a sibling temp dir, then rename. Hashes are computed here. Returns the SHA-256 hex of manifest.json (Ruling E14: the caller pins it). Notes: an existing empty directory at the target is replaced, a non-empty path is refused, a missing parent raises BundleError, and the call may raise (OSError) after the bundle is in place if the final directory fsync fails.
read_file_capped(dir_fd, name, rel, spent)
¶
Read one regular, single-link file from an open directory; return (bytes, new total).
json_shape_ok(v)
¶
Depth and node-count bound for parsed JSON (iterative).
npz_shapes(data, rel, max_len)
¶
Stored members only, laid out as np.savez writes them, and every array header exactly consistent with its member size, all checked before any array is read: a deflate bomb, a directory flood or a lying header is never inflated or allocated. Returns {array name: (shape, dtype)}.
read_bundle(dir, *, manifest_sha256)
¶
Ruling E14: manifest_sha256 (pinned by the caller, never read from the bundle) is compared to the manifest bytes before anything is parsed.
training.gate
¶
The gate (spec section 5): six offline checks a candidate must pass before shadow.
Held-out protocol, as in the test bed (exp_e_combined.py, exp_a2_calibrate.py): per seed, 5-fold StratifiedGroupKFold on the near-duplicate groups gives out-of-fold component logits; the temperatures are fitted on a random half of the groups and applied to the other half (and the other way round), so no row is scored by a calibration fitted on its own group. The baseline is the active recipe retrained on the same folds (not the active weights, which may have seen the mails). A recipe is a callable recipe(X, folders, train_idx, test_idx, *, seed) returning {component: (len(test_idx), 3) logits}; the components are calibrated and averaged. A recipe may add RESAMPLING_SKIPPED: [head, ...] (legacy_recipe does), which the report collects.
Check 4 counts head samples on the whole dataset, not per training fold; when it fails nothing is trained. Check 5 is measured cross-half (Ruling E13): the High cutoff chosen on one calibration half, by the same rule as the stored cutoff (Ruling E17), is scored on the other. Wilson intervals use per-repeat mean counts (pooled / repeats).
N_SPLITS = 5
module-attribute
¶
MIN_HEAD_SAMPLES = 8
module-attribute
¶
DANGEROUS_SAFE_TOLERANCE = 0.01
module-attribute
¶
MACRO_F1_TOLERANCE = 0.02
module-attribute
¶
MAX_ECE = 0.12
module-attribute
¶
HIGH_PRECISION = 0.9
module-attribute
¶
HIGH_CUTOFF_PRECISION = 0.95
module-attribute
¶
HIGH_CUTOFF_FLOOR = 0.85
module-attribute
¶
MEDIUM_CUTOFF = 0.6
module-attribute
¶
TIER_CUTOFF_KEYS = ('high', 'medium')
module-attribute
¶
HIGH_MIN_SHARE = 0.05
module-attribute
¶
RESAMPLING_SKIPPED = 'resampling_skipped'
module-attribute
¶
HIGH_UNREACHABLE = 1.01
module-attribute
¶
CLASSES = ('Safe', 'Unwanted', 'Dangerous')
module-attribute
¶
CHECKS = ('dangerous_called_safe', 'macro_f1', 'calibration_error', 'head_samples', 'high_tier_precision', 'campaign_dangerous_called_safe')
module-attribute
¶
GateReport
dataclass
¶
checks
instance-attribute
¶
seeds
instance-attribute
¶
counts
instance-attribute
¶
per_class = field(default_factory=list)
class-attribute
instance-attribute
¶
confusion_matrix = field(default_factory=list)
class-attribute
instance-attribute
¶
tiers = field(default_factory=list)
class-attribute
instance-attribute
¶
tier_cutoffs = field(default_factory=dict)
class-attribute
instance-attribute
¶
campaign = field(default_factory=dict)
class-attribute
instance-attribute
¶
baseline = field(default_factory=dict)
class-attribute
instance-attribute
¶
candidate = field(default_factory=dict)
class-attribute
instance-attribute
¶
fallback = False
class-attribute
instance-attribute
¶
temperature_at_bound = field(default_factory=dict)
class-attribute
instance-attribute
¶
temperatures = field(default_factory=list)
class-attribute
instance-attribute
¶
high_tier = field(default_factory=dict)
class-attribute
instance-attribute
¶
pooled_repeats = 1
class-attribute
instance-attribute
¶
note = ''
class-attribute
instance-attribute
¶
legacy_resampling_skipped = field(default_factory=list)
class-attribute
instance-attribute
¶
eligible
property
¶
wilson(successes, n, z=1.96)
¶
Wilson score interval for successes out of n (Ruling E4); (0, 1) when n is 0. Counts may be per-repeat means (pooled / repeats), hence floats.
ece(probs, labels, bins=15)
¶
Binned calibration error (Ruling E6): equal-width bins (lo, hi] on the top-class probability, |confidence - accuracy| weighted by the bin's share of rows.
head_class_counts(folders)
¶
{head: [rows per class]} over the whole dataset.
build_checks(*, dangerous_safe, baseline_dangerous_safe, macro_f1, baseline_macro_f1, ece, head_counts, high_tier, campaign_dangerous_safe, baseline_campaign_dangerous_safe)
¶
candidate_recipe(X, folders, train_idx, test_idx, *, seed, epochs=30, threads=2)
¶
The retrain recipe (training/recipe.py): head hierarchy and 5-NN vote logits.
legacy_recipe(X, folders, train_idx, test_idx, *, seed, epochs=30, threads=2)
¶
Version 0's recipe (the test bed's "asis"): SMOTE + random oversampling then class weights per head, head hierarchy only (no neighbour vote). A head that SMOTE cannot resample trains without it, as the legacy code did, and is listed under RESAMPLING_SKIPPED. Use it as baseline_recipe while version 0 is active. Needs imbalanced-learn, which the inference image does not ship.
calibrate_halves(logits, labels, groups, *, seed)
¶
Calibrated blend of the components: temperatures fitted on a random half of the groups score the other half, and the other way round. Returns (probs, [temperatures per half], in_a: the boolean membership of the first half).
high_tier_cross_half(probs, labels, in_a)
¶
Ruling E13: the High cutoff chosen on one half's calibrated predictions (by the stored cutoff's rule, Ruling E17), counted on the other half, both ways. unreachable when a half has no cutoff; floored when the 0.85 floor decided a half's cutoff.
run_gate(X, folders, groups_dup, groups_campaign, *, baseline_recipe, recipe=candidate_recipe, repeats=3, seed0=0)
¶
Run the six checks (spec section 5) over repeats seeds of 5 grouped folds (Ruling E7).
baseline_recipe is the active version's recipe (legacy_recipe for version 0); both recipes
are trained on the same train rows. Metrics are averaged over seeds; the confusion matrix and
the tiers hold counts pooled over seeds (each mail counted once per repeat), and Wilson
intervals use the per-repeat mean counts (Ruling E7).
training.dataset
¶
The training dataset (Task 8's ledger export), downloaded by the server through a presigned URL,
so untrusted bytes. An uncompressed npz with exactly four arrays, one row per ledger mail:
embeddings (float32 (n, d), the classifier input, token chunk-mean), campaign_embeddings
(float32 (n, d2), the first-128 cmp-e1 vector that the groups are built from, Ruling E18),
folders (fixed-width unicode, one of FOLDERS), case_ids (int64, unique, except NO_CASE,
which marks a row that matched no case and may repeat) and ties (int64 (k, 2), row index pairs
of the same campaign from CampaignMember, k <= MAX_ROWS, possibly empty; the campaign groups join
them, Ruling E24). No mail text.
The archive goes through bundle.py's checks (stored members only, layout as np.savez writes it,
every array header consistent with its member) before np.load(allow_pickle=False) reads it.
MAX_DATASET_BYTES = 200 * 2 ** 20
module-attribute
¶
MAX_ROWS = 8000
module-attribute
¶
MAX_FOLDER_CHARS = 64
module-attribute
¶
FOLDERS = ('0_LEGIT_INTERNAL_COMMUNICATION', '0_LEGIT_EXTERNAL_COMMUNICATION', '1_SPAM', '1_NEWSLETTER', '2_CLASSIC_PHISHING', '2_CLONE_PHISHING', '2_BLACKMAILING_PHISHING', '2_WHALING_PHISHING')
module-attribute
¶
NO_CASE = -1
module-attribute
¶
ARRAYS = ('embeddings', 'campaign_embeddings', 'folders', 'case_ids', 'ties')
module-attribute
¶
DatasetError
¶
Bases: Exception
Names the array or the rule, never the data.
Dataset
dataclass
¶
read_dataset(path)
¶
Model registry (Django)¶
The ai_model app: the registry is the only code that changes a model version's
status; promotion enforces the conditions; retrain drives the training job.
ai_model.registry
¶
The model registry. Every status change goes through these functions and writes a ModelEvent.
One previous version only: when a version becomes active, the old active becomes previous and
an older previous becomes discarded. Exactly one active: enforced by the unique
ModelVersion.active_slot column (MariaDB ignores partial unique indexes) plus locking reads.
BUCKET = 'ai-models'
module-attribute
¶
DATASET_BUCKET = 'ai-datasets'
module-attribute
¶
PRESIGN_EXPIRY = timedelta(minutes=15)
module-attribute
¶
BUNDLE_FILE_NAMES = ('heads/safe_suspicious.npz', 'heads/spam_dangerous.npz', 'heads/safe.npz', 'heads/unwanted.npz', 'heads/dangerous.npz', 'calibration.json', 'knn_index.npz', 'campaign_profile.json', 'manifest.json')
module-attribute
¶
logger = logging.getLogger(__name__)
module-attribute
¶
MAX_SHADOW_MIN_DAYS = 365
module-attribute
¶
V0_MANIFEST = {'version_id': 'v0', 'preprocessing_version': 'legacy-first128', 'source': 'legacy weights directory'}
module-attribute
¶
RegistryError
¶
Bases: Exception
StateChanged
¶
Bases: RegistryError
The version is no longer in the state the caller saw (a stale screen).
bundle_prefix_for(version_id, job_id)
¶
ensure_bucket()
¶
Create the bucket if absent (there is no deployment-time bootstrap for buckets). Callers that presign PUT urls (the retrain start) must call this first.
dataset_key_for(job_id)
¶
One dataset per training job, in DATASET_BUCKET.
ensure_dataset_bucket()
¶
ensure_bucket for DATASET_BUCKET.
create_candidate(version_id, bundle_prefix, manifest, manifest_sha256, campaign_profile, trigger, user, job_id, parent=None)
¶
set_active(version, user=None, note='')
¶
candidate, shadow, previous (rollback) and a discarded version 0 can become active. The old active becomes previous, an older previous becomes discarded (one previous only).
promote_shadow(version, user, note, check)
¶
set_active for a promotion (ai_model.promotion). check(locked_target) runs under the row locks and raises to refuse. Returns (old_active, old_previous) as they were, for revert_promote.
revert_promote(target, old_active, old_previous, user=None, note='')
¶
Undo promote_shadow after the server refused the reload: target back to shadow (to candidate if another version became the shadow meanwhile), old_active back to active, old_previous (discarded by set_active) back to previous. All rows are checked first: if any moved meanwhile nothing is written. True when reverted.
rollback_to_previous(user, note='', expected_id=None)
¶
The previous version becomes active again, picked under the row locks. Returns (target, old_active), or None when there is no previous. With expected_id, a previous with another id raises StateChanged.
revert_rollback(target, old_active, user=None, note='')
¶
Undo rollback_to_previous: a swap back, only if both rows are where it left them.
extend_shadow(version, days, user=None, note='')
¶
Raise the shadow's minimum period (shadow_min_days); it never goes down.
discard(version, user=None, note='', expected_status=None)
¶
With expected_status, a version in another status raises StateChanged.
set_shadow(version, user=None, note='')
¶
A gate-eligible candidate becomes the one shadow; its seeding (Ruling F8) is queued once the change commits.
shadow_refused(version)
¶
serversync: the server refused the shadow's bundle and runs without it. One event per run of refusals (the beat retries every 300 s); the version stays the shadow.
unshadow(version, user=None)
¶
The server refused a shadow just started: back to candidate, its shadow period cleared, so an admin may start it again. A version that is no longer the shadow is left as it is.
recheck_started(version, user, job_id)
¶
Ruling F11: a gate recheck of this version was posted (retrain.start_recheck).
retrain_requested(version, user, note)
¶
A manual retrain was started (api retrain view); version is the job's parent.
flag_resolved(user, note)
¶
A flagged label was resolved (api flag view).
record_breaker_reset(user, note)
¶
An admin reset the ai_model circuit breaker (api breaker view).
review_recorded(version, user, note)
¶
A shadow review was appended (promotion.review, inside its transaction).
record_recheck(version, job_id, passed, finished_at)
¶
Store a finished recheck as {job_id, passed, finished_at (ISO, UTC)}. Only the shadow version takes one: a version discarded or promoted meanwhile is left as it is.
fail(version, error, user=None)
¶
ensure_version_zero(user=None)
¶
Version 0 is the legacy weights: a row without a bundle (Ruling E19). Idempotent. It is active when nothing is, else previous when no previous exists, else discarded; set_active accepts a discarded v0, so it stays a rollback target.
reload_payload(active, shadow=None)
¶
Body for /v2/reload. Version 0 (no bundle) is active: null; no shadow is shadow: null.
ai_model.promotion
¶
Promote, roll back, extend and review shadow versions (Rulings F12, F13).
can_promote is the only place the promotion conditions live; promote re-evaluates them under
the row locks, so whatever a client sent, an unmet condition refuses the promotion. Pointer moves
go through registry.set_active; the server follows after commit (serversync, Ruling F2).
logger = logging.getLogger(__name__)
module-attribute
¶
S = ModelVersion.Status
module-attribute
¶
E = ShadowCampaignEffect.Effect
module-attribute
¶
SHADOW_MIN_RESULTS = 500
module-attribute
¶
DEFAULT_MIN_DAYS = 7
module-attribute
¶
WINDOW_DAYS = 15
module-attribute
¶
RECHECK_MAX_AGE_DAYS = 7
module-attribute
¶
critical_flips(version)
¶
Live results whose would-be verdict flips Safe/Dangerous (Ruling F6).
alert_delta(version)
¶
Ruling F9, from the cases' current campaign membership.
snapshot(version)
¶
The review items read once: {"critical": [ShadowResult], "delta": alert_delta}. Callers that need several of can_promote, latest_reviews and the lists pass one snapshot around.
latest_reviews(version, snap=None)
¶
{(kind, case_id): ShadowReview}, the latest review per item, current items only.
can_promote(version, snap=None)
¶
Unmet conditions by stable code; empty when the version may be promoted. Reads the fields of the object given (promote passes the locked row).
promote(version, user, note='')
¶
Ruling F13: conditions re-checked under the row locks, then set_active and a server sync.
rollback(user, note='', expected_id=None)
¶
The previous version becomes active again; PromotionRefused(["no_previous"]) without one, PromotionRefused(["state_changed"]) when expected_id names another version.
start_shadow(version, user, note='')
¶
registry.set_shadow, then a server sync that must load the shadow. A refusal (4xx) puts the version back to candidate and raises ReloadRefused; a transient failure keeps the shadow and the beat heals the server.
discard(version, user, note='', expected_status=None)
¶
registry.discard, then a best-effort server sync (a discarded shadow leaves the server). PromotionRefused(["state_changed"]) when the version is no longer in expected_status.
extend(version, days, user, note='')
¶
review(version, kind, case_id, user, note='')
¶
Append a review of one current flip or alert-delta item of the shadow; refuses an item that does not exist and a version that is not the shadow.
ai_model.retrain
¶
Retrain orchestration (Plan E, Task 8b): export the ledger, hand the inference server presigned
URLs through POST /v2/train, poll GET /v2/train/{job_id} with short Celery tasks
(ai_model.tasks.poll_retrain) and register a succeeded job as a candidate through the registry.
Nothing here activates or reloads a version (Plan F promotes). A gate recheck of the shadow
(start_recheck, Ruling F11) uses the same chain and running slot but has no bundle and never
creates a candidate: its result goes on ModelVersion.recheck through the registry.
A RetrainJob row is written before the server is called; a failed, cancelled, timed-out or half-uploaded job registers nothing and its bundle prefix and dataset are deleted. Presigned URLs go into the request body only: never logged, stored or printed. Errors are stored as class names, or, for a job the server failed, as the server's own short reason filtered by server_error.
logger = logging.getLogger('ai_model.retrain')
module-attribute
¶
J = RetrainJob.State
module-attribute
¶
POLL_SECONDS = 30
module-attribute
¶
MAX_AGE = timedelta(hours=6)
module-attribute
¶
STOP_MARGIN = timedelta(minutes=30)
module-attribute
¶
BACKOFF = timedelta(days=7)
module-attribute
¶
UPLOAD_EXPIRY = timedelta(hours=4, minutes=15)
module-attribute
¶
ENCODER_ID = 'sentence-transformers/paraphrase-multilingual-mpnet-base-v2'
module-attribute
¶
TRAINER_REVISION = 'plan-e-candidate-v1'
module-attribute
¶
GATE_KEYS = ('checks', 'candidate', 'baseline', 'campaign', 'tier_cutoffs')
module-attribute
¶
ERROR_MAX = 200
module-attribute
¶
RetrainRefused
¶
Bases: Exception
The retrain did not start; the message says why (no URL, no mail text). cause: the class name of an unexpected error behind it, if any.
cause = cause
instance-attribute
¶
JobRunning
¶
IncompleteUpload
¶
Bases: Exception
server_error(text)
¶
The server's error of a failed job, kept for the operator: only [A-Za-z0-9 _.:,()/=-],
at most ERROR_MAX characters; anything holding a URL ("://") or a query ("?") is dropped whole.
The server's reasons name fields, heads and classes (e.g. the head floor), never mail text.
running_job()
¶
start_retrain(user=None, trigger=ModelVersion.Trigger.MANUAL)
¶
Start one training job. RetrainRefused when the connector is disabled, a job is running, the export refuses (its message passes through) or the server refuses the request.
start_recheck(version, user=None)
¶
Ruling F11: re-run the gate of the shadow version on the current ledger with its recipe and seed (params.recheck: no final fit, no bundle). Refused like start_retrain, and for a version that is not the shadow. The poll stores {job_id, passed, finished_at} on ModelVersion.recheck; a failed or cancelled recheck changes nothing on the version.
poll_once(job_id)
¶
Ask the server once and settle the job if it ended. Returns the job's state after. The server's answer comes first: a job it finished is registered even when polled after MAX_AGE; MAX_AGE only ends a job that is still running (or that nobody can ask about).
give_up(job, error)
¶
Fail a job whose poll chain keeps raising, past MAX_AGE + STOP_MARGIN (poll_retrain).
poll_countdown()
¶
Seconds before the next poll: at least POLL_SECONDS, and longer than the client timeout, so a slow POST /v2/train is not answered 404 by a poll that overtook it.
is_recheck(job)
¶
cancel_retrain()
¶
Ask the server to cancel the running job; the poll records the outcome. None if no job.
interval_days(config)
¶
newest_version()
¶
The newest candidate, shadow, active or previous version: the age the schedule counts from.
maybe_start()
¶
The daily check: start a retrain when retrain_enabled is true, no job runs and the newest
candidate, shadow, active or previous version is older than retrain_interval_days.
Backoff: a scheduled job that failed (or a scheduled start that was refused, recorded as a
failed job with error "Refused") within BACKOFF stops the check until it is older.
ai_model.ledger
¶
The label ledger writers (Plan D). Append-only: a row is never updated except its status, flag_reason and resolution fields (rulings D4, D5), and, through replace_embedding only, the classifier embedding and its preprocessing version (Plan E, Task 8a).
MISMATCH = 'folder_verdict_mismatch'
module-attribute
¶
CONFLICT = {'Dangerous': 'Safe', 'Safe': 'Dangerous'}
module-attribute
¶
MIX_CLASSES = ('Dangerous', 'Safe', 'Unwanted')
module-attribute
¶
FolderObservation
dataclass
¶
enabled()
¶
Ruling D6: verdict and challenge rows only while the ai_model connector is enabled.
is_observed(mail)
¶
prepare_folder_observation(mail)
¶
The read-only pre-pass for a ReviewMail: None when the folder has no known class or the message is already observed there, else its key, matched case and snapshot.
record_folder_observation(observation, vectors, *, preprocessing_version, embedding_version)
¶
Append the folder row; None when a concurrent write got there first (re-checked here).
vectors is {"classifier": [float, ...], "campaign": [float, ...]}. The new row supersedes
every other non-superseded row of the message (active or flagged, all in other folders since
this one has none; ruling D5): a move is the analysts' newer label, so a flag on the old folder
row is moot and the new row is conflict-checked again. It is written flagged when it is a hard
conflict with the active verdict. Runs in a transaction (savepoint inside the sync's chunk).
attach_case(record_pk, case)
¶
Late case match (the backfill): set the case of a folder row that matched none, and run the live conflict check against that case's active verdict in the same transaction, so a hard conflict flags the row. False when the row got a case meanwhile.
record_verdict(case, user, label_class, *, observed_at=None)
¶
Append the analyst's verdict; None for the same class as the case's active verdict row. A different class supersedes that row; a hard conflict flags the folder rows (ruling D6).
Deliberate: a result outside Safe, Unwanted, Dangerous, Suspicious and Inconclusive (Unchallenged, AllowListed, Failure) is not a label. It stores nothing and does not supersede the earlier verdict row, which stays the case's last analyst label. observed_at (default now) lets the backfill date a row at the case's last update.
record_challenge(case, proposed)
¶
Append the reporter's proposal (trust proposal, never trainable, never flags), superseding the case's earlier active proposal; None for a blank or unknown proposal.
resolve_flag(record, user, note, *, keep)
¶
Close a flag: keep makes the row active again, else it is superseded. flag_reason stays as the audit trail (ruling D4). The row is re-read under a row lock, so a stale instance or a second resolver gets ValueError. With keep, any other active folder row of the message is superseded and carries the same resolution, naming this row.
replace_embedding(record, vector, preprocessing_version)
¶
The one exception to append-only, for ai_model_reembed_ledger: a folder row's classifier
embedding re-computed from the same mail in another preprocessing. Changes embedding and
preprocessing_version only, and only while the row still has the version the caller read
(False otherwise: changed meanwhile). LabelRecord.save() still refuses both fields.
ValueError unless vector is ENCODER_WIDTH finite floats (the message names no value).
ledger_summary()
¶
Counts of the current rows (superseded ones are history): {source: {class: n}}, the flagged count and the trainable count. Classes stay per source, as a folder Safe and a proposed Safe are not the same kind of label.
label_mix(now=None)
¶
Share of each class among the active folder rows now and among those observed more than
MIX_WINDOW_DAYS ago. jump names the class whose share moved most, when that is more than MIX_JUMP, when
both windows hold at least MIX_MIN_ROWS rows; else None.