"""飞书消息分流与决策队列服务。"""
from __future__ import annotations
import asyncio
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,
TruthNode,
User,
)
from .llm import LLMService
from .adaptive_rag_service import AdaptiveRAGService
from .ai_agent_kernel import (
build_decision_trace,
gate_execution as kernel_gate_execution,
resolve_correction as kernel_resolve_correction,
)
from .context_cache import context_cache_service
from .notification_service import NotificationService
from .operation_log_service import log_operation
from .project_command_utils import (
extract_project_create_description,
extract_project_create_title,
normalize_project_title_candidate,
)
from .sstc_service import ALIGNED_STATUSES
from .task_persistence import build_task_create_draft, persist_task
from ..utils.project_members import ensure_project_default_members
from ..utils.sequence import ensure_project_id_sequence
from ..utils.timezone import CN_TZ, now_cn_aware
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,
)
LEGACY_COMMAND_PROJECT_CREATE_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个).{0,6}项目",
re.IGNORECASE,
)
LEGACY_COMMAND_VERB_RE = re.compile(
r"(创建|新建|建立|开个|开一个|建个|建一个|做个|做一个|修改|更新|改成|改为|设为|设成|分配|指派|归档|重开|重新打开|恢复|标记完成|完成|提醒|通知|调整工时|调整工作量)",
re.IGNORECASE,
)
LEGACY_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,
)
QUERY_INTENT_RE = re.compile(
r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)",
re.IGNORECASE,
)
FEISHU_BOT_MENTION_RE = re.compile(r"(?:]*>[^<]*|@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_LEGACY_COMMAND_EXECUTED = "legacy_command_executed"
ROUTING_LEGACY_COMMAND_BLOCKED = "legacy_command_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 now_cn_aware()
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(CN_TZ).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(LEGACY_COMMAND_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,
}
explicit_project_request = self._looks_like_project_create_request(text)
explicit_task_request = self._looks_like_task_create_request(text)
explicit_external_request = self._looks_like_external_connect_request(text)
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}")
correction_decision = kernel_resolve_correction(text)
if correction_decision.action != "none" and not (
explicit_project_request or explicit_task_request or explicit_external_request
):
decision_trace = build_decision_trace(
intent=INTENT_CLARIFICATION_REQUIRED,
confidence=correction_decision.confidence,
route_tier="rule",
route_function="_resolve_intent_gate",
gate_action="pending_manual",
gate_code="correction_review",
correction_action=correction_decision.action,
correction_keywords=correction_decision.matched_keywords,
complexity_reasons=[],
)
return {
"intent": INTENT_CLARIFICATION_REQUIRED,
"canonical_action": "clarify",
"clarification_template": "检测到修正/回滚语义,已进入待确认队列。请明确目标对象后继续执行。",
"risk_level": "high",
"fields": {},
"correction_match": correction_match,
"decision_trace": decision_trace,
}
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
and not (explicit_project_request or explicit_task_request or explicit_external_request)
):
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 explicit_project_request:
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 explicit_task_request:
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 explicit_external_request:
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_query_like_question(text):
return {
"intent": INTENT_QUERY,
"canonical_action": None,
"clarification_template": "",
"risk_level": "low",
"fields": {},
"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 = 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 = 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"^]*>[^<]*\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 = normalize_project_title_candidate(candidate)
if normalized.endswith(("方向", "规划", "创新")):
normalized = 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 = 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 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 = 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 = 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": "explicit_service",
"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": "explicit_service",
"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": "explicit_service",
"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(CN_TZ).replace(tzinfo=None)
item = FeishuItem(
tenant_id=self.tenant_id,
feishu_message_id=feishu_message_id,
project_score=1.0 if matched_project_id is not None else None,
signal_confidence=0.0 if matched_project_id is not None else None,
raw_message=message[:4000],
split_from_item_id=None,
level="command",
level_confidence=1.0,
level_reasoning=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": "explicit_service",
"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]:
logger.info(
"operation_started",
tenant_id=self.tenant_id,
trace_id=trace_id,
operation_code=operation_code,
payload=payload,
executor="explicit_service",
)
if operation_code == "project.create":
result = await self._commit_project_create(
operator_user=operator_user,
payload=payload,
trace_id=trace_id,
)
elif operation_code == "task.create":
result = await self._commit_task_create(
operator_user=operator_user,
payload=payload,
trace_id=trace_id,
)
else:
raise ValueError("operation_not_supported")
execution = self._wrap_committed_operation(operation_code=operation_code, payload=payload, result=result)
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="explicit_service",
)
return execution
@staticmethod
def _wrap_committed_operation(*, operation_code: str, payload: dict[str, Any], result: dict[str, Any]) -> dict[str, Any]:
status = str(result.get("status") or "").strip().lower()
return {
"dry_run": False,
"requested_count": 1,
"applied_count": 1 if status == "applied" else 0,
"skipped_count": 1 if status == "skipped" else 0,
"failed_count": 1 if status == "failed" else 0,
"results": [
{
"index": 1,
"operation_code": operation_code,
"payload": dict(payload),
**result,
}
],
}
async def _commit_project_create(
self,
*,
operator_user: User,
payload: dict[str, Any],
trace_id: str,
) -> dict[str, Any]:
operator_user_id = _safe_int(getattr(operator_user, "id", None))
if operator_user_id is None:
raise ValueError("operator_not_found")
title = normalize_project_title_candidate(str(payload.get("title") or ""))
if not title:
raise ValueError("project title required")
description = str(payload.get("description") or "").strip()[:4000] or None
existing = (
await self.db.execute(
select(Project).where(
and_(
Project.tenant_id == self.tenant_id,
or_(func.lower(Project.title) == title.lower(), func.lower(Project.name) == title.lower()),
Project.status != "inactive",
)
).limit(1)
)
).scalar_one_or_none()
if existing is not None:
return {
"status": "skipped",
"message": "project_already_exists",
"entity_type": "project",
"entity_id": str(existing.id),
"result": {
"project_id": int(existing.id),
"title": str(existing.title or existing.name or "").strip(),
"status": str(existing.status or "").strip(),
},
}
await ensure_project_id_sequence(self.db)
project = Project(
tenant_id=self.tenant_id,
name=title,
title=title,
description=description,
owner_id=operator_user_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,
)
await log_operation(
self.db,
tenant_id=self.tenant_id,
user_id=operator_user_id,
project_id=int(project.id),
entity_type="project",
entity_id=str(project.id),
action="project.create",
before_data=None,
after_data={
"title": project.title,
"status": project.status,
"owner_id": int(project.owner_id) if project.owner_id is not None else None,
},
remark=trace_id[:1000],
)
await self.db.commit()
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
return {
"status": "applied",
"message": f"project created #{int(project.id)}",
"entity_type": "project",
"entity_id": str(project.id),
"result": {
"project_id": int(project.id),
"title": str(project.title or project.name or "").strip(),
"status": str(project.status or "").strip(),
},
}
async def _commit_task_create(
self,
*,
operator_user: User,
payload: dict[str, Any],
trace_id: str,
) -> dict[str, Any]:
operator_user_id = _safe_int(getattr(operator_user, "id", None))
if operator_user_id is None:
raise ValueError("operator_not_found")
project_id = _safe_int(payload.get("project_id"))
if project_id is None:
raise ValueError("project_context_missing")
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:
raise ValueError("project_context_missing")
charter = (
await self.db.execute(
select(TruthNode).where(
and_(
TruthNode.tenant_id == self.tenant_id,
TruthNode.project_id == int(project_id),
TruthNode.node_type == "charter",
TruthNode.status.in_(tuple(ALIGNED_STATUSES)),
)
).order_by(TruthNode.updated_at.desc(), TruthNode.id.desc())
)
).scalars().first()
if charter is None:
raise ValueError("aligned charter required before task.create")
task = await persist_task(
self.db,
tenant_id=self.tenant_id,
truth_node_id=int(charter.id),
project_id=int(project_id),
draft=build_task_create_draft(
title=str(payload.get("title") or ""),
description=str(payload.get("description") or "").strip() or None,
priority=payload.get("priority"),
),
actor_user_id=operator_user_id,
action="task.create",
remark=trace_id[:1000],
commit=True,
refresh_workload=False,
)
await context_cache_service.invalidate_system_context(tenant_id=self.tenant_id)
self._sync_task_rag(task=task)
return {
"status": "applied",
"message": f"task created #{int(task.id)}",
"entity_type": "task",
"entity_id": str(task.id),
"result": {
"task_id": int(task.id),
"title": str(task.title or "").strip(),
"project_id": int(project_id),
},
}
def _sync_task_rag(self, *, task: Any) -> None:
try:
content = (
(str(getattr(task, "description", "") or ""))
+ f"\n状态: {str(getattr(task, 'status', '') or '')}"
+ f"\n负责人: {int(getattr(task, 'assignee_id', 0) or 0) if getattr(task, 'assignee_id', None) else '未分配'}"
)
asyncio.create_task(
AdaptiveRAGService.upsert_pmflow_record_async(
tenant_id=self.tenant_id,
record_type="task",
record_id=int(task.id),
project_id=int(task.project_id),
title=str(task.title or "任务"),
content=content,
)
)
except Exception:
return
@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=CN_TZ)
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="explicit_service",
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="explicit_service",
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_operation_commit_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="explicit_service",
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=CN_TZ)
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="explicit_service",
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="explicit_service",
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_operation_commit_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="explicit_service",
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=CN_TZ)
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_block_legacy_command_request(
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=CN_TZ)
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_query_like_question(message):
return True
if cls._is_legacy_business_command(text):
return False
if cls._looks_like_project_lookup_question(text):
return True
return bool(OBVIOUS_QUESTION_RE.search(text))
@classmethod
def _is_query_like_question(cls, message: str) -> bool:
text = str(message or "").strip()
if not text:
return False
normalized = re.sub(r"\s+", "", text.lower())
has_question_structure = bool(OBVIOUS_QUESTION_RE.search(text) or QUERY_INTENT_RE.search(text))
if not has_question_structure:
return False
has_operation_verb = bool(LEGACY_COMMAND_VERB_RE.search(text))
imperative_tokens = ("帮我", "请", "麻烦", "直接", "立刻", "马上", "执行", "处理", "安排")
has_imperative = any(token in normalized for token in imperative_tokens)
return not has_operation_verb and not has_imperative
@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(LEGACY_COMMAND_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_legacy_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_legacy_business_command(cls, message: str) -> bool:
text = str(message or "").strip()
if not text:
return False
if cls._is_query_like_question(message):
return False
if cls._looks_like_project_create_command(text):
return True
return bool(LEGACY_COMMAND_VERB_RE.search(text) and LEGACY_COMMAND_OBJECT_RE.search(text))
async def _maybe_block_legacy_command_request(
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_legacy_business_command(message):
return None
_ = sender, source_chat_id, source_sender_open_id, captured_at, inferred_project_id, projects
return await self._build_legacy_command_blocked_capture(
message=message,
feishu_message_id=feishu_message_id,
duration_ms=int((time.perf_counter() - process_started_at) * 1000),
reason="旧 command-first 规划链路已下线,请改用 GLM 分析后直接调用显式接口。",
)
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_legacy_command_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_LEGACY_COMMAND_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(CN_TZ).replace(tzinfo=None)
item = FeishuItem(
tenant_id=self.tenant_id,
feishu_message_id=feishu_message_id,
project_score=1.0 if matched_project_id is not None else None,
signal_confidence=0.0 if matched_project_id is not None else None,
raw_message=message[:4000],
split_from_item_id=None,
level="command",
level_confidence=1.0,
level_reasoning="legacy_command_first_execution",
card_type="command",
route_status=self._normalize_route_status(route_status or ROUTE_STATUS_AUTO_DONE)[:30],
matched_project_id=int(matched_project_id) if matched_project_id is not None else None,
source_chat_id=(source_chat_id or "")[:128] or None,
source_sender=(sender or "")[:120] or None,
source_sender_open_id=(source_sender_open_id or "")[:128] or None,
source_occurred_at=occurred_at,
ai_output=json.dumps({"command_result": command_result}, ensure_ascii=False)[:8000],
message_class="auto_done",
safe_to_auto_execute=True,
auto_execute_state="done",
result_payload={
"intent": "command",
"executor": "legacy_command_planner",
"operation_code": None,
"result_summary": self._build_operation_commit_reply(command_result),
"command_result": command_result,
"project_id": int(matched_project_id) if matched_project_id is not None else None,
"target_url": f"/projects/{int(matched_project_id)}/board" if matched_project_id is not None else None,
},
queue_visible=False,
processed_at=_utc_now().replace(tzinfo=None),
)
self.db.add(item)
await self.db.flush()
return int(item.id)
@staticmethod
def _resolve_command_route_status(command_result: dict[str, Any]) -> str:
status = str(
command_result.get("command_status")
or command_result.get("run_status")
or command_result.get("execution_status")
or ""
).strip().lower()
if status in {"completed", "partial", "planned"}:
return ROUTE_STATUS_AUTO_DONE
return ROUTE_STATUS_FAILED
@staticmethod
def _collect_command_project_ids(command_result: dict[str, Any]) -> list[int]:
project_ids: set[int] = set()
project_info = command_result.get("project") if isinstance(command_result, dict) else None
if isinstance(project_info, dict):
project_id = project_info.get("id")
try:
if project_id is not None and int(project_id) > 0:
project_ids.add(int(project_id))
except (TypeError, ValueError):
pass
execution = command_result.get("execution") if isinstance(command_result, dict) else None
results = execution.get("results") if isinstance(execution, dict) else None
if isinstance(results, list):
for row in results:
if not isinstance(row, dict):
continue
if str(row.get("entity_type") or "").strip() != "project":
continue
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
candidate = payload.get("project_id") or row.get("entity_id")
try:
if candidate is not None and int(candidate) > 0:
project_ids.add(int(candidate))
except (TypeError, ValueError):
continue
return sorted(project_ids)
@staticmethod
def _extract_created_project_id(command_result: dict[str, Any]) -> Optional[int]:
execution = command_result.get("execution") if isinstance(command_result, dict) else None
results = execution.get("results") if isinstance(execution, dict) else None
if not isinstance(results, list):
return None
for row in results:
if not isinstance(row, dict):
continue
if str(row.get("operation_code") or "").strip() != "project.create":
continue
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
candidate = payload.get("project_id") or row.get("entity_id")
try:
if candidate is not None and int(candidate) > 0:
return int(candidate)
except (TypeError, ValueError):
continue
return None
@classmethod
def _build_operation_commit_reply(cls, command_result: dict[str, Any]) -> str:
execution = command_result.get("execution") if isinstance(command_result, dict) else {}
results = execution.get("results") if isinstance(execution, dict) else []
if not isinstance(results, list) or not results:
return "未执行:未识别到可安全落库的业务操作。"
fragments = [cls._format_operation_result_fragment(row) for row in results[:3] if isinstance(row, dict)]
fragments = [item for item in fragments if item]
applied_count = int((execution or {}).get("applied_count") or 0)
skipped_count = int((execution or {}).get("skipped_count") or 0)
failed_count = int((execution or {}).get("failed_count") or 0)
if applied_count <= 0 and skipped_count <= 0 and failed_count > 0:
first_failed = next((row for row in results if isinstance(row, dict) and str(row.get("status") or "") == "failed"), {})
message = str(first_failed.get("message") or "执行失败").strip()
return f"未执行:{message}"
if not fragments:
return "已接收指令,但未生成可展示的执行回执。"
prefix = "未重复执行:" if applied_count <= 0 and skipped_count > 0 else "已执行完成:"
reply = f"{prefix}{';'.join(fragments[:2])}"
if failed_count > 0 and applied_count > 0:
reply = f"{reply};另有 {failed_count} 项失败"
return reply + "。"
@staticmethod
def _format_operation_result_fragment(row: dict[str, Any]) -> str:
operation_code = str(row.get("operation_code") or "").strip()
status = str(row.get("status") or "").strip().lower()
message = str(row.get("message") or "").strip()
payload = row.get("result") if isinstance(row.get("result"), dict) else {}
if status == "failed":
return ""
if operation_code.startswith("project."):
project_id = payload.get("project_id") or row.get("entity_id")
title = str(payload.get("title") or payload.get("project_title") or "").strip()
status_text = str(payload.get("status") or "").strip()
label = f"项目#{project_id}"
if title:
label = f"{label}「{title}」"
if operation_code == "project.create" and status == "applied":
return f"{label}已创建,状态 {status_text or 'active'}"
if operation_code == "project.create" and status == "skipped":
return f"{label}已存在,状态 {status_text or 'active'}"
if status_text:
return f"{label}状态已更新为 {status_text}"
return f"{label}{message or '已处理'}"
if operation_code == "task.create":
return f"任务#{row.get('entity_id') or ''}已创建".strip()
if operation_code == "task.assign":
return f"任务#{row.get('entity_id') or ''}已分配".strip()
if operation_code == "task.prioritize":
return f"任务#{row.get('entity_id') or ''}优先级已更新".strip()
if operation_code == "task.status_transition":
return f"任务#{row.get('entity_id') or ''}状态已更新".strip()
if operation_code == "personnel.workload_update":
return f"成员#{row.get('entity_id') or ''}工时已更新".strip()
if operation_code == "notification.create":
return f"通知#{row.get('entity_id') or ''}已创建".strip()
return message or operation_code
@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 北京时间,避免 asyncpg aware/naive 报错。
today_start = _utc_now().astimezone(CN_TZ).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": "explicit_service",
"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": "explicit_service",
"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
gate = kernel_gate_execution(
confidence=float(confidence or 0.0),
risk_level=("medium" if matched_project_id else "high"),
correction_action="none",
)
decision_trace = build_decision_trace(
intent=INTENT_SIGNAL_INTAKE,
confidence=float(confidence or 0.0),
route_tier="rule",
route_function="_route_signal_item",
gate_action=gate.action,
gate_code=gate.gate_code,
correction_action="none",
correction_keywords=[],
complexity_reasons=[],
)
if cold_start_enabled or gate.action != "auto_execute" 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 = "保守自动化已拦截自动入库,避免早期误关联。"
elif gate.action == "reject":
item.blocking_reason = gate.reason
item.next_step_text = "请补充更明确的执行对象后重试,或人工确认继续处理。"
item.decision_reason = "共享决策内核判定置信度不足,自动执行被拒绝。"
else:
item.blocking_reason = gate.reason or "当前置信度不足以自动入库,需要你拍板后再继续。"
item.next_step_text = "确认后,AI 将把信号入库并进入后续生成流程。"
item.decision_reason = "信号虽然可执行,但归属判断仍存在不确定性。"
item.result_payload = {
"result_summary": "信号已进入待确认队列。",
"result_count": 0,
"impact_objects": [signal.title or f"信号#{signal.id}"],
"decision_trace": decision_trace,
}
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),
"decision_trace": decision_trace,
}
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 ("explicit_service" 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)
)