Files
mini-openclaw-v2/backend/services/assistant_experiences.py
T
qiaoqichaoandClaude Code d56c699ce6 chore(v2): 从兴邦预审v2白名单抽取独立可运行基座
按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 12:58:26 +08:00

171 lines
12 KiB
Python

"""Human feedback -> reviewed, scoped, expiring executable experience guidance."""
from copy import deepcopy
from contextlib import nullcontext
from datetime import datetime, timezone, timedelta
from services.assistant_store import get, put, listing, event, uid, now, digest, Conflict
from services.assistant_materials import FIELDS
CATEGORIES = {"expression", "factual", "data", "rule_omission", "false_positive", "judgment", "process"}
def matches(experience, snapshot, identity, at=None):
at = at or datetime.now(timezone.utc)
if experience["data_domain"] != snapshot["data_domain"]:
return False, "数据域不同"
if datetime.fromisoformat(experience["expires_at"]) <= at:
return False, "已过期"
if experience["scope"] == "project" and experience["project_id"] != snapshot["project_id"]:
return False, "非来源项目"
if experience["scope"] == "team" and not identity.get("preview") and experience["team"] not in identity.get("teams", []):
return False, "无经验团队权限"
if experience.get("industry") and experience["industry"] != snapshot["industry"]:
return False, "行业不匹配"
if any(snapshot["values"].get(k) != v for k, v in experience["conditions"].items()):
return False, "触发条件不满足"
if experience.get("exception_field") and snapshot["values"].get(experience["exception_field"]) not in (None, ""):
return False, "例外条件满足"
return True, "条件匹配"
def apply_experiences(experiences, snapshot, identity, run_id):
checks, applications, guidance = [], [], []
for item in experiences:
matched, reason = matches(item, snapshot, identity)
if not matched:
continue
field = item["required_field"]
refs = [r["id"] for f in set(item["conditions"]) | {field} for r in snapshot["sources"].get(f, [])]
check = {"code": "experience." + item["component_id"], "title": item["title"], "result": "unknown" if snapshot["values"].get(field) in (None, "") else "pass",
"fields": list(item["conditions"]) + [field], "suggested_action": item["action"], "evidence_ids": refs,
"basis_ids": [item["component_id"] + "@" + item["version"]], "severity": "attention"}
checks.append(check)
applications.append({"id": uid("application"), "project_id": snapshot["project_id"], "run_id": run_id,
"experience_version_id": item["id"], "reason": reason, "status": "executed", "check_code": check["code"], "evidence_ids": refs, "created_at": now()})
guidance.append({"id": item["component_id"], "version": item["version"], "title": item["title"], "action": item["action"], "conditions": item["conditions"], "exceptions": item["exceptions"]})
return checks, applications, guidance
def evaluate_experience(candidate):
"""Isolated synthetic holdout probes; never sent to a model or production run."""
snapshot = {"project_id": candidate["project_id"], "data_domain": candidate["data_domain"], "industry": candidate.get("industry", ""), "values": deepcopy(candidate["conditions"]), "sources": {}}
snapshot["values"][candidate["required_field"]] = None
user = {"id": "holdout", "teams": [candidate.get("team")], "preview": False}
probes = []
def probe(name, sample, actor, at, expected):
actual = matches(candidate, sample, actor, at)[0]
probes.append({"name": name, "expected": expected, "actual": actual, "passed": actual == expected})
at = datetime.now(timezone.utc)
probe("适用样本", snapshot, user, at, True)
negative = deepcopy(snapshot)
key = next(iter(candidate["conditions"]))
negative["values"][key] = "holdout-not-matching"
probe("条件不符对照", negative, user, at, False)
other_domain = dict(snapshot, data_domain="production" if candidate["data_domain"] == "demo" else "demo")
probe("数据域隔离", other_domain, user, at, False)
probe("过期经验", snapshot, user, datetime.fromisoformat(candidate["expires_at"]) + timedelta(seconds=1), False)
excepted = deepcopy(snapshot)
excepted["values"][candidate["exception_field"]] = "到账凭证"
probe("例外反例", excepted, user, at, False)
if candidate["scope"] == "team":
probe("无团队权限", snapshot, {"teams": []}, at, False)
else:
probe("项目隔离", dict(snapshot, project_id="HOLDOUT-OTHER"), user, at, False)
checks, apps, _ = apply_experiences([candidate], snapshot, user, "holdout")
probes.append({"name": "候选实际执行新增缺件检查", "expected": 1, "actual": len(checks), "passed": len(checks) == 1 and checks[0]["result"] == "unknown" and len(apps) == 1})
return {"suite_version": "experience-scope-holdout@1.0", "baseline_checks": 0, "candidate_checks": len(checks), "probes": probes, "passed": all(p["passed"] for p in probes)}
class ExperiencesMixin:
def feedback(self, project_id, actor, payload):
if payload.get("category") not in CATEGORIES or not payload.get("note", "").strip():
raise ValueError("请选择反馈类型并填写内容")
with self.connect() as db:
get(db, "project", project_id)
target_kind, target_id = payload.get("target_kind"), payload.get("target_id")
if target_kind not in ("finding", "report", "run") or get(db, target_kind, target_id)["project_id"] != project_id:
raise ValueError("反馈对象不属于本项目")
feedback = {"id": uid("feedback"), "project_id": project_id, "actor_id": actor, "created_at": now(), **{k: payload[k] for k in ("category", "note", "target_kind", "target_id")}}
result = put(db, "feedback", feedback, immutable=True)
event(db, project_id, "feedback.created", actor, {"feedback_id": result["id"], "category": result["category"]})
return result
def save_experience(self, project_id, actor, payload, db=None):
required = ("title", "trigger", "action", "exceptions", "feedback_id", "required_field", "exception_field", "expires_at")
if any(not str(payload.get(k, "")).strip() for k in required) or payload.get("scope") not in ("project", "team"):
raise ValueError("经验须包含来源反馈、适用条件、建议动作、例外、执行字段、范围及有效期")
conditions = payload.get("conditions", {})
if not conditions or set(conditions) - set(FIELDS.values()) or payload["required_field"] not in FIELDS.values() or payload["exception_field"] not in FIELDS.values():
raise ValueError("经验触发条件和执行字段必须来自标准字段")
if payload["required_field"] in conditions:
raise ValueError("缺件检查字段不能同时是触发条件")
expires = datetime.fromisoformat(payload["expires_at"])
if expires.tzinfo is None or expires <= datetime.now(timezone.utc):
raise ValueError("有效期须为带时区的未来时间")
if payload["scope"] == "team" and not payload.get("team"):
raise ValueError("团队经验须指定团队")
with (nullcontext(db) if db is not None else self.connect()) as db:
project = get(db, "project", project_id)
feedback = get(db, "feedback", payload["feedback_id"])
if feedback["project_id"] != project_id:
raise ValueError("来源反馈不属于该项目")
if feedback["category"] in ("expression", "factual", "data"):
raise ValueError("表达、事实或数据纠正不自动升级为核查经验;请先完成相应修正")
body = {k: deepcopy(v) for k, v in payload.items() if k in set(required) | {"conditions", "scope", "team", "industry"}}
body.update(id=uid("experience"), project_id=project_id, actor_id=actor, edited_by=actor, version="1.0.0", status="draft", created_at=now(), data_domain=project["data_domain"])
body["component_id"] = body["id"]
result = put(db, "experience", body)
event(db, project_id, "experience.draft", actor, {"experience_id": body["id"]})
return result
def experience_action(self, project_id, experience_id, identity, action, expected, note):
if not note.strip():
raise ValueError("请填写审核或操作说明")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
body = get(db, "experience", experience_id)
if body["project_id"] != project_id:
raise KeyError("经验不存在")
if body["revision"] != expected:
raise Conflict("经验已更新")
actor = identity["id"]
if action == "evaluate":
if body["status"] != "draft":
raise Conflict("仅草稿可评测")
result = evaluate_experience(body)
evaluation = {"id": uid("evaluation"), "project_id": project_id, "experience_id": experience_id,
"candidate_hash": digest({k: v for k, v in body.items() if k not in ("revision", "evaluation_id")}), "created_at": now(), **result}
put(db, "evaluation", evaluation, immutable=True)
body["evaluation_id"] = evaluation["id"]
body["evaluation"] = result
elif action in ("publish", "reject", "rollback"):
if not identity.get("preview") and (identity["role"] != "reviewer" or actor == body["edited_by"]):
raise PermissionError("需要独立审核人")
if action == "publish":
if body["status"] != "draft" or not body.get("evaluation_id"):
raise Conflict("请先完成隔离评测")
evaluation = get(db, "evaluation", body["evaluation_id"])
actual_hash = digest({k: v for k, v in body.items() if k not in ("revision", "evaluation_id", "evaluation")})
if not evaluation["passed"] or actual_hash != evaluation["candidate_hash"]:
raise Conflict("评测失败或候选已改变,不允许发布")
if body["scope"] == "team":
project = get(db, "project", project_id)
public_text = " ".join(body[k] for k in ("title", "trigger", "action", "exceptions"))
if any(project.get(k) and project[k] in public_text for k in ("id", "customer", "name")):
raise ValueError("团队经验含来源项目标识,请脱敏后创建新候选")
body.update(status="published", approved_by=actor, published_at=now(), review_note=note)
published = dict(deepcopy(body), id=body["id"] + "@" + body["version"])
put(db, "experience_version", published, immutable=True)
put(db, "experience_release", {"id": body["id"], "project_id": project_id, "version_id": published["id"]})
elif action == "rollback":
if body["status"] != "published":
raise Conflict("仅已发布版本可以停用回滚")
body["status"] = "rolled_back"
db.execute("DELETE FROM assistant_objects WHERE kind='experience_release' AND id=?", (body["id"],))
else:
body.update(status="rejected", review_note=note)
else:
raise ValueError("不支持的经验动作")
result = put(db, "experience", body, expected=expected)
event(db, project_id, "experience." + action, actor, {"experience_id": body["id"], "note": note})
return result