PMFlow/backend/app/services/signal/signal_source_repair/mixin_persist.py

246 lines
8.6 KiB
Python

"""Persistence and visibility logging helpers for repair workflow."""
from __future__ import annotations
from .shared import (
NotificationService,
ProjectSignalLink,
Signal,
SignalSourceRegistry,
and_,
get_audit_context,
json,
log_operation,
now_cn_naive,
select,
)
class SignalSourceRepairPersistMixin:
"""Persist repaired links and emit visibility records."""
@staticmethod
def _build_signal_source_value(*, row: SignalSourceRegistry) -> str:
candidate = str(row.source_identifier or "").strip() or str(row.source_kind or "").strip() or "signal_source"
return candidate[:50]
@staticmethod
def _build_signal_priority(*, row: SignalSourceRegistry) -> str:
config = row.config if isinstance(row.config, dict) else {}
raw_context = row.raw_context if isinstance(row.raw_context, dict) else {}
requirement_item = raw_context.get("requirement_item") if isinstance(raw_context.get("requirement_item"), dict) else {}
raw_priority = str(config.get("priority") or requirement_item.get("priority") or "medium").strip().lower()
if raw_priority in {"p0", "critical", "high"}:
return "high"
if raw_priority in {"p2", "low"}:
return "low"
return "medium"
@staticmethod
def _build_signal_content(*, row: SignalSourceRegistry) -> str:
lines = [f"信号源:{str(row.source_name or '未命名信号源').strip()}"]
description = str(row.description or "").strip()
if description:
lines.append(description)
source_kind = str(row.source_kind or "").strip()
if source_kind:
lines.append(f"来源类型:{source_kind}")
source_identifier = str(row.source_identifier or "").strip()
if source_identifier:
lines.append(f"来源标识:{source_identifier}")
return "\n".join(line for line in lines if line).strip()[:2000]
@staticmethod
def _build_signal_analysis(
*,
project_id: int,
row: SignalSourceRegistry,
) -> str:
payload = {
"source_context": {
"registry_id": int(row.id),
"source_kind": str(row.source_kind or "").strip(),
"source_name": str(row.source_name or "").strip(),
"source_identifier": str(row.source_identifier or "").strip(),
"requested_via": str(row.requested_via or "").strip(),
},
"ai_analysis": {
"requirement_project_scope": "existing_project",
"reason": "signal_source_registry_repair_materialized",
"target_project_hints": [int(project_id)],
},
}
return json.dumps(payload, ensure_ascii=False)
async def _ensure_project_signal_link(
self,
*,
tenant_id: int,
project_id: int,
signal_id: int,
) -> None:
existing = (
await self.db.execute(
select(ProjectSignalLink.id).where(
and_(
ProjectSignalLink.tenant_id == int(tenant_id),
ProjectSignalLink.project_id == int(project_id),
ProjectSignalLink.signal_id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return
self.db.add(
ProjectSignalLink(
tenant_id=int(tenant_id),
project_id=int(project_id),
signal_id=int(signal_id),
link_type="related",
relevance_score=1.0,
)
)
async def _materialize_registry_signal(
self,
*,
tenant_id: int,
project_id: int,
row: SignalSourceRegistry,
) -> Signal:
summary = str(row.description or row.source_name or "信号源").strip()
signal = Signal(
tenant_id=int(tenant_id),
source=self._build_signal_source_value(row=row),
content=self._build_signal_content(row=row),
impact_score=0.6,
priority=self._build_signal_priority(row=row),
processed=False,
strategic_analysis=self._build_signal_analysis(project_id=project_id, row=row),
tasks_generated=json.dumps([], ensure_ascii=False),
matched_project_id=int(project_id),
title=(str(row.source_name or "未命名信号源").strip() or "未命名信号源")[:80],
signal_type="新需求",
summary=summary[:300] or "信号源物化记录",
reasoning=json.dumps(["由 signal_source_registry 修复流程直接物化为正式 Signal。"], ensure_ascii=False),
confidence=0.85,
recommended_action="enter_only",
status="待处理",
)
self.db.add(signal)
await self.db.flush()
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(signal.id),
)
return signal
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()
signal = (
await self.db.execute(
select(Signal).where(
and_(
Signal.tenant_id == int(tenant_id),
Signal.id == int(signal_id),
)
).limit(1)
)
).scalar_one_or_none()
if signal is not None and getattr(signal, "matched_project_id", None) is None:
signal.matched_project_id = int(project_id)
await self._ensure_project_signal_link(
tenant_id=tenant_id,
project_id=project_id,
signal_id=int(signal_id),
)
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: int | None,
capture_status: str,
work_item_id: int | None,
route_status: str,
) -> dict[str, object]:
return {
"registry_id": int(row.id),
"signal_id": signal_id,
"capture_status": capture_status,
"work_item_id": work_item_id,
"route_status": route_status,
}