diff --git a/backend/src/routes/sessions.ts b/backend/src/routes/sessions.ts index a9f3a224..aff39763 100644 --- a/backend/src/routes/sessions.ts +++ b/backend/src/routes/sessions.ts @@ -509,12 +509,15 @@ export function sessionRoutes( // registry snapshot. The legacy fallback stays available for sessions created before // the browser-local preference migration. let id: string; + let sessionLanguage = language; try { - ({ id } = await runner.sessionNew({ + const created = await runner.sessionNew({ question: b.question, name: b.name, workspaceConfigPath, workspaceId, workspaceRevision, provider, model, thinking, interactionLanguage: language, - })); + }); + id = created.id; + sessionLanguage = created.interaction_language ?? language; manifestPersisted = true; if (revisionLease) { await revisionLease.markPersisted().catch((error: unknown) => { @@ -527,7 +530,7 @@ export function sessionRoutes( } catch { return storageFailure(reply); } const options = { provider, model, thinking, - interactionLanguage: language, + interactionLanguage: sessionLanguage, author: principal.displayName ?? principal.subject, principal, question: b.question, diff --git a/backend/src/tht/tht-runner.ts b/backend/src/tht/tht-runner.ts index 8f5f7851..f1c997be 100644 --- a/backend/src/tht/tht-runner.ts +++ b/backend/src/tht/tht-runner.ts @@ -507,7 +507,7 @@ export class ThtRunner { if (v) a.push(f, v); } a.push("--json"); - return this.json<{ id: string }>(a, o.workspaceConfigPath ?? o.workspace); + return this.json<{ id: string; interaction_language?: string }>(a, o.workspaceConfigPath ?? o.workspace); } /** Build and persist the deterministic F1 retrieval pack for a new session. */ diff --git a/backend/test/routes-sessions.test.ts b/backend/test/routes-sessions.test.ts index a24f882e..996ba969 100644 --- a/backend/test/routes-sessions.test.ts +++ b/backend/test/routes-sessions.test.ts @@ -140,6 +140,33 @@ test("resume rejects browser interaction language overrides", async () => { } finally { await app.close(); } }); +test("new session starts Pi with the question language persisted by the harness", async () => { + let runtime: any; + const app = buildApp(loadConfig({ THT_HARNESS_DIR: "../harness" }), { + thtRunner: { + sessionNew: async () => ({ id: "english-question", interaction_language: "en" }), + searchPack: async () => {}, + }, + readiness: { ensure: async () => ({ ok: true }) }, + mgr: { + get: () => undefined, + createFor: (_id: string, options: any) => { + runtime = options; + return { bridge: { onClientEvent: () => {} } }; + }, + configure: async () => {}, start: () => {}, + }, + getSettings: () => ({ workspace: "default" }), + }); + try { + const response = await app.inject({ method: "POST", url: "/sessions", payload: { + question: "How many patients were admitted last year?", interactionLanguage: "it", + } }); + expect(response.statusCode).toBe(200); + expect(runtime.interactionLanguage).toBe("en"); + } finally { await app.close(); } +}); + test("resume pins legacy interaction language using the resolved harness workspace", async () => { let pinnedWorkspace: string | undefined; let runtime: any; diff --git a/docs/architecture/application-shell.md b/docs/architecture/application-shell.md index 66cbe8eb..4fb21522 100644 --- a/docs/architecture/application-shell.md +++ b/docs/architecture/application-shell.md @@ -96,10 +96,15 @@ deve invece contenere `mode` e `defaultLocale`, altrimenti viene rifiutato. ## Lingua, continuità e dati La lingua UI traduce il testo dell'applicazione, non i contenuti di dominio. -Alla creazione, la lingua UI viene acquisita come `interactionLanguage`; il -manifest salva `interaction_language`, che governa domande e scelte del modello. +Alla creazione, la lingua UI viene acquisita come `interactionLanguage` di ripiego. +Il CLI riconosce la lingua della domanda originale e salva `interaction_language` +nel manifest; usa il ripiego solo per input troppo brevi, ambigui o composti da codice. +Questa lingua governa domande, spiegazioni, scelte e controlli HITL. Il gate la +include nei descrittori e il frontend la applica al sottoalbero dei widget, +senza cambiare la lingua della navigazione. Alla ripresa vale la lingua salvata, non l'ultima scelta dell'header. Per i manifest -precedenti senza campo viene fissata la lingua workspace disponibile alla prima ripresa. +precedenti senza campo viene riconosciuta e fissata la lingua della domanda, +con la lingua workspace disponibile alla prima ripresa come ripiego. Il cambio lingua Omics invia il form Django e ricarica la pagina. ThothII conserva solo l'ID della selezione in `sessionStorage`, separato per pathname, issuer e diff --git a/docs/operations/shell-and-localization.md b/docs/operations/shell-and-localization.md index 880ab1c6..4ed21e65 100644 --- a/docs/operations/shell-and-localization.md +++ b/docs/operations/shell-and-localization.md @@ -184,19 +184,22 @@ Ci sono tre scelte distinte: | Scelta | Dove viene conservata | Cosa influenza | | --- | --- | --- | -| Lingua UI | preferenza full o stato Omics | label, form, messaggi e controlli | -| Lingua di interazione | `interaction_language` nel manifest | nuove domande, spiegazioni e scelte del modello | +| Lingua UI | preferenza full o stato Omics | navigazione, amministrazione e messaggi generali | +| Lingua di interazione | `interaction_language` nel manifest | domande, spiegazioni, scelte e controlli HITL | | Lingua workspace | configurazione del workspace | documenti, descrizioni e contenuti di dominio | La creazione web acquisisce la lingua UI prima delle operazioni asincrone e la -invia come `interactionLanguage`. Un cambio successivo non modifica quella -richiesta. La ripresa legge il manifest e non usa il locale del browser come +invia come `interactionLanguage` di ripiego. Il CLI riconosce localmente la lingua +della domanda originale e la fissa nel manifest; usa il ripiego per testo troppo +breve, ambiguo o composto soltanto da codice. Un cambio successivo non modifica +quella richiesta. Il gate trasmette la lingua nei widget: anche i controlli HITL +seguono la sessione, mentre la navigazione conserva la lingua UI. +La ripresa legge il manifest e non usa il locale del browser come override. SQL, identificatori, valori e citazioni dei contenuti rimangono invariati. Per sessioni precedenti senza `interaction_language`, la prima ripresa fissa la -lingua del workspace in modo idempotente. Se il workspace era stato modificato -nel frattempo, non esiste una registrazione da cui ricostruire con certezza la -vecchia lingua: il criterio di compatibilità è quella disponibile alla ripresa. +lingua della domanda in modo idempotente, usando la lingua del workspace come +ripiego. Le sessioni che hanno già una lingua fissata la conservano. Il cambio lingua di Omics ricarica la pagina. ThothII ricorda soltanto l'identificatore della sessione per utente e pagina, senza salvare una trascrizione nel browser. diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index df4b742d..8db881ce 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -31,6 +31,7 @@ export interface SchemaTable { export interface WidgetDescriptor { id: string; + interaction_language?: string; schema_version?: number; session_id?: string; phase?: string; diff --git a/frontend/src/i18n/index.tsx b/frontend/src/i18n/index.tsx index 464466a1..56d8d3d7 100644 --- a/frontend/src/i18n/index.tsx +++ b/frontend/src/i18n/index.tsx @@ -1,4 +1,4 @@ -import { useCallback, useSyncExternalStore } from "react"; +import { createContext, useCallback, useContext, useSyncExternalStore, type ReactNode } from "react"; import { itCore } from "./locales/it-core"; import { itAdmin } from "./locales/it-admin"; import { itWorkflow } from "./locales/it-workflow"; @@ -63,8 +63,18 @@ export function translate(message: string, params?: TranslationParams): string { return translateIn(locale, message, params); } +const InteractionLocale = createContext(undefined); + +/** Scope HITL controls to the session language without changing application navigation. */ +export function InteractionLanguage({ language, children }: { language?: string; children: ReactNode }) { + return + {children} + ; +} + export function useI18n() { - const current = useSyncExternalStore(subscribe, getLocale, () => "en"); + const uiLocale = useSyncExternalStore(subscribe, getLocale, () => "en"); + const current = useContext(InteractionLocale) ?? uiLocale; const t = useCallback( (message: string, params?: TranslationParams) => translateIn(current, message, params), [current], diff --git a/frontend/src/shell/AppShell.session-mgmt.test.tsx b/frontend/src/shell/AppShell.session-mgmt.test.tsx index 2b65cc6f..90048af8 100644 --- a/frontend/src/shell/AppShell.session-mgmt.test.tsx +++ b/frontend/src/shell/AppShell.session-mgmt.test.tsx @@ -1213,7 +1213,7 @@ test("closing and reopening Model activity preserves the complete activity log", expect(useSessionStore.getState().activityLog).toEqual(beforeClose); }); -test("session finalization shows the completion banner and returns to landing", async () => { +test("a new question after finalization restores the landing and administration navigation", async () => { useSessionStore.getState().resetSession(); let finalized = false; server.use( @@ -1221,7 +1221,7 @@ test("session finalization shows the completion banner and returns to landing", http.get("/api/sessions", () => HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)), ); - wrap(); + wrap(adminUser); await userEvent.click(await screen.findByText("Attiva uno")); await userEvent.click(await screen.findByRole("button", { name: /resume/i })); // The final turn ends: the session is now finalized on disk and agent_end @@ -1233,9 +1233,18 @@ test("session finalization shows the completion banner and returns to landing", }); const cta = await screen.findByRole("button", { name: /start a new question/i }); expect(screen.getByText(/session completed and finalized/i)).toBeInTheDocument(); + await userEvent.click(screen.getByRole("button", { name: /show model activity/i })); + expect(screen.getByRole("heading", { name: "Model activity" })).toBeVisible(); + expect(screen.queryByRole("complementary", { name: "Session navigation" })).not.toBeInTheDocument(); await userEvent.click(cta); // Landing state: the composer invites a brand-new question. expect(await screen.findByText(/type your question/i)).toBeInTheDocument(); + expect(screen.queryByRole("heading", { name: "Model activity" })).not.toBeInTheDocument(); + expect(screen.queryByText(/session completed and finalized/i)).not.toBeInTheDocument(); + const navigation = screen.getByRole("complementary", { name: "Session navigation" }); + expect(within(navigation).getByRole("button", { name: "Administration" })).toBeVisible(); + expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue(""); + expect(useSessionStore.getState().activityLog).toEqual([]); }); test("renaming a group reassigns its members via setSessionGroup", async () => { diff --git a/frontend/src/shell/AppShell.tsx b/frontend/src/shell/AppShell.tsx index 2a7409a4..164991ca 100644 --- a/frontend/src/shell/AppShell.tsx +++ b/frontend/src/shell/AppShell.tsx @@ -728,9 +728,9 @@ export function AppShell({ canLogout }: AppShellProps) { invalidateResumeIntent(); newSessionOperationRef.current = null; resetSession(); - // Starting a new question closes any open session detail panel: the reader is - // moving away from that session, so its left-hand box must not linger. + // Restore the landing layout, including the right-hand administration rail. setPanelSession(null); + setShowActivity(false); setAwaitingQuestion(true); setCreatingSession(false); selectActiveSession(null); diff --git a/frontend/src/shell/WidgetHost.i18n.test.tsx b/frontend/src/shell/WidgetHost.i18n.test.tsx index edcc7029..d8e50f66 100644 --- a/frontend/src/shell/WidgetHost.i18n.test.tsx +++ b/frontend/src/shell/WidgetHost.i18n.test.tsx @@ -23,3 +23,23 @@ test("gate chrome follows UI locale while model questions and choices remain in expect(screen.getByRole("button", { name: "Salva e procedi" })).toBeInTheDocument(); expect(useSessionStore.getState().pendingWidget?.options?.[0].label).toBe("Salva e procedi"); }); + +test.each([ + ["en", "it", "Save and proceed", "Other — specify"], + ["it", "en", "Salva e procedi", "Altro: specifica"], +])("HITL controls follow question language %s even when the UI locale changes", (language, uiLocale, save, other) => { + setLocale(uiLocale); + useSessionStore.setState({ pendingWidget: { + id: "language-gate", widget: "select", interaction_language: language, + title: "Which admission date should be used?", + options: [{ id: "approve", label: "Save and proceed", label_i18n: "Save and proceed" }], + reserved: ["back", "exit", "other"], + } }); + render(); + expect(screen.getByRole("button", { name: save })).toBeVisible(); + expect(screen.getByRole("button", { name: other })).toBeVisible(); + act(() => setLocale(language)); + act(() => setLocale(uiLocale)); + expect(screen.getByRole("button", { name: other })).toBeVisible(); + expect(screen.getByText("Which admission date should be used?")).toBeVisible(); +}); diff --git a/frontend/src/shell/WidgetHost.tsx b/frontend/src/shell/WidgetHost.tsx index 1bd0b0a0..c1417350 100644 --- a/frontend/src/shell/WidgetHost.tsx +++ b/frontend/src/shell/WidgetHost.tsx @@ -1,4 +1,4 @@ -import { useI18n } from "../i18n"; +import { InteractionLanguage, useI18n } from "../i18n"; import { useEffect, useRef, useState } from "react"; import { useSessionStore } from "../store/sessionStore"; import { resolve } from "../widgets"; @@ -9,6 +9,13 @@ import { captureAuthOperation, isAuthOperationCurrent } from "../auth/authOperat import { localizeWidget } from "../widgets/localize"; export function WidgetHost({ sessionId }: { sessionId: string | null }) { + const language = useSessionStore((s) => s.pendingWidget?.interaction_language); + return + + ; +} + +function SessionWidgetHost({ sessionId }: { sessionId: string | null }) { const { t: translate } = useI18n(); const source = useSessionStore((s) => s.pendingWidget); const pending = source ? localizeWidget(source, translate) : null; diff --git a/harness/.pi/extensions/gate/__tests__/gate_interaction_language.test.js b/harness/.pi/extensions/gate/__tests__/gate_interaction_language.test.js index e1e10d3f..577fca2d 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_interaction_language.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_interaction_language.test.js @@ -43,6 +43,8 @@ for (const [language, workspaceLanguage, entry, title, yes, no] of [ assert.ok(injected.systemPrompt.includes(`interaction_language=${language}`)); assert.ok(injected.systemPrompt.includes(`workspace_language=${workspaceLanguage}`)); assert.match(injected.systemPrompt, /reviewer questions, explanations, and choices/); + const name = new Intl.DisplayNames(["en"], { type: "language" }).of(language); + assert.ok(injected.systemPrompt.includes(`artifact explanation in ${name}`)); } let widget; ctx.ui.input = async title => { @@ -51,10 +53,19 @@ for (const [language, workspaceLanguage, entry, title, yes, no] of [ }; await tools.get("reviewer_datamart").def.execute("call", { session: "s1" }, null, null, ctx); assert.equal(widget.title, title); + assert.equal(widget.interaction_language, language); assert.equal(widget.title_i18n, "Generate a datamart?"); assert.deepEqual(widget.options.map(o => [o.id, o.label]), [ ["generate", yes], ["skip", no], ]); + // Pi may supply a new context object for a tool; language must come from + // the manifest even for a model-authored select, not just generated chrome. + await tools.get("reviewer_select").def.execute("select", { + session: "s1", title: "Choose the admission date", + options: [{ id: "skip", label: "First admission" }], + }, null, null, { ...ctx }); + assert.equal(widget.interaction_language, language); + assert.equal(widget.title, "Choose the admission date"); } finally { cp.execFileSync = previous.exec; for (const [key, value] of [["THT_SESSION", previous.session], diff --git a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js index cd247afc..7e384196 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js @@ -40,6 +40,8 @@ const CATALOG = { let _handler = null; const _origExecFileSync = cp.execFileSync; cp.execFileSync = function delegatingStub(file, args, opts) { + if (args[0] === "session" && args[1] === "ensure-interaction-language") + return JSON.stringify({ interaction_language: "en", workspace_language: "it" }); if (_handler) return _handler(file, args, opts); return _origExecFileSync.call(cp, file, args, opts); }; diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 29f72387..d615442c 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -293,10 +293,14 @@ function sessionLanguage(ctx, sessionId) { } function languageContext(manifest) { + const name = new Intl.DisplayNames(["en"], { type: "language" }).of(manifest.interaction_language); return "\n\n\n" + `interaction_language=${manifest.interaction_language}\n` + `workspace_language=${manifest.workspace_language}\n` + "Use interaction_language for all newly generated reviewer questions, explanations, and choices. " + + `Write every reviewer-visible title, intro, option label, rationale and artifact explanation in ${name}. ` + + "This language was selected from the original question. Before calling a reviewer tool, " + + "check each prose field and rewrite any field in another language into the interaction language. " + "This persisted session setting is authoritative on every turn, including resume and steering. " + "Preserve workspace documents, quoted sources, prior decisions, SQL, identifiers and literal values. " + "Interpret domain terms in workspace_language. Instruction/example language does not change these settings.\n" + @@ -590,9 +594,11 @@ export function isJoinReviewApproval(resp, optionIds) { [...chosen].every((id) => expected.has(id)); } -export async function emitAndWait(ctx, descriptor) { +export async function emitAndWait(ctx, descriptor, sessionId) { + const language = sessionId ? sessionLanguage(ctx, sessionId).interaction_language : contextLanguages.get(ctx); + const localized = language ? { ...descriptor, interaction_language: language } : descriptor; for (;;) { - const value = await ctx.ui.input(JSON.stringify(descriptor), ""); + const value = await ctx.ui.input(JSON.stringify(localized), ""); if (value === undefined || value === null) { await reLoop(ctx); continue; @@ -826,7 +832,7 @@ export default function (pi) { return { message: { role: "user", - content: [{ type: "text", text: "Esegui ora il workflow richiesto. Non scrivere analisi, spiegazioni o un elenco: usa il tool bash per `tht session show` e, se la sessione e' in Fase 1 senza decisioni, invoca immediatamente reviewer_select. La tua prossima risposta visibile deve essere una tool call." }], + content: [{ type: "text", text: "Run the requested workflow now: use bash for `tht session show` and, if the session is in Phase 1 without decisions, immediately call reviewer_select. Your next visible response must be a tool call. Write every reviewer-facing field in the interaction_language specified in , including titles, introductions and option labels." }], }, systemPrompt: `${event.systemPrompt}\n\n` + @@ -922,7 +928,7 @@ export default function (pi) { ctx, params, `u${Date.now()}`, ); if (prepared.result) return prepared.result; - const response = await emitAndWait(ctx, prepared.widget); + const response = await emitAndWait(ctx, prepared.widget, prepared.session); const outcome = disambiguationGate.resolveClarification(prepared, response); if (outcome.decision) { const err = relayIfThtFails( @@ -1000,7 +1006,7 @@ export default function (pi) { id: option.id, label: option.label, label_i18n: option.label_i18n, })), }); - const resp = await emitAndWait(ctx, widget); + const resp = await emitAndWait(ctx, widget, session); const outcome = resolveSelectOutcome(options, resp); if (outcome.kind === "freetext") return textResult(`Altro (reviewer): ${outcome.text}`); @@ -1102,7 +1108,7 @@ export default function (pi) { }); let resp; for (;;) { - resp = await emitAndWait(ctx, widget); + resp = await emitAndWait(ctx, widget, session); if (resp.control === "freetext" || resp.control === "back" || resp.control === "exit") break; if (!joinOnly || isJoinReviewApproval(resp, meritOptions.map((option) => option.id))) @@ -1243,7 +1249,7 @@ export default function (pi) { title, tables: enriched, }); - const resp = await emitAndWait(ctx, widget); + const resp = await emitAndWait(ctx, widget, session); if (resp.control === "back") return textResult("Il reviewer vuole tornare indietro."); if (resp.control === "exit") return textResult("Il reviewer vuole uscire."); if (resp.control === "freetext") @@ -1494,7 +1500,7 @@ export default function (pi) { }); let outcome; for (;;) { - const resp = await emitAndWait(ctx, widget); + const resp = await emitAndWait(ctx, widget, session); outcome = resolveConfirmOutcome(resp); if (outcome.kind !== "unknown") break; await ctx.ui.notify(gateText(locale, "chooseApproval"), "warning"); diff --git a/harness/.pi/prompts/nuova-domanda.md b/harness/.pi/prompts/nuova-domanda.md index 6db7f0ee..9287504c 100644 --- a/harness/.pi/prompts/nuova-domanda.md +++ b/harness/.pi/prompts/nuova-domanda.md @@ -6,4 +6,5 @@ Nuova domanda ThothII: "$@" Use the session manifest's interaction_language for reviewer dialogue. Workspace content and SQL retain their original language and values. A managed session already -has its language pinned; standalone `tht session new` defaults to workspace language. +has its language pinned from the question; standalone `tht session new` also detects +the question's language, using workspace language only for ambiguous input. diff --git a/harness/.pi/skills/tht-sessione/SKILL.md b/harness/.pi/skills/tht-sessione/SKILL.md index c82fa298..2b4fff3a 100644 --- a/harness/.pi/skills/tht-sessione/SKILL.md +++ b/harness/.pi/skills/tht-sessione/SKILL.md @@ -17,19 +17,25 @@ confirmation and is persisted directly — an option without a payload only asks Continue records the complete join set), `reviewer_confirm` (gate on an artifact / phase transition). Free text arrives via the "Altro/Other" option or by prefixing `!` in chat. -**Language contract:** the session manifest's `interaction_language` controls all +**Language contract:** session creation detects the original question's language and +pins it as `interaction_language` (the UI/CLI preference is only a fallback for +short or ambiguous input). The session manifest's `interaction_language` controls all new reviewer questions, explanations, option labels and rationales, including prose you generate inside review artifacts. It remains authoritative throughout the session, including resume and steering from a browser using a different UI locale. The gate injects this persisted language into each model turn; the language of these instructions -and examples does not select the output language. +and examples does not select the output language. Before each reviewer tool call, +check that every generated title, intro, question, option label, rationale and +artifact explanation is in that language. Rewrite mismatched prose before calling +the tool; retain identifiers and quoted source values verbatim. `workspace.language` controls workspace-owned documents, catalog descriptions, Evidence and interpretation of domain terms. Preserve quoted source content and prior decisions verbatim. Keep SQL, identifiers, literal values and workspace artifacts unchanged by the interaction preference. Ask about ambiguous domain terms in the interaction language. For a legacy manifest without the field, run `tht session ensure-interaction-language - --json` before interacting: it pins workspace language once and accepts no override. + --json` before interacting: it detects the original question's language with +workspace language as fallback, pins it once and accepts no override. ## Phase map (advance cheat-sheet) diff --git a/harness/pyproject.toml b/harness/pyproject.toml index d737d638..9f293867 100644 --- a/harness/pyproject.toml +++ b/harness/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "tqdm>=4.66", "yake>=0.4", "portalocker>=2.10", + "langdetect>=1.0.9,<2", "litellm>=1.98,<2", ] diff --git a/harness/tests/test_session_interaction_language.py b/harness/tests/test_session_interaction_language.py index 57817c4e..a25c92de 100644 --- a/harness/tests/test_session_interaction_language.py +++ b/harness/tests/test_session_interaction_language.py @@ -26,14 +26,31 @@ def invoke(*args): return json.loads(result.stdout) -def test_new_pins_explicit_language_and_preserves_workspace_content(monkeypatch, tmp_path): +def test_new_pins_question_language_and_preserves_workspace_content(monkeypatch, tmp_path): configure(monkeypatch, tmp_path) question = "Elenca i pazienti con età superiore a 65 anni" session_id = invoke("new", question, "--interaction-language", "en", "--json")["id"] manifest = invoke("show", session_id, "--json") - assert manifest["interaction_language"] == "en" + assert manifest["interaction_language"] == "it" assert manifest["question"] == question - assert invoke("list", "--json")[0]["interaction_language"] == "en" + assert invoke("list", "--json")[0]["interaction_language"] == "it" + + +@pytest.mark.parametrize("fallback", ["en", "it", None]) +def test_english_question_controls_interaction_in_italian_workspace(monkeypatch, tmp_path, fallback): + configure(monkeypatch, tmp_path) + question = "How many patients were admitted to the hospital last year?" + args = ["--interaction-language", fallback] if fallback else [] + created = invoke("new", question, *args, "--json") + assert created["interaction_language"] == "en" + assert invoke("show", created["id"], "--json")["interaction_language"] == "en" + + +@pytest.mark.parametrize("question", ["", "q", "123", "HbA1c", "SELECT * FROM patients"]) +def test_ambiguous_or_code_question_keeps_fallback(monkeypatch, tmp_path, question): + configure(monkeypatch, tmp_path) + created = invoke("new", question, "--interaction-language", "fr-FR", "--json") + assert invoke("show", created["id"], "--json")["interaction_language"] == "fr-FR" @pytest.mark.parametrize("language", ["", "en_US", "en