feat(t1-live): examinateur avec interruption probabiliste pilotee backend (Sprint 7a)
- Session T1 Live : monologue candidat + interruptions pilotees backend (VAD manuel).
- Voix examinateur native Gemini ; le backend decide le timing (tirage probabiliste 0-2, fenetre [25s,75s]), Gemini formule la relance sur signal d'injection (anti-TD-22).
- Injection : activityEnd -> clientContent -> activityStart ; signaux WS interruption_start/end.
- Fin de session : activityEnd final flushe le dernier segment candidat ; relance terminale coupee (audio non renvoye, texte jete) ; seul le texte candidat conserve pour l'evaluation.
- buildT1SystemPrompt : nouvel artefact, regle 7 du T2 NON propagee (questions autorisees).
- Route /t1/live : auth Premium reutilisee, contexte questionnaire dynamique, persistance EO_T1 (sujet_id null), evaluation via correctEO('EO_T1'), phonologie stub /4 (TD-08 gele).
- geminiLive.ts : exports additifs + buildSetupFrame parametrable VAD (T2 inchange).
- gitignore : exclusion des artefacts jetables de test/spike.
This commit is contained in:
parent
5f7e52d88a
commit
868bd09397
7 changed files with 1404 additions and 17 deletions
487
src/lib/geminiLiveT1.ts
Normal file
487
src/lib/geminiLiveT1.ts
Normal file
|
|
@ -0,0 +1,487 @@
|
|||
/**
|
||||
* geminiLiveT1.ts — Sprint 7a (T1 EO Live, examinateur avec interruption
|
||||
* pilotée par le BACKEND).
|
||||
*
|
||||
* Ce module porte la spécificité T1 :
|
||||
* - buildT1SystemPrompt : le prompt système de l'examinateur ;
|
||||
* - openGeminiLiveT1Session : le proxy WS + l'horloge probabiliste qui décide
|
||||
* QUAND interrompre, et l'injection de la relance (clientContent).
|
||||
*
|
||||
* Les helpers WS bas niveau (parseAudioChunk, isEndSignal, tryParseGeminiJson,
|
||||
* reconstructTranscript) et le setup frame paramétrable (buildSetupFrame) sont
|
||||
* réutilisés depuis `geminiLive.ts` (exports additifs Sprint 7a).
|
||||
*
|
||||
* ⚠ Différence fondamentale avec T2 : en T1, l'examinateur DOIT poser des
|
||||
* questions pour relancer le candidat. La règle 7 du T2 (interdiction absolue
|
||||
* de poser des questions / bannissement du point d'interrogation) NE DOIT
|
||||
* JAMAIS être propagée ici. Cf. TD-22 / TD-23.
|
||||
*
|
||||
* MODÈLE 1 (acté) : c'est l'HORLOGE PROBABILISTE du backend qui décide seule du
|
||||
* timing des interruptions. Le backend NE lit PAS la transcription partielle
|
||||
* pour décider — Gemini formule la relance à partir de son contexte audio
|
||||
* interne. (Découverte spike : en VAD manuel, inputTranscription n'est flushé
|
||||
* qu'à l'envoi d'activityEnd, pas en continu.)
|
||||
*/
|
||||
|
||||
import { WebSocket as NodeWebSocket } from "ws";
|
||||
import type { PresentationReponses } from "../controllers/presentationController.js";
|
||||
import {
|
||||
GEMINI_LIVE_URL,
|
||||
buildSetupFrame,
|
||||
isEndSignal,
|
||||
parseAudioChunk,
|
||||
reconstructTranscript,
|
||||
tryParseGeminiJson,
|
||||
type TranscriptEntry,
|
||||
type WebSocketLike,
|
||||
} from "./geminiLive.js";
|
||||
|
||||
/**
|
||||
* Construit le prompt système T1 Live à partir des réponses du questionnaire
|
||||
* candidat (transmises dynamiquement — il n'existe pas de sujet T1 en base).
|
||||
*
|
||||
* Le prompt définit le RÔLE de l'examinateur : il reste silencieux par défaut
|
||||
* et ne prend la parole QUE lorsque le backend le lui signale (injection
|
||||
* `clientContent` au moment choisi par l'horloge probabiliste). C'est le
|
||||
* BACKEND qui décide du TIMING ; l'examinateur, lui, formule librement une
|
||||
* relance courte à partir de son contexte audio interne.
|
||||
*/
|
||||
export function buildT1SystemPrompt(input: {
|
||||
reponses: PresentationReponses;
|
||||
}): string {
|
||||
const { reponses } = input;
|
||||
return `RÔLE : Tu es un examinateur bienveillant de l'épreuve d'Expression Orale du TCF Canada (Tâche 1, entretien dirigé). Le candidat se présente en monologue : identité, parcours, situation familiale, loisirs, et projet d'immigration au Canada.
|
||||
|
||||
CONTEXTE DU CANDIDAT (pour formuler des relances pertinentes et personnalisées) :
|
||||
- Identité : ${reponses.prenom_age_ville}
|
||||
- Formation / métier : ${reponses.formation_metier}
|
||||
- Situation familiale : ${reponses.situation_familiale}
|
||||
- Loisirs : ${reponses.loisirs}
|
||||
- Projet Canada : ${reponses.motivation_canada}
|
||||
|
||||
RÈGLES :
|
||||
1. Tu parles TOUJOURS en français naturel et courant, niveau B2-C1, sur un ton bienveillant et professionnel.
|
||||
2. Tu RESTES SILENCIEUX par défaut. Tant que le candidat parle, tu n'interviens JAMAIS de ta propre initiative.
|
||||
3. Tu prends la parole UNIQUEMENT lorsqu'on te le signale, et alors UNIQUEMENT pour relancer le candidat par UNE question.
|
||||
4. Ta relance est COURTE : une seule question de 10 à 20 mots, liée à ce que le candidat vient de dire ou à son contexte ci-dessus.
|
||||
5. Tu PEUX et tu DOIS poser des questions : c'est le cœur de ton rôle d'examinateur en Tâche 1. Utilise le point d'interrogation normalement.
|
||||
6. Une seule question à la fois. Jamais de liste, jamais d'enchaînement de plusieurs questions dans la même prise de parole.
|
||||
7. Tu ne corriges JAMAIS les erreurs du candidat et tu ne commentes jamais sa langue, ses erreurs ou sa performance.
|
||||
8. Tu restes toujours dans ton rôle d'examinateur. Tu ne mentionnes jamais que tu es une IA ou un modèle.`;
|
||||
}
|
||||
|
||||
// ── Constantes nommées (PAS de nombres magiques) ────────────────────────────
|
||||
|
||||
/** Timeout total de la session T1 Live (filet de sécurité). */
|
||||
export const T1_SESSION_TIMEOUT_MS = 180_000;
|
||||
/** Warning client : 30 s avant le timeout. */
|
||||
export const T1_SESSION_WARNING_MS = 150_000;
|
||||
|
||||
/** Distribution du nombre d'interruptions tirées au début de session. */
|
||||
export const T1_INTERRUPTION_P0 = 0.2; // P(0 interruption)
|
||||
export const T1_INTERRUPTION_P1 = 0.6; // P(1 interruption)
|
||||
export const T1_INTERRUPTION_P2 = 0.2; // P(2 interruptions)
|
||||
|
||||
/** Fenêtre temporelle (depuis le début de session) où placer les interruptions. */
|
||||
export const T1_INTERRUPTION_WINDOW_START_MS = 25_000;
|
||||
export const T1_INTERRUPTION_WINDOW_END_MS = 75_000;
|
||||
/** Espacement minimal garanti entre deux interruptions. */
|
||||
export const T1_INTERRUPTION_MIN_SPACING_MS = 20_000;
|
||||
|
||||
/**
|
||||
* Délai d'attente, après l'activityEnd FINAL, pour laisser Gemini flusher la
|
||||
* transcription du dernier segment candidat avant de finaliser la session.
|
||||
*/
|
||||
export const T1_TERMINAL_FLUSH_GRACE_MS = 3_000;
|
||||
|
||||
/** MIME du flux audio candidat (PCM 16 kHz mono), identique au T2. */
|
||||
const T1_INPUT_AUDIO_MIME = "audio/pcm;rate=16000";
|
||||
|
||||
/** VAD MANUEL : c'est le backend qui borne les tours (activityStart/End). */
|
||||
const T1_MANUAL_VAD = { disabled: true } as const;
|
||||
|
||||
/** Consigne interne injectée pour déclencher une relance (jamais lue à voix haute). */
|
||||
const T1_RELANCE_INSTRUCTION =
|
||||
"[CONSIGNE INTERNE — ne pas répéter] Interromps maintenant le candidat avec UNE seule question de relance courte et pertinente, liée à ce qu'il vient de dire.";
|
||||
|
||||
const ACTIVITY_START_FRAME = JSON.stringify({
|
||||
realtimeInput: { activityStart: {} },
|
||||
});
|
||||
const ACTIVITY_END_FRAME = JSON.stringify({
|
||||
realtimeInput: { activityEnd: {} },
|
||||
});
|
||||
|
||||
function buildRelanceFrame(): string {
|
||||
return JSON.stringify({
|
||||
clientContent: {
|
||||
turns: [{ role: "user", parts: [{ text: T1_RELANCE_INSTRUCTION }] }],
|
||||
turnComplete: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Logique probabiliste (fonctions pures, testables avec random injecté) ────
|
||||
|
||||
/**
|
||||
* Tire le nombre d'interruptions de la session selon la distribution
|
||||
* P0/P1/P2. `random()` ∈ [0,1).
|
||||
*/
|
||||
export function drawT1InterruptionCount(random: () => number): 0 | 1 | 2 {
|
||||
const r = random();
|
||||
if (r < T1_INTERRUPTION_P0) return 0;
|
||||
if (r < T1_INTERRUPTION_P0 + T1_INTERRUPTION_P1) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Planifie les instants (offsets ms depuis le début de session) des
|
||||
* interruptions dans la fenêtre [START, END], avec un espacement minimal
|
||||
* garanti de MIN_SPACING entre deux interruptions.
|
||||
*/
|
||||
export function planT1InterruptionInstants(
|
||||
count: 0 | 1 | 2,
|
||||
random: () => number,
|
||||
): number[] {
|
||||
const start = T1_INTERRUPTION_WINDOW_START_MS;
|
||||
const end = T1_INTERRUPTION_WINDOW_END_MS;
|
||||
const spacing = T1_INTERRUPTION_MIN_SPACING_MS;
|
||||
|
||||
if (count === 0) return [];
|
||||
if (count === 1) {
|
||||
return [start + random() * (end - start)];
|
||||
}
|
||||
// count === 2 : premier dans [start, end - spacing], second au moins
|
||||
// `spacing` après le premier et au plus `end`.
|
||||
const first = start + random() * (end - spacing - start);
|
||||
const second = first + spacing + random() * (end - (first + spacing));
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
// ── Options de session ───────────────────────────────────────────────────────
|
||||
|
||||
export interface OpenGeminiLiveT1SessionOptions {
|
||||
/** Réponses du questionnaire candidat (contexte du prompt T1). */
|
||||
reponses: PresentationReponses;
|
||||
/** Callback de fin de session avec le transcript reconstruit. */
|
||||
onSessionEnd?: (transcript: string) => void | Promise<void>;
|
||||
/** Override timeout (défaut T1_SESSION_TIMEOUT_MS). */
|
||||
timeoutMs?: number;
|
||||
/** Override warning (défaut T1_SESSION_WARNING_MS). */
|
||||
warningMs?: number;
|
||||
/** Surcharge la clé API (défaut process.env.GEMINI_API_KEY). */
|
||||
apiKey?: string;
|
||||
/** Injection pour les tests — fabrique de WebSocket vers Gemini. */
|
||||
clientFactory?: (url: string) => WebSocketLike;
|
||||
/** Source d'aléa injectable (défaut Math.random) pour la testabilité. */
|
||||
random?: () => number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ouvre une session T1 Live : proxy WS bidirectionnel client ⇄ Gemini en VAD
|
||||
* MANUEL, avec interruption(s) injectée(s) au(x) instant(s) tiré(s) par
|
||||
* l'horloge probabiliste.
|
||||
*
|
||||
* Contrat WS côté client (figé — la suite du sprint 7b en dépend) :
|
||||
* - {type:'interruption_start'} : l'examinateur prend la parole ;
|
||||
* - {type:'interruption_end'} : le candidat peut reprendre.
|
||||
*
|
||||
* Séquence d'une interruption (Modèle 1) :
|
||||
* activityEnd → clientContent(relance, turnComplete) → (turnComplete Gemini)
|
||||
* → activityStart.
|
||||
*
|
||||
* FIN DE SESSION : on envoie un activityEnd FINAL pour flusher le dernier
|
||||
* segment candidat (sinon perdu — la transcription n'est flushée qu'à
|
||||
* activityEnd en VAD manuel). Cet activityEnd déclenche AUSSI une relance
|
||||
* examinateur « terminale » : on la SUPPRIME (audio non forwardé au client,
|
||||
* texte jeté). Cf. point de vigilance dans le handler de message.
|
||||
*/
|
||||
export function openGeminiLiveT1Session(
|
||||
clientWs: WebSocketLike,
|
||||
opts: OpenGeminiLiveT1SessionOptions,
|
||||
): void {
|
||||
const apiKey = opts.apiKey ?? process.env.GEMINI_API_KEY;
|
||||
if (!apiKey) {
|
||||
clientWs.close(4005, "GEMINI_CONFIG");
|
||||
return;
|
||||
}
|
||||
|
||||
const timeoutMs = opts.timeoutMs ?? T1_SESSION_TIMEOUT_MS;
|
||||
const warningMs = opts.warningMs ?? T1_SESSION_WARNING_MS;
|
||||
const random = opts.random ?? Math.random;
|
||||
const systemPrompt = buildT1SystemPrompt({ reponses: opts.reponses });
|
||||
|
||||
const url = `${GEMINI_LIVE_URL}?key=${apiKey}`;
|
||||
const factory =
|
||||
opts.clientFactory ??
|
||||
((u: string) => new NodeWebSocket(u) as unknown as WebSocketLike);
|
||||
|
||||
const geminiWs = factory(url);
|
||||
|
||||
const entries: TranscriptEntry[] = [];
|
||||
|
||||
// ── État ──
|
||||
let started = false; // startSession() exécuté une seule fois
|
||||
let sessionEnded = false; // endSession() entamé (idempotence)
|
||||
let finalized = false; // finalize() exécuté une seule fois
|
||||
let candidateTurnOpen = false; // un tour candidat est ouvert côté Gemini
|
||||
let injecting = false; // une interruption est en cours
|
||||
let awaitingRelance = false; // on attend le turnComplete de la relance
|
||||
// terminalFlush : on a envoyé l'activityEnd FINAL. À partir de là, l'audio et
|
||||
// le texte de l'examinateur (relance terminale) sont SUPPRIMÉS ; seule la
|
||||
// transcription candidat reste collectée.
|
||||
let terminalFlush = false;
|
||||
|
||||
const interruptionTimers: ReturnType<typeof setTimeout>[] = [];
|
||||
let warningTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let finalizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const clearTimers = () => {
|
||||
for (const t of interruptionTimers) clearTimeout(t);
|
||||
interruptionTimers.length = 0;
|
||||
if (warningTimer !== null) {
|
||||
clearTimeout(warningTimer);
|
||||
warningTimer = null;
|
||||
}
|
||||
if (timeoutTimer !== null) {
|
||||
clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const geminiSend = (frame: string) => {
|
||||
try {
|
||||
geminiWs.send(frame);
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[T1] Gemini send failed:",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
void endSession();
|
||||
}
|
||||
};
|
||||
|
||||
const clientSend = (obj: unknown) => {
|
||||
try {
|
||||
clientWs.send(JSON.stringify(obj));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
// ── Injection d'une interruption ──
|
||||
const doInterruption = () => {
|
||||
if (sessionEnded || terminalFlush || injecting || !candidateTurnOpen)
|
||||
return;
|
||||
injecting = true;
|
||||
awaitingRelance = true;
|
||||
candidateTurnOpen = false;
|
||||
clientSend({ type: "interruption_start" });
|
||||
geminiSend(ACTIVITY_END_FRAME);
|
||||
geminiSend(buildRelanceFrame());
|
||||
};
|
||||
|
||||
const resumeAfterInjection = () => {
|
||||
awaitingRelance = false;
|
||||
injecting = false;
|
||||
geminiSend(ACTIVITY_START_FRAME);
|
||||
candidateTurnOpen = true;
|
||||
clientSend({ type: "interruption_end" });
|
||||
};
|
||||
|
||||
// ── Démarrage (sur setupComplete) ──
|
||||
const startSession = () => {
|
||||
if (started) return;
|
||||
started = true;
|
||||
|
||||
// Ouvre le premier tour candidat.
|
||||
geminiSend(ACTIVITY_START_FRAME);
|
||||
candidateTurnOpen = true;
|
||||
|
||||
// Tire et planifie les interruptions.
|
||||
const count = drawT1InterruptionCount(random);
|
||||
const instants = planT1InterruptionInstants(count, random);
|
||||
for (const offset of instants) {
|
||||
interruptionTimers.push(setTimeout(() => doInterruption(), offset));
|
||||
}
|
||||
|
||||
warningTimer = setTimeout(() => {
|
||||
if (sessionEnded) return;
|
||||
clientSend({ type: "warning", message: "30 secondes restantes" });
|
||||
}, warningMs);
|
||||
|
||||
timeoutTimer = setTimeout(() => {
|
||||
void endSession();
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
const finalize = async () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
try {
|
||||
geminiWs.close(1000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
if (opts.onSessionEnd) {
|
||||
try {
|
||||
await opts.onSessionEnd(reconstructTranscript(entries));
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"[T1] onSessionEnd threw:",
|
||||
err instanceof Error ? err.message : String(err),
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// endSession est idempotent : double signal end → un seul flush + finalize.
|
||||
async function endSession() {
|
||||
if (sessionEnded) return;
|
||||
sessionEnded = true;
|
||||
clearTimers();
|
||||
terminalFlush = true;
|
||||
// Flush du dernier segment candidat : indispensable car en VAD manuel la
|
||||
// transcription candidat n'est émise qu'à l'activityEnd.
|
||||
if (candidateTurnOpen) {
|
||||
geminiSend(ACTIVITY_END_FRAME);
|
||||
candidateTurnOpen = false;
|
||||
}
|
||||
// Laisse à Gemini le temps d'émettre l'inputTranscription flushée, puis
|
||||
// finalise (la relance terminale éventuelle est ignorée — cf. handler).
|
||||
finalizeTimer = setTimeout(
|
||||
() => void finalize(),
|
||||
T1_TERMINAL_FLUSH_GRACE_MS,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Gemini → client ──
|
||||
geminiWs.on("open", () => {
|
||||
geminiSend(buildSetupFrame(systemPrompt, T1_MANUAL_VAD));
|
||||
});
|
||||
|
||||
geminiWs.on("message", (data) => {
|
||||
const parsed = tryParseGeminiJson(data);
|
||||
|
||||
if (parsed?.setupComplete) {
|
||||
startSession();
|
||||
}
|
||||
|
||||
if (parsed) {
|
||||
const sc = parsed.serverContent;
|
||||
|
||||
// POINT DE VIGILANCE — séparation "audio relance terminale à couper" vs
|
||||
// "texte candidat final à garder" quand ils arrivent dans le MÊME
|
||||
// message Gemini : on traite CHAMP PAR CHAMP, pas message par message.
|
||||
// - serverContent.inputTranscription.text = CANDIDAT → toujours gardé,
|
||||
// y compris pendant le flush terminal (c'est précisément ce qu'on veut
|
||||
// récupérer).
|
||||
// - serverContent.outputTranscription.text = EXAMINATEUR → ignoré
|
||||
// pendant le flush terminal (relance terminale jetée).
|
||||
// - serverContent.modelTurn.*.inlineData = audio EXAMINATEUR → non
|
||||
// forwardé au client pendant le flush terminal (cf. plus bas).
|
||||
if (sc?.inputTranscription?.text) {
|
||||
entries.push({ speaker: "candidat", text: sc.inputTranscription.text });
|
||||
}
|
||||
if (!terminalFlush && sc?.outputTranscription?.text) {
|
||||
entries.push({
|
||||
speaker: "examinateur",
|
||||
text: sc.outputTranscription.text,
|
||||
});
|
||||
}
|
||||
|
||||
// Reprise candidat après la relance (jamais pendant le flush terminal :
|
||||
// on ne rouvre pas de tour, la session se termine).
|
||||
if (sc?.turnComplete && injecting && awaitingRelance && !terminalFlush) {
|
||||
resumeAfterInjection();
|
||||
}
|
||||
}
|
||||
|
||||
// Forward verbatim au client SAUF pendant le flush terminal : ainsi l'audio
|
||||
// de la relance terminale (modelTurn inlineData) n'est jamais entendu par
|
||||
// le candidat.
|
||||
if (!terminalFlush) {
|
||||
try {
|
||||
clientWs.send(data);
|
||||
} catch {
|
||||
void endSession();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
geminiWs.on("close", () => {
|
||||
if (!sessionEnded) {
|
||||
clearTimers();
|
||||
sessionEnded = true;
|
||||
try {
|
||||
clientWs.close(4006, "GEMINI_DISCONNECTED");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
geminiWs.on("error", () => {
|
||||
if (!sessionEnded) {
|
||||
clearTimers();
|
||||
sessionEnded = true;
|
||||
try {
|
||||
clientWs.close(4006, "GEMINI_DISCONNECTED");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// ── Client → Gemini ──
|
||||
clientWs.on("message", (data) => {
|
||||
if (isEndSignal(data)) {
|
||||
void endSession();
|
||||
return;
|
||||
}
|
||||
const audioBase64 = parseAudioChunk(data);
|
||||
if (
|
||||
audioBase64 !== null &&
|
||||
!sessionEnded &&
|
||||
candidateTurnOpen &&
|
||||
!injecting
|
||||
) {
|
||||
geminiSend(
|
||||
JSON.stringify({
|
||||
realtimeInput: {
|
||||
audio: { data: audioBase64, mimeType: T1_INPUT_AUDIO_MIME },
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
// Tout autre message client est ignoré.
|
||||
});
|
||||
|
||||
clientWs.on("close", () => {
|
||||
clearTimers();
|
||||
if (finalizeTimer !== null) {
|
||||
clearTimeout(finalizeTimer);
|
||||
finalizeTimer = null;
|
||||
}
|
||||
sessionEnded = true;
|
||||
try {
|
||||
geminiWs.close(1000);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
|
||||
clientWs.on("error", () => {
|
||||
clearTimers();
|
||||
if (finalizeTimer !== null) {
|
||||
clearTimeout(finalizeTimer);
|
||||
finalizeTimer = null;
|
||||
}
|
||||
sessionEnded = true;
|
||||
try {
|
||||
geminiWs.close(1011);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue