PMFlow/backend/app/services/signal_source_repair_servic...

466 lines
16 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.

"""信号源脏数据修复服务。"""
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