PMFlow/backend/app/services/experiment_sync_service.py

302 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""实验结果同步服务(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