Fix new-question landing and question-language HITL
Publish documentation / publish (push) Successful in 34s
Publish documentation / publish (push) Successful in 34s
Reset the activity panel when starting a new question so the landing navigation is restored. Detect and persist the original question language, pass it through runtime and widget descriptors, and scope HITL controls to that language. Validated with gate, session, backend and frontend tests, TypeScript checks, Ruff and strict docs build. Rebuilt and restarted local core/frontend; both healthy and serving HTTP successfully.
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<session-language>\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 <session-language>, 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");
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
<id> --json` before interacting: it pins workspace language once and accepts no override.
|
||||
<id> --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)
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"tqdm>=4.66",
|
||||
"yake>=0.4",
|
||||
"portalocker>=2.10",
|
||||
"langdetect>=1.0.9,<2",
|
||||
"litellm>=1.98,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -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<script>", "en--US", "en\nIGNORE"])
|
||||
@@ -71,6 +88,16 @@ def test_legacy_language_is_pinned_once_through_public_operation_and_resume(monk
|
||||
assert invoke("show", legacy.id, "--json")["interaction_language"] == "it"
|
||||
|
||||
|
||||
def test_legacy_english_question_is_pinned_before_resume(monkeypatch, tmp_path):
|
||||
from tht.session.store import create_session
|
||||
|
||||
cfg = configure(monkeypatch, tmp_path)
|
||||
legacy = create_session("How many patients were admitted last year?", cfg.database, tmp_path)
|
||||
result = CliRunner().invoke(session_app, ["reopen", legacy.id])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert invoke("show", legacy.id, "--json")["interaction_language"] == "en"
|
||||
|
||||
|
||||
def test_cli_reopen_pins_legacy_language_without_rewriting_artifacts(monkeypatch, tmp_path):
|
||||
from tht.session.store import create_session
|
||||
|
||||
|
||||
@@ -178,7 +178,8 @@ def list_cmd(
|
||||
def new_cmd(
|
||||
question: str = typer.Argument(..., help="La domanda in linguaggio naturale."),
|
||||
interaction_language: str = typer.Option(
|
||||
None, "--interaction-language", help="BCP-47 interaction language; defaults to workspace language."
|
||||
None, "--interaction-language",
|
||||
help="BCP-47 fallback for ambiguous questions; otherwise use the question's language.",
|
||||
),
|
||||
provider: str = typer.Option(None, "--provider", help="Provider LLM (es. zai, anthropic)."),
|
||||
model: str = typer.Option(None, "--model", help="Modello LLM (es. glm-5.2)."),
|
||||
@@ -193,10 +194,11 @@ def new_cmd(
|
||||
from tht.session.store import _extract_name, new_session_manifest, render_question_md
|
||||
|
||||
cfg = _load_config_or_exit(config)
|
||||
from tht.session.language import validate_language_tag
|
||||
from tht.session.language import question_interaction_language
|
||||
|
||||
try:
|
||||
language = validate_language_tag(
|
||||
language = question_interaction_language(
|
||||
question,
|
||||
interaction_language if interaction_language is not None else getattr(cfg, "language", "en")
|
||||
)
|
||||
except ValueError as exc:
|
||||
@@ -209,7 +211,7 @@ def new_cmd(
|
||||
repository.create(manifest)
|
||||
repository.write_artifact(manifest.id, "question", render_question_md(question))
|
||||
if json_out:
|
||||
typer.echo(json.dumps({"id": manifest.id}, ensure_ascii=False))
|
||||
typer.echo(json.dumps({"id": manifest.id, "interaction_language": language}, ensure_ascii=False))
|
||||
return
|
||||
typer.secho(f"OK: sessione creata ({manifest.id})", fg=typer.colors.GREEN)
|
||||
typer.echo(manifest.id)
|
||||
@@ -384,7 +386,7 @@ def ensure_interaction_language_cmd(
|
||||
json_out: bool = typer.Option(False, "--json", help="Emit pristine JSON."),
|
||||
config: Path = CONFIG_OPT,
|
||||
) -> None:
|
||||
"""Pin a missing legacy language from the workspace; never override a saved value."""
|
||||
"""Pin a missing language from the question; never override a saved value."""
|
||||
cfg = _load_config_or_exit(config)
|
||||
manifest = ensure_session_language_or_exit(cfg, session_id)
|
||||
if json_out:
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"""Session language policy, independent of workspace document content."""
|
||||
import re
|
||||
from functools import lru_cache
|
||||
|
||||
from langdetect import DetectorFactory, LangDetectException
|
||||
from langdetect.detector_factory import PROFILES_DIRECTORY
|
||||
|
||||
_LANGUAGE_TAG = re.compile(
|
||||
r"(?P<language>[A-Za-z]{2,8})(?:-(?P<script>[A-Za-z]{4}))?"
|
||||
@@ -21,3 +25,36 @@ def validate_language_tag(value: str) -> str:
|
||||
if match["region"]:
|
||||
parts.append(match["region"].upper())
|
||||
return "-".join(parts) + match["rest"].lower()
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _question_detector_factory() -> DetectorFactory:
|
||||
factory = DetectorFactory()
|
||||
factory.seed = 0
|
||||
factory.load_profile(PROFILES_DIRECTORY)
|
||||
return factory
|
||||
|
||||
|
||||
def question_interaction_language(question: str, fallback: str) -> str:
|
||||
"""Pin confidently detected prose language; short/ambiguous/code input uses the hint."""
|
||||
fallback = validate_language_tag(fallback)
|
||||
prose = re.sub(r"```[\s\S]*?```|`[^`]*`|\b\w*_\w*\b", " ", question)
|
||||
if re.match(
|
||||
r"\s*(?:SELECT\s+.+\s+FROM\b|WITH\s+\w+\s+AS\s*\(|INSERT\s+INTO\b|"
|
||||
r"UPDATE\s+\w+\s+SET\b|DELETE\s+FROM\b|CREATE\s+TABLE\b)",
|
||||
prose, re.IGNORECASE | re.DOTALL,
|
||||
):
|
||||
return fallback
|
||||
if sum(character.isalpha() for character in prose) < 12:
|
||||
return fallback
|
||||
detector = _question_detector_factory().create()
|
||||
detector.append(prose)
|
||||
try:
|
||||
candidates = detector.get_probabilities()
|
||||
except LangDetectException:
|
||||
return fallback
|
||||
if not candidates or candidates[0].prob < 0.90:
|
||||
return fallback
|
||||
detected = validate_language_tag(candidates[0].lang)
|
||||
# Preserve a regional preference when it agrees with the detected language.
|
||||
return fallback if detected.split("-")[0] == fallback.split("-")[0] else detected
|
||||
|
||||
@@ -151,13 +151,13 @@ def new_session_manifest(
|
||||
|
||||
def pin_interaction_language(manifest: SessionManifest, workspace_language: str) -> bool:
|
||||
"""Apply legacy compatibility once, while the repository holds its writer lock."""
|
||||
from tht.session.language import validate_language_tag
|
||||
from tht.session.language import question_interaction_language
|
||||
|
||||
if manifest.interaction_language is not None:
|
||||
return False
|
||||
if manifest.status == "finalized" or manifest.archived:
|
||||
raise SessionError("Session is read-only (finalized or archived)")
|
||||
manifest.interaction_language = validate_language_tag(workspace_language)
|
||||
manifest.interaction_language = question_interaction_language(manifest.question, workspace_language)
|
||||
manifest.updated_at = datetime.now(UTC)
|
||||
manifest.updated_by = current_author()
|
||||
return True
|
||||
|
||||
Generated
+11
@@ -866,6 +866,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langdetect"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/72/a3add0e4eec4eb9e2569554f7c70f4a3c27712f40e3284d483e88094cc0e/langdetect-1.0.9.tar.gz", hash = "sha256:cbc1fef89f8d062739774bd51eda3da3274006b3661d199c2655f6b3f6d605a0", size = 981474, upload-time = "2021-05-07T07:54:13.562Z" }
|
||||
|
||||
[[package]]
|
||||
name = "litellm"
|
||||
version = "1.98.0"
|
||||
@@ -2006,6 +2015,7 @@ version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "datasketch" },
|
||||
{ name = "langdetect" },
|
||||
{ name = "litellm" },
|
||||
{ name = "portalocker" },
|
||||
{ name = "psycopg2-binary" },
|
||||
@@ -2035,6 +2045,7 @@ s3 = [
|
||||
requires-dist = [
|
||||
{ name = "boto3", marker = "extra == 's3'", specifier = ">=1.34,<2" },
|
||||
{ name = "datasketch", specifier = ">=1.6" },
|
||||
{ name = "langdetect", specifier = ">=1.0.9,<2" },
|
||||
{ name = "litellm", specifier = ">=1.98,<2" },
|
||||
{ name = "portalocker", specifier = ">=2.10" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9" },
|
||||
|
||||
Reference in New Issue
Block a user