1932 lines
82 KiB
Python
1932 lines
82 KiB
Python
"""信号源接入编排服务。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import re
|
||
import time
|
||
from datetime import datetime, timezone
|
||
from types import SimpleNamespace
|
||
from typing import Any, Optional
|
||
from uuid import uuid4
|
||
|
||
from sqlalchemy import and_, select
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_prompts import (
|
||
PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION,
|
||
PROMPT_SIGNAL_SOURCE_ONBOARDING,
|
||
)
|
||
from ..models import (
|
||
AIWorkerExecution,
|
||
Project,
|
||
Signal,
|
||
SignalSourceRegistry,
|
||
User,
|
||
)
|
||
from ..database import AsyncSessionLocal
|
||
from ..utils.timezone import now_cn_naive
|
||
from .ai_orchestration_run_service import AIOrchestrationRunService
|
||
from .prompt_profile_service import prompt_profile_service
|
||
from .llm import llm_service
|
||
|
||
SOURCE_ONBOARDING_ACTION_RE = re.compile(
|
||
r"(新增|增加|添加|接入|配置|注册|建立|加个|加一个|加).{0,8}(信号源|来源|监控源|channel|source|webhook|飞书|github|url|rss)",
|
||
re.IGNORECASE,
|
||
)
|
||
SOURCE_KIND_HINTS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||
("feishu", ("飞书", "lark", "群聊", "chat")),
|
||
("github", ("github", "repo", "仓库", "pull request", "issue")),
|
||
("webhook", ("webhook", "回调", "回传")),
|
||
("url", ("url", "链接", "网页", "站点", "rss")),
|
||
("browser", ("浏览器", "标签页", "页面")),
|
||
("pmflow", ("pmflow", "内置ai", "ai管家", "steward")),
|
||
)
|
||
DEFAULT_WORKER_ROLE_KEYS = ("pm_dispatcher", "tech_architect", "delivery_coach")
|
||
MAX_SOURCE_NAME_LEN = 120
|
||
MAX_SOURCE_IDENTIFIER_LEN = 180
|
||
WORKER_MAX_CONCURRENCY = 3
|
||
COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS = 6.0
|
||
|
||
|
||
def _utc_now_naive() -> datetime:
|
||
return now_cn_naive()
|
||
|
||
|
||
class SignalSourceOrchestrationService:
|
||
"""把“新增信号源”转换为 OpenClaw 指挥与下级 AI worker 执行链。"""
|
||
|
||
session_factory = AsyncSessionLocal
|
||
|
||
def __init__(self, db: AsyncSession) -> None:
|
||
self.db = db
|
||
|
||
@classmethod
|
||
def looks_like_signal_source_request(cls, text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or ""))
|
||
if len(normalized) < 3:
|
||
return False
|
||
if "信号源" in normalized and any(
|
||
token in normalized
|
||
for token in ("新增", "增加", "添加", "接入", "配置", "注册", "建立", "加个", "加一个", "加")
|
||
):
|
||
return True
|
||
return bool(SOURCE_ONBOARDING_ACTION_RE.search(normalized))
|
||
|
||
async def orchestrate_from_signal(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
signal_id: Optional[int],
|
||
initiating_text: str,
|
||
requested_via: str,
|
||
operator_user_id: Optional[int] = None,
|
||
dry_run: bool = False,
|
||
response_mode: str = "fast",
|
||
) -> dict[str, Any]:
|
||
response_mode = "complete" if str(response_mode or "").strip().lower() == "complete" else "fast"
|
||
trace_id = f"source-onboard-{tenant_id}-{project_id}-{int(time.time() * 1000)}-{uuid4().hex[:8]}"
|
||
project = await self._load_project(tenant_id=tenant_id, project_id=project_id)
|
||
signal = await self._load_signal(tenant_id=tenant_id, signal_id=signal_id) if signal_id is not None else None
|
||
run_service = AIOrchestrationRunService(self.db) if self.db is not None else None
|
||
if project is None:
|
||
return {
|
||
"trace_id": trace_id,
|
||
"status": "skipped",
|
||
"reason": "project_or_signal_not_found",
|
||
}
|
||
if signal is None:
|
||
if dry_run:
|
||
signal = self._build_ephemeral_signal(initiating_text=initiating_text)
|
||
else:
|
||
return {
|
||
"trace_id": trace_id,
|
||
"status": "skipped",
|
||
"reason": "project_or_signal_not_found",
|
||
}
|
||
project_ref = SimpleNamespace(
|
||
id=int(project.id),
|
||
title=str(project.title or "").strip(),
|
||
name=str(project.name or "").strip(),
|
||
description=str(project.description or "").strip(),
|
||
owner_id=int(project.owner_id) if getattr(project, "owner_id", None) else None,
|
||
)
|
||
signal_ref = SimpleNamespace(
|
||
id=int(signal.id) if getattr(signal, "id", None) is not None else None,
|
||
title=str(signal.title or "").strip(),
|
||
summary=str(signal.summary or "").strip(),
|
||
signal_type=str(signal.signal_type or "").strip(),
|
||
recommended_action=str(signal.recommended_action or "").strip(),
|
||
content=str(signal.content or "").strip(),
|
||
)
|
||
real_signal_id = signal_ref.id
|
||
persisted_signal_id = int(real_signal_id) if real_signal_id and int(real_signal_id) > 0 else None
|
||
|
||
if not self.looks_like_signal_source_request(initiating_text):
|
||
result = {
|
||
"trace_id": trace_id,
|
||
"status": "skipped",
|
||
"run_status": "completed",
|
||
"response_mode": response_mode,
|
||
"planner_status": "skipped",
|
||
"planner_diagnostics": None,
|
||
"summary": "当前输入不属于信号源接入请求。",
|
||
"pending_workers": [],
|
||
"worker_executions": [],
|
||
"openclaw": {"status": "skipped", "reason": "not_signal_source_request"},
|
||
"reason": "not_signal_source_request",
|
||
}
|
||
if run_service is not None:
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="signal_source_onboarding",
|
||
project_id=project_id,
|
||
signal_id=persisted_signal_id,
|
||
response_mode=response_mode,
|
||
status="completed",
|
||
planner_status="skipped",
|
||
openclaw_status="skipped",
|
||
worker_total=0,
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(result.get("summary") or "").strip(),
|
||
planner_diagnostics=None,
|
||
result_payload=dict(result),
|
||
created_by_user_id=operator_user_id,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
await self.db.commit()
|
||
return result
|
||
|
||
plan, planner_status, planner_diagnostics = await self._plan_onboarding(
|
||
tenant_id=tenant_id,
|
||
project=project_ref,
|
||
signal=signal_ref,
|
||
initiating_text=initiating_text,
|
||
requested_via=requested_via,
|
||
response_mode=response_mode,
|
||
)
|
||
if not bool(plan.get("should_onboard")):
|
||
result = {
|
||
"trace_id": trace_id,
|
||
"status": "skipped",
|
||
"run_status": "completed",
|
||
"response_mode": response_mode,
|
||
"reason": "planner_rejected",
|
||
"planner_status": planner_status,
|
||
"planner_diagnostics": planner_diagnostics,
|
||
"summary": str(plan.get("summary") or "").strip(),
|
||
"pending_workers": [],
|
||
"worker_executions": [],
|
||
"openclaw": {"status": "skipped", "reason": "planner_rejected"},
|
||
}
|
||
if run_service is not None:
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="signal_source_onboarding",
|
||
project_id=project_id,
|
||
signal_id=persisted_signal_id,
|
||
response_mode=response_mode,
|
||
status="completed",
|
||
planner_status=planner_status,
|
||
openclaw_status="skipped",
|
||
worker_total=0,
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(result),
|
||
created_by_user_id=operator_user_id,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
await self.db.commit()
|
||
return result
|
||
|
||
source_payload = plan.get("source") if isinstance(plan.get("source"), dict) else {}
|
||
worker_role_keys = self._normalize_worker_roles(plan.get("worker_roles"))
|
||
registry_result = await self._create_or_reuse_registry(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
signal_id=signal_id,
|
||
operator_user_id=operator_user_id,
|
||
requested_via=requested_via,
|
||
source_payload=source_payload,
|
||
initiating_text=initiating_text,
|
||
dry_run=dry_run,
|
||
)
|
||
|
||
operator_user = await self._resolve_operator_user(
|
||
tenant_id=tenant_id,
|
||
project=project_ref,
|
||
operator_user_id=operator_user_id,
|
||
)
|
||
operator_ref = self._build_user_snapshot(operator_user)
|
||
openclaw_result = await self._dispatch_openclaw(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_ref,
|
||
project=project_ref,
|
||
instruction=str(plan.get("openclaw_instruction") or "").strip(),
|
||
dry_run=dry_run,
|
||
response_mode=response_mode,
|
||
)
|
||
openclaw_status = str(openclaw_result.get("status") or "").strip().lower()
|
||
base_result = self._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=response_mode,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=str(plan.get("summary") or "").strip(),
|
||
registry_result=registry_result,
|
||
source_payload=source_payload,
|
||
openclaw_result=openclaw_result,
|
||
worker_results=[],
|
||
pending_workers=list(worker_role_keys),
|
||
status="planned" if dry_run else "partial",
|
||
run_status="completed" if dry_run or not worker_role_keys else "running",
|
||
)
|
||
|
||
if dry_run:
|
||
planned_results = self._build_planned_worker_results(worker_role_keys)
|
||
final_result = self._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=response_mode,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=str(plan.get("summary") or "").strip(),
|
||
registry_result=registry_result,
|
||
source_payload=source_payload,
|
||
openclaw_result=openclaw_result,
|
||
worker_results=planned_results,
|
||
pending_workers=[],
|
||
status="planned",
|
||
run_status="completed",
|
||
)
|
||
if run_service is not None:
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="signal_source_onboarding",
|
||
project_id=project_id,
|
||
signal_id=persisted_signal_id,
|
||
response_mode=response_mode,
|
||
status="completed",
|
||
planner_status=planner_status,
|
||
openclaw_status=openclaw_status,
|
||
worker_total=len(worker_role_keys),
|
||
worker_completed=len(planned_results),
|
||
worker_failed=0,
|
||
summary=str(final_result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(final_result),
|
||
created_by_user_id=operator_user_id,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
await self.db.commit()
|
||
return final_result
|
||
|
||
if not worker_role_keys:
|
||
registry_status, onboarding_status = self._resolve_registry_activation(
|
||
source_payload=source_payload,
|
||
openclaw_result=openclaw_result,
|
||
worker_results=[],
|
||
)
|
||
final_status = self._resolve_orchestration_status(openclaw_status=openclaw_status, worker_results=[])
|
||
if final_status == "completed" and onboarding_status != "completed":
|
||
final_status = "pending_manual"
|
||
final_result = self._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=response_mode,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=str(plan.get("summary") or "").strip(),
|
||
registry_result={
|
||
"registry_id": registry_result.get("registry_id"),
|
||
"status": registry_status,
|
||
"onboarding_status": onboarding_status,
|
||
},
|
||
source_payload=source_payload,
|
||
openclaw_result=openclaw_result,
|
||
worker_results=[],
|
||
pending_workers=[],
|
||
status=final_status,
|
||
run_status="completed",
|
||
)
|
||
if run_service is not None:
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="signal_source_onboarding",
|
||
project_id=project_id,
|
||
signal_id=persisted_signal_id,
|
||
response_mode=response_mode,
|
||
status=final_status,
|
||
planner_status=planner_status,
|
||
openclaw_status=openclaw_status,
|
||
worker_total=0,
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(final_result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(final_result),
|
||
created_by_user_id=operator_user_id,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
if registry_result.get("registry_id") is not None:
|
||
registry_row = await self._load_signal_source_registry(
|
||
tenant_id=tenant_id,
|
||
registry_id=int(registry_result["registry_id"]),
|
||
)
|
||
if registry_row is not None:
|
||
registry_row.last_signal_at = _utc_now_naive()
|
||
registry_row.last_worker_run_at = _utc_now_naive()
|
||
registry_row.status = registry_status
|
||
registry_row.onboarding_status = onboarding_status
|
||
await self.db.commit()
|
||
if real_signal_id:
|
||
persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(real_signal_id))
|
||
if persisted_signal is not None:
|
||
await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result)
|
||
return final_result
|
||
|
||
if run_service is not None:
|
||
await run_service.create_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
run_type="signal_source_onboarding",
|
||
project_id=project_id,
|
||
signal_id=persisted_signal_id,
|
||
response_mode=response_mode,
|
||
status="running",
|
||
planner_status=planner_status,
|
||
openclaw_status=openclaw_status,
|
||
worker_total=len(worker_role_keys),
|
||
worker_completed=0,
|
||
worker_failed=0,
|
||
summary=str(base_result.get("summary") or "").strip(),
|
||
planner_diagnostics=planner_diagnostics,
|
||
result_payload=dict(base_result),
|
||
created_by_user_id=operator_user_id,
|
||
)
|
||
await self.db.commit()
|
||
|
||
task = asyncio.create_task(
|
||
type(self)._background_finalize_with_guard(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
project_payload=self._serialize_snapshot(project_ref),
|
||
signal_payload=self._serialize_snapshot(signal_ref),
|
||
registry_result=dict(registry_result),
|
||
openclaw_result=dict(openclaw_result),
|
||
worker_role_keys=list(worker_role_keys),
|
||
operator_user_payload=self._serialize_snapshot(operator_ref),
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=str(plan.get("summary") or "").strip(),
|
||
signal_id=persisted_signal_id,
|
||
),
|
||
name=f"signal-source-workers-{trace_id}",
|
||
)
|
||
if response_mode == "fast":
|
||
return base_result
|
||
try:
|
||
return await asyncio.wait_for(asyncio.shield(task), timeout=COMPLETE_WORKER_WAIT_TIMEOUT_SECONDS)
|
||
except asyncio.TimeoutError:
|
||
if run_service is None:
|
||
return base_result
|
||
run_row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
||
if run_row is None:
|
||
return base_result
|
||
snapshot = AIOrchestrationRunService.serialize_run(run_row)
|
||
payload = dict(run_row.result_payload or base_result)
|
||
payload["run_status"] = str(snapshot.get("run_status") or "running")
|
||
payload["status"] = str(payload.get("status") or "partial")
|
||
payload["pending_workers"] = snapshot.get("pending_workers") or payload.get("pending_workers") or []
|
||
return payload
|
||
|
||
async def list_project_sources(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
limit: int = 20,
|
||
) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(SignalSourceRegistry).where(
|
||
and_(
|
||
SignalSourceRegistry.tenant_id == tenant_id,
|
||
SignalSourceRegistry.project_id == project_id,
|
||
)
|
||
).order_by(SignalSourceRegistry.updated_at.desc()).limit(max(1, min(limit, 100)))
|
||
)
|
||
).scalars().all()
|
||
data: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
executions = (
|
||
await self.db.execute(
|
||
select(AIWorkerExecution).where(
|
||
and_(
|
||
AIWorkerExecution.tenant_id == tenant_id,
|
||
AIWorkerExecution.signal_source_registry_id == int(row.id),
|
||
)
|
||
).order_by(AIWorkerExecution.created_at.desc()).limit(6)
|
||
)
|
||
).scalars().all()
|
||
data.append(
|
||
{
|
||
"id": int(row.id),
|
||
"project_id": int(row.project_id),
|
||
"signal_id": int(row.signal_id) if row.signal_id else None,
|
||
"source_kind": str(row.source_kind or ""),
|
||
"source_name": str(row.source_name or ""),
|
||
"source_identifier": str(row.source_identifier or ""),
|
||
"requested_via": str(row.requested_via or ""),
|
||
"status": str(row.status or ""),
|
||
"onboarding_status": str(row.onboarding_status or ""),
|
||
"updated_at": row.updated_at.isoformat() if row.updated_at else None,
|
||
"worker_executions": [
|
||
{
|
||
"id": int(item.id),
|
||
"worker_role_key": str(item.worker_role_key or ""),
|
||
"worker_role_name": str(item.worker_role_name or ""),
|
||
"status": str(item.status or ""),
|
||
"summary": str(item.summary or ""),
|
||
"completed_at": item.completed_at.isoformat() if item.completed_at else None,
|
||
}
|
||
for item in executions
|
||
],
|
||
}
|
||
)
|
||
return data
|
||
|
||
async def _plan_onboarding(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project: Project,
|
||
signal: Signal,
|
||
initiating_text: str,
|
||
requested_via: str,
|
||
response_mode: str,
|
||
) -> tuple[dict[str, Any], str, dict[str, Any]]:
|
||
request_payload = {
|
||
"requested_via": requested_via,
|
||
"initiating_text": initiating_text[:600],
|
||
}
|
||
project_payload = {
|
||
"project_id": int(project.id),
|
||
"title": str(project.title or project.name or "").strip(),
|
||
"description": str(project.description or "").strip()[:400],
|
||
}
|
||
signal_payload = {
|
||
"signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None,
|
||
"title": str(signal.title or "").strip(),
|
||
"summary": str(signal.summary or "").strip()[:400],
|
||
"signal_type": str(signal.signal_type or "").strip(),
|
||
"recommended_action": str(signal.recommended_action or "").strip(),
|
||
}
|
||
prompt = PROMPT_SIGNAL_SOURCE_ONBOARDING.format(
|
||
request_json=json.dumps(request_payload, ensure_ascii=False),
|
||
project_json=json.dumps(project_payload, ensure_ascii=False),
|
||
signal_json=json.dumps(signal_payload, ensure_ascii=False),
|
||
)
|
||
try:
|
||
extraction = await llm_service.generate_structured_object(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=tenant_id,
|
||
function_name="signal_source_onboarding_plan",
|
||
use_prompt_profile=False,
|
||
max_tokens=420,
|
||
response_mode=response_mode,
|
||
)
|
||
diagnostics = self._build_planner_diagnostics(extraction)
|
||
if extraction.payload is None:
|
||
return (
|
||
self._heuristic_onboarding_plan(
|
||
initiating_text=initiating_text,
|
||
requested_via=requested_via,
|
||
error=str(extraction.error_code or "structured_unknown"),
|
||
),
|
||
"fallback",
|
||
diagnostics,
|
||
)
|
||
plan = extraction.payload
|
||
normalized = self._normalize_onboarding_plan(
|
||
plan=plan,
|
||
initiating_text=initiating_text,
|
||
requested_via=requested_via,
|
||
)
|
||
return normalized, "planned", diagnostics
|
||
except Exception as exc:
|
||
return (
|
||
self._heuristic_onboarding_plan(
|
||
initiating_text=initiating_text,
|
||
requested_via=requested_via,
|
||
error=str(exc),
|
||
),
|
||
"fallback",
|
||
{
|
||
"used_fallback": True,
|
||
"error_code": "structured_empty",
|
||
"source_field": None,
|
||
"provider": "",
|
||
"model": "",
|
||
},
|
||
)
|
||
|
||
def _normalize_onboarding_plan(
|
||
self,
|
||
*,
|
||
plan: dict[str, Any],
|
||
initiating_text: str,
|
||
requested_via: str,
|
||
) -> dict[str, Any]:
|
||
should_onboard = bool(plan.get("should_onboard"))
|
||
summary = str(plan.get("summary") or "").strip()[:300]
|
||
source = plan.get("source") if isinstance(plan.get("source"), dict) else {}
|
||
worker_roles = self._normalize_worker_roles(plan.get("worker_roles"))
|
||
if not should_onboard:
|
||
return {
|
||
"should_onboard": False,
|
||
"summary": summary or "未识别为新增信号源请求。",
|
||
"source": None,
|
||
"worker_roles": [],
|
||
"openclaw_instruction": "",
|
||
}
|
||
normalized_source = self._normalize_source_payload(
|
||
source_payload=source,
|
||
fallback_text=initiating_text,
|
||
requested_via=requested_via,
|
||
)
|
||
return {
|
||
"should_onboard": True,
|
||
"summary": summary or f"准备接入信号源 {normalized_source['source_name']}。",
|
||
"source": normalized_source,
|
||
"worker_roles": worker_roles,
|
||
"openclaw_instruction": (
|
||
str(plan.get("openclaw_instruction") or "").strip()
|
||
or self._build_openclaw_instruction(normalized_source, initiating_text)
|
||
)[:600],
|
||
}
|
||
|
||
def _heuristic_onboarding_plan(
|
||
self,
|
||
*,
|
||
initiating_text: str,
|
||
requested_via: str,
|
||
error: str,
|
||
) -> dict[str, Any]:
|
||
source = self._normalize_source_payload(
|
||
source_payload={},
|
||
fallback_text=initiating_text,
|
||
requested_via=requested_via,
|
||
)
|
||
return {
|
||
"should_onboard": True,
|
||
"summary": "系统已按规则兜底为信号源接入请求,并生成最小接入闭环。",
|
||
"source": source,
|
||
"worker_roles": list(DEFAULT_WORKER_ROLE_KEYS),
|
||
"openclaw_instruction": self._build_openclaw_instruction(source, initiating_text),
|
||
}
|
||
|
||
def _normalize_source_payload(
|
||
self,
|
||
*,
|
||
source_payload: dict[str, Any],
|
||
fallback_text: str,
|
||
requested_via: str,
|
||
) -> dict[str, Any]:
|
||
source_kind = self._normalize_source_kind(source_payload.get("source_kind"), text=fallback_text, requested_via=requested_via)
|
||
source_name = self._clean_source_name_candidate(str(source_payload.get("source_name") or "").strip())
|
||
if not source_name:
|
||
source_name = self._extract_source_name(fallback_text, default=f"{source_kind}信号源")
|
||
source_name = source_name[:MAX_SOURCE_NAME_LEN] or f"{source_kind}信号源"
|
||
source_identifier = str(source_payload.get("source_identifier") or "").strip()
|
||
if source_identifier:
|
||
existing_kind = source_identifier.split(":", 1)[0].strip().lower()
|
||
if existing_kind and existing_kind != source_kind:
|
||
source_identifier = ""
|
||
if not source_identifier:
|
||
source_identifier = self._build_source_identifier(
|
||
source_kind=source_kind,
|
||
source_name=source_name,
|
||
text=fallback_text,
|
||
source_payload=source_payload,
|
||
)
|
||
description = str(source_payload.get("description") or "").strip()[:1000] or fallback_text[:300]
|
||
config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}
|
||
return {
|
||
"source_kind": source_kind,
|
||
"source_name": source_name,
|
||
"source_identifier": source_identifier[:MAX_SOURCE_IDENTIFIER_LEN],
|
||
"description": description,
|
||
"config": config,
|
||
}
|
||
|
||
@staticmethod
|
||
def _normalize_source_kind(raw_kind: Any, *, text: str, requested_via: str) -> str:
|
||
cleaned = str(raw_kind or "").strip().lower()
|
||
for kind, hints in SOURCE_KIND_HINTS:
|
||
if cleaned == kind:
|
||
return kind
|
||
if any(token in cleaned for token in hints):
|
||
return kind
|
||
text_lower = str(text or "").lower()
|
||
for kind, hints in SOURCE_KIND_HINTS:
|
||
if any(token in text_lower for token in hints):
|
||
return kind
|
||
via = str(requested_via or "").strip().lower()
|
||
if via in {"feishu", "pmflow", "github", "browser"}:
|
||
return via
|
||
return "custom"
|
||
|
||
@classmethod
|
||
def _extract_source_name(cls, text: str, *, default: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
if not cleaned:
|
||
return default
|
||
repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", cleaned)
|
||
if repo_match:
|
||
return repo_match.group(1)[:MAX_SOURCE_NAME_LEN]
|
||
quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned)
|
||
if quoted_match:
|
||
candidate = cls._clean_source_name_candidate(quoted_match.group(1))
|
||
if candidate:
|
||
return candidate[:MAX_SOURCE_NAME_LEN]
|
||
for pattern in (
|
||
r"(?:新增|增加|添加|接入|配置|注册|建立)(?:一个|1个|一条|一组|新的|一个新的)?\s*(.+?)(?:信号源|来源|监控源)",
|
||
r"(?:信号源|来源|监控源)(?:为|是|叫|命名为)?\s*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if match:
|
||
value = cls._clean_source_name_candidate(match.group(1))
|
||
if value:
|
||
return value[:MAX_SOURCE_NAME_LEN]
|
||
return cls._clean_source_name_candidate(cleaned)[:MAX_SOURCE_NAME_LEN] or default
|
||
|
||
@classmethod
|
||
def _build_source_identifier(
|
||
cls,
|
||
*,
|
||
source_kind: str,
|
||
source_name: str,
|
||
text: str,
|
||
source_payload: dict[str, Any],
|
||
) -> str:
|
||
normalized_text = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
explicit_locator = cls._extract_explicit_source_locator(source_payload=source_payload, text=text)
|
||
if explicit_locator:
|
||
basis = re.sub(r"[^a-zA-Z0-9:/._-]+", "-", explicit_locator).strip("-").lower()
|
||
return f"{source_kind}:{basis[:120]}"
|
||
if cls._source_name_is_generic(source_name, source_kind=source_kind):
|
||
digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16]
|
||
return f"{source_kind}:req-{digest}"
|
||
basis = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "-", source_name).strip("-").lower()
|
||
if basis:
|
||
return f"{source_kind}:{basis[:120]}"
|
||
digest = hashlib.sha1(f"{source_kind}:{normalized_text}".encode("utf-8")).hexdigest()[:16]
|
||
return f"{source_kind}:{digest}"
|
||
|
||
@staticmethod
|
||
def _clean_source_name_candidate(value: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(value or "")).strip()
|
||
cleaned = re.sub(r"^(?:给)?项目id[::]?\s*\d+\s*", "", cleaned, flags=re.IGNORECASE)
|
||
cleaned = re.sub(r"[,。,.;;::].*$", "", cleaned)
|
||
cleaned = cleaned.strip(" []【】()()::\"'“”")
|
||
cleaned = re.sub(r"^(?:请|帮我|帮忙|把|将|需要|想要|用于|用来)\s*", "", cleaned, flags=re.IGNORECASE)
|
||
cleaned = re.sub(
|
||
r"^(?:一个|1个|一条|一组|一个新的|新的|新增的)\s+",
|
||
"",
|
||
cleaned,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
cleaned = re.sub(r"(?:信号源|来源|监控源|source|channel)$", "", cleaned, flags=re.IGNORECASE).strip()
|
||
if cleaned.lower() == "github":
|
||
return "GitHub"
|
||
if cleaned.lower() == "feishu":
|
||
return "Feishu"
|
||
normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", cleaned).lower()
|
||
if normalized in {"", "项目id", "给项目id", "复合", "复合信号源", "信号源", "来源", "监控源"}:
|
||
return ""
|
||
return cleaned
|
||
|
||
@classmethod
|
||
def _extract_explicit_source_locator(cls, *, source_payload: dict[str, Any], text: str) -> str:
|
||
config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}
|
||
direct_candidates = [
|
||
source_payload.get("repo"),
|
||
source_payload.get("repository"),
|
||
source_payload.get("url"),
|
||
source_payload.get("webhook_url"),
|
||
source_payload.get("channel_id"),
|
||
source_payload.get("chat_id"),
|
||
config.get("repo"),
|
||
config.get("repository"),
|
||
config.get("url"),
|
||
config.get("webhook_url"),
|
||
config.get("channel_id"),
|
||
config.get("chat_id"),
|
||
]
|
||
for candidate in direct_candidates:
|
||
normalized = cls._normalize_locator_fragment(candidate)
|
||
if normalized:
|
||
return normalized
|
||
|
||
url_match = re.search(r"https?://[^\s,。,;;))】\\]]+", str(text or ""), re.IGNORECASE)
|
||
if url_match:
|
||
return cls._normalize_locator_fragment(url_match.group(0))
|
||
|
||
repo_match = re.search(r"\b([A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+)\b", str(text or ""))
|
||
if repo_match:
|
||
return f"repo:{repo_match.group(1).lower()}"
|
||
|
||
chat_match = re.search(
|
||
r"\b(?:chat_id|channel_id|群聊id|频道id)[::=\s]+([A-Za-z0-9_-]{6,})\b",
|
||
str(text or ""),
|
||
re.IGNORECASE,
|
||
)
|
||
if chat_match:
|
||
return f"chat:{chat_match.group(1).lower()}"
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _normalize_locator_fragment(value: Any) -> str:
|
||
text = str(value or "").strip().strip("\"'“”")
|
||
if not text:
|
||
return ""
|
||
text = re.sub(r"\?.*$", "", text).rstrip("/")
|
||
return text[:140].lower()
|
||
|
||
@classmethod
|
||
def _source_name_is_generic(cls, source_name: str, *, source_kind: str) -> bool:
|
||
normalized = re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(source_name or "")).lower()
|
||
if not normalized:
|
||
return True
|
||
generic_tokens = {
|
||
re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", source_kind).lower(),
|
||
re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", f"{source_kind}信号源").lower(),
|
||
"项目id",
|
||
"给项目id",
|
||
"复合",
|
||
"复合信号源",
|
||
}
|
||
for kind, hints in SOURCE_KIND_HINTS:
|
||
if kind != source_kind:
|
||
continue
|
||
generic_tokens.update(
|
||
re.sub(r"[^a-zA-Z0-9\u4e00-\u9fff]+", "", str(token)).lower()
|
||
for token in hints
|
||
)
|
||
break
|
||
return normalized in generic_tokens
|
||
|
||
@staticmethod
|
||
def _normalize_worker_roles(raw_roles: Any) -> list[str]:
|
||
roles: list[str] = []
|
||
if isinstance(raw_roles, list):
|
||
for item in raw_roles:
|
||
key = str(item or "").strip()
|
||
if key in DEFAULT_WORKER_ROLE_KEYS and key not in roles:
|
||
roles.append(key)
|
||
if roles:
|
||
return roles[: len(DEFAULT_WORKER_ROLE_KEYS)]
|
||
return list(DEFAULT_WORKER_ROLE_KEYS)
|
||
|
||
@staticmethod
|
||
def _build_openclaw_instruction(source: dict[str, Any], initiating_text: str) -> str:
|
||
return (
|
||
f"围绕新增信号源“{source.get('source_name') or '未命名信号源'}”完成接入准备。"
|
||
"请创建必要任务、分配合适负责人,并通知相关成员。"
|
||
f"补充上下文:{str(initiating_text or '').strip()[:240]}"
|
||
)
|
||
|
||
async def _create_or_reuse_registry(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project_id: int,
|
||
signal_id: Optional[int],
|
||
operator_user_id: Optional[int],
|
||
requested_via: str,
|
||
source_payload: dict[str, Any],
|
||
initiating_text: str,
|
||
dry_run: bool,
|
||
) -> dict[str, Any]:
|
||
normalized_source = self._normalize_source_payload(
|
||
source_payload=source_payload,
|
||
fallback_text=initiating_text,
|
||
requested_via=requested_via,
|
||
)
|
||
if dry_run:
|
||
return {
|
||
"status": "planned",
|
||
"registry_id": None,
|
||
"source_kind": normalized_source.get("source_kind"),
|
||
"source_name": normalized_source.get("source_name"),
|
||
"source_identifier": normalized_source.get("source_identifier"),
|
||
"description": str(normalized_source.get("description") or "").strip(),
|
||
}
|
||
|
||
row = SignalSourceRegistry(
|
||
tenant_id=tenant_id,
|
||
project_id=project_id,
|
||
signal_id=signal_id,
|
||
created_by_user_id=operator_user_id,
|
||
source_kind=str(normalized_source["source_kind"]),
|
||
source_name=str(normalized_source["source_name"]),
|
||
source_identifier=str(normalized_source["source_identifier"]),
|
||
description=str(normalized_source.get("description") or "").strip() or None,
|
||
requested_via=str(requested_via or "system").strip()[:40] or "system",
|
||
status="pending",
|
||
onboarding_status="pending",
|
||
config=normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else {},
|
||
raw_context={
|
||
"initiating_text": str(initiating_text or "")[:2000],
|
||
"requested_via": requested_via,
|
||
},
|
||
last_signal_at=_utc_now_naive(),
|
||
)
|
||
self.db.add(row)
|
||
try:
|
||
await self.db.flush()
|
||
await self.db.commit()
|
||
await self.db.refresh(row)
|
||
return {
|
||
"status": "created",
|
||
"registry_id": int(row.id),
|
||
"source_kind": str(row.source_kind or ""),
|
||
"source_name": str(row.source_name or ""),
|
||
"source_identifier": str(row.source_identifier or ""),
|
||
"description": str(row.description or ""),
|
||
}
|
||
except IntegrityError:
|
||
await self.db.rollback()
|
||
existing = (
|
||
await self.db.execute(
|
||
select(SignalSourceRegistry).where(
|
||
and_(
|
||
SignalSourceRegistry.tenant_id == tenant_id,
|
||
SignalSourceRegistry.project_id == project_id,
|
||
SignalSourceRegistry.source_kind == str(normalized_source["source_kind"]),
|
||
SignalSourceRegistry.source_identifier == str(normalized_source["source_identifier"]),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existing is None:
|
||
raise
|
||
existing.signal_id = signal_id
|
||
if str(normalized_source.get("source_name") or "").strip():
|
||
existing.source_name = str(normalized_source.get("source_name") or "").strip()[:MAX_SOURCE_NAME_LEN]
|
||
existing.description = str(normalized_source.get("description") or existing.description or "").strip() or None
|
||
existing.config = normalized_source.get("config") if isinstance(normalized_source.get("config"), dict) else existing.config
|
||
existing.raw_context = {
|
||
"initiating_text": str(initiating_text or "")[:2000],
|
||
"requested_via": requested_via,
|
||
}
|
||
if not isinstance(existing.config, dict) or not existing.config:
|
||
existing.status = "pending"
|
||
existing.onboarding_status = "pending"
|
||
existing.last_signal_at = _utc_now_naive()
|
||
await self.db.commit()
|
||
return {
|
||
"status": "reused",
|
||
"registry_id": int(existing.id),
|
||
"source_kind": str(existing.source_kind or ""),
|
||
"source_name": str(existing.source_name or ""),
|
||
"source_identifier": str(existing.source_identifier or ""),
|
||
"description": str(existing.description or ""),
|
||
}
|
||
|
||
async def _dispatch_openclaw(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
operator_user: Optional[User],
|
||
project: Project,
|
||
instruction: str,
|
||
dry_run: bool,
|
||
response_mode: str,
|
||
) -> dict[str, Any]:
|
||
if not instruction:
|
||
return {
|
||
"status": "skipped",
|
||
"reason": "empty_instruction",
|
||
}
|
||
if operator_user is None:
|
||
return {
|
||
"status": "skipped",
|
||
"reason": "no_manager_operator",
|
||
}
|
||
try:
|
||
from .openclaw_commander_service import OpenClawCommanderService
|
||
|
||
result = await OpenClawCommanderService(self.db).dispatch_command(
|
||
tenant_id=tenant_id,
|
||
operator_user=operator_user,
|
||
project_id=int(project.id),
|
||
instruction=instruction,
|
||
dry_run=dry_run,
|
||
max_operations=6,
|
||
response_mode=response_mode,
|
||
persist_run=False,
|
||
)
|
||
return {
|
||
"status": "planned" if dry_run else "completed",
|
||
"result": result,
|
||
}
|
||
except Exception as exc:
|
||
return {
|
||
"status": "failed",
|
||
"reason": str(exc),
|
||
}
|
||
|
||
async def _execute_worker_roles(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project: Project,
|
||
signal: Signal,
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_role_keys: list[str],
|
||
trace_id: str,
|
||
operator_user: Optional[User],
|
||
dry_run: bool,
|
||
response_mode: str,
|
||
) -> list[dict[str, Any]]:
|
||
bundle = await prompt_profile_service.get_profiles(self.db, tenant_id=tenant_id)
|
||
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
|
||
role_map = {
|
||
str(item.get("role_key") or "").strip(): item
|
||
for item in roles
|
||
if isinstance(item, dict) and str(item.get("role_key") or "").strip()
|
||
}
|
||
|
||
if dry_run:
|
||
return self._build_planned_worker_results(worker_role_keys, role_map=role_map)
|
||
|
||
semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY)
|
||
|
||
async def _run_role(role_key: str) -> dict[str, Any]:
|
||
role = role_map.get(role_key)
|
||
if not isinstance(role, dict):
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_key,
|
||
"status": "failed",
|
||
"reason": "role_not_configured",
|
||
}
|
||
try:
|
||
async with semaphore:
|
||
return await type(self)._execute_single_worker_role_isolated(
|
||
tenant_id=tenant_id,
|
||
project_payload=self._serialize_snapshot(project),
|
||
signal_payload=self._serialize_snapshot(signal),
|
||
registry_result=dict(registry_result),
|
||
openclaw_result=dict(openclaw_result),
|
||
role=dict(role),
|
||
trace_id=trace_id,
|
||
operator_user_payload=self._serialize_snapshot(operator_user),
|
||
response_mode=response_mode,
|
||
)
|
||
except Exception as exc:
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key,
|
||
"status": "failed",
|
||
"reason": str(exc)[:300],
|
||
}
|
||
|
||
tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys]
|
||
return list(await asyncio.gather(*tasks, return_exceptions=False))
|
||
|
||
async def _execute_single_worker_role(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project: Project,
|
||
signal: Signal,
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
role: dict[str, Any],
|
||
trace_id: str,
|
||
operator_user: Optional[User],
|
||
dry_run: bool,
|
||
response_mode: str,
|
||
) -> dict[str, Any]:
|
||
role_key = str(role.get("role_key") or "").strip()
|
||
role_name = str(role.get("role_name") or role_key).strip()[:64] or role_key
|
||
registry_id = registry_result.get("registry_id")
|
||
source_payload = {
|
||
"registry_id": int(registry_id) if registry_id is not None else None,
|
||
"source_kind": str(registry_result.get("source_kind") or ""),
|
||
"source_name": str(registry_result.get("source_name") or ""),
|
||
"source_identifier": str(registry_result.get("source_identifier") or ""),
|
||
"description": str(registry_result.get("description") or ""),
|
||
}
|
||
|
||
execution_row: Optional[AIWorkerExecution] = None
|
||
if not dry_run:
|
||
execution_row = AIWorkerExecution(
|
||
tenant_id=tenant_id,
|
||
project_id=int(project.id),
|
||
signal_id=int(signal.id) if getattr(signal, "id", None) is not None else None,
|
||
signal_source_registry_id=int(registry_id) if registry_id is not None else None,
|
||
operator_user_id=int(operator_user.id) if operator_user is not None else None,
|
||
trace_id=trace_id,
|
||
operation_trace_id=(
|
||
str((openclaw_result.get("result") or {}).get("trace_id") or "").strip()
|
||
if isinstance(openclaw_result.get("result"), dict)
|
||
else None
|
||
),
|
||
execution_type="signal_source_onboarding",
|
||
worker_role_key=role_key,
|
||
worker_role_name=role_name,
|
||
status="running",
|
||
input_payload=source_payload,
|
||
started_at=_utc_now_naive(),
|
||
)
|
||
self.db.add(execution_row)
|
||
await self.db.flush()
|
||
# 先提交 running 状态,避免 SQLite 事务跨 LLM 调用长期持有写锁。
|
||
await self.db.commit()
|
||
|
||
prompt = self._build_worker_prompt(
|
||
role=role,
|
||
project=project,
|
||
signal=signal,
|
||
source_payload=source_payload,
|
||
openclaw_result=openclaw_result,
|
||
)
|
||
if dry_run:
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_name,
|
||
"status": "planned",
|
||
}
|
||
try:
|
||
extraction = await llm_service.generate_structured_object(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=tenant_id,
|
||
function_name="signal_source_ai_worker_execution",
|
||
use_prompt_profile=False,
|
||
max_tokens=420,
|
||
response_mode="complete" if response_mode == "fast" else response_mode,
|
||
)
|
||
if extraction.payload is None:
|
||
raise ValueError(str(extraction.error_code or "structured_empty"))
|
||
parsed = self._normalize_worker_output(
|
||
extraction.payload,
|
||
role_name=role_name,
|
||
source_name=source_payload.get("source_name"),
|
||
)
|
||
if execution_row is not None:
|
||
execution_row.status = "completed"
|
||
execution_row.summary = str(parsed.get("summary") or "").strip()[:1000]
|
||
execution_row.output_payload = parsed
|
||
execution_row.completed_at = _utc_now_naive()
|
||
await self.db.commit()
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_name,
|
||
"status": "completed",
|
||
"summary": str(parsed.get("summary") or "").strip(),
|
||
"execution_id": int(execution_row.id) if execution_row is not None else None,
|
||
"used_fallback": False,
|
||
}
|
||
except Exception as exc:
|
||
fallback = self._fallback_worker_output(
|
||
role_name=role_name,
|
||
source_name=str(source_payload.get("source_name") or "信号源"),
|
||
)
|
||
if execution_row is not None:
|
||
execution_row.status = "completed"
|
||
execution_row.summary = str(fallback.get("summary") or "").strip()[:1000]
|
||
execution_row.output_payload = fallback
|
||
execution_row.error_message = str(exc)[:1000]
|
||
execution_row.completed_at = _utc_now_naive()
|
||
await self.db.commit()
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_name,
|
||
"status": "completed",
|
||
"reason": str(exc),
|
||
"summary": str(fallback.get("summary") or "").strip(),
|
||
"used_fallback": True,
|
||
"execution_id": int(execution_row.id) if execution_row is not None else None,
|
||
}
|
||
|
||
@classmethod
|
||
async def _execute_single_worker_role_isolated(
|
||
cls,
|
||
*,
|
||
tenant_id: int,
|
||
project_payload: dict[str, Any],
|
||
signal_payload: dict[str, Any],
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
role: dict[str, Any],
|
||
trace_id: str,
|
||
operator_user_payload: Optional[dict[str, Any]],
|
||
response_mode: str,
|
||
) -> dict[str, Any]:
|
||
session_factory = getattr(cls, "session_factory", AsyncSessionLocal)
|
||
async with session_factory() as db:
|
||
service = cls(db)
|
||
return await service._execute_single_worker_role(
|
||
tenant_id=tenant_id,
|
||
project=SimpleNamespace(**project_payload),
|
||
signal=SimpleNamespace(**signal_payload),
|
||
registry_result=registry_result,
|
||
openclaw_result=openclaw_result,
|
||
role=role,
|
||
trace_id=trace_id,
|
||
operator_user=SimpleNamespace(**operator_user_payload) if isinstance(operator_user_payload, dict) else None,
|
||
dry_run=False,
|
||
response_mode=response_mode,
|
||
)
|
||
|
||
@classmethod
|
||
async def _background_finalize_with_guard(
|
||
cls,
|
||
*,
|
||
tenant_id: int,
|
||
trace_id: str,
|
||
project_payload: dict[str, Any],
|
||
signal_payload: dict[str, Any],
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_role_keys: list[str],
|
||
operator_user_payload: Optional[dict[str, Any]],
|
||
planner_status: str,
|
||
planner_diagnostics: Optional[dict[str, Any]],
|
||
summary: str,
|
||
signal_id: Optional[int],
|
||
) -> dict[str, Any]:
|
||
try:
|
||
return await cls._execute_workers_and_finalize(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
project_payload=project_payload,
|
||
signal_payload=signal_payload,
|
||
registry_result=registry_result,
|
||
openclaw_result=openclaw_result,
|
||
worker_role_keys=worker_role_keys,
|
||
operator_user_payload=operator_user_payload,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=summary,
|
||
signal_id=signal_id,
|
||
)
|
||
except Exception as exc:
|
||
session_factory = getattr(cls, "session_factory", AsyncSessionLocal)
|
||
async with session_factory() as db:
|
||
service = cls(db)
|
||
run_service = AIOrchestrationRunService(db)
|
||
row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
||
response_mode = str(row.response_mode or "fast") if row is not None else "fast"
|
||
existing_payload = dict(row.result_payload or {}) if row is not None and isinstance(row.result_payload, dict) else {}
|
||
source_payload = existing_payload.get("signal_source") if isinstance(existing_payload.get("signal_source"), dict) else {}
|
||
worker_results = (
|
||
existing_payload.get("worker_executions")
|
||
if isinstance(existing_payload.get("worker_executions"), list)
|
||
else []
|
||
)
|
||
completed_keys = {
|
||
str(item.get("worker_role_key") or "").strip()
|
||
for item in worker_results
|
||
if isinstance(item, dict) and str(item.get("worker_role_key") or "").strip()
|
||
}
|
||
pending_workers = [key for key in worker_role_keys if key not in completed_keys] or list(worker_role_keys)
|
||
failed_result = service._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=response_mode,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=summary,
|
||
registry_result={
|
||
"registry_id": source_payload.get("registry_id"),
|
||
"status": source_payload.get("status"),
|
||
},
|
||
source_payload={
|
||
"source_kind": source_payload.get("source_kind"),
|
||
"source_name": source_payload.get("source_name"),
|
||
"source_identifier": source_payload.get("source_identifier"),
|
||
},
|
||
openclaw_result=openclaw_result,
|
||
worker_results=worker_results,
|
||
pending_workers=pending_workers,
|
||
status="failed",
|
||
run_status="failed",
|
||
)
|
||
failed_result["background_error"] = str(exc)[:500]
|
||
if row is not None:
|
||
await run_service.update_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
status="failed",
|
||
planner_status=planner_status,
|
||
openclaw_status=str(openclaw_result.get("status") or row.openclaw_status or ""),
|
||
worker_total=max(len(worker_role_keys), int(row.worker_total or 0)),
|
||
worker_completed=int(row.worker_completed or 0),
|
||
worker_failed=int(row.worker_failed or 0),
|
||
summary=summary,
|
||
planner_diagnostics=planner_diagnostics or {},
|
||
result_payload=failed_result,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
await db.commit()
|
||
return failed_result
|
||
|
||
@classmethod
|
||
async def _execute_workers_and_finalize(
|
||
cls,
|
||
*,
|
||
tenant_id: int,
|
||
trace_id: str,
|
||
project_payload: dict[str, Any],
|
||
signal_payload: dict[str, Any],
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_role_keys: list[str],
|
||
operator_user_payload: Optional[dict[str, Any]],
|
||
planner_status: str,
|
||
planner_diagnostics: Optional[dict[str, Any]],
|
||
summary: str,
|
||
signal_id: Optional[int],
|
||
) -> dict[str, Any]:
|
||
session_factory = getattr(cls, "session_factory", AsyncSessionLocal)
|
||
async with session_factory() as db:
|
||
service = cls(db)
|
||
bundle = await prompt_profile_service.get_profiles(db, tenant_id=tenant_id)
|
||
roles = bundle.get("roles") if isinstance(bundle.get("roles"), list) else []
|
||
role_map = {
|
||
str(item.get("role_key") or "").strip(): item
|
||
for item in roles
|
||
if isinstance(item, dict) and str(item.get("role_key") or "").strip()
|
||
}
|
||
semaphore = asyncio.Semaphore(WORKER_MAX_CONCURRENCY)
|
||
|
||
async def _run_role(role_key: str) -> dict[str, Any]:
|
||
role = role_map.get(role_key)
|
||
if not isinstance(role, dict):
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_key,
|
||
"status": "failed",
|
||
"reason": "role_not_configured",
|
||
}
|
||
try:
|
||
async with semaphore:
|
||
return await cls._execute_single_worker_role_isolated(
|
||
tenant_id=tenant_id,
|
||
project_payload=project_payload,
|
||
signal_payload=signal_payload,
|
||
registry_result=registry_result,
|
||
openclaw_result=openclaw_result,
|
||
role=dict(role),
|
||
trace_id=trace_id,
|
||
operator_user_payload=operator_user_payload,
|
||
response_mode="complete",
|
||
)
|
||
except Exception as exc:
|
||
return {
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": str(role.get("role_name") or role_key).strip()[:64] or role_key,
|
||
"status": "failed",
|
||
"reason": str(exc)[:300],
|
||
}
|
||
|
||
tasks = [asyncio.create_task(_run_role(role_key)) for role_key in worker_role_keys]
|
||
worker_results: list[dict[str, Any]] = []
|
||
for task in asyncio.as_completed(tasks):
|
||
result = await task
|
||
worker_results.append(result)
|
||
await service._update_run_after_worker(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
summary=summary,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
registry_result=registry_result,
|
||
openclaw_result=openclaw_result,
|
||
worker_role_keys=worker_role_keys,
|
||
worker_results=worker_results,
|
||
)
|
||
|
||
final_result = await service._finalize_orchestration_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
signal_id=signal_id,
|
||
summary=summary,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
registry_result=registry_result,
|
||
openclaw_result=openclaw_result,
|
||
worker_role_keys=worker_role_keys,
|
||
worker_results=worker_results,
|
||
)
|
||
return final_result
|
||
|
||
@staticmethod
|
||
def _serialize_snapshot(value: Any) -> Optional[dict[str, Any]]:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, dict):
|
||
return dict(value)
|
||
data = getattr(value, "__dict__", None)
|
||
if isinstance(data, dict):
|
||
return {str(key): data[key] for key in data}
|
||
return None
|
||
|
||
@staticmethod
|
||
def _build_planner_diagnostics(extraction: Any) -> dict[str, Any]:
|
||
return {
|
||
"used_fallback": extraction.error_code is not None,
|
||
"error_code": extraction.error_code,
|
||
"source_field": extraction.source_field or None,
|
||
"provider": str(extraction.provider or ""),
|
||
"model": str(extraction.model or ""),
|
||
}
|
||
|
||
def _build_planned_worker_results(
|
||
self,
|
||
worker_role_keys: list[str],
|
||
*,
|
||
role_map: Optional[dict[str, dict[str, Any]]] = None,
|
||
) -> list[dict[str, Any]]:
|
||
results: list[dict[str, Any]] = []
|
||
for role_key in worker_role_keys:
|
||
role = role_map.get(role_key) if isinstance(role_map, dict) else None
|
||
role_name = str((role or {}).get("role_name") or role_key).strip()[:64] or role_key
|
||
results.append(
|
||
{
|
||
"worker_role_key": role_key,
|
||
"worker_role_name": role_name,
|
||
"status": "planned",
|
||
}
|
||
)
|
||
return results
|
||
|
||
@staticmethod
|
||
def _build_signal_source_snapshot(registry_result: dict[str, Any], source_payload: dict[str, Any]) -> dict[str, Any]:
|
||
return {
|
||
"registry_id": registry_result.get("registry_id"),
|
||
"status": registry_result.get("status"),
|
||
"onboarding_status": registry_result.get("onboarding_status"),
|
||
"source_kind": source_payload.get("source_kind"),
|
||
"source_name": source_payload.get("source_name"),
|
||
"source_identifier": source_payload.get("source_identifier"),
|
||
"description": source_payload.get("description"),
|
||
"config": source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {},
|
||
}
|
||
|
||
def _build_orchestration_result(
|
||
self,
|
||
*,
|
||
trace_id: str,
|
||
response_mode: str,
|
||
planner_status: str,
|
||
planner_diagnostics: Optional[dict[str, Any]],
|
||
summary: str,
|
||
registry_result: dict[str, Any],
|
||
source_payload: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_results: list[dict[str, Any]],
|
||
pending_workers: list[str],
|
||
status: str,
|
||
run_status: str,
|
||
) -> dict[str, Any]:
|
||
return {
|
||
"trace_id": trace_id,
|
||
"status": status,
|
||
"run_status": run_status,
|
||
"response_mode": response_mode,
|
||
"planner_status": planner_status,
|
||
"planner_diagnostics": planner_diagnostics,
|
||
"summary": summary,
|
||
"pending_workers": list(pending_workers),
|
||
"signal_source": self._build_signal_source_snapshot(registry_result, source_payload),
|
||
"openclaw": openclaw_result,
|
||
"worker_executions": list(worker_results),
|
||
}
|
||
|
||
@classmethod
|
||
def _has_actionable_source_config(cls, *, source_payload: dict[str, Any]) -> bool:
|
||
if not isinstance(source_payload, dict):
|
||
return False
|
||
config = source_payload.get("config") if isinstance(source_payload.get("config"), dict) else {}
|
||
if config:
|
||
return True
|
||
locator = cls._extract_explicit_source_locator(
|
||
source_payload=source_payload,
|
||
text=str(source_payload.get("description") or ""),
|
||
)
|
||
if locator:
|
||
return True
|
||
source_kind = str(source_payload.get("source_kind") or "").strip().lower()
|
||
source_identifier = str(source_payload.get("source_identifier") or "").strip().lower()
|
||
if source_kind in {"github", "feishu", "webhook", "url"} and source_identifier and ":req-" not in source_identifier:
|
||
return True
|
||
if source_kind in {"pmflow", "custom", "browser"} and (
|
||
not source_identifier or source_identifier.startswith(f"{source_kind}:req-")
|
||
):
|
||
return False
|
||
return False
|
||
|
||
@staticmethod
|
||
def _has_non_fallback_worker_result(worker_results: list[dict[str, Any]]) -> bool:
|
||
for item in worker_results:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
status = str(item.get("status") or "").strip().lower()
|
||
if status != "completed":
|
||
continue
|
||
if bool(item.get("used_fallback")):
|
||
continue
|
||
return True
|
||
return False
|
||
|
||
@staticmethod
|
||
def _extract_openclaw_execution_rows(openclaw_result: dict[str, Any]) -> list[dict[str, Any]]:
|
||
if not isinstance(openclaw_result, dict):
|
||
return []
|
||
result = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result
|
||
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
|
||
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
|
||
return [row for row in rows if isinstance(row, dict)]
|
||
|
||
@classmethod
|
||
def _has_material_openclaw_execution(cls, openclaw_result: dict[str, Any]) -> bool:
|
||
for row in cls._extract_openclaw_execution_rows(openclaw_result):
|
||
status = str(row.get("status") or "").strip().lower()
|
||
if status not in {"applied", "skipped"}:
|
||
continue
|
||
operation_code = str(row.get("operation_code") or "").strip().lower()
|
||
if operation_code == "notification.create":
|
||
continue
|
||
return True
|
||
return False
|
||
|
||
@classmethod
|
||
def _resolve_registry_activation(
|
||
cls,
|
||
*,
|
||
source_payload: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_results: list[dict[str, Any]],
|
||
) -> tuple[str, str]:
|
||
actionable_config = cls._has_actionable_source_config(source_payload=source_payload)
|
||
has_real_worker = cls._has_non_fallback_worker_result(worker_results)
|
||
has_material_openclaw = cls._has_material_openclaw_execution(openclaw_result)
|
||
if actionable_config and has_real_worker and has_material_openclaw:
|
||
return "active", "completed"
|
||
return "pending", "pending_manual"
|
||
|
||
@staticmethod
|
||
def _resolve_orchestration_status(*, openclaw_status: str, worker_results: list[dict[str, Any]]) -> str:
|
||
normalized_openclaw = str(openclaw_status or "").strip().lower()
|
||
worker_statuses = [str(item.get("status") or "").strip().lower() for item in worker_results]
|
||
has_worker_completed = any(status == "completed" for status in worker_statuses)
|
||
has_worker_failed = any(status == "failed" for status in worker_statuses)
|
||
if normalized_openclaw == "failed" and not has_worker_completed:
|
||
return "failed"
|
||
if has_worker_failed or normalized_openclaw == "failed":
|
||
return "partial"
|
||
if has_worker_completed or normalized_openclaw == "completed":
|
||
return "completed"
|
||
return "failed"
|
||
|
||
@staticmethod
|
||
def _worker_counts(worker_results: list[dict[str, Any]]) -> tuple[int, int]:
|
||
completed = 0
|
||
failed = 0
|
||
for item in worker_results:
|
||
status = str(item.get("status") or "").strip().lower()
|
||
if status == "failed":
|
||
failed += 1
|
||
continue
|
||
if status in {"completed", "planned", "skipped"}:
|
||
completed += 1
|
||
return completed, failed
|
||
|
||
async def _update_run_after_worker(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
trace_id: str,
|
||
summary: str,
|
||
planner_status: str,
|
||
planner_diagnostics: Optional[dict[str, Any]],
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_role_keys: list[str],
|
||
worker_results: list[dict[str, Any]],
|
||
) -> None:
|
||
run_service = AIOrchestrationRunService(self.db)
|
||
row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
||
if row is None:
|
||
return
|
||
source_payload = row.result_payload.get("signal_source") if isinstance(row.result_payload, dict) else {}
|
||
completed_count, failed_count = self._worker_counts(worker_results)
|
||
completed_keys = {
|
||
str(item.get("worker_role_key") or "").strip()
|
||
for item in worker_results
|
||
if str(item.get("worker_role_key") or "").strip()
|
||
}
|
||
pending_workers = [key for key in worker_role_keys if key not in completed_keys]
|
||
status = "partial" if pending_workers else self._resolve_orchestration_status(
|
||
openclaw_status=str(openclaw_result.get("status") or ""),
|
||
worker_results=worker_results,
|
||
)
|
||
result_payload = self._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=str(row.response_mode or "fast"),
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=summary,
|
||
registry_result={
|
||
"registry_id": (source_payload or {}).get("registry_id"),
|
||
"status": (source_payload or {}).get("status"),
|
||
},
|
||
source_payload={
|
||
"source_kind": (source_payload or {}).get("source_kind"),
|
||
"source_name": (source_payload or {}).get("source_name"),
|
||
"source_identifier": (source_payload or {}).get("source_identifier"),
|
||
},
|
||
openclaw_result=openclaw_result,
|
||
worker_results=worker_results,
|
||
pending_workers=pending_workers,
|
||
status=status,
|
||
run_status="running" if pending_workers else status,
|
||
)
|
||
await run_service.update_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
status="running" if pending_workers else status,
|
||
planner_status=planner_status,
|
||
openclaw_status=str(openclaw_result.get("status") or ""),
|
||
worker_total=len(worker_role_keys),
|
||
worker_completed=completed_count,
|
||
worker_failed=failed_count,
|
||
summary=summary,
|
||
planner_diagnostics=planner_diagnostics or {},
|
||
result_payload=result_payload,
|
||
completed_at=None if pending_workers else _utc_now_naive(),
|
||
)
|
||
await self.db.commit()
|
||
|
||
async def _finalize_orchestration_run(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
trace_id: str,
|
||
signal_id: Optional[int],
|
||
summary: str,
|
||
planner_status: str,
|
||
planner_diagnostics: Optional[dict[str, Any]],
|
||
registry_result: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
worker_role_keys: list[str],
|
||
worker_results: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
run_service = AIOrchestrationRunService(self.db)
|
||
row = await run_service.get_run(tenant_id=tenant_id, trace_id=trace_id)
|
||
response_mode = str(row.response_mode or "fast") if row is not None else "fast"
|
||
source_payload = row.result_payload.get("signal_source") if row is not None and isinstance(row.result_payload, dict) else {}
|
||
final_status = self._resolve_orchestration_status(
|
||
openclaw_status=str(openclaw_result.get("status") or ""),
|
||
worker_results=worker_results,
|
||
)
|
||
registry_status, onboarding_status = self._resolve_registry_activation(
|
||
source_payload=source_payload if isinstance(source_payload, dict) else {},
|
||
openclaw_result=openclaw_result,
|
||
worker_results=worker_results,
|
||
)
|
||
if final_status == "completed" and onboarding_status != "completed":
|
||
final_status = "pending_manual"
|
||
completed_count, failed_count = self._worker_counts(worker_results)
|
||
final_result = self._build_orchestration_result(
|
||
trace_id=trace_id,
|
||
response_mode=response_mode,
|
||
planner_status=planner_status,
|
||
planner_diagnostics=planner_diagnostics,
|
||
summary=summary,
|
||
registry_result={
|
||
"registry_id": (source_payload or {}).get("registry_id"),
|
||
"status": registry_status,
|
||
"onboarding_status": onboarding_status,
|
||
},
|
||
source_payload={
|
||
"source_kind": (source_payload or {}).get("source_kind"),
|
||
"source_name": (source_payload or {}).get("source_name"),
|
||
"source_identifier": (source_payload or {}).get("source_identifier"),
|
||
},
|
||
openclaw_result=openclaw_result,
|
||
worker_results=worker_results,
|
||
pending_workers=[],
|
||
status=final_status,
|
||
run_status=final_status,
|
||
)
|
||
await run_service.update_run(
|
||
tenant_id=tenant_id,
|
||
trace_id=trace_id,
|
||
status=final_status,
|
||
planner_status=planner_status,
|
||
openclaw_status=str(openclaw_result.get("status") or ""),
|
||
worker_total=len(worker_role_keys),
|
||
worker_completed=completed_count,
|
||
worker_failed=failed_count,
|
||
summary=summary,
|
||
planner_diagnostics=planner_diagnostics or {},
|
||
result_payload=final_result,
|
||
completed_at=_utc_now_naive(),
|
||
)
|
||
if registry_result.get("registry_id") is not None:
|
||
registry_row = await self._load_signal_source_registry(
|
||
tenant_id=tenant_id,
|
||
registry_id=int(registry_result["registry_id"]),
|
||
)
|
||
if registry_row is not None:
|
||
registry_row.last_signal_at = _utc_now_naive()
|
||
registry_row.last_worker_run_at = _utc_now_naive()
|
||
registry_row.status = registry_status
|
||
registry_row.onboarding_status = onboarding_status
|
||
if signal_id:
|
||
persisted_signal = await self._load_signal(tenant_id=tenant_id, signal_id=int(signal_id))
|
||
if persisted_signal is not None:
|
||
await self._persist_signal_orchestration_result(signal=persisted_signal, orchestration_result=final_result)
|
||
await self.db.commit()
|
||
return final_result
|
||
|
||
@staticmethod
|
||
def _build_worker_prompt(
|
||
*,
|
||
role: dict[str, Any],
|
||
project: Project,
|
||
signal: Signal,
|
||
source_payload: dict[str, Any],
|
||
openclaw_result: dict[str, Any],
|
||
) -> str:
|
||
role_payload = {
|
||
"role_key": str(role.get("role_key") or "").strip(),
|
||
"role_name": str(role.get("role_name") or "").strip(),
|
||
"role_description": str(role.get("role_description") or "").strip(),
|
||
"system_prompt": str(role.get("system_prompt") or "").strip(),
|
||
}
|
||
project_payload = {
|
||
"project_id": int(project.id),
|
||
"title": str(project.title or project.name or "").strip(),
|
||
"description": str(project.description or "").strip()[:1000],
|
||
}
|
||
signal_payload = {
|
||
"signal_id": int(signal.id) if getattr(signal, "id", None) is not None else None,
|
||
"title": str(signal.title or "").strip(),
|
||
"summary": str(signal.summary or signal.content or "").strip()[:1200],
|
||
}
|
||
commander_payload = openclaw_result.get("result") if isinstance(openclaw_result.get("result"), dict) else openclaw_result
|
||
embedded_prompt = PROMPT_AI_WORKER_SIGNAL_SOURCE_EXECUTION.format(
|
||
role_json=json.dumps(role_payload, ensure_ascii=False),
|
||
project_json=json.dumps(project_payload, ensure_ascii=False),
|
||
source_json=json.dumps(source_payload, ensure_ascii=False),
|
||
signal_json=json.dumps(signal_payload, ensure_ascii=False),
|
||
commander_json=json.dumps(commander_payload or {}, ensure_ascii=False),
|
||
)
|
||
system_prompt = str(role.get("system_prompt") or "").strip()
|
||
if system_prompt:
|
||
return f"[Role System Prompt]\n{system_prompt}\n\n{embedded_prompt}"
|
||
return embedded_prompt
|
||
|
||
@staticmethod
|
||
def _normalize_worker_output(parsed: dict[str, Any], *, role_name: str, source_name: Any) -> dict[str, Any]:
|
||
deliverables = parsed.get("deliverables") if isinstance(parsed.get("deliverables"), list) else []
|
||
risks = parsed.get("risks") if isinstance(parsed.get("risks"), list) else []
|
||
next_actions = parsed.get("next_actions") if isinstance(parsed.get("next_actions"), list) else []
|
||
return {
|
||
"summary": str(parsed.get("summary") or f"{role_name} 已完成 {source_name} 的接入分析。").strip()[:1000],
|
||
"deliverables": [str(item).strip()[:200] for item in deliverables if str(item).strip()][:4],
|
||
"risks": [str(item).strip()[:200] for item in risks if str(item).strip()][:4],
|
||
"next_actions": [str(item).strip()[:200] for item in next_actions if str(item).strip()][:4],
|
||
"used_fallback": False,
|
||
}
|
||
|
||
@staticmethod
|
||
def _fallback_worker_output(*, role_name: str, source_name: str) -> dict[str, Any]:
|
||
return {
|
||
"summary": f"{role_name} 已为 {source_name} 生成兜底执行摘要。",
|
||
"deliverables": [f"梳理 {source_name} 的接入目标与边界"],
|
||
"risks": [f"{source_name} 的标识与接入细节仍需补充确认"],
|
||
"next_actions": ["等待 OpenClaw 生成任务并推进执行"],
|
||
"used_fallback": True,
|
||
}
|
||
|
||
async def _persist_signal_orchestration_result(
|
||
self,
|
||
*,
|
||
signal: Signal,
|
||
orchestration_result: dict[str, Any],
|
||
) -> None:
|
||
strategic_payload: dict[str, Any] = {}
|
||
raw_strategic = signal.strategic_analysis
|
||
if isinstance(raw_strategic, dict):
|
||
strategic_payload = dict(raw_strategic)
|
||
elif isinstance(raw_strategic, str) and raw_strategic.strip():
|
||
try:
|
||
parsed = json.loads(raw_strategic)
|
||
except Exception:
|
||
parsed = {}
|
||
if isinstance(parsed, dict):
|
||
strategic_payload = parsed
|
||
strategic_payload["signal_source_orchestration"] = orchestration_result
|
||
signal.strategic_analysis = json.dumps(strategic_payload, ensure_ascii=False)
|
||
orchestration_status = str(orchestration_result.get("status") or "").lower()
|
||
signal.processed = orchestration_status in {"completed", "partial"}
|
||
signal.status = self._map_orchestration_status_to_signal_status(orchestration_status)
|
||
signal.tasks_generated = json.dumps(
|
||
self._extract_task_operations(orchestration_result.get("openclaw")),
|
||
ensure_ascii=False,
|
||
)
|
||
await self.db.commit()
|
||
|
||
@staticmethod
|
||
def _extract_task_operations(openclaw_payload: Any) -> list[dict[str, Any]]:
|
||
if not isinstance(openclaw_payload, dict):
|
||
return []
|
||
result = openclaw_payload.get("result") if isinstance(openclaw_payload.get("result"), dict) else openclaw_payload
|
||
execution = result.get("execution") if isinstance(result, dict) and isinstance(result.get("execution"), dict) else {}
|
||
rows = execution.get("results") if isinstance(execution.get("results"), list) else []
|
||
tasks: list[dict[str, Any]] = []
|
||
for row in rows:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
operation_code = str(row.get("operation_code") or "").strip()
|
||
payload = row.get("payload") if isinstance(row.get("payload"), dict) else {}
|
||
if operation_code not in {"task.create", "task.assign", "task.prioritize", "task.status_transition"}:
|
||
continue
|
||
tasks.append(
|
||
{
|
||
"operation_code": operation_code,
|
||
"status": str(row.get("status") or "").strip(),
|
||
"entity_id": row.get("entity_id"),
|
||
"task_id": payload.get("task_id"),
|
||
"title": payload.get("title"),
|
||
"summary": str(row.get("message") or "").strip(),
|
||
}
|
||
)
|
||
return tasks
|
||
|
||
@staticmethod
|
||
def _map_orchestration_status_to_signal_status(status: str) -> str:
|
||
normalized = str(status or "").strip().lower()
|
||
if normalized == "completed":
|
||
return "已编排"
|
||
if normalized == "pending_manual":
|
||
return "待确认接入"
|
||
if normalized == "partial":
|
||
return "部分编排"
|
||
if normalized == "failed":
|
||
return "编排失败"
|
||
if normalized == "planned":
|
||
return "已规划"
|
||
if normalized == "skipped":
|
||
return "已跳过"
|
||
return "待处理"
|
||
|
||
async def _resolve_operator_user(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
project: Project,
|
||
operator_user_id: Optional[int],
|
||
) -> Optional[User]:
|
||
preferred_ids: list[int] = []
|
||
if operator_user_id is not None:
|
||
preferred_ids.append(int(operator_user_id))
|
||
if project.owner_id:
|
||
preferred_ids.append(int(project.owner_id))
|
||
|
||
for user_id in preferred_ids:
|
||
row = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.id == user_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if row is not None and str(row.role or "").strip().upper() in {"BOSS", "ADMIN"}:
|
||
return row
|
||
|
||
return (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == tenant_id,
|
||
User.is_active.is_(True),
|
||
User.role.in_(["BOSS", "ADMIN"]),
|
||
)
|
||
).order_by(User.id.asc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
def _build_user_snapshot(user: Optional[User]) -> Optional[SimpleNamespace]:
|
||
if user is None:
|
||
return None
|
||
user_id = getattr(user, "id", None)
|
||
if user_id is None:
|
||
return None
|
||
return SimpleNamespace(
|
||
id=int(user_id),
|
||
name=str(getattr(user, "name", "") or "").strip(),
|
||
username=str(getattr(user, "username", "") or "").strip(),
|
||
role=str(getattr(user, "role", "") or "").strip(),
|
||
)
|
||
|
||
async def _load_project(self, *, tenant_id: int, project_id: int) -> Optional[Project]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == tenant_id,
|
||
Project.id == project_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_signal(self, *, tenant_id: int, signal_id: int) -> Optional[Signal]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Signal).where(
|
||
and_(
|
||
Signal.tenant_id == tenant_id,
|
||
Signal.id == signal_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _load_signal_source_registry(
|
||
self,
|
||
*,
|
||
tenant_id: int,
|
||
registry_id: int,
|
||
) -> Optional[SignalSourceRegistry]:
|
||
return (
|
||
await self.db.execute(
|
||
select(SignalSourceRegistry).where(
|
||
and_(
|
||
SignalSourceRegistry.tenant_id == tenant_id,
|
||
SignalSourceRegistry.id == registry_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
def _build_ephemeral_signal(*, initiating_text: str) -> Signal:
|
||
signal = Signal(
|
||
tenant_id=1,
|
||
source="PMFlow",
|
||
content=initiating_text[:2000],
|
||
impact_score=0.7,
|
||
priority="medium",
|
||
processed=False,
|
||
title=initiating_text[:80] or "新增信号源",
|
||
signal_type="新需求",
|
||
summary=initiating_text[:300],
|
||
recommended_action="enter_only",
|
||
status="待处理",
|
||
)
|
||
signal.id = 0 # type: ignore[assignment]
|
||
return signal
|
||
|
||
@staticmethod
|
||
def _parse_json_object(raw: str) -> dict[str, Any]:
|
||
text = str(raw or "").strip()
|
||
if "```" in text:
|
||
match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.S)
|
||
if match:
|
||
text = match.group(1)
|
||
if not text.startswith("{"):
|
||
match = re.search(r"(\{.*\})", text, re.S)
|
||
if match:
|
||
text = match.group(1)
|
||
parsed = json.loads(text)
|
||
if not isinstance(parsed, dict):
|
||
raise ValueError("json_object_required")
|
||
return parsed
|