diff --git a/.env.example b/.env.example index 809a3a0..2ee467c 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ OPENAI_BASE_URL=https://api-n-cd.playones.com/v1 OPENAI_MODEL=gpt-6-luna # 单次模型 HTTP 请求超时(秒) OPENAI_TIMEOUT_SECONDS=300 +# 模型请求和模型凭据检查默认直连;设为 true 才读取 Windows/HTTP(S)_PROXY/ALL_PROXY/NO_PROXY。 +# 仅环境配置,不显示在网页中;修改后重启服务生效。TLS 校验及自定义 CA 不受影响。 +OPENAI_USE_SYSTEM_PROXY=false # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 diff --git a/DESIGN.md b/DESIGN.md index 34dbbc5..5e8cb1d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,6 +88,10 @@ Responses 消息交给工具、下游解析器或正式裁定前,必须具有 执行平台与证据来源分别记录。报告和证据索引从执行记录汇总来源及实际 step_id/call_id/source_id;界面、接口、文件和终端各自只能支持其观察范围,业务后台名称或网址不能推断平台。最终复核明确事实、矛盾和未验证边界;来源清单不生成新结论,不覆盖 AI 原判、有效结论或人工复验。 +原生安卓动作在派发时记录所选设备来源。安卓截图来源也可从已保存的成功截图回执读取设备、采集时间、文件路径和校验值,并引用实际 trace_id;兼容 Flash 与 Pro 的回执封装,不从模型参数补造来源。空步骤、笔记和结束声明不计入缺失动作来源;窗口枚举等无单一目标的操作保留未关联状态,该计数不等于截图丢失。未生成独立输出正文时,报告展示当前有效判定及依据,保留人工复验优先级,不把正文缺失写成验收结论缺失。 + +规划器的笔记工具与执行器的设备、插件工具分别绑定。规划器依据任务的实际授权插件清单制定计划,由执行器加载并验证能力;规划阶段没有绑定动作工具,不表示任务缺少这些能力,也不构成重新申请授权的依据。 + MCP 与单任务 HTTP 入参拒绝未知字段,校验失败不调度任务。MCP JSON 结果保留兼容文本并提供 structuredContent;任务摘要分页明确总数与 next_offset,引用实际存储编号。跨端阶段先完整初始化,再按既有阶段 id 更新状态与依据;补丁保留其他阶段、范围、资源和顺序,合并后统一校验并保存,失败不更新内存状态。 Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配置和 MCP 共用保存的全局设置。任务开始时固定设置,尚未开始的任务使用最新值;批量、精选及提炼用例的 Flash 复跑同样遵循。关闭时跳过最终 Checker,保留正常执行、步骤、截图、报告和人工复验;结果为“执行完成 · 未做 AI 判定”,不标记“AI确认”或测试通过。Pro 与主动探索的验收规则保持不变。 @@ -195,3 +199,7 @@ Flash 的最终 AI 判定与分析独立配置,默认关闭;首页功能配 回放两侧面板默认收起、独立展开,普通浮窗和影院模式共用;插件声明期望/最小/最大宽度,宿主按视口收缩,窄屏一次展开一侧,始终保持面板位于回放的左侧或右侧。任务需求只展示本次运行最初的提示词,复用安全 Markdown 渲染。人工表单字段均可留空,不调用模型、不覆盖已有执行结论或人工复验。 评审按单次任务和插件 ID 保存版本历史;运行中只读,终态可编辑或清空。停用/卸载隐藏面板与新导出字段但保留记录,任务删除时同步清理。报告只采用已安装且启用的贡献,空值保持空白;修改记录或插件状态使后续报告缓存失效。 + +执行详情中的 Payload 默认折叠;其他超长字段也默认折叠,展开后保留完整可复制内容并限制显示高度。原始用例面板仅由启用的工作区插件声明,额外导入列不影响未启用插件的用户。评审支持所有执行模式和目标,用户修改评审后自动保存:结果选择立即保存,备注短暂防抖;切换任务前保存,失败时保留输入和当前任务。仅用户编辑触发写入,AI 不推断人工结论。 + +模型网络代理策略仅由环境项 `OPENAI_USE_SYSTEM_PROXY` 控制,默认 false。同步、异步模型客户端和模型凭据检查统一遵循;不出现在前端配置中,不改变 TLS 验证与自定义 CA 设置。 diff --git a/README.md b/README.md index ebeaadd..625051b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 基于 [ARTEMIS](https://github.com/google/artemis) 扩展的 Android AI 自动化测试平台。用自然语言描述任务,即可通过简体中文网页或 MCP 驱动真机、模拟器执行操作并收集结果。模型统一使用 **OpenAI Responses API**。 -**2.3.0**:新增工作区插件接口,支持步骤回放左右侧栏、Markdown 初始需求、人工评审及报告字段扩展;配套「任务人工评审 1.0.0」从插件市场独立安装。新增 gpt-6.1-terra,探索默认 high。见 [2.3.0 发行说明](docs/releases/2.3.0.md);上一版本见 [2.2.0](docs/releases/2.2.0.md)。 +**2.4.0**:人工评审支持原始用例、自动保存与上一条/下一条;执行详情默认折叠长字段。升级 OpenAI/LangChain 及安全依赖,修复 Windows 资源锁、报告证据与历史用量。配套「任务人工评审 1.1.0」独立安装。见 [2.4.0 发行说明](docs/releases/2.4.0.md);上一版本见 [2.3.0](docs/releases/2.3.0.md)。 ## 主要功能 diff --git a/apps/admin_console/database/repositories/session_repository.py b/apps/admin_console/database/repositories/session_repository.py index 36e8d03..8ac15d9 100644 --- a/apps/admin_console/database/repositories/session_repository.py +++ b/apps/admin_console/database/repositories/session_repository.py @@ -15,6 +15,7 @@ import json import logging import os +import re import sqlite3 import time from typing import Any @@ -235,6 +236,47 @@ class SessionRepository: rows = cursor.fetchall() return [r[0] for r in rows if r and r[0]] + def get_recorded_model_info_map(self, session_ids: list[str]) -> dict[str, dict[str, str]]: + """Read each session's own requested model from its earliest diagnostics. + + Prefer operator/Flash requests over auxiliary roles. Older sessions may + have no model diagnostics; do not replace their missing data with the + current global configuration. + """ + result: dict[str, dict[str, str]] = {} + safe_payload = "CASE WHEN json_valid(t.payload) THEN t.payload ELSE '{}' END" + valid_model = f"json_type({safe_payload}, '$.model') = 'text'" + role = f"lower(json_extract({safe_payload}, '$.role'))" + with db_session(self.db_path) as conn: + for offset in range(0, len(session_ids), 500): + batch = session_ids[offset : offset + 500] + placeholders = ",".join("?" for _ in batch) + rows = conn.execute( + "SELECT s.session_id, COALESCE(" + " (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id" + f" AND t.type = 'llm_diagnostic' AND {valid_model}" + f" AND {role} IN ('operator', 'flashrunner')" + " ORDER BY t.timestamp ASC LIMIT 1)," + " (SELECT t.payload FROM traces t WHERE t.session_id = s.session_id" + f" AND t.type = 'llm_diagnostic' AND {valid_model}" + " ORDER BY t.timestamp ASC LIMIT 1)" + ") AS model_payload FROM sessions s" + f" WHERE s.session_id IN ({placeholders})", + batch, + ).fetchall() + for row in rows: + if not row[1]: + continue + payload = json.loads(row[1]) + model = payload.get("model") + if isinstance(model, str) and model.strip(): + provider = payload.get("provider") + result[str(row[0])] = { + "id": model, + "provider": provider if isinstance(provider, str) else "", + } + return result + def get_llm_traces_for_profiles_map( self, session_ids: list[str] | None = None, limit: int = 3 ) -> dict[str, list[str]]: @@ -349,7 +391,22 @@ class SessionRepository: total += t cached += _int(payload.get("cached_tokens")) node = str(payload.get("node") or "").strip().lower() - model_name = str(payload.get("model") or "unknown") + model_name = payload.get("model") + if not model_name: + # Older gateway traces carry the model in their own endpoint key. + source = str(payload.get("source") or "") + source_parts = source.split(":", 2) + if len(source_parts) == 3 and source_parts[0] == "openai": + # Model aliases can contain colons. The HTTP(S) base URL + # marks the boundary; an ambiguous key stays unknown. + match = re.match(r"^openai:(.+?):https?://", source) + model_name = match[1] if match else None + elif len(source_parts) == 3 and source_parts[:2] in ( + ["lens", "visual_transition"], + ["lens", "step_capsule"], + ): + model_name = source_parts[2] if source_parts[2] != "fallback" else None + model_name = str(model_name or "unknown") group = by_node_model.setdefault( (node, model_name), { diff --git a/apps/admin_console/database/repositories/step_repository.py b/apps/admin_console/database/repositories/step_repository.py index d76b5aa..b1a2025 100644 --- a/apps/admin_console/database/repositories/step_repository.py +++ b/apps/admin_console/database/repositories/step_repository.py @@ -220,6 +220,41 @@ class StepRepository: # recovering, waiting, completed, and terminal outcomes. Other tools # retain the historical compact payload to avoid sending large blobs. res = payload_obj.get("result") + if trace_type == "tool" and trace_name == "android_file": + # Preserve host-written file receipts, not file contents. A file's + # checksum is not a screenshot-store image ID. + if isinstance(res, str): + try: + res = json.loads(res) + except ValueError: + res = None + if isinstance(res, dict) and "outcome" in res: + res = res["outcome"] + if isinstance(res, str): + try: + res = json.loads(res) + except ValueError: + res = None + if isinstance(res, dict): + result_payload["result"] = { + key: self._clean_value(res[key]) + for key in ( + "ok", + "action", + "path", + "trace_path", + "device_path", + "bytes", + "sha256", + "verified", + "width", + "height", + "captured_at", + "device_serial", + ) + if key in res + } + return result_payload is_video_analysis = str(trace_name or "").lower() in { "video_analysis", "video_analyzer", diff --git a/apps/admin_console/routers/sessions.py b/apps/admin_console/routers/sessions.py index 3fb1bf1..37e236b 100644 --- a/apps/admin_console/routers/sessions.py +++ b/apps/admin_console/routers/sessions.py @@ -84,6 +84,9 @@ def _list_sessions_sync(): with store.connection() as connection: exploration_runs = exploration_metadata(connection) jobs_by_id = store.jobs_for_sessions([str(row.get("session_id")) for row in rows]) + recorded_models = session_repo.get_recorded_model_info_map( + [str(row.get("session_id")) for row in rows] + ) video_rec_map = session_repo.get_video_recordings_map() latest_recordings = session_repo.get_latest_video_recordings_map() agent_names_by_session = session_repo.get_agent_trace_names_map() @@ -217,6 +220,13 @@ def _list_sessions_sync(): print(f"[list_sessions] Failed to update orphaned sessions: {e}") for row in result: + # Session history must not inherit today's global model configuration. + row["model_info"] = { + **row["model_info"], + "id": "", + "provider": "", + **recorded_models.get(str(row.get("session_id")), {}), + } if row.get("exploration_kind") == "validation" and row.get("exploration_case_name"): row["display_name"] = row["exploration_case_name"] model = row.pop("exploration_model", None) diff --git a/apps/admin_console/routers/tasks.py b/apps/admin_console/routers/tasks.py index 95b73c4..eed19dc 100644 --- a/apps/admin_console/routers/tasks.py +++ b/apps/admin_console/routers/tasks.py @@ -441,6 +441,24 @@ async def get_status(): ) model_info = model_service.get_active_model_info(active_profile) + model_sid = running_sid if is_running else latest_session_id + if model_info and model_sid: + recorded_model = session_repo.get_recorded_model_info_map([str(model_sid)]).get( + str(model_sid) + ) + if not isinstance(recorded_model, dict): + matching_task = next( + ( + item + for item in state.queue_items + if isinstance(item, dict) and str(item.get("session_id")) == str(model_sid) + ), + None, + ) + override = (matching_task or {}).get("runtime_overrides") or {} + model = override.get("model") + recorded_model = {"id": model, "provider": "openai"} if model else {} + model_info = {**model_info, "id": "", "provider": "", **recorded_model} # Unified Global Queue: merge web tasks and external SDK/CLI device queue tickets global_queued = DeviceExecutionLock.get_queued_tasks() @@ -508,6 +526,12 @@ async def get_status(): is_paused = state.is_paused conn_profile = conn_info.get("profile") or active_profile conn_model_info = model_service.get_active_model_info(conn_profile) + if conn_model_info and model_info: + conn_model_info = { + **conn_model_info, + "id": model_info["id"], + "provider": model_info["provider"], + } return { "status": "paused" if is_paused else "running", "paused_error": state.paused_error if is_paused else None, diff --git a/apps/admin_console/services/media_service.py b/apps/admin_console/services/media_service.py index e114d04..1384eef 100644 --- a/apps/admin_console/services/media_service.py +++ b/apps/admin_console/services/media_service.py @@ -149,7 +149,7 @@ class MediaService: return p LIVE_RECORDING_GRACE_SECONDS = 10.0 - TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_") + TRACE_STATUS_SUFFIXES = ("_PASS_", "_FAIL_", "_TESTFAIL_", "_BLOCKED_", "_UNVERIFIED_") @classmethod def is_live_recording(cls, p: Path) -> bool: diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 241614f..16953fe 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -36,7 +36,7 @@ from artemis.runtime.case_result import RESULT_LABELS, resolve_case_result from apps.admin_console.services.case_review import reviewed_result EXPORT_ROOT = WORKSPACE_ROOT / "artifacts" / "reports" -BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-2" +BATCH_ARCHIVE_SIGNATURE = "artemis-batch-layout-3" def batch_archive_signature(batch_id: str | None = None) -> str: @@ -234,11 +234,14 @@ def session_report(session_id: str, db_path=None) -> dict: if status in {"failed", "interrupted"} and verification.startswith("未验证"): verification = "未验证(任务异常终止)" if not content: - content = ( - "本任务未生成最终结论。请查看异常原因和已记录的执行步骤。" - if status in {"failed", "interrupted"} - else "本任务没有保存最终结论。以下内容仅来自已记录的执行状态和证据。" - ) + lines = ["本任务未保存独立输出正文。", f"当前有效判定:{verification}"] + if test_result: + if test_result.get("reason"): + lines.append(f"判定依据:{test_result['reason']}") + lines.extend(f"- {item}" for item in test_result.get("evidence") or []) + elif error: + lines.append(f"异常原因:{error}") + content = "\n\n".join(lines) return { "id": session_id, **guidance_details(session_id, db_path=repo.db_path), @@ -264,6 +267,7 @@ def session_report(session_id: str, db_path=None) -> dict: "category": case.get("category", ""), "subcategory": case.get("subcategory", ""), "preconditions": case.get("preconditions", ""), + "original_case": case.get("original_case"), "task_config": case.get("task_config") or {}, "task_extensions": task_extensions.report_sections(session_id, store), } @@ -330,17 +334,24 @@ def source_text(value: dict) -> str: for item in value.get("sources", []): references = ", ".join( " / ".join( - f"{key}={ref[key]}" for key in ("step_id", "call_id", "source_id") if ref.get(key) + f"{key}={ref[key]}" + for key in ("step_id", "call_id", "source_id", "trace_id") + if ref.get(key) ) or "编号未记录" for ref in item.get("references", []) ) + variation = "(来源标签随步骤变化,详见原始步骤)" if item.get("label_varies") else "" lines.append( - f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']} · {references}" + f"{labels.get(item['kind'], '其他')} · {item['label'] or item['target_id']}" + f"{variation} · {references}" ) if value.get("sources_omitted"): lines.append(f"另 {value['sources_omitted']} 个来源见原始步骤记录。") - lines.append(f"来源未完整记录的步骤:{value.get('unattributed_step_count', '未知')}。") + lines.append( + f"未关联到单一来源的步骤:{value.get('unattributed_step_count', '未知')}。" + "窗口枚举等操作可能没有单一目标;此计数不表示截图丢失。" + ) lines.append(value.get("note") or "来源清单不代表断言已验证。") return "\n".join(lines) @@ -367,6 +378,15 @@ def metadata(report: dict) -> list[tuple]: ("检测项目", report.get("category") or "—"), ("测试子项", report.get("subcategory") or "—"), ("前置约束提取", report.get("preconditions") or "—"), + *( + [ + ("原始用例 · 预置条件", report["original_case"].get("preconditions") or "未填写"), + ("原始用例 · 操作步骤", report["original_case"].get("steps") or "未填写"), + ("原始用例 · 预期结果", report["original_case"].get("expected") or "未填写"), + ] + if report.get("original_case") is not None + else [] + ), ("任务配置", config_text(report.get("task_config"))), ("任务目标", report["goal"]), ("预期结果", report["expected"] or "未指定"), @@ -949,8 +969,7 @@ def session_videos(report: dict) -> list[tuple[Path, str]]: return [] source = Path(str(candidate)).resolve() trace_root = TRACES_PATH.resolve() - # Workers save recordings in their finalized web_