"""实验结果同步服务(P1)""" from datetime import datetime, timezone from typing import Any, Dict, Optional, Set from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import Experiment, ExperimentTruthNodeSync, TruthNode from ..utils.timezone import now_cn_aware, now_cn_naive class ExperimentSyncService: """实验结果 -> 宪章同步,支持自动应用、人工审核与回滚。""" def __init__(self, db: AsyncSession): self.db = db async def auto_sync_experiment_result( self, experiment_id: int, truth_node_id: int, tenant_id: int, project_id: Optional[int] = None, sync_type: str = "result_sync", auto_apply_threshold: float = 0.8, ) -> Dict[str, Any]: experiment = await self._get_experiment(experiment_id, tenant_id) truth_node = await self._get_truth_node(truth_node_id, tenant_id) if not experiment or not truth_node: raise ValueError("实验或宪章不存在") if project_id is not None: await self.validate_experiment_truth_same_project( experiment=experiment, truth_node=truth_node, tenant_id=tenant_id, project_id=project_id, ) confidence = self._calc_confidence(experiment.current_score) sync_data = self._build_sync_data(experiment, truth_node, sync_type) auto_apply = confidence >= auto_apply_threshold record = ExperimentTruthNodeSync( tenant_id=tenant_id, experiment_id=experiment.id, experiment_external_id=str(experiment.id), truth_node_id=truth_node.id, sync_type=sync_type, sync_data=sync_data, confidence_score=confidence, auto_applied=auto_apply, apply_status="pending", ) if auto_apply: await self._apply_sync_to_truth_node(record, truth_node) record.apply_status = "applied" self.db.add(record) await self.db.commit() await self.db.refresh(record) return { "sync_id": record.id, "confidence_score": record.confidence_score, "auto_applied": record.auto_applied, "apply_status": record.apply_status, "sync_data": record.sync_data, } async def validate_experiment_truth_same_project( self, experiment: Experiment, truth_node: TruthNode, tenant_id: int, project_id: int, ) -> None: """交叉校验实验与宪章均属于指定项目。 注意:Experiment 当前无 project_id 字段,使用 linked_context_nodes 与项目宪章集合交叉验证。 """ if truth_node.project_id != project_id: raise ValueError( f"宪章 {truth_node.id} 不属于项目 {project_id},拒绝跨项目同步" ) # 强约束优先:Experiment.project_id 命中则直接使用。 if experiment.project_id is not None: if int(experiment.project_id) != int(project_id): raise ValueError( f"实验 {experiment.id} 不属于项目 {project_id},拒绝跨项目同步" ) return project_truth_nodes = await self._get_project_truth_node_ids(project_id, tenant_id) if not project_truth_nodes: raise ValueError(f"项目 {project_id} 下无可用宪章,无法校验实验归属") linked = experiment.linked_context_nodes or [] linked_ids = self._normalize_ids(linked) if not linked_ids: raise ValueError( "实验未关联任何上下文节点(linked_context_nodes 为空),无法通过项目归属校验" ) if linked_ids.isdisjoint(project_truth_nodes): raise ValueError( f"实验 {experiment.id} 未关联项目 {project_id} 下宪章,拒绝跨项目同步" ) # 迁移期兼容:可推断归属时补写 project_id,减少后续重复推断。 experiment.project_id = project_id async def _get_project_truth_node_ids(self, project_id: int, tenant_id: int) -> Set[int]: result = await self.db.execute( select(TruthNode.id).where( and_( TruthNode.tenant_id == tenant_id, TruthNode.project_id == project_id, ) ) ) return {int(row[0]) for row in result.all()} def _normalize_ids(self, raw: Any) -> Set[int]: ids: Set[int] = set() if isinstance(raw, list): for item in raw: try: ids.add(int(item)) except (TypeError, ValueError): continue return ids async def review_sync( self, sync_id: int, tenant_id: int, reviewer_id: int, action: str, review_notes: Optional[str] = None, ) -> Dict[str, Any]: record = await self._get_sync_record(sync_id, tenant_id) if not record: raise ValueError("同步记录不存在") if record.apply_status == "applied" and action == "approve": return {"sync_id": record.id, "apply_status": record.apply_status} record.reviewed_by = reviewer_id record.reviewed_at = now_cn_naive() record.review_notes = review_notes if action == "approve": truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) if truth_node: await self._apply_sync_to_truth_node(record, truth_node) record.apply_status = "applied" else: record.apply_status = "rejected" await self.db.commit() await self.db.refresh(record) return { "sync_id": record.id, "apply_status": record.apply_status, "reviewed_at": record.reviewed_at.isoformat() if record.reviewed_at else None, } async def rollback_sync( self, sync_id: int, tenant_id: int, reviewer_id: int, rollback_notes: Optional[str] = None, ) -> Dict[str, Any]: record = await self._get_sync_record(sync_id, tenant_id) if not record: raise ValueError("同步记录不存在") if record.apply_status != "applied": raise ValueError("仅已应用记录支持回滚") truth_node = await self._get_truth_node(record.truth_node_id, tenant_id) if not truth_node: raise ValueError("关联宪章不存在") snapshot = (record.sync_data or {}).get("previous_truth_content") if snapshot is None: raise ValueError("缺少回滚快照,无法回滚") truth_node.content = snapshot truth_node.version = int(truth_node.version or 1) + 1 record.apply_status = "requires_review" record.reviewed_by = reviewer_id record.reviewed_at = now_cn_naive() record.review_notes = rollback_notes or "manual rollback" await self.db.commit() await self.db.refresh(record) return { "sync_id": record.id, "apply_status": record.apply_status, "rolled_back": True, } async def list_sync_records( self, tenant_id: int, truth_node_id: Optional[int] = None, limit: int = 50, ) -> Dict[str, Any]: query = select(ExperimentTruthNodeSync).where( ExperimentTruthNodeSync.tenant_id == tenant_id ) if truth_node_id is not None: query = query.where(ExperimentTruthNodeSync.truth_node_id == truth_node_id) query = query.order_by(ExperimentTruthNodeSync.created_at.desc()).limit(limit) result = await self.db.execute(query) items = result.scalars().all() return { "items": [ { "id": i.id, "experiment_id": i.experiment_id, "truth_node_id": i.truth_node_id, "sync_type": i.sync_type, "confidence_score": i.confidence_score, "auto_applied": i.auto_applied, "apply_status": i.apply_status, "created_at": i.created_at.isoformat() if i.created_at else None, } for i in items ] } async def _get_experiment(self, experiment_id: int, tenant_id: int) -> Optional[Experiment]: result = await self.db.execute( select(Experiment).where( and_(Experiment.id == experiment_id, Experiment.tenant_id == tenant_id) ) ) return result.scalar_one_or_none() async def _get_truth_node(self, truth_node_id: int, tenant_id: int) -> Optional[TruthNode]: result = await self.db.execute( select(TruthNode).where( and_(TruthNode.id == truth_node_id, TruthNode.tenant_id == tenant_id) ) ) return result.scalar_one_or_none() async def _get_sync_record( self, sync_id: int, tenant_id: int ) -> Optional[ExperimentTruthNodeSync]: result = await self.db.execute( select(ExperimentTruthNodeSync).where( and_(ExperimentTruthNodeSync.id == sync_id, ExperimentTruthNodeSync.tenant_id == tenant_id) ) ) return result.scalar_one_or_none() def _calc_confidence(self, current_score: Optional[float]) -> float: score = float(current_score or 0.0) if score > 1: score /= 100.0 return round(max(0.0, min(1.0, score)), 2) def _build_sync_data( self, experiment: Experiment, truth_node: TruthNode, sync_type: str ) -> Dict[str, Any]: return { "sync_type": sync_type, "experiment_summary": { "id": experiment.id, "title": experiment.title, "status": experiment.status, "target_metric": experiment.target_metric, "current_score": experiment.current_score, }, "previous_truth_content": truth_node.content, } async def _apply_sync_to_truth_node( self, record: ExperimentTruthNodeSync, truth_node: TruthNode ) -> None: content = truth_node.content if isinstance(truth_node.content, dict) else {} if not isinstance(content, dict): content = {"raw": str(truth_node.content)} sync_section = content.get("experiment_sync", {}) sync_section["last_sync"] = { "sync_id": record.id, "sync_type": record.sync_type, "confidence_score": record.confidence_score, "experiment_summary": (record.sync_data or {}).get("experiment_summary", {}), "applied_at": now_cn_aware().isoformat(), } content["experiment_sync"] = sync_section truth_node.content = content truth_node.version = int(truth_node.version or 1) + 1