PMFlow/backend/app/services/feishu_signal_pipeline/mixin_followup_b.py

300 lines
15 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.

"""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