diff --git a/CLAUDE.md b/CLAUDE.md index 97ae0d4..48b7e59 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,5 +67,6 @@ pnpm + Turborepo. `apps/api` : NestJS, Prisma, PostgreSQL (pgvector + PostGIS), - ✅ **Revue pixel R5 validée par le référent (17/07)** — 1 correction appliquée sur arbitrage : la Bibliothèque-corpus passe en **tableau** (Document/Rattaché à/Indexation/Corpus + actions ; interrupteur éteint pour les non-indexables), vignettes conservées sur les cartes Documents des fiches ; « Appliquer » direct et calibrage au runbook validés tels quels. 16/16 Playwright rejoués. - 🏁 **R5 CLOSE (17/07/2026, tag `release/r5`)** : recettée par le référent (revue pixel + arbitrage tableau), recette passée SANS clé API, durcissement répété en local conteneurisé. **R0 → R5 : périmètre v1 couvert.** - ✅ **Recette terrain mobile sur iPhone 15 Pro — 7/7 validés (19/07, ADR-005)** : Expo Go bloqué par le retard d'approbation Apple (SDK 54 vs notre SDK 57) → **décision : builds natifs locaux (Xcode/Android Studio, signature gratuite) pour la vraie recette, Expo Go conservé pour l'aperçu sans installation**. 5 obstacles techniques réels corrigés et documentés (UDID plutôt que nom, ne jamais contourner `expo run:*` par un xcodebuild manuel, modules Expo/RN précompilés SDK 56/57 incompatibles avec la liaison statique du projet, `fetch` global incompatible avec l'upload multipart natif — fix permanent `EXPO_PUBLIC_USE_RN_FETCH=1` dans `apps/mobile/.env`, découverte réseau du dev client peu fiable). Recette iOS : connexion, scan caméra réel (résolution + rejet QR étranger), vrai mode avion, conflit D2 tranché par l'humain avec photo réellement téléversée, persistance à travers fermeture/reconstruction de l'app, suggestions R5 au vrai modèle, purge de sécurité au changement de compte — tout validé. Android : build natif sur émulateur, passage santé complet. -- 🔄 **Reprise ici** : redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »). Restes : recette Android sur appareil physique (quand disponible), calibrage `AI_SEUIL_*` sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). +- ✅ **Dictée implémentée (22/07, ADR-004 §5)** : écran Voix R5 (jamais construit) — `faster-whisper` local (CTranslate2, CPU, opt-in `AI_TRANSCRIPTION=off|locale`, défaut off), audio jamais persisté (purge serveur ET mobile quoi qu'il arrive), transcription → relecture humaine → suggestion immédiate et/ou sauvegarde dans `InterventionReport.note` (champ existant depuis R2, jamais eu d'écran jusqu'ici) → `note` rejoint le corpus à la clôture comme les bilans codés. Mobile : `expo-audio`/`expo-file-system`, bouton dicter sur l'écran de clôture. Docker `siop2-ai` embarque le modèle (1,54→2,19 Go). Vérifié réellement (voix de synthèse → texte fidèle, bout en bout API, conteneur Docker construit, chaîne corpus complète note→clôture→réindexation→recherche). 80 tests API, 29 pytest ai, mobile vert. +- 🔄 **Reprise ici** : test tactile de la dictée sur iPhone (bloqué par une connexion USB qui ne s'est pas rétablie malgré câble/port/redémarrage — reporté comme la recette Android). Redéploiement Dokploy de l'instance ENSET (`AI_SERVICE_TOKEN` à créer — runbook §2 — puis « Réindexer tout »). Restes : recette Android sur appareil physique, calibrage `AI_SEUIL_*` et qualité darija sur corpus SPELEV réel, secret `DOKPLOY_WEBHOOK_URL`, production client SPELEV (attend les accès serveur du partenaire). - Détail quotidien : `docs/journal/journal.md`. Dépôt : `siop-spelev/siop2` (privé), jalons R0→R5. diff --git a/apps/ai/Dockerfile b/apps/ai/Dockerfile index 2d6a42f..4b09094 100644 --- a/apps/ai/Dockerfile +++ b/apps/ai/Dockerfile @@ -8,29 +8,35 @@ FROM ghcr.io/astral-sh/uv:python3.11-bookworm-slim AS builder WORKDIR /app ENV UV_LINK_MODE=copy \ - FASTEMBED_CACHE_PATH=/opt/fastembed + FASTEMBED_CACHE_PATH=/opt/fastembed \ + HF_HOME=/opt/whisper # Manifestes d'abord (cache de couche), puis le code. L'installation du projet # reste éditable (.pth → /app/src) : src est donc copié dans l'image finale. COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project --no-dev \ - --extra embeddings --extra generation + --extra embeddings --extra generation --extra transcription COPY src src RUN uv sync --frozen --no-dev \ - --extra embeddings --extra generation + --extra embeddings --extra generation --extra transcription -# Le modèle d'embeddings est EMBARQUÉ dans l'image : pas de téléchargement au -# boot (démarrage prévisible, marche sans accès à Hugging Face en production). +# Les modèles locaux (embeddings + dictée) sont EMBARQUÉS dans l'image : pas +# de téléchargement au boot (démarrage prévisible, marche sans accès à +# Hugging Face en production). La dictée reste opt-in (AI_TRANSCRIPTION=off +# par défaut) — le modèle est prêt si elle est activée un jour, sans rebuild. RUN uv run python -c "from siop_ai.embeddings import EmbeddeurLocal; EmbeddeurLocal()" +RUN uv run python -c "from siop_ai.transcription import TranscripteurLocal; TranscripteurLocal('small')" FROM python:3.11-slim-bookworm WORKDIR /app ENV PATH=/app/.venv/bin:$PATH \ - FASTEMBED_CACHE_PATH=/opt/fastembed + FASTEMBED_CACHE_PATH=/opt/fastembed \ + HF_HOME=/opt/whisper RUN useradd --system --create-home siop COPY --from=builder --chown=siop:siop /app/.venv /app/.venv COPY --from=builder --chown=siop:siop /app/src /app/src COPY --from=builder --chown=siop:siop /opt/fastembed /opt/fastembed +COPY --from=builder --chown=siop:siop /opt/whisper /opt/whisper USER siop EXPOSE 8000 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ diff --git a/apps/ai/pyproject.toml b/apps/ai/pyproject.toml index a878409..64530f4 100644 --- a/apps/ai/pyproject.toml +++ b/apps/ai/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "asyncpg>=0.30", "pypdf>=5.1", "minio>=7.2", + "python-multipart>=0.0.20", ] [project.optional-dependencies] @@ -17,6 +18,9 @@ dependencies = [ embeddings = ["fastembed>=0.4"] # Génération opt-in (ADR-004 §3) — absente des tests/CI (repli extractif). generation = ["anthropic>=0.75"] +# Transcription opt-in (dictée R5, ADR-004 §5) — absente des tests/CI +# (transcripteur déterministe). CTranslate2/CPU, licence MIT, local. +transcription = ["faster-whisper>=1.1"] [dependency-groups] dev = [ diff --git a/apps/ai/src/siop_ai/app.py b/apps/ai/src/siop_ai/app.py index 2688f44..13c3cb8 100644 --- a/apps/ai/src/siop_ai/app.py +++ b/apps/ai/src/siop_ai/app.py @@ -7,7 +7,7 @@ from contextlib import asynccontextmanager from dataclasses import asdict import asyncpg -from fastapi import Depends, FastAPI, Header, HTTPException +from fastapi import Depends, FastAPI, Header, HTTPException, UploadFile from pydantic import BaseModel, Field from .assistant import repondre, suggerer_bilan @@ -16,6 +16,7 @@ from .embeddings import construire_embeddeur from .generation import construire_generateur from .ingestion import reindexer_tout from .recherche import chercher +from .transcription import construire_transcripteur @asynccontextmanager @@ -26,6 +27,9 @@ async def cycle_de_vie(app: FastAPI): app.state.generateur = construire_generateur( reglages.ai_generation, reglages.ai_api_key, reglages.ai_model ) + app.state.transcripteur = construire_transcripteur( + reglages.ai_transcription, reglages.ai_transcription_model + ) app.state.pool = await asyncpg.create_pool(reglages.database_url, min_size=1, max_size=5) yield await app.state.pool.close() @@ -50,6 +54,7 @@ async def sante() -> dict: "status": "ok", "service": "siop2-ai", "generation": reglages.ai_generation, # « off » = extractif — jamais la clé + "transcription": reglages.ai_transcription, } @@ -113,3 +118,16 @@ async def suggerer(corps: RequeteSuggestion) -> dict: seuil_confiance_forte=app.state.reglages.ai_seuil_confiance_forte, ) return {"suggestions": [asdict(s) for s in suggestions]} + + +@app.post("/internal/transcrire", dependencies=[Depends(verifier_jeton)]) +async def transcrire(fichier: UploadFile) -> dict: + """Dictée opt-in (R5 D5, loi 09-08) : l'audio ne persiste JAMAIS — un + fichier temporaire le temps de l'inférence, supprimé aussitôt (voir + transcription.py). 503 propre si AI_TRANSCRIPTION=off (défaut).""" + if app.state.transcripteur is None: + raise HTTPException(status_code=503, detail="Transcription non activée (AI_TRANSCRIPTION=off)") + audio = await fichier.read() + extension = (fichier.filename or "audio.m4a").rsplit(".", 1)[-1] + texte = app.state.transcripteur.transcrire(audio, extension) + return {"texte": texte} diff --git a/apps/ai/src/siop_ai/config.py b/apps/ai/src/siop_ai/config.py index 011240b..30be75b 100644 --- a/apps/ai/src/siop_ai/config.py +++ b/apps/ai/src/siop_ai/config.py @@ -29,6 +29,10 @@ class Reglages(BaseSettings): ai_seuil_pertinence: float = 0.45 ai_seuil_suggestion: float = 0.40 ai_seuil_confiance_forte: float = 0.55 + # Dictée opt-in (R5 D5, ADR-004 §5) : « off » (défaut — endpoint refuse + # proprement), « locale » (faster-whisper, CPU) ou « deterministe » (CI). + ai_transcription: str = "off" + ai_transcription_model: str = "small" # tiny|base|small|medium|large-v3 model_config = {"env_prefix": "", "case_sensitive": False} @@ -43,4 +47,6 @@ def charger_reglages() -> Reglages: raise ValueError("AI_GENERATION doit valoir « off » ou « api »") if reglages.ai_generation == "api" and not reglages.ai_api_key: raise ValueError("AI_GENERATION=api exige AI_API_KEY (voir ADR-004 §3)") + if reglages.ai_transcription not in ("off", "locale", "deterministe"): + raise ValueError("AI_TRANSCRIPTION doit valoir « off », « locale » ou « deterministe »") return reglages diff --git a/apps/ai/src/siop_ai/ingestion.py b/apps/ai/src/siop_ai/ingestion.py index e94d9fc..4810c1e 100644 --- a/apps/ai/src/siop_ai/ingestion.py +++ b/apps/ai/src/siop_ai/ingestion.py @@ -119,7 +119,7 @@ async def indexer_bilans( await cnx.execute('DELETE FROM "RagChunk" WHERE "sourceType" = \'WORK_ORDER\'') bilans = await cnx.fetch( ''' - SELECT wo.id, wo.reference, wo.title, wo."completedAt", + SELECT wo.id, wo.reference, wo.title, wo."completedAt", ir.note, a.reference AS asset_ref, a.brand, a.model, (SELECT json_object_agg(rv.field, rv.label) FROM "InterventionReport" ir2 @@ -137,10 +137,13 @@ async def indexer_bilans( for bilan in bilans: champs = json.loads(bilan["bilan"]) if bilan["bilan"] else {} codes = " ; ".join(f"{champ} : {label}" for champ, label in champs.items()) + # note (D5) : dictée ou saisie libre relue par l'humain — même pipeline + # d'anonymisation que le reste, aucun traitement à part. + note = f" Description libre : {bilan['note']}." if bilan["note"] else "" contenu = anonymiser( f"Intervention {bilan['reference']} — {bilan['title']}. " f"Appareil {bilan['asset_ref']} ({bilan['brand']} {bilan['model'] or ''}). " - f"Bilan codé : {codes}.", + f"Bilan codé : {codes}.{note}", noms, ) quand = bilan["completedAt"].date().isoformat() if bilan["completedAt"] else "date inconnue" diff --git a/apps/ai/src/siop_ai/transcription.py b/apps/ai/src/siop_ai/transcription.py new file mode 100644 index 0000000..f2e63eb --- /dev/null +++ b/apps/ai/src/siop_ai/transcription.py @@ -0,0 +1,54 @@ +"""Transcription audio (dictée R5, ADR-004 §5) : l'audio ne survit JAMAIS à +cet appel — un fichier temporaire le temps de l'inférence, supprimé aussitôt, +quoi qu'il arrive (D5, loi 09-08). Seul le texte transcrit est retourné ; la +relecture humaine reste le seul contenu conservé, dans l'OT (D1). +""" + +import os +import tempfile +from typing import Protocol + + +class Transcripteur(Protocol): + def transcrire(self, audio: bytes, extension: str) -> str: ... + + +class TranscripteurLocal: + """faster-whisper (CTranslate2, CPU, licence MIT) — chargé paresseusement, + jamais importé en test. Modèle et langue forcée en français : le mélange + français/darija du terrain reste un point de vigilance non calibré (pas + de mesure préalable demandée par le référent, 22/07/2026) — à revoir sur + échantillons réels si la qualité déçoit en recette.""" + + def __init__(self, modele: str) -> None: + from faster_whisper import WhisperModel # import différé (dépendance optionnelle) + + self._modele = WhisperModel(modele, device="cpu", compute_type="int8") + + def transcrire(self, audio: bytes, extension: str) -> str: + fd, chemin = tempfile.mkstemp(suffix=f".{extension}") + try: + with os.fdopen(fd, "wb") as f: + f.write(audio) + segments, _ = self._modele.transcribe(chemin, language="fr", vad_filter=True) + return " ".join(segment.text.strip() for segment in segments).strip() + finally: + os.remove(chemin) # D5 — l'audio ne doit JAMAIS survivre à l'appel + + +class TranscripteurDeterministe: + """Tests/CI : pas de modèle, pas de dépendance audio réelle — renvoie un + texte stable dérivé de la taille du fichier (même interface).""" + + def transcrire(self, audio: bytes, extension: str) -> str: + return f"transcription déterministe ({len(audio)} octets, .{extension})" + + +def construire_transcripteur(mode: str, modele: str) -> Transcripteur | None: + """None si la dictée n'est pas activée (AI_TRANSCRIPTION=off, défaut) — + l'appelant doit alors refuser proprement (503), pas planter.""" + if mode == "off": + return None + if mode == "deterministe": + return TranscripteurDeterministe() + return TranscripteurLocal(modele) diff --git a/apps/ai/tests/test_app.py b/apps/ai/tests/test_app.py index 0607f0f..9f5c7ab 100644 --- a/apps/ai/tests/test_app.py +++ b/apps/ai/tests/test_app.py @@ -16,6 +16,33 @@ def test_endpoints_internes_refuses_sans_jeton(): client = _client_sans_db() assert client.post("/internal/reindex").status_code == 401 assert client.post("/internal/search", json={"question": "couple de serrage ?"}).status_code == 401 + assert client.post("/internal/transcrire", files={"fichier": ("a.m4a", b"x")}).status_code == 401 + + +def test_transcription_off_par_defaut_503(): + """Dictée pas activée (AI_TRANSCRIPTION=off, défaut) : refus propre, + jamais un 500 — même contrat que le service IA éteint côté NestJS.""" + reponse = _client_sans_db().post( + "/internal/transcrire", + files={"fichier": ("note.m4a", b"faux-audio")}, + headers={"X-Service-Token": "dev-only-ai-token"}, + ) + assert reponse.status_code == 503 + + +def test_transcription_deterministe_retourne_un_texte(): + from siop_ai.transcription import TranscripteurDeterministe + + client = _client_sans_db() + app.state.transcripteur = TranscripteurDeterministe() + reponse = client.post( + "/internal/transcrire", + files={"fichier": ("note.m4a", b"faux-audio")}, + headers={"X-Service-Token": "dev-only-ai-token"}, + ) + assert reponse.status_code == 200 + assert "10 octets" in reponse.json()["texte"] + assert ".m4a" in reponse.json()["texte"] def test_question_trop_courte_rejetee_avant_tout(): @@ -32,6 +59,11 @@ def _client_sans_db() -> TestClient: modèle d'embeddings) ne tourne pas — on pose l'état minimal. Les tests d'intégration DB se font en local (recette), pas en CI (convention R5.1).""" from siop_ai.config import charger_reglages + from siop_ai.transcription import construire_transcripteur - app.state.reglages = charger_reglages() + reglages = charger_reglages() + app.state.reglages = reglages + app.state.transcripteur = construire_transcripteur( + reglages.ai_transcription, reglages.ai_transcription_model + ) return TestClient(app, raise_server_exceptions=False) diff --git a/apps/ai/tests/test_transcription.py b/apps/ai/tests/test_transcription.py new file mode 100644 index 0000000..3a44f77 --- /dev/null +++ b/apps/ai/tests/test_transcription.py @@ -0,0 +1,36 @@ +"""Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut (aucune dépendance +audio requise), déterministe en CI/tests. Le vrai moteur (faster-whisper) +se vérifie en recette réelle (convention R5.1 — pas de mesure préalable +demandée par le référent, 22/07/2026).""" + +import pytest + +from siop_ai.config import charger_reglages +from siop_ai.transcription import TranscripteurDeterministe, construire_transcripteur + + +def test_off_par_defaut_ne_construit_rien(): + assert construire_transcripteur("off", "small") is None + + +def test_deterministe_stable_et_derive_la_taille(): + transcripteur = construire_transcripteur("deterministe", "small") + assert isinstance(transcripteur, TranscripteurDeterministe) + texte = transcripteur.transcrire(b"12345", "m4a") + assert "5 octets" in texte + assert ".m4a" in texte + assert transcripteur.transcrire(b"12345", "m4a") == texte # stable + + +def test_mode_inconnu_refuse_au_boot(monkeypatch): + monkeypatch.setenv("AI_TRANSCRIPTION", "toujours") + with pytest.raises(ValueError, match="AI_TRANSCRIPTION"): + charger_reglages() + + +def test_modele_configurable(monkeypatch): + monkeypatch.setenv("AI_TRANSCRIPTION", "deterministe") + monkeypatch.setenv("AI_TRANSCRIPTION_MODEL", "medium") + reglages = charger_reglages() + assert reglages.ai_transcription == "deterministe" + assert reglages.ai_transcription_model == "medium" diff --git a/apps/ai/uv.lock b/apps/ai/uv.lock index 2b92e9d..dc2ed18 100644 --- a/apps/ai/uv.lock +++ b/apps/ai/uv.lock @@ -149,6 +149,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, ] +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba", size = 4340222, upload-time = "2026-07-02T06:37:58.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25", size = 22499354, upload-time = "2026-07-02T06:36:58.751Z" }, + { url = "https://files.pythonhosted.org/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484", size = 18175248, upload-time = "2026-07-02T06:37:01.741Z" }, + { url = "https://files.pythonhosted.org/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0", size = 33387843, upload-time = "2026-07-02T06:37:05.097Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017", size = 35536910, upload-time = "2026-07-02T06:37:08.806Z" }, + { url = "https://files.pythonhosted.org/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f", size = 38984619, upload-time = "2026-07-02T06:37:11.861Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1", size = 34451176, upload-time = "2026-07-02T06:37:15.154Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44", size = 36619869, upload-time = "2026-07-02T06:37:18.495Z" }, + { url = "https://files.pythonhosted.org/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629", size = 27556236, upload-time = "2026-07-02T06:37:21.388Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6", size = 20221133, upload-time = "2026-07-02T06:37:23.88Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5", size = 22740741, upload-time = "2026-07-02T06:37:26.659Z" }, + { url = "https://files.pythonhosted.org/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8", size = 18384189, upload-time = "2026-07-02T06:37:29.518Z" }, + { url = "https://files.pythonhosted.org/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e", size = 36749881, upload-time = "2026-07-02T06:37:33.096Z" }, + { url = "https://files.pythonhosted.org/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe", size = 38645927, upload-time = "2026-07-02T06:37:37.086Z" }, + { url = "https://files.pythonhosted.org/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b", size = 40454783, upload-time = "2026-07-02T06:37:40.904Z" }, + { url = "https://files.pythonhosted.org/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f", size = 37573117, upload-time = "2026-07-02T06:37:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b", size = 39669026, upload-time = "2026-07-02T06:37:48.761Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b", size = 28448336, upload-time = "2026-07-02T06:37:52.477Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c", size = 21377289, upload-time = "2026-07-02T06:37:55.935Z" }, +] + [[package]] name = "certifi" version = "2026.6.17" @@ -351,6 +377,44 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "ctranslate2" +version = "4.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyyaml" }, + { name = "setuptools" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/86/c4/0e450796f90e54f3325697fc67db4f4ecd397aef96d7b3924e26fb8bd04b/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c2db633a06e3b34bbfb72fd26eee58053d9df1f9c1610ac4df3a6a1e25af7d7", size = 1270559, upload-time = "2026-07-03T12:39:01.154Z" }, + { url = "https://files.pythonhosted.org/packages/b7/54/7b6db16470d0788fb8ab43a99e3e18ba9d41a9b50b7fef7dec353eafbe20/ctranslate2-4.8.1-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:079976cbce3a68de04bf9948d08c96beb86df44e5cd2974e4187bc9c9bb388f3", size = 11928069, upload-time = "2026-07-03T12:39:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/37/66/8fee1366631d224bf26b34db9063a0c88ce358d58331c2393689b0ea27ff/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74bae0a8dc9f98c5a6100bf1c17a91782b384ea53b83e2606030ebf9f25318fe", size = 16707971, upload-time = "2026-07-03T12:39:05.09Z" }, + { url = "https://files.pythonhosted.org/packages/30/84/f610e90bb419707632b9b668476b9fd4cdb090c9b53c119ce017699b58ca/ctranslate2-4.8.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0a584c17f21779eb9035bcbc1ec280998f90b36725b70a5ff911f33e343199a", size = 39351971, upload-time = "2026-07-03T12:39:08.555Z" }, + { url = "https://files.pythonhosted.org/packages/76/6c/7230ecbdd23ab867715e1b6ffe99211c39c11cae8ec2d6c3ec9208c38ee2/ctranslate2-4.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:82982f07a7d615d2248d17d6ec4c43cd50e534b094aa27cda62125a5e3a6e3fc", size = 19219248, upload-time = "2026-07-03T12:39:11.329Z" }, + { url = "https://files.pythonhosted.org/packages/6d/09/9a50eeab00db68aeac08f6ab7f98b5c36abd26b89cbd707ea39e70656500/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9de0dddd91ae68da0a7323441e90708d14b31d31cd443004dda0e1198b5bf11e", size = 1270522, upload-time = "2026-07-03T12:39:13.368Z" }, + { url = "https://files.pythonhosted.org/packages/2f/97/6c41c4d3ae539ec76b1943c362184677befd7c1d5290d2ec361182cdb1e0/ctranslate2-4.8.1-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:82e0e6eb7d4301fd79a714495c8faf34242e09542cef04c9e9794c3fe90014a1", size = 11930367, upload-time = "2026-07-03T12:39:14.896Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d4/03428106134a0a58922461074f8942f92c5ed0bb3a8d018677ad64a9c476/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5ca144b93035b9f53e6d67b7cdf5802c3fffca9aa0247940eecbd4592c68ce2f", size = 16882768, upload-time = "2026-07-03T12:39:17.425Z" }, + { url = "https://files.pythonhosted.org/packages/47/c9/976a565398a03fb2973cbe5edd5ca03c4332d86b634799e0ee562420d3bc/ctranslate2-4.8.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dacc408f716ebc73b3b3c6ddd937700e776c4c68b6d9c81862990150ff0f6af6", size = 39529060, upload-time = "2026-07-03T12:39:20.468Z" }, + { url = "https://files.pythonhosted.org/packages/c0/82/0a5f7f2b03b4e10aacb3146715724e1b96bb993cc7d199be28c9825aa120/ctranslate2-4.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:49f96e861b57301f0b76a082109bde2cac8204a6b4fedc870883008271e82251", size = 19220789, upload-time = "2026-07-03T12:39:23.356Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a7/3101c3a0785253a8ef386f39744ad19c28c75b7f227e7c232aee7a5c416a/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ba628835e6ad4ad399261ab6cb51bf152de563e6b122a9e8eb0c61e69f925931", size = 1270478, upload-time = "2026-07-03T12:39:25.401Z" }, + { url = "https://files.pythonhosted.org/packages/89/b9/e50c7558e96a054d6b1e6a6c5e729dda4a4f05584e065f2902aa5f1bc4c8/ctranslate2-4.8.1-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:85ef15ce0b2172ec471975b8a30d5c5bc71e7cffcd163ad6c07ea32f1943d940", size = 11930241, upload-time = "2026-07-03T12:39:26.927Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2f/ea7a19c6d7e949b731fb034664633184bbfc7882846d107f4d790693fb76/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0030670278a73cae09dff9bca72cdd248af61f9367257f18db9b3b94fbb3a50d", size = 16883512, upload-time = "2026-07-03T12:39:29.302Z" }, + { url = "https://files.pythonhosted.org/packages/99/4a/21f325a9d0925d8ad24b04249adf29bf9909442967603634f7f6d4acbb79/ctranslate2-4.8.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4242a7f8e285f922525f4cffd5b1fb43cbacc61d0611cf54832e9c447d030840", size = 39529085, upload-time = "2026-07-03T12:39:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e7/37da1a7500b57496a5269318c4f57962ea0c26dcac06b85222d7831acf00/ctranslate2-4.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:d52499f05a60a791aeadee28d609efa130142f376d1ea76b2b1c593bb01f8827", size = 19220784, upload-time = "2026-07-03T12:39:35.74Z" }, + { url = "https://files.pythonhosted.org/packages/c6/66/39111224e418400d97fd79fbc9e72329c51f91a3e7a9c9a1a182e4f88022/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b4c3246aa4a7f309109a841ca743a72cc4abad4f93c0bf7da691023323215621", size = 1271321, upload-time = "2026-07-03T12:39:37.907Z" }, + { url = "https://files.pythonhosted.org/packages/ef/89/13f827fae226eea51315729c00111f716813d7736ebb827fecb8f361fe0d/ctranslate2-4.8.1-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:c989f747789e8619cbc2e06443b3674c31bc71bad0369652485bd894b627360a", size = 11930735, upload-time = "2026-07-03T12:39:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/c9/94/4b73f9bbaba29df4227cc65114f11d83fe6d696ef3705cb1ade79eb118fd/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c90eb0bd67b6bb183712cc3fd14bf01ec4f622cd625c5b33cc6c56be7d1c9c34", size = 16872460, upload-time = "2026-07-03T12:39:42.272Z" }, + { url = "https://files.pythonhosted.org/packages/ee/d0/9816494d5ff0745bdf9abe5af04e57a103a416444e604cbe83a6eb0aed7b/ctranslate2-4.8.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e3e3aef4670a6c8dcea367401675f82b49b02c18f5837221bcd7cca90b1707a8", size = 39494736, upload-time = "2026-07-03T12:39:45.733Z" }, + { url = "https://files.pythonhosted.org/packages/6c/dc/22a2c874ca8bb6caa7018dfefdff92dddd487db31cf169891c4c6d408091/ctranslate2-4.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:a2dcce0a57beee984a691d9daa8fc3fd389f5b6cada2644c34571011833bd5b1", size = 19477164, upload-time = "2026-07-03T12:39:48.952Z" }, + { url = "https://files.pythonhosted.org/packages/77/39/7b8d47bf49748ba73182742683eef74b46608beb879765d9d4efc46bc345/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a28c5889585cd17ee3649dfd46d9002ddf50204173f8bff476b9f76d6585795", size = 1293935, upload-time = "2026-07-03T12:39:50.924Z" }, + { url = "https://files.pythonhosted.org/packages/c1/20/434e30c752c433eaef5deccd4de54775bc1f205a6fe6c9e756b737018209/ctranslate2-4.8.1-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:911a5cdef8a405c1804330613a1865f616eb9c092a0e932ee4648128eb20b627", size = 11951789, upload-time = "2026-07-03T12:39:52.886Z" }, + { url = "https://files.pythonhosted.org/packages/85/f2/d716426220b462bbb5bb354b9c6c8d9a41285f067203c860cc79f9f19917/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:84723cae6f802551bbf2438e5e4810722631a2183b89a82c31df26566b54821d", size = 16860414, upload-time = "2026-07-03T12:39:55.54Z" }, + { url = "https://files.pythonhosted.org/packages/69/11/cdab0e7e2ad4e547f15ab227c09207569f1272abae05816900ecebb0797a/ctranslate2-4.8.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1910752ec541980644191fa3b407bc61dee00e88070b0aed29b4cef75010b3ea", size = 39465200, upload-time = "2026-07-03T12:39:59.017Z" }, + { url = "https://files.pythonhosted.org/packages/c0/03/126e963fc3237a416f3085b8a663ebd8ab449ed6c37195b4e0b49597ba0c/ctranslate2-4.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:dc9f1abef55579cc02cdc74b3a55df38491ec56d177d6e6039609d61d09ed30e", size = 19499597, upload-time = "2026-07-03T12:40:01.68Z" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -407,6 +471,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/e8/26b7d78bb8972498c467ca34cb12ee2e60d26ba5eae6d8443189a1af37a5/fastembed-0.8.0-py3-none-any.whl", hash = "sha256:40bee672657574a1009e35ec50030a55f2b426842cb011845379817641bbbbd0", size = 116572, upload-time = "2026-03-23T16:34:40.69Z" }, ] +[[package]] +name = "faster-whisper" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "av" }, + { name = "ctranslate2" }, + { name = "huggingface-hub" }, + { name = "onnxruntime" }, + { name = "tokenizers" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/99/49ee85903dee060d9f08297b4a342e5e0bcfca2f027a07b4ee0a38ab13f9/faster_whisper-1.2.1-py3-none-any.whl", hash = "sha256:79a66ad50688c0b794dd501dc340a736992a6342f7f95e5811be60b5224a26a7", size = 1118909, upload-time = "2025-10-31T11:35:47.794Z" }, +] + [[package]] name = "filelock" version = "3.30.2" @@ -1360,6 +1440,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1455,6 +1544,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/57/c9/e69b1ff4c8b69093ef08b8919ab767af0569666865b39c30a8795d88d3c6/ruff-0.15.22-py3-none-win_arm64.whl", hash = "sha256:e1168075b72158510839f250027659cdd78476f40507dd517892304c41318661", size = 11298172, upload-time = "2026-07-16T15:14:10.51Z" }, ] +[[package]] +name = "setuptools" +version = "83.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, +] + [[package]] name = "siop-ai" version = "0.1.0" @@ -1465,6 +1563,7 @@ dependencies = [ { name = "minio" }, { name = "pydantic-settings" }, { name = "pypdf" }, + { name = "python-multipart" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -1475,6 +1574,9 @@ embeddings = [ generation = [ { name = "anthropic" }, ] +transcription = [ + { name = "faster-whisper" }, +] [package.dev-dependencies] dev = [ @@ -1490,12 +1592,14 @@ requires-dist = [ { name = "asyncpg", specifier = ">=0.30" }, { name = "fastapi", specifier = ">=0.115" }, { name = "fastembed", marker = "extra == 'embeddings'", specifier = ">=0.4" }, + { name = "faster-whisper", marker = "extra == 'transcription'", specifier = ">=1.1" }, { name = "minio", specifier = ">=7.2" }, { name = "pydantic-settings", specifier = ">=2.6" }, { name = "pypdf", specifier = ">=5.1" }, + { name = "python-multipart", specifier = ">=0.0.20" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, ] -provides-extras = ["embeddings", "generation"] +provides-extras = ["embeddings", "generation", "transcription"] [package.metadata.requires-dev] dev = [ diff --git a/apps/api/src/assistant/assistant.controller.ts b/apps/api/src/assistant/assistant.controller.ts index 4cc4ca8..0fd07d4 100644 --- a/apps/api/src/assistant/assistant.controller.ts +++ b/apps/api/src/assistant/assistant.controller.ts @@ -1,4 +1,13 @@ -import { Body, Controller, HttpCode, Post } from '@nestjs/common'; +import { + BadRequestException, + Body, + Controller, + HttpCode, + Post, + UploadedFile, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { AssistantAskSchema, SuggestBilanSchema, @@ -9,6 +18,10 @@ import { ZodValidationPipe } from '../common/zod-validation.pipe'; import { RequirePermission } from '../permissions/require-permission.decorator'; import { AssistantService } from './assistant.service'; +// Dictée (D5) : un enregistrement de quelques dizaines de secondes tient +// largement dans 15 Mo — pas besoin du plafond 20 Mo des documents R3. +const AUDIO_MAX_BYTES = 15 * 1024 * 1024; + @Controller('assistant') export class AssistantController { constructor(private readonly assistant: AssistantService) {} @@ -36,4 +49,18 @@ export class AssistantController { suggest(@Body(new ZodValidationPipe(SuggestBilanSchema)) body: SuggestBilan) { return this.assistant.suggestBilan(body); } + + /** Dictée (D5) — même droit que la saisie du bilan qu'elle alimente. */ + @Post('transcribe') + @HttpCode(200) + @RequirePermission('WORK_ORDERS', 'edit') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: AUDIO_MAX_BYTES } })) + transcribe(@UploadedFile() file: Express.Multer.File | undefined) { + if (!file) throw new BadRequestException('Aucun enregistrement reçu (champ « file »)'); + return this.assistant.transcribe({ + buffer: file.buffer, + originalName: file.originalname, + contentType: file.mimetype, + }); + } } diff --git a/apps/api/src/assistant/assistant.service.ts b/apps/api/src/assistant/assistant.service.ts index a2f86d7..8fc2b3f 100644 --- a/apps/api/src/assistant/assistant.service.ts +++ b/apps/api/src/assistant/assistant.service.ts @@ -6,6 +6,7 @@ import type { BilanSuggestionsResponse, ReindexResult, SuggestBilan, + TranscriptionResult, } from '@siop/shared'; import { loadEnv } from '../config/env'; @@ -97,6 +98,43 @@ export class AssistantService { }; } + /** Dictée (R5 D5, opt-in) : l'audio ne transite qu'une fois vers `siop2-ai` + * — jamais écrit ni sur disque ni en base ici, transmis tel quel en + * multipart. Le service IA l'efface aussitôt transcrit (transcription.py). */ + async transcribe(audio: { + buffer: Buffer; + originalName: string; + contentType: string; + }): Promise { + const form = new FormData(); + form.append( + 'fichier', + new Blob([new Uint8Array(audio.buffer)], { type: audio.contentType }), + audio.originalName, + ); + let reponse: Response; + try { + reponse = await fetch(`${this.env.AI_SERVICE_URL}/internal/transcrire`, { + method: 'POST', + headers: { 'X-Service-Token': this.env.AI_SERVICE_TOKEN }, + body: form, + }); + } catch { + this.journal.warn('Service IA injoignable (/internal/transcrire)'); + throw new ServiceUnavailableException( + 'Dictée indisponible pour le moment — réessayez dans un instant.', + ); + } + if (!reponse.ok) { + this.journal.warn(`Service IA a refusé /internal/transcrire (${reponse.status})`); + throw new ServiceUnavailableException( + 'Dictée indisponible pour le moment — réessayez dans un instant.', + ); + } + const brut = (await reponse.json()) as { texte: string }; + return { text: brut.texte }; + } + async suggestBilan(dto: SuggestBilan): Promise { const brut = await this.appeler<{ suggestions: { diff --git a/apps/api/test/assistant.e2e-spec.ts b/apps/api/test/assistant.e2e-spec.ts index 699ee24..bdcf12d 100644 --- a/apps/api/test/assistant.e2e-spec.ts +++ b/apps/api/test/assistant.e2e-spec.ts @@ -49,6 +49,8 @@ const REPONSE_REINDEX = { extraits: 180, }; +const REPONSE_TRANSCRIRE = { texte: 'Porte cabine qui rebondit, cellule encrassée.' }; + describe('Assistant (e2e — stub du service IA)', () => { let app: INestApplication; let stub: Server; @@ -71,6 +73,7 @@ describe('Assistant (e2e — stub du service IA)', () => { if (req.url === '/internal/ask') res.end(JSON.stringify(REPONSE_ASK)); else if (req.url === '/internal/suggest') res.end(JSON.stringify(REPONSE_SUGGEST)); else if (req.url === '/internal/reindex') res.end(JSON.stringify(REPONSE_REINDEX)); + else if (req.url === '/internal/transcrire') res.end(JSON.stringify(REPONSE_TRANSCRIRE)); else { res.statusCode = 404; res.end('{}'); @@ -169,6 +172,27 @@ describe('Assistant (e2e — stub du service IA)', () => { await http().post('/assistant/reindex').set(auth(ahmed)).expect(403); }); + it('transcribe : dictée (D5) — texte transcrit, réservé à qui édite les OT', async () => { + const res = await http() + .post('/assistant/transcribe') + .set(auth(ahmed)) + .attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' }) + .expect(200); + expect(res.body).toEqual({ text: 'Porte cabine qui rebondit, cellule encrassée.' }); + expect(requetesRecues.at(-1)!.url).toBe('/internal/transcrire'); + expect(requetesRecues.at(-1)!.jeton).toBe('jeton-de-test'); + // Karim (Demandeur) n'a pas WORK_ORDERS.edit + await http() + .post('/assistant/transcribe') + .set(auth(karim)) + .attach('file', Buffer.from('faux-audio'), { filename: 'note.m4a', contentType: 'audio/m4a' }) + .expect(403); + }); + + it('transcribe : refuse proprement sans fichier joint', async () => { + await http().post('/assistant/transcribe').set(auth(ahmed)).expect(400); + }); + it('question trop courte : 400 avant tout appel au service IA', async () => { const avant = requetesRecues.length; await http().post('/assistant/ask').set(auth(ahmed)).send({ question: 'ab' }).expect(400); diff --git a/apps/mobile/app.json b/apps/mobile/app.json index 5b47a98..54a1c10 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -26,7 +26,8 @@ "plugins": [ "expo-router", "expo-font", - "expo-secure-store" + "expo-secure-store", + "expo-audio" ], "scheme": "siop" } diff --git a/apps/mobile/app/ot/[id]/cloture.tsx b/apps/mobile/app/ot/[id]/cloture.tsx index 16071dc..a3a97e3 100644 --- a/apps/mobile/app/ot/[id]/cloture.tsx +++ b/apps/mobile/app/ot/[id]/cloture.tsx @@ -1,6 +1,8 @@ +import * as FileSystem from 'expo-file-system'; import { router, useLocalSearchParams } from 'expo-router'; import { useState } from 'react'; import { Pressable, ScrollView, Text, TextInput, View } from 'react-native'; +import { RecordingPresets, requestRecordingPermissionsAsync, useAudioRecorder } from 'expo-audio'; import { SafeAreaView } from 'react-native-safe-area-context'; import { BILAN_FIELD_LABELS, @@ -9,9 +11,15 @@ import { type BilanField, type BilanSuggestion, type ReportUpsert, + type WorkOrderDetail, } from '@siop/shared'; import { useQueryClient } from '@tanstack/react-query'; -import { useReferenceValues, useSuggestionBilan, useWorkOrder } from '@/api/exploitation'; +import { + useReferenceValues, + useSuggestionBilan, + useTranscription, + useWorkOrder, +} from '@/api/exploitation'; import { useHorsLigne } from '@/auth/session'; import { BoutonTel, Carte, ChoixTel, EnteteFiche } from '@/composants/ui'; import { enfilerBilan, enfilerTransition } from '@/file/actions'; @@ -97,6 +105,7 @@ export default function PageCloture() { setChoix((c) => ({ ...c, [s.field]: s.valueId }))} /> @@ -160,28 +169,67 @@ export default function PageCloture() { /** Écran 4 des maquettes R5 : décrire au pouce → chips suggérées (D1 — un * appui = un choix humain, les chips ne font que pré-remplir les sélecteurs). * L'IA est un service serveur : hors-ligne, la suggestion attend le réseau - * — la clôture en file R4, elle, n'en a pas besoin. */ + * — la clôture en file R4, elle, n'en a pas besoin. + * Écran 6 (Voix, R5 D5, amendé 22/07) : dicter au lieu de taper — l'audio + * n'est JAMAIS conservé (transcrit puis effacé côté serveur ET localement), + * seule la transcription relue compte. Une fois jointe à l'OT, elle rejoint + * le corpus de l'assistant à la clôture, comme les bilans déjà codés. */ function CarteSuggestion({ + ot, horsLigne, surApplication, }: { + ot: WorkOrderDetail; horsLigne: boolean; surApplication: (s: BilanSuggestion) => void; }) { const t = useTokens(); + const queryClient = useQueryClient(); const suggerer = useSuggestionBilan(); + const transcrire = useTranscription(); + const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY); const [description, setDescription] = useState(''); const [appliquees, setAppliquees] = useState>(new Set()); + const [enregistrement, setEnregistrement] = useState(false); + const [jointe, setJointe] = useState(false); const suggestions = suggerer.data?.suggestions ?? []; + const demarrerDictee = async () => { + const { granted } = await requestRecordingPermissionsAsync(); + if (!granted) return; + await recorder.prepareToRecordAsync(); + recorder.record(); + setEnregistrement(true); + setJointe(false); + }; + + const terminerDictee = async () => { + await recorder.stop(); + setEnregistrement(false); + const uri = recorder.uri; + if (!uri) return; + transcrire.mutate( + { uri, nom: 'dictee.m4a', mime: 'audio/m4a' }, + { + onSuccess: (resultat) => setDescription(resultat.text), + // Loi 09-08 (D5) : l'audio ne survit JAMAIS à l'appel, succès ou pas — + // le fichier local suit le même sort que sur le serveur. + onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }), + }, + ); + }; + return ( { + setDescription(v); + setJointe(false); + }} maxLength={2000} - placeholder="Décrivez la panne et ce que vous avez fait…" + placeholder="Décrivez la panne et ce que vous avez fait, ou dictez avec 🎙…" placeholderTextColor={t.encre3} accessibilityLabel="Décrire pour suggérer" style={{ @@ -197,6 +245,43 @@ function CarteSuggestion({ textAlignVertical: 'top', }} /> + void (enregistrement ? terminerDictee() : demarrerDictee())} + /> + {transcrire.isError ? ( + + {transcrire.error.message} + + ) : null} + {transcrire.isSuccess && !enregistrement ? ( + + ✓ Audio transcrit et supprimé — relisez avant d’appliquer. + + ) : null} + { + enfilerBilan(queryClient, ot, { note: description.trim() }, {}); + setJointe(true); + }} + /> + + 🛡 Une fois jointe et l’OT clôturé, cette description (anonymisée) rejoint le corpus de + l’assistant — comme les bilans déjà codés. + { + const form = new FormData(); + form.append('file', { + uri: fichier.uri, + name: fichier.nom, + type: fichier.mime, + } as unknown as Blob); + const jeton = await lireJeton(); + const res = await fetch(`${API_URL}/assistant/transcribe`, { + method: 'POST', + headers: jeton ? { Authorization: `Bearer ${jeton}` } : undefined, + body: form, + }); + if (!res.ok) { + const corps = (await res.json().catch(() => null)) as { message?: string } | null; + throw new Error(corps?.message ?? `Dictée indisponible (${res.status})`); + } + return (await res.json()) as { text: string }; + }, + }); +} + export function useBilan(otId: string) { const invalide = useInvalideOT(otId); return useMutation({ diff --git a/apps/mobile/src/api/schema.d.ts b/apps/mobile/src/api/schema.d.ts index da530c5..fcf0831 100644 --- a/apps/mobile/src/api/schema.d.ts +++ b/apps/mobile/src/api/schema.d.ts @@ -509,6 +509,23 @@ export interface paths { patch?: never; trace?: never; }; + "/assistant/transcribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */ + post: operations["transcribeAudio"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/search": { parameters: { query?: never; @@ -1567,6 +1584,9 @@ export interface components { reportsIndexed: number; chunks: number; }; + TranscriptionResult: { + text: string; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -3210,6 +3230,40 @@ export interface operations { }; }; }; + transcribeAudio: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Texte transcrit — à relire (D1) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TranscriptionResult"]; + }; + }; + /** @description Dictée non activée ou service IA indisponible */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; globalSearch: { parameters: { query: { diff --git a/apps/mobile/src/file/actions.ts b/apps/mobile/src/file/actions.ts index 4c3e227..2d238c3 100644 --- a/apps/mobile/src/file/actions.ts +++ b/apps/mobile/src/file/actions.ts @@ -85,7 +85,7 @@ export function enfilerBilan( patchDetail(queryClient, ot.id, (c) => ({ ...c, report: { - note: c.report?.note ?? null, + note: corps.note !== undefined ? corps.note : (c.report?.note ?? null), doorState: labels.doorStateId !== undefined ? (labels.doorStateId ?? null) : (c.report?.doorState ?? null), cabinPosition: labels.cabinPositionId !== undefined ? (labels.cabinPositionId ?? null) : (c.report?.cabinPosition ?? null), diff --git a/apps/web/src/api/schema.d.ts b/apps/web/src/api/schema.d.ts index da530c5..fcf0831 100644 --- a/apps/web/src/api/schema.d.ts +++ b/apps/web/src/api/schema.d.ts @@ -509,6 +509,23 @@ export interface paths { patch?: never; trace?: never; }; + "/assistant/transcribe": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage */ + post: operations["transcribeAudio"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/search": { parameters: { query?: never; @@ -1567,6 +1584,9 @@ export interface components { reportsIndexed: number; chunks: number; }; + TranscriptionResult: { + text: string; + }; SearchResponse: { workOrders: { /** Format: uuid */ @@ -3210,6 +3230,40 @@ export interface operations { }; }; }; + transcribeAudio: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Texte transcrit — à relire (D1) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TranscriptionResult"]; + }; + }; + /** @description Dictée non activée ou service IA indisponible */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; globalSearch: { parameters: { query: { diff --git a/docs/03-architecture/adr/ADR-004-modeles-ia.md b/docs/03-architecture/adr/ADR-004-modeles-ia.md index 2fc475c..48d90c5 100644 --- a/docs/03-architecture/adr/ADR-004-modeles-ia.md +++ b/docs/03-architecture/adr/ADR-004-modeles-ia.md @@ -51,6 +51,23 @@ réseau privé — la règle d'or ESLint d'ADR-001 concerne le code TypeScript de l'API, la topologie « MinIO jamais exposé » du runbook reste respectée. +5. **Transcription (dictée, R5 D5) : locale, opt-in, jamais de persistance de l'audio.** + Amendement du 22/07/2026 — le référent choisit `faster-whisper` (CTranslate2, CPU, + licence MIT) plutôt qu'une API externe, même philosophie que les embeddings : « open + source et local ». `AI_TRANSCRIPTION=off` par défaut (endpoint refuse proprement, + 503) ; `locale` charge le modèle (taille configurable par `AI_TRANSCRIPTION_MODEL`, + défaut `small`) ; `deterministe` en tests/CI (aucune dépendance audio). Le flux : + l'audio ne transite qu'une fois vers `siop2-ai` (multipart, jamais écrit sur disque + par `siop2-api`), un fichier temporaire le temps de l'inférence côté `siop2-ai`, + **supprimé aussitôt quoi qu'il arrive** — succès ou erreur (loi 09-08, D5). Seul le + texte transcrit revient, à relire par l'humain (D1) avant tout usage : suggestion de + bilan immédiate, et/ou sauvegarde dans `InterventionReport.note` — qui, à la clôture + de l'OT, rejoint le corpus comme les libellés codés (même pipeline d'anonymisation, + D4). **Point de vigilance non calibré** (décision explicite du référent : pas de + prototype de mesure préalable, contrairement au choix du modèle d'embeddings) : + Whisper transcrit mal le darija, probable en mélange avec le français sur le + terrain — à mesurer sur des enregistrements réels si la qualité déçoit en recette. + ## Conséquences - L'anonymisation (D4) s'applique **à l'ingestion** — les index ne contiennent jamais @@ -58,3 +75,11 @@ - Le mode extractif est le contrat de base : toute recette R5 doit passer SANS clé API. - Si le partenaire veut un jour une génération 100 % locale (llama.cpp…), seul le point 3 change — interface `Generateur` prévue pour ça. +- La dictée (point 5) est opt-in et sans mesure de qualité préalable : à recetter avec + attention avant toute promesse de qualité au client, en particulier en darija. +- Implémentée et vérifiée le 22/07/2026 : transcription réelle (français, voix de + synthèse) juste et rapide en local, bout en bout via l'API NestJS, et dans l'image + Docker de production construite pour l'occasion (modèle embarqué, aucun téléchargement + au démarrage). **Reste** : le test tactile sur iPhone physique (bouton dicter, permission + micro) n'a pas pu se jouer — blocage USB persistant malgré câble/port/redémarrage + multiples, reporté comme la recette Android (attend un accès matériel qui fonctionne). diff --git a/docs/06-production/runbook-dokploy.md b/docs/06-production/runbook-dokploy.md index cddb1a3..13ab5dc 100644 --- a/docs/06-production/runbook-dokploy.md +++ b/docs/06-production/runbook-dokploy.md @@ -48,6 +48,8 @@ domaine (Traefik/Dokploy) ──▶ siop2-web :80 (nginx, statique) | `AI_API_KEY` | — | clé API Anthropic — **uniquement** si `AI_GENERATION=api` ; le service refuse de démarrer si elle manque en mode api | | `AI_MODEL` | — | défaut `claude-opus-4-8` | | `AI_SEUIL_PERTINENCE` / `AI_SEUIL_SUGGESTION` / `AI_SEUIL_CONFIANCE_FORTE` | — | défauts 0,45 / 0,40 / 0,55 — **à calibrer sur le corpus client** (voir §7) | +| `AI_TRANSCRIPTION` | — | `off` (défaut) ou `locale` (dictée R5 D5, `faster-whisper` — le modèle est déjà dans l'image, l'activer ne demande pas de rebuild) | +| `AI_TRANSCRIPTION_MODEL` | — | défaut `small` — `tiny`/`base`/`small`/`medium`/`large-v3`, compromis qualité/vitesse CPU non calibré sur le darija (voir ADR-004 §5) | Profils types : @@ -125,6 +127,13 @@ version reste visible côté web). - Mode génératif : ajouter `AI_GENERATION=api` + `AI_API_KEY` dans l'environnement Dokploy puis redéployer le service `siop2-ai` seul. La clé ne transite jamais par le dépôt ni par les journaux (`/healthz` n'expose que le mode). +- Dictée (D5) : ajouter `AI_TRANSCRIPTION=locale` dans l'environnement Dokploy puis + redéployer `siop2-ai` — le modèle Whisper est déjà dans l'image (§ci-dessus), pas + de rebuild ni de téléchargement au boot. L'audio n'est **jamais** stocké, ni par + `siop2-api` ni par `siop2-ai` : transcrit à la volée, effacé aussitôt (voir + ADR-004 §5). Point de vigilance non calibré : la qualité en darija (fréquent sur + le terrain, mélangé au français) — à mesurer sur de vrais enregistrements si la + dictée déçoit en recette. ## 6. Calibrage des seuils sur le corpus client diff --git a/docs/journal/journal.md b/docs/journal/journal.md index 4b86c03..310e09e 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,50 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-22 — Pr. Daaif (+ Claude) — Dictée implémentée : audio → transcription locale → corpus (ADR-004 §5) + +**Actions** + +- Suite du feu vert « on passe à l'audio » (maquette Voix amendée le 22/07) : implémentation complète de la + dictée (R5 D5) — écran maquetté jamais construit jusqu'ici, ni open source ni local en LLM externe, choix du + référent réaffirmé (« toujours pour l'open source et le local »). +- **`apps/ai`** : `faster-whisper` (CTranslate2, CPU, MIT), `AI_TRANSCRIPTION=off|locale|deterministe` (défaut + off), `AI_TRANSCRIPTION_MODEL` (défaut `small`) ; endpoint `/internal/transcrire` — l'audio ne survit + JAMAIS à l'appel (fichier temporaire supprimé quoi qu'il arrive, succès ou erreur) ; `indexer_bilans` inclut + désormais `InterventionReport.note` (anonymisée par le même pipeline D4) — le champ existait en base et au + contrat depuis R2 mais **n'avait jamais eu d'écran** ; 29 pytest (dont le transcripteur déterministe pour CI). +- **Contrat** (77 opérations) : `POST /assistant/transcribe` (multipart, `TranscriptionResult`). +- **API NestJS** : proxy multipart vers `siop2-ai` (`AssistantService.transcribe`), `WORK_ORDERS.edit` — même + droit que la saisie du bilan qu'elle alimente ; 2 tests e2e ajoutés (80 tests API au total). +- **Mobile** : `expo-audio` (enregistrement) + `expo-file-system` (purge locale) ; carte « Décrire pour + suggérer » de l'écran de clôture gagne un bouton dicter/terminer, une confirmation de purge, et + « Joindre la description à l'OT » (sauvegarde dans `note` via la file existante — corrige au passage un bug + latent : `enfilerBilan` ignorait silencieusement toute mise à jour de `note`). +- **Docker** : `siop2-ai` embarque désormais aussi le modèle Whisper au build (image 1,54 Go → 2,19 Go) — + construit et vérifié réellement (transcription en conteneur, non-root, 0 téléchargement au démarrage). +- **Vérification réelle** (voix de synthèse macOS `say`, français) : transcription fidèle en direct + (`TranscripteurLocal`), bout en bout via l'API NestJS, et dans le conteneur Docker construit — puis chaîne + complète corpus confirmée : note sauvegardée → OT clôturé → réindexation → contenu retrouvé par recherche + sémantique avec un bon score de pertinence. +- **Nettoyage** : la base de dev locale, polluée par les tests manuels de la recette terrain (deux OT seedés + clôturés en dehors de leur état d'origine), a été réinitialisée avec l'accord explicite du référent + (`prisma migrate reset --force`, bloqué par défaut pour un agent IA — consentement demandé et obtenu avant + exécution). + +**Décisions** + +- Pas de prototype de mesure français/darija avant l'implémentation (confirmé une seconde fois) — seuls des + contrôles d'ingénierie de base (le code tourne, avec de la vraie parole) ont été faits, pas un calibrage. +- **Reste** : le test tactile sur iPhone physique (bouton dicter, permission micro) n'a pas pu se jouer — + blocage USB persistant malgré câble/port/redémarrage/mode développeur essayés à plusieurs reprises. Reporté + comme la recette Android, sur décision du référent — ne bloque pas la suite. + +**Prochaine étape** : test tactile de la dictée sur iPhone dès que la connexion USB fonctionnera ; recette +Android sur appareil physique ; redéploiement Dokploy (`AI_SERVICE_TOKEN`) ; calibrage `AI_SEUIL_*` et qualité +darija sur corpus SPELEV réel. + +--- + ## 2026-07-22 — Pr. Daaif (+ Claude) — Idée backlog : transcription audio → corpus (maquette amendée, pas codée) **Actions** diff --git a/docs/openapi.json b/docs/openapi.json index e271295..36dacbd 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1322,6 +1322,54 @@ } } }, + "/assistant/transcribe": { + "post": { + "operationId": "transcribeAudio", + "summary": "Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage", + "tags": [ + "assistant" + ], + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + } + }, + "required": [ + "file" + ] + } + } + } + }, + "responses": { + "200": { + "description": "Texte transcrit — à relire (D1)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TranscriptionResult" + } + } + } + }, + "503": { + "description": "Dictée non activée ou service IA indisponible" + } + } + } + }, "/search": { "get": { "operationId": "globalSearch", @@ -5427,6 +5475,19 @@ ], "additionalProperties": false }, + "TranscriptionResult": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "text": { + "type": "string" + } + }, + "required": [ + "text" + ], + "additionalProperties": false + }, "SearchResponse": { "$schema": "https://json-schema.org/draft/2020-12/schema", "type": "object", diff --git a/infra/docker-compose.dokploy.yml b/infra/docker-compose.dokploy.yml index 590d6e1..2de802d 100644 --- a/infra/docker-compose.dokploy.yml +++ b/infra/docker-compose.dokploy.yml @@ -78,6 +78,10 @@ services: AI_SEUIL_PERTINENCE: ${AI_SEUIL_PERTINENCE:-0.45} AI_SEUIL_SUGGESTION: ${AI_SEUIL_SUGGESTION:-0.40} AI_SEUIL_CONFIANCE_FORTE: ${AI_SEUIL_CONFIANCE_FORTE:-0.55} + # Dictée opt-in (R5 D5, ADR-004 §5) : off par défaut ; le modèle est déjà + # dans l'image (§ci-dessus), l'activer ne demande pas de rebuild. + AI_TRANSCRIPTION: ${AI_TRANSCRIPTION:-off} + AI_TRANSCRIPTION_MODEL: ${AI_TRANSCRIPTION_MODEL:-small} depends_on: siop2-postgres: condition: service_healthy diff --git a/packages/shared/src/contract.ts b/packages/shared/src/contract.ts index 804ebd3..750756a 100644 --- a/packages/shared/src/contract.ts +++ b/packages/shared/src/contract.ts @@ -57,6 +57,7 @@ import { BilanSuggestionsResponseSchema, ReindexResultSchema, SuggestBilanSchema, + TranscriptionResultSchema, } from './schemas/assistant'; import { ConsumePartSchema, @@ -564,6 +565,18 @@ export const API_CONTRACT: ApiOperation[] = [ 503: { description: 'Service IA indisponible' }, }, }, + { + operationId: 'transcribeAudio', + method: 'post', + path: '/assistant/transcribe', + summary: 'Dictée (R5 D5, opt-in) — l’audio est transcrit puis JAMAIS conservé, à relire avant tout usage', + tags: ['assistant'], + multipartFields: { file: 'file' }, + responses: { + 200: { description: 'Texte transcrit — à relire (D1)', name: 'TranscriptionResult', schema: TranscriptionResultSchema }, + 503: { description: 'Dictée non activée ou service IA indisponible' }, + }, + }, { operationId: 'globalSearch', method: 'get', diff --git a/packages/shared/src/schemas/assistant.ts b/packages/shared/src/schemas/assistant.ts index d4caa8f..a118c23 100644 --- a/packages/shared/src/schemas/assistant.ts +++ b/packages/shared/src/schemas/assistant.ts @@ -71,3 +71,11 @@ export const ReindexResultSchema = z.object({ chunks: z.number().int(), }); export type ReindexResult = z.infer; + +// ————— Dictée (R5 D5, opt-in) — l'audio ne transite qu'une fois, jamais stocké ————— + +export const TranscriptionResultSchema = z.object({ + /** À relire avant tout usage (D1) — jamais enregistré tel quel. */ + text: z.string(), +}); +export type TranscriptionResult = z.infer; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a07424f..04fbce4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -135,12 +135,18 @@ importers: expo: specifier: ~57.0.6 version: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-audio: + specifier: ~57.0.2 + version: 57.0.2(expo-asset@57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3))(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) expo-camera: specifier: ~57.0.3 version: 57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) expo-constants: specifier: ~57.0.5 version: 57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2)) + expo-file-system: + specifier: ~57.0.1 + version: 57.0.1(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2)) expo-font: specifier: ~57.0.1 version: 57.0.1(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3) @@ -3816,6 +3822,14 @@ packages: react: '*' react-native: '*' + expo-audio@57.0.2: + resolution: {integrity: sha512-KCXS0jUgJm+rAzafdHczjrvFdsSDmH+0RYAvTWG8LyT4s8ERk4P8tYHH9/ByOwiczsLVCVAMKz8nGKQyuT4pMQ==} + peerDependencies: + expo: '*' + expo-asset: '*' + react: '*' + react-native: '*' + expo-camera@57.0.3: resolution: {integrity: sha512-Q+3aZ63eQCkdB6/FZrO/lfacNAg/j8JCeKQL2nBdf6vBeOo1Y2PKYx1/vK+U5LaRnIo/0tMGmCOzZ1JGhTeMIw==} peerDependencies: @@ -10851,6 +10865,13 @@ snapshots: - supports-color - typescript + expo-audio@57.0.2(expo-asset@57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3))(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): + dependencies: + expo: 57.0.6(@babel/core@7.29.7)(@expo/dom-webview@57.0.1)(@expo/metro-runtime@57.0.5)(expo-router@57.0.6)(react-dom@19.2.3(react@19.2.3))(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native-worklets@0.10.2(@babel/core@7.29.7)(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(supports-color@10.2.2)(typescript@6.0.3) + expo-asset: 57.0.5(expo@57.0.6)(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3)(typescript@6.0.3) + react: 19.2.3 + react-native: 0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2) + expo-camera@57.0.3(@types/emscripten@1.41.5)(expo@57.0.6)(react-native-web@0.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react-native@0.86.0(@babel/core@7.29.7)(@react-native/jest-preset@0.86.0(@babel/core@7.29.7)(react@19.2.3))(@react-native/metro-config@0.86.0(@babel/core@7.29.7))(@types/react@19.2.17)(react@19.2.3)(supports-color@10.2.2))(react@19.2.3): dependencies: barcode-detector: 3.2.1(@types/emscripten@1.41.5)