"""信号源脏数据修复服务。""" from __future__ import annotations import asyncio import os import re from typing import Any, Optional from sqlalchemy import and_, select from sqlalchemy.ext.asyncio import AsyncSession from ..models import SignalSourceRegistry, User from ..utils.audited_write import get_audit_context from ..utils.timezone import now_cn_naive from .notification_service import NotificationService from .operation_log_service import log_operation from .signal_capture import ProactiveSignalCaptureService class SignalSourceRepairError(RuntimeError): """信号源修复失败。""" class SignalSourceRepairTimeoutError(SignalSourceRepairError): """信号源修复超时。""" def __init__(self, *, registry_id: int, stage: str, timeout_seconds: int) -> None: super().__init__( f"signal source repair timeout: registry_id={int(registry_id)}, " f"stage={stage}, timeout_seconds={int(timeout_seconds)}" ) self.registry_id = int(registry_id) self.stage = str(stage) self.timeout_seconds = int(timeout_seconds) class SignalSourceRepairMissingSignalError(SignalSourceRepairError): """修复路径未拿到 signal_id。""" def __init__(self, *, registry_id: int, project_id: int, status: str) -> None: super().__init__( f"signal source repair failed: registry_id={int(registry_id)}, " f"project_id={int(project_id)}, capture_status={status or 'unknown'}" ) self.registry_id = int(registry_id) self.project_id = int(project_id) self.status = str(status or "") class SignalSourceRepairService: """通过 capture-feishu 接口补录信号并回填历史 registry.signal_id。""" def __init__(self, db: AsyncSession) -> None: self.db = db @staticmethod def _safe_int(value: Any) -> Optional[int]: try: if value is None: return None return int(value) except (TypeError, ValueError): return None @staticmethod def _resolve_timeout_seconds() -> int: raw = ( os.getenv("SIGNAL_SOURCE_REPAIR_TIMEOUT_SECONDS") or os.getenv("LLM_REQUEST_TIMEOUT") or "60" ) try: timeout = int(float(raw)) except (TypeError, ValueError): timeout = 60 return max(10, min(timeout, 300)) @staticmethod def _build_repair_message(*, project_id: int, registry_id: int, source_name: str) -> str: return ( f"给项目ID: {project_id} 补录历史接入记录 registry#{registry_id}({source_name})," "请仅执行入库,不需要二次处理。" ) @staticmethod def _build_explicit_repair_message(*, project_id: int, row: SignalSourceRegistry) -> str: source_kind = str(row.source_kind or "").strip()[:40] source_name = str(row.source_name or "历史信号源").strip()[:80] source_identifier = str(row.source_identifier or "").strip()[:180] return ( f"项目 {int(project_id)} 新增信号源:{source_name}。" f"source_kind={source_kind}; source_identifier={source_identifier}。" "这是历史脏数据补录,请执行 signal_source.onboard 入库并生成消息台记录。" ) @staticmethod def _build_capture_message_id(*, project_id: int, registry_id: int, stage: str) -> str: phase = "explicit" if str(stage or "").strip().lower() == "explicit" else "base" return f"om_registry_repair_{int(project_id)}_{int(registry_id)}_{phase}"[:120] async def _load_registry( self, *, tenant_id: int, project_id: int, registry_id: int, ) -> Optional[SignalSourceRegistry]: return ( await self.db.execute( select(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == int(tenant_id), SignalSourceRegistry.project_id == int(project_id), SignalSourceRegistry.id == int(registry_id), ) ).limit(1) ) ).scalar_one_or_none() @staticmethod def _build_group_chat_id(row: SignalSourceRegistry) -> str: raw = (row.config or {}).get("chat_id") or row.source_identifier or f"repair_registry_{int(row.id)}" return str(raw)[:120] @staticmethod def _extract_route_status(capture_result: dict[str, Any]) -> str: rows = capture_result.get("results") if not isinstance(rows, list) or not rows: return "" first = rows[0] if isinstance(rows[0], dict) else {} return str(first.get("route_status") or "") @classmethod def _extract_signal_id(cls, capture_result: dict[str, Any]) -> Optional[int]: candidates: list[Any] = [capture_result.get("signal_id")] for key in ("signal_source_workflow", "signal_source_orchestration", "ai_execution"): payload = capture_result.get(key) if isinstance(payload, dict): candidates.append(payload.get("signal_id")) rows = capture_result.get("results") if isinstance(rows, list) and rows and isinstance(rows[0], dict): candidates.append(rows[0].get("signal_id")) for item in candidates: value = cls._safe_int(item) if value is not None: return value return None @staticmethod def _normalize_identifier_key(value: Any) -> str: key = re.sub(r"\s+", "", str(value or "").strip().lower()) if not key: return "" for prefix in ( "feishu:chat:", "wecom:chat:", "feishu_chat_", "wecom_chat_", "feishu:", "wecom:", ): if key.startswith(prefix): key = key[len(prefix):] break return key[:180] async def _find_reusable_signal_id( self, *, tenant_id: int, project_id: int, row: SignalSourceRegistry, ) -> Optional[int]: target_key = self._normalize_identifier_key(row.source_identifier) if not target_key: return None candidates = ( await self.db.execute( select(SignalSourceRegistry).where( and_( SignalSourceRegistry.tenant_id == int(tenant_id), SignalSourceRegistry.project_id == int(project_id), SignalSourceRegistry.id != int(row.id), SignalSourceRegistry.signal_id.is_not(None), ) ) .order_by(SignalSourceRegistry.created_at.desc(), SignalSourceRegistry.id.desc()) .limit(50) ) ).scalars().all() for candidate in candidates: current_key = self._normalize_identifier_key(candidate.source_identifier) if current_key and current_key == target_key: return self._safe_int(candidate.signal_id) for candidate in candidates: current_key = self._normalize_identifier_key(candidate.source_identifier) if current_key and (current_key.endswith(target_key) or target_key.endswith(current_key)): return self._safe_int(candidate.signal_id) return None async def _capture_registry( self, *, capture_service: ProactiveSignalCaptureService, tenant_id: int, project_id: int, sender: str, row: SignalSourceRegistry, stage: str, timeout_seconds: int, ) -> dict[str, Any]: message = ( self._build_explicit_repair_message(project_id=project_id, row=row) if stage == "explicit" else self._build_repair_message( project_id=project_id, registry_id=int(row.id), source_name=str(row.source_name or "信号源"), ) ) coroutine = capture_service.capture_from_feishu( group_chat_id=self._build_group_chat_id(row), message_content=message, sender=sender, tenant_id=int(tenant_id), feishu_message_id=self._build_capture_message_id( project_id=project_id, registry_id=int(row.id), stage=stage, ), ) try: return await asyncio.wait_for(coroutine, timeout=timeout_seconds) except asyncio.TimeoutError as exc: raise SignalSourceRepairTimeoutError( registry_id=int(row.id), stage=stage, timeout_seconds=timeout_seconds, ) from exc async def _active_user_ids(self, *, tenant_id: int) -> list[int]: rows = ( await self.db.execute( select(User.id).where( and_( User.tenant_id == int(tenant_id), User.is_active.is_(True), ) ) ) ).scalars().all() return [int(item) for item in rows if item is not None] async def _emit_visibility_records( self, *, tenant_id: int, project_id: int, row: SignalSourceRegistry, signal_id: int, capture_status: str, ) -> None: dedupe_key = f"signal-source-repair:{int(tenant_id)}:{int(row.id)}:{int(signal_id)}" await NotificationService(self.db).create_notifications( tenant_id=int(tenant_id), user_ids=await self._active_user_ids(tenant_id=tenant_id), category="system", title="AI 已修复历史信号源关联", content=( f"项目ID: {int(project_id)}\n" f"信号源ID: {int(row.id)}\n" f"signal_id: {int(signal_id)}\n" f"status: {capture_status or 'relinked'}" ), source="ai_steward_repair", entity_type="signal_source_registry", entity_id=int(row.id), kind="info", priority="medium", status="notified", recommended_action="view_result", dedupe_key=dedupe_key, payload={ "project_id": int(project_id), "registry_id": int(row.id), "signal_id": int(signal_id), "capture_status": capture_status or "relinked", }, commit=False, ) context = get_audit_context() await log_operation( self.db, tenant_id=int(tenant_id), user_id=int(context.user_id or 1), project_id=int(project_id), entity_type="signal_source_registry", entity_id=str(int(row.id)), action="signal_source_registry.repair_link", after_data={ "signal_id": int(signal_id), "capture_status": capture_status or "relinked", }, remark="repair dirty signal source registry link", ) async def _persist_registry_link( self, *, tenant_id: int, project_id: int, row: SignalSourceRegistry, signal_id: int, capture_status: str, ) -> None: row.signal_id = int(signal_id) row.last_signal_at = now_cn_naive() await self._emit_visibility_records( tenant_id=tenant_id, project_id=project_id, row=row, signal_id=signal_id, capture_status=capture_status, ) await self.db.flush() def _build_item_result( self, *, row: SignalSourceRegistry, signal_id: Optional[int], capture_status: str, work_item_id: Optional[int], route_status: str, ) -> dict[str, Any]: return { "registry_id": int(row.id), "signal_id": signal_id, "capture_status": capture_status, "work_item_id": work_item_id, "route_status": route_status, } async def _repair_single_registry( self, *, capture_service: ProactiveSignalCaptureService, tenant_id: int, project_id: int, registry_id: int, sender: str, timeout_seconds: int, ) -> dict[str, Any]: row = await self._load_registry( tenant_id=tenant_id, project_id=project_id, registry_id=registry_id, ) if row is None: return {"registry_id": int(registry_id), "status": "not_found"} if row.signal_id is not None: return self._build_item_result( row=row, signal_id=self._safe_int(row.signal_id), capture_status="already_linked", work_item_id=None, route_status="", ) reusable_signal_id = await self._find_reusable_signal_id( tenant_id=tenant_id, project_id=project_id, row=row, ) if reusable_signal_id is not None: await self._persist_registry_link( tenant_id=tenant_id, project_id=project_id, row=row, signal_id=int(reusable_signal_id), capture_status="relinked", ) return self._build_item_result( row=row, signal_id=int(reusable_signal_id), capture_status="relinked", work_item_id=None, route_status="", ) capture_result = await self._capture_registry( capture_service=capture_service, tenant_id=tenant_id, project_id=project_id, sender=sender, row=row, stage="base", timeout_seconds=timeout_seconds, ) signal_id = self._extract_signal_id(capture_result) if signal_id is None: capture_result = await self._capture_registry( capture_service=capture_service, tenant_id=tenant_id, project_id=project_id, sender=sender, row=row, stage="explicit", timeout_seconds=timeout_seconds, ) signal_id = self._extract_signal_id(capture_result) if signal_id is None: signal_id = await self._find_reusable_signal_id( tenant_id=tenant_id, project_id=project_id, row=row, ) if signal_id is None: raise SignalSourceRepairMissingSignalError( registry_id=int(row.id), project_id=int(project_id), status=str(capture_result.get("status") or ""), ) capture_status = str(capture_result.get("status") or "captured") await self._persist_registry_link( tenant_id=tenant_id, project_id=project_id, row=row, signal_id=int(signal_id), capture_status=capture_status, ) return self._build_item_result( row=row, signal_id=int(signal_id), capture_status=capture_status, work_item_id=self._safe_int(capture_result.get("work_item_id")), route_status=self._extract_route_status(capture_result), ) async def repair_registries( self, *, tenant_id: int, project_id: int, registry_ids: list[int], sender: str, ) -> list[dict[str, Any]]: capture_service = ProactiveSignalCaptureService(self.db) timeout_seconds = self._resolve_timeout_seconds() results: list[dict[str, Any]] = [] try: for registry_id in registry_ids: item = await self._repair_single_registry( capture_service=capture_service, tenant_id=tenant_id, project_id=project_id, registry_id=int(registry_id), sender=sender, timeout_seconds=timeout_seconds, ) results.append(item) await self.db.commit() except Exception: await self.db.rollback() raise return results