Profile Personas (RAG half)
Status: implemented (this PR)
Repos: rag (this repo) + gateway (persona/profile CRUD, image upload flow — out of scope here)
Problem
- Personas were inert.
teacher_agent.load_contextselectedresponse_style_json,reasoning_rules_jsonfrompersonas— columns that don't exist (the table isid, name, system_prompt, is_default). The query always raised, theexceptswallowed it, andpersona_ruleswas silentlyNonefor every request.persona_idwas accepted end-to-end but never did anything. - No image moderation. The gateway needed a way to screen a student-uploaded image before it reaches chat/vision flows, and RAG already owns the OpenAI client/settings this needs.
Plan
Persona loader
- Query
id, name, system_promptbypersona_id; when absent or unknown, fall back to theis_default = TRUErow. - Cache the resolved row (
CacheService.persona_cache, mirroring the existing rerank/chunk cache idiom) so a chat turn doesn't pay a DB round-trip every time. - Truncate
system_promptdefensively (2000 chars) before it ever enters the prompt. - Injection point and precedence were already correct in
app/services/llm.py(_build_system_prompt): the persona block sits after the core grounding/citation contract but before the exercise contract, the answer-format contract, and the final language/script directive — the parts of the prompt that speak last win, so persona tone guidance cannot override them. This PR only clarified that block (heading + explicit precedence comment); no reordering was needed or made, since existing pinned tests (tests/services/test_llm.py) already encode that ordering.
Image moderation
POST /moderate-image,{image_base64, mime_type}→{flagged, categories}.- OpenAI Moderation API,
omni-moderation-latest, image input via adata:URL built frommime_type+image_base64— no new API-key plumbing (reusessettings.OPENAI_API_KEY). - Validates mime_type (
image/jpeg|png|webp), base64 decodability, and a 5 MB decoded-size cap before calling out; 400 on any of those, 502 on an OpenAI failure. Image bytes are never logged. - Auth mirrors
/chat(get_current_user— any authenticated caller, not admin-gated), since the gateway calls this service directly.
Behavior change flagged for downstream review
Falling back to the is_default persona when persona_id is absent means every
/chat request without an explicit persona now gets the default persona's system_prompt
injected (previously: no persona_id → no persona block at all). This is what the spec for
this work explicitly asked for, and it only changes tone/style guidance — it cannot
override grounding, citations, format, or language per the precedence above — but it is a
live behavior change for the default case. No promptfoo/golden fixtures live in this repo
(the live-eval config lives in the gateway repo), so nothing here needed updating; flag
this for whoever owns the gateway-side eval fixtures in case a personas.is_default row
exists in production.
Review round 1 (quality review: 2 CRITICAL, 1 HIGH, 3 MEDIUM, 1 LOW)
CRITICAL — guardrails never ran on the streaming path
generate_answer's grounding (apply_grounding_guardrails) and language
(ensure_answer_language) checks only ran in its non-streaming branch — /chat always
streams (stream=True), so a persona-influenced streamed answer had nothing checking
it for missing citations or wrong-language output. This PR is what first makes persona
text reach that prompt in practice, so it's also what first activates the gap.
Decision — post-stream guardrail, not inline gating. generate_answer's streaming
branch stays untouched: tokens are forwarded to the client the moment they're generated,
which is the entire point of streaming (first-token latency). Instead,
app/api/routers/chat.py's agent_streaming_generator — which already accumulates
full_answer for billing — runs the SAME two checks, in the SAME order, against the fully
-streamed answer once it's complete, and emits a new notice SSE event (added to
ChatChunk's event Literal) carrying {grounding, language, corrected_answer} when
either check would have altered the answer. It never rewrites what was already streamed;
it flags it, and logs an error-level line with the failure kind.
Latency tradeoff (explicit, not free): zero added latency before the first token —
nothing changes about when streaming starts or how fast tokens arrive. A small amount of
latency is added before the done event: apply_grounding_guardrails is synchronous/local
(negligible), but ensure_answer_language makes a real OpenAI call when it detects a
language mismatch, so a language-flagged turn pays one extra API round-trip in the tail of
the stream, after all tokens have already reached the client. Accepted as a fixed cost of
having any backstop on the streaming path — the alternative (buffering the whole answer
before streaming) would reintroduce exactly the first-byte-timeout failure class the
stream-first design (app/api/routers/chat.py's stream_with_graph) exists to avoid.
apply_grounding_guardrails was renamed from _apply_grounding_guardrails (dropped the
leading underscore) since it's now a shared cross-module function, not an llm.py-private
helper.
CRITICAL — moderation call unbounded + shared thread pool
Three independent fixes, all in app/services/moderation.py:
- client.moderations.create(..., timeout=MODERATION_TIMEOUT_SECONDS) (10s) — an
unbounded call could hang a request indefinitely.
- A dedicated ThreadPoolExecutor(max_workers=4) (_MODERATION_EXECUTOR) plus
moderate_image_async(), which the router now calls instead of asyncio.to_thread.
asyncio.to_thread uses the event loop's shared default executor — the same one
/chat's synchronous LangGraph invocation runs on — so a moderation burst could have
starved /chat, or vice versa.
- /moderate-image added to RateLimitMiddleware's /wallet//upload bucket (30
req/min) — a media-handling endpoint, the closest existing fit — so the burst is also
bounded at the edge, not only inside the executor.
HIGH — no size bound before body parse
ModerateImageRequest.image_base64 gets max_length=7_000_000
(ceil(5 MB / 3) * 4 + slack, matching MAX_IMAGE_BYTES) so Pydantic rejects an
oversized payload at validation, before any base64 decode. The service's own
decoded-size check stays as defense in depth for direct (non-HTTP) callers.
MEDIUM — negative persona-cache TTL
load_persona_rules now caches a "nothing found" result (no persona_id match AND no
is_default row) too, under a short, distinct TTL (_NEGATIVE_PERSONA_CACHE_TTL_SECONDS
= 60, vs. the 5-minute positive TTL) — a personas-less deployment no longer pays a DB
read on every single chat turn, while still recovering within a minute of a default
persona being added.
MEDIUM — cache keying / deduplication
A persona resolved via the is_default fallback (whether persona_id was absent or
simply unknown) is now always cached under the shared default-persona sentinel key, never
under the raw requested id — a client cycling through bogus persona_ids no longer stores
N full duplicate copies of the (identical) default persona, which would otherwise evict
legitimate cache entries under the 200-item LRU cap. A distinct raw id that falls back to
default gets a tiny {"alias_of": <sentinel>} marker instead, so a repeat lookup of that
same bogus id follows the alias and skips the DB without storing another duplicate.
LOW — mime_type as a closed schema type
ModerateImageRequest.mime_type is now Literal["image/jpeg", "image/png",
"image/webp"] instead of a bare str. An unsupported value now 422s at the schema layer
instead of reaching the service's InvalidImageInputError → 400 path. Noted for the
gateway team: the gateway treats any 4xx from this endpoint as a moderation failure
either way, so this is not a cross-repo contract break — but the specific status code for
a bad mime_type changed from 400 to 422.