feat: release 2.4.0 with original cases and automatic human review
This commit is contained in:
1 parent
baf069854b
commit
4dc2057d43
66 files changed
+1390
-206
No files matched your search
@@ -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),
|
||||
{
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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_<time>_<id>_PASS/FAIL
|
||||
# folder. The recording row already ties that path to this session.
|
||||
# The recording row already ties the finalized trace folder to this session.
|
||||
if not source.is_relative_to(trace_root):
|
||||
return []
|
||||
# A resumed recording lives under resume_NNNN; the initial recording is
|
||||
|
||||
@@ -9,6 +9,7 @@ from apps.admin_console.services.automation_store import AutomationStore, identi
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from artemis.plugins.models import PluginManifest
|
||||
from artemis.plugins.store import PluginStore
|
||||
from apps.admin_console.services.task_import import case_title
|
||||
|
||||
TERMINAL = {"completed", "success", "failed", "cancelled", "interrupted", "skipped"}
|
||||
|
||||
@@ -41,10 +42,26 @@ def task_context(session_id: str, store: AutomationStore) -> dict:
|
||||
job = store.job(session_id) or {}
|
||||
if not session and not job:
|
||||
raise ValueError("任务记录不存在")
|
||||
case = job.get("case") or {}
|
||||
navigation = None
|
||||
if batch_id := job.get("batch_id"):
|
||||
jobs = [item for item in store.jobs(batch_id) if item.get("status") in TERMINAL]
|
||||
ids = [item["session_id"] for item in jobs]
|
||||
index = ids.index(session_id) if session_id in ids else -1
|
||||
navigation = {
|
||||
"scope": "batch",
|
||||
"position": index + 1,
|
||||
"total": len(ids),
|
||||
"previous": ids[index - 1] if index > 0 else None,
|
||||
"next": ids[index + 1] if 0 <= index < len(ids) - 1 else None,
|
||||
}
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"initial_prompt": session.get("initial_goal") or (job.get("case") or {}).get("goal", ""),
|
||||
"editable": (job.get("status") or session.get("status")) in TERMINAL,
|
||||
"case_title": case_title(case),
|
||||
"original_case": case.get("original_case"),
|
||||
"navigation": navigation,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ HEADERS = (
|
||||
"Artemis提示词",
|
||||
"任务配置(JSON)",
|
||||
)
|
||||
CASE_HEADERS = ("预置条件", "操作步骤", "预期结果")
|
||||
MAX_ROWS = int(os.getenv("ARTEMIS_IMPORT_MAX_ROWS", "1000"))
|
||||
MAX_BYTES = int(os.getenv("ARTEMIS_IMPORT_MAX_BYTES", "10485760"))
|
||||
|
||||
@@ -61,6 +62,15 @@ class TaskOptions(BaseModel):
|
||||
return value.model_dump(exclude_unset=True)
|
||||
|
||||
|
||||
class OriginalCase(BaseModel):
|
||||
"""Imported review material, never appended to the execution prompt."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
preconditions: str = Field(default="", max_length=10000)
|
||||
steps: str = Field(default="", max_length=10000)
|
||||
expected: str = Field(default="", max_length=10000)
|
||||
|
||||
|
||||
class TaskCase(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
case_id: str = Field(default="", max_length=200)
|
||||
@@ -70,16 +80,29 @@ class TaskCase(BaseModel):
|
||||
preconditions: str = Field(default="", max_length=10000)
|
||||
goal: str = Field(min_length=1, max_length=10000)
|
||||
task_config: TaskOptions = Field(default_factory=TaskOptions)
|
||||
original_case: OriginalCase | None = None
|
||||
|
||||
|
||||
def original_case_enabled() -> bool:
|
||||
from apps.admin_console.services.task_extensions import active_manifests
|
||||
|
||||
return any(
|
||||
panel.kind == "original_case"
|
||||
for manifest in active_manifests()
|
||||
if manifest.workspace is not None
|
||||
for panel in manifest.workspace.panels
|
||||
)
|
||||
|
||||
|
||||
def template() -> bytes:
|
||||
headers = (*HEADERS, *CASE_HEADERS) if original_case_enabled() else HEADERS
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
assert ws is not None
|
||||
ws.title = "任务"
|
||||
ws.append(HEADERS)
|
||||
ws.append(headers)
|
||||
ws.freeze_panes = "A2"
|
||||
ws.auto_filter.ref = "A1:G1"
|
||||
ws.auto_filter.ref = f"A1:{get_column_letter(len(headers))}1"
|
||||
for cell in ws[1]:
|
||||
cell.font = Font(bold=True, color="FFFFFF")
|
||||
cell.fill = PatternFill("solid", fgColor="245C78")
|
||||
@@ -93,7 +116,7 @@ def template() -> bytes:
|
||||
help_sheet.append(["前置约束提取及其他描述列仅作查看、搜索和报告元数据,不追加到提示词。"])
|
||||
help_sheet.append(["任务配置(JSON)可留空,空字段继承批次或全局配置;禁止密钥与未知字段。"])
|
||||
help_sheet.append(["示例仅供参考,不会导入执行。请在“任务”页填写自己的用例。"])
|
||||
help_sheet.append(HEADERS)
|
||||
help_sheet.append(headers)
|
||||
help_sheet.append(
|
||||
[
|
||||
"示例-001",
|
||||
@@ -130,7 +153,18 @@ def template() -> bytes:
|
||||
json.dumps({"profile": "flash", "execution_target": "computer"}, ensure_ascii=False),
|
||||
]
|
||||
)
|
||||
for column in range(1, 8):
|
||||
if len(headers) > len(HEADERS):
|
||||
help_sheet.append(
|
||||
[
|
||||
"人工评审插件附加列:预置条件、操作步骤、预期结果。仅保存原始用例,均不追加到 AI 提示词。"
|
||||
]
|
||||
)
|
||||
for row_number in (6, 7):
|
||||
for column, value in enumerate(
|
||||
("填写原始用例的预置条件", "填写原始用例的操作步骤", "填写原始用例的预期结果"), 8
|
||||
):
|
||||
help_sheet.cell(row_number, column, value)
|
||||
for column in range(1, len(headers) + 1):
|
||||
help_sheet.column_dimensions[get_column_letter(column)].width = 70 if column > 5 else 26
|
||||
for row in help_sheet:
|
||||
for cell in row:
|
||||
@@ -164,7 +198,10 @@ def parse_workbook(data: bytes) -> dict:
|
||||
raise ValueError("缺少“任务”工作表,请使用下载的模板")
|
||||
rows = iter(wb["任务"].iter_rows())
|
||||
headers = [str(cell.value or "").strip() for cell in next(rows, [])]
|
||||
if headers != list(HEADERS):
|
||||
supported_headers = [list(HEADERS)]
|
||||
if original_case_enabled():
|
||||
supported_headers.append([*HEADERS, *CASE_HEADERS])
|
||||
if headers not in supported_headers:
|
||||
raise ValueError("列名或顺序与新版模板不一致,请重新下载模板")
|
||||
tasks, errors = [], []
|
||||
for number, row in enumerate(rows, 2):
|
||||
@@ -190,9 +227,29 @@ def parse_workbook(data: bytes) -> dict:
|
||||
"preconditions": values[4],
|
||||
"goal": values[5],
|
||||
"task_config": config,
|
||||
"original_case": (
|
||||
dict(
|
||||
zip(
|
||||
("preconditions", "steps", "expected"),
|
||||
(
|
||||
str(cell.value if cell.value is not None else "").strip()
|
||||
for cell in row[7:10]
|
||||
),
|
||||
)
|
||||
)
|
||||
if len(headers) == 10
|
||||
else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tasks.append(
|
||||
{
|
||||
"row": number,
|
||||
**case.model_dump(
|
||||
exclude={"original_case"} if case.original_case is None else None
|
||||
),
|
||||
}
|
||||
)
|
||||
tasks.append({"row": number, **case.model_dump()})
|
||||
except (ValueError, TypeError) as exc:
|
||||
errors.append({"row": number, "message": str(exc)[:500]})
|
||||
if not tasks and not errors:
|
||||
|
||||
Reference in new issue
Block a user