161 lines
7.2 KiB
Python
161 lines
7.2 KiB
Python
"""Durable manual-demonstration commands, sharing the automation transaction."""
|
||
|
||
import time
|
||
from uuid import uuid4
|
||
|
||
from artemis.exploration.store import ExplorationConflict, ExplorationStore
|
||
|
||
|
||
TERMINAL = {"completed", "failed", "cancelled", "timed_out"}
|
||
|
||
|
||
DEMONSTRATION_SOURCE_RULE = (
|
||
"人工示范的 source 是用户选择的来源,recorded_sources 是实际录像来源"
|
||
"(computer=电脑,android=安卓,both=双端)。延续示范中各步骤的设备归属;"
|
||
"仅补充网址或缩小范围不表示更换平台。用户明确改端或原目标包含跨端步骤时,"
|
||
"按对应来源处理,不把整个任务固定在某一端。当前主设备截图不决定示范来源;"
|
||
"执行、验收和用例提炼必须核对每步的平台,不能用手机上的成功证明电脑步骤完成。"
|
||
)
|
||
|
||
|
||
def demonstration_provenance(demo: dict) -> dict:
|
||
"""Capture metadata is authoritative even when the model omits its source."""
|
||
return {
|
||
"id": demo["id"],
|
||
"run_id": demo["run_id"],
|
||
"source": demo["source"],
|
||
"recorded_sources": sorted({track["kind"] for track in demo.get("media", [])}),
|
||
}
|
||
|
||
|
||
def demonstration_summary(demo: dict, summary: str) -> str:
|
||
labels = {"android": "安卓", "computer": "电脑", "both": "安卓与电脑"}
|
||
provenance = demonstration_provenance(demo)
|
||
recorded = "、".join(labels.get(kind, kind) for kind in provenance["recorded_sources"])
|
||
return f"人工示范({labels[demo['source']]};录像来源:{recorded or '未记录'})\n{summary}"
|
||
|
||
|
||
class Demonstrations:
|
||
def __init__(self, repository: ExplorationStore):
|
||
self.repository = repository
|
||
|
||
def get(self, demo_id: str) -> dict:
|
||
value = self.repository.database.get("exploration_demo", demo_id)
|
||
if value is None:
|
||
raise KeyError("人工示范不存在")
|
||
return value
|
||
|
||
def list(self, session_id: str) -> list[dict]:
|
||
self.repository.get(session_id)
|
||
with self.repository.database.connection() as connection:
|
||
rows = connection.execute(
|
||
"SELECT payload FROM automation_records WHERE kind='exploration_demo' "
|
||
"AND json_extract(payload,'$.exploration_id')=? ORDER BY updated_at",
|
||
(session_id,),
|
||
).fetchall()
|
||
import json
|
||
|
||
return [json.loads(row[0]) for row in rows]
|
||
|
||
def completed_context(self, session_id: str) -> list[dict]:
|
||
# Keep this small: summaries already live in the conversation. Read the
|
||
# durable records so legacy messages and process restarts work too.
|
||
return [
|
||
demonstration_provenance(demo)
|
||
for demo in self.list(session_id)
|
||
if demo["status"] == "completed"
|
||
]
|
||
|
||
def request(self, session_id: str, source: str, request_id: str) -> dict:
|
||
repo = self.repository
|
||
with repo._transaction() as connection:
|
||
session = repo._require(connection, session_id)
|
||
|
||
def create():
|
||
run_id = session["active_run_id"]
|
||
run = repo._get(connection, "exploration_run", run_id) if run_id else None
|
||
if (
|
||
not run
|
||
or run["kind"] != "exploration"
|
||
or run["status"] not in {"running", "waiting_user"}
|
||
):
|
||
raise ExplorationConflict("请等待探索开始执行,再请求人工示范")
|
||
if session["active_demonstration_id"]:
|
||
raise ExplorationConflict("已有进行中的人工示范")
|
||
value = {
|
||
"id": str(uuid4()),
|
||
"exploration_id": session_id,
|
||
"run_id": run_id,
|
||
"source": source,
|
||
"status": "requested",
|
||
"command": None,
|
||
"created_at": time.time(),
|
||
"deadline": None,
|
||
"media": [],
|
||
"error": None,
|
||
"timeout_seconds": session["config"]["demonstration_timeout_seconds"],
|
||
"warning_seconds": session["config"]["demonstration_warning_seconds"],
|
||
}
|
||
repo._put(connection, "exploration_demo", value["id"], value)
|
||
session["active_demonstration_id"] = value["id"]
|
||
repo._save_session(connection, session)
|
||
return value
|
||
|
||
return repo._operation(connection, f"{session_id}:demo", request_id, source, create)
|
||
|
||
def command(
|
||
self, session_id: str, demo_id: str, command: str, request_id: str, *, seconds: int = 0
|
||
) -> dict:
|
||
if command not in {"finish", "cancel", "extend"}:
|
||
raise ValueError("不支持的示范操作")
|
||
if command == "extend" and not 60 <= seconds <= 86400:
|
||
raise ValueError("延长时长必须在 60–86400 秒之间")
|
||
repo = self.repository
|
||
with repo._transaction() as connection:
|
||
session = repo._require(connection, session_id)
|
||
demo = repo._get(connection, "exploration_demo", demo_id)
|
||
if not demo or demo["exploration_id"] != session_id:
|
||
raise KeyError("人工示范不存在")
|
||
|
||
def update():
|
||
if session["active_demonstration_id"] != demo_id or demo["status"] not in {
|
||
"requested",
|
||
"recording",
|
||
}:
|
||
raise ExplorationConflict("示范已结束或正在分析")
|
||
if command != "cancel" and demo["status"] != "recording":
|
||
raise ExplorationConflict("请等待录像就绪")
|
||
if demo.get("command"):
|
||
raise ExplorationConflict("示范已请求结束")
|
||
if demo.get("deadline") and time.time() >= demo["deadline"]:
|
||
raise ExplorationConflict("示范已到时,请等待保存录像")
|
||
if command == "extend":
|
||
demo["deadline"] += seconds
|
||
else:
|
||
demo["command"] = command
|
||
return repo._put(connection, "exploration_demo", demo_id, demo)
|
||
|
||
return repo._operation(
|
||
connection, f"{demo_id}:command", request_id, [command, seconds], update
|
||
)
|
||
|
||
def transition(self, demo_id: str, status: str, **fields) -> dict:
|
||
repo = self.repository
|
||
with repo._transaction() as connection:
|
||
demo = repo._get(connection, "exploration_demo", demo_id)
|
||
if not demo:
|
||
raise KeyError("人工示范不存在")
|
||
session = repo._require(connection, demo["exploration_id"])
|
||
if demo["status"] in TERMINAL:
|
||
return demo
|
||
if session["active_run_id"] != demo["run_id"]:
|
||
raise ExplorationConflict("示范所属执行已结束")
|
||
demo.update(status=status, **fields)
|
||
if status == "recording":
|
||
demo.update(started_at=time.time(), deadline=time.time() + demo["timeout_seconds"])
|
||
if status in TERMINAL:
|
||
demo["finished_at"] = time.time()
|
||
session["active_demonstration_id"] = None
|
||
repo._save_session(connection, session)
|
||
return repo._put(connection, "exploration_demo", demo_id, demo)
|