diff --git a/backend/app/main.py b/backend/app/main.py index 4d3ef5d..503585f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -353,14 +353,20 @@ def workspaces(path: str | None = None): return safely(lambda: store.workspaces(path)) +class BuildRunRequest(BaseModel): + output_locale: str = Field(default="", max_length=35, pattern=r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") + + @app.post("/api/builds/{build_id}/runs") -def invoke_build(build_id: str): - return safely(lambda: store.invoke_remote_build(build_id)) +def invoke_build(build_id: str, values: BuildRunRequest | None = None): + return safely( + lambda: store.invoke_remote_build(build_id, output_locale=values.output_locale if values else None) + ) @app.post("/api/builds/{build_id}/tests") -def test_build(build_id: str): - return safely(lambda: store.test_build(build_id)) +def test_build(build_id: str, values: BuildRunRequest | None = None): + return safely(lambda: store.test_build(build_id, output_locale=values.output_locale if values else None)) @app.get("/api/build-tests/{session_id}") diff --git a/backend/app/models.py b/backend/app/models.py index 5d49baf..f6fdb70 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -72,6 +72,7 @@ class Run(BaseModel): build_name: str | None = None repository: str | None = None supervisor_profile_name: str | None = None + output_locale: str | None = None prompt_source: str | None = None prompt_snapshot: str | None = None execution_mode: Literal["run", "test"] = "run" diff --git a/backend/app/store.py b/backend/app/store.py index b05ee91..6b11632 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -2864,7 +2864,7 @@ def _write_prompt_template( temporary.replace(CONFIG / "prompt-templates.yaml") return template - def _assembled_prompt(self, build: dict[str, Any]) -> tuple[str, str]: + def _assembled_prompt(self, build: dict[str, Any], output_locale: str | None = None) -> tuple[str, str]: """Resolve the global manager contract and the build's evaluation policy.""" template_id = build.get("manager_template_id", "manager-default-v1") template = next((item for item in self.prompt_templates() if item.get("id") == template_id), None) @@ -2910,15 +2910,27 @@ def _assembled_prompt(self, build: dict[str, Any]) -> tuple[str, str]: ) if part ) + resolved_output_locale = ( + str(output_locale or self.application_settings()["manager_output_locale"]).strip() or "en" + ) + output_language_name = { + "en": "English", + "ja": "Japanese", + "ko": "Korean", + }.get(resolved_output_locale.lower().split("-", 1)[0], resolved_output_locale) assembled = "\n\n".join( part for part in ( operational.replace(MANAGER_PROMPT_SLOT, manager_policy).replace( MANAGER_OUTPUT_LANGUAGE_SLOT, "# Output language\n" - "Write all human-readable string values in the configured application language " - f"({self.application_settings()['manager_output_locale']}). " - "Keep JSON keys, field names, and required enum values exactly as specified.", + "Write every human-readable JSON string value in " + f"{output_language_name} ({resolved_output_locale}). " + "This applies even when the persona, rendered UI, or source evidence uses another language; " + "do not switch to the persona's language for behavior_trace, summaries, improvements, or issues. " + "If another instruction asks you to preserve an issue title, evidence, or reproduction, " + "preserve its facts and severity rather than its source-language wording. " + "Keep JSON keys, field names, required enum values, and verbatim UI strings quoted as evidence exactly as specified.", ), f"# Evaluation context\nRepository: {build.get('repository', '')}\n{legacy_context}", case_text, @@ -4030,6 +4042,7 @@ def create_run( supervisor_profile_name: str | None = None, prompt_source: str | None = None, prompt_snapshot: str | None = None, + output_locale: str | None = None, loop_limit: int = 1, timezone: str = "UTC", schedule_enabled: bool = False, @@ -4069,6 +4082,7 @@ def create_run( build_name=build_name, repository=repository, supervisor_profile_name=supervisor_profile_name, + output_locale=output_locale, prompt_source=prompt_source, prompt_snapshot=prompt_snapshot, execution_mode=execution_mode, @@ -4695,7 +4709,7 @@ def supervisor_result(result: object) -> object: supervisor_prompt = run.prompt_snapshot or "" if run.build_id: try: - _, supervisor_prompt = self._assembled_prompt(self.build(run.build_id)) + _, supervisor_prompt = self._assembled_prompt(self.build(run.build_id), run.output_locale) except ValueError: # The original immutable run snapshot remains a safe fallback # if an operator has made the prompt temporarily unreadable. @@ -5257,14 +5271,19 @@ def emergency_stop(self) -> list[Run]: stopped.append(self.cancel(run.id)) return stopped - def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run: + def invoke_remote_build( + self, build_id: str, execution_mode: str = "run", output_locale: str | None = None + ) -> Run: build = self.build(build_id) executor = build.get("executor", {}) if not build.get("enabled"): raise ValueError("This build is not enabled.") if execution_mode not in {"run", "test"}: raise ValueError("execution_mode must be run or test") - prompt_source, prompt_snapshot = self._assembled_prompt(build) + resolved_output_locale = ( + str(output_locale or self.application_settings()["manager_output_locale"]).strip() or "en" + ) + prompt_source, prompt_snapshot = self._assembled_prompt(build, resolved_output_locale) if executor.get("type") != "remote-http": return self.create_run( build["runner_id"], @@ -5273,6 +5292,7 @@ def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run build_id=build["id"], build_name=build["name"], supervisor_profile_name=build.get("model_profile_name"), + output_locale=resolved_output_locale, prompt_source=prompt_source, prompt_snapshot=prompt_snapshot, loop_limit=1 if execution_mode == "test" else int(build.get("run_limit", 1)), @@ -5314,6 +5334,7 @@ def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run build_id=build["id"], build_name=build["name"], supervisor_profile_name=build.get("model_profile_name"), + output_locale=resolved_output_locale, execution_mode=execution_mode, execution_type="invoke", status="queued", @@ -5330,11 +5351,11 @@ def invoke_remote_build(self, build_id: str, execution_mode: str = "run") -> Run threading.Thread(target=self._execute_remote, args=(run.id, executor), daemon=True).start() return run - def test_build(self, build_id: str) -> Run: + def test_build(self, build_id: str, output_locale: str | None = None) -> Run: build = self.build(build_id) if not build.get("enabled"): raise ValueError("This build is not enabled.") - return self.invoke_remote_build(build_id, "test") + return self.invoke_remote_build(build_id, "test", output_locale=output_locale) def _execute_remote(self, run_id: str, executor: dict[str, Any]) -> None: run = self._load(run_id) diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 3e3c5a3..7df735e 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -1525,5 +1525,23 @@ def test_manager_output_language_is_injected_into_the_assembled_prompt(tmp_path, encoding="utf-8", ) _, prompt = store._assembled_prompt({"manager_template_id": "manager-default-v1", "repository": "test"}) - assert "configured application language (ja)" in prompt + assert "Japanese (ja)" in prompt assert store_module.MANAGER_OUTPUT_LANGUAGE_SLOT not in prompt + + +def test_run_output_language_overrides_the_shared_application_setting(tmp_path, monkeypatch): + monkeypatch.setattr(store_module, "SETTINGS", tmp_path / "settings.json") + monkeypatch.setattr(store_module, "CONFIG", tmp_path / "config") + store = store_module.ConsoleStore() + store.save_application_settings({"manager_output_locale": "en"}) + (tmp_path / "config").mkdir(exist_ok=True) + (tmp_path / "config" / "prompt-templates.yaml").write_text( + "- id: manager-default-v1\n name: Default\n version: 1\n content: Assess evidence.\n", + encoding="utf-8", + ) + _, prompt = store._assembled_prompt( + {"manager_template_id": "manager-default-v1", "repository": "test"}, "ko" + ) + assert "Korean (ko)" in prompt + assert "do not switch to the persona's language" in prompt + assert "facts and severity rather than its source-language wording" in prompt diff --git a/frontend/src/domain/models.ts b/frontend/src/domain/models.ts index 5e44fef..eee3697 100644 --- a/frontend/src/domain/models.ts +++ b/frontend/src/domain/models.ts @@ -15,7 +15,7 @@ export type BehaviorTrace = { persona_goal?:string; current_action?:string; next export type SupervisorEvaluation = { score:number; approval:'approved'|'rejected'|'pending'; behavior_summary?:string; behavior_trace?:BehaviorTrace; summary:string } export type SupervisorResult = { evaluation?:SupervisorEvaluation; improvements:Record[]; reported_issues:Record[] } export type SupervisorRecord = { iteration:number; candidate_id?:string|null; status:'pending'|'completed'|'not_configured'|'invalid_response'|'failed'; prompt?:string; response?:SupervisorResult; error?:string; recorded_at?:string } -export type Run = { id:string; workflow_id:string; workflow_name:string;runner_version?:number;runner_source_sha256?:string; build_id?:string; build_name?:string; execution_mode?:'run'|'test'; execution_type?:'pipeline'|'invoke'; loop_limit?:number;start_iteration?:number;retry_of_run_id?:string;retry_mode?:'restart'|'resume';iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;iteration_candidates?:{id:string;iteration:number;score?:number;status:string;selected?:boolean}[]; status:string; created_at?:string; updated_at?:string; finished_at?:string; current_phase?:string; pid?:number; last_pid?:number; telemetry_trace_id?:string; prompt_source?:string; prompt_snapshot?:string; supervisor_status?:'pending'|'completed'|'not_configured'|'invalid_response'|'failed'; supervisor_response?:SupervisorResult; supervisor_error?:string; supervisor_results?:SupervisorRecord[]; workflow_graph?:WorkflowGraphDefinition|null; step_results?:RunStepResult[]; proposed_improvements?:number; approved_improvements?:number; reported_issues?:number; approval_score?:number } +export type Run = { id:string; workflow_id:string; workflow_name:string;runner_version?:number;runner_source_sha256?:string; build_id?:string; build_name?:string; execution_mode?:'run'|'test'; execution_type?:'pipeline'|'invoke'; loop_limit?:number;start_iteration?:number;retry_of_run_id?:string;retry_mode?:'restart'|'resume';iteration_strategy?:'linear'|'score_select';candidates_per_iteration?:number;iteration_candidates?:{id:string;iteration:number;score?:number;status:string;selected?:boolean}[]; status:string; created_at?:string; updated_at?:string; finished_at?:string; current_phase?:string; pid?:number; last_pid?:number; telemetry_trace_id?:string; output_locale?:string; prompt_source?:string; prompt_snapshot?:string; supervisor_status?:'pending'|'completed'|'not_configured'|'failed'; supervisor_response?:SupervisorResult; supervisor_error?:string; supervisor_results?:SupervisorRecord[]; workflow_graph?:WorkflowGraphDefinition|null; step_results?:RunStepResult[]; proposed_improvements?:number; approved_improvements?:number; reported_issues?:number; approval_score?:number } export type TelemetrySpan = { name:string; traceId:string; spanId:string; parentSpanId?:string|null; startTime?:number; endTime?:number; attributes?:Record; status?:string; events?:{name:string;attributes?:Record}[] } export type RunTelemetry = { trace_id?:string; spans:TelemetrySpan[] } export type PromptRevision = { iteration?:number; phase?:string; path:string; status:'initial'|'applied'|'blocked'|'rolled_back'|'unchanged'; reason?:string|null; recorded_at?:string; version_id?:string|null; run_id?:string|null; before?:string|null; after?:string|null; before_sha256?:string|null; after_sha256?:string|null } diff --git a/frontend/src/features/settings/page.tsx b/frontend/src/features/settings/page.tsx index 7fac949..6f01ace 100644 --- a/frontend/src/features/settings/page.tsx +++ b/frontend/src/features/settings/page.tsx @@ -75,11 +75,6 @@ export function SettingsPage({ .then((values) => { setPrompt(values.manager_prompt_template); setChatProfile(values.chat_model_profile_name); - if (values.manager_output_locale !== locale) { - return api("/api/application-settings", "PUT", { - manager_output_locale: locale, - }); - } }) .catch(() => pushToast("Unable to load operational prompt.")); }, [locale, pushToast]); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 8945004..7167c2b 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -173,14 +173,14 @@ export default function App() { }); }; const invoke = (id: string) => - api(`/api/builds/${id}/runs`, "POST") + api(`/api/builds/${id}/runs`, "POST", { output_locale: locale }) .then(() => { room.setNotice(ui.evaluationStarted, "success"); room.refresh(); }) .catch((e) => room.setNotice(e.message)); const testBuild = (id: string) => - api(`/api/builds/${id}/tests`, "POST") + api(`/api/builds/${id}/tests`, "POST", { output_locale: locale }) .then((run) => { room.setNotice(ui.evaluationTestStarted, "success"); return run;