PMFlow/backend/app/api/sstc_api/charters_ai_alignment.py

259 lines
8.8 KiB
Python

from __future__ import annotations
from .shared import *
from ...services.sstc.charter_prefill_builder import _build_ai_first_charter_content
from ...services.sstc.charter_prefill_utils import _has_complete_module_data, _merge_prefill_content
from ...services.sstc.charter_seed_utils import _extract_charter_signal_ids
from ...services.sstc.entity_lookup import _ensure_charter, _ensure_project
from ...services.sstc.signal_core import _as_dict, _parse_node_id, _parse_project_id, _sync_pmflow_rag_async
router = APIRouter(tags=["sstc"])
class CharterAIPrefillRequest(BaseModel):
force: bool = False
raw_text: Optional[str] = None
@router.post("/projects/{project_id}/charters/{charter_id}/ai-prefill")
async def ai_prefill_charter(
project_id: str,
charter_id: str,
body: CharterAIPrefillRequest = Body(default_factory=CharterAIPrefillRequest),
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before_content = _as_dict(charter.content)
before_status = str(charter.status or "")
if not body.force and _has_complete_module_data(before_content):
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": before_content,
"status": charter.status,
"version": charter.version,
"prefilled": False,
"skipped": True,
}
)
signal_ids = _extract_charter_signal_ids(before_content, charter.signal_id)
signal_rows: list[Signal] = []
if signal_ids:
signal_rows = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id.in_(signal_ids),
)
)
)
).scalars().all()
raw_text = str(body.raw_text or before_content.get("raw_source_text") or "").strip()
if not signal_rows and not raw_text:
raise HTTPException(status_code=400, detail="未找到可用于预填的信号源或原始文本")
generated = await _build_ai_first_charter_content(
db=db,
tenant_id=tenant_id,
project_id=pid,
signal_rows=signal_rows,
signal_ids=signal_ids,
raw_text=raw_text,
)
merged = _merge_prefill_content(
before_content, generated, overwrite_existing=bool(body.force)
)
merged["signal_ids"] = signal_ids
if raw_text:
merged["raw_source_text"] = raw_text
charter.content = merged
charter.status = "draft"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(cid),
action="ai_prefill",
before_data={"content": before_content, "status": before_status},
after_data={"content": merged, "status": "draft"},
)
await db.commit()
await db.refresh(charter)
_sync_pmflow_rag_async(
tenant_id=tenant_id,
record_type="charter",
record_id=int(charter.id),
project_id=pid,
title=charter.title,
content=_as_dict(charter.content),
)
return success_response(
{
"id": str(charter.id),
"title": charter.title,
"content": _as_dict(charter.content),
"status": charter.status,
"version": charter.version,
"prefilled": True,
"skipped": False,
}
)
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/start")
async def start_alignment(
project_id: str,
charter_id: str,
body: AlignmentStartRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
await _ensure_project(SSTCService(db, tenant_id=tenant_id), pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
before = {"status": charter.status}
charter.status = "aligning"
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="charter",
entity_id=str(cid),
action="start_alignment",
before_data=before,
after_data={"status": charter.status, "note": body.note},
)
await db.commit()
return success_response({"id": str(cid), "status": charter.status})
@router.post("/projects/{project_id}/charters/{charter_id}/alignment/confirm")
async def confirm_alignment(
project_id: str,
charter_id: str,
body: AlignmentConfirmRequest,
permission_user: User = Depends(require_project_member_or_manager),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
_ = permission_user
pid = _parse_project_id(project_id)
cid = _parse_node_id(charter_id, "charter_id")
tenant_id = int(current_user.tenant_id)
svc = SSTCService(db, tenant_id=tenant_id)
await _ensure_project(svc, pid)
charter = await _ensure_charter(db, tenant_id=tenant_id, project_id=pid, charter_id=cid)
row_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.user_id == int(current_user.id),
AlignmentRecord.tenant_id == tenant_id,
)
)
)
row = row_result.scalar_one_or_none()
if row is None:
row = AlignmentRecord(
truth_node_id=cid,
user_id=int(current_user.id),
alignment_score=body.score,
is_confirmed=body.confirmed,
comments=body.comments,
tenant_id=tenant_id,
)
db.add(row)
else:
row.alignment_score = body.score
row.is_confirmed = body.confirmed
row.comments = body.comments
member_ids = await svc.get_project_member_ids(pid)
if not member_ids:
# 无项目成员时至少以当前操作者作为最小确认集合,避免状态永久卡在“对齐中”。
member_ids = [int(current_user.id)]
confirmed_map: set[int] = set()
rows_result = await db.execute(
select(AlignmentRecord).where(
and_(
AlignmentRecord.truth_node_id == cid,
AlignmentRecord.tenant_id == tenant_id,
AlignmentRecord.is_confirmed.is_(True),
)
)
)
for r in rows_result.scalars().all():
confirmed_map.add(int(r.user_id))
all_confirmed = set(member_ids).issubset(confirmed_map) if member_ids else False
if all_confirmed:
charter.status = "aligned"
elif (charter.status or "").lower() not in {"aligned"}:
charter.status = "aligning"
if all_confirmed:
content = _as_dict(charter.content)
signal_ids = [sid for sid in content.get("signal_ids", []) if isinstance(sid, int)]
if charter.signal_id and int(charter.signal_id) not in signal_ids:
signal_ids.append(int(charter.signal_id))
if signal_ids:
signal_rows = (
await db.execute(
select(Signal).where(
and_(
Signal.tenant_id == tenant_id,
Signal.id.in_(signal_ids),
)
)
)
).scalars().all()
for signal in signal_rows:
signal.status = "已关闭"
signal.processed = True
await log_operation(
db,
tenant_id=tenant_id,
user_id=int(current_user.id),
project_id=pid,
entity_type="alignment",
entity_id=f"{cid}:{current_user.id}",
action="confirm",
after_data={
"score": body.score,
"confirmed": body.confirmed,
"all_confirmed": all_confirmed,
"charter_status": charter.status,
},
)
await db.commit()
return success_response(
{
"charter_id": str(cid),
"charter_status": charter.status,
"all_confirmed": all_confirmed,
"confirmed_count": len(confirmed_map),
"member_count": len(member_ids),
}
)