"""信号源接入编排服务。""" 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 from ..utils.timezone import now_cn_naive 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