246 lines
8.6 KiB
Python
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,
|
|
}
|