PMFlow/backend/app/services/signal_source_orchestration...

1928 lines
82 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""信号源接入编排服务。"""
from __future__ import annotations
import asyncio
import 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 .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 datetime.now(timezone.utc).replace(tzinfo=None)
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) < 6:
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