diff --git a/backend/app/main.py b/backend/app/main.py index 3e7eb31..6ae27d4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,10 +1,11 @@ from __future__ import annotations import json +import re from pathlib import Path from typing import Literal -from fastapi import FastAPI, HTTPException, Query, Response, WebSocket +from fastapi import FastAPI, HTTPException, Query, Request, Response, WebSocket from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -80,6 +81,30 @@ def safely(action): raise HTTPException(409, str(error)) +_LOCALE_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") + + +def request_locale(request: Request) -> str | None: + """Return the browser's highest-priority valid ``Accept-Language`` locale.""" + choices: list[tuple[float, int, str]] = [] + for index, item in enumerate(request.headers.get("accept-language", "").split(",")): + value, *parameters = item.strip().split(";") + if not _LOCALE_PATTERN.fullmatch(value): + continue + quality = 1.0 + for parameter in parameters: + name, separator, raw_value = parameter.strip().partition("=") + if name.lower() != "q" or not separator: + continue + try: + quality = float(raw_value) + except ValueError: + quality = 0.0 + if quality > 0: + choices.append((quality, index, value)) + return max(choices, default=(0.0, 0, ""), key=lambda item: (item[0], -item[1]))[2] or None + + def paginated(values: list, response: Response, page: int, per_page: int) -> list: """Use GitLab-compatible pagination headers for collection endpoints.""" total = len(values) @@ -358,15 +383,23 @@ class BuildRunRequest(BaseModel): @app.post("/api/builds/{build_id}/runs") -def invoke_build(build_id: str, values: BuildRunRequest | None = None): +def invoke_build(build_id: str, request: Request, values: BuildRunRequest | None = None): return safely( - lambda: store.invoke_remote_build(build_id, output_locale=values.output_locale if values else None) + lambda: store.invoke_remote_build( + build_id, + output_locale=(request_locale(request) or (values.output_locale if values else None)), + ) ) @app.post("/api/builds/{build_id}/tests") -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)) +def test_build(build_id: str, request: Request, values: BuildRunRequest | None = None): + return safely( + lambda: store.test_build( + build_id, + output_locale=(request_locale(request) or (values.output_locale if values else None)), + ) + ) @app.get("/api/build-tests/{session_id}") @@ -638,8 +671,10 @@ def list_project_pipelines( status_code=201, summary="Start a project pipeline", ) -def create_project_pipeline(project_id: str, values: PipelineCreate): - return safely(lambda: store.invoke_remote_build(project_id, values.execution_mode)) +def create_project_pipeline(project_id: str, values: PipelineCreate, request: Request): + return safely( + lambda: store.invoke_remote_build(project_id, values.execution_mode, request_locale(request)) + ) @app.get("/api/v1/quick-starts", tags=["Quick starts"], operation_id="listQuickStarts") @@ -1010,7 +1045,7 @@ class CycleAnalysisRequest(BaseModel): @app.post("/api/cycle-improvements/analyze") -def analyze_cycle(values: CycleAnalysisRequest): +def analyze_cycle(values: CycleAnalysisRequest, request: Request): """Use the configured system AI to diagnose a build's PDCA loop.""" profile_name = store.application_settings()["chat_model_profile_name"] if not profile_name: @@ -1043,7 +1078,11 @@ def analyze_cycle(values: CycleAnalysisRequest): "not a single iteration. Identify evidence of plan, do, check, and act; score trends, " "repeated proposals, and whether accepted work was verified. Recommend only operating-cycle " "changes (runner, workflow, tests, supervisor prompt, or cadence). Respond only in " - + (f"Use BCP 47 locale '{values.locale}'. " if values.locale else "") + + ( + f"Use BCP 47 locale '{request_locale(request) or values.locale}'. " + if request_locale(request) or values.locale + else "" + ) + "Respond in concise Markdown with headings for Health, Evidence, Bottleneck, and Recommended next action.\n\n" + json.dumps(context, ensure_ascii=False, default=str) ) @@ -1056,7 +1095,7 @@ def analyze_cycle(values: CycleAnalysisRequest): @app.post("/api/chat") -def chat(values: ChatMessage): +def chat(values: ChatMessage, request: Request): profile_name = store.application_settings()["chat_model_profile_name"] if not profile_name: raise HTTPException(409, "Select an AI model profile for the chat assistant in Settings.") @@ -1070,6 +1109,8 @@ def chat(values: ChatMessage): "The local OpenAPI contract is available at /api/openapi.json; use it as the source of truth " "when explaining API endpoints, parameters, and response shapes.\n" ) + if locale := request_locale(request): + prompt += f"Respond in BCP 47 locale '{locale}'.\n" if history: prompt += f"Conversation so far:\n{history}\n\n" prompt += f"User: {values.content}\nAssistant:" diff --git a/backend/app/store.py b/backend/app/store.py index 78f989a..0104624 100644 --- a/backend/app/store.py +++ b/backend/app/store.py @@ -95,7 +95,7 @@ def _application_data_dir() -> Path: \"improvements\": [{\"title\":\"string\",\"status\":\"proposed|adopted|rejected\",\"rationale\":\"string\",\"acceptanceEvidence\":\"string\"}], \"reported_issues\": [{\"title\":\"string\",\"severity\":\"low|medium|high|critical\",\"evidence\":\"string\",\"reproduction\":\"string\",\"status\":\"open|acknowledged|resolved\"}] } -For an evaluated AI, include behavior_trace and fill every field. This is an evidence-backed persona journey, not the evaluator's procedure and not hidden reasoning. Keep the visible journey concise: persona_goal is the persona's stable, first-person wish in one sentence; current_action is a first-person sentence describing the one meaningful action the persona took in this iteration; decision is the persona's first-person judgment or resulting choice from that action; next_action is the one specific, safe next action in first person. Do not describe navigation, waits, screenshots, generic control inspection, or other repeated mechanics in any visible journey field. evidence is a concise source-backed factual record for the evidence drawer, not a visible journey item. Use the persona's wording where useful, but do not invent motives, feelings, beliefs, or facts beyond the declared persona and observed evidence. Do not reveal hidden reasoning or evaluator chain-of-thought. Do not include behavior_trace for non-AI targets. behavior_summary is deprecated and should be omitted. Always include both array keys, using empty arrays when there are no items.""" +For an evaluated AI, include behavior_trace and fill every field. This is an evidence-backed persona journey, not the evaluator's procedure and not hidden reasoning. Keep the visible journey concise and written from the persona's perspective: persona_goal is the persona's stable wish; current_action is the one meaningful action taken in this iteration; decision is the resulting judgment or choice; next_action is the one specific, safe next action. Write each field as one to three natural sentences in the selected output language. Follow that language's normal grammar, ellipsis, and point of view; do not mechanically repeat a subject or pronoun across fields. Do not describe navigation, waits, screenshots, generic control inspection, or other repeated mechanics in any visible journey field. evidence is a concise source-backed factual record for the evidence drawer, not a visible journey item. Use the persona's wording where useful, but do not invent motives, feelings, beliefs, or facts beyond the declared persona and observed evidence. Do not reveal hidden reasoning or evaluator chain-of-thought. Do not include behavior_trace for non-AI targets. behavior_summary is deprecated and should be omitted. Always include both array keys, using empty arrays when there are no items.""" LEGACY_OPERATIONAL_MANAGER_PROMPT = """You are an approval-first operations manager for recurring AI evaluations. Preserve the task safety boundary, collect observable evidence, and never claim success without stated acceptance evidence. Escalate required approvals diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index 7df735e..ec3f5c6 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -15,6 +15,7 @@ from app.models import Run, Step, Workflow from fastapi.testclient import TestClient from orbit_sdk import RunnerContext +from starlette.requests import Request def test_health_is_available(): @@ -23,6 +24,17 @@ def test_health_is_available(): assert response.json() == {"status": "ok"} +def test_request_locale_prefers_the_browser_accept_language_priority(): + request = Request( + { + "type": "http", + "headers": [(b"accept-language", b"ja;q=0.6, ko-KR;q=0.9, en;q=0.8")], + } + ) + + assert main_module.request_locale(request) == "ko-KR" + + def test_generated_sdk_docs_are_served_from_the_local_app(tmp_path, monkeypatch): docs = tmp_path / "site" / "sdk" docs.mkdir(parents=True)