"""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