feat: release 2.4.0 with original cases and automatic human review

This commit is contained in:
chenmingxuan committed 2026-10-28 17:25:08 +08:00
1 parent baf069854b
commit 4dc2057d43
66 files changed
+1390 -206

No files matched your search

+1 -1
View File
@@ -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:
+30 -11
View File
@@ -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,
}
+63 -6
View File
@@ -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: