5987 lines
264 KiB
Python
5987 lines
264 KiB
Python
"""飞书消息分流与决策队列服务。"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import hashlib
|
||
import re
|
||
import time
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Any, Literal, Optional
|
||
|
||
from pydantic import BaseModel, Field, ValidationError
|
||
from sqlalchemy import and_, delete, func, or_, select, text
|
||
from sqlalchemy.exc import IntegrityError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from ..config.ai_prompts import (
|
||
PROMPT_FEISHU_LEVEL_CLASSIFICATION,
|
||
PROMPT_FEISHU_PROJECT_PREFILL,
|
||
PROMPT_FEISHU_SIGNAL_EXTRACTION,
|
||
)
|
||
from ..core.logging import get_logger
|
||
from ..models import (
|
||
FeishuItem,
|
||
AIProcessingLog,
|
||
FeishuReplyOutbox,
|
||
Integration,
|
||
Project,
|
||
ProjectSignalLink,
|
||
ProjectSuggestion,
|
||
Signal,
|
||
SignalEdit,
|
||
User,
|
||
)
|
||
from .llm import LLMService
|
||
from .adaptive_rag_service import AdaptiveRAGService
|
||
from .notification_service import NotificationService
|
||
from .openclaw_commander_service import OpenClawCommanderService, OperationExecutorService
|
||
from ..utils.project_members import ensure_project_default_members
|
||
from ..utils.sequence import ensure_project_id_sequence
|
||
|
||
logger = get_logger(__name__)
|
||
|
||
PROJECT_PRIORITY_ORDER = {
|
||
"P0": 4,
|
||
"P1": 3,
|
||
"P2": 2,
|
||
"P3": 1,
|
||
"high": 3,
|
||
"medium": 2,
|
||
"low": 1,
|
||
}
|
||
|
||
SIGNAL_TYPE_OPTIONS = {"竞品动态", "用户反馈", "项目风险", "新需求"}
|
||
PROJECT_INTENT_RE = re.compile(r"(立项|新项目|我们要做|需要开发|启动|规划)", re.IGNORECASE)
|
||
SIGNAL_FACT_RE = re.compile(r"(竞品|上线|发布|反馈|风险|bug|故障|告警|投诉)", re.IGNORECASE)
|
||
TIMELINE_RE = re.compile(r"(\d+\s*(天|周|月)|本周|本月|月底|下周|下月|Q[1-4]|\d{1,2}月\d{1,2}日)")
|
||
OWNER_RE = re.compile(r"(由|负责人|owner|owner:|@)(\S+)")
|
||
INDEPENDENT_REQUIREMENT_RE = re.compile(
|
||
r"(独立需求|单独需求|单开|另起|新增需求|新建项目|新增项目|单独立项|开个新|做个新)",
|
||
re.IGNORECASE,
|
||
)
|
||
SIMPLE_SIGNAL_REQUEST_RE = re.compile(
|
||
r"(新增|增加|加一个|加个|补充|支持|实现|接入|优化|修复|改进).{0,18}(功能|能力|接口|页面|按钮|流程|识别|提醒|校验|报表|字段|规则|模块)",
|
||
re.IGNORECASE,
|
||
)
|
||
PROJECT_COMPLEXITY_RE = re.compile(
|
||
r"(立项|新项目|项目章程|里程碑|预算|排期|roadmap|跨团队|跨部门|季度目标|年度目标|okr)",
|
||
re.IGNORECASE,
|
||
)
|
||
QUESTION_REQUIREMENT_VERB_RE = re.compile(
|
||
r"(新增|增加|加一个|加个|支持|接入|实现|优化|修复|改进|调整|补充|完善|同步|对齐|落库|入库)",
|
||
re.IGNORECASE,
|
||
)
|
||
QUESTION_REQUIREMENT_OBJECT_RE = re.compile(
|
||
r"(功能|能力|接口|页面|按钮|流程|校验|报表|字段|规则|模块|任务|项目|需求|通知|提醒|配置|归属|队列|信号)",
|
||
re.IGNORECASE,
|
||
)
|
||
PROJECT_LIST_QUERY_RE = re.compile(
|
||
r"(现在|当前|目前|系统里|系统中|项目里|项目中)?.{0,4}(有哪些项目|项目列表|项目清单|哪(些|几个)项目)",
|
||
re.IGNORECASE,
|
||
)
|
||
PROJECT_ID_QUERY_RE = re.compile(
|
||
r"项目\s*id\s*[::]?\s*(\d+).{0,12}(有吗|存在吗|还在吗|是否存在|是不是)",
|
||
re.IGNORECASE,
|
||
)
|
||
OPENCLAW_PROJECT_CREATE_RE = re.compile(
|
||
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目",
|
||
re.IGNORECASE,
|
||
)
|
||
OPENCLAW_COMMAND_VERB_RE = re.compile(
|
||
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)",
|
||
re.IGNORECASE,
|
||
)
|
||
OPENCLAW_COMMAND_OBJECT_RE = re.compile(
|
||
r"(项目|任务|负责人|优先级|状态|通知|提醒|工时|工作量)",
|
||
re.IGNORECASE,
|
||
)
|
||
FOLLOWUP_CONTINUE_RE = re.compile(r"(继续处理|继续推进|继续执行|接着处理|继续跑)", re.IGNORECASE)
|
||
FOLLOWUP_CREATE_CONTINUE_RE = re.compile(r"(继续创建|接着创建|继续建|接着建)", re.IGNORECASE)
|
||
FOLLOWUP_STATUS_RE = re.compile(
|
||
r"(进度|状态|处理到哪|处理了吗|现在怎么样|怎么样了|结果呢|是否已经创建|已经创建了吗|创建了吗|创建成功了吗|建好了吗|建成了吗)",
|
||
re.IGNORECASE,
|
||
)
|
||
FOLLOWUP_REFERENCE_RE = re.compile(
|
||
r"(刚才|刚刚|前面|之前|上次|上一条|我刚才要求|我刚才说|我说的|那个)",
|
||
re.IGNORECASE,
|
||
)
|
||
OBVIOUS_QUESTION_RE = re.compile(
|
||
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|在哪|哪一个|哪几个)",
|
||
re.IGNORECASE,
|
||
)
|
||
FEISHU_BOT_MENTION_RE = re.compile(r"(?:<at[^>]*>[^<]*</at>|@pmflow\b|@_user_1\b)", re.IGNORECASE)
|
||
FEISHU_QUOTE_PREFIX_RE = re.compile(r"(?m)^\s*(?:回复|引用|转发)[::]\s*")
|
||
FEISHU_QUOTE_LINE_RE = re.compile(r"(?m)^\s*(?:>+|【引用】|引用:|回复:).*$")
|
||
FEISHU_NOISE_TOKEN_RE = re.compile(r"\[(?:附带图片|图片|表情|文件|语音|消息卡片)\]", re.IGNORECASE)
|
||
EXTERNAL_INTEGRATION_RE = re.compile(
|
||
r"(github|gitlab|jira|slack|webhook|飞书|企业微信|企微|lark|api|接入|授权|同步)",
|
||
re.IGNORECASE,
|
||
)
|
||
CROSS_SYSTEM_RE = re.compile(r"(跨系统|跨平台|跨团队|外部系统|第三方系统)", re.IGNORECASE)
|
||
TASK_CREATE_RE = re.compile(
|
||
r"(新增|新建|创建|加一个|加个|补一个|补个).{0,8}任务",
|
||
re.IGNORECASE,
|
||
)
|
||
PROJECT_CREATE_GATE_RE = re.compile(
|
||
r"(新增|新建|创建|建立|立项|放到|放进|加入|录入|进).{0,18}(pmflow|项目列表|项目库|项目台账|项目主数据|一个项目|项目)",
|
||
re.IGNORECASE,
|
||
)
|
||
HEALTHCHECK_RE = re.compile(
|
||
r"^(?:ping|pong|health(?:check)?|在线吗|活着吗|存活吗|还在吗|服务正常吗)\??$",
|
||
re.IGNORECASE,
|
||
)
|
||
SHORT_CONFIRM_RE = re.compile(
|
||
r"^(?:收到|好的|好|ok|okay|明白|了解|知道了|收到啦|嗯嗯|行|辛苦了|thanks?|thx)[!!.。]?$",
|
||
re.IGNORECASE,
|
||
)
|
||
CREATE_ACTION_RE = re.compile(r"(新增|新建|创建|建立|接入|配置|录入|加入|放到|放进|立项)", re.IGNORECASE)
|
||
CORRECTION_CUE_RE = re.compile(
|
||
r"(不对|错了|理解错|不是这个|不是这个意思|我说的是|我要的是|不是啊|不是新增 md|不是写 markdown)",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
ROUTING_AUTO_CREATE_PROJECT = "auto_create_project"
|
||
ROUTING_AUTO_ENTER_SIGNAL = "auto_enter_signal"
|
||
ROUTING_PENDING_LOW_CONFIDENCE = "pending_low_confidence"
|
||
ROUTING_PENDING_COLD_START = "pending_cold_start"
|
||
ROUTING_PENDING_UNCERTAIN_PROJECT = "pending_uncertain_project"
|
||
ROUTING_DUPLICATE_FILTERED = "duplicate_filtered"
|
||
ROUTING_NOISE_FILTERED = "noise_filtered"
|
||
ROUTING_FAILED = "failed"
|
||
ROUTING_DIRECT_QA = "direct_qa"
|
||
ROUTING_OPENCLAW_EXECUTED = "openclaw_executed"
|
||
ROUTING_OPENCLAW_BLOCKED = "openclaw_blocked"
|
||
ROUTING_QUEUE_FOLLOWUP_QUERY = "queue_followup_query"
|
||
ROUTING_QUEUE_FOLLOWUP_CONFIRM = "queue_followup_confirm"
|
||
ROUTING_QUEUE_FOLLOWUP_BLOCKED = "queue_followup_blocked"
|
||
ROUTING_INTENT_GATE_QUERY = "intent_gate_query"
|
||
ROUTING_INTENT_GATE_HEALTHCHECK = "intent_gate_healthcheck"
|
||
ROUTING_INTENT_GATE_CLARIFICATION = "intent_gate_clarification"
|
||
ROUTING_INTENT_GATE_EXTERNAL_CONNECT = "intent_gate_external_connect"
|
||
ROUTING_OPERATION_COMMITTED = "operation_committed"
|
||
ROUTING_OPERATION_FAILED = "operation_failed"
|
||
|
||
INTENT_QUERY = "query"
|
||
INTENT_SMALLTALK = "smalltalk"
|
||
INTENT_HEALTHCHECK = "healthcheck"
|
||
INTENT_CREATE_PROJECT = "create_project"
|
||
INTENT_CREATE_TASK = "create_task"
|
||
INTENT_SIGNAL_INTAKE = "signal_intake"
|
||
INTENT_EXTERNAL_CONNECT = "external_connect"
|
||
INTENT_CLARIFICATION_REQUIRED = "clarification_required"
|
||
|
||
ROUTE_STATUS_CLASSIFIED = "classified"
|
||
ROUTE_STATUS_QUEUED = "queued_for_decision"
|
||
ROUTE_STATUS_AUTO_RUNNING = "auto_running"
|
||
ROUTE_STATUS_AUTO_DONE = "auto_done"
|
||
ROUTE_STATUS_FAILED = "failed"
|
||
ROUTE_STATUS_DISMISSED = "dismissed"
|
||
ROUTE_STATUS_UNDONE = "undone"
|
||
|
||
ACTIVE_ITEM_ROUTE_STATUSES = {
|
||
ROUTE_STATUS_CLASSIFIED,
|
||
ROUTE_STATUS_QUEUED,
|
||
ROUTE_STATUS_AUTO_RUNNING,
|
||
"pending",
|
||
}
|
||
AUTO_DONE_ITEM_ROUTE_STATUSES = {
|
||
ROUTE_STATUS_AUTO_DONE,
|
||
"auto_processed",
|
||
"confirmed",
|
||
}
|
||
|
||
DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD = 0.80
|
||
DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD = 0.85
|
||
DEFAULT_PROJECT_UNCERTAIN_THRESHOLD = 0.60
|
||
|
||
|
||
class _LevelSplitItemSchema(BaseModel):
|
||
level: Literal["project", "signal"]
|
||
content: str = Field(min_length=1, max_length=4000)
|
||
|
||
|
||
class _LevelClassificationSchema(BaseModel):
|
||
build_intent_score: float = Field(ge=0.0, le=1.0)
|
||
scope_score: float = Field(ge=0.0, le=1.0)
|
||
timeline_score: float = Field(ge=0.0, le=1.0)
|
||
owner_score: float = Field(ge=0.0, le=1.0)
|
||
project_score: float = Field(ge=0.0, le=1.0)
|
||
level: Literal["project", "signal"]
|
||
level_confidence: float = Field(ge=0.0, le=1.0)
|
||
level_reasoning: str = Field(min_length=1, max_length=600)
|
||
should_split: bool = False
|
||
split_items: list[_LevelSplitItemSchema] = Field(default_factory=list, max_length=2)
|
||
|
||
|
||
class _SignalExtractionSchema(BaseModel):
|
||
title: str = Field(min_length=1, max_length=200)
|
||
type: Literal["竞品动态", "用户反馈", "项目风险", "新需求"]
|
||
priority: Literal["P0", "P1", "P2", "P3"]
|
||
matched_project_id: Optional[int] = None
|
||
summary: str = Field(min_length=1, max_length=3000)
|
||
reasoning: list[str] = Field(min_length=1, max_length=3)
|
||
confidence: float = Field(ge=0.0, le=1.0)
|
||
recommended_action: Literal["enter_only", "create_charter", "trigger_alert"]
|
||
|
||
|
||
class _ProjectPrefillSchema(BaseModel):
|
||
suggested_name: str = Field(min_length=1, max_length=255)
|
||
suggested_description: str = Field(min_length=1, max_length=4000)
|
||
suggested_owner: str = Field(min_length=1, max_length=120)
|
||
suggested_timeline: str = Field(min_length=1, max_length=120)
|
||
confidence: float = Field(ge=0.0, le=1.0)
|
||
reasoning: list[str] = Field(min_length=1, max_length=3)
|
||
|
||
|
||
def _normalize_reasoning(value: Any, max_items: int = 3) -> list[str]:
|
||
if isinstance(value, list):
|
||
rows = [str(x).strip() for x in value if str(x).strip()]
|
||
return rows[:max_items]
|
||
if isinstance(value, str):
|
||
parts = [p.strip(" -•\t") for p in value.splitlines() if p.strip()]
|
||
return parts[:max_items]
|
||
return []
|
||
|
||
|
||
def _safe_json_object(value: str) -> dict[str, Any]:
|
||
raw = (value or "").strip()
|
||
if raw.startswith("```"):
|
||
chunks = raw.split("```")
|
||
if len(chunks) >= 2:
|
||
raw = chunks[1].strip()
|
||
if raw.startswith("json"):
|
||
raw = raw[4:].strip()
|
||
try:
|
||
parsed = json.loads(raw)
|
||
return parsed if isinstance(parsed, dict) else {}
|
||
except Exception:
|
||
return {}
|
||
|
||
|
||
def _validate_schema(
|
||
schema: type[BaseModel],
|
||
payload: dict[str, Any],
|
||
*,
|
||
context: str,
|
||
) -> dict[str, Any]:
|
||
if not isinstance(payload, dict):
|
||
raise ValueError(f"{context}_schema_invalid: payload_not_object")
|
||
try:
|
||
if hasattr(schema, "model_validate"):
|
||
model = schema.model_validate(payload) # type: ignore[attr-defined]
|
||
else:
|
||
model = schema.parse_obj(payload) # type: ignore[attr-defined]
|
||
except ValidationError as exc:
|
||
raise ValueError(f"{context}_schema_invalid: {str(exc)[:400]}") from exc
|
||
if hasattr(model, "model_dump"):
|
||
return model.model_dump() # type: ignore[no-any-return]
|
||
return model.dict() # type: ignore[no-any-return]
|
||
|
||
|
||
def _safe_float(value: Any, default: float = 0.0) -> float:
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return float(default)
|
||
|
||
|
||
def _safe_bool(value: Any, default: bool = False) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if value is None:
|
||
return default
|
||
text = str(value).strip().lower()
|
||
if text in {"1", "true", "yes", "y", "on", "是", "真"}:
|
||
return True
|
||
if text in {"0", "false", "no", "n", "off", "否", "假"}:
|
||
return False
|
||
return default
|
||
|
||
|
||
def _safe_int(value: Any) -> Optional[int]:
|
||
if isinstance(value, bool):
|
||
return None
|
||
try:
|
||
return int(str(value).strip())
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _normalize_level_value(value: Any, default: str = "signal") -> str:
|
||
text = str(value or "").strip().lower()
|
||
if not text:
|
||
return default
|
||
if text in {"project", "project-level", "project_level", "new_project"}:
|
||
return "project"
|
||
if text in {"signal", "signal-level", "signal_level"}:
|
||
return "signal"
|
||
if text in {"项目", "项目级", "立项", "新项目"}:
|
||
return "project"
|
||
if text in {"信号", "信号级", "需求", "需求级", "事件"}:
|
||
return "signal"
|
||
if "project" in text or "项目" in text or "立项" in text:
|
||
return "project"
|
||
if "signal" in text or "信号" in text or "需求" in text:
|
||
return "signal"
|
||
return default
|
||
|
||
|
||
def _first_non_empty(data: dict[str, Any], keys: tuple[str, ...]) -> Any:
|
||
for key in keys:
|
||
if key not in data:
|
||
continue
|
||
value = data.get(key)
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, str) and not value.strip():
|
||
continue
|
||
return value
|
||
return None
|
||
|
||
|
||
def _utc_now() -> datetime:
|
||
return datetime.now(timezone.utc)
|
||
|
||
|
||
def _compact_text(value: Any, max_len: int = 160) -> str:
|
||
text = str(value or "").strip()
|
||
if len(text) <= max_len:
|
||
return text
|
||
return f"{text[: max_len - 3]}..."
|
||
|
||
|
||
def _safe_dict(value: Any) -> dict[str, Any]:
|
||
if isinstance(value, dict):
|
||
return value
|
||
if isinstance(value, str):
|
||
return _safe_json_object(value)
|
||
return {}
|
||
|
||
|
||
def _build_undo_deadline(value: Optional[datetime], *, minutes: int = 10) -> Optional[datetime]:
|
||
if value is None:
|
||
return None
|
||
if value.tzinfo is not None:
|
||
value = value.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return value + timedelta(minutes=minutes)
|
||
|
||
|
||
class FeishuSignalPipelineService:
|
||
"""飞书消息的两段式分流与路由。"""
|
||
|
||
def __init__(self, db: AsyncSession, *, tenant_id: int):
|
||
self.db = db
|
||
self.tenant_id = int(tenant_id)
|
||
self.llm = LLMService()
|
||
|
||
@staticmethod
|
||
def _normalize_route_status(value: Any) -> str:
|
||
text = str(value or "").strip().lower()
|
||
if text in {ROUTE_STATUS_CLASSIFIED, ROUTE_STATUS_QUEUED, ROUTE_STATUS_AUTO_RUNNING, ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_FAILED, ROUTE_STATUS_DISMISSED, ROUTE_STATUS_UNDONE}:
|
||
return text
|
||
if text == "pending":
|
||
return ROUTE_STATUS_QUEUED
|
||
if text in {"auto_processed", "confirmed"}:
|
||
return ROUTE_STATUS_AUTO_DONE
|
||
if text == "dismissed":
|
||
return ROUTE_STATUS_DISMISSED
|
||
return text or ROUTE_STATUS_FAILED
|
||
|
||
@classmethod
|
||
def _is_active_route_status(cls, value: Any) -> bool:
|
||
return cls._normalize_route_status(value) in {
|
||
ROUTE_STATUS_CLASSIFIED,
|
||
ROUTE_STATUS_QUEUED,
|
||
ROUTE_STATUS_AUTO_RUNNING,
|
||
}
|
||
|
||
@staticmethod
|
||
def _is_healthcheck_message(message: str) -> bool:
|
||
return bool(HEALTHCHECK_RE.search(str(message or "").strip()))
|
||
|
||
@staticmethod
|
||
def _is_short_confirmation_message(message: str) -> bool:
|
||
return bool(SHORT_CONFIRM_RE.search(str(message or "").strip()))
|
||
|
||
@staticmethod
|
||
def _is_correction_message(message: str) -> bool:
|
||
return bool(CORRECTION_CUE_RE.search(str(message or "").strip()))
|
||
|
||
@staticmethod
|
||
def _looks_like_continue_followup(message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
if FOLLOWUP_CREATE_CONTINUE_RE.search(text):
|
||
return bool(FOLLOWUP_REFERENCE_RE.search(text))
|
||
return bool(FOLLOWUP_CONTINUE_RE.search(text))
|
||
|
||
@staticmethod
|
||
def _looks_like_task_create_request(message: str) -> bool:
|
||
return bool(TASK_CREATE_RE.search(str(message or "").strip()))
|
||
|
||
@staticmethod
|
||
def _looks_like_project_create_request(message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
if PROJECT_CREATE_GATE_RE.search(text):
|
||
return True
|
||
return bool(OPENCLAW_PROJECT_CREATE_RE.search(text))
|
||
|
||
@staticmethod
|
||
def _looks_like_external_connect_request(message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
return bool(CREATE_ACTION_RE.search(text) and EXTERNAL_INTEGRATION_RE.search(text))
|
||
|
||
@staticmethod
|
||
def _can_execute_high_value_action(user: Optional[User]) -> bool:
|
||
if user is None:
|
||
return False
|
||
return str(getattr(user, "role", "") or "").upper() in {"BOSS", "ADMIN", "PM"}
|
||
|
||
@staticmethod
|
||
def _stable_positive_int(*parts: Any) -> int:
|
||
raw = "::".join(str(part or "").strip() for part in parts if str(part or "").strip())
|
||
digest = hashlib.sha1(raw.encode("utf-8")).hexdigest()
|
||
return int(digest[:12], 16)
|
||
|
||
@staticmethod
|
||
def _normalize_key_fragment(value: str, *, max_len: int = 80) -> str:
|
||
normalized = re.sub(r"[^0-9a-z\u4e00-\u9fff]+", "", str(value or "").strip().lower())
|
||
return normalized[:max_len]
|
||
|
||
@classmethod
|
||
def _build_intent_fragment(cls, message: str) -> str:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return "generic"
|
||
if EXTERNAL_INTEGRATION_RE.search(text):
|
||
return "external_integration"
|
||
if CROSS_SYSTEM_RE.search(text):
|
||
return "cross_system"
|
||
if PROJECT_COMPLEXITY_RE.search(text):
|
||
return "project_setup"
|
||
if SIMPLE_SIGNAL_REQUEST_RE.search(text):
|
||
return "feature_change"
|
||
fragment = cls._normalize_key_fragment(text, max_len=64)
|
||
return fragment or "generic"
|
||
|
||
def _build_queue_dedupe_key(
|
||
self,
|
||
*,
|
||
level: str,
|
||
intent: Optional[str],
|
||
message: str,
|
||
source_chat_id: str,
|
||
matched_project_id: Optional[int],
|
||
source_object: Optional[str] = None,
|
||
) -> str:
|
||
project_key = str(int(matched_project_id)) if matched_project_id is not None else "global"
|
||
source_key = self._normalize_key_fragment(source_chat_id or "direct", max_len=48) or "direct"
|
||
intent_key = self._normalize_key_fragment(str(intent or "").strip().lower(), max_len=48) or self._build_intent_fragment(message)
|
||
object_key = self._normalize_key_fragment(source_object or message, max_len=64) or "generic"
|
||
return f"{level}:{project_key}:{source_key}:{intent_key}:{object_key}"[:255]
|
||
|
||
async def _find_active_dedupe_item(self, dedupe_key: Optional[str]) -> Optional[FeishuItem]:
|
||
normalized_key = (dedupe_key or "").strip()
|
||
if not normalized_key:
|
||
return None
|
||
return (
|
||
await self.db.execute(
|
||
select(FeishuItem)
|
||
.where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.dedupe_key == normalized_key,
|
||
FeishuItem.route_status.in_(tuple(ACTIVE_ITEM_ROUTE_STATUSES)),
|
||
)
|
||
)
|
||
.order_by(FeishuItem.updated_at.desc(), FeishuItem.id.desc())
|
||
.limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _build_existing_item_result(self, item: FeishuItem) -> dict[str, Any]:
|
||
level = str(item.level or "").lower()
|
||
result: dict[str, Any] = {
|
||
"item_id": int(item.id),
|
||
"level": level,
|
||
"project_id": int(item.matched_project_id) if item.matched_project_id is not None else None,
|
||
"signal_id": None,
|
||
"route_status": self._normalize_route_status(item.route_status),
|
||
"clarification_message": item.blocking_reason or None,
|
||
}
|
||
if level == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
if suggestion and suggestion.created_project_id:
|
||
result["project_id"] = int(suggestion.created_project_id)
|
||
return result
|
||
|
||
if level == "command":
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
project_id = _safe_int(payload.get("project_id") or item.matched_project_id)
|
||
if project_id is not None:
|
||
result["project_id"] = int(project_id)
|
||
return result
|
||
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if signal:
|
||
result["signal_id"] = int(signal.id)
|
||
if signal.matched_project_id is not None:
|
||
result["project_id"] = int(signal.matched_project_id)
|
||
return result
|
||
|
||
async def _resolve_intent_gate(
|
||
self,
|
||
*,
|
||
message: str,
|
||
inferred_project_id: Optional[int],
|
||
source_chat_id: str = "",
|
||
source_sender_open_id: Optional[str] = None,
|
||
sender: str = "",
|
||
) -> dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return {
|
||
"intent": INTENT_SMALLTALK,
|
||
"canonical_action": None,
|
||
"clarification_template": "",
|
||
"risk_level": "low",
|
||
"fields": {},
|
||
"correction_match": None,
|
||
}
|
||
|
||
correction_match: Optional[dict[str, Any]] = None
|
||
if self.db is not None and len(text) >= 4:
|
||
try:
|
||
recall_rows = await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).retrieve_intent_correction_samples(
|
||
utterance=text,
|
||
top_k=1,
|
||
project_id=inferred_project_id,
|
||
)
|
||
if recall_rows:
|
||
correction_match = recall_rows[0]
|
||
except Exception as exc:
|
||
logger.warning(f"intent_correction_recall_failed: tenant_id={self.tenant_id}, error={exc}")
|
||
|
||
if self._is_healthcheck_message(text):
|
||
return {
|
||
"intent": INTENT_HEALTHCHECK,
|
||
"canonical_action": None,
|
||
"clarification_template": "",
|
||
"risk_level": "low",
|
||
"fields": {},
|
||
"correction_match": correction_match,
|
||
}
|
||
if self._is_short_confirmation_message(text):
|
||
return {
|
||
"intent": INTENT_SMALLTALK,
|
||
"canonical_action": None,
|
||
"clarification_template": "",
|
||
"risk_level": "low",
|
||
"fields": {},
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
if correction_match and float(correction_match.get("score") or 0.0) >= 0.84:
|
||
gate = {
|
||
"intent": str(correction_match.get("correct_intent") or INTENT_SIGNAL_INTAKE).strip().lower(),
|
||
"canonical_action": str(correction_match.get("canonical_action") or "").strip() or None,
|
||
"clarification_template": str(correction_match.get("clarification_template") or "").strip(),
|
||
"risk_level": str(correction_match.get("risk_level") or "medium").strip().lower() or "medium",
|
||
"fields": {},
|
||
"correction_match": correction_match,
|
||
}
|
||
if gate["intent"] not in {
|
||
INTENT_QUERY,
|
||
INTENT_SMALLTALK,
|
||
INTENT_HEALTHCHECK,
|
||
INTENT_CREATE_PROJECT,
|
||
INTENT_CREATE_TASK,
|
||
INTENT_SIGNAL_INTAKE,
|
||
INTENT_EXTERNAL_CONNECT,
|
||
INTENT_CLARIFICATION_REQUIRED,
|
||
}:
|
||
gate["intent"] = INTENT_SIGNAL_INTAKE
|
||
return gate
|
||
|
||
if self._looks_like_project_create_request(text):
|
||
fields = self._extract_project_intent_fields(text)
|
||
fields = await self._maybe_prefill_project_fields_from_embedded_content(
|
||
message=text,
|
||
fields=fields,
|
||
)
|
||
fields = await self._maybe_prefill_project_fields_from_context(
|
||
message=text,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
fields=fields,
|
||
)
|
||
if fields["ready"]:
|
||
return {
|
||
"intent": INTENT_CREATE_PROJECT,
|
||
"canonical_action": "project.create",
|
||
"clarification_template": "",
|
||
"risk_level": "high",
|
||
"fields": fields,
|
||
"correction_match": correction_match,
|
||
}
|
||
return {
|
||
"intent": INTENT_CLARIFICATION_REQUIRED,
|
||
"canonical_action": "project.create",
|
||
"clarification_template": self._build_project_clarification_message(fields["missing_fields"]),
|
||
"risk_level": "high",
|
||
"fields": fields,
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
if self._looks_like_task_create_request(text):
|
||
fields = self._extract_task_intent_fields(text=text, inferred_project_id=inferred_project_id)
|
||
if fields["ready"]:
|
||
return {
|
||
"intent": INTENT_CREATE_TASK,
|
||
"canonical_action": "task.create",
|
||
"clarification_template": "",
|
||
"risk_level": "high",
|
||
"fields": fields,
|
||
"correction_match": correction_match,
|
||
}
|
||
return {
|
||
"intent": INTENT_CLARIFICATION_REQUIRED,
|
||
"canonical_action": "task.create",
|
||
"clarification_template": self._build_task_clarification_message(fields["missing_fields"]),
|
||
"risk_level": "high",
|
||
"fields": fields,
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
if self._looks_like_external_connect_request(text):
|
||
return {
|
||
"intent": INTENT_EXTERNAL_CONNECT,
|
||
"canonical_action": "source.connect",
|
||
"clarification_template": "外部系统接入默认进入待决策队列。请补充接入目标、授权边界、回流项目和幂等要求。",
|
||
"risk_level": "high",
|
||
"fields": {"project_id": inferred_project_id},
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
if self._is_obvious_question(text):
|
||
return {
|
||
"intent": INTENT_QUERY,
|
||
"canonical_action": None,
|
||
"clarification_template": "",
|
||
"risk_level": "low",
|
||
"fields": {},
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
return {
|
||
"intent": INTENT_SIGNAL_INTAKE,
|
||
"canonical_action": "signal.enter",
|
||
"clarification_template": "",
|
||
"risk_level": "medium",
|
||
"fields": {"project_id": inferred_project_id},
|
||
"correction_match": correction_match,
|
||
}
|
||
|
||
@staticmethod
|
||
def _extract_project_scope(text: str, *, description: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
for pattern in (
|
||
r"(?:范围|scope|面向|目标|用于|负责|服务于)[::\s]*(.+)",
|
||
r"(?:描述|背景|场景|做什么)[::\s]*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::")
|
||
if candidate:
|
||
return candidate[:400]
|
||
if len(description) >= 12:
|
||
return description[:400]
|
||
return ""
|
||
|
||
def _extract_project_intent_fields(self, message: str) -> dict[str, Any]:
|
||
title = OpenClawCommanderService._extract_project_create_title(message)
|
||
if not title:
|
||
cleaned = re.sub(r"\s+", " ", str(message or "")).strip()
|
||
for pattern in (
|
||
r"(?:名称|名字|项目名)(?:叫|是|为)?[::\s]*(.+)",
|
||
r"项目(?:叫|名为|名称叫|名称是|名称为)[::\s]*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
candidate = re.sub(r"[,。,.;;!??].*$", "", match.group(1)).strip(" ,,::\"'“”")
|
||
if candidate:
|
||
title = candidate[:24]
|
||
break
|
||
description = OpenClawCommanderService._extract_project_create_description(
|
||
instruction=message,
|
||
title=title,
|
||
)
|
||
project_scope = self._extract_project_scope(message, description=description)
|
||
missing_fields: list[str] = []
|
||
if not title:
|
||
missing_fields.append("title")
|
||
if not description:
|
||
missing_fields.append("description")
|
||
if not project_scope:
|
||
missing_fields.append("project_scope")
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"project_scope": project_scope,
|
||
"missing_fields": missing_fields,
|
||
"ready": not missing_fields,
|
||
}
|
||
|
||
@staticmethod
|
||
def _looks_like_contextual_project_request(message: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(message or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
return any(
|
||
token in normalized
|
||
for token in (
|
||
"根据这个",
|
||
"根据上面",
|
||
"基于这个",
|
||
"基于上面",
|
||
"按这个",
|
||
"按上面",
|
||
"汇总以上",
|
||
"汇总上面",
|
||
"以上内容",
|
||
"上面内容",
|
||
"这个汇总",
|
||
"这个总结",
|
||
"刚才内容",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _strip_feishu_mention_prefix(text: str) -> str:
|
||
cleaned = re.sub(r"^<at[^>]*>[^<]*</at>\s*", "", str(text or ""), flags=re.IGNORECASE).strip()
|
||
return re.sub(r"\s+", " ", cleaned).strip()
|
||
|
||
@staticmethod
|
||
def _looks_like_placeholder_project_text(text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return True
|
||
if len(normalized) > 48:
|
||
return False
|
||
return any(
|
||
token in normalized
|
||
for token in (
|
||
"创建个项目",
|
||
"创建一个项目",
|
||
"新建一个项目",
|
||
"新增一个项目",
|
||
"给我创建个项目",
|
||
"项目在项目列表",
|
||
"项目在pmflow",
|
||
"放进pmflow",
|
||
"放到pmflow",
|
||
"放进项目列表",
|
||
"放到项目列表",
|
||
"汇总以上内容",
|
||
"根据这个",
|
||
"根据上面",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_project_request_artifact(text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return False
|
||
return any(
|
||
token in normalized
|
||
for token in (
|
||
"给我创建个项目",
|
||
"给我创建一个项目",
|
||
"创建个项目",
|
||
"创建一个项目",
|
||
"新建一个项目",
|
||
"新增一个项目",
|
||
"项目在项目列表",
|
||
"项目列表@pmflow",
|
||
"进项目列表",
|
||
"放进项目列表",
|
||
"放到项目列表",
|
||
"放进pmflow",
|
||
"放到pmflow",
|
||
"根据这个汇总",
|
||
"根据上面汇总",
|
||
"汇总以上内容",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _looks_like_unusable_project_context(text: str) -> bool:
|
||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||
if not normalized:
|
||
return True
|
||
return any(
|
||
token in normalized
|
||
for token in (
|
||
"系统处理中出现异常",
|
||
"本次未执行",
|
||
"请稍后重试",
|
||
"requestwasaborted",
|
||
"connectionaborted",
|
||
"429",
|
||
"模型服务异常",
|
||
"无法生成可靠回答",
|
||
"无法直接给结论",
|
||
"没有输出,可能后端有问题",
|
||
"让我看看main.py",
|
||
)
|
||
)
|
||
|
||
@staticmethod
|
||
def _guess_project_title_from_text(text: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
if not cleaned:
|
||
return ""
|
||
normalized = re.sub(r"\s+", "", cleaned).lower()
|
||
if "十五五" in cleaned and any(token in cleaned for token in ("医药", "医疗", "器械", "中医药")):
|
||
return "十五五医药方向研究"
|
||
if "医药" in cleaned and any(
|
||
token in normalized
|
||
for token in ("方向", "值得关注", "创新药", "中医药", "老龄化", "供应链安全", "出海")
|
||
):
|
||
return "医药方向研究"
|
||
if "医疗器械" in cleaned and "国产替代" in cleaned:
|
||
return "医疗器械国产替代研究"
|
||
return ""
|
||
|
||
@staticmethod
|
||
def _extract_embedded_project_context(message: str) -> str:
|
||
cleaned = re.sub(r"\s+", " ", str(message or "")).strip()
|
||
if len(cleaned) < 80:
|
||
return ""
|
||
markers = []
|
||
for pattern in (
|
||
r"(?:根据这个|根据上面|基于这个|基于上面|按这个|按上面|汇总以上内容|汇总上面内容|汇总一下|这个汇总|这个总结).{0,40}(?:创建|新建|建立|建个|建一个).{0,8}项目",
|
||
r"(?:给我|帮我|请|麻烦)?(?:创建|新建|建立|建个|建一个|做个|做一个)(?:一个|个)?项目(?:在|进|到)?(?:pmflow|项目列表)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if match:
|
||
markers.append(match.start())
|
||
if not markers:
|
||
return ""
|
||
context = cleaned[: min(markers)].strip(" ,,::")
|
||
if len(context) < 80:
|
||
return ""
|
||
return context[:4000]
|
||
|
||
@staticmethod
|
||
def _derive_project_title_from_context(*, question: str, answer: str) -> str:
|
||
guessed = FeishuSignalPipelineService._guess_project_title_from_text(f"{question}\n{answer}".strip())
|
||
if guessed:
|
||
return guessed
|
||
|
||
def _normalize(raw: str) -> str:
|
||
candidate = re.sub(r"\s+", " ", str(raw or "")).strip(" ,,。.!!??::;;")
|
||
candidate = re.sub(r"^(?:请问|麻烦|帮我|我想知道|我想了解|想了解一下|目前|现在|关于|对于|针对)", "", candidate)
|
||
candidate = re.sub(
|
||
r"(?:还有哪些|有哪些|值得关注什么|值得关注哪些|值得关注|应该投什么|该投什么|该怎么做|怎么做|是什么|是啥|可以吗|吗|呢)$",
|
||
"",
|
||
candidate,
|
||
)
|
||
candidate = re.sub(r"[,,].*$", "", candidate).strip(" ,,。.!!??::;;")
|
||
candidate = re.sub(r"(?:里面|里)$", "", candidate).strip(" ,,。.!!??::;;")
|
||
candidate = re.sub(r"[。;;!!??].*$", "", candidate).strip(" ,,。.!!??::;;")
|
||
normalized = OpenClawCommanderService._normalize_project_title_candidate(candidate)
|
||
if normalized.endswith(("方向", "规划", "创新")):
|
||
normalized = OpenClawCommanderService._normalize_project_title_candidate(f"{normalized}研究")
|
||
return normalized
|
||
|
||
title = _normalize(question)
|
||
if title:
|
||
return title
|
||
|
||
cleaned_answer = re.sub(r"\s+", " ", str(answer or "")).strip()
|
||
for pattern in (
|
||
r"((?:十五五|医药|医疗|器械|公共卫生|老龄化)[^。;;!!??]{0,16}(?:方向|规划|创新|体系))",
|
||
r"((?:[A-Za-z0-9\u4e00-\u9fff]{2,18})(?:方向|规划|创新|中台|系统|平台|治理|流程|方案|体系))",
|
||
):
|
||
match = re.search(pattern, cleaned_answer)
|
||
if not match:
|
||
continue
|
||
title = _normalize(match.group(1))
|
||
if title:
|
||
return title
|
||
return ""
|
||
|
||
async def _load_recent_chat_turns(
|
||
self,
|
||
*,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
sender: str,
|
||
limit: int = 3,
|
||
) -> list[dict[str, str]]:
|
||
if self.db is None or not source_chat_id:
|
||
return []
|
||
conversation_key = self._build_rag_conversation_key(
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
)
|
||
external_session_id = AdaptiveRAGService._external_session_id_from_key(conversation_key)
|
||
if external_session_id is None:
|
||
return []
|
||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||
messages = await rag_service.context_cache.get_session_messages(
|
||
tenant_id=self.tenant_id,
|
||
session_id=int(external_session_id),
|
||
)
|
||
if not messages:
|
||
return []
|
||
turns = rag_service.context_cache.messages_to_turns(messages)
|
||
return [row for row in turns if isinstance(row, dict)][-limit:]
|
||
|
||
async def _load_recent_chat_reply_fallback_text(self, *, source_chat_id: str, limit: int = 5) -> str:
|
||
if self.db is None or not source_chat_id:
|
||
return ""
|
||
rows = (
|
||
await self.db.execute(
|
||
select(FeishuReplyOutbox)
|
||
.where(
|
||
and_(
|
||
FeishuReplyOutbox.chat_id == (source_chat_id or "")[:128],
|
||
FeishuReplyOutbox.status == "sent",
|
||
)
|
||
)
|
||
.order_by(FeishuReplyOutbox.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
for row in rows:
|
||
text = self._strip_feishu_mention_prefix(str(row.message_text or ""))
|
||
if not text:
|
||
continue
|
||
if self._looks_like_unusable_project_context(text):
|
||
continue
|
||
return text[:4000]
|
||
return ""
|
||
|
||
async def _maybe_prefill_project_fields_from_embedded_content(
|
||
self,
|
||
*,
|
||
message: str,
|
||
fields: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if fields.get("ready"):
|
||
return fields
|
||
|
||
embedded_context = self._extract_embedded_project_context(message)
|
||
if not embedded_context or self._looks_like_unusable_project_context(embedded_context):
|
||
return fields
|
||
|
||
current_description = str(fields.get("description") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(current_description)
|
||
or self._looks_like_project_request_artifact(current_description)
|
||
):
|
||
current_description = ""
|
||
current_scope = str(fields.get("project_scope") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(current_scope)
|
||
or self._looks_like_project_request_artifact(current_scope)
|
||
):
|
||
current_scope = ""
|
||
|
||
title = str(fields.get("title") or "").strip() or self._guess_project_title_from_text(embedded_context)
|
||
description = current_description or embedded_context[:4000]
|
||
project_scope = current_scope or self._extract_project_scope(embedded_context, description=description)
|
||
if title and description and project_scope:
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"project_scope": project_scope,
|
||
"missing_fields": [],
|
||
"ready": True,
|
||
}
|
||
|
||
project_info = await self._extract_project_prefill(
|
||
f"当前消息里的项目材料:{embedded_context}\n\n用户动作:{message}\n\n请仅基于当前消息里的材料补全项目名称、项目描述、项目范围。"
|
||
)
|
||
suggested_name = OpenClawCommanderService._normalize_project_title_candidate(
|
||
str(project_info.get("suggested_name") or "").strip()
|
||
)
|
||
if self._looks_like_placeholder_project_text(suggested_name):
|
||
suggested_name = ""
|
||
title = title or suggested_name
|
||
description = description or str(project_info.get("suggested_description") or "").strip()[:4000]
|
||
if self._looks_like_unusable_project_context(description):
|
||
description = ""
|
||
project_scope = project_scope or self._extract_project_scope(embedded_context, description=description)
|
||
|
||
missing_fields: list[str] = []
|
||
if not title:
|
||
missing_fields.append("title")
|
||
if not description:
|
||
missing_fields.append("description")
|
||
if not project_scope:
|
||
missing_fields.append("project_scope")
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"project_scope": project_scope,
|
||
"missing_fields": missing_fields,
|
||
"ready": not missing_fields,
|
||
}
|
||
|
||
async def _maybe_prefill_project_fields_from_context(
|
||
self,
|
||
*,
|
||
message: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
sender: str,
|
||
fields: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
if fields.get("ready"):
|
||
return fields
|
||
if not source_chat_id or not self._looks_like_contextual_project_request(message):
|
||
return fields
|
||
|
||
turns = await self._load_recent_chat_turns(
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
limit=3,
|
||
)
|
||
context_blocks: list[str] = []
|
||
question_hint = ""
|
||
answer_hint = ""
|
||
if turns:
|
||
anchor = turns[-1]
|
||
recent_question = str(anchor.get("question") or "").strip()
|
||
recent_answer = str(anchor.get("answer") or "").strip()
|
||
if recent_question:
|
||
context_blocks.append(f"最近用户问题:{recent_question}")
|
||
question_hint = recent_question
|
||
if recent_answer and not self._looks_like_unusable_project_context(recent_answer):
|
||
context_blocks.append(f"最近系统回答:{recent_answer}")
|
||
answer_hint = recent_answer
|
||
|
||
if not context_blocks:
|
||
reply_fallback = await self._load_recent_chat_reply_fallback_text(source_chat_id=source_chat_id)
|
||
if reply_fallback:
|
||
context_blocks.append(f"最近系统回答:{reply_fallback}")
|
||
answer_hint = reply_fallback
|
||
|
||
if not context_blocks:
|
||
return fields
|
||
|
||
base_description = str(fields.get("description") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(base_description)
|
||
or self._looks_like_project_request_artifact(base_description)
|
||
):
|
||
base_description = ""
|
||
if not answer_hint and not base_description:
|
||
return fields
|
||
|
||
heuristic_title = self._derive_project_title_from_context(question=question_hint, answer=answer_hint)
|
||
heuristic_description = base_description or answer_hint[:4000]
|
||
heuristic_scope = str(fields.get("project_scope") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(heuristic_scope)
|
||
or self._looks_like_project_request_artifact(heuristic_scope)
|
||
):
|
||
heuristic_scope = ""
|
||
if not heuristic_scope and heuristic_description:
|
||
heuristic_scope = self._extract_project_scope(
|
||
f"{question_hint}\n\n{answer_hint}".strip(),
|
||
description=heuristic_description,
|
||
)
|
||
if heuristic_title and heuristic_description and heuristic_scope:
|
||
return {
|
||
"title": str(fields.get("title") or "").strip() or heuristic_title,
|
||
"description": heuristic_description,
|
||
"project_scope": heuristic_scope,
|
||
"missing_fields": [],
|
||
"ready": True,
|
||
}
|
||
|
||
prefill_source = (
|
||
f"当前建项目请求:{message}\n\n"
|
||
+ "\n\n".join(context_blocks)
|
||
+ "\n\n请基于最近上下文补全项目名称、项目描述、项目范围。"
|
||
)
|
||
project_info = await self._extract_project_prefill(prefill_source)
|
||
current_description = str(fields.get("description") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(current_description)
|
||
or self._looks_like_project_request_artifact(current_description)
|
||
):
|
||
current_description = ""
|
||
current_scope = str(fields.get("project_scope") or "").strip()
|
||
if (
|
||
self._looks_like_placeholder_project_text(current_scope)
|
||
or self._looks_like_project_request_artifact(current_scope)
|
||
):
|
||
current_scope = ""
|
||
title = (
|
||
str(fields.get("title") or "").strip()
|
||
or heuristic_title
|
||
or OpenClawCommanderService._normalize_project_title_candidate(str(project_info.get("suggested_name") or ""))
|
||
)
|
||
description = current_description or str(project_info.get("suggested_description") or "").strip()
|
||
if not description and answer_hint:
|
||
description = answer_hint[:4000]
|
||
if self._looks_like_unusable_project_context(description):
|
||
description = ""
|
||
project_scope = current_scope
|
||
if not project_scope:
|
||
scope_source = f"{description}\n\n{answer_hint}".strip()
|
||
project_scope = self._extract_project_scope(scope_source, description=description)
|
||
|
||
missing_fields: list[str] = []
|
||
if not title:
|
||
missing_fields.append("title")
|
||
if not description:
|
||
missing_fields.append("description")
|
||
if not project_scope:
|
||
missing_fields.append("project_scope")
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"project_scope": project_scope,
|
||
"missing_fields": missing_fields,
|
||
"ready": not missing_fields,
|
||
}
|
||
|
||
def _resolve_project_candidate_fields(
|
||
self,
|
||
*,
|
||
item: FeishuItem,
|
||
suggestion: Optional[ProjectSuggestion] = None,
|
||
) -> dict[str, Any]:
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {}
|
||
title = str(
|
||
(suggestion.suggested_name if suggestion is not None else None)
|
||
or operation_payload.get("title")
|
||
or ""
|
||
).strip()
|
||
description = str(
|
||
(suggestion.suggested_description if suggestion is not None else None)
|
||
or operation_payload.get("description")
|
||
or ""
|
||
).strip()
|
||
project_scope = str(payload.get("project_scope") or "").strip()
|
||
if not title or not description:
|
||
extracted = self._extract_project_intent_fields(item.raw_message or "")
|
||
if not title:
|
||
title = str(extracted.get("title") or "").strip()
|
||
if not description:
|
||
description = str(extracted.get("description") or "").strip()
|
||
if not project_scope:
|
||
project_scope = str(extracted.get("project_scope") or "").strip()
|
||
if not project_scope:
|
||
project_scope = self._extract_project_scope(item.raw_message or "", description=description)
|
||
|
||
missing_fields: list[str] = []
|
||
if not title:
|
||
missing_fields.append("title")
|
||
if not description:
|
||
missing_fields.append("description")
|
||
if not project_scope:
|
||
missing_fields.append("project_scope")
|
||
return {
|
||
"title": title,
|
||
"description": description,
|
||
"project_scope": project_scope,
|
||
"missing_fields": missing_fields,
|
||
"ready": not missing_fields,
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_project_clarification_message(missing_fields: list[str]) -> str:
|
||
labels = {
|
||
"title": "项目名称",
|
||
"description": "项目描述",
|
||
"project_scope": "项目范围",
|
||
}
|
||
ordered = [labels[field] for field in missing_fields if field in labels]
|
||
suffix = "、".join(ordered) if ordered else "项目名称、项目描述、项目范围"
|
||
return f"未执行:当前建项目请求还缺少 {suffix}。请直接补充这三项后我再创建。"
|
||
|
||
@classmethod
|
||
def _build_project_preview_title(
|
||
cls,
|
||
*,
|
||
title: str,
|
||
description: str,
|
||
project_scope: str,
|
||
) -> str:
|
||
normalized_title = OpenClawCommanderService._normalize_project_title_candidate(title)
|
||
if normalized_title and not cls._looks_like_placeholder_project_text(normalized_title):
|
||
return normalized_title
|
||
|
||
for candidate in (project_scope, description):
|
||
cleaned = str(candidate or "").strip()
|
||
if not cleaned or cls._looks_like_placeholder_project_text(cleaned):
|
||
continue
|
||
normalized = OpenClawCommanderService._normalize_project_title_candidate(cleaned[:48])
|
||
if normalized:
|
||
return normalized
|
||
return "项目创建请求"
|
||
|
||
@classmethod
|
||
def _build_project_preview_summary(
|
||
cls,
|
||
*,
|
||
description: str,
|
||
project_scope: str,
|
||
blocking_reason: Optional[str],
|
||
fallback_text: str,
|
||
) -> str:
|
||
normalized_description = str(description or "").strip()
|
||
if cls._looks_like_placeholder_project_text(normalized_description):
|
||
normalized_description = ""
|
||
normalized_scope = str(project_scope or "").strip()
|
||
if cls._looks_like_placeholder_project_text(normalized_scope):
|
||
normalized_scope = ""
|
||
|
||
fragments: list[str] = []
|
||
if normalized_description:
|
||
fragments.append(_compact_text(normalized_description, max_len=140))
|
||
if normalized_scope and normalized_scope not in normalized_description:
|
||
fragments.append(f"范围:{_compact_text(normalized_scope, max_len=90)}")
|
||
if fragments:
|
||
return ";".join(fragments[:2])
|
||
if str(blocking_reason or "").strip():
|
||
return str(blocking_reason).strip()
|
||
return _compact_text(fallback_text, max_len=140)
|
||
|
||
def _extract_task_intent_fields(self, *, text: str, inferred_project_id: Optional[int]) -> dict[str, Any]:
|
||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip()
|
||
title = ""
|
||
quoted_match = re.search(r"[\"'“](.+?)[\"'”]", cleaned)
|
||
if quoted_match:
|
||
title = quoted_match.group(1).strip()
|
||
if not title:
|
||
for pattern in (
|
||
r"(?:新增|新建|创建|加一个|加个).{0,8}任务(?:叫|名为|标题是|是|:|:)?\s*(.+)",
|
||
r"任务(?:叫|名为|标题是|是|:|:)\s*(.+)",
|
||
):
|
||
match = re.search(pattern, cleaned, re.IGNORECASE)
|
||
if not match:
|
||
continue
|
||
candidate = re.sub(r"[。;;!!??].*$", "", match.group(1)).strip(" ,,::")
|
||
candidate = re.sub(r"^(?:到|进|放到)\s*项目.*$", "", candidate, flags=re.IGNORECASE).strip(" ,,::")
|
||
if candidate:
|
||
title = candidate[:120]
|
||
break
|
||
|
||
description = ""
|
||
if title:
|
||
description = cleaned.replace(title, "", 1).strip(" ,,::")
|
||
description = re.sub(r"^(?:新增|新建|创建|加一个|加个).{0,8}任务", "", description, flags=re.IGNORECASE).strip(" ,,::")
|
||
|
||
missing_fields: list[str] = []
|
||
if inferred_project_id is None:
|
||
missing_fields.append("project_id")
|
||
if not title:
|
||
missing_fields.append("title")
|
||
return {
|
||
"project_id": int(inferred_project_id) if inferred_project_id is not None else None,
|
||
"title": title,
|
||
"description": description[:5000] or None,
|
||
"missing_fields": missing_fields,
|
||
"ready": not missing_fields,
|
||
}
|
||
|
||
@staticmethod
|
||
def _build_task_clarification_message(missing_fields: list[str]) -> str:
|
||
labels = {
|
||
"project_id": "归属项目",
|
||
"title": "任务标题",
|
||
}
|
||
ordered = [labels[field] for field in missing_fields if field in labels]
|
||
suffix = "、".join(ordered) if ordered else "归属项目和任务标题"
|
||
return f"未执行:当前建任务请求还缺少 {suffix}。请补充后我再创建。"
|
||
|
||
@staticmethod
|
||
def _build_pending_operation_summary(*, operation_code: str, payload: dict[str, Any]) -> str:
|
||
if operation_code == "project.create":
|
||
return f"待创建项目:{str(payload.get('title') or '未命名项目').strip()}"
|
||
if operation_code == "task.create":
|
||
return f"待创建任务:{str(payload.get('title') or '未命名任务').strip()}"
|
||
return f"待执行动作:{operation_code}"
|
||
|
||
@staticmethod
|
||
def _build_pending_operation_next_step(*, operation_code: str) -> str:
|
||
if operation_code == "project.create":
|
||
return "补齐信息或确认后,系统会创建项目并绑定真实项目 ID。"
|
||
if operation_code == "task.create":
|
||
return "确认后,系统会在目标项目中创建任务。"
|
||
return "补齐上下文后,系统会继续执行该动作。"
|
||
|
||
@staticmethod
|
||
def _build_gate_source_object(*, intent: str, fields: dict[str, Any], message: str) -> str:
|
||
if intent == INTENT_CREATE_PROJECT:
|
||
return str(fields.get("title") or fields.get("project_scope") or message)
|
||
if intent == INTENT_CREATE_TASK:
|
||
return str(fields.get("title") or fields.get("project_id") or message)
|
||
return str(fields.get("project_id") or intent or message)
|
||
|
||
async def _queue_project_intent_item(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
occurred_at: datetime,
|
||
intent_gate: dict[str, Any],
|
||
blocking_reason: str,
|
||
decision_reason: str,
|
||
) -> dict[str, Any]:
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
source_object = self._build_gate_source_object(
|
||
intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT),
|
||
fields=fields,
|
||
message=message,
|
||
)
|
||
dedupe_key = self._build_queue_dedupe_key(
|
||
level="project",
|
||
intent=str(intent_gate.get("intent") or INTENT_CREATE_PROJECT),
|
||
message=message,
|
||
source_chat_id=source_chat_id,
|
||
matched_project_id=None,
|
||
source_object=source_object,
|
||
)
|
||
existing_item = await self._find_active_dedupe_item(dedupe_key)
|
||
if existing_item is not None:
|
||
existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1
|
||
existing_item.blocking_reason = blocking_reason[:2000]
|
||
existing_item.decision_reason = decision_reason[:1000]
|
||
existing_item.raw_message = message[:4000]
|
||
await self.db.flush()
|
||
result = await self._build_existing_item_result(existing_item)
|
||
result["clarification_message"] = blocking_reason
|
||
return result
|
||
|
||
item = FeishuItem(
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=feishu_message_id,
|
||
project_score=1.0,
|
||
signal_confidence=0.0,
|
||
raw_message=message[:4000],
|
||
split_from_item_id=None,
|
||
level="project",
|
||
level_confidence=1.0,
|
||
level_reasoning="intent_gate:create_project",
|
||
card_type="new_project_candidate",
|
||
route_status=ROUTE_STATUS_QUEUED,
|
||
matched_project_id=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.replace(tzinfo=None),
|
||
ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000],
|
||
decision_reason=decision_reason[:1000] or None,
|
||
blocking_reason=blocking_reason[:2000] or None,
|
||
safe_to_auto_execute=False,
|
||
auto_execute_state="blocked",
|
||
dedupe_key=dedupe_key,
|
||
message_class="action_required",
|
||
occurrence_count=1,
|
||
next_step_text=self._build_pending_operation_next_step(operation_code="project.create"),
|
||
queue_visible=True,
|
||
result_payload={
|
||
"intent": INTENT_CREATE_PROJECT,
|
||
"executor": "operation_executor",
|
||
"operation_code": "project.create",
|
||
"operation_payload": {
|
||
"title": str(fields.get("title") or "").strip(),
|
||
"description": str(fields.get("description") or "").strip(),
|
||
},
|
||
"project_scope": str(fields.get("project_scope") or "").strip() or None,
|
||
"result_summary": self._build_pending_operation_summary(
|
||
operation_code="project.create",
|
||
payload={
|
||
"title": str(fields.get("title") or "").strip(),
|
||
},
|
||
),
|
||
},
|
||
)
|
||
self.db.add(item)
|
||
await self.db.flush()
|
||
|
||
suggestion = ProjectSuggestion(
|
||
tenant_id=self.tenant_id,
|
||
feishu_item_id=int(item.id),
|
||
suggested_name=(str(fields.get("title") or "").strip() or message[:20] or "新项目建议")[:255],
|
||
suggested_description=(str(fields.get("description") or "").strip() or message[:400])[:4000],
|
||
suggested_owner=(sender or "待指定")[:120],
|
||
suggested_timeline="待确认",
|
||
confidence=1.0,
|
||
reasoning=json.dumps(["命中 intent gate:create_project,等待确认或补齐字段。"], ensure_ascii=False),
|
||
status="pending",
|
||
)
|
||
self.db.add(suggestion)
|
||
await self.db.flush()
|
||
return {
|
||
"item_id": int(item.id),
|
||
"level": "project",
|
||
"project_id": None,
|
||
"signal_id": None,
|
||
"route_status": ROUTE_STATUS_QUEUED,
|
||
"clarification_message": blocking_reason,
|
||
}
|
||
|
||
async def _queue_command_intent_item(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
occurred_at: datetime,
|
||
intent_gate: dict[str, Any],
|
||
operation_code: str,
|
||
operation_payload: dict[str, Any],
|
||
blocking_reason: str,
|
||
decision_reason: str,
|
||
matched_project_id: Optional[int],
|
||
) -> dict[str, Any]:
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
source_object = self._build_gate_source_object(
|
||
intent=str(intent_gate.get("intent") or ""),
|
||
fields=fields,
|
||
message=message,
|
||
)
|
||
dedupe_key = self._build_queue_dedupe_key(
|
||
level="command",
|
||
intent=str(intent_gate.get("intent") or ""),
|
||
message=message,
|
||
source_chat_id=source_chat_id,
|
||
matched_project_id=matched_project_id,
|
||
source_object=source_object,
|
||
)
|
||
existing_item = await self._find_active_dedupe_item(dedupe_key)
|
||
if existing_item is not None:
|
||
existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1
|
||
existing_item.blocking_reason = blocking_reason[:2000]
|
||
existing_item.decision_reason = decision_reason[:1000]
|
||
existing_item.raw_message = message[:4000]
|
||
existing_item.result_payload = {
|
||
**(existing_item.result_payload if isinstance(existing_item.result_payload, dict) else {}),
|
||
"intent": str(intent_gate.get("intent") or ""),
|
||
"executor": "operation_executor",
|
||
"operation_code": operation_code,
|
||
"operation_payload": operation_payload,
|
||
"result_summary": self._build_pending_operation_summary(
|
||
operation_code=operation_code,
|
||
payload=operation_payload,
|
||
),
|
||
}
|
||
await self.db.flush()
|
||
result = await self._build_existing_item_result(existing_item)
|
||
result["clarification_message"] = blocking_reason
|
||
return result
|
||
|
||
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=f"intent_gate:{str(intent_gate.get('intent') or '')}",
|
||
card_type=operation_code.replace(".", "_")[:30],
|
||
route_status=ROUTE_STATUS_QUEUED,
|
||
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.replace(tzinfo=None),
|
||
ai_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False)[:8000],
|
||
decision_reason=decision_reason[:1000] or None,
|
||
blocking_reason=blocking_reason[:2000] or None,
|
||
safe_to_auto_execute=False,
|
||
auto_execute_state="blocked",
|
||
dedupe_key=dedupe_key,
|
||
message_class="action_required",
|
||
occurrence_count=1,
|
||
next_step_text=self._build_pending_operation_next_step(operation_code=operation_code),
|
||
queue_visible=True,
|
||
result_payload={
|
||
"intent": str(intent_gate.get("intent") or ""),
|
||
"executor": "operation_executor",
|
||
"operation_code": operation_code,
|
||
"operation_payload": operation_payload,
|
||
"result_summary": self._build_pending_operation_summary(
|
||
operation_code=operation_code,
|
||
payload=operation_payload,
|
||
),
|
||
"project_id": int(matched_project_id) if matched_project_id is not None else None,
|
||
},
|
||
)
|
||
self.db.add(item)
|
||
await self.db.flush()
|
||
return {
|
||
"item_id": int(item.id),
|
||
"level": "command",
|
||
"project_id": int(matched_project_id) if matched_project_id is not None else None,
|
||
"signal_id": None,
|
||
"route_status": ROUTE_STATUS_QUEUED,
|
||
"clarification_message": blocking_reason,
|
||
}
|
||
|
||
async def _record_committed_operation_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],
|
||
intent: str,
|
||
operation_code: str,
|
||
execution_result: dict[str, Any],
|
||
matched_project_id: Optional[int],
|
||
) -> Optional[int]:
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is not None:
|
||
occurred_at = occurred_at.astimezone(timezone.utc).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=f"intent_gate:{intent}",
|
||
card_type=operation_code.replace(".", "_")[:30],
|
||
route_status=ROUTE_STATUS_AUTO_DONE,
|
||
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({"execution_result": execution_result}, ensure_ascii=False)[:8000],
|
||
decision_reason="deterministic_operation_committed",
|
||
blocking_reason=None,
|
||
safe_to_auto_execute=True,
|
||
auto_execute_state="done",
|
||
message_class="auto_done",
|
||
occurrence_count=1,
|
||
next_step_text="真实提交已完成,结果已绑定 DB commit。",
|
||
queue_visible=False,
|
||
processed_at=_utc_now().replace(tzinfo=None),
|
||
result_payload={
|
||
"intent": intent,
|
||
"executor": "operation_executor",
|
||
"operation_code": operation_code,
|
||
"result_summary": str(execution_result.get("message") or "").strip() or operation_code,
|
||
"operation_result": execution_result.get("result") if isinstance(execution_result.get("result"), dict) else {},
|
||
"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,
|
||
},
|
||
)
|
||
self.db.add(item)
|
||
await self.db.flush()
|
||
return int(item.id)
|
||
|
||
async def _execute_controlled_operation(
|
||
self,
|
||
*,
|
||
operator_user: User,
|
||
operation_code: str,
|
||
payload: dict[str, Any],
|
||
trace_id: str,
|
||
) -> dict[str, Any]:
|
||
executor = OperationExecutorService(self.db)
|
||
project_context = None
|
||
project_id = _safe_int(payload.get("project_id"))
|
||
if operation_code != "project.create" and project_id is not None:
|
||
project_context = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if project_context is None:
|
||
raise ValueError("project_context_missing")
|
||
|
||
logger.info(
|
||
"operation_started",
|
||
tenant_id=self.tenant_id,
|
||
trace_id=trace_id,
|
||
operation_code=operation_code,
|
||
payload=payload,
|
||
executor="operation_executor",
|
||
)
|
||
execution = await executor.execute_operations(
|
||
tenant_id=self.tenant_id,
|
||
operator_user=operator_user,
|
||
project=project_context,
|
||
operations=[
|
||
{
|
||
"operation_code": operation_code,
|
||
"payload": payload,
|
||
"reason": "intent_gate",
|
||
}
|
||
],
|
||
dry_run=False,
|
||
trace_id=trace_id,
|
||
)
|
||
first = execution["results"][0] if execution.get("results") else {}
|
||
event_name = "operation_committed" if str(first.get("status") or "") in {"applied", "skipped"} else "operation_failed"
|
||
logger.info(
|
||
event_name,
|
||
tenant_id=self.tenant_id,
|
||
trace_id=trace_id,
|
||
operation_code=operation_code,
|
||
result=first,
|
||
executor="operation_executor",
|
||
)
|
||
return execution
|
||
|
||
@staticmethod
|
||
def _derive_wrong_interpretation_from_recent_item(item: Optional[FeishuItem]) -> str:
|
||
if item is None:
|
||
return "unknown"
|
||
level = str(item.level or "").strip().lower()
|
||
if level == "project":
|
||
return "project_candidate"
|
||
if level == "signal":
|
||
return "signal_intake"
|
||
if level == "command":
|
||
return str(item.card_type or "command")
|
||
return level or "unknown"
|
||
|
||
async def _maybe_write_intent_correction_sample(
|
||
self,
|
||
*,
|
||
message: str,
|
||
source_chat_id: str,
|
||
intent_gate: dict[str, Any],
|
||
) -> None:
|
||
if self.db is None or not self._is_correction_message(message):
|
||
return
|
||
recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id, limit=3) if source_chat_id else []
|
||
wrong_interpretation = self._derive_wrong_interpretation_from_recent_item(recent_items[0] if recent_items else None)
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
clarification_template = str(intent_gate.get("clarification_template") or "").strip()
|
||
if not clarification_template:
|
||
if str(intent_gate.get("intent") or "") == INTENT_CREATE_PROJECT:
|
||
clarification_template = self._build_project_clarification_message(
|
||
list(fields.get("missing_fields") or []),
|
||
)
|
||
elif str(intent_gate.get("intent") or "") == INTENT_CREATE_TASK:
|
||
clarification_template = self._build_task_clarification_message(
|
||
list(fields.get("missing_fields") or []),
|
||
)
|
||
try:
|
||
await AdaptiveRAGService(self.db, tenant_id=self.tenant_id).upsert_intent_correction_sample(
|
||
user_utterance=message,
|
||
wrong_interpretation=wrong_interpretation,
|
||
correct_intent=str(intent_gate.get("intent") or INTENT_CLARIFICATION_REQUIRED),
|
||
canonical_action=str(intent_gate.get("canonical_action") or "").strip() or "clarify",
|
||
clarification_template=clarification_template,
|
||
risk_level=str(intent_gate.get("risk_level") or "medium"),
|
||
project_id=_safe_int(fields.get("project_id")),
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(f"intent_correction_write_failed: tenant_id={self.tenant_id}, error={exc}")
|
||
|
||
async def _build_healthcheck_capture(
|
||
self,
|
||
*,
|
||
message: str,
|
||
feishu_message_id: Optional[str],
|
||
process_started_at: float,
|
||
) -> dict[str, Any]:
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent": INTENT_HEALTHCHECK}, ensure_ascii=False),
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="healthcheck",
|
||
routing_decision=ROUTING_INTENT_GATE_HEALTHCHECK,
|
||
routing_reason="命中 healthcheck,直接返回执行链可用状态,不进入 Inbox。",
|
||
reasoning_bullets=[
|
||
"intent gate 已识别为 healthcheck。",
|
||
"不涉及写库动作。",
|
||
"回执固定由后端生成。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_HEALTHCHECK,
|
||
intent=INTENT_HEALTHCHECK,
|
||
executor="router",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "answered",
|
||
"classification": "healthcheck",
|
||
"project_scope": "healthcheck",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-healthcheck-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
"intent": INTENT_HEALTHCHECK,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": "healthcheck=ok;执行链在线;仅真实 commit 后才会回“已执行”。",
|
||
}
|
||
|
||
async def _handle_create_project_gate(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
captured_at: Optional[datetime],
|
||
intent_gate: dict[str, Any],
|
||
process_started_at: float,
|
||
) -> dict[str, Any]:
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_project_clarification_message(
|
||
list(fields.get("missing_fields") or []),
|
||
)
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is None:
|
||
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
||
|
||
operator_user = await self._resolve_command_operator_user(sender=sender)
|
||
if not fields.get("ready"):
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=1.0,
|
||
signal_confidence=0.0,
|
||
level="project",
|
||
routing_decision=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
routing_reason="建项目缺少必填字段,已转待澄清,禁止猜测执行。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_project。",
|
||
"当前缺少 project.create 必填字段。",
|
||
"仅保留结构化待澄清项,不触发执行器。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
intent=INTENT_CLARIFICATION_REQUIRED,
|
||
executor="operation_executor",
|
||
operation_code="project.create",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "project",
|
||
"project_scope": "project",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-project-clarify-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
"intent": INTENT_CLARIFICATION_REQUIRED,
|
||
"clarification_message": clarification_message,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": clarification_message,
|
||
}
|
||
|
||
if not self._can_execute_high_value_action(operator_user):
|
||
queued = await self._queue_project_intent_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
occurred_at=occurred_at,
|
||
intent_gate=intent_gate,
|
||
blocking_reason=clarification_message if not fields.get("ready") else "建项目属于高风险动作,当前需要你确认后再执行。",
|
||
decision_reason="high_value_action_guard",
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=_safe_int(queued.get("item_id")),
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=1.0,
|
||
signal_confidence=0.0,
|
||
level="project",
|
||
routing_decision=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
routing_reason="建项目命中高风险保护,已转待决策/待澄清。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_project。",
|
||
"当前未满足自动执行条件。",
|
||
"仅保留结构化待决策项,不做猜测执行。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT,
|
||
executor="operation_executor",
|
||
operation_code="project.create",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "project",
|
||
"project_scope": "project",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-project-pending-{queued.get('item_id')}",
|
||
"results": [queued],
|
||
"intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_PROJECT,
|
||
"clarification_message": queued.get("clarification_message"),
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"),
|
||
}
|
||
|
||
payload_description = str(fields.get("description") or "").strip()
|
||
project_scope = str(fields.get("project_scope") or "").strip()
|
||
if project_scope and project_scope not in payload_description:
|
||
payload_description = f"{payload_description}\n\n项目范围:{project_scope}".strip()
|
||
payload = {
|
||
"title": str(fields.get("title") or "").strip(),
|
||
"description": payload_description,
|
||
}
|
||
trace_id = f"intent-project-{self._stable_positive_int(message, sender, feishu_message_id)}"
|
||
execution = await self._execute_controlled_operation(
|
||
operator_user=operator_user,
|
||
operation_code="project.create",
|
||
payload=payload,
|
||
trace_id=trace_id,
|
||
)
|
||
first = execution["results"][0] if execution.get("results") else {}
|
||
status = str(first.get("status") or "").strip().lower()
|
||
if status not in {"applied", "skipped"}:
|
||
raise ValueError(str(first.get("message") or "project.create failed"))
|
||
|
||
project_result = first.get("result") if isinstance(first.get("result"), dict) else {}
|
||
project_id = _safe_int(project_result.get("project_id") or first.get("entity_id"))
|
||
item_id = await self._record_committed_operation_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
intent=INTENT_CREATE_PROJECT,
|
||
operation_code="project.create",
|
||
execution_result=first,
|
||
matched_project_id=project_id,
|
||
)
|
||
reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}})
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=item_id,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000],
|
||
project_score=1.0,
|
||
signal_confidence=0.0,
|
||
level="project",
|
||
routing_decision=ROUTING_OPERATION_COMMITTED,
|
||
routing_reason="建项目已通过 deterministic operation executor 提交,并以真实 DB 结果回执。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_project。",
|
||
"执行器仅走 project.create。",
|
||
"回复绑定真实 commit 结果。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_OPERATION_COMMITTED,
|
||
intent=INTENT_CREATE_PROJECT,
|
||
executor="operation_executor",
|
||
operation_code="project.create",
|
||
operation_result=first if isinstance(first, dict) else None,
|
||
reply_source="db_commit",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "project",
|
||
"project_scope": "project",
|
||
"linked_project_ids": [int(project_id)] if project_id is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": int(project_id) if project_id is not None else None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": trace_id,
|
||
"results": [
|
||
{
|
||
"item_id": item_id,
|
||
"level": "command",
|
||
"project_id": int(project_id) if project_id is not None else None,
|
||
"signal_id": None,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
}
|
||
],
|
||
"intent": INTENT_CREATE_PROJECT,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": reply_text,
|
||
"command_result": {"execution": execution},
|
||
}
|
||
|
||
async def _handle_create_task_gate(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
captured_at: Optional[datetime],
|
||
intent_gate: dict[str, Any],
|
||
process_started_at: float,
|
||
) -> dict[str, Any]:
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
operation_payload = {
|
||
"project_id": _safe_int(fields.get("project_id")),
|
||
"title": str(fields.get("title") or "").strip(),
|
||
"description": str(fields.get("description") or "").strip() or None,
|
||
}
|
||
clarification_message = str(intent_gate.get("clarification_template") or "").strip() or self._build_task_clarification_message(
|
||
list(fields.get("missing_fields") or []),
|
||
)
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is None:
|
||
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
||
|
||
operator_user = await self._resolve_command_operator_user(sender=sender)
|
||
if not fields.get("ready"):
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=1.0 if fields.get("project_id") is not None else None,
|
||
signal_confidence=0.0 if fields.get("project_id") is not None else None,
|
||
level="command",
|
||
routing_decision=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
routing_reason="建任务缺少必填字段,已转待澄清,禁止猜测执行。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_task。",
|
||
"当前缺少 task.create 必填字段。",
|
||
"仅保留结构化待澄清项,不触发执行器。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
intent=INTENT_CLARIFICATION_REQUIRED,
|
||
executor="operation_executor",
|
||
operation_code="task.create",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-task-clarify-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
"intent": INTENT_CLARIFICATION_REQUIRED,
|
||
"clarification_message": clarification_message,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": clarification_message,
|
||
}
|
||
|
||
if not self._can_execute_high_value_action(operator_user):
|
||
queued = await self._queue_command_intent_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
occurred_at=occurred_at,
|
||
intent_gate=intent_gate,
|
||
operation_code="task.create",
|
||
operation_payload={k: v for k, v in operation_payload.items() if v is not None},
|
||
blocking_reason=clarification_message if not fields.get("ready") else "建任务属于高风险动作,当前需要你确认后再执行。",
|
||
decision_reason="high_value_action_guard",
|
||
matched_project_id=_safe_int(fields.get("project_id")),
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=_safe_int(queued.get("item_id")),
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=1.0 if fields.get("project_id") is not None else None,
|
||
signal_confidence=0.0 if fields.get("project_id") is not None else None,
|
||
level="command",
|
||
routing_decision=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
routing_reason="建任务命中高风险保护,已转待决策/待澄清。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_task。",
|
||
"当前未满足自动执行条件。",
|
||
"仅保留结构化待决策项,不做猜测执行。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_CLARIFICATION,
|
||
intent=INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK,
|
||
executor="operation_executor",
|
||
operation_code="task.create",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-task-pending-{queued.get('item_id')}",
|
||
"results": [queued],
|
||
"intent": INTENT_CLARIFICATION_REQUIRED if not fields.get("ready") else INTENT_CREATE_TASK,
|
||
"clarification_message": queued.get("clarification_message"),
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": str(queued.get("clarification_message") or "已转待决策。"),
|
||
}
|
||
|
||
trace_id = f"intent-task-{self._stable_positive_int(message, sender, feishu_message_id)}"
|
||
execution = await self._execute_controlled_operation(
|
||
operator_user=operator_user,
|
||
operation_code="task.create",
|
||
payload={k: v for k, v in operation_payload.items() if v is not None},
|
||
trace_id=trace_id,
|
||
)
|
||
first = execution["results"][0] if execution.get("results") else {}
|
||
status = str(first.get("status") or "").strip().lower()
|
||
if status not in {"applied", "skipped"}:
|
||
raise ValueError(str(first.get("message") or "task.create failed"))
|
||
project_id = _safe_int(fields.get("project_id"))
|
||
item_id = await self._record_committed_operation_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
intent=INTENT_CREATE_TASK,
|
||
operation_code="task.create",
|
||
execution_result=first,
|
||
matched_project_id=project_id,
|
||
)
|
||
reply_text = self._build_openclaw_command_reply({"execution": {"results": [first], "applied_count": 1 if status == "applied" else 0, "skipped_count": 1 if status == "skipped" else 0, "failed_count": 0}})
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=item_id,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate, "execution": execution}, ensure_ascii=False)[:120000],
|
||
project_score=1.0 if project_id is not None else None,
|
||
signal_confidence=0.0 if project_id is not None else None,
|
||
level="command",
|
||
routing_decision=ROUTING_OPERATION_COMMITTED,
|
||
routing_reason="建任务已通过 deterministic operation executor 提交,并以真实 DB 结果回执。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 create_task。",
|
||
"执行器仅走 task.create。",
|
||
"回复绑定真实 commit 结果。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_OPERATION_COMMITTED,
|
||
intent=INTENT_CREATE_TASK,
|
||
executor="operation_executor",
|
||
operation_code="task.create",
|
||
operation_result=first if isinstance(first, dict) else None,
|
||
reply_source="db_commit",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": [int(project_id)] if project_id is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": trace_id,
|
||
"results": [
|
||
{
|
||
"item_id": item_id,
|
||
"level": "command",
|
||
"project_id": int(project_id) if project_id is not None else None,
|
||
"signal_id": None,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
}
|
||
],
|
||
"intent": INTENT_CREATE_TASK,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": reply_text,
|
||
"command_result": {"execution": execution},
|
||
}
|
||
|
||
async def _handle_external_connect_gate(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
captured_at: Optional[datetime],
|
||
intent_gate: dict[str, Any],
|
||
process_started_at: float,
|
||
) -> dict[str, Any]:
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is None:
|
||
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
||
fields = intent_gate.get("fields") if isinstance(intent_gate.get("fields"), dict) else {}
|
||
blocking_reason = str(intent_gate.get("clarification_template") or "").strip() or "外部系统接入默认进入待决策队列。"
|
||
queued = await self._queue_command_intent_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
occurred_at=occurred_at,
|
||
intent_gate=intent_gate,
|
||
operation_code="source.connect",
|
||
operation_payload={"project_id": _safe_int(fields.get("project_id"))},
|
||
blocking_reason=blocking_reason,
|
||
decision_reason="external_connect_requires_manual_decision",
|
||
matched_project_id=_safe_int(fields.get("project_id")),
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=_safe_int(queued.get("item_id")),
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=1.0 if fields.get("project_id") is not None else None,
|
||
signal_confidence=0.0 if fields.get("project_id") is not None else None,
|
||
level="command",
|
||
routing_decision=ROUTING_INTENT_GATE_EXTERNAL_CONNECT,
|
||
routing_reason="外部系统接入被收口到待决策队列,不允许自由执行。",
|
||
reasoning_bullets=[
|
||
"intent gate 命中 external_connect。",
|
||
"当前仅允许受控工具链执行。",
|
||
"系统已转待决策,不做任意 HTTP/shell。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
normalized_text=message,
|
||
route=ROUTING_INTENT_GATE_EXTERNAL_CONNECT,
|
||
intent=INTENT_EXTERNAL_CONNECT,
|
||
executor="router",
|
||
operation_code="source.connect",
|
||
reply_source="deterministic_reply",
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": [int(fields["project_id"])] if fields.get("project_id") is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-external-{queued.get('item_id')}",
|
||
"results": [queued],
|
||
"intent": INTENT_EXTERNAL_CONNECT,
|
||
"clarification_message": queued.get("clarification_message"),
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": str(queued.get("clarification_message") or blocking_reason),
|
||
}
|
||
|
||
async def process_message(
|
||
self,
|
||
*,
|
||
message_content: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str] = None,
|
||
feishu_message_id: Optional[str] = None,
|
||
captured_at: Optional[datetime] = None,
|
||
chat_type: Optional[str] = None,
|
||
) -> dict[str, Any]:
|
||
process_started_at = time.perf_counter()
|
||
normalized_chat_type = self._normalize_chat_type(chat_type=chat_type, source_chat_id=source_chat_id)
|
||
message = self._normalize_incoming_text(message_content)
|
||
if not message:
|
||
return self._decorate_capture_result(
|
||
{"status": "ignored", "reason": "empty_message", "results": []},
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.empty_message",
|
||
db_mutations=[],
|
||
)
|
||
|
||
if self._is_water_talk_message(message):
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level=None,
|
||
routing_decision=ROUTING_NOISE_FILTERED,
|
||
routing_reason="识别为闲聊/问答水话,已过滤且不进入待处理队列。",
|
||
reasoning_bullets=[
|
||
"消息缺少可执行交付信息。",
|
||
"不满足需求信号的最小结构(目标/对象/动作)。",
|
||
"已按噪音消息忽略,不触发入库与排队。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result({
|
||
"status": "ignored",
|
||
"reason": ROUTING_NOISE_FILTERED,
|
||
"classification": "non_requirement",
|
||
"project_scope": "non_requirement",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-noise-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
}, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.noise_filtered", db_mutations=[])
|
||
|
||
if feishu_message_id:
|
||
await self._acquire_message_lock(feishu_message_id)
|
||
|
||
if feishu_message_id and await self._has_processed_message(feishu_message_id):
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level=None,
|
||
routing_decision=ROUTING_DUPLICATE_FILTERED,
|
||
routing_reason="检测到重复飞书消息 ID,已过滤且不再重复执行 AI 处理。",
|
||
reasoning_bullets=[
|
||
"消息唯一标识已存在于处理日志中。",
|
||
"本次请求被标记为重复消息。",
|
||
"系统已跳过后续分类和入库动作。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result({
|
||
"status": "ignored",
|
||
"reason": ROUTING_DUPLICATE_FILTERED,
|
||
"classification": "duplicate",
|
||
"project_scope": "duplicate",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-duplicate-{feishu_message_id}",
|
||
"results": [],
|
||
}, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_filtered", db_mutations=[])
|
||
|
||
projects = await self._load_active_projects()
|
||
chat_context_resolution = {
|
||
"project_id": None,
|
||
"ambiguous": False,
|
||
"candidate_ids": [],
|
||
"top_ratio": 0.0,
|
||
}
|
||
if source_chat_id:
|
||
try:
|
||
chat_context_resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id)
|
||
except Exception:
|
||
chat_context_resolution = {
|
||
"project_id": None,
|
||
"ambiguous": False,
|
||
"candidate_ids": [],
|
||
"top_ratio": 0.0,
|
||
}
|
||
inferred_project_id = _safe_int(chat_context_resolution.get("project_id"))
|
||
|
||
followup_capture = await self._maybe_handle_queue_followup(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
)
|
||
if followup_capture is not None:
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
followup_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.followup",
|
||
db_mutations=self._summarize_db_mutations(followup_capture),
|
||
)
|
||
|
||
lookup_capture = await self._maybe_answer_project_lookup_question(
|
||
message=message,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
projects=projects,
|
||
)
|
||
if lookup_capture is not None:
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
lookup_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.project_lookup",
|
||
db_mutations=[],
|
||
)
|
||
|
||
intent_gate = await self._resolve_intent_gate(
|
||
message=message,
|
||
inferred_project_id=inferred_project_id,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
)
|
||
logger.info(
|
||
"intent_resolved",
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=feishu_message_id,
|
||
intent=str(intent_gate.get("intent") or ""),
|
||
canonical_action=str(intent_gate.get("canonical_action") or ""),
|
||
normalized_text=message[:400],
|
||
)
|
||
await self._maybe_write_intent_correction_sample(
|
||
message=message,
|
||
source_chat_id=source_chat_id,
|
||
intent_gate=intent_gate,
|
||
)
|
||
|
||
gate_intent = str(intent_gate.get("intent") or INTENT_SIGNAL_INTAKE)
|
||
if gate_intent == INTENT_SMALLTALK:
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent_gate": intent_gate}, ensure_ascii=False),
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="smalltalk",
|
||
routing_decision=ROUTING_NOISE_FILTERED,
|
||
routing_reason="intent gate 识别为 smalltalk/短确认,不进入 Inbox。",
|
||
reasoning_bullets=[
|
||
"消息不包含可执行主数据动作。",
|
||
"命中 short confirmation / smalltalk。",
|
||
"系统直接忽略,不写通知。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
{
|
||
"status": "ignored",
|
||
"reason": ROUTING_NOISE_FILTERED,
|
||
"classification": "smalltalk",
|
||
"project_scope": "smalltalk",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-smalltalk-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
},
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.smalltalk",
|
||
db_mutations=[],
|
||
)
|
||
|
||
if gate_intent == INTENT_HEALTHCHECK:
|
||
healthcheck_capture = await self._build_healthcheck_capture(
|
||
message=message,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
healthcheck_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.healthcheck",
|
||
db_mutations=[],
|
||
)
|
||
|
||
if gate_intent == INTENT_QUERY:
|
||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||
conversation_key = self._build_rag_conversation_key(
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
)
|
||
rag_result = await rag_service.answer_question(
|
||
question=message,
|
||
asked_by=None,
|
||
asked_via="feishu",
|
||
project_id=inferred_project_id,
|
||
source_chat_id=source_chat_id,
|
||
conversation_key=conversation_key,
|
||
)
|
||
qa_capture = await self._build_direct_qa_capture(
|
||
message=message,
|
||
rag_result=rag_result,
|
||
inferred_project_id=inferred_project_id,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
route_reason="intent gate 判定为 query,直接走 RAG 直答通道,不进入决策队列。",
|
||
intent_label=INTENT_QUERY,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
qa_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.query",
|
||
db_mutations=[],
|
||
)
|
||
|
||
if gate_intent == INTENT_CREATE_PROJECT or (
|
||
gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "project.create"
|
||
):
|
||
project_capture = await self._handle_create_project_gate(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
intent_gate=intent_gate,
|
||
process_started_at=process_started_at,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
project_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.project_create",
|
||
db_mutations=self._summarize_db_mutations(project_capture),
|
||
)
|
||
|
||
if gate_intent == INTENT_CREATE_TASK or (
|
||
gate_intent == INTENT_CLARIFICATION_REQUIRED and str(intent_gate.get("canonical_action") or "") == "task.create"
|
||
):
|
||
task_capture = await self._handle_create_task_gate(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
intent_gate=intent_gate,
|
||
process_started_at=process_started_at,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
task_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.task_create",
|
||
db_mutations=self._summarize_db_mutations(task_capture),
|
||
)
|
||
|
||
if gate_intent == INTENT_EXTERNAL_CONNECT:
|
||
external_capture = await self._handle_external_connect_gate(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
intent_gate=intent_gate,
|
||
process_started_at=process_started_at,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
external_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.intent_gate.external_connect",
|
||
db_mutations=self._summarize_db_mutations(external_capture),
|
||
)
|
||
|
||
command_capture = await self._maybe_execute_openclaw_command(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
inferred_project_id=inferred_project_id,
|
||
projects=projects,
|
||
process_started_at=process_started_at,
|
||
)
|
||
if command_capture is not None:
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
command_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.command",
|
||
db_mutations=self._summarize_db_mutations(command_capture),
|
||
)
|
||
|
||
rag_service = AdaptiveRAGService(self.db, tenant_id=self.tenant_id)
|
||
conversation_key = self._build_rag_conversation_key(
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
sender=sender,
|
||
)
|
||
|
||
sidecar_rag_result: Optional[dict[str, Any]] = None
|
||
intent_for_response = "statement"
|
||
force_signal_fastpath = False
|
||
rag_question_part = message
|
||
|
||
if self._is_obvious_question(message):
|
||
rag_result = await rag_service.answer_question(
|
||
question=rag_question_part,
|
||
asked_by=None,
|
||
asked_via="feishu",
|
||
project_id=inferred_project_id,
|
||
source_chat_id=source_chat_id,
|
||
conversation_key=conversation_key,
|
||
)
|
||
if self._is_requirement_like_question(message):
|
||
sidecar_rag_result = rag_result
|
||
intent_for_response = "question_requirement"
|
||
message = self._extract_requirement_statement_from_question(message)
|
||
if not message:
|
||
message = self._normalize_incoming_text(message_content)
|
||
force_signal_fastpath = True
|
||
else:
|
||
qa_capture = await self._build_direct_qa_capture(
|
||
message=message,
|
||
rag_result=rag_result,
|
||
inferred_project_id=inferred_project_id,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
route_reason="明显问句直接走 RAG 直答通道,不进入决策队列。",
|
||
intent_label="question_obvious",
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
qa_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.qa_direct",
|
||
db_mutations=[],
|
||
)
|
||
else:
|
||
intent_result = await rag_service.classify_intent(message)
|
||
intent = str(intent_result.get("intent") or "statement").lower()
|
||
intent_for_response = intent
|
||
rag_question_part = str(intent_result.get("question_part") or "").strip() or message
|
||
rag_statement_part = str(intent_result.get("statement_part") or "").strip()
|
||
|
||
if intent == "question":
|
||
rag_result = await rag_service.answer_question(
|
||
question=rag_question_part,
|
||
asked_by=None,
|
||
asked_via="feishu",
|
||
project_id=inferred_project_id,
|
||
source_chat_id=source_chat_id,
|
||
conversation_key=conversation_key,
|
||
)
|
||
if self._is_requirement_like_question(message):
|
||
sidecar_rag_result = rag_result
|
||
intent_for_response = "question_requirement"
|
||
message = self._extract_requirement_statement_from_question(message)
|
||
if not message:
|
||
message = self._normalize_incoming_text(message_content)
|
||
force_signal_fastpath = True
|
||
else:
|
||
qa_capture = await self._build_direct_qa_capture(
|
||
message=message,
|
||
rag_result=rag_result,
|
||
inferred_project_id=inferred_project_id,
|
||
feishu_message_id=feishu_message_id,
|
||
process_started_at=process_started_at,
|
||
route_reason="识别为问答消息,已走 RAG 直答通道,不进入决策队列。",
|
||
intent_label=intent,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result(
|
||
qa_capture,
|
||
chat_type=normalized_chat_type,
|
||
normalized_text=message,
|
||
route=f"{normalized_chat_type}.qa_direct",
|
||
db_mutations=[],
|
||
)
|
||
|
||
if intent == "mixed":
|
||
mixed_rag_result = await rag_service.answer_question(
|
||
question=rag_question_part,
|
||
asked_by=None,
|
||
asked_via="feishu",
|
||
project_id=inferred_project_id,
|
||
source_chat_id=source_chat_id,
|
||
conversation_key=conversation_key,
|
||
)
|
||
if rag_statement_part:
|
||
message = rag_statement_part
|
||
sidecar_rag_result = mixed_rag_result
|
||
|
||
try:
|
||
if force_signal_fastpath:
|
||
level_result = self._heuristic_level_fallback(message)
|
||
else:
|
||
level_result = await self._classify_level(message=message, projects=projects)
|
||
project_score = _safe_float(level_result.get("project_score"), 0.0)
|
||
level = _normalize_level_value(
|
||
level_result.get("level"),
|
||
default="project" if project_score >= 0.6 else "signal",
|
||
)
|
||
level_confidence = _safe_float(level_result.get("level_confidence"), project_score)
|
||
level_reasoning = str(level_result.get("level_reasoning") or "").strip()
|
||
|
||
split_items = []
|
||
seen_split_items: set[tuple[str, str]] = set()
|
||
raw_split_items = level_result.get("split_items")
|
||
should_split = bool(level_result.get("should_split"))
|
||
if should_split and isinstance(raw_split_items, list):
|
||
for item in raw_split_items[:2]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_level = _normalize_level_value(item.get("level"), default=level)
|
||
item_content = str(item.get("content") or "").strip()
|
||
if item_level in {"project", "signal"} and item_content:
|
||
dedupe_key = (item_level, item_content)
|
||
if dedupe_key in seen_split_items:
|
||
continue
|
||
seen_split_items.add(dedupe_key)
|
||
split_items.append({"level": item_level, "content": item_content})
|
||
if not split_items:
|
||
split_items = [{"level": level if level in {"project", "signal"} else "signal", "content": message}]
|
||
|
||
routing_policy = await self._load_routing_policy()
|
||
occurred_at = captured_at or _utc_now()
|
||
if occurred_at.tzinfo is None:
|
||
occurred_at = occurred_at.replace(tzinfo=timezone.utc)
|
||
|
||
results: list[dict[str, Any]] = []
|
||
parent_item_id: Optional[int] = None
|
||
for idx, split in enumerate(split_items):
|
||
item_result = await self._process_one_item(
|
||
content=split["content"],
|
||
level=split["level"],
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
occurred_at=occurred_at,
|
||
level_result=level_result,
|
||
level_confidence=level_confidence,
|
||
level_reasoning=level_reasoning,
|
||
project_score=project_score,
|
||
cold_start_enabled=bool(routing_policy["cold_start_enabled"]),
|
||
signal_auto_enter_threshold=float(routing_policy["signal_auto_enter_threshold"]),
|
||
project_auto_create_threshold=float(routing_policy["project_auto_create_threshold"]),
|
||
project_uncertain_threshold=float(routing_policy["project_uncertain_threshold"]),
|
||
chat_context_auto_link_enabled=bool(routing_policy["chat_context_auto_link_enabled"]),
|
||
projects=projects,
|
||
split_from_item_id=parent_item_id if idx > 0 else None,
|
||
chat_type=normalized_chat_type,
|
||
chat_context_resolution=chat_context_resolution,
|
||
)
|
||
if idx == 0 and item_result.get("item_id"):
|
||
parent_item_id = int(item_result["item_id"])
|
||
results.append(item_result)
|
||
|
||
await self.db.commit()
|
||
|
||
signal_ids = [int(x["signal_id"]) for x in results if x.get("signal_id")]
|
||
linked_projects = sorted(
|
||
{
|
||
int(x["project_id"])
|
||
for x in results
|
||
if x.get("project_id") is not None
|
||
}
|
||
)
|
||
classification = "split" if len(results) > 1 else (results[0].get("level") or "signal")
|
||
clarification_message = next(
|
||
(
|
||
str(row.get("clarification_message") or "").strip()
|
||
for row in results
|
||
if isinstance(row, dict) and str(row.get("clarification_message") or "").strip()
|
||
),
|
||
"",
|
||
)
|
||
direct_reply_text: Optional[str] = None
|
||
if sidecar_rag_result:
|
||
sidecar_answer = str(sidecar_rag_result.get("answer") or "").strip()
|
||
if sidecar_answer:
|
||
direct_reply_text = (
|
||
self._build_rag_reply_text(
|
||
answer=sidecar_answer,
|
||
sources=sidecar_rag_result.get("sources") or [],
|
||
)
|
||
+ "\n\n我已回答你的问题,并把需求部分记录为信号进入处理队列。"
|
||
)
|
||
return self._decorate_capture_result({
|
||
"status": "captured",
|
||
"classification": classification,
|
||
"project_scope": classification,
|
||
"linked_project_ids": linked_projects,
|
||
"signal_id": str(signal_ids[0]) if signal_ids else None,
|
||
"created_project_id": linked_projects[0] if linked_projects else None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-item-{results[0].get('item_id')}" if results else None,
|
||
"results": results,
|
||
"intent": intent_for_response,
|
||
"rag_result": sidecar_rag_result,
|
||
"direct_reply_required": bool(direct_reply_text),
|
||
"direct_reply_text": direct_reply_text,
|
||
"clarification_message": clarification_message or None,
|
||
}, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.{classification}", db_mutations=self._summarize_db_mutations({"results": results, "signal_id": str(signal_ids[0]) if signal_ids else None, "created_project_id": linked_projects[0] if linked_projects else None}))
|
||
except IntegrityError as exc:
|
||
await self.db.rollback()
|
||
if feishu_message_id and self._is_duplicate_integrity_error(exc):
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level=None,
|
||
routing_decision=ROUTING_DUPLICATE_FILTERED,
|
||
routing_reason="唯一约束命中,判定为重复飞书消息,已跳过重复处理。",
|
||
reasoning_bullets=[
|
||
"数据库唯一约束阻止了重复入库。",
|
||
"当前请求被收敛到 duplicate 模板回执。",
|
||
"后续不再触发 AI 分类与入库动作。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
return self._decorate_capture_result({
|
||
"status": "ignored",
|
||
"reason": ROUTING_DUPLICATE_FILTERED,
|
||
"classification": "duplicate",
|
||
"project_scope": "duplicate",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-duplicate-{feishu_message_id}",
|
||
"results": [],
|
||
}, chat_type=normalized_chat_type, normalized_text=message, route=f"{normalized_chat_type}.duplicate_integrity", db_mutations=[])
|
||
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level=None,
|
||
routing_decision=ROUTING_FAILED,
|
||
routing_reason="数据库完整性校验失败,已停止当前消息处理。",
|
||
reasoning_bullets=[
|
||
f"异常类型:{exc.__class__.__name__}",
|
||
"可能原因:唯一约束/外键约束冲突。",
|
||
"请结合 SQL 日志排查数据一致性问题。",
|
||
],
|
||
processing_status="failed",
|
||
error_message=str(exc)[:2000] or "integrity_error",
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
raise
|
||
except Exception as exc:
|
||
await self.db.rollback()
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level=None,
|
||
routing_decision=ROUTING_FAILED,
|
||
routing_reason="AI 处理链路执行失败,已停止当前消息处理并写入错误日志。",
|
||
reasoning_bullets=[
|
||
f"异常类型:{exc.__class__.__name__}",
|
||
f"异常信息:{str(exc)[:120] or '未知错误'}",
|
||
"请在日志详情中查看完整错误并重试。",
|
||
],
|
||
processing_status="failed",
|
||
error_message=str(exc)[:2000] or "unknown_error",
|
||
duration_ms=duration_ms,
|
||
)
|
||
await self.db.commit()
|
||
raise
|
||
|
||
@staticmethod
|
||
def _is_duplicate_integrity_error(exc: IntegrityError) -> bool:
|
||
message = str(exc or "").lower()
|
||
if "feishu_items" not in message:
|
||
return False
|
||
duplicate_hints = (
|
||
"uq_feishu_items_tenant_message_root",
|
||
"uq_feishu_items_tenant_message_split",
|
||
"unique constraint",
|
||
"duplicate key value",
|
||
"is not unique",
|
||
)
|
||
return any(hint in message for hint in duplicate_hints)
|
||
|
||
@staticmethod
|
||
def _build_rag_reply_text(*, answer: str, sources: list[dict[str, Any]]) -> str:
|
||
clean_answer = str(answer or "").strip() or "我未找到足够证据,建议补充更多上下文。"
|
||
labels: list[str] = []
|
||
deep_link = ""
|
||
for source in sources[:4]:
|
||
if not isinstance(source, dict):
|
||
continue
|
||
label = str(source.get("label") or source.get("source") or "").strip()
|
||
if label and label not in labels:
|
||
labels.append(label)
|
||
if not deep_link:
|
||
meta = source.get("metadata") if isinstance(source.get("metadata"), dict) else {}
|
||
target_url = str(meta.get("target_url") or "").strip()
|
||
if not target_url:
|
||
project_id = meta.get("project_id")
|
||
if project_id is not None:
|
||
try:
|
||
target_url = f"/projects/{int(project_id)}/board"
|
||
except Exception:
|
||
target_url = ""
|
||
if target_url.startswith("/"):
|
||
deep_link = target_url
|
||
source_text = " / ".join(labels) if labels else "未命中可引用来源"
|
||
if deep_link:
|
||
return f"{clean_answer}\n\n来源: {source_text}\nView details → {deep_link}"
|
||
return f"{clean_answer}\n\n来源: {source_text}"
|
||
|
||
@staticmethod
|
||
def _normalize_chat_type(*, chat_type: Optional[str], source_chat_id: str) -> str:
|
||
text = str(chat_type or "").strip().lower()
|
||
if text in {"p2p", "single", "single_chat", "dm", "direct"}:
|
||
return "p2p"
|
||
if text in {"group", "chat", "group_chat"}:
|
||
return "group"
|
||
return "group" if source_chat_id else "p2p"
|
||
|
||
@staticmethod
|
||
def _normalize_incoming_text(message_content: Optional[str]) -> str:
|
||
text = str(message_content or "").replace("\r\n", "\n").replace("\r", "\n")
|
||
if not text:
|
||
return ""
|
||
text = FEISHU_BOT_MENTION_RE.sub(" ", text)
|
||
text = FEISHU_QUOTE_LINE_RE.sub(" ", text)
|
||
text = FEISHU_QUOTE_PREFIX_RE.sub("", text)
|
||
text = FEISHU_NOISE_TOKEN_RE.sub(" ", text)
|
||
text = re.sub(r"@所有人|@everyone", " ", text, flags=re.IGNORECASE)
|
||
text = re.sub(r"[ \t]+", " ", text)
|
||
text = re.sub(r"\n{2,}", "\n", text)
|
||
text = re.sub(r"\s+", " ", text).strip()
|
||
return text[:2000]
|
||
|
||
@classmethod
|
||
def _is_obvious_question(cls, message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
if cls._is_openclaw_business_command(text):
|
||
return False
|
||
if cls._looks_like_project_lookup_question(text):
|
||
return True
|
||
return bool(OBVIOUS_QUESTION_RE.search(text))
|
||
|
||
@staticmethod
|
||
def _decorate_capture_result(
|
||
result: dict[str, Any],
|
||
*,
|
||
chat_type: str,
|
||
normalized_text: str,
|
||
route: str,
|
||
db_mutations: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
payload = dict(result or {})
|
||
direct_reply_text = str(payload.get("direct_reply_text") or "").strip()
|
||
if direct_reply_text:
|
||
payload["direct_reply_text"] = FeishuSignalPipelineService._normalize_direct_reply_text(direct_reply_text)
|
||
clarification_message = str(payload.get("clarification_message") or "").strip()
|
||
if clarification_message:
|
||
payload["clarification_message"] = FeishuSignalPipelineService._normalize_direct_reply_text(clarification_message)
|
||
followup_reply = str(payload.get("followup_reply") or "").strip()
|
||
if followup_reply:
|
||
payload["followup_reply"] = FeishuSignalPipelineService._normalize_direct_reply_text(followup_reply)
|
||
payload["chat_type"] = chat_type
|
||
payload["normalized_text"] = normalized_text
|
||
payload["route"] = route
|
||
payload["db_mutations"] = db_mutations
|
||
payload.setdefault("results", [])
|
||
return payload
|
||
|
||
@staticmethod
|
||
def _normalize_direct_reply_text(text: str) -> str:
|
||
cleaned = str(text or "").strip()
|
||
if cleaned.startswith("```"):
|
||
cleaned = cleaned.replace("```", "").strip()
|
||
if cleaned.lower().startswith("json"):
|
||
cleaned = cleaned[4:].strip()
|
||
if not cleaned:
|
||
return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。"
|
||
lowered = cleaned.lower()
|
||
provider_error_signals = (
|
||
"request was aborted",
|
||
"request aborted",
|
||
"connection aborted",
|
||
"operation aborted",
|
||
"context canceled",
|
||
"context cancelled",
|
||
"cancelled",
|
||
"canceled",
|
||
"rate limit",
|
||
"too many requests",
|
||
"quota",
|
||
"insufficient_quota",
|
||
"insufficient quota",
|
||
"authentication failed",
|
||
"service unavailable",
|
||
"upstream error",
|
||
"bad gateway",
|
||
"gateway timeout",
|
||
"internal server error",
|
||
"connection reset",
|
||
"econnreset",
|
||
"socket hang up",
|
||
)
|
||
if any(flag in lowered for flag in provider_error_signals):
|
||
return "系统处理中出现异常,本次未执行。请稍后重试,或补充更明确的目标、对象和动作。"
|
||
return cleaned[:500]
|
||
|
||
@staticmethod
|
||
def _summarize_db_mutations(result: dict[str, Any]) -> list[dict[str, Any]]:
|
||
if not isinstance(result, dict):
|
||
return []
|
||
mutations: list[dict[str, Any]] = []
|
||
command_result = result.get("command_result") if isinstance(result.get("command_result"), dict) else None
|
||
execution = command_result.get("execution") if isinstance(command_result, dict) else None
|
||
execution_results = execution.get("results") if isinstance(execution, dict) else None
|
||
if isinstance(execution_results, list):
|
||
for row in execution_results[:6]:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
mutations.append(
|
||
{
|
||
"operation_code": str(row.get("operation_code") or ""),
|
||
"status": str(row.get("status") or ""),
|
||
"entity_type": str(row.get("entity_type") or ""),
|
||
"entity_id": str(row.get("entity_id") or ""),
|
||
}
|
||
)
|
||
if mutations:
|
||
return mutations
|
||
|
||
rows = result.get("results")
|
||
if isinstance(rows, list):
|
||
for row in rows[:6]:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
mutations.append(
|
||
{
|
||
"item_id": row.get("item_id"),
|
||
"level": row.get("level"),
|
||
"route_status": row.get("route_status"),
|
||
"project_id": row.get("project_id"),
|
||
"signal_id": row.get("signal_id"),
|
||
}
|
||
)
|
||
return mutations
|
||
|
||
async def _build_direct_qa_capture(
|
||
self,
|
||
*,
|
||
message: str,
|
||
rag_result: dict[str, Any],
|
||
inferred_project_id: Optional[int],
|
||
feishu_message_id: Optional[str],
|
||
process_started_at: float,
|
||
route_reason: str,
|
||
intent_label: str,
|
||
) -> dict[str, Any]:
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"intent": intent_label, "rag": rag_result}, ensure_ascii=False)[:4000],
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="question",
|
||
routing_decision=ROUTING_DIRECT_QA,
|
||
routing_reason=route_reason,
|
||
reasoning_bullets=[
|
||
f"intent={intent_label}",
|
||
"命中 question 路由,跳过 signal/project 处理。",
|
||
"回答已记录 QA 训练数据。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
return {
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"project_scope": "question",
|
||
"linked_project_ids": [int(inferred_project_id)] if inferred_project_id is not None else [],
|
||
"signal_id": None,
|
||
"created_project_id": int(inferred_project_id) if inferred_project_id is not None else None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-qa-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
"rag_result": rag_result,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": self._build_rag_reply_text(
|
||
answer=str(rag_result.get("answer") or ""),
|
||
sources=rag_result.get("sources") or [],
|
||
),
|
||
}
|
||
|
||
async def _maybe_handle_queue_followup(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
feishu_message_id: Optional[str],
|
||
process_started_at: float,
|
||
) -> Optional[dict[str, Any]]:
|
||
if not source_chat_id:
|
||
return None
|
||
wants_continue = self._looks_like_continue_followup(message)
|
||
wants_status = bool(FOLLOWUP_STATUS_RE.search(message))
|
||
if not wants_continue and not wants_status:
|
||
return None
|
||
|
||
recent_items = await self._load_recent_chat_items(source_chat_id=source_chat_id)
|
||
if not recent_items:
|
||
reply_text = "当前群里还没有可追踪的飞书处理项。"
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "群上下文中不存在可追踪的最近处理项。"
|
||
classification = "question"
|
||
status = "answered"
|
||
mutations: list[dict[str, Any]] = []
|
||
results: list[dict[str, Any]] = []
|
||
elif wants_continue:
|
||
pending_items = [item for item in recent_items if self._normalize_route_status(item.route_status) == ROUTE_STATUS_QUEUED]
|
||
if len(pending_items) != 1:
|
||
reply_text = self._build_followup_pending_clarification(pending_items or recent_items[:3])
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "群上下文中存在多个待处理项,无法自动确定继续处理目标。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = []
|
||
results = []
|
||
else:
|
||
operator_user = await self._resolve_command_operator_user(sender=sender)
|
||
if operator_user is None:
|
||
reply_text = "未执行:当前租户下没有可用操作者,无法继续处理该项。"
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "继续处理被阻断:当前租户下没有可用操作者。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = []
|
||
results = []
|
||
else:
|
||
target_item = pending_items[0]
|
||
if (target_item.level or "").lower() == "project":
|
||
suggestion = await self._get_project_suggestion(int(target_item.id))
|
||
if suggestion is None:
|
||
reply_text = "未执行:最近待处理项目候选缺少建议记录,无法继续处理。"
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "继续处理被阻断:最近待处理项目候选缺少建议记录。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = []
|
||
results = []
|
||
else:
|
||
candidate_fields = self._resolve_project_candidate_fields(item=target_item, suggestion=suggestion)
|
||
if not candidate_fields["ready"]:
|
||
reply_text = (
|
||
str(target_item.blocking_reason or "").strip()
|
||
or self._build_project_clarification_message(candidate_fields["missing_fields"])
|
||
)
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "继续处理被阻断:最近待处理建项目项仍缺少必填字段。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = []
|
||
results = []
|
||
else:
|
||
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
|
||
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
|
||
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
|
||
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = self._summarize_db_mutations(
|
||
{
|
||
"results": [
|
||
{
|
||
"item_id": int(target_item.id),
|
||
"level": target_item.level,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
"project_id": confirm_result.get("project_id"),
|
||
"signal_id": confirm_result.get("signal_id"),
|
||
}
|
||
]
|
||
}
|
||
)
|
||
results = [
|
||
{
|
||
"item_id": int(target_item.id),
|
||
"level": target_item.level,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
"project_id": _safe_int(confirm_result.get("project_id")),
|
||
"signal_id": _safe_int(confirm_result.get("signal_id")),
|
||
}
|
||
]
|
||
else:
|
||
signal = await self._get_signal_by_item(int(target_item.id))
|
||
if (target_item.level or "").lower() == "signal" and not (signal and (signal.matched_project_id or target_item.matched_project_id)):
|
||
reply_text = "未执行:最近待处理信号仍缺少明确项目归属,请先补充项目名或项目 ID。"
|
||
decision = ROUTING_QUEUE_FOLLOWUP_BLOCKED
|
||
reason = "继续处理被阻断:最近待处理信号缺少项目归属。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = []
|
||
results = []
|
||
else:
|
||
confirm_result = await self.confirm_item(item_id=int(target_item.id), user_id=int(operator_user.id))
|
||
reply_text = self._build_followup_confirmation_reply(target_item=target_item, confirm_result=confirm_result)
|
||
decision = ROUTING_QUEUE_FOLLOWUP_CONFIRM
|
||
reason = "命中继续处理指令,已按最近唯一待处理项继续执行。"
|
||
classification = "command"
|
||
status = "captured"
|
||
mutations = self._summarize_db_mutations(
|
||
{
|
||
"results": [
|
||
{
|
||
"item_id": int(target_item.id),
|
||
"level": target_item.level,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
"project_id": confirm_result.get("project_id"),
|
||
"signal_id": confirm_result.get("signal_id"),
|
||
}
|
||
]
|
||
}
|
||
)
|
||
results = [
|
||
{
|
||
"item_id": int(target_item.id),
|
||
"level": target_item.level,
|
||
"route_status": ROUTE_STATUS_AUTO_DONE,
|
||
"project_id": _safe_int(confirm_result.get("project_id")),
|
||
"signal_id": _safe_int(confirm_result.get("signal_id")),
|
||
}
|
||
]
|
||
else:
|
||
target_item = self._select_followup_status_target(message=message, recent_items=recent_items)
|
||
reply_text = await self._build_followup_status_reply(target_item)
|
||
decision = ROUTING_QUEUE_FOLLOWUP_QUERY
|
||
reason = "命中进度/状态追问,已直接基于真实 DB 返回最近处理项状态。"
|
||
classification = "question"
|
||
status = "answered"
|
||
mutations = []
|
||
results = []
|
||
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps(
|
||
{
|
||
"followup_reply": reply_text,
|
||
"recent_item_ids": [int(item.id) for item in recent_items[:3]],
|
||
"wants_continue": wants_continue,
|
||
"wants_status": wants_status,
|
||
},
|
||
ensure_ascii=False,
|
||
)[:4000],
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="followup",
|
||
routing_decision=decision,
|
||
routing_reason=reason,
|
||
reasoning_bullets=[
|
||
"follow-up 查询/指令命中最近飞书上下文。",
|
||
"回复内容只基于真实 DB 状态,不使用自由生成。",
|
||
f"recent_items={len(recent_items)}",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
)
|
||
|
||
latest = recent_items[0] if recent_items else None
|
||
linked_project_ids: list[int] = []
|
||
if results and results[0].get("project_id") is not None:
|
||
linked_project_ids.append(int(results[0]["project_id"]))
|
||
if latest and latest.matched_project_id is not None:
|
||
linked_project_ids.append(int(latest.matched_project_id))
|
||
linked_project_ids = sorted(set(linked_project_ids))
|
||
return {
|
||
"status": status,
|
||
"classification": classification,
|
||
"project_scope": classification,
|
||
"linked_project_ids": linked_project_ids,
|
||
"signal_id": str(results[0].get("signal_id")) if results else None,
|
||
"created_project_id": str(results[0].get("project_id")) if results else None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-followup-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": results,
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": reply_text,
|
||
"db_mutations": mutations,
|
||
}
|
||
|
||
async def _load_recent_chat_items(self, *, source_chat_id: str, limit: int = 5) -> list[FeishuItem]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(FeishuItem)
|
||
.where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.source_chat_id == (source_chat_id or "")[:128],
|
||
)
|
||
)
|
||
.order_by(FeishuItem.id.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
return list(rows)
|
||
|
||
@staticmethod
|
||
def _extract_item_operation_code(item: FeishuItem) -> str:
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
return str(payload.get("operation_code") or "").strip().lower()
|
||
|
||
@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]
|
||
|
||
@staticmethod
|
||
def _build_followup_pending_clarification(items: list[FeishuItem]) -> str:
|
||
if not items:
|
||
return "当前群没有待处理项可继续。"
|
||
fragments = []
|
||
for item in items[:3]:
|
||
level = str(item.level or "").lower()
|
||
label = "项目候选" if level == "project" else ("命令" if level == "command" else "信号")
|
||
fragments.append(f"{label}#{int(item.id)}")
|
||
return f"当前群最近有多个待处理项:{'、'.join(fragments)}。请先明确要继续哪一条。"
|
||
|
||
def _build_followup_confirmation_reply(self, *, target_item: FeishuItem, confirm_result: dict[str, Any]) -> str:
|
||
level = str(target_item.level or "").lower()
|
||
if level == "project":
|
||
project_id = confirm_result.get("project_id")
|
||
return f"已继续处理:项目候选#{int(target_item.id)} 已确认并创建项目#{project_id}。"
|
||
if level == "command":
|
||
project_id = confirm_result.get("project_id")
|
||
task_id = confirm_result.get("task_id")
|
||
if task_id and project_id:
|
||
return f"已继续处理:任务#{task_id} 已在项目#{project_id} 中创建。"
|
||
if project_id:
|
||
return f"已继续处理:命令队列项#{int(target_item.id)} 已落到项目#{project_id}。"
|
||
return f"已继续处理:命令队列项#{int(target_item.id)} 已确认。"
|
||
signal_id = confirm_result.get("signal_id")
|
||
project_id = confirm_result.get("project_id")
|
||
if signal_id and project_id:
|
||
return f"已继续处理:信号#{signal_id} 已确认并关联项目#{project_id}。"
|
||
return f"已继续处理:队列项#{int(target_item.id)} 已确认。"
|
||
|
||
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 状态。"
|
||
|
||
@staticmethod
|
||
def _looks_like_project_create_command(message: str) -> bool:
|
||
return bool(OPENCLAW_PROJECT_CREATE_RE.search(str(message or "")))
|
||
|
||
@classmethod
|
||
def _looks_like_project_lookup_question(cls, message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
if cls._is_openclaw_business_command(text):
|
||
return False
|
||
normalized = re.sub(r"\s+", "", text.lower())
|
||
if PROJECT_LIST_QUERY_RE.search(text):
|
||
return True
|
||
if PROJECT_ID_QUERY_RE.search(text):
|
||
return True
|
||
if "项目" not in normalized:
|
||
return False
|
||
return any(token in normalized for token in ("有吗", "有没有", "有无", "是否有", "是否存在", "存在吗", "是什么", "哪些"))
|
||
|
||
@classmethod
|
||
def _is_openclaw_business_command(cls, message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
if cls._looks_like_project_create_command(text):
|
||
return True
|
||
return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text))
|
||
|
||
async def _maybe_execute_openclaw_command(
|
||
self,
|
||
*,
|
||
message: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
captured_at: Optional[datetime],
|
||
inferred_project_id: Optional[int],
|
||
projects: list[dict[str, Any]],
|
||
process_started_at: float,
|
||
) -> Optional[dict[str, Any]]:
|
||
if not self._is_openclaw_business_command(message):
|
||
return None
|
||
|
||
operator_user = await self._resolve_command_operator_user(sender=sender)
|
||
if operator_user is None:
|
||
return await self._build_openclaw_blocked_capture(
|
||
message=message,
|
||
feishu_message_id=feishu_message_id,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
reason="当前租户下没有可用操作者,系统未执行写操作。",
|
||
)
|
||
|
||
target_project_id = inferred_project_id or self._guess_matched_project_id_from_text(message, projects)
|
||
commander = OpenClawCommanderService(self.db)
|
||
if self._looks_like_project_create_command(message):
|
||
command_result = await commander.dispatch_global_command(
|
||
tenant_id=self.tenant_id,
|
||
operator_user=operator_user,
|
||
instruction=message,
|
||
dry_run=False,
|
||
max_operations=4,
|
||
response_mode="fast",
|
||
persist_run=False,
|
||
)
|
||
elif target_project_id is not None:
|
||
command_result = await commander.dispatch_command(
|
||
tenant_id=self.tenant_id,
|
||
operator_user=operator_user,
|
||
project_id=int(target_project_id),
|
||
instruction=message,
|
||
dry_run=False,
|
||
max_operations=6,
|
||
response_mode="fast",
|
||
persist_run=False,
|
||
)
|
||
else:
|
||
return await self._build_openclaw_blocked_capture(
|
||
message=message,
|
||
feishu_message_id=feishu_message_id,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
reason="缺少明确项目上下文。请指明项目名,或在对应项目群内发送该指令。",
|
||
)
|
||
|
||
execution = command_result.get("execution") if isinstance(command_result, dict) else {}
|
||
requested_count = int((execution or {}).get("requested_count") or 0)
|
||
if requested_count <= 0 and int(command_result.get("planned_operations") or 0) <= 0:
|
||
return await self._build_openclaw_blocked_capture(
|
||
message=message,
|
||
feishu_message_id=feishu_message_id,
|
||
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
|
||
reason="未识别到可安全执行的业务操作,系统未写库。",
|
||
)
|
||
|
||
linked_project_ids = self._collect_command_project_ids(command_result)
|
||
created_project_id = self._extract_created_project_id(command_result)
|
||
primary_project_id = created_project_id or (linked_project_ids[0] if linked_project_ids else None)
|
||
route_status = self._resolve_command_route_status(command_result)
|
||
item_id = await self._record_command_context_item(
|
||
message=message,
|
||
sender=sender,
|
||
source_chat_id=source_chat_id,
|
||
source_sender_open_id=source_sender_open_id,
|
||
feishu_message_id=feishu_message_id,
|
||
captured_at=captured_at,
|
||
matched_project_id=primary_project_id,
|
||
route_status=route_status,
|
||
command_result=command_result,
|
||
)
|
||
reply_text = self._build_openclaw_command_reply(command_result)
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=item_id,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:120000],
|
||
project_score=1.0 if primary_project_id is not None else None,
|
||
signal_confidence=0.0,
|
||
level="command",
|
||
routing_decision=ROUTING_OPENCLAW_EXECUTED,
|
||
routing_reason="识别为明确业务命令,已直接进入 OpenClaw command 层并按真实执行结果回执。",
|
||
reasoning_bullets=[
|
||
"命中 command-first 路由,跳过 RAG 问答与信号分流。",
|
||
f"planner_status={str(command_result.get('planner_status') or '') or 'unknown'}",
|
||
f"openclaw_status={str(command_result.get('openclaw_status') or '') or 'unknown'}",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": linked_project_ids,
|
||
"signal_id": None,
|
||
"created_project_id": created_project_id or primary_project_id,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": str(command_result.get("trace_id") or ""),
|
||
"results": [
|
||
{
|
||
"item_id": item_id,
|
||
"level": "command",
|
||
"project_id": primary_project_id,
|
||
"signal_id": None,
|
||
"route_status": route_status,
|
||
}
|
||
],
|
||
"intent": "command",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": reply_text,
|
||
"command_result": command_result,
|
||
}
|
||
|
||
async def _maybe_answer_project_lookup_question(
|
||
self,
|
||
*,
|
||
message: str,
|
||
feishu_message_id: Optional[str],
|
||
process_started_at: float,
|
||
projects: list[dict[str, Any]],
|
||
) -> Optional[dict[str, Any]]:
|
||
if not self._looks_like_project_lookup_question(message):
|
||
return None
|
||
|
||
linked_project_ids: list[int] = []
|
||
reply_text = ""
|
||
id_match = PROJECT_ID_QUERY_RE.search(message)
|
||
if id_match:
|
||
target_id = int(id_match.group(1))
|
||
matched = next((item for item in projects if _safe_int(item.get("id")) == target_id), None)
|
||
if matched is None:
|
||
reply_text = f"项目#{target_id} 不存在,或当前租户下不可见。"
|
||
else:
|
||
linked_project_ids = [target_id]
|
||
reply_text = self._format_project_lookup_hit_rows([matched], prefix="查到了")
|
||
elif PROJECT_LIST_QUERY_RE.search(message):
|
||
if not projects:
|
||
reply_text = "当前没有可见项目。"
|
||
else:
|
||
reply_text = self._format_project_lookup_hit_rows(projects[:8], prefix="当前项目")
|
||
linked_project_ids = [int(item["id"]) for item in projects[:8] if _safe_int(item.get("id"))]
|
||
else:
|
||
keyword = self._extract_project_lookup_keyword(message)
|
||
matched_rows = self._match_projects_by_keyword(projects=projects, keyword=keyword)
|
||
if matched_rows:
|
||
linked_project_ids = [int(item["id"]) for item in matched_rows if _safe_int(item.get("id"))]
|
||
reply_text = self._format_project_lookup_hit_rows(matched_rows[:5], prefix="查到了")
|
||
elif keyword:
|
||
reply_text = f"没有查到包含“{keyword}”的项目。"
|
||
else:
|
||
reply_text = "这条问题缺少可检索的项目关键词。"
|
||
|
||
duration_ms = int((time.perf_counter() - process_started_at) * 1000)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=json.dumps({"deterministic_lookup": True, "linked_project_ids": linked_project_ids}, ensure_ascii=False),
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="question",
|
||
routing_decision=ROUTING_DIRECT_QA,
|
||
routing_reason="命中项目查询问句,已直接基于真实 DB 返回结果,跳过 LLM 分流。",
|
||
reasoning_bullets=[
|
||
"命中 deterministic project lookup 规则。",
|
||
"直接查询 projects 表,不走 signal/project 分流。",
|
||
"回复内容只基于真实 DB 结果。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
return {
|
||
"status": "answered",
|
||
"classification": "question",
|
||
"project_scope": "question",
|
||
"linked_project_ids": linked_project_ids,
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": f"feishu-project-lookup-{(feishu_message_id or 'noid')[:32]}",
|
||
"results": [],
|
||
"intent": "question",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": reply_text,
|
||
}
|
||
|
||
async def _build_openclaw_blocked_capture(
|
||
self,
|
||
*,
|
||
message: str,
|
||
feishu_message_id: Optional[str],
|
||
duration_ms: int,
|
||
reason: str,
|
||
) -> dict[str, Any]:
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=None,
|
||
raw_message=message,
|
||
ai_raw_output=None,
|
||
project_score=None,
|
||
signal_confidence=None,
|
||
level="command",
|
||
routing_decision=ROUTING_OPENCLAW_BLOCKED,
|
||
routing_reason=reason,
|
||
reasoning_bullets=[
|
||
"命中 command-first 路由,但当前缺少安全执行所需的上下文。",
|
||
"系统未执行任何写库动作。",
|
||
"回执只基于确定性拦截结果,不使用 LLM 概括。",
|
||
],
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=duration_ms,
|
||
)
|
||
return {
|
||
"status": "captured",
|
||
"classification": "command",
|
||
"project_scope": "command",
|
||
"linked_project_ids": [],
|
||
"signal_id": None,
|
||
"created_project_id": None,
|
||
"auto_tasks": [],
|
||
"ai_trace_id": None,
|
||
"results": [],
|
||
"intent": "command",
|
||
"direct_reply_required": True,
|
||
"direct_reply_text": f"未执行:{reason}",
|
||
"command_result": None,
|
||
}
|
||
|
||
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(timezone.utc).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="OpenClaw 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": "openclaw_commander",
|
||
"operation_code": None,
|
||
"result_summary": self._build_openclaw_command_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("openclaw_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_openclaw_command_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_openclaw_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_openclaw_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
|
||
|
||
@staticmethod
|
||
def _extract_project_lookup_keyword(message: str) -> str:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return ""
|
||
cleaned = re.sub(
|
||
r"(请问|麻烦问下|问下|现在|当前|目前|系统里|系统中|项目里|项目中|有没有|有无|有吗|是否有|是否存在|存在吗|这个|那个|一个|个|项目|列表|清单|有|吗|呢|呀|啊|么)",
|
||
" ",
|
||
text,
|
||
flags=re.IGNORECASE,
|
||
)
|
||
cleaned = re.sub(r"[,。,.;;::!??()()\\[\\]【】]+", " ", cleaned)
|
||
cleaned = re.sub(r"\s+", " ", cleaned).strip()
|
||
return cleaned[:40]
|
||
|
||
@staticmethod
|
||
def _match_projects_by_keyword(*, projects: list[dict[str, Any]], keyword: str) -> list[dict[str, Any]]:
|
||
normalized_keyword = re.sub(r"\s+", "", str(keyword or "")).lower()
|
||
if len(normalized_keyword) < 2:
|
||
return []
|
||
matched: list[dict[str, Any]] = []
|
||
for item in projects:
|
||
title = re.sub(r"\s+", "", str(item.get("title") or item.get("name") or "")).lower()
|
||
if normalized_keyword and normalized_keyword in title:
|
||
matched.append(item)
|
||
return matched
|
||
|
||
@staticmethod
|
||
def _format_project_lookup_hit_rows(rows: list[dict[str, Any]], *, prefix: str) -> str:
|
||
if not rows:
|
||
return "当前没有可见项目。"
|
||
fragments: list[str] = []
|
||
for item in rows[:8]:
|
||
project_id = item.get("id")
|
||
title = str(item.get("title") or item.get("name") or "").strip() or f"项目#{project_id}"
|
||
status = str(item.get("status") or "").strip() or "unknown"
|
||
fragments.append(f"#{project_id} {title}({status})")
|
||
return f"{prefix}:{'、'.join(fragments)}。"
|
||
|
||
@classmethod
|
||
def _should_hide_pending_item_from_inbox(cls, item: FeishuItem) -> bool:
|
||
if str(item.decision_reason or "").strip() == "missing_required_fields":
|
||
return True
|
||
raw_message = str(item.raw_message or "").strip()
|
||
if not raw_message:
|
||
return False
|
||
normalized = re.sub(r"\s+", "", raw_message).lower()
|
||
if cls._is_water_talk_message(raw_message):
|
||
return True
|
||
if cls._is_healthcheck_message(raw_message):
|
||
return True
|
||
if cls._is_short_confirmation_message(raw_message):
|
||
return True
|
||
if cls._looks_like_project_lookup_question(raw_message):
|
||
return True
|
||
if "长连接" in normalized and any(token in normalized for token in ("已通", "都已通", "正常", "在线", "已恢复")):
|
||
return True
|
||
return False
|
||
|
||
async def list_queue(self, *, limit: int = 30) -> dict[str, Any]:
|
||
routing_policy = await self._load_routing_policy()
|
||
pending_rows = (
|
||
await self.db.execute(
|
||
select(FeishuItem)
|
||
.where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.queue_visible.is_(True),
|
||
FeishuItem.route_status.in_((ROUTE_STATUS_QUEUED, "pending")),
|
||
)
|
||
)
|
||
.order_by(FeishuItem.created_at.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
pending_rows = self._dedupe_queue_items(pending_rows)
|
||
pending_rows = [row for row in pending_rows if not self._should_hide_pending_item_from_inbox(row)]
|
||
|
||
# DB 列是 timestamp without time zone,比较值必须使用 naive UTC,避免 asyncpg aware/naive 报错。
|
||
today_start = _utc_now().astimezone(timezone.utc).replace(
|
||
hour=0,
|
||
minute=0,
|
||
second=0,
|
||
microsecond=0,
|
||
tzinfo=None,
|
||
)
|
||
auto_rows = (
|
||
await self.db.execute(
|
||
select(FeishuItem)
|
||
.where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE, "auto_processed", "confirmed")),
|
||
FeishuItem.processed_at.is_not(None),
|
||
FeishuItem.processed_at >= today_start,
|
||
)
|
||
)
|
||
.order_by(FeishuItem.processed_at.desc())
|
||
.limit(limit)
|
||
)
|
||
).scalars().all()
|
||
auto_rows = self._dedupe_queue_items(auto_rows)
|
||
auto_rows = [row for row in auto_rows if not self._is_water_talk_message(row.raw_message or "")]
|
||
|
||
pending_items = [await self._serialize_item(row) for row in pending_rows]
|
||
pending_items.sort(
|
||
key=lambda x: (
|
||
-int(x.get("priority_rank") or 0),
|
||
str(x.get("created_at") or ""),
|
||
)
|
||
)
|
||
auto_items = [await self._serialize_item(row) for row in auto_rows]
|
||
|
||
signal_auto = len([x for x in auto_items if x.get("level") == "signal"])
|
||
project_auto = len(
|
||
[
|
||
x for x in auto_items
|
||
if x.get("level") == "project"
|
||
or (
|
||
x.get("level") == "command"
|
||
and isinstance(x.get("command"), dict)
|
||
and str(x["command"].get("operation_code") or "") == "project.create"
|
||
)
|
||
]
|
||
)
|
||
return {
|
||
"pending_count": len(pending_items),
|
||
"pending_items": pending_items,
|
||
"auto_processed_items": auto_items,
|
||
"auto_summary": {
|
||
"signals_auto_entered_today": signal_auto,
|
||
"projects_auto_created_today": project_auto,
|
||
},
|
||
"cold_start": bool(routing_policy["cold_start_enabled"]),
|
||
}
|
||
|
||
async def get_item_detail(self, item_id: int) -> dict[str, Any]:
|
||
item = await self._get_item(item_id)
|
||
if not item:
|
||
raise ValueError("item_not_found")
|
||
return await self._serialize_item(item, include_raw=True, include_history=True)
|
||
|
||
async def update_item(
|
||
self,
|
||
*,
|
||
item_id: int,
|
||
payload: dict[str, Any],
|
||
user_id: int,
|
||
) -> dict[str, Any]:
|
||
item = await self._get_item(item_id)
|
||
if not item:
|
||
raise ValueError("item_not_found")
|
||
|
||
changed = False
|
||
if (item.level or "").lower() == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
if not suggestion:
|
||
raise ValueError("project_suggestion_not_found")
|
||
for field in ("suggested_name", "suggested_description", "suggested_owner", "suggested_timeline"):
|
||
if field in payload:
|
||
new_value = str(payload.get(field) or "").strip() or None
|
||
if getattr(suggestion, field) != new_value:
|
||
setattr(suggestion, field, new_value)
|
||
changed = True
|
||
if changed:
|
||
suggestion.status = "pending"
|
||
else:
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if not signal:
|
||
raise ValueError("signal_not_found")
|
||
editable_fields = (
|
||
"title",
|
||
"signal_type",
|
||
"priority",
|
||
"summary",
|
||
"recommended_action",
|
||
"matched_project_id",
|
||
)
|
||
for field in editable_fields:
|
||
if field not in payload:
|
||
continue
|
||
old_value = getattr(signal, field)
|
||
raw_new = payload.get(field)
|
||
if field == "matched_project_id":
|
||
new_value = int(raw_new) if raw_new is not None else None
|
||
else:
|
||
new_value = str(raw_new).strip() if raw_new is not None else None
|
||
if new_value == "":
|
||
new_value = None
|
||
if old_value == new_value:
|
||
continue
|
||
setattr(signal, field, new_value)
|
||
self.db.add(
|
||
SignalEdit(
|
||
tenant_id=self.tenant_id,
|
||
signal_id=int(signal.id),
|
||
field=field,
|
||
old_value=None if old_value is None else str(old_value),
|
||
new_value=None if new_value is None else str(new_value),
|
||
edited_by=int(user_id),
|
||
edited_at=_utc_now().replace(tzinfo=None),
|
||
)
|
||
)
|
||
changed = True
|
||
|
||
if changed:
|
||
await self.db.commit()
|
||
return await self.get_item_detail(item_id)
|
||
|
||
async def dismiss_item(self, *, item_id: int, reason: str) -> dict[str, Any]:
|
||
item = await self._get_item(item_id)
|
||
if not item:
|
||
raise ValueError("item_not_found")
|
||
item.route_status = ROUTE_STATUS_DISMISSED
|
||
item.queue_visible = False
|
||
item.dismissed_reason = (reason or "").strip()[:300] or "用户手动忽略"
|
||
item.processed_at = _utc_now().replace(tzinfo=None)
|
||
item.message_class = "ignored"
|
||
item.auto_execute_state = "dismissed"
|
||
|
||
if (item.level or "").lower() == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
if suggestion:
|
||
suggestion.status = "dismissed"
|
||
else:
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if signal:
|
||
signal.processed = True
|
||
signal.status = "已关闭"
|
||
|
||
await self.db.commit()
|
||
return {"id": str(item.id), "status": item.route_status}
|
||
|
||
async def confirm_item(self, *, item_id: int, user_id: int, action: Optional[str] = None) -> dict[str, Any]:
|
||
item = await self._get_item(item_id)
|
||
if not item:
|
||
raise ValueError("item_not_found")
|
||
|
||
level = (item.level or "").lower()
|
||
result: dict[str, Any] = {"id": str(item.id), "level": level}
|
||
if level == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
if not suggestion:
|
||
raise ValueError("project_suggestion_not_found")
|
||
candidate_fields = self._resolve_project_candidate_fields(item=item, suggestion=suggestion)
|
||
if not candidate_fields["ready"]:
|
||
raise ValueError(
|
||
str(item.blocking_reason or "").strip()
|
||
or self._build_project_clarification_message(candidate_fields["missing_fields"])
|
||
)
|
||
operator_user = await self._load_active_user_by_id(user_id=int(user_id))
|
||
if operator_user is None:
|
||
raise ValueError("operator_not_found")
|
||
execution = await self._execute_controlled_operation(
|
||
operator_user=operator_user,
|
||
operation_code="project.create",
|
||
payload={
|
||
"title": candidate_fields["title"],
|
||
"description": candidate_fields["description"],
|
||
},
|
||
trace_id=f"confirm-project-{int(item.id)}-{int(user_id)}",
|
||
)
|
||
first = execution["results"][0] if execution.get("results") else {}
|
||
status = str(first.get("status") or "").strip().lower()
|
||
if status not in {"applied", "skipped"}:
|
||
raise ValueError(str(first.get("message") or "project_create_failed"))
|
||
project_result = first.get("result") if isinstance(first.get("result"), dict) else {}
|
||
project_id = _safe_int(project_result.get("project_id") or first.get("entity_id"))
|
||
suggestion.status = "confirmed"
|
||
suggestion.created_project_id = int(project_id) if project_id is not None else None
|
||
item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id
|
||
item.result_payload = {
|
||
"intent": INTENT_CREATE_PROJECT,
|
||
"executor": "operation_executor",
|
||
"operation_code": "project.create",
|
||
"result_summary": f"已创建项目 #{project_id}" if project_id else "已创建项目",
|
||
"operation_result": project_result,
|
||
"target_url": f"/projects/{project_id}/board" if project_id else None,
|
||
"project_id": project_id,
|
||
}
|
||
result["project_id"] = str(project_id) if project_id is not None else None
|
||
elif level == "command":
|
||
operator_user = await self._load_active_user_by_id(user_id=int(user_id))
|
||
if operator_user is None:
|
||
raise ValueError("operator_not_found")
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
operation_code = str(payload.get("operation_code") or "").strip()
|
||
operation_payload = payload.get("operation_payload") if isinstance(payload.get("operation_payload"), dict) else {}
|
||
if operation_code not in {"task.create"}:
|
||
raise ValueError("command_operation_not_supported")
|
||
execution = await self._execute_controlled_operation(
|
||
operator_user=operator_user,
|
||
operation_code=operation_code,
|
||
payload=operation_payload,
|
||
trace_id=f"confirm-command-{int(item.id)}-{int(user_id)}",
|
||
)
|
||
first = execution["results"][0] if execution.get("results") else {}
|
||
status = str(first.get("status") or "").strip().lower()
|
||
if status not in {"applied", "skipped"}:
|
||
raise ValueError(str(first.get("message") or "command_execution_failed"))
|
||
project_id = _safe_int(operation_payload.get("project_id") or payload.get("project_id") or item.matched_project_id)
|
||
task_id = _safe_int(first.get("entity_id"))
|
||
item.matched_project_id = int(project_id) if project_id is not None else item.matched_project_id
|
||
item.result_payload = {
|
||
**payload,
|
||
"executor": "operation_executor",
|
||
"result_summary": f"已创建任务 #{task_id}" if task_id is not None else "已创建任务",
|
||
"operation_result": first.get("result") if isinstance(first.get("result"), dict) else {},
|
||
"target_url": f"/projects/{project_id}/board" if project_id else None,
|
||
"project_id": project_id,
|
||
}
|
||
result["project_id"] = str(project_id) if project_id is not None else None
|
||
result["task_id"] = str(task_id) if task_id is not None else None
|
||
else:
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if not signal:
|
||
raise ValueError("signal_not_found")
|
||
matched_project_id = signal.matched_project_id or item.matched_project_id
|
||
if not matched_project_id:
|
||
raise ValueError("signal_missing_project")
|
||
await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id))
|
||
signal.processed = False
|
||
signal.status = "待处理"
|
||
if action in {"create_charter", "trigger_alert"}:
|
||
signal.recommended_action = action
|
||
result["signal_id"] = str(signal.id)
|
||
result["project_id"] = str(matched_project_id)
|
||
|
||
item.route_status = ROUTE_STATUS_AUTO_DONE
|
||
item.queue_visible = False
|
||
item.processed_at = _utc_now().replace(tzinfo=None)
|
||
item.message_class = "auto_done"
|
||
item.safe_to_auto_execute = False
|
||
item.auto_execute_state = "done"
|
||
item.blocking_reason = None
|
||
item.next_step_text = "你已授权继续执行,结果已落地,可进入结果页查看。"
|
||
item.undoable_until = _build_undo_deadline(item.processed_at) if level in {"project", "signal"} else None
|
||
if level == "project":
|
||
created_project_id = _safe_int(result.get("project_id"))
|
||
item.result_payload = {
|
||
**(item.result_payload if isinstance(item.result_payload, dict) else {}),
|
||
"result_count": 1,
|
||
"impact_objects": [f"项目#{created_project_id}"] if created_project_id else [],
|
||
}
|
||
elif level == "signal":
|
||
project_id = _safe_int(result.get("project_id"))
|
||
signal_id = _safe_int(result.get("signal_id"))
|
||
item.result_payload = {
|
||
"result_summary": f"已确认信号 #{signal_id} 并关联项目 #{project_id}" if project_id and signal_id else "已确认信号并完成入库",
|
||
"result_count": 1,
|
||
"impact_objects": [f"信号#{signal_id}"] if signal_id else [],
|
||
"target_url": f"/projects/{project_id}/board" if project_id else None,
|
||
"project_id": project_id,
|
||
}
|
||
else:
|
||
payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
item.result_payload = {
|
||
**payload,
|
||
"result_count": 1,
|
||
}
|
||
await self.db.commit()
|
||
return result
|
||
|
||
async def undo_auto_processed(self, *, item_id: int) -> dict[str, Any]:
|
||
item = await self._get_item(item_id)
|
||
if not item:
|
||
raise ValueError("item_not_found")
|
||
if self._normalize_route_status(item.route_status) != ROUTE_STATUS_AUTO_DONE:
|
||
raise ValueError("item_not_auto_processed")
|
||
|
||
level = (item.level or "").lower()
|
||
if level == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
if suggestion and suggestion.created_project_id:
|
||
project = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(suggestion.created_project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if project:
|
||
project.status = "inactive"
|
||
if suggestion:
|
||
suggestion.status = "pending"
|
||
elif level == "signal":
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if signal:
|
||
await self.db.execute(
|
||
delete(ProjectSignalLink).where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||
ProjectSignalLink.signal_id == int(signal.id),
|
||
)
|
||
)
|
||
)
|
||
signal.status = "待处理"
|
||
signal.processed = False
|
||
|
||
item.route_status = ROUTE_STATUS_UNDONE
|
||
item.queue_visible = False
|
||
item.processed_at = _utc_now().replace(tzinfo=None)
|
||
item.message_class = "auto_done"
|
||
item.auto_execute_state = "undone"
|
||
item.undoable_until = None
|
||
existing_payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
item.result_payload = {
|
||
**existing_payload,
|
||
"result_summary": "该结果已撤销",
|
||
"undone": True,
|
||
}
|
||
await self.db.commit()
|
||
return {"id": str(item.id), "status": item.route_status}
|
||
|
||
async def _process_one_item(
|
||
self,
|
||
*,
|
||
content: str,
|
||
level: str,
|
||
sender: str,
|
||
source_chat_id: str,
|
||
source_sender_open_id: Optional[str],
|
||
feishu_message_id: Optional[str],
|
||
occurred_at: datetime,
|
||
level_result: dict[str, Any],
|
||
level_confidence: float,
|
||
level_reasoning: str,
|
||
project_score: float,
|
||
cold_start_enabled: bool,
|
||
signal_auto_enter_threshold: float,
|
||
project_auto_create_threshold: float,
|
||
project_uncertain_threshold: float,
|
||
chat_context_auto_link_enabled: bool,
|
||
projects: list[dict[str, Any]],
|
||
split_from_item_id: Optional[int],
|
||
chat_type: str,
|
||
chat_context_resolution: dict[str, Any],
|
||
) -> dict[str, Any]:
|
||
item_started_at = time.perf_counter()
|
||
level_val = "project" if level == "project" else "signal"
|
||
matched_project_hint = _safe_int(chat_context_resolution.get("project_id"))
|
||
dedupe_key = self._build_queue_dedupe_key(
|
||
level=level_val,
|
||
intent=INTENT_SIGNAL_INTAKE if level_val == "signal" else INTENT_CREATE_PROJECT,
|
||
message=content,
|
||
source_chat_id=source_chat_id,
|
||
matched_project_id=matched_project_hint,
|
||
source_object=content,
|
||
)
|
||
existing_item = await self._find_active_dedupe_item(dedupe_key)
|
||
if existing_item is not None:
|
||
existing_item.occurrence_count = max(1, int(existing_item.occurrence_count or 1)) + 1
|
||
existing_item.raw_message = content[:4000]
|
||
existing_item.source_sender = (sender or "")[:120] or None
|
||
existing_item.source_sender_open_id = (source_sender_open_id or "")[:128] or None
|
||
existing_item.source_chat_id = (source_chat_id or "")[:128] or None
|
||
existing_item.source_occurred_at = occurred_at.replace(tzinfo=None)
|
||
existing_item.level_reasoning = level_reasoning[:1000]
|
||
existing_item.ai_output = json.dumps(
|
||
{
|
||
"chat_type": chat_type,
|
||
"normalized_text": content,
|
||
"level_result": level_result,
|
||
},
|
||
ensure_ascii=False,
|
||
)[:8000]
|
||
await self.db.flush()
|
||
return await self._build_existing_item_result(existing_item)
|
||
|
||
item = FeishuItem(
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=feishu_message_id,
|
||
project_score=project_score,
|
||
signal_confidence=max(0.0, 1 - project_score),
|
||
raw_message=content[:4000],
|
||
split_from_item_id=split_from_item_id,
|
||
level=level_val,
|
||
level_confidence=level_confidence,
|
||
level_reasoning=level_reasoning[:1000],
|
||
card_type="signal" if level_val == "signal" else "new_project",
|
||
route_status=ROUTE_STATUS_CLASSIFIED,
|
||
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.replace(tzinfo=None),
|
||
ai_output=json.dumps(
|
||
{
|
||
"chat_type": chat_type,
|
||
"normalized_text": content,
|
||
"level_result": level_result,
|
||
},
|
||
ensure_ascii=False,
|
||
)[:8000],
|
||
decision_reason=level_reasoning[:1000] or None,
|
||
safe_to_auto_execute=False,
|
||
auto_execute_state="blocked",
|
||
dedupe_key=dedupe_key,
|
||
message_class="action_required",
|
||
occurrence_count=1,
|
||
next_step_text="AI 正在校验上下文,确认后会继续推进后续动作。",
|
||
queue_visible=True,
|
||
)
|
||
self.db.add(item)
|
||
await self.db.flush()
|
||
|
||
if level_val == "project":
|
||
project_info = await self._extract_project_prefill(content)
|
||
suggestion = ProjectSuggestion(
|
||
tenant_id=self.tenant_id,
|
||
feishu_item_id=int(item.id),
|
||
suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255],
|
||
suggested_description=(project_info.get("suggested_description") or content[:400])[:4000],
|
||
suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120],
|
||
suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120],
|
||
confidence=_safe_float(project_info.get("confidence"), level_confidence),
|
||
reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False),
|
||
status="pending",
|
||
)
|
||
self.db.add(suggestion)
|
||
await self.db.flush()
|
||
project_id = await self._route_project_item(
|
||
item=item,
|
||
suggestion=suggestion,
|
||
sender=sender,
|
||
project_score=project_score,
|
||
cold_start_enabled=cold_start_enabled,
|
||
project_auto_create_threshold=project_auto_create_threshold,
|
||
)
|
||
routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log(
|
||
project_score=project_score,
|
||
cold_start_enabled=cold_start_enabled,
|
||
project_auto_create_threshold=project_auto_create_threshold,
|
||
project_uncertain_threshold=project_uncertain_threshold,
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=int(item.id),
|
||
raw_message=content,
|
||
ai_raw_output=json.dumps(
|
||
{
|
||
"level_classification": level_result,
|
||
"project_prefill": project_info,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
project_score=project_score,
|
||
signal_confidence=max(0.0, 1 - project_score),
|
||
level="project",
|
||
routing_decision=routing_decision,
|
||
routing_reason=routing_reason,
|
||
reasoning_bullets=reasoning_bullets,
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - item_started_at) * 1000),
|
||
)
|
||
return {
|
||
"item_id": int(item.id),
|
||
"level": "project",
|
||
"project_id": project_id,
|
||
"signal_id": None,
|
||
"route_status": item.route_status,
|
||
}
|
||
|
||
simple_signal_request = self._is_simple_signal_source_request(content)
|
||
signal_info = await self._extract_signal(content, projects=projects)
|
||
matched_project_id = signal_info.get("matched_project_id")
|
||
if matched_project_id is not None:
|
||
try:
|
||
matched_project_id = int(matched_project_id)
|
||
except (TypeError, ValueError):
|
||
matched_project_id = None
|
||
|
||
confidence = _safe_float(signal_info.get("confidence"), max(0.55, 1 - project_score))
|
||
if simple_signal_request:
|
||
confidence = max(confidence, min(0.95, signal_auto_enter_threshold + 0.05))
|
||
inferred_project_id: Optional[int] = None
|
||
clarification_message: Optional[str] = None
|
||
if (chat_context_auto_link_enabled or simple_signal_request) and matched_project_id is None and source_chat_id:
|
||
inferred_project_id = _safe_int(chat_context_resolution.get("project_id"))
|
||
if inferred_project_id is not None:
|
||
matched_project_id = int(inferred_project_id)
|
||
elif bool(chat_context_resolution.get("ambiguous")):
|
||
candidate_ids = [int(x) for x in (chat_context_resolution.get("candidate_ids") or [])[:3] if _safe_int(x)]
|
||
if candidate_ids:
|
||
clarification_message = (
|
||
f"当前群最近同时涉及项目#{'、#'.join([str(pid) for pid in candidate_ids])},"
|
||
"请明确这条消息归属哪个项目。"
|
||
)
|
||
|
||
if matched_project_id is None and self._should_promote_signal_to_project_candidate(
|
||
content=content,
|
||
signal_info=signal_info,
|
||
confidence=confidence,
|
||
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
||
simple_signal_request=simple_signal_request,
|
||
):
|
||
promoted_project_score = max(float(project_score or 0.0), float(project_uncertain_threshold))
|
||
item.level = "project"
|
||
item.project_score = promoted_project_score
|
||
item.signal_confidence = max(0.0, 1 - promoted_project_score)
|
||
item.card_type = "new_project_candidate"
|
||
item.matched_project_id = None
|
||
|
||
project_info = await self._extract_project_prefill(content)
|
||
suggestion = ProjectSuggestion(
|
||
tenant_id=self.tenant_id,
|
||
feishu_item_id=int(item.id),
|
||
suggested_name=(project_info.get("suggested_name") or content[:20] or "新项目建议")[:255],
|
||
suggested_description=(project_info.get("suggested_description") or content[:400])[:4000],
|
||
suggested_owner=(project_info.get("suggested_owner") or sender or "待分配")[:120],
|
||
suggested_timeline=(project_info.get("suggested_timeline") or "待确认")[:120],
|
||
confidence=_safe_float(project_info.get("confidence"), confidence),
|
||
reasoning=json.dumps(_normalize_reasoning(project_info.get("reasoning")), ensure_ascii=False),
|
||
status="pending",
|
||
)
|
||
self.db.add(suggestion)
|
||
await self.db.flush()
|
||
|
||
project_id = await self._route_project_item(
|
||
item=item,
|
||
suggestion=suggestion,
|
||
sender=sender,
|
||
project_score=promoted_project_score,
|
||
cold_start_enabled=cold_start_enabled,
|
||
project_auto_create_threshold=project_auto_create_threshold,
|
||
)
|
||
routing_decision, routing_reason, reasoning_bullets = self._build_project_routing_log(
|
||
project_score=promoted_project_score,
|
||
cold_start_enabled=cold_start_enabled,
|
||
project_auto_create_threshold=project_auto_create_threshold,
|
||
project_uncertain_threshold=project_uncertain_threshold,
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=int(item.id),
|
||
raw_message=content,
|
||
ai_raw_output=json.dumps(
|
||
{
|
||
"level_classification": level_result,
|
||
"signal_extraction": signal_info,
|
||
"promoted_to_project_candidate": True,
|
||
"simple_signal_request": simple_signal_request,
|
||
"project_prefill": project_info,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
project_score=promoted_project_score,
|
||
signal_confidence=confidence,
|
||
level="project",
|
||
routing_decision=routing_decision,
|
||
routing_reason=routing_reason,
|
||
reasoning_bullets=reasoning_bullets,
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - item_started_at) * 1000),
|
||
)
|
||
return {
|
||
"item_id": int(item.id),
|
||
"level": "project",
|
||
"project_id": project_id,
|
||
"signal_id": None,
|
||
"route_status": item.route_status,
|
||
}
|
||
|
||
signal = Signal(
|
||
tenant_id=self.tenant_id,
|
||
source="Feishu",
|
||
content=f"[{sender or '未知用户'}]: {content[:800]}",
|
||
impact_score=max(0.1, min(1.0, confidence)),
|
||
priority=self._to_signal_priority(str(signal_info.get("priority") or "P2")),
|
||
processed=False,
|
||
strategic_analysis=json.dumps(
|
||
{
|
||
"source_context": {
|
||
"group_id": source_chat_id,
|
||
"sender": sender,
|
||
"sender_open_id": source_sender_open_id,
|
||
},
|
||
"ai_analysis": {
|
||
"classification": "signal",
|
||
"reason": signal_info.get("summary"),
|
||
},
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
tasks_generated="[]",
|
||
feishu_item_id=int(item.id),
|
||
matched_project_id=matched_project_id,
|
||
title=(signal_info.get("title") or content[:40] or "未命名信号")[:200],
|
||
signal_type=self._normalize_signal_type(signal_info.get("type")),
|
||
summary=(signal_info.get("summary") or "")[:3000] or None,
|
||
reasoning=json.dumps(_normalize_reasoning(signal_info.get("reasoning")), ensure_ascii=False),
|
||
confidence=confidence,
|
||
recommended_action=self._normalize_recommended_action(signal_info.get("recommended_action")),
|
||
status="待处理",
|
||
)
|
||
self.db.add(signal)
|
||
await self.db.flush()
|
||
|
||
project_id = await self._route_signal_item(
|
||
item=item,
|
||
signal=signal,
|
||
matched_project_id=matched_project_id,
|
||
confidence=confidence,
|
||
cold_start_enabled=cold_start_enabled,
|
||
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
||
)
|
||
routing_decision, routing_reason, reasoning_bullets = self._build_signal_routing_log(
|
||
confidence=confidence,
|
||
cold_start_enabled=cold_start_enabled,
|
||
matched_project_id=matched_project_id,
|
||
signal_auto_enter_threshold=signal_auto_enter_threshold,
|
||
)
|
||
await self._write_processing_log(
|
||
feishu_message_id=feishu_message_id,
|
||
feishu_item_id=int(item.id),
|
||
raw_message=content,
|
||
ai_raw_output=json.dumps(
|
||
{
|
||
"level_classification": level_result,
|
||
"signal_extraction": signal_info,
|
||
"simple_signal_request": simple_signal_request,
|
||
"chat_context_inferred_project_id": inferred_project_id,
|
||
"chat_context_resolution": chat_context_resolution,
|
||
"clarification_message": clarification_message,
|
||
},
|
||
ensure_ascii=False,
|
||
),
|
||
project_score=project_score,
|
||
signal_confidence=confidence,
|
||
level="signal",
|
||
routing_decision=routing_decision,
|
||
routing_reason=routing_reason,
|
||
reasoning_bullets=reasoning_bullets,
|
||
processing_status="success",
|
||
error_message=None,
|
||
duration_ms=int((time.perf_counter() - item_started_at) * 1000),
|
||
)
|
||
return {
|
||
"item_id": int(item.id),
|
||
"level": "signal",
|
||
"project_id": project_id,
|
||
"signal_id": int(signal.id),
|
||
"route_status": item.route_status,
|
||
"clarification_message": clarification_message,
|
||
}
|
||
|
||
async def _route_project_item(
|
||
self,
|
||
*,
|
||
item: FeishuItem,
|
||
suggestion: ProjectSuggestion,
|
||
sender: str,
|
||
project_score: float,
|
||
cold_start_enabled: bool,
|
||
project_auto_create_threshold: float,
|
||
) -> Optional[int]:
|
||
_ = project_auto_create_threshold
|
||
high_score = project_score >= max(project_auto_create_threshold, 0.7)
|
||
item.card_type = "new_project" if high_score else "new_project_candidate"
|
||
item.route_status = ROUTE_STATUS_QUEUED
|
||
item.queue_visible = True
|
||
item.message_class = "action_required"
|
||
item.safe_to_auto_execute = False
|
||
item.auto_execute_state = "blocked"
|
||
item.blocking_reason = (
|
||
"当前处于冷启动保护期,建项目默认需要你确认后再执行。"
|
||
if cold_start_enabled
|
||
else "建项目属于高风险动作,默认需要你授权后再执行。"
|
||
)
|
||
item.decision_reason = (
|
||
"intent gate/自由分类均不再直接 auto create,避免误把自然语言写成项目主数据。"
|
||
)
|
||
item.next_step_text = "确认后,系统会走受控 project.create,并绑定真实项目 ID。"
|
||
return None
|
||
|
||
async def _route_signal_item(
|
||
self,
|
||
*,
|
||
item: FeishuItem,
|
||
signal: Signal,
|
||
matched_project_id: Optional[int],
|
||
confidence: float,
|
||
cold_start_enabled: bool,
|
||
signal_auto_enter_threshold: float,
|
||
) -> Optional[int]:
|
||
item.matched_project_id = int(matched_project_id) if matched_project_id else None
|
||
if cold_start_enabled or confidence < signal_auto_enter_threshold or not matched_project_id:
|
||
item.card_type = "signal"
|
||
item.route_status = ROUTE_STATUS_QUEUED
|
||
item.queue_visible = True
|
||
item.message_class = "action_required"
|
||
item.safe_to_auto_execute = False
|
||
item.auto_execute_state = "blocked"
|
||
if not matched_project_id:
|
||
item.blocking_reason = "当前缺少明确项目归属,继续自动执行会产生错误副作用。"
|
||
item.next_step_text = "补齐项目归属后,AI 将自动把信号入库并继续后续链路。"
|
||
item.decision_reason = "上下文尚不完整,系统暂停在需你决策队列。"
|
||
elif cold_start_enabled:
|
||
item.blocking_reason = "当前处于冷启动保护期,信号入库暂不自动执行。"
|
||
item.next_step_text = "确认后,AI 将继续完成信号入库并推进后续动作。"
|
||
item.decision_reason = "保守自动化已拦截自动入库,避免早期误关联。"
|
||
else:
|
||
item.blocking_reason = "当前置信度不足以自动入库,需要你拍板后再继续。"
|
||
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
|
||
item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。"
|
||
return matched_project_id
|
||
|
||
await self._ensure_signal_link(signal_id=int(signal.id), project_id=int(matched_project_id))
|
||
item.card_type = "signal_auto"
|
||
item.route_status = ROUTE_STATUS_AUTO_DONE
|
||
item.queue_visible = False
|
||
item.processed_at = _utc_now().replace(tzinfo=None)
|
||
item.message_class = "auto_done"
|
||
item.safe_to_auto_execute = True
|
||
item.auto_execute_state = "done"
|
||
item.blocking_reason = None
|
||
item.decision_reason = "低风险且上下文完整,AI 已自动完成信号入库。"
|
||
item.next_step_text = "下一步可基于该信号继续生成 charter 或分发任务。"
|
||
item.undoable_until = _build_undo_deadline(item.processed_at)
|
||
item.result_payload = {
|
||
"result_summary": f"已自动入库信号 {signal.title or '未命名信号'}",
|
||
"result_count": 1,
|
||
"impact_objects": [signal.title or f"信号#{signal.id}", f"项目#{int(matched_project_id)}"],
|
||
"target_url": f"/projects/{int(matched_project_id)}/board",
|
||
"project_id": int(matched_project_id),
|
||
}
|
||
await self._notify_signal_auto_entered(
|
||
project_id=int(matched_project_id),
|
||
signal=signal,
|
||
)
|
||
return int(matched_project_id)
|
||
|
||
async def _extract_project_prefill(self, message: str) -> dict[str, Any]:
|
||
prompt = PROMPT_FEISHU_PROJECT_PREFILL.format(message=message[:1500])
|
||
try:
|
||
raw = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="feishu_project_prefill",
|
||
)
|
||
parsed = _safe_json_object(raw)
|
||
if not parsed:
|
||
raise ValueError("project_prefill_empty_json")
|
||
parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning"))
|
||
return _validate_schema(_ProjectPrefillSchema, parsed, context="project_prefill")
|
||
except Exception as exc:
|
||
logger.warning(f"feishu_project_prefill_fallback: error={exc}")
|
||
fallback = self._heuristic_project_prefill_fallback(message)
|
||
return _validate_schema(_ProjectPrefillSchema, fallback, context="project_prefill_fallback")
|
||
|
||
async def _extract_signal(self, message: str, *, projects: list[dict[str, Any]]) -> dict[str, Any]:
|
||
if self._is_simple_signal_source_request(message):
|
||
fallback = self._heuristic_signal_fallback(message=message, projects=projects)
|
||
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fastpath")
|
||
|
||
prompt = PROMPT_FEISHU_SIGNAL_EXTRACTION.format(
|
||
message=message[:1500],
|
||
projects_json=json.dumps(projects, ensure_ascii=False),
|
||
)
|
||
try:
|
||
raw = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="feishu_signal_extraction",
|
||
)
|
||
parsed = _safe_json_object(raw)
|
||
if not parsed:
|
||
raise ValueError("signal_extraction_empty_json")
|
||
parsed["reasoning"] = _normalize_reasoning(parsed.get("reasoning"))
|
||
return _validate_schema(_SignalExtractionSchema, parsed, context="signal_extraction")
|
||
except Exception as exc:
|
||
logger.warning(f"feishu_signal_extraction_fallback: error={exc}")
|
||
fallback = self._heuristic_signal_fallback(message=message, projects=projects)
|
||
return _validate_schema(_SignalExtractionSchema, fallback, context="signal_extraction_fallback")
|
||
|
||
async def _classify_level(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]:
|
||
if self._is_simple_signal_source_request(message):
|
||
fallback = self._heuristic_level_fallback(message)
|
||
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fastpath")
|
||
|
||
prompt = PROMPT_FEISHU_LEVEL_CLASSIFICATION.format(
|
||
message=message[:1500],
|
||
projects_json=json.dumps(projects, ensure_ascii=False),
|
||
)
|
||
try:
|
||
raw = await self.llm.generate(
|
||
prompt,
|
||
db=self.db,
|
||
tenant_id=self.tenant_id,
|
||
function_name="feishu_level_classification",
|
||
)
|
||
parsed = _safe_json_object(raw)
|
||
if not parsed:
|
||
raise ValueError("level_classification_empty_json")
|
||
|
||
# 兼容不同模型输出别名,避免只返回总分/中文 level 时被误降级到 signal。
|
||
bi_raw = _first_non_empty(parsed, ("build_intent_score", "buildIntentScore", "intent_score", "build_intent"))
|
||
sc_raw = _first_non_empty(parsed, ("scope_score", "scopeScore", "scope"))
|
||
tl_raw = _first_non_empty(parsed, ("timeline_score", "timelineScore", "timeline"))
|
||
ow_raw = _first_non_empty(parsed, ("owner_score", "ownerScore", "owner"))
|
||
provided_project_score_raw = _first_non_empty(parsed, ("project_score", "projectScore", "score"))
|
||
|
||
bi = _safe_float(bi_raw, 0.0)
|
||
sc = _safe_float(sc_raw, 0.0)
|
||
tl = _safe_float(tl_raw, 0.0)
|
||
ow = _safe_float(ow_raw, 0.0)
|
||
weighted = round((bi * 0.4) + (sc * 0.25) + (tl * 0.2) + (ow * 0.15), 4)
|
||
|
||
component_count = sum(value is not None for value in (bi_raw, sc_raw, tl_raw, ow_raw))
|
||
has_project_score = provided_project_score_raw is not None
|
||
provided_project_score = _safe_float(provided_project_score_raw, 0.0)
|
||
|
||
final_project_score = weighted
|
||
if component_count < 4 and has_project_score:
|
||
final_project_score = provided_project_score
|
||
elif component_count > 0 and has_project_score:
|
||
# 当模型给出了总分与维度分,优先采用更保守的较高值,降低误判为 signal 的风险。
|
||
final_project_score = max(weighted, provided_project_score)
|
||
final_project_score = max(0.0, min(1.0, final_project_score))
|
||
|
||
default_level = "project" if final_project_score >= 0.6 else "signal"
|
||
normalized_split_items: list[dict[str, str]] = []
|
||
raw_split_items = parsed.get("split_items")
|
||
if isinstance(raw_split_items, list):
|
||
for item in raw_split_items[:2]:
|
||
if not isinstance(item, dict):
|
||
continue
|
||
item_level = _normalize_level_value(item.get("level"), default=default_level)
|
||
item_content = str(item.get("content") or "").strip()
|
||
if not item_content:
|
||
continue
|
||
normalized_split_items.append({"level": item_level, "content": item_content})
|
||
|
||
level_reasoning = str(parsed.get("level_reasoning") or "").strip() or "模型返回缺少理由,已按结构化兜底。"
|
||
payload: dict[str, Any] = {
|
||
"build_intent_score": max(0.0, min(1.0, bi)),
|
||
"scope_score": max(0.0, min(1.0, sc)),
|
||
"timeline_score": max(0.0, min(1.0, tl)),
|
||
"owner_score": max(0.0, min(1.0, ow)),
|
||
"project_score": final_project_score,
|
||
"level": _normalize_level_value(parsed.get("level"), default=default_level),
|
||
"level_confidence": max(
|
||
0.0,
|
||
min(1.0, _safe_float(parsed.get("level_confidence"), max(final_project_score, 1 - final_project_score))),
|
||
),
|
||
"level_reasoning": level_reasoning[:600],
|
||
"should_split": _safe_bool(parsed.get("should_split"), False),
|
||
"split_items": normalized_split_items,
|
||
}
|
||
if self._is_simple_signal_source_request(message):
|
||
payload["project_score"] = min(_safe_float(payload.get("project_score"), final_project_score), 0.35)
|
||
payload["level"] = "signal"
|
||
payload["should_split"] = False
|
||
payload["split_items"] = []
|
||
payload["level_reasoning"] = (
|
||
f"{payload['level_reasoning']};命中简单需求模式,按信号处理避免误升级为项目。"
|
||
)[:600]
|
||
return _validate_schema(_LevelClassificationSchema, payload, context="level_classification")
|
||
except Exception as exc:
|
||
logger.warning(f"feishu_level_classification_fallback: error={exc}")
|
||
fallback = self._heuristic_level_fallback(message)
|
||
return _validate_schema(_LevelClassificationSchema, fallback, context="level_classification_fallback")
|
||
|
||
def _heuristic_level_fallback(self, message: str) -> dict[str, Any]:
|
||
text = message or ""
|
||
build_intent_score = 1.0 if PROJECT_INTENT_RE.search(text) else 0.2
|
||
scope_score = 0.75 if ("系统" in text or "平台" in text or "功能" in text) else 0.35
|
||
timeline_score = 0.8 if TIMELINE_RE.search(text) else 0.1
|
||
owner_score = 0.7 if OWNER_RE.search(text) else 0.15
|
||
project_score = round(
|
||
(build_intent_score * 0.4)
|
||
+ (scope_score * 0.25)
|
||
+ (timeline_score * 0.2)
|
||
+ (owner_score * 0.15),
|
||
4,
|
||
)
|
||
has_project_intent = bool(PROJECT_INTENT_RE.search(text))
|
||
has_signal_fact = bool(SIGNAL_FACT_RE.search(text))
|
||
should_split = bool(has_project_intent and has_signal_fact)
|
||
simple_signal_request = self._is_simple_signal_source_request(text)
|
||
if simple_signal_request:
|
||
project_score = min(project_score, 0.35)
|
||
should_split = False
|
||
level = "signal"
|
||
level_reasoning = "命中简单需求规则,按信号处理,避免误升级项目。"
|
||
else:
|
||
level = "project" if project_score >= 0.6 else "signal"
|
||
level_reasoning = "基于关键词与上下文规则的兜底判定。"
|
||
return {
|
||
"build_intent_score": build_intent_score,
|
||
"scope_score": scope_score,
|
||
"timeline_score": timeline_score,
|
||
"owner_score": owner_score,
|
||
"project_score": project_score,
|
||
"level": level,
|
||
"level_confidence": max(project_score, 1 - project_score),
|
||
"level_reasoning": level_reasoning,
|
||
"should_split": should_split,
|
||
"split_items": (
|
||
[
|
||
{"level": "project", "content": text},
|
||
{"level": "signal", "content": text},
|
||
]
|
||
if should_split
|
||
else []
|
||
),
|
||
}
|
||
|
||
@staticmethod
|
||
def _guess_matched_project_id_from_text(message: str, projects: list[dict[str, Any]]) -> Optional[int]:
|
||
normalized_message = str(message or "").strip().lower()
|
||
if not normalized_message:
|
||
return None
|
||
for row in projects:
|
||
if not isinstance(row, dict):
|
||
continue
|
||
pid_raw = row.get("id")
|
||
title = str(row.get("name") or row.get("title") or "").strip()
|
||
if not title or len(title) < 2:
|
||
continue
|
||
try:
|
||
pid = int(pid_raw) if pid_raw is not None else None
|
||
except Exception:
|
||
pid = None
|
||
if pid is None:
|
||
continue
|
||
if title.lower() in normalized_message:
|
||
return pid
|
||
return None
|
||
|
||
def _heuristic_signal_fallback(self, *, message: str, projects: list[dict[str, Any]]) -> dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
normalized = text.lower()
|
||
cleaned = re.sub(r"\s+", " ", text).strip()
|
||
summary = cleaned[:280] if cleaned else "飞书消息(空内容)"
|
||
|
||
signal_type = "新需求"
|
||
priority = "P2"
|
||
recommended_action = "enter_only"
|
||
if any(k in normalized for k in ("故障", "告警", "宕机", "报错", "bug", "风险")):
|
||
signal_type = "项目风险"
|
||
priority = "P1"
|
||
recommended_action = "trigger_alert"
|
||
elif any(k in normalized for k in ("反馈", "吐槽", "体验", "投诉")):
|
||
signal_type = "用户反馈"
|
||
priority = "P2"
|
||
elif any(k in normalized for k in ("竞品", "对标", "发布", "上线")):
|
||
signal_type = "竞品动态"
|
||
priority = "P2"
|
||
|
||
title_seed = cleaned or "飞书需求信号"
|
||
title = title_seed[:48]
|
||
if not title:
|
||
title = "飞书需求信号"
|
||
confidence = 0.78 if signal_type in {"项目风险", "用户反馈"} else 0.72
|
||
|
||
return {
|
||
"title": title,
|
||
"type": signal_type,
|
||
"priority": priority,
|
||
"matched_project_id": self._guess_matched_project_id_from_text(text, projects),
|
||
"summary": summary,
|
||
"reasoning": ["LLM 返回异常,已按关键词规则生成兜底信号结构。"],
|
||
"confidence": confidence,
|
||
"recommended_action": recommended_action,
|
||
}
|
||
|
||
@staticmethod
|
||
def _heuristic_project_prefill_fallback(message: str) -> dict[str, Any]:
|
||
text = str(message or "").strip()
|
||
cleaned = re.sub(r"\s+", " ", text).strip()
|
||
suggested_name = cleaned[:30] if cleaned else "飞书新需求项目"
|
||
if len(suggested_name) < 4:
|
||
suggested_name = "飞书新需求项目"
|
||
suggested_description = (
|
||
f"由飞书需求自动生成的项目草案:{cleaned[:200]}"
|
||
if cleaned
|
||
else "由飞书需求自动生成的项目草案,待补充目标、范围与里程碑。"
|
||
)
|
||
return {
|
||
"suggested_name": suggested_name,
|
||
"suggested_description": suggested_description,
|
||
"suggested_owner": "待指定",
|
||
"suggested_timeline": "待评估",
|
||
"confidence": 0.62,
|
||
"reasoning": ["LLM 返回异常,已按规则生成项目预填草案。"],
|
||
}
|
||
|
||
async def _load_routing_policy(self) -> dict[str, float | bool]:
|
||
integration = (
|
||
await self.db.execute(
|
||
select(Integration).where(
|
||
and_(
|
||
Integration.tenant_id == self.tenant_id,
|
||
Integration.type == "feishu",
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
config: dict[str, Any] = {}
|
||
if integration is not None:
|
||
if isinstance(integration.config, dict):
|
||
config = integration.config
|
||
elif isinstance(integration.config, str):
|
||
config = _safe_json_object(integration.config)
|
||
|
||
cold_start_enabled = bool(config.get("cold_start_enabled"))
|
||
chat_context_auto_link_enabled = bool(config.get("chat_context_auto_link_enabled", False))
|
||
|
||
signal_auto_enter_threshold = min(
|
||
1.0,
|
||
max(
|
||
0.0,
|
||
_safe_float(
|
||
config.get("signal_auto_enter_threshold"),
|
||
DEFAULT_SIGNAL_AUTO_ENTER_THRESHOLD,
|
||
),
|
||
),
|
||
)
|
||
project_auto_create_threshold = min(
|
||
1.0,
|
||
max(
|
||
0.0,
|
||
_safe_float(
|
||
config.get("project_auto_create_threshold"),
|
||
DEFAULT_PROJECT_AUTO_CREATE_THRESHOLD,
|
||
),
|
||
),
|
||
)
|
||
project_uncertain_threshold = min(
|
||
project_auto_create_threshold,
|
||
max(
|
||
0.0,
|
||
_safe_float(
|
||
config.get("project_uncertain_threshold"),
|
||
DEFAULT_PROJECT_UNCERTAIN_THRESHOLD,
|
||
),
|
||
),
|
||
)
|
||
return {
|
||
"cold_start_enabled": cold_start_enabled,
|
||
"signal_auto_enter_threshold": signal_auto_enter_threshold,
|
||
"project_auto_create_threshold": project_auto_create_threshold,
|
||
"project_uncertain_threshold": project_uncertain_threshold,
|
||
"chat_context_auto_link_enabled": chat_context_auto_link_enabled,
|
||
}
|
||
|
||
async def _load_active_projects(self) -> list[dict[str, Any]]:
|
||
rows = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.status != "inactive",
|
||
)
|
||
)
|
||
)
|
||
).scalars().all()
|
||
return [
|
||
{
|
||
"id": int(p.id),
|
||
"name": p.name,
|
||
"title": p.title or p.name,
|
||
"status": p.status,
|
||
"description": p.description or "",
|
||
}
|
||
for p in rows
|
||
]
|
||
|
||
async def _create_project_from_suggestion(
|
||
self,
|
||
suggestion: ProjectSuggestion,
|
||
*,
|
||
actor_user_id: Optional[int],
|
||
) -> Project:
|
||
owner_id = await self._resolve_user_id_by_name(suggestion.suggested_owner) or actor_user_id
|
||
project_name = (suggestion.suggested_name or "新项目").strip()[:255] or "新项目"
|
||
project_desc = (suggestion.suggested_description or "").strip()[:4000] or None
|
||
await ensure_project_id_sequence(self.db)
|
||
project = Project(
|
||
tenant_id=self.tenant_id,
|
||
name=project_name,
|
||
title=project_name,
|
||
description=project_desc,
|
||
owner_id=owner_id,
|
||
status="active",
|
||
domain_id=None,
|
||
assigned_role="ai_engineer",
|
||
signal_count=0,
|
||
)
|
||
self.db.add(project)
|
||
await self.db.flush()
|
||
await ensure_project_default_members(
|
||
self.db,
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project.id),
|
||
owner_id=int(project.owner_id) if project.owner_id is not None else None,
|
||
assigned_role=project.assigned_role,
|
||
)
|
||
return project
|
||
|
||
async def _ensure_signal_link(self, *, signal_id: int, project_id: int) -> None:
|
||
existed = (
|
||
await self.db.execute(
|
||
select(ProjectSignalLink.id).where(
|
||
and_(
|
||
ProjectSignalLink.tenant_id == self.tenant_id,
|
||
ProjectSignalLink.project_id == project_id,
|
||
ProjectSignalLink.signal_id == signal_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existed is not None:
|
||
return
|
||
row = ProjectSignalLink(
|
||
tenant_id=self.tenant_id,
|
||
project_id=int(project_id),
|
||
signal_id=int(signal_id),
|
||
link_type="feishu_signal",
|
||
relevance_score=0.8,
|
||
)
|
||
self.db.add(row)
|
||
|
||
async def _notify_project_created(self, *, project: Project) -> None:
|
||
if not project.owner_id:
|
||
return
|
||
project_name = project.title or project.name or f"项目#{project.id}"
|
||
await NotificationService(self.db).create_notification(
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(project.owner_id),
|
||
category="requirement",
|
||
title=f"创建项目:{project_name}",
|
||
content=f"AI 已根据飞书消息自动创建项目 [{project_name}],可直接进入 SSTC 看板。",
|
||
source="feishu",
|
||
entity_type="project",
|
||
entity_id=int(project.id),
|
||
target_url=f"/projects/{project.id}/board",
|
||
kind="auto_done",
|
||
priority="high",
|
||
status="auto_done",
|
||
recommended_action="view_result",
|
||
dedupe_key=f"feishu-project-auto:{int(project.id)}",
|
||
payload={
|
||
"message_class": "auto_done",
|
||
"summary": f"AI 已自动创建项目 {project_name}",
|
||
"ai_plan": "下一步可进入项目看板补齐 charter 与执行任务。",
|
||
"project_id": int(project.id),
|
||
"project_name": project_name,
|
||
"result_count": 1,
|
||
"impact_objects": [project_name],
|
||
"can_undo": True,
|
||
"undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(),
|
||
},
|
||
commit=False,
|
||
)
|
||
|
||
async def _notify_signal_auto_entered(self, *, project_id: int, signal: Signal) -> None:
|
||
project = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == project_id,
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if not project or not project.owner_id:
|
||
return
|
||
project_name = project.title or project.name or f"项目#{project.id}"
|
||
await NotificationService(self.db).create_notification(
|
||
tenant_id=self.tenant_id,
|
||
user_id=int(project.owner_id),
|
||
category="requirement",
|
||
title=f"信号已自动入库:{signal.title or '未命名信号'}",
|
||
content="高置信度信号已自动进入项目信号源,可直接继续生成宪章。",
|
||
source="feishu",
|
||
entity_type="signal",
|
||
entity_id=int(signal.id),
|
||
target_url=f"/projects/{project_id}/board",
|
||
kind="auto_done",
|
||
priority=str(signal.priority or "medium"),
|
||
status="auto_done",
|
||
recommended_action="view_result",
|
||
dedupe_key=f"feishu-signal-auto:{int(signal.id)}:{int(project_id)}",
|
||
payload={
|
||
"message_class": "auto_done",
|
||
"summary": f"AI 已把信号 {signal.title or f'#{signal.id}'} 自动入库到 {project_name}",
|
||
"ai_plan": "下一步可继续生成 charter、任务或通知负责人。",
|
||
"project_id": int(project_id),
|
||
"project_name": project_name,
|
||
"result_count": 1,
|
||
"impact_objects": [signal.title or f"信号#{signal.id}", project_name],
|
||
"can_undo": True,
|
||
"undo_deadline": _build_undo_deadline(_utc_now().replace(tzinfo=None)).isoformat(),
|
||
},
|
||
commit=False,
|
||
)
|
||
|
||
async def _load_project_name(self, project_id: Optional[int]) -> Optional[str]:
|
||
if project_id is None:
|
||
return None
|
||
project = (
|
||
await self.db.execute(
|
||
select(Project).where(
|
||
and_(
|
||
Project.tenant_id == self.tenant_id,
|
||
Project.id == int(project_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if project is None:
|
||
return None
|
||
return str(project.title or project.name or "").strip() or f"项目#{int(project_id)}"
|
||
|
||
@staticmethod
|
||
def _resolve_message_class(item: FeishuItem) -> str:
|
||
value = str(item.message_class or "").strip().lower()
|
||
if value in {"action_required", "auto_running", "auto_done", "info", "chat", "query", "ignored"}:
|
||
return value
|
||
route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status)
|
||
if route_status == ROUTE_STATUS_QUEUED:
|
||
return "action_required"
|
||
if route_status == ROUTE_STATUS_AUTO_RUNNING:
|
||
return "auto_running"
|
||
if route_status in {ROUTE_STATUS_AUTO_DONE, ROUTE_STATUS_UNDONE}:
|
||
return "auto_done"
|
||
return "info"
|
||
|
||
@staticmethod
|
||
def _resolve_auto_execute_state(item: FeishuItem) -> str:
|
||
value = str(item.auto_execute_state or "").strip().lower()
|
||
if value:
|
||
return value
|
||
route_status = FeishuSignalPipelineService._normalize_route_status(item.route_status)
|
||
if route_status == ROUTE_STATUS_AUTO_DONE:
|
||
return "done"
|
||
if route_status == ROUTE_STATUS_QUEUED:
|
||
return "blocked"
|
||
if route_status == ROUTE_STATUS_UNDONE:
|
||
return "undone"
|
||
if route_status == ROUTE_STATUS_FAILED:
|
||
return "failed"
|
||
return "idle"
|
||
|
||
async def _serialize_item(
|
||
self,
|
||
item: FeishuItem,
|
||
*,
|
||
include_raw: bool = False,
|
||
include_history: bool = False,
|
||
) -> dict[str, Any]:
|
||
result_payload = item.result_payload if isinstance(item.result_payload, dict) else {}
|
||
ai_output = _safe_dict(item.ai_output)
|
||
project_name = await self._load_project_name(_safe_int(item.matched_project_id))
|
||
row: dict[str, Any] = {
|
||
"id": str(item.id),
|
||
"feishu_message_id": item.feishu_message_id,
|
||
"level": item.level,
|
||
"card_type": item.card_type,
|
||
"route_status": self._normalize_route_status(item.route_status),
|
||
"project_score": item.project_score,
|
||
"signal_confidence": item.signal_confidence,
|
||
"level_confidence": item.level_confidence,
|
||
"level_reasoning": item.level_reasoning,
|
||
"matched_project_id": item.matched_project_id,
|
||
"project_name": project_name,
|
||
"sender": item.source_sender,
|
||
"created_at": item.created_at.isoformat() if item.created_at else None,
|
||
"processed_at": item.processed_at.isoformat() if item.processed_at else None,
|
||
"updated_at": item.updated_at.isoformat() if item.updated_at else None,
|
||
"decision_reason": item.decision_reason,
|
||
"blocking_reason": item.blocking_reason,
|
||
"safe_to_auto_execute": bool(item.safe_to_auto_execute),
|
||
"auto_execute_state": self._resolve_auto_execute_state(item),
|
||
"dedupe_key": item.dedupe_key,
|
||
"message_class": self._resolve_message_class(item),
|
||
"occurrence_count": int(item.occurrence_count or 1),
|
||
"next_step_text": item.next_step_text,
|
||
"undoable_until": item.undoable_until.isoformat() if item.undoable_until else None,
|
||
"result_payload": result_payload or None,
|
||
"display_kind": self._resolve_message_class(item),
|
||
"target_url": result_payload.get("target_url") if isinstance(result_payload, dict) else None,
|
||
"priority_rank": 0,
|
||
}
|
||
if not row["target_url"] and item.matched_project_id:
|
||
row["target_url"] = f"/projects/{int(item.matched_project_id)}/board"
|
||
|
||
if include_raw:
|
||
row["raw_message"] = item.raw_message
|
||
|
||
if (item.level or "").lower() == "project":
|
||
suggestion = await self._get_project_suggestion(item.id)
|
||
operation_payload = result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else {}
|
||
project_fields = {
|
||
"title": str(
|
||
(suggestion.suggested_name if suggestion is not None else None)
|
||
or operation_payload.get("title")
|
||
or ""
|
||
).strip(),
|
||
"description": str(
|
||
(suggestion.suggested_description if suggestion is not None else None)
|
||
or operation_payload.get("description")
|
||
or ""
|
||
).strip(),
|
||
"project_scope": str(result_payload.get("project_scope") or "").strip(),
|
||
"missing_fields": [],
|
||
"ready": False,
|
||
}
|
||
if (
|
||
self._looks_like_placeholder_project_text(project_fields["title"])
|
||
or self._looks_like_placeholder_project_text(project_fields["description"])
|
||
or self._looks_like_placeholder_project_text(project_fields["project_scope"])
|
||
):
|
||
project_fields = await self._maybe_prefill_project_fields_from_context(
|
||
message=str(item.raw_message or ""),
|
||
source_chat_id=str(item.source_chat_id or ""),
|
||
source_sender_open_id=str(item.source_sender_open_id or "") or None,
|
||
sender=str(item.source_sender or ""),
|
||
fields=project_fields,
|
||
)
|
||
if suggestion:
|
||
created_project_id = _safe_int(suggestion.created_project_id)
|
||
created_project_name = await self._load_project_name(created_project_id)
|
||
row["project_suggestion"] = {
|
||
"id": str(suggestion.id),
|
||
"suggested_name": suggestion.suggested_name,
|
||
"suggested_description": suggestion.suggested_description,
|
||
"suggested_owner": suggestion.suggested_owner,
|
||
"suggested_timeline": suggestion.suggested_timeline,
|
||
"confidence": suggestion.confidence,
|
||
"reasoning": _normalize_reasoning(suggestion.reasoning),
|
||
"status": suggestion.status,
|
||
"created_project_id": str(created_project_id) if created_project_id else None,
|
||
}
|
||
row["project_name"] = created_project_name or row["project_name"]
|
||
row["matched_project_id"] = created_project_id or row["matched_project_id"]
|
||
row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium"
|
||
row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3
|
||
if created_project_id and not row["target_url"]:
|
||
row["target_url"] = f"/projects/{created_project_id}/board"
|
||
row["title"] = self._build_project_preview_title(
|
||
title=str(project_fields.get("title") or ""),
|
||
description=str(project_fields.get("description") or ""),
|
||
project_scope=str(project_fields.get("project_scope") or ""),
|
||
)
|
||
row["summary"] = self._build_project_preview_summary(
|
||
description=str(project_fields.get("description") or ""),
|
||
project_scope=str(project_fields.get("project_scope") or ""),
|
||
blocking_reason=item.blocking_reason,
|
||
fallback_text=str(ai_output.get("normalized_text") or item.raw_message or ""),
|
||
)
|
||
row["detail_summary"] = row["summary"]
|
||
if not row.get("priority"):
|
||
row["priority"] = "high" if (item.project_score or 0) >= 0.85 else "medium"
|
||
row["priority_rank"] = 4 if (item.project_score or 0) >= 0.85 else 3
|
||
return row
|
||
|
||
if (item.level or "").lower() == "command":
|
||
operation_code = str(result_payload.get("operation_code") or "").strip()
|
||
row["title"] = (
|
||
str(result_payload.get("result_summary") or "").strip()
|
||
or operation_code
|
||
or "受控动作"
|
||
)
|
||
row["summary"] = str(item.blocking_reason or row["title"] or "").strip()
|
||
row["priority"] = "high" if row["display_kind"] == "action_required" else "medium"
|
||
row["priority_rank"] = 4 if row["display_kind"] == "action_required" else 2
|
||
if not row.get("next_step_text"):
|
||
row["next_step_text"] = self._build_pending_operation_next_step(operation_code=operation_code)
|
||
if result_payload:
|
||
row["command"] = {
|
||
"operation_code": operation_code or None,
|
||
"executor": str(result_payload.get("executor") or "").strip() or None,
|
||
"operation_payload": result_payload.get("operation_payload") if isinstance(result_payload.get("operation_payload"), dict) else None,
|
||
"operation_result": result_payload.get("operation_result") if isinstance(result_payload.get("operation_result"), dict) else None,
|
||
}
|
||
return row
|
||
|
||
signal = await self._get_signal_by_item(item.id)
|
||
if signal:
|
||
matched_project_id = _safe_int(signal.matched_project_id) or _safe_int(item.matched_project_id)
|
||
matched_project_name = await self._load_project_name(matched_project_id)
|
||
row["signal"] = {
|
||
"id": str(signal.id),
|
||
"title": signal.title,
|
||
"signal_type": signal.signal_type,
|
||
"priority": signal.priority,
|
||
"summary": signal.summary,
|
||
"reasoning": _normalize_reasoning(signal.reasoning),
|
||
"confidence": signal.confidence,
|
||
"recommended_action": signal.recommended_action,
|
||
"matched_project_id": signal.matched_project_id,
|
||
"status": signal.status,
|
||
}
|
||
row["title"] = signal.title or "未命名信号"
|
||
row["summary"] = signal.summary
|
||
row["priority"] = signal.priority
|
||
row["priority_rank"] = PROJECT_PRIORITY_ORDER.get(str(signal.priority or "").upper(), 1)
|
||
row["matched_project_id"] = matched_project_id
|
||
row["project_name"] = matched_project_name or row["project_name"]
|
||
if matched_project_id and not row["target_url"]:
|
||
row["target_url"] = f"/projects/{matched_project_id}/board"
|
||
if not row.get("next_step_text"):
|
||
if row["display_kind"] == "auto_done":
|
||
row["next_step_text"] = "下一步可继续生成 charter、任务或通知负责人。"
|
||
elif not matched_project_id:
|
||
row["next_step_text"] = "补齐项目归属后,AI 将自动继续后续链路。"
|
||
if not row.get("summary"):
|
||
row["summary"] = _compact_text(signal.content or item.raw_message, max_len=140)
|
||
if include_history:
|
||
history_rows = (
|
||
await self.db.execute(
|
||
select(SignalEdit)
|
||
.where(
|
||
and_(
|
||
SignalEdit.tenant_id == self.tenant_id,
|
||
SignalEdit.signal_id == int(signal.id),
|
||
)
|
||
)
|
||
.order_by(SignalEdit.edited_at.desc())
|
||
.limit(20)
|
||
)
|
||
).scalars().all()
|
||
row["edit_history"] = [
|
||
{
|
||
"field": h.field,
|
||
"old_value": h.old_value,
|
||
"new_value": h.new_value,
|
||
"edited_by": str(h.edited_by) if h.edited_by else None,
|
||
"edited_at": h.edited_at.isoformat() if h.edited_at else None,
|
||
}
|
||
for h in history_rows
|
||
]
|
||
return row
|
||
|
||
async def _resolve_user_id_by_name(self, name: Optional[str]) -> Optional[int]:
|
||
key = (name or "").strip()
|
||
if not key:
|
||
return None
|
||
user = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
func.lower(User.name) == key.lower(),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
if user:
|
||
return int(user.id)
|
||
fuzzy = (
|
||
await self.db.execute(
|
||
select(User).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
User.name.ilike(f"%{key}%"),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return int(fuzzy.id) if fuzzy else None
|
||
|
||
async def _fallback_user_id(self) -> Optional[int]:
|
||
user = (
|
||
await self.db.execute(
|
||
select(User.id).where(
|
||
and_(
|
||
User.tenant_id == self.tenant_id,
|
||
User.is_active.is_(True),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return int(user) if user else None
|
||
|
||
async def _resolve_chat_context_project(self, *, source_chat_id: str) -> dict[str, Any]:
|
||
chat_id = (source_chat_id or "").strip()
|
||
if not chat_id:
|
||
return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0}
|
||
|
||
recent_rows = (
|
||
await self.db.execute(
|
||
select(FeishuItem.matched_project_id)
|
||
.where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.source_chat_id == chat_id,
|
||
FeishuItem.matched_project_id.is_not(None),
|
||
FeishuItem.route_status.in_((ROUTE_STATUS_AUTO_DONE, "auto_processed", "confirmed")),
|
||
)
|
||
)
|
||
.order_by(FeishuItem.id.desc())
|
||
.limit(20)
|
||
)
|
||
).all()
|
||
project_ids = [int(row[0]) for row in recent_rows if row and row[0] is not None]
|
||
if not project_ids:
|
||
return {"project_id": None, "ambiguous": False, "candidate_ids": [], "top_ratio": 0.0}
|
||
|
||
unique_ids = set(project_ids)
|
||
candidate_ids = sorted(unique_ids, key=lambda pid: (-project_ids.count(pid), pid))
|
||
if len(unique_ids) == 1:
|
||
return {"project_id": int(project_ids[0]), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": 1.0}
|
||
|
||
top_pid = max(unique_ids, key=lambda pid: project_ids.count(pid))
|
||
top_count = project_ids.count(top_pid)
|
||
ratio = top_count / len(project_ids)
|
||
if top_count >= 2 and ratio >= 0.6:
|
||
return {"project_id": int(top_pid), "ambiguous": False, "candidate_ids": candidate_ids, "top_ratio": ratio}
|
||
return {"project_id": None, "ambiguous": True, "candidate_ids": candidate_ids, "top_ratio": ratio}
|
||
|
||
async def _infer_project_id_from_chat_context(self, *, source_chat_id: str) -> Optional[int]:
|
||
resolution = await self._resolve_chat_context_project(source_chat_id=source_chat_id)
|
||
return _safe_int(resolution.get("project_id"))
|
||
|
||
async def _get_item(self, item_id: int) -> Optional[FeishuItem]:
|
||
return (
|
||
await self.db.execute(
|
||
select(FeishuItem).where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.id == int(item_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _get_project_suggestion(self, item_id: int) -> Optional[ProjectSuggestion]:
|
||
return (
|
||
await self.db.execute(
|
||
select(ProjectSuggestion).where(
|
||
and_(
|
||
ProjectSuggestion.tenant_id == self.tenant_id,
|
||
ProjectSuggestion.feishu_item_id == int(item_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
async def _get_signal_by_item(self, item_id: int) -> Optional[Signal]:
|
||
return (
|
||
await self.db.execute(
|
||
select(Signal).where(
|
||
and_(
|
||
Signal.tenant_id == self.tenant_id,
|
||
Signal.feishu_item_id == int(item_id),
|
||
)
|
||
)
|
||
)
|
||
).scalar_one_or_none()
|
||
|
||
def _dedupe_queue_items(self, rows: list[FeishuItem]) -> list[FeishuItem]:
|
||
deduped: list[FeishuItem] = []
|
||
seen_signal_keys: set[str] = set()
|
||
aggregated: dict[str, FeishuItem] = {}
|
||
for row in rows:
|
||
explicit_key = str(row.dedupe_key or "").strip()
|
||
if explicit_key:
|
||
existing = aggregated.get(explicit_key)
|
||
if existing is None:
|
||
aggregated[explicit_key] = row
|
||
deduped.append(row)
|
||
else:
|
||
existing.occurrence_count = max(1, int(existing.occurrence_count or 1)) + max(
|
||
1, int(row.occurrence_count or 1)
|
||
)
|
||
continue
|
||
level = str(row.level or "").lower()
|
||
if level != "signal":
|
||
deduped.append(row)
|
||
continue
|
||
message_id = str(row.feishu_message_id or "").strip()
|
||
key = message_id or f"{str(row.raw_message or '')[:120]}::{str(row.source_sender or '')}"
|
||
if key in seen_signal_keys:
|
||
continue
|
||
seen_signal_keys.add(key)
|
||
deduped.append(row)
|
||
return deduped
|
||
|
||
@staticmethod
|
||
def _is_water_talk_message(message: str) -> bool:
|
||
content = str(message or "").strip().lower()
|
||
if not content:
|
||
return True
|
||
normalized = re.sub(r"\s+", "", content)
|
||
question_hints = (
|
||
"?",
|
||
"?",
|
||
"吗",
|
||
"怎么",
|
||
"如何",
|
||
"为什么",
|
||
"谁",
|
||
"多少",
|
||
"是否",
|
||
"有没有",
|
||
"几号",
|
||
"几点",
|
||
"周几",
|
||
)
|
||
water_keywords = (
|
||
"你是谁",
|
||
"你是誰",
|
||
"什么身份",
|
||
"身份确认",
|
||
"今天什么日期",
|
||
"今天几号",
|
||
"当前日期",
|
||
"日期疑问",
|
||
"询问日期",
|
||
"现在几点",
|
||
"几点了",
|
||
"今天周几",
|
||
"今天星期几",
|
||
"说话",
|
||
"在吗",
|
||
"在不在",
|
||
)
|
||
requirement_hints = (
|
||
"需求",
|
||
"优化",
|
||
"调整",
|
||
"修复",
|
||
"bug",
|
||
"故障",
|
||
"报错",
|
||
"项目",
|
||
"页面",
|
||
"接口",
|
||
"功能",
|
||
"排版",
|
||
"样式",
|
||
"任务",
|
||
"立项",
|
||
"上线",
|
||
"配置",
|
||
)
|
||
if any(k in normalized for k in water_keywords) and not any(h in normalized for h in requirement_hints):
|
||
return True
|
||
if any(k in normalized for k in question_hints):
|
||
return False
|
||
return False
|
||
|
||
@staticmethod
|
||
def _build_rag_conversation_key(
|
||
*,
|
||
source_chat_id: Optional[str],
|
||
source_sender_open_id: Optional[str],
|
||
sender: Optional[str],
|
||
) -> str:
|
||
chat = str(source_chat_id or "").strip()
|
||
if not chat:
|
||
return ""
|
||
actor = str(source_sender_open_id or "").strip()
|
||
if not actor:
|
||
actor = str(sender or "").strip().lower()
|
||
actor = re.sub(r"\s+", "", actor)
|
||
actor = actor[:96] if actor else "unknown"
|
||
return f"feishu:{chat}:{actor}"
|
||
|
||
@staticmethod
|
||
def _is_requirement_like_question(message: str) -> bool:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return False
|
||
normalized = re.sub(r"\s+", "", text.lower())
|
||
has_question = (
|
||
"?" in normalized
|
||
or "?" in normalized
|
||
or any(
|
||
cue in normalized
|
||
for cue in ("吗", "是否", "是不是", "要不要", "能不能", "可不可以", "需不需要", "有没有必要")
|
||
)
|
||
)
|
||
if not has_question:
|
||
return False
|
||
if INDEPENDENT_REQUIREMENT_RE.search(normalized):
|
||
return True
|
||
if SIMPLE_SIGNAL_REQUEST_RE.search(normalized):
|
||
return True
|
||
has_verb = bool(QUESTION_REQUIREMENT_VERB_RE.search(normalized))
|
||
has_object = bool(QUESTION_REQUIREMENT_OBJECT_RE.search(normalized))
|
||
return has_verb and has_object
|
||
|
||
@staticmethod
|
||
def _extract_requirement_statement_from_question(message: str) -> str:
|
||
text = str(message or "").strip()
|
||
if not text:
|
||
return ""
|
||
candidate = re.sub(r"^(请问|问下|想问下|我想问下|麻烦问下|请教下)\s*", "", text, flags=re.IGNORECASE).strip()
|
||
candidate = re.sub(r"\s*[??!!。\.]+\s*$", "", candidate).strip()
|
||
candidate = re.sub(r"(吗|呢|呀|吧)\s*$", "", candidate).strip()
|
||
replacements = (
|
||
(r"能不能", "需要"),
|
||
(r"可不可以", "需要"),
|
||
(r"要不要", "需要"),
|
||
(r"需不需要", "需要"),
|
||
(r"有没有必要", "需要"),
|
||
(r"是不是", ""),
|
||
(r"是否", ""),
|
||
)
|
||
for pattern, repl in replacements:
|
||
candidate = re.sub(pattern, repl, candidate, flags=re.IGNORECASE)
|
||
candidate = re.sub(r"\s+", " ", candidate).strip(" ,,::")
|
||
return candidate or text
|
||
|
||
async def _has_processed_message(self, feishu_message_id: str) -> bool:
|
||
key = (feishu_message_id or "").strip()
|
||
if not key:
|
||
return False
|
||
existed_in_items = (
|
||
await self.db.execute(
|
||
select(FeishuItem.id).where(
|
||
and_(
|
||
FeishuItem.tenant_id == self.tenant_id,
|
||
FeishuItem.feishu_message_id == key,
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
if existed_in_items is not None:
|
||
return True
|
||
existed = (
|
||
await self.db.execute(
|
||
select(AIProcessingLog.id).where(
|
||
and_(
|
||
AIProcessingLog.tenant_id == self.tenant_id,
|
||
AIProcessingLog.feishu_message_id == key,
|
||
or_(
|
||
AIProcessingLog.level.is_(None),
|
||
AIProcessingLog.level != "ingress",
|
||
),
|
||
or_(
|
||
AIProcessingLog.routing_decision.is_(None),
|
||
AIProcessingLog.routing_decision != "event_received",
|
||
),
|
||
)
|
||
).limit(1)
|
||
)
|
||
).scalar_one_or_none()
|
||
return existed is not None
|
||
|
||
async def _acquire_message_lock(self, feishu_message_id: str) -> None:
|
||
key = (feishu_message_id or "").strip()
|
||
if not key:
|
||
return
|
||
try:
|
||
bind = self.db.get_bind()
|
||
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "") or "").lower()
|
||
except Exception:
|
||
dialect_name = ""
|
||
if dialect_name not in {"postgresql", "postgres"}:
|
||
return
|
||
try:
|
||
await self.db.execute(
|
||
text("SELECT pg_advisory_xact_lock(hashtext(:key))"),
|
||
{"key": key},
|
||
)
|
||
except Exception as exc:
|
||
logger.warning(f"feishu_pipeline_message_lock_failed: key={key[:32]}, error={exc}")
|
||
|
||
async def _write_processing_log(
|
||
self,
|
||
*,
|
||
feishu_message_id: Optional[str],
|
||
feishu_item_id: Optional[int],
|
||
raw_message: Optional[str],
|
||
ai_raw_output: Optional[str],
|
||
project_score: Optional[float],
|
||
signal_confidence: Optional[float],
|
||
level: Optional[str],
|
||
routing_decision: str,
|
||
routing_reason: Optional[str],
|
||
reasoning_bullets: Optional[list[str]],
|
||
processing_status: str,
|
||
error_message: Optional[str],
|
||
duration_ms: Optional[int],
|
||
normalized_text: Optional[str] = None,
|
||
route: Optional[str] = None,
|
||
intent: Optional[str] = None,
|
||
executor: Optional[str] = None,
|
||
operation_code: Optional[str] = None,
|
||
operation_result: Optional[dict[str, Any]] = None,
|
||
reply_source: Optional[str] = None,
|
||
) -> None:
|
||
normalized_route = (route or routing_decision or "")[:80] or None
|
||
normalized_intent = (intent or level or "")[:40] or None
|
||
normalized_executor = (executor or ("operation_executor" if operation_code else "router"))[:40] or None
|
||
normalized_reply_source = (
|
||
reply_source
|
||
or ("db_commit" if operation_code else "deterministic_reply")
|
||
)
|
||
self.db.add(
|
||
AIProcessingLog(
|
||
tenant_id=self.tenant_id,
|
||
feishu_message_id=(feishu_message_id or "")[:128] or None,
|
||
feishu_item_id=int(feishu_item_id) if feishu_item_id is not None else None,
|
||
raw_message=(raw_message or "")[:8000] or None,
|
||
ai_raw_output=(ai_raw_output or "")[:120000] or None,
|
||
project_score=project_score,
|
||
signal_confidence=signal_confidence,
|
||
level=(level or "")[:20] or None,
|
||
normalized_text=(normalized_text or raw_message or "")[:8000] or None,
|
||
route=normalized_route,
|
||
intent=normalized_intent,
|
||
executor=normalized_executor,
|
||
operation_code=(operation_code or "")[:80] or None,
|
||
operation_result=operation_result if isinstance(operation_result, dict) else None,
|
||
reply_source=(normalized_reply_source or "")[:40] or None,
|
||
routing_decision=(routing_decision or ROUTING_FAILED)[:64],
|
||
routing_reason=(routing_reason or "")[:1000] or None,
|
||
reasoning_bullets=[str(x).strip()[:120] for x in (reasoning_bullets or [])[:3]],
|
||
processing_status=(processing_status or "success")[:20],
|
||
error_message=(error_message or "")[:4000] or None,
|
||
duration_ms=max(0, int(duration_ms)) if duration_ms is not None else None,
|
||
)
|
||
)
|
||
|
||
def _build_project_routing_log(
|
||
self,
|
||
*,
|
||
project_score: float,
|
||
cold_start_enabled: bool,
|
||
project_auto_create_threshold: float,
|
||
project_uncertain_threshold: float,
|
||
) -> tuple[str, str, list[str]]:
|
||
_ = project_auto_create_threshold
|
||
if cold_start_enabled:
|
||
return (
|
||
ROUTING_PENDING_COLD_START,
|
||
"当前处于冷启动期,项目候选会统一进入待处理队列。",
|
||
[
|
||
"冷启动期开启,自动创建策略暂缓。",
|
||
f"project_score={project_score:.2f},仅用于排序参考。",
|
||
"等待人工确认后再执行创建动作。",
|
||
],
|
||
)
|
||
if project_score >= project_uncertain_threshold:
|
||
return (
|
||
ROUTING_PENDING_UNCERTAIN_PROJECT,
|
||
"项目分数较高,但建项已收口到待决策链,不再自动创建。",
|
||
[
|
||
f"project_score={project_score:.2f},高于 {project_uncertain_threshold:.2f}。",
|
||
"即使意图强,也必须等待受控 project.create。",
|
||
"已进入待处理队列等待确认。",
|
||
],
|
||
)
|
||
return (
|
||
ROUTING_PENDING_LOW_CONFIDENCE,
|
||
"项目分数偏低,暂不自动创建,进入待处理队列。",
|
||
[
|
||
f"project_score={project_score:.2f},低于 {project_uncertain_threshold:.2f}。",
|
||
"消息更接近执行信号而非完整立项需求。",
|
||
"需要人工补充信息后再决策。",
|
||
],
|
||
)
|
||
|
||
def _build_signal_routing_log(
|
||
self,
|
||
*,
|
||
confidence: float,
|
||
cold_start_enabled: bool,
|
||
matched_project_id: Optional[int],
|
||
signal_auto_enter_threshold: float,
|
||
) -> tuple[str, str, list[str]]:
|
||
if cold_start_enabled:
|
||
return (
|
||
ROUTING_PENDING_COLD_START,
|
||
"当前处于冷启动期,信号统一进入待处理队列。",
|
||
[
|
||
"冷启动期开启,自动入库策略暂缓。",
|
||
f"signal_confidence={confidence:.2f},仅用于排序参考。",
|
||
"等待人工审核后再入库。",
|
||
],
|
||
)
|
||
if confidence < signal_auto_enter_threshold:
|
||
return (
|
||
ROUTING_PENDING_LOW_CONFIDENCE,
|
||
"信号置信度低于自动入库阈值,已进入待处理队列。",
|
||
[
|
||
f"signal_confidence={confidence:.2f},低于 {signal_auto_enter_threshold:.2f} 阈值。",
|
||
"当前信息不足以支持自动决策。",
|
||
"需要人工确认后再执行后续动作。",
|
||
],
|
||
)
|
||
if not matched_project_id:
|
||
return (
|
||
ROUTING_PENDING_UNCERTAIN_PROJECT,
|
||
"置信度达标但项目归属不确定,转人工确认。",
|
||
[
|
||
f"signal_confidence={confidence:.2f},达到自动判定线。",
|
||
"未识别到明确项目归属。",
|
||
"已进入待处理队列等待项目确认。",
|
||
],
|
||
)
|
||
return (
|
||
ROUTING_AUTO_ENTER_SIGNAL,
|
||
"信号置信度达标且项目归属明确,系统已自动入库。",
|
||
[
|
||
f"signal_confidence={confidence:.2f},高于 {signal_auto_enter_threshold:.2f} 阈值。",
|
||
f"已匹配项目 #{int(matched_project_id)}。",
|
||
"已自动建立信号关联并发送通知。",
|
||
],
|
||
)
|
||
|
||
@staticmethod
|
||
def _normalize_recommended_action(value: Any) -> str:
|
||
action = str(value or "enter_only").strip().lower()
|
||
if action not in {"enter_only", "create_charter", "trigger_alert"}:
|
||
return "enter_only"
|
||
return action
|
||
|
||
@staticmethod
|
||
def _normalize_signal_type(value: Any) -> str:
|
||
signal_type = str(value or "新需求").strip()
|
||
if signal_type in SIGNAL_TYPE_OPTIONS:
|
||
return signal_type
|
||
return "新需求"
|
||
|
||
@staticmethod
|
||
def _to_signal_priority(value: str) -> str:
|
||
key = str(value or "").strip().upper()
|
||
if key in {"P0", "HIGH"}:
|
||
return "high"
|
||
if key in {"P1", "MEDIUM"}:
|
||
return "medium"
|
||
if key in {"P2", "LOW"}:
|
||
return "low"
|
||
if key == "P3":
|
||
return "low"
|
||
return "medium"
|
||
|
||
def _should_promote_signal_to_project_candidate(
|
||
self,
|
||
*,
|
||
content: str,
|
||
signal_info: dict[str, Any],
|
||
confidence: float,
|
||
signal_auto_enter_threshold: float,
|
||
simple_signal_request: bool,
|
||
) -> bool:
|
||
_ = (content, signal_info, confidence, signal_auto_enter_threshold, simple_signal_request)
|
||
# 项目创建只能通过 intent gate:create_project + 受控 operation executor 落库;
|
||
# 不再把 signal 自由升级为 project candidate。
|
||
return False
|
||
|
||
@staticmethod
|
||
def _is_simple_signal_source_request(message: str) -> bool:
|
||
text = re.sub(r"\s+", "", str(message or ""))
|
||
if len(text) < 6:
|
||
return False
|
||
if PROJECT_COMPLEXITY_RE.search(text):
|
||
return False
|
||
if SIMPLE_SIGNAL_REQUEST_RE.search(text):
|
||
return True
|
||
action_tokens = ("新增", "增加", "实现", "支持", "修复", "优化", "接入", "改造", "调整")
|
||
object_tokens = ("功能", "能力", "页面", "接口", "流程", "按钮", "提醒", "识别", "字段", "规则", "逻辑")
|
||
return (
|
||
len(text) <= 180
|
||
and any(token in text for token in action_tokens)
|
||
and any(token in text for token in object_tokens)
|
||
)
|