302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""实验结果同步服务(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
|