from __future__ import annotations import hashlib import json import platform import re import time from copy import deepcopy from pathlib import Path from urllib.parse import parse_qsl, urlsplit from fastapi import HTTPException from .db import Store, digest, encoded, identifier, now from .learning_checks import evidence_graph_errors, output_assertion_checks from .worker import WorkerError SENSITIVE_KEY = re.compile(r"(^|[_-])(password|passwd|phone|mobile|otp|sms|api_key|access_token|refresh_token)($|[_-])|密码|验证码|手机号", re.I) NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}") def check_entry_url(value: str) -> str: try: parsed = urlsplit(value) if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password: raise ValueError if any(SENSITIVE_KEY.search(key) for key, _ in parse_qsl(parsed.query)): raise ValueError parsed.port except ValueError: raise HTTPException(422, "入口需为不含凭据的 http 或 https 页面地址") from None return value def check_parameters(value: dict) -> dict: if len(value) > 20: raise HTTPException(422, "首版每条链路最多 20 个参数") for key, item in value.items(): if not NAME.fullmatch(key) or SENSITIVE_KEY.search(key): raise HTTPException(422, "参数名需为普通业务字段,不能用于登录凭据") if not isinstance(item, str) or len(item) > 2000: raise HTTPException(422, "首版参数值需为不超过 2000 字符的文本") return value def os_name(value: str) -> str: return {"win32": "windows", "windows": "windows", "darwin": "macos", "macos": "macos", "linux": "linux"}.get(value.lower(), value.lower()) def compare_outputs(actual: dict, expected: dict) -> list[str]: return [item["message"] for item in output_assertion_checks(actual, expected) if not item["passed"]] class LearningService: def __init__(self, store: Store, worker): self.store, self.worker = store, worker self.sessions: dict[str, dict] = {} # Browser contexts are process-owned. Persisted learning evidence survives, # but a new server cannot inherit a predecessor's ready confirmation. self.store.execute("UPDATE learning_tasks SET status='paused',updated_at=$1 WHERE status IN ('awaiting_user','observing')", (now(),)) def browser_readiness(self, task_id: str) -> dict: status = self.sessions.get(task_id, {}).get("status", "closed") if status == "ready": return {"ready": True, "code": "ready", "message": "本次浏览器会话已由维护者确认,可以观察或试跑", "next_action": "none"} if status == "awaiting_user": return {"ready": False, "code": "confirmation_required", "message": "请在官方页面完成登录,再人工确认当前账号;确认前不采集页面", "next_action": "confirm_ready"} return {"ready": False, "code": "session_closed", "message": "请打开学习浏览器并人工确认登录状态;历史证据和验证记录已保留", "next_action": "open_browser"} def pause_active_task(self, task_id: str) -> None: self.store.execute("UPDATE learning_tasks SET status='paused',updated_at=$1 WHERE id=$2 AND status IN ('awaiting_user','observing')", (now(), task_id)) async def refresh_browser(self, task_id: str, owner_id: str) -> dict: self.task(task_id, owner_id) if task_id in self.sessions: try: state = await self.call(task_id, "status", {}) except HTTPException: self.sessions.pop(task_id, None) self.pause_active_task(task_id) else: if state.get("status") == "closed": self.sessions.pop(task_id, None) self.pause_active_task(task_id) else: self.sessions[task_id] = {**state, "status": "ready" if state.get("manual_confirmation") else "awaiting_user"} return self.browser_readiness(task_id) async def require_ready(self, task_id: str, owner_id: str) -> None: readiness = await self.refresh_browser(task_id, owner_id) if not readiness["ready"]: code = "browser_confirmation_required" if readiness["code"] == "confirmation_required" else "browser_session_required" raise HTTPException(409, {"code": code, "message": readiness["message"], "next_action": readiness["next_action"]}) def task(self, task_id: str, owner_id: str) -> dict: row = self.store.one("SELECT * FROM learning_tasks WHERE id=$1 AND owner_id=$2", (task_id, owner_id)) if not row: raise HTTPException(404, "学习任务不存在") row["parameters"] = json.loads(row.pop("parameters_json")) row.pop("owner_id", None) row.pop("ordinal", None) row["browser_status"] = self.sessions.get(task_id, {}).get("status", "closed") row["browser_readiness"] = self.browser_readiness(task_id) return row def candidate(self, candidate_id: str, owner_id: str) -> dict: row = self.store.one("SELECT c.* FROM candidates c JOIN learning_tasks t ON t.id=c.task_id WHERE c.id=$1 AND t.owner_id=$2", (candidate_id, owner_id)) if not row: raise HTTPException(404, "候选能力不存在") row["manifest"] = json.loads(row.pop("manifest_json")) row.pop("ordinal", None) validation = self.store.one(f"SELECT kind,status FROM validations WHERE candidate_id=$1 ORDER BY {self.store.order_column()} DESC LIMIT 1", (candidate_id,)) row["status"] = "validated" if validation and validation["kind"] == "browser" and validation["status"] == "passed" else "checked" if validation and validation["kind"] == "static" and validation["status"] == "passed" else "candidate" return row def tasks(self, owner_id: str) -> list[dict]: return [self.task(row["id"], owner_id) for row in self.store.rows(f"SELECT id FROM learning_tasks WHERE owner_id=$1 ORDER BY {self.store.order_column()} DESC", (owner_id,))] def create_task(self, owner_id: str, values: dict) -> dict: check_entry_url(values["entry_url"]) parameters = check_parameters(values.get("parameters") or {}) if not values["name"].strip(): raise HTTPException(422, "学习任务名称不能为空") timestamp, task_id = now(), identifier("learn") account_key = values["account_key"].strip() if not re.fullmatch(r"[A-Za-z0-9_.-]{2,80}", account_key) or re.fullmatch(r"1\d{10}", account_key): raise HTTPException(422, "本地账号标识请使用 2 至 80 位字母数字编号,不使用平台手机号") self.store.execute("INSERT INTO learning_tasks(id,owner_id,name,platform,entry_url,goal,account_key,parameters_json,status,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)", (task_id, owner_id, values["name"].strip(), values["platform"], values["entry_url"], values["goal"].strip(), account_key, encoded(parameters), "draft", timestamp, timestamp)) return self.task(task_id, owner_id) def set_status(self, task_id: str, status: str) -> None: self.store.execute("UPDATE learning_tasks SET status=$1,updated_at=$2 WHERE id=$3", (status, now(), task_id)) def overview(self, owner_id: str) -> dict: task_ids = "SELECT id FROM learning_tasks WHERE owner_id=$1" def count(table, where="", args=()): scope = "owner_id=$1" if table == "learning_tasks" else f"task_id IN ({task_ids})" return self.store.one(f"SELECT count(*) AS n FROM {table} WHERE {scope}{where}", (owner_id, *args))["n"] return {"tasks": count("learning_tasks"), "candidates": count("candidates"), "validations": count("validations"), "passed": count("validations", " AND status='passed'"), "failed": count("validations", " AND status='failed'"), "published": 0} def observations(self, task_id: str) -> list[dict]: return [{"id": row["id"], "task_id": row["task_id"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows(f"SELECT * FROM observations WHERE task_id=$1 ORDER BY {self.store.order_column()} ASC", (task_id,))] def steps(self, task_id: str) -> list[dict]: return [{"id": row["id"], "position": row["position"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows("SELECT * FROM learning_steps WHERE task_id=$1 ORDER BY position", (task_id,))] def validations(self, owner_id: str, candidate_id: str | None = None, task_id: str | None = None) -> list[dict]: query = "SELECT v.* FROM validations v JOIN learning_tasks t ON t.id=v.task_id WHERE t.owner_id=$1" args = [owner_id] if candidate_id: args.append(candidate_id) query += f" AND v.candidate_id=${len(args)}" if task_id: args.append(task_id) query += f" AND v.task_id=${len(args)}" return [{"id": row["id"], "candidate_id": row["candidate_id"], "task_id": row["task_id"], "kind": row["kind"], "status": row["status"], "created_at": row["created_at"], **json.loads(row["data_json"])} for row in self.store.rows(query + f" ORDER BY {self.store.order_column('v')} DESC", tuple(args))] def detail(self, task_id: str, owner_id: str) -> dict: task = self.task(task_id, owner_id) task.update(observations=self.observations(task_id), steps=self.steps(task_id), candidates=[self.candidate(row["id"], owner_id) for row in self.store.rows("SELECT id FROM candidates WHERE task_id=$1 ORDER BY version DESC", (task_id,))], validations=self.validations(owner_id, task_id=task_id)) return task def candidates(self, owner_id: str) -> list[dict]: return [self.candidate(row["id"], owner_id) for row in self.store.rows(f"SELECT c.id FROM candidates c JOIN learning_tasks t ON t.id=c.task_id WHERE t.owner_id=$1 ORDER BY {self.store.order_column('c')} DESC", (owner_id,))] async def call(self, task_id: str, method: str, params: dict) -> dict: try: return await self.worker.call(method, {"task_id": task_id, **params}) except WorkerError as error: if error.code.lower() in ("worker_closed", "worker_timeout", "browser_closed", "session_not_found", "session_closed"): self.sessions.pop(task_id, None) self.pause_active_task(task_id) raise HTTPException(409, {"message": str(error), "code": error.code}) from None async def open(self, task_id: str, owner_id: str) -> dict: task = self.task(task_id, owner_id) scoped_account = hashlib.sha256(f"{owner_id}|{task['platform']}|{task['account_key']}".encode()).hexdigest() result = await self.call(task_id, "open", {"account_key": scoped_account, "entry_url": task["entry_url"]}) self.sessions[task_id] = {**result, "status": "awaiting_user"} self.set_status(task_id, "awaiting_user") return result async def ready(self, task_id: str, owner_id: str, identity_label: str) -> dict: self.task(task_id, owner_id) if task_id not in self.sessions: raise HTTPException(409, {"code": "browser_session_required", "message": self.browser_readiness(task_id)["message"], "next_action": "open_browser"}) if re.search(r"(? str: target = Path(raw_path).resolve() if not target.is_relative_to(self.store.home) or target.suffix.lower() != ".png" or not target.is_file(): raise HTTPException(409, "执行器证据文件无效,未登记到验证记录") content = target.read_bytes() if not content.startswith(b"\x89PNG\r\n\x1a\n"): raise HTTPException(409, "执行器证据不是有效 PNG") artifact_id = identifier("evidence") self.store.execute("INSERT INTO artifacts(id,task_id,relative_path,sha256,bytes,created_at) VALUES($1,$2,$3,$4,$5,$6)", (artifact_id, task_id, target.relative_to(self.store.home).as_posix(), hashlib.sha256(content).hexdigest(), len(content), now())) return artifact_id def save_observation(self, task_id: str, capture: dict, note: str = "", source: str = "learning") -> dict: data = deepcopy(capture) raw_path = data.pop("screenshot_path", None) data["source"], data["note"] = source, note if raw_path: artifact_id = self.register_artifact(task_id, raw_path) data["artifact_id"] = artifact_id data["screenshot_url"] = "/api/learning/artifacts/" + artifact_id data["text"] = str(data.get("text", ""))[:30000] data["elements"] = list(data.get("elements") or [])[:300] observation_id, timestamp = identifier("observation"), now() self.store.execute("INSERT INTO observations(id,task_id,data_json,created_at) VALUES($1,$2,$3,$4)", (observation_id, task_id, encoded(data), timestamp)) return {"id": observation_id, "task_id": task_id, "created_at": timestamp, **data} async def capture(self, task_id: str, owner_id: str, note: str = "") -> dict: await self.require_ready(task_id, owner_id) capture = await self.call(task_id, "capture", {"note": note}) return self.save_observation(task_id, capture, note) async def action(self, task_id: str, owner_id: str, values: dict) -> dict: task = self.task(task_id, owner_id) await self.require_ready(task_id, owner_id) observations = self.observations(task_id) if not observations: raise HTTPException(409, "请先采集当前页面,再记录链路动作") if len(self.steps(task_id)) >= 60: raise HTTPException(409, "本次学习已达到 60 步上限,请拆分能力") last = observations[-1] element = next((item for item in last.get("elements", []) if item.get("selector") == values["selector"]), None) if not element or element.get("sensitive"): raise HTTPException(422, "请选择最近页面证据中的非敏感元素") kind, parameter, value = values["kind"], values.get("parameter"), values.get("value", "") if parameter: if parameter not in task["parameters"]: raise HTTPException(422, "参数未在学习任务中声明") value = task["parameters"][parameter] if kind == "read" and not NAME.fullmatch(values.get("output_name") or ""): raise HTTPException(422, "读取步骤需要输出字段名,例如 results") result = await self.call(task_id, "action", {"kind": kind, "selector": values["selector"], "value": value, "output_name": values.get("output_name")}) before = self.save_observation(task_id, result["before"], "动作前", "action") if result.get("before") else last after = self.save_observation(task_id, result["after"], "动作后", "action") if result.get("after") else None step = {"kind": kind, "selector": values["selector"], "evidence_id": before["id"], "after_evidence_id": after["id"] if after else None} if kind == "fill": step.update({"parameter": parameter} if parameter else {"value": value}) if kind == "read": step["output_name"] = values["output_name"] with self.store.connect(immediate=True) as conn: position = conn.execute("SELECT COALESCE(MAX(position),0)+1 AS position FROM learning_steps WHERE task_id=$1", (task_id,)).fetchone()["position"] step_id, timestamp = identifier("step"), now() conn.execute("INSERT INTO learning_steps(id,task_id,position,data_json,created_at) VALUES($1,$2,$3,$4,$5)", (step_id, task_id, position, encoded(step), timestamp)) return {"step": {"id": step_id, "position": position, "created_at": timestamp, **step}, "result": result.get("result"), "before": before, "after": after} async def close(self, task_id: str, owner_id: str) -> dict: self.task(task_id, owner_id) result = await self.call(task_id, "close", {}) self.sessions.pop(task_id, None) self.pause_active_task(task_id) return result def create_candidate(self, task_id: str, owner_id: str, name: str | None = None) -> dict: task = self.task(task_id, owner_id) steps = [{k: v for k, v in step.items() if k not in ("id", "created_at", "position")} for step in self.steps(task_id)] references = sorted({step[key] for step in steps for key in ("evidence_id", "after_evidence_id") if step.get(key)}) bindings = [] for reference in references: evidence = self.evidence_snapshot(task_id, reference) bindings.append({"observation_id": reference, "content_digest": digest(evidence) if evidence else None}) manifest = {"schema_version": 1, "integrity_version": 1, "entry_url": task["entry_url"], "parameters": deepcopy(task["parameters"]), "steps": steps, "evidence_bindings": bindings, "platform": task["platform"], "identity_requirement": "operator_confirmed_session", "scope": "readonly_learning"} candidate_id = identifier("candidate") with self.store.connect(immediate=True) as conn: version = conn.execute("SELECT COALESCE(MAX(version),0)+1 AS version FROM candidates WHERE task_id=$1", (task_id,)).fetchone()["version"] conn.execute("INSERT INTO candidates(id,task_id,name,version,digest,manifest_json,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", (candidate_id, task_id, name or task["name"], version, digest(manifest), encoded(manifest), now())) self.set_status(task_id, "candidate") return self.candidate(candidate_id, owner_id) def evidence_snapshot(self, task_id: str, observation_id: str) -> dict | None: row = self.store.one("SELECT * FROM observations WHERE id=$1 AND task_id=$2", (observation_id, task_id)) if not row: return None data = json.loads(row["data_json"]) artifact = self.store.one("SELECT id,sha256,bytes FROM artifacts WHERE id=$1 AND task_id=$2", (data.get("artifact_id", ""), task_id)) return {"observation_id": row["id"], "task_id": task_id, "created_at": row["created_at"], "data": data, "artifact": artifact} def source_integrity_errors(self, candidate: dict) -> list[str]: manifest, task_id = candidate["manifest"], candidate["task_id"] if manifest.get("integrity_version") != 1: return ["候选尚未冻结动作前后证据,请基于原任务生成新版本"] bindings = manifest.get("evidence_bindings") or [] references = {item.get("observation_id") for item in bindings} current = {reference: snapshot for reference in references if (snapshot := self.evidence_snapshot(task_id, reference))} errors = evidence_graph_errors(manifest.get("steps") or [], bindings, current) for snapshot in current.values(): artifact = snapshot["artifact"] if not artifact: errors.append("冻结来源证据缺少关联截图") continue stored = self.store.one("SELECT relative_path FROM artifacts WHERE id=$1 AND task_id=$2", (artifact["id"], task_id)) target = (self.store.home / stored["relative_path"]).resolve() if not target.is_relative_to(self.store.home) or not target.is_file(): errors.append("冻结来源截图不存在或越出证据目录") elif hashlib.sha256(target.read_bytes()).hexdigest() != artifact["sha256"]: errors.append("冻结来源截图摘要不匹配") return list(dict.fromkeys(errors)) def add_validation(self, candidate: dict, kind: str, errors: list[str], **data) -> dict: record_id, timestamp = identifier("validation"), now() content = {"candidate_digest": candidate["digest"], "errors": errors, "parameters": {}, "expected_outputs": {}, "actual_outputs": {}, "environment": {}, "evidence": [], "sample_name": "", "duration_ms": 0, "model_calls": 0, "model_usage_status": "not_used", **data} status = "failed" if errors else "passed" self.store.execute("INSERT INTO validations(id,candidate_id,task_id,kind,status,data_json,created_at) VALUES($1,$2,$3,$4,$5,$6,$7)", (record_id, candidate["id"], candidate["task_id"], kind, status, encoded(content), timestamp)) return {"id": record_id, "candidate_id": candidate["id"], "task_id": candidate["task_id"], "kind": kind, "status": status, "created_at": timestamp, **content} def static_errors(self, candidate: dict) -> list[str]: manifest = candidate["manifest"] errors = [] if digest(manifest) != candidate["digest"]: errors.append("能力清单校验值不匹配") if manifest.get("schema_version") != 1: errors.append("不支持的能力契约版本") errors.extend(self.source_integrity_errors(candidate)) steps = manifest.get("steps") or [] if not steps: errors.append("候选没有实际记录的步骤") if len(steps) > 60: errors.append("候选超过步骤预算") output_names = set() for position, step in enumerate(steps, 1): prefix = f"第 {position} 步" if step.get("kind") not in ("fill", "click", "read"): errors.append(prefix + "动作不在首版只读范围内") if not isinstance(step.get("selector"), str) or not step["selector"].strip(): errors.append(prefix + "缺少定位规则") observation = self.store.one("SELECT data_json FROM observations WHERE id=$1 AND task_id=$2", (step.get("evidence_id", ""), candidate["task_id"])) if not observation: errors.append(prefix + "没有同任务来源证据") else: observed = json.loads(observation["data_json"]) element = next((item for item in observed.get("elements", []) if item.get("selector") == step.get("selector")), None) if not element or element.get("sensitive"): errors.append(prefix + "定位规则缺少非敏感页面依据") elif step.get("kind") != "read" and not element.get("unique"): errors.append(prefix + "操作目标不唯一") if step.get("kind") == "fill" and step.get("parameter") and step["parameter"] not in manifest.get("parameters", {}): errors.append(prefix + "参数未声明") if step.get("kind") == "fill" and not step.get("parameter") and not isinstance(step.get("value"), str): errors.append(prefix + "缺少明确输入值或参数绑定") if step.get("kind") == "read": name = step.get("output_name", "") if not NAME.fullmatch(name) or name in output_names: errors.append(prefix + "输出名缺失、非法或重复") output_names.add(name) if not output_names: errors.append("首版候选至少需要一个可核对的读取输出") return errors def check(self, candidate_id: str, owner_id: str) -> dict: candidate = self.candidate(candidate_id, owner_id) return self.add_validation(candidate, "static", self.static_errors(candidate), environment={"validator": "learning-v1", "os": os_name(platform.system())}) async def verify(self, candidate_id: str, owner_id: str, values: dict) -> dict: candidate = self.candidate(candidate_id, owner_id) manifest = candidate["manifest"] static = self.store.one(f"SELECT status FROM validations WHERE candidate_id=$1 AND kind='static' ORDER BY {self.store.order_column()} DESC LIMIT 1", (candidate_id,)) if not static or static["status"] != "passed" or self.static_errors(candidate): raise HTTPException(409, "请先通过候选固定校验,再进行浏览器试跑") expected = values.get("expected_outputs") or {} output_names = {step.get("output_name") for step in manifest["steps"] if step["kind"] == "read"} if not expected or not set(expected).issubset(output_names): raise HTTPException(422, "请为候选的读取输出填写至少一项预期文本") for expectation in expected.values(): items = [expectation] if isinstance(expectation, str) else expectation if not isinstance(items, list) or not items or any(not isinstance(item, str) or not item.strip() or len(item) > 2000 for item in items): raise HTTPException(422, "预期结果需为非空文本或文本列表") overrides = check_parameters(values.get("parameters") or {}) if not set(overrides).issubset(manifest["parameters"]): raise HTTPException(422, "试跑包含候选未声明的参数") parameters = {**manifest["parameters"], **overrides} await self.require_ready(candidate["task_id"], owner_id) started = time.monotonic() environment = {"os": os_name(platform.system()), "python_version": platform.python_version(), "runner": "playwright-learning-v1", "identity_verification": "operator_confirmed"} evidence, errors, outputs = [], [], {} try: result = await self.call(candidate["task_id"], "verify", {"entry_url": manifest["entry_url"], "steps": manifest["steps"], "parameters": parameters, "expected_outputs": expected}) outputs = result.get("outputs") or {} errors = [str(error) for error in result.get("errors", [])] environment["browser_version"] = str(result.get("browser_version") or "unknown") if environment["browser_version"] == "unknown": errors.append("执行器没有提供实际浏览器版本") if result.get("os") and os_name(result["os"]) != environment["os"]: errors.append("执行器返回的系统与本机实测系统不一致") for capture in result.get("evidence", []): if isinstance(capture, dict): observation = self.save_observation(candidate["task_id"], capture, values.get("sample_name", "变参试跑"), "verification") evidence.append({"observation_id": observation["id"], "artifact_id": observation.get("artifact_id"), "screenshot_url": observation.get("screenshot_url"), "url": observation.get("url"), "title": observation.get("title")}) errors.extend(compare_outputs(outputs, expected)) if not result.get("passed") and not errors: errors.append("执行器未确认试跑成功") if not evidence: errors.append("试跑缺少页面证据") except HTTPException as error: detail = error.detail # Missing process-owned session is an operational precondition, not a # failed business assertion. Do not fabricate a new validation result. if isinstance(detail, dict) and detail.get("code", "").lower() in ("session_closed", "session_not_found", "worker_closed", "worker_timeout", "awaiting_user"): raise errors.append(detail.get("message", "试跑未完成") if isinstance(detail, dict) else str(detail)) record = self.add_validation(candidate, "browser", list(dict.fromkeys(errors)), parameters=parameters, expected_outputs=expected, actual_outputs=outputs, environment=environment, evidence=evidence, sample_name=values.get("sample_name", "变参试跑"), duration_ms=round((time.monotonic() - started) * 1000), assertion_checks=output_assertion_checks(outputs, expected), verification_basis="synthetic_browser_fixture" if manifest.get("platform") == "demo" else "browser_execution_with_fixed_assertions", independent_readback={"status": "not_configured", "independent_identity": False, "message": "当前保存浏览器执行输出并由后台固定比较;尚未接入独立身份与来源读回,不能等同 A2A 的独立业务核验"}) self.set_status(candidate["task_id"], "validated" if record["status"] == "passed" else "validation_failed") return record def publish_check(self, candidate_id: str, owner_id: str) -> dict: candidate = self.candidate(candidate_id, owner_id) rows = self.validations(owner_id, candidate_id=candidate_id) static = next((row for row in rows if row["kind"] == "static"), None) passed = [row for row in rows if row["kind"] == "browser" and row["status"] == "passed" and row.get("candidate_digest") == candidate["digest"]] samples = {encoded(row["parameters"]) for row in passed} systems = {row.get("environment", {}).get("os") for row in passed} evidence_ok = bool(passed) for row in passed: if not row.get("evidence"): evidence_ok = False for item in row.get("evidence", []): if not item.get("artifact_id"): evidence_ok = False continue try: self.artifact(item["artifact_id"], owner_id) except HTTPException: evidence_ok = False checks = [ {"name": "候选结构与来源校验", "passed": bool(static and static["status"] == "passed" and not self.static_errors(candidate)), "detail": "引用同版本固定校验与原始页面证据"}, {"name": "至少两组不同参数试跑", "passed": len(samples) >= 2, "detail": f"已通过 {len(samples)} 组不同参数"}, {"name": "Windows 实际试跑", "passed": "windows" in systems, "detail": "只认可当前执行主机记录的真实系统"}, {"name": "macOS 实际试跑", "passed": "macos" in systems, "detail": "未在 macOS 执行不能计为通过"}, {"name": "试跑证据完整", "passed": evidence_ok, "detail": "各通过记录需具备关联截图证据"}, {"name": "独立来源读回", "passed": False, "detail": "本轮仅完成浏览器执行与固定断言,尚未接入独立身份的来源业务读回"}, ] return {"ready": all(item["passed"] for item in checks), "checks": checks, "candidate_id": candidate_id, "digest": candidate["digest"], "published": False, "message": "当前阶段提供发布预检,尚未接入商家能力分发"} def artifact(self, artifact_id: str, owner_id: str) -> Path: row = self.store.one("SELECT a.* FROM artifacts a JOIN learning_tasks t ON t.id=a.task_id WHERE a.id=$1 AND t.owner_id=$2", (artifact_id, owner_id)) if not row: raise HTTPException(404, "证据不存在") target = (self.store.home / row["relative_path"]).resolve() if not target.is_relative_to(self.store.home) or not target.is_file(): raise HTTPException(404, "证据文件不存在") if hashlib.sha256(target.read_bytes()).hexdigest() != row["sha256"]: raise HTTPException(409, "证据内容已变化,不能当作原验证依据") return target async def demo(self, owner_id: str, base_url: str) -> dict: task = self.create_task(owner_id, {"name": "本机搜索详情评论示例", "platform": "demo", "entry_url": base_url.rstrip("/") + "/api/learning/demo-page", "goal": "记录搜索、打开详情与读取评论,通过两组参数核对当前对象与输出", "account_key": "local-demo", "parameters": {"keyword": "建材"}}) task_id = task["id"] try: await self.open(task_id, owner_id) await self.ready(task_id, owner_id, "本机演示页面 无平台账号") await self.capture(task_id, owner_id, "示例初始页面") await self.action(task_id, owner_id, {"kind": "fill", "selector": '[data-testid="search-input"]', "parameter": "keyword"}) await self.action(task_id, owner_id, {"kind": "click", "selector": '[data-testid="search-button"]'}) await self.action(task_id, owner_id, {"kind": "read", "selector": '[data-testid="result"]', "output_name": "results"}) await self.action(task_id, owner_id, {"kind": "click", "selector": '[data-testid="detail-link"]'}) await self.action(task_id, owner_id, {"kind": "read", "selector": '[data-testid="comment"]', "output_name": "comments"}) candidate = self.create_candidate(task_id, owner_id) self.check(candidate["id"], owner_id) for keyword in ("建材", "装修"): await self.verify(candidate["id"], owner_id, {"parameters": {"keyword": keyword}, "expected_outputs": {"results": keyword, "comments": keyword}, "sample_name": f"关键词 {keyword}"}) return {"task_id": task_id, "candidate_id": candidate["id"]} finally: if task_id in self.sessions: try: await self.close(task_id, owner_id) except HTTPException: pass