Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ jobs:
# Real-DB integration tests -- the offline suite above stubs Postgres,
# so this is the only job that exercises the actual schema end to end.
- name: Test (real-Postgres integration)
run: pytest -q tests/test_auth_integration.py tests/test_feedback_integration.py tests/test_actions_integration.py tests/test_llm_credentials_integration.py
run: pytest -q tests/test_auth_integration.py tests/test_feedback_integration.py tests/test_actions_integration.py tests/test_llm_credentials_integration.py tests/test_extraction_concurrency_integration.py

web:
name: Web (lint · type · test · build)
Expand Down
7 changes: 4 additions & 3 deletions apps/api/app/db/models/llm_credential.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ class UserLlmCredential(Base):
model: Mapped[str] = mapped_column(Text, nullable=False)
# Stamped by a successful /test only, via the revision-conditioned UPDATE.
last_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Incremented on every PUT; the /test success stamp conditions on it so a
# credential replaced mid-test can never be marked verified by the old
# credential's test call.
# Incremented when a PUT changes the credential's contents (key/provider/
# base_url/model) -- not on flag-only edits; the /test success stamp
# conditions on it so a credential replaced mid-test can never be marked
# verified by the old credential's test call.
revision: Mapped[int] = mapped_column(Integer, nullable=False, server_default="1")
# Separate from having a credential at all: extraction runs on a settled
# verdict subset, classification runs on every ingested message, so a
Expand Down
57 changes: 50 additions & 7 deletions apps/api/app/routes/llm_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
LlmTestResultOut,
LlmUsageOut,
)
from app.services.nlp import persistence
from app.services.nlp.extractor import test_credential
from app.services.nlp.providers import (
PROVIDER_PRESETS,
Expand Down Expand Up @@ -121,8 +122,13 @@ def _lock_guard_user(db: Session, user_id: UUID) -> None:
REPLACES the old single-row `with_for_update()` PUT used to take on the
credential row itself -- no route below takes a separate credential-row
lock of its own; this is THE lock.

Delegates to `persistence.lock_guard_user` -- the same implementation
`record_extraction`'s D2 credential-identity check takes, so there's one
SQL statement for this lock, not two copies that could drift (plan:
2026-08-19-extraction-cost-hardening D2).
"""
db.execute(select(AppUser.id).where(AppUser.id == user_id).with_for_update())
persistence.lock_guard_user(db, user_id)


def _credential_rows(db: Session, user_id: UUID) -> list[UserLlmCredential]:
Expand Down Expand Up @@ -725,12 +731,15 @@ async def put_llm_settings(
def _apply_update(target: UserLlmCredential) -> None:
# Read BEFORE mutating -- both flag resolvers and `material_changed`
# need `target`'s state as it stood before this write. A flag-only
# edit (no new key, same provider/base_url/model)
# must NOT clear last_verified_at -- that'd be a confusing "your
# verified key just went unverified" regression for a save that
# didn't touch the credential itself.
# edit, OR a re-send of the exact same key with the same
# provider/base_url/model, must NOT clear last_verified_at, bump
# revision, or reset extraction attempts -- none of those changed
# what credential is stored, so treating it as material would reopen
# rows that are still capped under the SAME dead credential.
# `target.api_key` is an EncryptedText column that reads back as
# plaintext, so this `!=` is a correct plaintext comparison.
material_changed = (
api_key_provided
(api_key_provided and effective_api_key != target.api_key)
or target.provider != provider
or target.base_url != base_url
or target.model != model
Expand All @@ -745,9 +754,17 @@ def _apply_update(target: UserLlmCredential) -> None:
target.base_url = base_url
target.api_key = effective_api_key
target.model = model
target.revision += 1
if material_changed:
target.revision += 1
target.last_verified_at = None
# D2 recovery: a material rewrite is exactly the fix a
# credential-class cap-jump was waiting for -- reset this
# caller's terminally-capped rows so they're claimable again
# under whatever now-different credential this write leaves
# active. A flag-only edit changes nothing about the credential
# itself, so it deliberately does NOT reset (the row is still
# capped under the SAME dead credential).
persistence.reset_failed_extraction_attempts(db, current_user.id)
target.updated_at = now

if row is None:
Expand All @@ -771,6 +788,11 @@ def _apply_update(target: UserLlmCredential) -> None:
)
row.updated_at = now
db.add(row)
# D2 recovery: this caller's first-ever credential always lands
# active -- same "the credential just changed" reasoning as a
# material PUT above, for whatever failed rows predate having any
# credential at all (e.g. rows capped under the server fallback).
persistence.reset_failed_extraction_attempts(db, current_user.id)
try:
db.commit()
except IntegrityError:
Expand Down Expand Up @@ -904,6 +926,12 @@ def delete_llm_settings(

Guard-locked (D6) like every other structural mutation, so a concurrent
create/activate for this user can't interleave with the wipe.

Deliberately does NOT reset D2's terminally-capped ``failed`` rows
(unlike PUT/create/activate) -- there's no credential left to retry
under, so resetting here would just leave them capped-at-zero with
nothing to claim them until a NEW credential shows up, at which point
that credential's own mutation resets them anyway.
"""
_lock_guard_user(db, current_user.id)
rows = _credential_rows(db, current_user.id)
Expand Down Expand Up @@ -1005,6 +1033,12 @@ async def create_llm_credential(
)
row.updated_at = now
db.add(row)
if not rows:
# D2 recovery: landing active because it's the caller's first
# credential is the same "the active credential just changed" event
# as a material PUT -- reset. An inactive spare (rows non-empty)
# changes nothing about which credential is active, so it does not.
persistence.reset_failed_extraction_attempts(db, current_user.id)
db.commit()
return _credential_summary(row)

Expand Down Expand Up @@ -1053,6 +1087,11 @@ def activate_llm_credential(
old_active.is_active = False
db.flush()
target.is_active = True
# D2 recovery: a genuine switch changes which credential is active,
# so any of the caller's rows capped under the old one deserve a
# fresh claimable life. A call that targets the already-active
# credential (this branch doesn't run) changes nothing.
persistence.reset_failed_extraction_attempts(db, current_user.id)
db.commit()
return _credential_summary(target)

Expand All @@ -1067,6 +1106,10 @@ def delete_llm_credential(
(switch to another with ``/activate`` first, or use the singular
``DELETE /settings/llm`` kill switch to remove everything at once).
404s if ``credential_id`` isn't the caller's.

Always removes an INACTIVE spare, so the active credential (whatever
D2's cap-jumped rows are capped under) never changes here -- no
`persistence.reset_failed_extraction_attempts` call, same reasoning as the kill switch.
"""
_lock_guard_user(db, current_user.id)
rows = _credential_rows(db, current_user.id)
Expand Down
80 changes: 41 additions & 39 deletions apps/api/app/services/nlp/classifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,44 +351,46 @@ def _classify_attempt(
# gone -- kept as one constant anyway since there's still only one place
# that needs it.
_CLASSIFICATION_PROMPT = (
"You classify an email into exactly ONE label, from the RECIPIENT's point "
"of view. Decide what the recipient must actually DO.\n\n"
"Labels:\n"
"- needs_reply: the recipient is personally expected to WRITE BACK. A real "
"person asks them a question, requests info, or awaits their response.\n"
"- action_required: the recipient must personally complete a concrete "
"off-email task with a real consequence or deadline -- pay an invoice, sign "
"a document, submit a form, RSVP to a real invitation, reset a password they "
"must change. NOT a reply, and NOT optional.\n"
"- fyi: informational / automated / transactional mail to read for awareness. "
"Receipts, order & shipping updates, notifications, statements, newsletters "
"you subscribed to, calendar notices, app/system alerts. This is the DEFAULT "
"when no genuine personal task or reply is required.\n"
"- promotional: marketing, sales, offers, deals, or bulk mail trying to get "
"you to buy or click. Has a commercial/advertising intent.\n"
"- security_alert: account or login security -- verification codes, new "
"sign-ins, suspicious activity, password/2FA notices.\n"
"- spam: junk, scams, or phishing.\n\n"
"CRITICAL boundary rules (this is where mistakes happen):\n"
"1. Marketing CTAs are NOT action_required. 'Shop now', 'click here', "
"'limited time', 'upgrade today', auto-renewal notices -> promotional or fyi.\n"
"2. A soft/optional/automated 'confirm', 'renew', 'click', 'verify' is NOT "
"action_required. Only use action_required when the recipient genuinely has "
"to do the task or face a consequence.\n"
"3. When unsure between action_required and fyi, choose fyi. Most automated "
"and bulk email is fyi.\n"
"4. needs_reply requires a real human awaiting YOUR written response, not an "
"automated 'do not reply' message.\n\n"
"Examples:\n"
"- 'Your Amazon order has shipped, arriving Tuesday' -> fyi\n"
"- 'Your monthly statement is ready to view' -> fyi\n"
"- 'Invoice #429 is due Friday, pay to avoid late fees' -> action_required\n"
"- 'Please sign the attached contract by EOD' -> action_required\n"
"- 'Hey, can you send me the report when you get a chance?' -> needs_reply\n"
"- '50% off this weekend only -- shop now!' -> promotional\n"
"- 'New sign-in to your account from a new device' -> security_alert\n\n"
"Return JSON only with keys: label, confidence (0-1), rationale."
)
"You classify an email into exactly ONE label, from the RECIPIENT's "
"point of view -- what must they actually DO?\n\n"
"Labels:\n"
"- needs_reply: recipient must WRITE BACK -- a real person asked a "
"question, requested info, or awaits a reply.\n"
"- action_required: a concrete off-email task with a real deadline or "
"consequence -- pay, sign, submit a form, RSVP, reset a required "
"password. Not a reply, not optional.\n"
"- fyi: informational/automated/transactional mail for awareness "
"(receipts, shipping updates, statements, newsletters, system "
"alerts). DEFAULT when no genuine task or reply is required.\n"
"- promotional: marketing/sales/offers/bulk mail with commercial "
"intent.\n"
"- security_alert: account/login security -- codes, new sign-ins, "
"suspicious activity, password/2FA notices.\n"
"- spam: junk, scams, or phishing.\n\n"
"Boundary rules:\n"
"1. Marketing CTAs ('shop now', 'click here', 'limited time', "
"auto-renewal) -> promotional or fyi, not action_required.\n"
"2. A soft/optional/automated 'confirm'/'renew'/'click'/'verify' is not "
"action_required -- only when the recipient must act or face a real "
"consequence.\n"
"3. Unsure between action_required and fyi? Choose fyi -- most "
"automated/bulk mail is fyi.\n"
"4. needs_reply needs a real human awaiting YOUR written reply, not an "
"automated do-not-reply message.\n\n"
"Examples:\n"
"- '50% off this weekend only -- shop now!' -> promotional, not "
"action_required.\n"
"- 'Your monthly statement is ready to view' -> fyi, not "
"action_required.\n"
"- 'Automated notice: your account was updated. Do not reply.' -> "
"fyi, not needs_reply.\n\n"
"Return JSON only with keys: label, confidence (0-1), rationale."
)

# D9 (2026-08-19-extraction-cost-hardening): prod p50 body is ~2,450 chars,
# so the median call is untouched by this cap -- it only trims the long tail,
# where the label signal is already in the first few thousand chars anyway.
_CLASSIFICATION_TEXT_MAX_LEN = 3000

# The BYOK classification call's own token budget -- a label/confidence/
# rationale reply is far smaller than extraction's structured JSON, so this
Expand Down Expand Up @@ -547,7 +549,7 @@ def _classify_llm_user(
result = call_chat_completion(
credential,
prompt=_CLASSIFICATION_PROMPT,
user_content=f"Email:\n{text[:6000]}",
user_content=f"Email:\n{text[:_CLASSIFICATION_TEXT_MAX_LEN]}",
max_tokens=_CLASSIFICATION_MAX_TOKENS,
timeout=_CLASSIFICATION_TIMEOUT_S,
policy=policy,
Expand Down
Loading
Loading