From d6de01c82e9b4fdae7c10d7d2bef151d56a53ea1 Mon Sep 17 00:00:00 2001 From: cyning Date: Fri, 10 Jul 2026 12:04:32 +0800 Subject: [PATCH 1/2] fix(ops): stabilize chat messages, artifacts API, and Bailian fallback Add GET /ops/runs/{id}/artifacts, gracefully handle missing artifacts table, map clarify runs to fast for DB route constraint compatibility, and improve Bailian quota detection plus production model fallback chain. Co-authored-by: Cursor --- api/ops/chat_service.py | 4 ++- api/ops/llm/model_catalog.py | 19 ++++++------ api/ops/llm/providers/openai_compatible.py | 21 ++++++++++--- api/ops/runs.py | 14 +++++++++ api/ops/store/runs.py | 22 ++++++++----- supabase/sql/ops_desk_p1_clarify_route.sql | 7 +++++ .../ops_desk_p1_clarify_route_rollback.sql | 5 +++ tests/ops_desk/test_llm_usage_metrics.py | 25 +++++++++++++++ tests/ops_desk/test_orchestrator_p1.py | 31 +++++++++++++++++++ 9 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 supabase/sql/ops_desk_p1_clarify_route.sql create mode 100644 supabase/sql/ops_desk_p1_clarify_route_rollback.sql diff --git a/api/ops/chat_service.py b/api/ops/chat_service.py index 791c71ff..ee81ec3c 100644 --- a/api/ops/chat_service.py +++ b/api/ops/chat_service.py @@ -131,7 +131,9 @@ def handle_ops_chat_message( clarification = clarify_if_fallback(body.message, body.session_id, transcript, slots) if clarification.needs_clarification: - run = store.create_run(query=body.message, route="clarify", session_id=body.session_id) + # clarify 为 API 层路由;落库用 fast(ops_runs_route_check 未含 clarify)。 + # 语义由 clarify.asked 事件承载;迁移 ops_desk_p1_clarify_route.sql 后可改为 route="clarify"。 + run = store.create_run(query=body.message, route="fast", session_id=body.session_id) run_id = str(run["id"]) store.append_event( run_id, diff --git a/api/ops/llm/model_catalog.py b/api/ops/llm/model_catalog.py index 66143449..f0bf96a2 100644 --- a/api/ops/llm/model_catalog.py +++ b/api/ops/llm/model_catalog.py @@ -47,24 +47,25 @@ def bailian_model_ids() -> list[str]: def resolve_bailian_model_chain(primary: str | None) -> list[str]: - """从 primary 起向下 fallback;未知 id 则 primary 优先再完整链。 + """从 primary 起构建 fallback 链;未知 id 则 primary 优先再补生产模型。 若 primary 为 test_only(如 kimi 无额度测试项),配额 fallback 时跳过链中 其余 test_only(如 ZHIPU),直达 deepseek-v4-pro 等生产模型。 + + 生产模型 primary 耗尽额度时,回退到其余生产模型(避免链尾模型无后继)。 """ chain_ids = bailian_model_ids() test_only = bailian_test_only_model_ids() + prod_ids = [mid for mid in chain_ids if mid not in test_only] if not primary or not primary.strip(): return list(chain_ids) primary = primary.strip() - if primary not in chain_ids: - return [primary, *[mid for mid in chain_ids if mid not in test_only or mid == primary]] - idx = chain_ids.index(primary) - tail = chain_ids[idx:] - if tail and tail[0] in test_only: - prod_tail = [mid for mid in tail[1:] if mid not in test_only] - return [tail[0], *prod_tail] - return tail + if primary in test_only: + prod_tail = [mid for mid in prod_ids] + return [primary, *prod_tail] + if primary in prod_ids: + return [primary, *[mid for mid in prod_ids if mid != primary]] + return [primary, *prod_ids] def get_chat_models_payload() -> dict[str, Any]: diff --git a/api/ops/llm/providers/openai_compatible.py b/api/ops/llm/providers/openai_compatible.py index d1e18fa6..e3b38e13 100644 --- a/api/ops/llm/providers/openai_compatible.py +++ b/api/ops/llm/providers/openai_compatible.py @@ -11,25 +11,36 @@ from api.ops.llm.errors import OpsLlmRequestError from api.ops.llm.types import LlmCompletionResult, LlmUsage -_BAILIAN_QUOTA_MARKER = "AllocationQuota.FreeTierOnly" +_BAILIAN_QUOTA_MARKERS = ( + "AllocationQuota.FreeTierOnly", + "free quota has been exhausted", + "free tier only", +) _DEFAULT_MAX_ATTEMPTS = 3 +def _text_has_bailian_quota_marker(text: str) -> bool: + lowered = text.lower() + return any(marker.lower() in lowered for marker in _BAILIAN_QUOTA_MARKERS) + + def is_bailian_quota_error(response: requests.Response) -> bool: - """百炼无额度:403 且 body 含 AllocationQuota.FreeTierOnly。""" + """百炼无额度:403 且 body 含已知配额耗尽标记。""" if response.status_code != 403: return False + if _text_has_bailian_quota_marker(response.text): + return True try: data = response.json() except ValueError: - return _BAILIAN_QUOTA_MARKER in response.text + return False err = data.get("error") if isinstance(err, dict): code = str(err.get("code") or err.get("type") or "") message = str(err.get("message") or "") - if _BAILIAN_QUOTA_MARKER in code or _BAILIAN_QUOTA_MARKER in message: + if _text_has_bailian_quota_marker(code) or _text_has_bailian_quota_marker(message): return True - return _BAILIAN_QUOTA_MARKER in str(data) + return _text_has_bailian_quota_marker(str(data)) def _is_retryable_status(status_code: int) -> bool: diff --git a/api/ops/runs.py b/api/ops/runs.py index a8a018dc..4f672a72 100644 --- a/api/ops/runs.py +++ b/api/ops/runs.py @@ -40,6 +40,20 @@ def get_events( return {"run_id": run_id, "after_seq": after_seq, "events": events} +@router.get("/{run_id}/artifacts") +def get_artifacts( + run_id: str = Path(...), + store: OpsRunStore = Depends(_store), + _: None = Depends(require_ops_secret), +) -> dict[str, Any]: + """返回 run 关联的 ops_run_artifacts;无记录时 artifacts=[]。""" + run = store.get_run(run_id) + if not run: + raise HTTPException(status_code=404, detail={"code": "RUN_NOT_FOUND"}) + artifacts = store.list_artifacts(run_id) + return {"run_id": run_id, "artifacts": artifacts} + + @router.post("/{run_id}/retry") def retry_run( run_id: str = Path(...), diff --git a/api/ops/store/runs.py b/api/ops/store/runs.py index 9504b1ee..7bf9f378 100644 --- a/api/ops/store/runs.py +++ b/api/ops/store/runs.py @@ -5,6 +5,8 @@ import time from typing import Any +from postgrest.exceptions import APIError + from api.ops.events_schema import SCHEMA_VERSION from api.rag_env import supabase_client, supabase_execute_with_retry @@ -273,13 +275,19 @@ def _once() -> dict[str, Any]: def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: def _once() -> list[dict[str, Any]]: - res = ( - self.client.table("ops_run_artifacts") - .select("*") - .eq("run_id", run_id) - .order("created_at", desc=True) - .execute() - ) + try: + res = ( + self.client.table("ops_run_artifacts") + .select("*") + .eq("run_id", run_id) + .order("created_at", desc=True) + .execute() + ) + except APIError as exc: + # 迁移 ops_desk_p1_artifacts.sql 未应用时,读路径静默返回空列表 + if getattr(exc, "code", None) == "PGRST205": + return [] + raise return res.data if isinstance(res.data, list) else [] return supabase_execute_with_retry(_once) diff --git a/supabase/sql/ops_desk_p1_clarify_route.sql b/supabase/sql/ops_desk_p1_clarify_route.sql new file mode 100644 index 00000000..3f0ae2d5 --- /dev/null +++ b/supabase/sql/ops_desk_p1_clarify_route.sql @@ -0,0 +1,7 @@ +-- Ops Chat P1-3 · clarify 路由扩展 +-- 用途:ops_runs.route CHECK 增加 clarify(FALLBACK 澄清短路) +-- 依赖:ops_desk_s2_session_00_route.sql 已应用 + +ALTER TABLE public.ops_runs DROP CONSTRAINT IF EXISTS ops_runs_route_check; +ALTER TABLE public.ops_runs ADD CONSTRAINT ops_runs_route_check + CHECK (route IN ('fast', 'deep', 'react', 'session_00', 'clarify')); diff --git a/supabase/sql/ops_desk_p1_clarify_route_rollback.sql b/supabase/sql/ops_desk_p1_clarify_route_rollback.sql new file mode 100644 index 00000000..6543c245 --- /dev/null +++ b/supabase/sql/ops_desk_p1_clarify_route_rollback.sql @@ -0,0 +1,5 @@ +-- Ops Chat P1-3 · clarify 路由扩展回滚 + +ALTER TABLE public.ops_runs DROP CONSTRAINT IF EXISTS ops_runs_route_check; +ALTER TABLE public.ops_runs ADD CONSTRAINT ops_runs_route_check + CHECK (route IN ('fast', 'deep', 'react', 'session_00')); diff --git a/tests/ops_desk/test_llm_usage_metrics.py b/tests/ops_desk/test_llm_usage_metrics.py index b00b4f78..dd49b43a 100644 --- a/tests/ops_desk/test_llm_usage_metrics.py +++ b/tests/ops_desk/test_llm_usage_metrics.py @@ -970,6 +970,13 @@ def test_resolve_bailian_model_chain_from_primary() -> None: assert "kimi/kimi-k2.7-code" not in chain +def test_resolve_bailian_model_chain_qwen_fallbacks_to_prod_models() -> None: + from api.ops.llm.model_catalog import resolve_bailian_model_chain + + chain = resolve_bailian_model_chain("qwen3.7-plus") + assert chain == ["qwen3.7-plus", "deepseek-v4-pro", "deepseek-v4-flash"] + + def test_resolve_bailian_model_chain_skips_test_only_after_kimi() -> None: from api.ops.llm.model_catalog import resolve_bailian_model_chain @@ -992,6 +999,14 @@ class QuotaResponse: def json(self) -> dict[str, Any]: return {"error": {"code": "AllocationQuota.FreeTierOnly", "message": "no quota"}} + class FreeQuotaExhaustedResponse: + status_code = 403 + ok = False + text = '{"error":{"message":"The free quota has been exhausted."}}' + + def json(self) -> dict[str, Any]: + return {"error": {"message": "The free quota has been exhausted."}} + class OkResponse: status_code = 200 ok = True @@ -1010,6 +1025,8 @@ def fake_post(*args: Any, **kwargs: Any) -> Any: model = kwargs["json"]["model"] if model in ("kimi/kimi-k2.7-code",): return QuotaResponse() + if model == "qwen3.7-plus": + return FreeQuotaExhaustedResponse() return OkResponse(model) monkeypatch.setattr( @@ -1026,6 +1043,14 @@ def fake_post(*args: Any, **kwargs: Any) -> Any: assert result.content == "ok:deepseek-v4-pro" assert result.usage.model == "deepseek-v4-pro" + result2 = provider.complete( + [{"role": "user", "content": "hi"}], + model="qwen3.7-plus", + step="analyze", + ) + assert result2.content == "ok:deepseek-v4-pro" + assert result2.usage.model == "deepseek-v4-pro" + def test_chat_models_endpoint_bailian(monkeypatch) -> None: monkeypatch.setenv("OPS_LLM_PROVIDER", "bailian") diff --git a/tests/ops_desk/test_orchestrator_p1.py b/tests/ops_desk/test_orchestrator_p1.py index 67b51ad8..51012bc8 100644 --- a/tests/ops_desk/test_orchestrator_p1.py +++ b/tests/ops_desk/test_orchestrator_p1.py @@ -90,6 +90,7 @@ class FakeStore(OpsRunStore): def __init__(self) -> None: # type: ignore[override] self.runs: dict[str, dict[str, Any]] = {} self.events: dict[str, list[dict[str, Any]]] = {} + self.artifacts: dict[str, list[dict[str, Any]]] = {} self._counter = 0 def create_run( @@ -149,6 +150,9 @@ def validate_retry_token(self, run_id: str, retry_token: str) -> bool: run = self.get_run(run_id) return bool(run) and run.get("retry_token") == retry_token + def list_artifacts(self, run_id: str) -> list[dict[str, Any]]: + return list(self.artifacts.get(run_id, [])) + @pytest.fixture def client(monkeypatch: pytest.MonkeyPatch) -> TestClient: @@ -280,3 +284,30 @@ def test_stream_not_implemented(client: TestClient) -> None: resp = client.get("/api/py/ops/runs/run-1/stream", headers={"x-ops-secret": "test"}) assert resp.status_code == 404 assert resp.json()["detail"]["code"] == "SSE_NOT_IMPLEMENTED" + + +def test_get_run_artifacts_empty(client: TestClient) -> None: + resp = client.post( + "/api/py/ops/chat/messages", + json={"message": "#545 适合我吗"}, + headers={"x-ops-secret": "test"}, + ) + run_id = resp.json()["run_id"] + + art_resp = client.get( + f"/api/py/ops/runs/{run_id}/artifacts", + headers={"x-ops-secret": "test"}, + ) + assert art_resp.status_code == 200 + body = art_resp.json() + assert body["run_id"] == run_id + assert body["artifacts"] == [] + + +def test_get_run_artifacts_not_found(client: TestClient) -> None: + resp = client.get( + "/api/py/ops/runs/run-missing/artifacts", + headers={"x-ops-secret": "test"}, + ) + assert resp.status_code == 404 + assert resp.json()["detail"]["code"] == "RUN_NOT_FOUND" From b5a00110b987a044c3c25f123dcd85fcd9dcec0f Mon Sep 17 00:00:00 2001 From: cyning Date: Fri, 10 Jul 2026 12:17:57 +0800 Subject: [PATCH 2/2] docs(tech-graph): declare ops_run_artifacts in manifest tables Align manifest with ops_run_artifacts SQL and artifacts read API so tech_graph manifest_check passes in CI. Co-authored-by: Cursor --- docs/_tech_graph/_manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/_tech_graph/_manifest.json b/docs/_tech_graph/_manifest.json index b94732fe..202d7831 100644 --- a/docs/_tech_graph/_manifest.json +++ b/docs/_tech_graph/_manifest.json @@ -183,6 +183,7 @@ "ops_issues", "ops_pull_requests", "ops_repos", + "ops_run_artifacts", "ops_run_checkpoints", "ops_run_events", "ops_runs",