300 lines
15 KiB
Python
300 lines
15 KiB
Python
"""Feishu signal pipeline mixin."""
|
||
from __future__ import annotations
|
||
|
||
from .shared import *
|
||
|
||
|
||
class FeishuPipelineFollowupBMixin:
|
||
@classmethod
|
||
def _select_followup_status_target(cls, *, message: str, recent_items: list[FeishuItem]) -> FeishuItem:
|
||
if not recent_items:
|
||
raise ValueError("recent_items_empty")
|
||
normalized = re.sub(r"\s+", "", str(message or "")).lower()
|
||
wants_project_create = any(token in normalized for token in ("创建", "建好", "建成", "项目"))
|
||
wants_source_status = any(token in normalized for token in ("信号源", "web搜索", "webhook", "source"))
|
||
|
||
if wants_source_status:
|
||
for item in recent_items:
|
||
operation_code = cls._extract_item_operation_code(item)
|
||
if operation_code == "source.connect" or "信号源" in str(item.raw_message or ""):
|
||
return item
|
||
|
||
if wants_project_create:
|
||
for item in recent_items:
|
||
operation_code = cls._extract_item_operation_code(item)
|
||
level = str(item.level or "").strip().lower()
|
||
card_type = str(item.card_type or "").strip().lower()
|
||
if (
|
||
level == "project"
|
||
or operation_code == "project.create"
|
||
or card_type in {"project_create", "new_project", "new_project_candidate"}
|
||
):
|
||
return item
|
||
|
||
return recent_items[0]
|
||
|
||
async def _build_followup_status_reply(self, item: FeishuItem) -> str:
|
||
route_status = self._normalize_route_status(item.route_status)
|
||
level = str(item.level or "").strip().lower()
|
||
if level == "project":
|
||
suggestion = await self._get_project_suggestion(int(item.id))
|
||
project_id = suggestion.created_project_id if suggestion and suggestion.created_project_id else item.matched_project_id
|
||
if route_status == ROUTE_STATUS_AUTO_DONE and project_id:
|
||
return f"最近一条项目候选已确认,项目#{int(project_id)} 已落库。"
|
||
if route_status == ROUTE_STATUS_UNDONE:
|
||
return "最近一条项目候选已撤销,系统没有保留该次自动结果。"
|
||
return "最近一条项目候选仍在待确认,系统还没有落正式项目。"
|
||
|
||
if level == "command":
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
operation_code = str(payload.get("operation_code") or "").strip()
|
||
project_id = _safe_int(payload.get("project_id") or item.matched_project_id)
|
||
summary = str(payload.get("result_summary") or "").strip()
|
||
if route_status == ROUTE_STATUS_AUTO_DONE:
|
||
return summary or (
|
||
f"最近一条命令已执行完成,归属项目#{int(project_id)}。"
|
||
if project_id is not None
|
||
else "最近一条命令已执行完成。"
|
||
)
|
||
if route_status == ROUTE_STATUS_UNDONE:
|
||
return "最近一条命令结果已撤销。"
|
||
if route_status == ROUTE_STATUS_QUEUED:
|
||
return (
|
||
f"最近一条命令 {operation_code or '待处理动作'} 已入队待确认。"
|
||
if operation_code
|
||
else "最近一条命令已入队待确认。"
|
||
)
|
||
return "最近一条命令仍在处理中。"
|
||
|
||
signal = await self._get_signal_by_item(int(item.id))
|
||
signal_id = int(signal.id) if signal is not None else None
|
||
project_id = int(signal.matched_project_id or item.matched_project_id) if (signal and (signal.matched_project_id or item.matched_project_id)) else None
|
||
if route_status == ROUTE_STATUS_AUTO_DONE and signal_id and project_id:
|
||
return f"最近一条信号#{signal_id} 已确认并关联项目#{project_id}。"
|
||
if route_status == ROUTE_STATUS_QUEUED and signal_id and project_id:
|
||
return f"最近一条信号#{signal_id} 已入队待确认,候选项目#{project_id}。"
|
||
if route_status == ROUTE_STATUS_QUEUED and signal_id:
|
||
return f"最近一条信号#{signal_id} 已入队待确认,但项目归属仍不明确。"
|
||
if route_status == ROUTE_STATUS_UNDONE:
|
||
return "最近一条信号结果已撤销。"
|
||
return "最近一条飞书处理项仍在处理中,尚未得到可确认的 DB 状态。"
|
||
|
||
async def _resolve_command_operator_user(self, *, sender: str) -> Optional[User]:
|
||
sender_id = await self._resolve_user_id_by_name(sender)
|
||
if sender_id is not None:
|
||
sender_user = await self._load_active_user_by_id(user_id=int(sender_id))
|
||
if sender_user is not None:
|
||
return sender_user
|
||
|
||
manager = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
User.role.in_(["BOSS", "ADMIN", "PM"]),
|
||
)
|
||
).order_by(User.id.asc()).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if manager is not None:
|
||
return manager
|
||
return await self._load_active_user_by_id(user_id=await self._fallback_user_id())
|
||
|
||
async def _load_active_user_by_id(self, user_id: Optional[int]) -> Optional[User]:
|
||
if user_id is None:
|
||
return None
|
||
return (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.id == int(user_id),
|
||
User.is_active.is_(True),
|
||
User.status == "active",
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _record_command_context_item(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
captured_at: Optional[datetime],
|
||
matched_project_id: Optional[int],
|
||
route_status: str,
|
||
command_result: dict[str, Any],
|
||
) -> Optional[int]:
|
||
if matched_project_id is None and not source_chat_id:
|
||
return None
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is not None:
|
||
occurred_at = occurred_at.astimezone(CN_TZ).replace(tzinfo=None)
|
||
item = FeishuItem(
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=feishu_message_id,
|
||
project_score=1.0 if matched_project_id is not None else None,
|
||
signal_confidence=0.0 if matched_project_id is not None else None,
|
||
raw_message=message[:4000],
|
||
split_from_item_id=None,
|
||
level="command",
|
||
level_confidence=1.0,
|
||
level_reasoning="legacy_command_first_execution",
|
||
card_type="command",
|
||
route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30],
|
||
matched_project_id=int(matched_project_id) if matched_project_id is not None else None,
|
||
source_chat_id=(source_chat_id or "")[:128] or None,
|
||
source_sender=(sender or "")[:120] or None,
|
||
source_sender_open_id=(source_sender_open_id or "")[:128] or None,
|
||
source_occurred_at=occurred_at,
|
||
ai_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:8000],
|
||
message_class="auto_done",
|
||
safe_to_auto_execute=True,
|
||
auto_execute_state="done",
|
||
result_payload={
|
||
"intent": "command",
|
||
"executor": "legacy_command_planner",
|
||
"operation_code": None,
|
||
"result_summary": self._build_operation_commit_reply(command_result),
|
||
"command_result": command_result,
|
||
"project_id": int(matched_project_id) if matched_project_id is not None else None,
|
||
"target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None,
|
||
},
|
||
queue_visible=False,
|
||
processed_at=_utc_now().replace(tzinfo=None),
|
||
)
|
||
self.db.add(item)
|
||
await self.db.flush()
|
||
return int(item.id)
|
||
|
||
@staticmethod
|
||
def _resolve_command_route_status(command_result: dict[str, Any]) -> str:
|
||
status = str(
|
||
command_result.get("command_status")
|
||
or command_result.get("run_status")
|
||
or command_result.get("execution_status")
|
||
or ""
|
||
).strip().lower()
|
||
if status in {"completed", "partial", "planned"}:
|
||
return ROUTE_STATUS_AUTO_DONE
|
||
return ROUTE_STATUS_FAILED
|
||
|
||
@staticmethod
|
||
def _collect_command_project_ids(command_result: dict[str, Any]) -> list[int]:
|
||
project_ids: set[int] = set()
|
||
project_info = command_result.get("project") if isinstance(command_result, dict) else None
|
||
if isinstance(project_info, dict):
|
||
project_id = project_info.get("id")
|
||
try:
|
||
if project_id is not None and int(project_id) > 0:
|
||
project_ids.add(int(project_id))
|
||
except (TypeError, ValueError):
|
||
pass
|
||
execution = command_result.get("execution") if isinstance(command_result, dict) else None
|
||
results = execution.get("results") if isinstance(execution, dict) else None
|
||
if isinstance(results, list):
|
||
for row in results:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
if str(row.get("entity_type") or "").strip() != "project":
|
||
continue
|
||
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
|
||
candidate = payload.get("project_id") or row.get("entity_id")
|
||
try:
|
||
if candidate is not None and int(candidate) > 0:
|
||
project_ids.add(int(candidate))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return sorted(project_ids)
|
||
|
||
@staticmethod
|
||
def _extract_created_project_id(command_result: dict[str, Any]) -> Optional[int]:
|
||
execution = command_result.get("execution") if isinstance(command_result, dict) else None
|
||
results = execution.get("results") if isinstance(execution, dict) else None
|
||
if not isinstance(results, list):
|
||
return None
|
||
for row in results:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
if str(row.get("operation_code") or "").strip() != "project.create":
|
||
continue
|
||
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
|
||
candidate = payload.get("project_id") or row.get("entity_id")
|
||
try:
|
||
if candidate is not None and int(candidate) > 0:
|
||
return int(candidate)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
return None
|
||
|
||
@classmethod
|
||
def _build_operation_commit_reply(cls, command_result: dict[str, Any]) -> str:
|
||
execution = command_result.get("execution") if isinstance(command_result, dict) else {}
|
||
results = execution.get("results") if isinstance(execution, dict) else []
|
||
if not isinstance(results, list) or not results:
|
||
return "未执行:未识别到可安全落库的业务操作。"
|
||
|
||
fragments = [cls._format_operation_result_fragment(row) for row in results[:3] if isinstance(row, dict)]
|
||
fragments = [item for item in fragments if item]
|
||
applied_count = int((execution or {}).get("applied_count") or 0)
|
||
skipped_count = int((execution or {}).get("skipped_count") or 0)
|
||
failed_count = int((execution or {}).get("failed_count") or 0)
|
||
|
||
if applied_count <= 0 and skipped_count <= 0 and failed_count > 0:
|
||
first_failed = next((row for row in results if isinstance(row, dict) and str(row.get("status") or "") == "failed"), {})
|
||
message = str(first_failed.get("message") or "执行失败").strip()
|
||
return f"未执行:{message}"
|
||
if not fragments:
|
||
return "已接收指令,但未生成可展示的执行回执。"
|
||
|
||
prefix = "未重复执行:" if applied_count <= 0 and skipped_count > 0 else "已执行完成:"
|
||
reply = f"{prefix}{';'.join(fragments[:2])}"
|
||
if failed_count > 0 and applied_count > 0:
|
||
reply = f"{reply};另有 {failed_count} 项失败"
|
||
return reply + "。"
|
||
|
||
@staticmethod
|
||
def _format_operation_result_fragment(row: dict[str, Any]) -> str:
|
||
operation_code = str(row.get("operation_code") or "").strip()
|
||
status = str(row.get("status") or "").strip().lower()
|
||
message = str(row.get("message") or "").strip()
|
||
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
|
||
if status == "failed":
|
||
return ""
|
||
|
||
if operation_code.startswith("project."):
|
||
project_id = payload.get("project_id") or row.get("entity_id")
|
||
title = str(payload.get("title") or payload.get("project_title") or "").strip()
|
||
status_text = str(payload.get("status") or "").strip()
|
||
label = f"项目#{project_id}"
|
||
if title:
|
||
label = f"{label}「{title}」"
|
||
if operation_code == "project.create" and status == "applied":
|
||
return f"{label}已创建,状态 {status_text or 'active'}"
|
||
if operation_code == "project.create" and status == "skipped":
|
||
return f"{label}已存在,状态 {status_text or 'active'}"
|
||
if status_text:
|
||
return f"{label}状态已更新为 {status_text}"
|
||
return f"{label}{message or '已处理'}"
|
||
|
||
if operation_code == "task.create":
|
||
return f"任务#{row.get('entity_id') or ''}已创建".strip()
|
||
if operation_code == "task.assign":
|
||
return f"任务#{row.get('entity_id') or ''}已分配".strip()
|
||
if operation_code == "task.prioritize":
|
||
return f"任务#{row.get('entity_id') or ''}优先级已更新".strip()
|
||
if operation_code == "task.status_transition":
|
||
return f"任务#{row.get('entity_id') or ''}状态已更新".strip()
|
||
if operation_code == "personnel.workload_update":
|
||
return f"成员#{row.get('entity_id') or ''}工时已更新".strip()
|
||
if operation_code == "notification.create":
|
||
return f"通知#{row.get('entity_id') or ''}已创建".strip()
|
||
return message or operation_code
|
||
|