Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
1110729
feat: add llm_guardrail settings for self-hosted recognizer
inesaranab Aug 7, 2026
38195db
feat: guardrails semantic + exact match through Nemo Guardrails
inesaranab Aug 7, 2026
62296bc
infra: deploy Gemma-4 on Azure serverless A100
inesaranab Aug 9, 2026
4736b15
feat: added vllm config yaml and updated README.md with lessons from …
inesaranab Aug 10, 2026
8b1a678
feat: added vllm config yaml and updated README.md with lessons from …
inesaranab Aug 10, 2026
c75c01d
fix: transcript corruption, missed PII spans, and cold-start timeouts
inesaranab Aug 10, 2026
6a42eec
fix: build-time warmup, and prove the input rail actually ran
inesaranab Aug 10, 2026
800a036
fix: mid-word matches, empty-quote wildcards, case-sensitive misses
inesaranab Aug 10, 2026
6c29d73
Remove NeMo Guardrails; close two detector security holes
inesaranab Aug 10, 2026
6c73edb
feat: first steps for asyncronous asessment
inesaranab Aug 10, 2026
dca191e
feat: persist jobs in Azure Table Storage
inesaranab Aug 10, 2026
3c49a1e
feat: move screening to a queue-triggered worker
inesaranab Aug 10, 2026
b3baf58
feat: worker-job, azure-ops, and updated readme
inesaranab Aug 11, 2026
94393f6
fix: address reviewers inqueries
inesaranab Aug 11, 2026
563a558
fix: bound the queue payload, the message lifetime, and the retries
inesaranab Aug 11, 2026
9069b6c
fix: survive an unreadable message, and settle jobs the queue forgets
inesaranab Aug 11, 2026
6b6fef5
fix: settling a job no longer rewrites when it was accepted
inesaranab Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions .claude/skills/screening-azure-ops/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
name: screening-azure-ops
description: Operating the Screening service's Azure infrastructure without accidentally starting a GPU. Rules for Container Apps revisions, scale-to-zero, checking what is billing, and the traps that have cost real money on this subscription. Use before ANY az containerapp command, before deploying, and whenever asked whether something is running or costing money.
---

# Screening — Azure operations

The GPU app (`screening-gemma`) runs an A100 at **~€2.16/hour**. Every rule here exists
because it was broken once and billed for it.

## Rule 0 — Name the subscription explicitly in every command

Without `--subscription`, every command below runs against whatever the CLI's default
happens to be. A preflight check can then report on one subscription while the GPU is
billing on another, which makes "nothing is running" a false negative rather than an answer.

```bash
export AZURE_SUBSCRIPTION_ID=<id> # once per session
```

Append `--subscription "$AZURE_SUBSCRIPTION_ID"` to every `az containerapp` command in this
file, and use the same id in the `az rest` URL. The id is not written here — this file is in
a public repository.

## Rule 1 — Check what is running BEFORE starting work, not only after

Two separate incidents cost money because a session began without checking state: an A100
left running for 1h38m (~€3.50), and an app left at `minReplicas: 1` overnight.

```bash
for a in screening-app screening-gemma; do
echo "$a: replicas=$(az containerapp revision list -n $a -g screening-rg --subscription "$AZURE_SUBSCRIPTION_ID" --query 'sum([].properties.replicas)' -o tsv)"
echo " app-template min=$(az containerapp show -n $a -g screening-rg --subscription "$AZURE_SUBSCRIPTION_ID" --query 'properties.template.scale.minReplicas' -o tsv)"
done
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

Both lines matter — see Rule 3.

## Rule 2 — PURGE revisions before updating, never deactivate after

`az containerapp update` **reactivates deactivated revisions**. A revision that was born with
`minReplicas: 1` immediately starts a replica when reactivated. This has happened five times.

Wrong:
```bash
az containerapp update ... # resurrects old revisions
az containerapp revision deactivate ... # clean up after noticing
```

Right:
```bash
# 1. find every revision carrying min>0
az containerapp revision list -n <app> -g screening-rg \
--query "[].{rev:name, active:properties.active, min:properties.template.scale.minReplicas}" -o table
# 2. deactivate those FIRST
az containerapp revision deactivate -n <app> -g screening-rg --revision <NAME>
# 3. then update
# 4. then verify replicas again
```
Comment on lines +39 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate file ---'
sed -n '1,130p' .claude/skills/screening-azure-ops/SKILL.md
printf '%s\n' '--- Azure CLI references in repository ---'
rg -n -C 3 'containerapp (update|revision|deactivate)|minReplicas|subscription|PURGE|reactivat' .claude README.md .github 2>/dev/null || true

Repository: inesaranab/screening

Length of output: 12050


🌐 Web query:

Azure Container Apps az containerapp update deactivated revisions reactivate inactive revision minReplicas deactivate purge documentation

💡 Result:

In Azure Container Apps, revisions are snapshots of your application [1][2]. You can manage their state—active or inactive—using the Azure CLI, which is the recommended approach for these operations [3][4]. Revision State Management To control revision states, use the following Azure CLI commands: - Deactivate a revision: az containerapp revision deactivate --name <APP_NAME> --resource-group <RG_NAME> --revision <REVISION_NAME> [3][4][5] - Reactivate (activate) a revision: az containerapp revision activate --name <APP_NAME> --resource-group <RG_NAME> --revision <REVISION_NAME> [4][6][5] Revisions do not incur charges while in an inactive state [1][7]. Inactive Revision Limits and Purging Azure Container Apps tracks a limited number of inactive revisions [1][7]. - Default Limit: By default, the system tracks up to 100 inactive revisions [1][7]. Once this limit is exceeded, the oldest revisions are automatically purged [1][7]. - Customizing the Limit: You can adjust the number of tracked inactive revisions by using the --max-inactive-revisions parameter with the az containerapp create or az containerapp update commands [1][7]. Note that the Azure CLI extension for container apps may require preview features enabled to use this parameter [1]. Scaling and minReplicas The minReplicas setting is part of the scaling configuration of your container app [2]. If you set minReplicas to 0, your application will scale to zero when there is no traffic, which is a common way to manage resources effectively without manually deactivating revisions [2]. If ingress is disabled and you do not define a minReplicas value or a custom scale rule, the app may scale to zero with no way to start back up [2]. To ensure your application remains operational, ensure minReplicas is set to at least 1 or configure appropriate scaling rules [2]. For comprehensive management, you can list all revisions, including inactive ones, by using the --all flag: az containerapp revision list --name <APP_NAME> --resource-group <RG_NAME> --all [4]

Citations:


🌐 Web query:

site:learn.microsoft.com/en-us/azure/container-apps/revisions "reactivate" "update" deactivated revision

💡 Result:

In the context of Azure Container Apps, you can manage the state of revisions by activating and deactivating them when operating in multiple revision mode [1][2]. To reactivate a previously deactivated revision, you can use the activate command available in the Azure CLI. For example, in Bash, you can execute: az containerapp revision activate --revision <REVISION_NAME> --resource-group <RESOURCE_GROUP_NAME> Similarly, to deactivate a revision, which stops all its running replicas, you use the following command: az containerapp revision deactivate --revision <REVISION_NAME> --resource-group <RESOURCE_GROUP_NAME> When you update your application with revision-scope changes, a new revision is automatically created [1][2]. If you need to revert to a previous state or apply changes based on a specific revision, you can update your container app to use that revision [2]. Note that Azure Container Apps imposes a limit on the total number of available revisions, and revisions exceeding this count are purged [1].

Citations:


🌐 Web query:

site:github.com/Azure/azure-cli az containerapp update reactivate inactive revision

💡 Result:

To manage inactive revisions in Azure Container Apps using the Azure CLI, you should use the revision-specific commands rather than a general update command. There is no single "reactivate" command; instead, you activate a revision using the following command: az containerapp revision activate -g <RESOURCE_GROUP> -n <CONTAINER_APP_NAME> --revision <REVISION_NAME> [1] Conversely, if you need to deactivate a revision, use the corresponding command: az containerapp revision deactivate -g <RESOURCE_GROUP> -n <CONTAINER_APP_NAME> --revision <REVISION_NAME> [1] Note that the az containerapp update command is primarily used to modify configuration settings (such as image, environment variables, or scale rules) of the container app itself [1], and it does not serve as a mechanism to change the lifecycle state (active/inactive) of an existing revision. If you need to create a new revision based on a previous one, you can use the az containerapp revision copy command [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- deployment and revision settings ---'
sed -n '1,100p' .github/workflows/deploy.yml
printf '%s\n' '--- revision-related documentation and commands in the repository ---'
rg -n -C 5 'revision (list|activate|deactivate)|max-inactive-revisions|revisionMode|activeRevisionsMode|targetPort|scale' --glob '!**/node_modules/**' .

Repository: inesaranab/screening

Length of output: 29731


🏁 Script executed:

#!/bin/bash
set -eu
python3 - <<'PY'
from urllib.request import urlopen
from html import unescape
import re

urls = [
    "https://learn.microsoft.com/en-us/azure/container-apps/revisions",
    "https://learn.microsoft.com/en-us/azure/container-apps/revisions-manage",
    "https://raw.githubusercontent.com/Azure/azure-cli/dev/src/azure-cli/azure/cli/command_modules/containerapp/custom.py",
]
for url in urls:
    print(f"\n--- {url} ---")
    try:
        text = urlopen(url, timeout=20).read().decode("utf-8", "replace")
    except Exception as exc:
        print(f"FETCH_ERROR: {exc}")
        continue
    text = unescape(re.sub(r"<[^>]+>", " ", text))
    text = re.sub(r"\s+", " ", text)
    terms = ["reactivat", "deactivat", "purge", "inactive", "az containerapp update", "new revision"]
    for term in terms:
        hits = [m.start() for m in re.finditer(term, text, re.I)]
        for pos in hits[:5]:
            print(text[max(0, pos-220):pos+420])
PY

Repository: inesaranab/screening

Length of output: 883


🌐 Web query:

"az containerapp update --yaml" "reactivates" revision

💡 Result:

Using the az containerapp update --yaml command does not inherently "reactivate" a revision; instead, it updates the container app's configuration based on the provided YAML file [1][2]. Whether a new revision is created or an existing one is modified depends on the nature of the changes and the app's revision mode [1][3]. Key points regarding this command and revision behavior: 1. Revision-Scope vs. Application-Scope Changes: If your YAML file includes revision-scope changes (e.g., changes to container images, environment variables, or scale rules), the command will trigger the creation of a new revision [1][3]. Application-scope changes (e.g., certain metadata or label updates) do not create a new revision [3]. 2. Revision Reactivation: The az containerapp update command is not a substitute for explicit revision activation [4][5]. If you need to bring an inactive revision back into service, you should use the explicit command [4]: az containerapp revision activate --name <APP_NAME> --revision <REVISION_NAME> --resource-group <RG_NAME> 3. YAML Usage: The --yaml parameter allows you to supply a full configuration template [2][5]. When using this approach, be aware that the CLI will apply the configuration defined in the file, which may include settings that force a new revision deployment [2]. Ensure your YAML reflects the desired state of the application to avoid unintended deployments or configuration drift [2][6]. 4. Revision Modes: In Single Revision Mode, deploying a new configuration automatically deactivates the previous revision and activates the new one [7]. In Multiple Revision Mode, you can manage active and inactive revisions independently, allowing you to reactivate old revisions or shift traffic weights as needed [1][5].

Citations:


🌐 Web query:

Azure Container Apps update reactivates deactivated revision minReplicas

💡 Result:

There is no official documentation or specific update confirming that Azure Container Apps automatically reactivates a deactivated revision's minReplicas settings [1][2][3]. In Azure Container Apps, when a revision is deactivated, it is effectively stopped and does not consume resources [1][3]. Deactivation is a state change that stops all running replicas of that specific revision [1]. Scaling behavior, including minReplicas, is revision-specific; if you deactivate a revision, it remains dormant and does not actively scale [3]. If you are observing a situation where a revision appears to "reactivate" or where scaling behavior changes unexpectedly after a deployment or update, it is likely related to one of the following documented behaviors: 1. Deployment and Traffic Shifting: In single-revision mode, when you deploy a new revision, the previous revision is not deactivated until the new revision is fully ready, including meeting its own scaling requirements (e.g., minReplicas) [3]. 2. New Revision Scaling Defaults: When a new revision is created, it initializes based on its own configuration, not the current scale of the previous revision [4]. This can cause the application to start at its defined minReplicas (which might be 1) rather than the high replica count the previous revision may have scaled to under heavy load [4]. This is a common point of confusion that can look like a reset or unexpected behavior [4]. 3. Multiple Revision Mode: If you are using multiple revision mode, traffic weights determine which revisions are active [3]. If you manually reactivate a revision or update traffic weights to point to a previously deactivated one, it will resume operations according to its defined scaling configuration (minReplicas/maxReplicas) [3]. If you believe a revision is reactivating autonomously, verify if there are any automated CI/CD pipelines, Azure Policy, or scripts that might be updating the revision's traffic weights or activation status, as these actions would trigger the revision to resume its scaling profile.

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
fetch() {
  url="$1"
  printf '\n--- %s ---\n' "$url"
  curl -k -L --silent --show-error --max-time 30 "$url" |
    tr '\n' ' ' |
    sed 's/<[^>]*>/ /g; s/[[:space:]][[:space:]]*/ /g' |
    grep -oEi '.{0,240}(--all|inactive revisions|new revision|deactivat|reactivat|purge).{0,420}' |
    head -20 || true
}
fetch 'https://learn.microsoft.com/en-us/azure/container-apps/revisions'
fetch 'https://learn.microsoft.com/en-us/azure/container-apps/revisions-manage'
fetch 'https://raw.githubusercontent.com/Azure/azure-cli/dev/src/azure-cli/azure/cli/command_modules/containerapp/_help.py'

Repository: inesaranab/screening

Length of output: 15074


Set minReplicas: 0 on the new revision before updating.

az containerapp update does not reactivate a deactivated revision. A revision-scope update creates a new revision, and that revision can start a GPU when its scale template has minReplicas: 1. Deactivating old revisions does not prevent this. Set --min-replicas 0 in the update, inspect revisions with --all, and replace “PURGE” with “DEACTIVATE” because inactive revisions are retained.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.claude/skills/screening-azure-ops/SKILL.md around lines 25 - 45, Update
Rule 2 to state that revision-scope updates create a new revision and must
explicitly set minReplicas to 0 using --min-replicas 0 before updating. Change
revision inspection commands to include --all, and replace “PURGE” terminology
with “DEACTIVATE” to reflect that inactive revisions are retained; remove the
inaccurate claim that updates reactivate deactivated revisions and the old
deactivate-before-update sequence.

Source: MCP tools


## Rule 3 — The app template and its revisions are different things

Deactivating every revision stops everything running. It does **not** change the app
template. If the template says `minReplicas: 1`, the next revision created from it — by any
update, for any reason — starts a replica.

Fixing the template is itself an update, so it starts a GPU. Expect that and plan for it:
either use the boot for something needed, or deactivate immediately after.

## Rule 4 — `replica list` without `--revision` lies

It reports only the newest revision. It once showed an empty list while an A100 billed for
two more hours. Always use `revision list` with a `sum()`, or pass `--revision` explicitly.

## Rule 5 — An interrupted command may still have run

A rejected/interrupted tool call reached Azure anyway on 2026-08-10 and started an A100 that
ran unnoticed for 1h38m. After any interrupted `az` command, **verify state** rather than
assuming it did not execute.

## Rule 6 — A new revision always boots once, even with minReplicas: 0

It must prove itself healthy. On the GPU app that is a ~13 minute, ~€0.50 boot. Never create
a revision on `screening-gemma` casually.

## Verifying independently

When the answer matters, check by a second path:

```bash
# every container app in the whole subscription, not just the ones you remember
az containerapp list --query "[].{name:name, rg:resourceGroup, min:properties.template.scale.minReplicas}" -o table

# actual spend, a completely separate data path
az rest --method post \
--url "https://management.azure.com/subscriptions/<SUB>/providers/Microsoft.CostManagement/query?api-version=2023-11-01" \
--body '{"type":"ActualCost","timeframe":"MonthToDate","dataset":{"granularity":"Daily","aggregation":{"totalCost":{"name":"Cost","function":"Sum"}},"grouping":[{"type":"Dimension","name":"ServiceName"}]}}'
```

Idle baseline for this subscription is **~€0.17–0.60/day**. A day above that means something
ran.

## Known costs

| Thing | Cost |
|---|---|
| A100 (`Consumption NC24-A100`) | €2.16/hour, only while a replica runs |
| Premium file share, 100 GiB @ 135 MiB/s | ~€25/month, always |
| Everything else idle | ~€5/month |

Full reasoning and measurements: `infra/gemma/README.md`.
21 changes: 20 additions & 1 deletion .claude/skills/screening-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,26 @@ the framework docs can't know.
- Config is environment-driven via `pydantic-settings` (`SCREENING_` prefix);
`.env.example` is the committed template, `.env` is never committed.

## 6. Test-first — verified, not assumed
## 6. Docstrings — Google style, factual

- **Every module, class and public function has a Google-style docstring**: a summary
line, then `Args:` / `Returns:` / `Raises:` for functions, `Attributes:` for models.
- **State the property, not the incident that taught it.** "Does not raise; any
exception is recorded as a failed job" — not "we learned the hard way that an
uncaught exception leaves the job pending forever".
- **No war stories, no dates, no measurements, no "we".** Decision records and
measured findings belong in `infra/*/README.md` or the commit message, where a
reader is looking for history. A docstring is read by someone trying to use the
thing.
- **Self-contained.** Do not explain one symbol by referring to another
("an enum for the same reason as NextStep"). Say what *this* one does.
- **No prose constants.** A block of explanation assigned to a module-level string
is dead code, not documentation.
- Exception to all of the above: `#` comments *inside* a function body may carry the
non-obvious "why", including a measurement, when the code would otherwise look
wrong or invite a regression.

## 7. Test-first — verified, not assumed

- Two tiers, and a change isn't done until the right tier is green:
- `pytest -m "not live"` — deterministic, uses **fakes behind the ports** (canned +
Expand Down
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ SCREENING_LLM_API_KEY=ollama
SCREENING_LLM_MODEL=qwen2.5:3b
SCREENING_LLM_TIMEOUT_S=60

# Self-hosted Gemma-4 endpoint the guardrail calls.
# The default points at a local vLLM. In any deployed environment this MUST be
# set to the detector's internal FQDN (Fully Qualified Domain Name)
SCREENING_LLM_GUARDRAIL_BASE_URL=http://localhost:8001/v1
SCREENING_LLM_GUARDRAIL_MODEL=google/gemma-4-31B-it
# The endpoint scales to zero, so the first request after an idle period
# waits for a GPU to boot and load weights (~13 min). The default 60s
# timeout is too short for that and would make the request fail before
# the model is even ready — 15 min is ample enough to survive the activation
# window
SCREENING_LLM_GUARDRAIL_TIMEOUT_S=900

# Confident AI (DeepEval online evals).
CONFIDENT_API_KEY=
CONFIDENT_BASE_URL=https://eu.api.confident-ai.com
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ wheels/
.agents/*

.deepeval/
.coverage
23 changes: 17 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,23 @@ COPY app ./app
RUN useradd -m screening-user
USER screening-user

# Presidio recognizers (incl. GLiNER) lazy-load on first `.analyze()` call, not
# at construction, so building the guardrail alone doesn't fetch GLiNER's
# weights. Run an actual scrub so every model — spaCy, GLiNER, the injection
# classifier — is downloaded and cached into the image before HF_HUB_OFFLINE
# is set below; otherwise the first real request fails offline.
RUN python -c "import asyncio; from app.adapters.guard_classifier import ClassifierGuardrail; asyncio.run(ClassifierGuardrail().scrub('warmup'))"
# Warm the model caches into the image before HF_HUB_OFFLINE is set below;
# otherwise the first real request fails offline. Constructing the guardrail is
# enough: the only things that download are spaCy's en_core_web_sm and the
# injection classifier, and both load in `ClassifierGuardrail.__init__`.
#
# Deliberately NOT a real `.scrub()` any more. Article 9 detection is now an
# HTTP call to the Gemma endpoint (LLMGuardrailRecognizer), which does not exist
# at build time, and that recognizer fails closed on purpose — so a scrub here
# would abort the build. GLiNER, whose lazy loading was the original reason for
# running a scrub, is gone.
#
# SCREENING_SERVICE_API_KEY is a build-only placeholder passed to this one
# command (not an ENV, so it is never baked into the image): guard_classifier
# now imports app.config transitively, and Settings refuses to construct without
# a key.
RUN SCREENING_SERVICE_API_KEY=build-warmup-not-a-real-secret \
python -c "from app.adapters.guard_classifier import ClassifierGuardrail; ClassifierGuardrail()"
EXPOSE 8000

ENV HF_HUB_OFFLINE=1
Expand Down
Loading
Loading