"""SSTC 看板聚合服务""" from __future__ import annotations from datetime import datetime, timezone from typing import Any, Iterable from sqlalchemy import and_, func, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import ( AlignmentRecord, CognitiveConflict, Project, ProjectSignalLink, Signal, Task, TruthNode, User, UserWorkload, ) DONE_STATUSES = {"done", "completed"} ACTIVE_STATUSES = {"in_progress", "review"} BLOCKED_STATUSES = {"blocked"} ALIGNED_STATUSES = {"aligned", "confirmed", "已对齐"} ALIGNING_STATUSES = {"aligning", "alignment_in_progress", "对齐中"} ARCHIVED_STATUSES = {"archived", "已归档"} RELEASE_VERIFIED_STATUSES = {"verified", "已验证"} RELEASE_LIVE_STATUSES = {"online", "deployed", "已上线"} def _to_json(value: Any, fallback: dict | None = None) -> dict: if fallback is None: fallback = {} if isinstance(value, dict): return value if isinstance(value, str): import json try: parsed = json.loads(value) return parsed if isinstance(parsed, dict) else {"text": value} except (TypeError, ValueError): return {"text": value} return fallback def _avg(values: Iterable[float]) -> float: values = list(values) if not values: return 0.0 return float(sum(values) / len(values)) class SSTCService: def __init__(self, db: AsyncSession, tenant_id: int): self.db = db self.tenant_id = tenant_id async def get_project(self, project_id: int) -> Project | None: result = await self.db.execute( select(Project).where( and_(Project.id == project_id, Project.tenant_id == self.tenant_id) ) ) return result.scalar_one_or_none() async def get_signals(self, project_id: int) -> list[Signal]: result = await self.db.execute( select(Signal) .join(ProjectSignalLink, ProjectSignalLink.signal_id == Signal.id) .where( and_( ProjectSignalLink.project_id == project_id, Signal.tenant_id == self.tenant_id, ) ) .order_by(Signal.created_at.desc()) ) return list(result.scalars().all()) async def get_charters(self, project_id: int) -> list[TruthNode]: result = await self.db.execute( select(TruthNode) .where( and_( TruthNode.project_id == project_id, TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "charter", ) ) .order_by(TruthNode.created_at.desc()) ) return list(result.scalars().all()) async def get_tasks(self, project_id: int) -> list[Task]: result = await self.db.execute( select(Task).where( and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) ) ) return list(result.scalars().all()) async def get_releases(self, project_id: int) -> list[TruthNode]: result = await self.db.execute( select(TruthNode) .where( and_( TruthNode.project_id == project_id, TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "release", ) ) .order_by(TruthNode.created_at.desc()) ) return list(result.scalars().all()) async def get_reviews(self, project_id: int) -> list[TruthNode]: result = await self.db.execute( select(TruthNode) .where( and_( TruthNode.project_id == project_id, TruthNode.tenant_id == self.tenant_id, TruthNode.node_type == "review", ) ) .order_by(TruthNode.created_at.desc()) ) return list(result.scalars().all()) async def get_alignment_stats(self, charter_ids: list[int]) -> dict[str, Any]: if not charter_ids: return {"score": 0.0, "pending_nodes": 0, "unresolved_conflicts": 0} ar_result = await self.db.execute( select(AlignmentRecord).where( and_( AlignmentRecord.truth_node_id.in_(charter_ids), AlignmentRecord.tenant_id == self.tenant_id, ) ) ) records = list(ar_result.scalars().all()) score = _avg([float(r.alignment_score or 0) for r in records]) node_confirmed: dict[int, set[int]] = {} for r in records: if r.is_confirmed: node_confirmed.setdefault(int(r.truth_node_id), set()).add(int(r.user_id)) pending_nodes = len([nid for nid in charter_ids if not node_confirmed.get(nid)]) conflict_result = await self.db.execute( select(func.count(CognitiveConflict.id)).where( and_( CognitiveConflict.truth_node_id.in_(charter_ids), CognitiveConflict.tenant_id == self.tenant_id, CognitiveConflict.status != "resolved", ) ) ) unresolved_conflicts = int(conflict_result.scalar() or 0) return { "score": round(score, 2), "pending_nodes": pending_nodes, "unresolved_conflicts": unresolved_conflicts, } async def get_workload_health(self, assignee_ids: list[int]) -> dict[str, Any]: if not assignee_ids: return { "avg_ratio": 0.0, "overloaded_count": 0, "idle_count": 0, "member_count": 0, } result = await self.db.execute( select(UserWorkload).where( and_( UserWorkload.tenant_id == self.tenant_id, UserWorkload.user_id.in_(assignee_ids), ) ) ) workloads = list(result.scalars().all()) ratios = [] for row in workloads: ratio = float(row.workload_ratio or 0) if ratio > 1.5: ratio = ratio / 100.0 ratios.append(ratio) avg_ratio = _avg(ratios) overloaded_count = len([x for x in ratios if x > 1.0]) idle_count = len([x for x in ratios if x < 0.3]) return { "avg_ratio": round(avg_ratio, 2), "overloaded_count": overloaded_count, "idle_count": idle_count, "member_count": len(assignee_ids), } @staticmethod def _task_metrics(tasks: list[Task]) -> dict[str, Any]: total = len(tasks) done = len([t for t in tasks if (t.status or "").lower() in DONE_STATUSES]) blocked = len([t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES]) in_progress = len([t for t in tasks if (t.status or "").lower() in ACTIVE_STATUSES]) today_done = len( [ t for t in tasks if (t.status or "").lower() in DONE_STATUSES and t.completed_at and t.completed_at.date() == datetime.now().date() ] ) overdue = len( [ t for t in tasks if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES ] ) return { "total": total, "done": done, "blocked": blocked, "in_progress": in_progress, "today_done": today_done, "overdue": overdue, "progress": round((done / total) * 100, 2) if total else 0, } async def build_overview(self, project_id: int) -> dict[str, Any]: project = await self.get_project(project_id) if not project: return {} signals = await self.get_signals(project_id) charters = await self.get_charters(project_id) tasks = await self.get_tasks(project_id) charters_ids = [int(c.id) for c in charters] alignment = await self.get_alignment_stats(charters_ids) metrics = self._task_metrics(tasks) assignee_ids = sorted({int(t.assignee_id) for t in tasks if t.assignee_id}) workload = await self.get_workload_health(assignee_ids) chain_id = str(charters[0].id) if charters else None return { "project": { "id": str(project.id), "title": project.title or project.name, "description": project.description, "status": project.status, "owner_id": str(project.owner_id) if project.owner_id else None, "created_at": project.created_at.isoformat() if project.created_at else None, "chain_id": chain_id, "signal_count": len(signals), "charter_count": len(charters), "task_count": len(tasks), }, "chain_id": chain_id, "alignment_score": alignment["score"], "pending_alignment_nodes": alignment["pending_nodes"], "unresolved_conflicts": alignment["unresolved_conflicts"], "task_progress": metrics, "workload_health": workload, } async def build_stages(self, project_id: int) -> list[dict[str, Any]]: signals = await self.get_signals(project_id) charters = await self.get_charters(project_id) tasks = await self.get_tasks(project_id) releases = await self.get_releases(project_id) reviews = await self.get_reviews(project_id) has_p0 = any((s.priority or "").lower() == "p0" for s in signals) has_pending_signal = any(not bool(s.processed) for s in signals) signal_status = "已同步" if has_p0 or has_pending_signal: signal_status = "待处理" latest_charter = charters[0] if charters else None charter_status_raw = (latest_charter.status if latest_charter else "").lower() if not latest_charter: charter_status = "草稿" elif charter_status_raw in ALIGNING_STATUSES: charter_status = "对齐中" elif charter_status_raw in ARCHIVED_STATUSES: charter_status = "已归档" elif charter_status_raw in ALIGNED_STATUSES: charter_status = "已对齐" else: charter_status = "草稿" charter_aligned = charter_status == "已对齐" if not charter_aligned: task_status = "未解锁" elif not tasks: task_status = "待确认" elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): task_status = "已确认" else: task_status = "待确认" if not tasks: execution_status = "未开始" elif all((t.status or "").lower() in DONE_STATUSES for t in tasks): execution_status = "已完成" elif any((t.status or "").lower() in (ACTIVE_STATUSES | BLOCKED_STATUSES) for t in tasks): execution_status = "进行中" else: execution_status = "未开始" execution_done = execution_status == "已完成" latest_release = releases[0] if releases else None release_raw = (latest_release.status if latest_release else "").lower() if not execution_done: release_status = "未解锁" elif not latest_release: release_status = "待发布" elif release_raw in RELEASE_VERIFIED_STATUSES: release_status = "已验证" elif release_raw in RELEASE_LIVE_STATUSES: release_status = "已上线" else: release_status = "待发布" release_verified = release_status == "已验证" latest_review = reviews[0] if reviews else None review_raw = (latest_review.status if latest_review else "").lower() if not release_verified: review_status = "未解锁" elif not latest_review: review_status = "草稿" elif review_raw in ARCHIVED_STATUSES: review_status = "已归档" else: review_status = "草稿" metrics = self._task_metrics(tasks) return [ { "key": "signal", "label": "信号源", "status": signal_status, "summary": f"{len(signals)} 条有效信号", "locked": False, "editable": True, "risk_count": int(has_p0), }, { "key": "charter", "label": "需求宪章", "status": charter_status, "summary": f"{len(charters)} 份宪章", "locked": False, "editable": True, "risk_count": 1 if charter_status == "对齐中" else 0, }, { "key": "task", "label": "任务拆解", "status": task_status, "summary": f"{len(tasks)} 个任务", "locked": not charter_aligned, "editable": charter_aligned, "risk_count": metrics["blocked"], }, { "key": "execution", "label": "执行落地", "status": execution_status, "summary": f"完成率 {metrics['progress']}%", "locked": not charter_aligned, "editable": charter_aligned, "risk_count": metrics["blocked"] + metrics["overdue"], }, { "key": "release", "label": "发布验证", "status": release_status, "summary": f"{len(releases)} 次发布", "locked": not execution_done, "editable": execution_done, "risk_count": 0, }, { "key": "review", "label": "复盘沉淀", "status": review_status, "summary": f"{len(reviews)} 份复盘", "locked": not release_verified, "editable": release_verified, "risk_count": 0, }, ] async def build_risks(self, project_id: int) -> dict[str, Any]: charters = await self.get_charters(project_id) tasks = await self.get_tasks(project_id) charter_ids = [int(c.id) for c in charters] conflict_rows = [] if charter_ids: conflict_result = await self.db.execute( select(CognitiveConflict).where( and_( CognitiveConflict.truth_node_id.in_(charter_ids), CognitiveConflict.tenant_id == self.tenant_id, CognitiveConflict.status != "resolved", ) ) ) conflict_rows = list(conflict_result.scalars().all()) blocked_tasks = [t for t in tasks if (t.status or "").lower() in BLOCKED_STATUSES] overdue_tasks = [ t for t in tasks if t.due_date and t.due_date < datetime.now(timezone.utc).replace(tzinfo=None) and (t.status or "").lower() not in DONE_STATUSES ] risk_items = [ { "id": f"task-blocked-{t.id}", "type": "task_blocked", "priority": "high", "title": f"任务阻塞:{t.title}", "entity_id": str(t.id), } for t in blocked_tasks ] + [ { "id": f"task-overdue-{t.id}", "type": "task_overdue", "priority": "medium", "title": f"任务逾期:{t.title}", "entity_id": str(t.id), } for t in overdue_tasks ] conflicts = [ { "id": str(c.id), "type": c.conflict_type, "priority": "high", "title": c.description[:80], "entity_id": str(c.truth_node_id), } for c in conflict_rows ] return { "conflicts": conflicts, "risks": risk_items, "summary": { "conflict_count": len(conflicts), "risk_count": len(risk_items), "blocked_task_count": len(blocked_tasks), "overdue_task_count": len(overdue_tasks), }, } async def build_chain(self, project_id: int) -> dict[str, Any]: project = await self.get_project(project_id) if not project: return {} signals = await self.get_signals(project_id) charters = await self.get_charters(project_id) tasks = await self.get_tasks(project_id) tasks_by_charter: dict[str, list[dict[str, Any]]] = {} for c in charters: tasks_by_charter[str(c.id)] = [ { "id": str(t.id), "title": t.title, "status": t.status, "priority": t.priority, "charter_id": str(c.id), "description": t.description, "estimated_hours": float(t.estimated_hours) if t.estimated_hours else None, "assignee_id": str(t.assignee_id) if t.assignee_id else None, } for t in tasks if int(t.truth_node_id) == int(c.id) ] return { "project": { "id": str(project.id), "title": project.title or project.name, "description": project.description, }, "signals": [ { "id": str(s.id), "source": s.source, "content": (s.content or "")[:240], "impact_score": s.impact_score, "priority": s.priority, "processed": s.processed, } for s in signals ], "charters": [ { "id": str(c.id), "title": c.title, "content": _to_json(c.content), "signal_ids": _to_json(c.content).get("signal_ids", []), "status": c.status, "version": c.version, "parent_id": str(c.parent_id) if c.parent_id else None, } for c in charters ], "tasks_by_charter": tasks_by_charter, } async def has_member_confirmed(self, charter_id: int, user_id: int) -> bool: result = await self.db.execute( select(func.count(AlignmentRecord.id)).where( and_( AlignmentRecord.tenant_id == self.tenant_id, AlignmentRecord.truth_node_id == charter_id, AlignmentRecord.user_id == user_id, AlignmentRecord.is_confirmed.is_(True), ) ) ) return int(result.scalar() or 0) > 0 async def get_project_member_ids(self, project_id: int) -> list[int]: # 现有 schema 未显式项目成员关系,按项目任务 assignee + owner 聚合 task_result = await self.db.execute( select(Task.assignee_id).where( and_(Task.project_id == project_id, Task.tenant_id == self.tenant_id) ) ) member_ids = {int(x) for x in task_result.scalars().all() if x is not None} project = await self.get_project(project_id) if project and project.owner_id: member_ids.add(int(project.owner_id)) if not member_ids: users_result = await self.db.execute( select(User.id).where(User.tenant_id == self.tenant_id).limit(5) ) member_ids.update(int(x) for x in users_result.scalars().all()) return sorted(member_ids)