Skip to content

Commit a99f9c0

Browse files
committed
feat(ai-chat): voice input (STT) + capability-based model features
Replaces the implicit 'every model is a chat model' assumption with an explicit capability set, and adds hands-free voice input to the chat. Capability-based models: a new AIModelCapability enum (text / vision / audio_input) is stored as a JSONB capabilities array on AIModel (migration s1t2m3o4d5e6, GIN-indexed, defaults to ['text']). text is the DEFAULT but not forced -- an STT-only model like whisper-1 legitimately carries only audio_input. A single source-of-truth mapping (app/ai/providers/capabilities.py required_capabilities_for_task) declares what each task needs (chat->text, ocr->vision, transcription->audio_input, else->text). The AI-config Models UI shows feature toggles (text/vision/audio_input chips with icons + colored badges); at least one capability must remain. The transcription task type was added (TaskType + AIConfigSummary.transcription + voice_input workflow) so admins can assign an audio_input model to it; the Tasks tab renders it with an AudioLines icon. Voice input: POST /ai-assistance/transcribe accepts a compressed audio upload (validates size + MIME, normalizing the recorder's 'audio/webm;codecs=opus'), resolves the transcription assignment via AIProviderService.get_stt_target (validates the resolved model advertises audio_input; rejects chat-only misconfigs), and POSTs to the provider's /audio/transcriptions (OpenAI-compatible) returning {text}. Audio is ephemeral -- never persisted (may contain PHI); the resulting text is prompt-guard-scanned then dropped into the editable input box. Frontend: useVoiceRecorder records Opus/16kHz mono/ 24kbps (~90KB/30s) with a 60s cap; ChatVoiceButton is a unified control (tap = toggle, press-hold = push-to-talk, slide-off = cancel); RecordingBanner shows a pulsing dot + M:SS timer with explicit Stop (finish+transcribe) and Cancel (abort+discard) buttons, rendered above the input box (outside overflow-hidden) so neither it nor the attachment rail is clipped by the focus glow. 13 backend tests (test_stt_capabilities.py); squash-schema test relaxed for a baseline + follow-up chain. Frontend tsc + eslint + build green.
1 parent 565e2e3 commit a99f9c0

26 files changed

Lines changed: 1427 additions & 71 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
1313
## [Unreleased]
1414

15+
- **AI chat: voice input (speech-to-text) + capability-based model features.** Two coupled changes that rework how models declare what they can do and add hands-free chat input. **(1) Capability-based models:** replaced the implicit "every model is a chat model" assumption with an explicit capability set. A new `AIModelCapability` enum (`text` / `vision` / `audio_input`) is stored as a JSONB `capabilities` array on `AIModel` (migration `s1t2m3o4d5e6`, default `["text"]` so existing models keep their meaning). A single source-of-truth mapping (`app/ai/providers/capabilities.py` `required_capabilities_for_task`) declares what each task needs — `chat`→text, `ocr`→vision, `transcription`→audio_input, every other text-generation task→text — so the AI-config Models UI now shows **feature toggles** (Text is always on; Vision / Audio Input are opt-in chips with icons + colored badges on the list row). A model can be multimodal (e.g. `gpt-4o` = text+vision) or purpose-built (`whisper-1` = audio_input). The `transcription` task type was added to `TaskType`, surfaced in the config summary (`AIConfigSummary.transcription`) + a new `voice_input` workflow, so admins can create a `transcription` task assignment pointing at an `audio_input` model. **(2) Voice input:** a new `POST /ai-assistance/transcribe` endpoint accepts a compressed audio upload, resolves the `transcription` assignment via a new `AIProviderService.get_stt_target` (validates the resolved model advertises `audio_input`; rejects a misconfigured chat-only assignment with a clear guard message), and POSTs it to the provider's `/audio/transcriptions` (OpenAI-compatible) returning `{text}`. Audio is **ephemeral** — never persisted (it may contain PHI); the resulting text is prompt-guard-scanned for audit correlation and then placed in the chat input box for the user to review/edit before sending (it then flows through the normal chat pipeline). Size (20 MiB) + MIME guards at the trust boundary; backend `app/ai/assistance/stt.py` (`STTTarget`, `TranscriptionError`, `transcribe_audio`) + new config (`AI_STT_MAX_AUDIO_BYTES`, `AI_STT_TIMEOUT_SECONDS`, `OPENAI_STT_MODEL`). **Frontend:** `useVoiceRecorder` hook records Opus/16 kHz mono/24 kbps via `MediaRecorder` (~90 KB / 30s — tiny uploads) with a 60s hard cap; `ChatVoiceButton` is a **single unified control** handling both interaction models by gesture — tap (down→up < 250 ms) = toggle (tap to start, tap to stop), press & hold = push-to-talk — with a live elapsed timer and a clear "unsupported/permission-denied" path. Transcribed text appends into the editable input box (preserves typed draft + focuses for review). i18n added (en + el). 12 backend tests (`test_stt_capabilities.py`) cover the capability mapping, baseline inclusion, and STT-target resolution/enforcement; the squash-schema test was relaxed to allow a baseline + incremental follow-ups. Frontend tsc + eslint + build green.
16+
1517
- **AI chat: multimodal image support — ask questions with images.** The agentic chatbot (both the side-panel drawer and the full-screen `/ai-assistant` page) now accepts image attachments so users can ask about lab-report scans, photos, charts, or medication labels. **Backend:** a new modular `app/ai/assistance/attachments.py` is the single source of truth for image handling — an `AllowedImageMime` enum (JPEG/PNG/WEBP/GIF; SVG deliberately excluded as a script vector), `validate_image_data_url` / `validate_chat_images` (MIME whitelist + per-image `AI_CHAT_MAX_IMAGE_BYTES` 8 MiB + per-request `AI_CHAT_MAX_IMAGES` 4 count enforcement at the trust boundary, fail-fast `ImageValidationError` subclassing `ValueError` so it surfaces as a localized guard SSE message with no SDK leak), `build_multimodal_content` (emits the OpenAI vision content-block schema — plain `str` when no images, `[{"type":"text"},{"type":"image_url"}]` otherwise), and `has_images`. `AIAssistanceRequest.images: List[str]` (RFC 2397 data URLs) flows through the `/assist` + `/stream` endpoints; `AIAssistanceService.assist` validates images for the `chat` task only and threads them through `_chat_stream` / `_general_chat`. User messages now persist `{"text", "images": [...]}` in the `ChatMessage.content` JSONB, and `reconstruct_history` rebuilds multimodal `HumanMessage`s for past turns so vision context survives across turns. The chat system prompt gained an "IMAGE & VISION INPUT" rule block (examine images, transcribe values, never fabricate illegible numbers, route "look at my uploaded document" to `get_document_content`). Image bytes bypass the text prompt-injection guard (the HITL wall remains the structural defence for clinical writes). **Frontend:** modular, reusable pieces — `types/ai.ts` gained `ChatImageAttachment` (data URL), a `ChatAttachmentStatus` enum + `PendingChatAttachment` interface for composer state, and `images?` on `Message`; a new `useChatAttachments` hook centralizes client-side validation (mirrors the backend limits so rejected files are never base64-encoded) + `FileReader` data-URL encoding for three input vectors (pick, drag-drop onto the input bar, clipboard paste); `ChatAttachmentPicker` (paperclip button + hidden `<input type=file multiple>`) and `ChatAttachmentPreviewRail` (thumbnail chips with remove + encoding spinner) compose into the modernized input bar; `ChatMessageImages` renders galleries inside bubbles with count-adaptive layouts (single large tile / side-by-side / 1+2 / 2×2 grid with "+N" overflow) and a keyboard-navigable full-screen lightbox. The send button enables image-only sends ("what's this?"), and history reload maps persisted `images` back into the bubbles. 13 backend unit tests (`test_chat_attachments.py`); frontend tsc + eslint + vite build green.
1618

1719
- **Frontend: catalog list rows are now fully clickable + anatomy form unified with the Anatomy Explorer.** Three coupled UX/consistency improvements to the Catalogs workspace and the Anatomy page. (1) In `CatalogBrowser`, each list row and card was selectable only via the inner title/description button — the rest of the row (empty space, the badges column) did nothing. Selection moved to the whole `<li>`/card (cursor-pointer), while the inner interactive elements (class/scope badges, the external-domain link, the picker Add button) keep working independently via `stopPropagation`. (2) Catalogs > Anatomy's create/edit form previously fell back to the bare `GenericCatalogForm` (name + description only), unlike the Anatomy Explorer's `AnatomyStructureForm` (name, slug, category, SNOMED code, description). A new **`AnatomyForm`** is registered in the `CATALOG_FORMS` registry and is now the single shared field set used by **both** surfaces — `AnatomyStructureForm` was refactored to render it. It matches the patterns of the other catalog forms: the anatomy **class** is a `CatalogItemPicker` (`conceptKind="anatomy_class"`, single-select, bound to `class_concept_id`) instead of a legacy uppercase-enum dropdown, and **coding system + code** are two separate fields (`standard_system` select + `standard_code` text) like ConceptForm/BiomarkerForm. (3) Added an **"Open in catalog"** link from both the Anatomy detail card (`/catalogs?type=anatomy&item=<id>`) and the Explorer toolbar (`/catalogs?type=anatomy`) so a structure can be opened/edited in the full catalog workspace. Writes for anatomy now route through the `/anatomy` domain endpoint (not `/catalogs/anatomy`) via a new anatomy entry in the `writeTarget.ts` dispatcher (mirroring how `concept` writes go through `/concepts`), so the class concept is resolved + global-item RBAC enforced by the anatomy service; `buildWritePayload` gained an `anatomy` branch. Dropped the legacy `category` field from the `anatomyService` TS types (`AnatomyListParams`/`AnatomyStructureInput`/`AnatomyStructurePatch`) and the `list()` method — the modern field is `class_concept_id`/`class_concept_slug`; `AnatomySearchPopup` now converts its category filter to a class slug at the API boundary, and `bodyPartService` creates with `class_concept_slug: 'other'`. Frontend tsc + eslint clean.
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
"""add ai_models.capabilities (text/vision/audio_input)
2+
3+
Adds a ``capabilities`` JSONB array to ``ai_models`` declaring which
4+
modalities a model supports (its "features"): ``text`` (baseline, every model),
5+
``vision`` (image input — multimodal chat / vision OCR), ``audio_input``
6+
(speech-to-text — the ``transcription`` task). Tasks require specific
7+
capabilities so the task-assignment picker only offers eligible models.
8+
9+
Defaults to ``["text"]`` so every pre-existing model keeps its current meaning
10+
(a plain chat/LLM model). The new ``transcription`` task assignment resolves
11+
to models that advertise ``audio_input`` (e.g. ``whisper-1``).
12+
13+
Revision ID: s1t2m3o4d5e6
14+
Revises: 8ddb7ef7ca4d
15+
Create Date: 2026-07-16
16+
"""
17+
18+
from alembic import op
19+
import sqlalchemy as sa
20+
from sqlalchemy.dialects import postgresql
21+
22+
revision = "s1t2m3o4d5e6"
23+
down_revision = "8ddb7ef7ca4d"
24+
branch_labels = None
25+
depends_on = None
26+
27+
28+
def upgrade() -> None:
29+
op.add_column(
30+
"ai_models",
31+
sa.Column(
32+
"capabilities",
33+
postgresql.JSONB(astext_type=sa.Text()),
34+
nullable=False,
35+
server_default=sa.text("'[\"text\"]'"),
36+
),
37+
)
38+
# GIN index supports capability-containment lookups
39+
# (``capabilities ? 'vision'`` / ``capabilities @> '["audio_input"]'``).
40+
op.create_index(
41+
"ix_ai_models_capabilities",
42+
"ai_models",
43+
["capabilities"],
44+
unique=False,
45+
postgresql_using="gin",
46+
)
47+
48+
49+
def downgrade() -> None:
50+
op.drop_index("ix_ai_models_capabilities", table_name="ai_models")
51+
op.drop_column("ai_models", "capabilities")

backend/app/ai/assistance/stt.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Speech-to-text transcription against an OpenAI-compatible API.
2+
3+
STT is a different endpoint from chat completions (``POST /audio/transcriptions``,
4+
multipart form), so the LangChain ``ChatOpenAI`` chat factory cannot serve it.
5+
This module is the thin client that resolves the ``transcription`` task
6+
assignment (a model advertising the ``audio_input`` capability) and POSTs the
7+
compressed audio to the provider, returning plain text.
8+
9+
Security / privacy
10+
------------------
11+
Audio may contain PHI. It is transcribed and then **discarded** — never written
12+
to the DB, never logged at payload level. The resulting text flows through the
13+
existing chat pipeline (prompt guard + HITL wall) once it becomes ``user_input``.
14+
15+
Performance
16+
-----------
17+
Callers should send Opus/WebM at ~16 kHz mono (~24 kbps) — already tiny. The
18+
``AI_STT_MAX_AUDIO_BYTES`` guard rejects anything oversized before the network
19+
call. The whole operation is batch (one round-trip), not streamed.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import logging
25+
from dataclasses import dataclass
26+
from typing import Optional, Tuple
27+
28+
import httpx
29+
30+
from app.ai.providers.capabilities import required_capabilities_for_task
31+
from app.ai.providers.enums import TaskType
32+
from app.core.config import settings
33+
34+
logger = logging.getLogger(__name__)
35+
36+
37+
@dataclass
38+
class STTTarget:
39+
"""Resolved STT provider/model to call + the auth/base URL."""
40+
41+
api_key: Optional[str]
42+
api_base: str
43+
model_name: str
44+
45+
46+
class TranscriptionError(RuntimeError):
47+
"""Raised when transcription is unavailable or the provider call fails.
48+
49+
Subclasses ``RuntimeError`` (not ``ValueError``) so it is NOT mistaken for
50+
a soft guard message by the endpoint error classifier — it surfaces as a
51+
generic streaming/HTTP error with no SDK text leak.
52+
"""
53+
54+
55+
def _resolve_stt_target(provider, model) -> STTTarget:
56+
"""Build the STT call target from a resolved provider+model (or env fallback).
57+
58+
Validates the resolved model advertises the ``audio_input`` capability —
59+
a misconfigured assignment to a chat-only model is rejected early with a
60+
clear message instead of a cryptic provider 400.
61+
"""
62+
api_key = provider.get_api_key_plaintext() if provider else None
63+
api_base = (
64+
provider.api_base if provider and provider.api_base else "https://api.openai.com/v1"
65+
)
66+
model_name = model.model_name if model else settings.OPENAI_STT_MODEL
67+
68+
required = required_capabilities_for_task(TaskType.TRANSCRIPTION.value)
69+
caps = model.get_capabilities() if model and hasattr(model, "get_capabilities") else None
70+
have = {str(c) for c in caps} if caps else set()
71+
if required and not any(c.value in have for c in required):
72+
raise TranscriptionError(
73+
f"Configured STT model '{model_name}' does not advertise the "
74+
f"'audio_input' capability. Assign a speech-to-text model "
75+
f"(e.g. whisper-1) to the transcription task."
76+
)
77+
78+
if not api_key:
79+
raise TranscriptionError(
80+
"No API key configured for speech-to-text. Set an OPENAI_API_KEY "
81+
"or assign a transcription provider with a key."
82+
)
83+
84+
return STTTarget(api_key=api_key, api_base=api_base.rstrip("/"), model_name=model_name)
85+
86+
87+
async def transcribe_audio(
88+
audio_bytes: bytes,
89+
*,
90+
filename: str,
91+
mime_type: str,
92+
target: STTTarget,
93+
) -> str:
94+
"""POST compressed audio to ``{api_base}/audio/transcriptions`` and return
95+
the transcribed text.
96+
97+
The audio is sent as multipart form data (``file`` + ``model``) per the
98+
OpenAI-compatible API contract. Raises :class:`TranscriptionError` on any
99+
failure (timeout, auth, non-2xx). The caller never sees raw provider text
100+
beyond the transcribed payload.
101+
"""
102+
base = target.api_base.rstrip("/")
103+
url = f"{base}/audio/transcriptions"
104+
105+
# httpx builds the multipart boundary itself; do NOT pre-set Content-Type.
106+
files = {"file": (filename, audio_bytes, mime_type)}
107+
data = {"model": target.model_name}
108+
109+
try:
110+
async with httpx.AsyncClient(timeout=settings.AI_STT_TIMEOUT_SECONDS) as client:
111+
resp = await client.post(
112+
url,
113+
headers={"Authorization": f"Bearer {target.api_key}"},
114+
files=files,
115+
data=data,
116+
)
117+
except httpx.TimeoutException as exc:
118+
raise TranscriptionError("Speech-to-text request timed out.") from exc
119+
except httpx.HTTPError as exc:
120+
raise TranscriptionError("Speech-to-text service is unreachable.") from exc
121+
122+
if resp.status_code >= 400:
123+
logger.warning(
124+
"STT provider returned HTTP %s for model=%s", resp.status_code, target.model_name
125+
)
126+
if resp.status_code in (401, 403):
127+
raise TranscriptionError("Speech-to-text authentication failed.")
128+
if resp.status_code == 429:
129+
raise TranscriptionError("Speech-to-text rate limit reached. Try again shortly.")
130+
raise TranscriptionError(
131+
f"Speech-to-text failed (HTTP {resp.status_code})."
132+
)
133+
134+
try:
135+
payload = resp.json()
136+
except ValueError as exc:
137+
raise TranscriptionError("Speech-to-text returned a malformed response.") from exc
138+
139+
# OpenAI returns {"text": "..."}; tolerate alternate shapes.
140+
text = payload.get("text") if isinstance(payload, dict) else None
141+
if not text and isinstance(payload, dict):
142+
# Some providers nest under "result" or return a bare string.
143+
text = payload.get("result") or payload.get("transcript")
144+
if not text:
145+
raise TranscriptionError("Speech-to-text returned no text.")
146+
return str(text).strip()
147+
148+
149+
def split_filename(filename: str) -> Tuple[str, str]:
150+
"""Return ``(stem, ext)`` for a filename, lowercased extension with dot."""
151+
if "." in filename:
152+
stem, ext = filename.rsplit(".", 1)
153+
return stem, f".{ext.lower()}"
154+
return filename, ""
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""Map AI tasks to the model capabilities they require.
2+
3+
A model advertises a SET of capabilities (``AIModelCapability``: text / vision
4+
/ audio_input) on its JSONB ``capabilities`` column. Each task type needs at
5+
least one specific capability; this module is the single source of truth for
6+
that mapping so the task-assignment picker only offers eligible models and the
7+
runtime factories can sanity-check their resolved model.
8+
9+
Examples:
10+
* ``chat`` → needs ``text``
11+
* ``ocr`` → needs ``vision``
12+
* ``transcription`` → needs ``audio_input``
13+
* every other text-generation task (define_*, magic_fill, …) → ``text``
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Iterable, Optional, Set
19+
20+
from app.ai.providers.enums import TaskType
21+
from app.models.enums import AIModelCapability
22+
23+
24+
# The capability each task type REQUIRES (a model must advertise it to be
25+
# eligible). Tasks not listed default to {TEXT} (the baseline modality every
26+
# model carries). Kept as TaskType keys so renames are caught at import time.
27+
TASK_REQUIRED_CAPABILITY: dict = {
28+
TaskType.CHAT: {AIModelCapability.TEXT},
29+
TaskType.OCR: {AIModelCapability.VISION},
30+
TaskType.TRANSCRIPTION: {AIModelCapability.AUDIO_INPUT},
31+
}
32+
33+
34+
def required_capabilities_for_task(task_type: object) -> Set[AIModelCapability]:
35+
"""Return the set of capabilities a model must have to serve ``task_type``.
36+
37+
Unknown/unmapped task types (the long tail of text-generation tasks) fall
38+
back to ``{TEXT}``.
39+
"""
40+
key = TaskType.from_string(str(task_type)) if task_type is not None else None
41+
return set(TASK_REQUIRED_CAPABILITY.get(key, {AIModelCapability.TEXT}))
42+
43+
44+
def normalize_capabilities(values: Optional[Iterable[object]]) -> Set[str]:
45+
"""Coerce a raw capabilities payload (JSONB list of strings/enum) into a
46+
clean set of lowercase capability strings.
47+
48+
``text`` is the default modality (an empty/null payload falls back to
49+
``{"text"}``) but is NOT forced when the payload already declares other
50+
capabilities — an STT-only model like ``whisper-1`` legitimately carries
51+
only ``audio_input``.
52+
"""
53+
if not values:
54+
return {AIModelCapability.TEXT.value}
55+
result: Set[str] = set()
56+
for v in values:
57+
cap = AIModelCapability.from_string(str(v))
58+
if cap is not None:
59+
result.add(cap.value)
60+
# An explicitly-empty list (e.g. all values were invalid) → text baseline.
61+
return result or {AIModelCapability.TEXT.value}
62+
63+
64+
def model_supports(
65+
capabilities: Optional[Iterable[object]], required: Iterable[AIModelCapability]
66+
) -> bool:
67+
"""True when a model's capability set covers ALL ``required`` capabilities."""
68+
have = normalize_capabilities(capabilities)
69+
return all(c.value in have for c in required)

backend/app/ai/providers/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ class TaskType(str, enum.Enum):
9595
SUGGEST_CATEGORY_ICON = "suggest_category_icon"
9696
GENERATE_CATEGORY_ICON = "generate_category_icon"
9797
CHAT = "chat"
98+
TRANSCRIPTION = "transcription"
9899

99100
@classmethod
100101
def all_values(cls) -> List[str]:

0 commit comments

Comments
 (0)