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