分析 world model 并行查询

This commit is contained in:
Yuuuu 2026-03-11 00:11:26 +08:00
parent cf638e0a62
commit 7f837e7019
8 changed files with 726 additions and 170 deletions

View File

@ -39,6 +39,8 @@ backend/
│ ├── config/ # 配置与 prompt │ ├── config/ # 配置与 prompt
│ └── utils/ # 工具函数 │ └── utils/ # 工具函数
├── alembic/ # 数据库迁移 ├── alembic/ # 数据库迁移
│ └── versions/
│ └── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── Dockerfile ├── Dockerfile
└── tests/ └── tests/
@ -129,6 +131,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/` - 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层 - 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState` - 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
## 9. 新增代码的参考模式 ## 9. 新增代码的参考模式
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构 - 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
@ -139,6 +142,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
## 10. 幂等与事务约束 ## 10. 幂等与事务约束
- 所有入站事件必须经过 `ingest_events` 幂等去重 - 所有入站事件必须经过 `ingest_events` 幂等去重
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送 - 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
- 飞书回复发送幂等:`reply_hash = MD5(source_message_id + reply_text)`,同租户下 `status=sent` 的相同哈希不得重复发送
- 跨表写操作必须在同一个数据库事务内完成 - 跨表写操作必须在同一个数据库事务内完成
## 11. 已知 BOUNDARY(不要幻构行为) ## 11. 已知 BOUNDARY(不要幻构行为)

View File

@ -0,0 +1,111 @@
"""add feishu outbox reply hash
Revision ID: add_feishu_outbox_reply_hash
Revises: add_ai_pmo_simulation_field
Create Date: 2026-03-10
"""
from __future__ import annotations
from typing import Any
from alembic import op
import sqlalchemy as sa
revision = "add_feishu_outbox_reply_hash"
down_revision = "add_ai_pmo_simulation_field"
branch_labels = None
depends_on = None
def _table_exists(bind, table_name: str) -> bool:
return sa.inspect(bind).has_table(table_name)
def _column_exists(bind, table_name: str, column_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(col.get("name") == column_name for col in inspector.get_columns(table_name))
def _index_exists(bind, table_name: str, index_name: str) -> bool:
inspector = sa.inspect(bind)
if not inspector.has_table(table_name):
return False
return any(idx.get("name") == index_name for idx in inspector.get_indexes(table_name))
def _create_index_if_not_exists(
bind,
*,
index_name: str,
table_name: str,
columns: list[str],
unique: bool = False,
postgresql_where: Any = None,
sqlite_where: Any = None,
) -> None:
if _index_exists(bind, table_name, index_name):
return
kwargs: dict[str, Any] = {}
if postgresql_where is not None:
kwargs["postgresql_where"] = postgresql_where
if sqlite_where is not None:
kwargs["sqlite_where"] = sqlite_where
op.create_index(index_name, table_name, columns, unique=unique, **kwargs)
def _drop_index_if_exists(bind, table_name: str, index_name: str) -> None:
if _index_exists(bind, table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def upgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "feishu_reply_outbox"):
return
if not _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
op.add_column("feishu_reply_outbox", sa.Column("source_message_id", sa.String(length=128), nullable=True))
if not _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
op.add_column("feishu_reply_outbox", sa.Column("reply_hash", sa.String(length=32), nullable=True))
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_source_message_id",
table_name="feishu_reply_outbox",
columns=["source_message_id"],
)
_create_index_if_not_exists(
bind,
index_name="ix_feishu_reply_outbox_reply_hash",
table_name="feishu_reply_outbox",
columns=["reply_hash"],
)
sent_where = sa.text("status = 'sent' AND reply_hash IS NOT NULL")
_create_index_if_not_exists(
bind,
index_name="uq_feishu_reply_outbox_sent_reply_hash",
table_name="feishu_reply_outbox",
columns=["tenant_id", "reply_hash"],
unique=True,
postgresql_where=sent_where,
sqlite_where=sent_where,
)
def downgrade() -> None:
bind = op.get_bind()
if not _table_exists(bind, "feishu_reply_outbox"):
return
_drop_index_if_exists(bind, "feishu_reply_outbox", "uq_feishu_reply_outbox_sent_reply_hash")
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_reply_hash")
_drop_index_if_exists(bind, "feishu_reply_outbox", "ix_feishu_reply_outbox_source_message_id")
if _column_exists(bind, "feishu_reply_outbox", "reply_hash"):
op.drop_column("feishu_reply_outbox", "reply_hash")
if _column_exists(bind, "feishu_reply_outbox", "source_message_id"):
op.drop_column("feishu_reply_outbox", "source_message_id")

View File

@ -205,6 +205,31 @@ PROMPT_FEISHU_REQUIREMENT_ANALYSIS = """
""" """
PROMPT_FEISHU_HUMANIZED_REPLY = """
直接回答用户问题,不要做自我介绍。
你将基于以下上下文生成一条 35-140 字中文回复:
- 当前系统时间(北京时间):{now_cn_text}
- 提及用户:{sender_name}
- 用户消息:{user_message}
- 系统处理状态:{status}
- 分类结果:{classification}
- 处理原因:{reason}
- 处理追踪ID:{ai_trace_id}
- 关联项目ID:{linked_projects_text}
硬性规则:
1. 首句必须直接回答“用户消息”的核心问题,禁止先讲系统身份或接入方式。
2. 禁止默认输出“我是...”“我可以...”“我现在通过...接入...”这类自我介绍句式。
3. 仅当用户明确询问“你是谁/你能做什么/系统能力”时,才允许用一句话说明能力边界。
4. 若信息不足,请明确缺失信息并给出 1-3 条可执行下一步。
5. 如果用户询问日期/时间/星期,必须基于“当前系统时间(北京时间)”直接回答,不得编造。
6. 不要输出 Markdown,不要输出 JSON。
只输出最终回复正文。
""".strip()
PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """ PROMPT_FEISHU_PROJECT_SIGNAL_CLASSIFICATION = """
你是 PMFlow 的项目需求信号分流器。 你是 PMFlow 的项目需求信号分流器。
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。 请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。

View File

@ -4,23 +4,26 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
from contextlib import asynccontextmanager from contextlib import asynccontextmanager, suppress
from datetime import datetime, timedelta, timezone
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
import sqlalchemy as sa import sqlalchemy as sa
from sqlalchemy.exc import OperationalError, ProgrammingError
from .api.router import api_router from .api.router import api_router
from .core.config import settings from .core.config import settings
from .core.logging import configure_logging, get_logger from .core.logging import configure_logging, get_logger
from .core.response import success_response from .core.response import success_response
from .database import AsyncSessionLocal, engine from .database import AsyncSessionLocal, engine
from .models import Base from .models import Base, Project, WorkItem
from .services.scheduler_center import WorkloadRecalcScheduler from .services.scheduler_center import WorkloadRecalcScheduler
from .services.scheduler_center import KnowledgeUrlRefreshScheduler from .services.scheduler_center import KnowledgeUrlRefreshScheduler
from .services.scheduler_center import KnowledgeGraphIncrementalScheduler from .services.scheduler_center import KnowledgeGraphIncrementalScheduler
from .services.scheduler_center import AIPMOCadenceScheduler from .services.scheduler_center import AIPMOCadenceScheduler
from .services.ai_orchestration_run_service import AIOrchestrationRunService from .services.ai_orchestration_run_service import AIOrchestrationRunService
from .services.ai_pmo_kernel_service import AIPMOKernelService
from .services.skill_service import init_system_skill_tags from .services.skill_service import init_system_skill_tags
from .services.feishu_long_connection import init_feishu_long_connection_manager from .services.feishu_long_connection import init_feishu_long_connection_manager
from .services.feishu_integration import dispatch_pending_feishu_replies from .services.feishu_integration import dispatch_pending_feishu_replies
@ -30,6 +33,17 @@ configure_logging(settings.debug)
logger = get_logger(__name__) logger = get_logger(__name__)
register_context_cache_invalidation_hooks() register_context_cache_invalidation_hooks()
ACTIVE_PROJECT_STATUSES = ("active", "planning", "todo", "doing")
def _is_missing_table_error(exc: Exception) -> bool:
text = str(exc).lower()
return (
"no such table" in text
or "undefined table" in text
or "relation" in text and "does not exist" in text
)
def _sqlite_backfill_columns(sync_conn) -> None: def _sqlite_backfill_columns(sync_conn) -> None:
"""为历史 SQLite 库补齐新增列,避免运行时 500。""" """为历史 SQLite 库补齐新增列,避免运行时 500。"""
@ -78,6 +92,8 @@ def _sqlite_backfill_columns(sync_conn) -> None:
_add_column("ai_processing_logs", "operation_code", "VARCHAR(80)") _add_column("ai_processing_logs", "operation_code", "VARCHAR(80)")
_add_column("ai_processing_logs", "operation_result", "JSON") _add_column("ai_processing_logs", "operation_result", "JSON")
_add_column("ai_processing_logs", "reply_source", "VARCHAR(40)") _add_column("ai_processing_logs", "reply_source", "VARCHAR(40)")
_add_column("feishu_reply_outbox", "source_message_id", "VARCHAR(128)")
_add_column("feishu_reply_outbox", "reply_hash", "VARCHAR(32)")
def _normalize_dialect(value: str) -> str: def _normalize_dialect(value: str) -> str:
@ -165,8 +181,10 @@ def _ensure_feishu_integrity_guards(sync_conn) -> None:
sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()), sa.Column("created_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()), sa.Column("updated_at", sa.TIMESTAMP(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
sa.Column("trace_id", sa.String(length=64), nullable=True), sa.Column("trace_id", sa.String(length=64), nullable=True),
sa.Column("source_message_id", sa.String(length=128), nullable=True),
sa.Column("chat_id", sa.String(length=128), nullable=False), sa.Column("chat_id", sa.String(length=128), nullable=False),
sa.Column("message_text", sa.Text(), nullable=False), sa.Column("message_text", sa.Text(), nullable=False),
sa.Column("reply_hash", sa.String(length=32), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")), sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")),
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")), sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True), sa.Column("next_retry_at", sa.TIMESTAMP(), nullable=True),
@ -174,21 +192,37 @@ def _ensure_feishu_integrity_guards(sync_conn) -> None:
sa.Column("last_error", sa.Text(), nullable=True), sa.Column("last_error", sa.Text(), nullable=True),
) )
outbox_table.create(sync_conn, checkfirst=True) outbox_table.create(sync_conn, checkfirst=True)
outbox_columns = {col["name"] for col in sa.inspect(sync_conn).get_columns("feishu_reply_outbox")}
if "source_message_id" not in outbox_columns:
sync_conn.exec_driver_sql("ALTER TABLE feishu_reply_outbox ADD COLUMN source_message_id VARCHAR(128)")
if "reply_hash" not in outbox_columns:
sync_conn.exec_driver_sql("ALTER TABLE feishu_reply_outbox ADD COLUMN reply_hash VARCHAR(32)")
sync_conn.exec_driver_sql( sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)" "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)"
) )
sync_conn.exec_driver_sql( sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_trace_id ON feishu_reply_outbox (trace_id)" "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_trace_id ON feishu_reply_outbox (trace_id)"
) )
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_source_message_id ON feishu_reply_outbox (source_message_id)"
)
sync_conn.exec_driver_sql( sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_chat_id ON feishu_reply_outbox (chat_id)" "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_chat_id ON feishu_reply_outbox (chat_id)"
) )
sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_reply_hash ON feishu_reply_outbox (reply_hash)"
)
sync_conn.exec_driver_sql( sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)" "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)"
) )
sync_conn.exec_driver_sql( sync_conn.exec_driver_sql(
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_next_retry_at ON feishu_reply_outbox (next_retry_at)" "CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_next_retry_at ON feishu_reply_outbox (next_retry_at)"
) )
sync_conn.exec_driver_sql(
"CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_reply_outbox_sent_reply_hash "
"ON feishu_reply_outbox (tenant_id, reply_hash) "
"WHERE status = 'sent' AND reply_hash IS NOT NULL"
)
inspector = sa.inspect(sync_conn) inspector = sa.inspect(sync_conn)
if not inspector.has_table("feishu_items"): if not inspector.has_table("feishu_items"):
@ -311,7 +345,11 @@ def _ensure_feishu_integrity_guards(sync_conn) -> None:
) )
async def _feishu_outbox_drain_loop() -> None: def _is_postgresql_runtime() -> bool:
return _normalize_dialect(engine.dialect.name) == "postgresql"
async def _feishu_outbox_polling_loop() -> None:
while True: while True:
try: try:
dispatched = await dispatch_pending_feishu_replies(limit=50) dispatched = await dispatch_pending_feishu_replies(limit=50)
@ -323,6 +361,161 @@ async def _feishu_outbox_drain_loop() -> None:
await asyncio.sleep(2.0) await asyncio.sleep(2.0)
async def _feishu_outbox_listen_loop() -> None:
while True:
listener_callback = None
driver_connection = None
try:
async with engine.connect() as conn:
await conn.execute(sa.text("LISTEN feishu_outbox_ready"))
await conn.commit()
raw_conn = await conn.get_raw_connection()
driver_connection = getattr(raw_conn, "driver_connection", None)
if driver_connection is None or not hasattr(driver_connection, "add_listener"):
logger.warning("feishu_outbox_listen_unsupported_driver", driver=str(type(driver_connection)))
await _feishu_outbox_polling_loop()
return
notify_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=1024)
def _on_notify(_connection, _pid, _channel, payload) -> None:
try:
notify_queue.put_nowait(str(payload or ""))
except asyncio.QueueFull:
pass
listener_callback = _on_notify
await driver_connection.add_listener("feishu_outbox_ready", listener_callback)
logger.info("feishu_outbox_listen_ready")
while True:
dispatched = await dispatch_pending_feishu_replies(limit=50)
if dispatched > 0:
continue
try:
await asyncio.wait_for(notify_queue.get(), timeout=2.0)
while not notify_queue.empty():
notify_queue.get_nowait()
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
break
except Exception as exc:
logger.warning("feishu_outbox_listen_failed", error=str(exc))
await asyncio.sleep(2.0)
finally:
if driver_connection is not None and listener_callback is not None:
with suppress(Exception):
await driver_connection.remove_listener("feishu_outbox_ready", listener_callback)
async def _feishu_outbox_drain_loop() -> None:
if _is_postgresql_runtime():
await _feishu_outbox_listen_loop()
return
await _feishu_outbox_polling_loop()
async def _get_recent_active_projects(
*,
days: int,
limit_per_tenant: int,
max_projects: int,
) -> list[tuple[int, int]]:
since = (datetime.now(timezone.utc) - timedelta(days=max(1, int(days)))).replace(tzinfo=None)
per_tenant_limit = max(1, int(limit_per_tenant))
total_limit = max(per_tenant_limit, int(max_projects))
async with AsyncSessionLocal() as db:
try:
rows = (
await db.execute(
sa.select(
WorkItem.tenant_id.label("tenant_id"),
WorkItem.project_id.label("project_id"),
sa.func.max(WorkItem.created_at).label("last_seen_at"),
)
.join(
Project,
sa.and_(
Project.tenant_id == WorkItem.tenant_id,
Project.id == WorkItem.project_id,
),
)
.where(
sa.and_(
WorkItem.project_id.is_not(None),
WorkItem.created_at >= since,
Project.status.in_(ACTIVE_PROJECT_STATUSES),
)
)
.group_by(WorkItem.tenant_id, WorkItem.project_id)
)
).all()
except (OperationalError, ProgrammingError) as exc:
if not _is_missing_table_error(exc):
raise
logger.warning("[WM_WARMUP] 活跃项目查询跳过", reason="missing_table", error=str(exc))
return []
bucket: dict[int, list[tuple[datetime, int]]] = {}
for row in rows:
tenant_id = int(getattr(row, "tenant_id"))
project_id = int(getattr(row, "project_id"))
last_seen = getattr(row, "last_seen_at")
last_seen_at = last_seen if isinstance(last_seen, datetime) else datetime.min
bucket.setdefault(tenant_id, []).append((last_seen_at, project_id))
picked: list[tuple[datetime, int, int]] = []
for tenant_id, entries in bucket.items():
entries.sort(key=lambda item: item[0], reverse=True)
for last_seen_at, project_id in entries[:per_tenant_limit]:
picked.append((last_seen_at, tenant_id, project_id))
picked.sort(key=lambda item: item[0], reverse=True)
return [(tenant_id, project_id) for _, tenant_id, project_id in picked[:total_limit]]
async def _warmup_world_models() -> None:
days = max(1, int(os.getenv("WORLD_MODEL_WARMUP_DAYS", "7")))
limit_per_tenant = max(1, int(os.getenv("WORLD_MODEL_WARMUP_LIMIT_PER_TENANT", "5")))
max_projects = max(limit_per_tenant, int(os.getenv("WORLD_MODEL_WARMUP_MAX_PROJECTS", "50")))
try:
active_projects = await _get_recent_active_projects(
days=days,
limit_per_tenant=limit_per_tenant,
max_projects=max_projects,
)
warmed = 0
for tenant_id, project_id in active_projects:
try:
async with AsyncSessionLocal() as db:
kernel = AIPMOKernelService(db, tenant_id=int(tenant_id))
await kernel.build_world_model(
tenant_id=int(tenant_id),
project_id=int(project_id),
sender="wm_warmup",
)
warmed += 1
except Exception as exc:
logger.warning(
"[WM_WARMUP] 项目预热失败",
tenant_id=tenant_id,
project_id=project_id,
error=str(exc),
)
logger.info(
"[WM_WARMUP] 预热完成",
count=warmed,
total=len(active_projects),
days=days,
limit_per_tenant=limit_per_tenant,
max_projects=max_projects,
)
except Exception as exc:
logger.warning("[WM_WARMUP] 预热失败", error=str(exc))
@asynccontextmanager @asynccontextmanager
async def lifespan(_: FastAPI): async def lifespan(_: FastAPI):
"""应用生命周期:初始化表与系统技能标签。""" """应用生命周期:初始化表与系统技能标签。"""
@ -367,6 +560,10 @@ async def lifespan(_: FastAPI):
_feishu_outbox_drain_loop(), _feishu_outbox_drain_loop(),
name="feishu-outbox-drain-loop", name="feishu-outbox-drain-loop",
) )
wm_warmup_task = asyncio.create_task(
_warmup_world_models(),
name="wm-world-model-warmup",
)
yield yield
outbox_task.cancel() outbox_task.cancel()
@ -374,6 +571,10 @@ async def lifespan(_: FastAPI):
await outbox_task await outbox_task
except asyncio.CancelledError: except asyncio.CancelledError:
pass pass
if not wm_warmup_task.done():
wm_warmup_task.cancel()
with suppress(asyncio.CancelledError):
await wm_warmup_task
await feishu_lc.stop() await feishu_lc.stop()
await ai_pmo_cadence_scheduler.stop() await ai_pmo_cadence_scheduler.stop()
await kg_scheduler.stop() await kg_scheduler.stop()

View File

@ -15,8 +15,10 @@ class FeishuReplyOutbox(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True) trace_id: Mapped[Optional[str]] = mapped_column(VARCHAR(64), nullable=True, index=True)
source_message_id: Mapped[Optional[str]] = mapped_column(VARCHAR(128), nullable=True, index=True)
chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True) chat_id: Mapped[str] = mapped_column(VARCHAR(128), nullable=False, index=True)
message_text: Mapped[str] = mapped_column(TEXT, nullable=False) message_text: Mapped[str] = mapped_column(TEXT, nullable=False)
reply_hash: Mapped[Optional[str]] = mapped_column(VARCHAR(32), nullable=True, index=True)
status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True) status: Mapped[str] = mapped_column(VARCHAR(20), nullable=False, default="pending", index=True)
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0) attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_retry_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True) next_retry_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)

View File

@ -92,7 +92,30 @@ RECEIPT_PHASE_ACTION = "action_applied"
RECEIPT_PHASE_PENDING = "pending_manual" RECEIPT_PHASE_PENDING = "pending_manual"
RECEIPT_PHASE_ERROR = "error" RECEIPT_PHASE_ERROR = "error"
QUESTION_HINTS = ("?", "?", "怎么", "为何", "为什么", "什么", "多少", "是否", "帮我看", "状态") QUESTION_HINTS = (
"?",
"?",
"怎么",
"如何",
"为何",
"为什么",
"什么",
"是什么",
"哪些",
"有哪些",
"特点",
"技术特点",
"能力",
"介绍",
"说明",
"多少",
"是否",
"帮我看",
"状态",
"风险",
"能做什么",
"可以做什么",
)
SMALLTALK_EXACT = { SMALLTALK_EXACT = {
"收到", "收到",
"收到啦", "收到啦",
@ -161,6 +184,27 @@ class CanonicalIntent:
class IntentResolver: class IntentResolver:
"""把输入收口为 canonical intent。""" """把输入收口为 canonical intent。"""
OPERATION_VERBS = (
"新增",
"增加",
"加一个",
"加个",
"新建",
"创建",
"建立",
"立项",
"接入",
"修复",
"优化",
"改成",
"改为",
"更新",
"分配",
"指派",
"归档",
"执行",
)
@staticmethod @staticmethod
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent: def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
normalized = re.sub(r"\s+", "", str(text or "")).lower() normalized = re.sub(r"\s+", "", str(text or "")).lower()
@ -191,7 +235,7 @@ class IntentResolver:
pretty[:300], pretty[:300],
{}, {},
) )
if any(token in pretty for token in QUESTION_HINTS): if IntentResolver._looks_like_query(text=pretty):
return CanonicalIntent( return CanonicalIntent(
INTENT_QUERY, INTENT_QUERY,
0.88, 0.88,
@ -210,14 +254,31 @@ class IntentResolver:
{}, {},
) )
return CanonicalIntent( return CanonicalIntent(
INTENT_SIGNAL_CREATE, INTENT_QUERY,
0.65, 0.6,
project_id, project_id,
IntentResolver._build_signal_title(pretty, default="新信号"), "",
pretty[:300], pretty[:300],
{"fallback": True}, {"fallback": True, "reason": "default_query_fallback"},
) )
@staticmethod
def _looks_like_query(*, text: str) -> bool:
normalized = re.sub(r"\s+", "", str(text or "")).lower()
if not normalized:
return False
if any(token in normalized for token in QUESTION_HINTS):
return True
has_question_structure = (
"?" in normalized
or "?" in normalized
or any(token in normalized for token in ("吗", "呢", "吧", "是否", "是不是"))
)
if not has_question_structure:
return False
has_operation = any(token in normalized for token in IntentResolver.OPERATION_VERBS)
return not has_operation
@staticmethod @staticmethod
def _build_signal_title(text: str, *, default: str) -> str: def _build_signal_title(text: str, *, default: str) -> str:
cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::") cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::")

View File

@ -21,7 +21,8 @@ from uuid import uuid4
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import httpx import httpx
from sqlalchemy import and_, or_ from sqlalchemy import and_, or_, text, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select from sqlalchemy.future import select
@ -35,10 +36,10 @@ from ..models import (
AIProcessingLog, AIProcessingLog,
) )
from ..database import AsyncSessionLocal from ..database import AsyncSessionLocal
from ..config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY
from .notification_service import NotificationService from .notification_service import NotificationService
from .signal_capture import ProactiveSignalCaptureService from .signal_capture import ProactiveSignalCaptureService
from .llm import LLMService from .llm import LLMService
from .prompt_profile_service import prompt_profile_service
CN_TZ = timezone(timedelta(hours=8)) CN_TZ = timezone(timedelta(hours=8))
WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
@ -64,6 +65,29 @@ class FeishuIntegrationService:
def _normalize_event_mode(_: Optional[str] = None) -> str: def _normalize_event_mode(_: Optional[str] = None) -> str:
return "long_connection" return "long_connection"
@staticmethod
def _is_postgresql_session(db: AsyncSession) -> bool:
try:
bind = db.get_bind()
except Exception:
return False
dialect_name = str(getattr(getattr(bind, "dialect", None), "name", "")).lower()
return dialect_name.startswith("postgres")
@staticmethod
async def _notify_outbox_ready(db: AsyncSession, *, outbox_id: Optional[int]) -> None:
"""仅在 PostgreSQL 下发送 outbox ready 事件。"""
if not FeishuIntegrationService._is_postgresql_session(db):
return
try:
payload = str(int(outbox_id))[:64] if outbox_id is not None else ""
await db.execute(
text("SELECT pg_notify('feishu_outbox_ready', :payload)"),
{"payload": payload},
)
except Exception as exc:
print(f"[Feishu] outbox notify failed: outbox_id={outbox_id}, error={exc}")
async def load_config(self, tenant_id: int = 1): async def load_config(self, tenant_id: int = 1):
"""从数据库加载飞书配置""" """从数据库加载飞书配置"""
self.tenant_id = tenant_id self.tenant_id = tenant_id
@ -229,6 +253,7 @@ class FeishuIntegrationService:
mention_name=(sender.get('sender_name') or sender.get('name') or '你'), mention_name=(sender.get('sender_name') or sender.get('name') or '你'),
text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。", text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。",
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
self._emit_state_transition( self._emit_state_transition(
trace_id=state_trace_id, trace_id=state_trace_id,
@ -292,6 +317,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=self._build_error_reply_text(), text=self._build_error_reply_text(),
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
self._emit_state_transition( self._emit_state_transition(
trace_id=state_trace_id, trace_id=state_trace_id,
@ -374,6 +400,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=deterministic_reply, text=deterministic_reply,
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
reply_route = "execute_template" reply_route = "execute_template"
elif scene == "qa": elif scene == "qa":
@ -389,6 +416,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=str(result.get("direct_reply_text") or "已收到,正在处理。"), text=str(result.get("direct_reply_text") or "已收到,正在处理。"),
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
reply_route = "qa_direct" reply_route = "qa_direct"
elif ( elif (
@ -405,6 +433,7 @@ class FeishuIntegrationService:
message_content=message_content, message_content=message_content,
capture_result=result, capture_result=result,
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
reply_route = "qa_llm" reply_route = "qa_llm"
else: else:
@ -421,6 +450,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=self._build_error_reply_text(), text=self._build_error_reply_text(),
trace_id=state_trace_id, trace_id=state_trace_id,
source_message_id=message_id,
) )
reply_route = "error_template" reply_route = "error_template"
@ -558,6 +588,7 @@ class FeishuIntegrationService:
message_content: str, message_content: str,
capture_result: Dict[str, Any], capture_result: Dict[str, Any],
trace_id: Optional[str] = None, trace_id: Optional[str] = None,
source_message_id: Optional[str] = None,
) -> None: ) -> None:
"""被 @ 时回复一条有人味的执行反馈。""" """被 @ 时回复一条有人味的执行反馈。"""
deterministic_time_reply = self._build_time_truth_reply(message_content) deterministic_time_reply = self._build_time_truth_reply(message_content)
@ -568,6 +599,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=deterministic_time_reply, text=deterministic_time_reply,
trace_id=trace_id, trace_id=trace_id,
source_message_id=source_message_id,
) )
return return
@ -586,6 +618,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=execution_receipt, text=execution_receipt,
trace_id=trace_id, trace_id=trace_id,
source_message_id=source_message_id,
) )
return return
@ -597,20 +630,11 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=butler_reply, text=butler_reply,
trace_id=trace_id, trace_id=trace_id,
source_message_id=source_message_id,
) )
return return
try: try:
active_role = await prompt_profile_service.get_active_role(self.db, tenant_id=self.tenant_id)
role = active_role.get("role") if isinstance(active_role, dict) else {}
role_name = str((role or {}).get("role_name") or "产品经理(任务分派)").strip()
system_prompt = str((role or {}).get("system_prompt") or "").strip()
if len(system_prompt) < 20:
system_prompt = (
"你是 PMFlow 的任务分派产品经理。"
"请用简洁、专业、有温度的中文回复用户,先确认需求,再给可执行下一步。"
)
status = str(capture_result.get("status") or "unknown") status = str(capture_result.get("status") or "unknown")
classification = str(capture_result.get("classification") or "unknown") classification = str(capture_result.get("classification") or "unknown")
reason = str(capture_result.get("reason") or "").strip() reason = str(capture_result.get("reason") or "").strip()
@ -623,27 +647,16 @@ class FeishuIntegrationService:
f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}" f"{WEEKDAY_CN[now_cn.weekday()]} {now_cn.strftime('%H:%M:%S')}"
) )
prompt = f""" prompt = PROMPT_FEISHU_HUMANIZED_REPLY.format(
{system_prompt} now_cn_text=now_cn_text,
sender_name=sender_name,
你正在飞书群里以「{role_name}」身份回复用户。请基于以下上下文,生成一条 35-140 字中文回复: user_message=(message_content or "")[:600],
- 当前系统时间(北京时间):{now_cn_text} status=status,
- 提及用户:{sender_name} classification=classification,
- 用户消息:{(message_content or '')[:600]} reason=(reason or "无"),
- 系统处理状态:{status} ai_trace_id=(ai_trace_id or "无"),
- 分类结果:{classification} linked_projects_text=(linked_projects_text or "无"),
- 处理原因:{reason or "无"} )
- 处理追踪ID:{ai_trace_id or "无"}
- 关联项目ID:{linked_projects_text or '无'}
回复要求:
1. 语气自然、专业,避免机器口吻。
2. 先确认你已处理,再给 1-3 条下一步建议。
3. 若信息不足,请明确要补充什么。
4. 如果用户询问“日期/今天/时间/几点/星期”,必须基于“当前系统时间(北京时间)”直接回答,不得编造历史日期。
5. 不要输出 Markdown,不要输出 JSON。
只输出最终回复正文。
""".strip()
llm = LLMService() llm = LLMService()
raw_reply = await llm.generate( raw_reply = await llm.generate(
@ -655,6 +668,8 @@ class FeishuIntegrationService:
max_tokens=180, max_tokens=180,
) )
reply_text = self._clean_reply_text(raw_reply) reply_text = self._clean_reply_text(raw_reply)
if not self._is_capability_question(message_content):
reply_text = self._strip_identity_lead(reply_text)
if (not reply_text) or self._looks_like_provider_error(reply_text): if (not reply_text) or self._looks_like_provider_error(reply_text):
reply_text = self._fallback_reply_text(capture_result) reply_text = self._fallback_reply_text(capture_result)
except Exception as exc: except Exception as exc:
@ -667,6 +682,7 @@ class FeishuIntegrationService:
mention_name=sender_name, mention_name=sender_name,
text=reply_text, text=reply_text,
trace_id=trace_id, trace_id=trace_id,
source_message_id=source_message_id,
) )
async def _safe_reply_text( async def _safe_reply_text(
@ -677,6 +693,7 @@ class FeishuIntegrationService:
mention_open_id: Optional[str] = None, mention_open_id: Optional[str] = None,
mention_name: Optional[str] = None, mention_name: Optional[str] = None,
trace_id: Optional[str] = None, trace_id: Optional[str] = None,
source_message_id: Optional[str] = None,
) -> None: ) -> None:
try: try:
plain_text = self._normalize_outgoing_reply_text(text) plain_text = self._normalize_outgoing_reply_text(text)
@ -685,23 +702,16 @@ class FeishuIntegrationService:
display_name=mention_name, display_name=mention_name,
) )
final_text = f"{mention_prefix}{plain_text}".strip() final_text = f"{mention_prefix}{plain_text}".strip()
outbox_id = await self._enqueue_reply_outbox( await self._enqueue_reply_outbox(
chat_id=chat_id, chat_id=chat_id,
message_text=final_text, message_text=final_text,
trace_id=trace_id, trace_id=trace_id,
source_message_id=source_message_id,
) )
asyncio.create_task(self._dispatch_outbox_entry(outbox_id=outbox_id))
except Exception as exc: except Exception as exc:
print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}") print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}")
try: # 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
plain_text = self._normalize_outgoing_reply_text(text) return
mention_prefix = self._build_mention_prefix(
open_id=mention_open_id,
display_name=mention_name,
)
await self.send_message_to_group(chat_id=chat_id, message=f"{mention_prefix}{plain_text}".strip())
except Exception as send_exc:
print(f"[Feishu] send fallback failed: chat_id={chat_id}, error={send_exc}")
async def _enqueue_reply_outbox( async def _enqueue_reply_outbox(
self, self,
@ -709,130 +719,90 @@ class FeishuIntegrationService:
chat_id: str, chat_id: str,
message_text: str, message_text: str,
trace_id: Optional[str], trace_id: Optional[str],
source_message_id: Optional[str],
) -> int: ) -> int:
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
normalized_source_message_id = (source_message_id or "")[:128] or None
normalized_message_text = (message_text or "")[:2000] or "已收到,正在处理。"
reply_hash = self._build_reply_hash(
source_message_id=normalized_source_message_id,
message_text=normalized_message_text,
)
if reply_hash:
sent_exists = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_exists is not None:
return int(sent_exists)
row = FeishuReplyOutbox( row = FeishuReplyOutbox(
tenant_id=int(self.tenant_id or 1), tenant_id=int(self.tenant_id or 1),
trace_id=(trace_id or "")[:64] or None, trace_id=(trace_id or "")[:64] or None,
source_message_id=normalized_source_message_id,
chat_id=(chat_id or "")[:128], chat_id=(chat_id or "")[:128],
message_text=(message_text or "")[:2000] or "已收到,正在处理。", message_text=normalized_message_text,
reply_hash=reply_hash,
status="pending", status="pending",
attempts=0, attempts=0,
next_retry_at=None, next_retry_at=None,
) )
db.add(row) db.add(row)
try:
await db.flush()
await self._notify_outbox_ready(db, outbox_id=int(row.id) if row.id is not None else None)
await db.commit() await db.commit()
except IntegrityError:
await db.rollback()
if reply_hash:
sent_exists = (
await db.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.tenant_id == int(self.tenant_id or 1),
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_exists is not None:
return int(sent_exists)
raise
await db.refresh(row) await db.refresh(row)
return int(row.id) return int(row.id)
async def _dispatch_outbox_entry(self, *, outbox_id: int) -> None:
await self.__class__._dispatch_outbox_entry_by_id(outbox_id=outbox_id)
@classmethod
async def _dispatch_outbox_entry_by_id(cls, *, outbox_id: int) -> None:
for _ in range(OUTBOX_MAX_ATTEMPTS + 1):
now = datetime.utcnow()
should_wait_seconds = 0.0
async with AsyncSessionLocal() as db:
row = (
await db.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
status = str(row.status or "").lower()
if status == "sent":
return
if status == "failed":
return
if status == "sending":
stale_seconds = 0.0
marker = row.updated_at or row.created_at
if isinstance(marker, datetime):
try:
marker_naive = marker.replace(tzinfo=None) if marker.tzinfo else marker
stale_seconds = max(0.0, (now - marker_naive).total_seconds())
except Exception:
stale_seconds = 0.0
if stale_seconds >= OUTBOX_STALE_SENDING_SECONDS:
row.status = "retrying"
row.next_retry_at = now
await db.commit()
else:
return
if row.next_retry_at and row.next_retry_at > now:
should_wait_seconds = max(0.2, min(30.0, (row.next_retry_at - now).total_seconds()))
else:
row.status = "sending"
row.attempts = int(row.attempts or 0) + 1
row.last_error = None
await db.commit()
if should_wait_seconds > 0:
await asyncio.sleep(should_wait_seconds)
continue
send_error: Optional[str] = None
async with AsyncSessionLocal() as db_send:
row = (
await db_send.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
try:
service = FeishuIntegrationService(db_send)
await service.load_config(tenant_id=int(row.tenant_id))
await service.send_message_to_group(
chat_id=str(row.chat_id or ""),
message=str(row.message_text or ""),
)
row.status = "sent"
row.sent_at = datetime.utcnow()
row.next_retry_at = None
row.last_error = None
await db_send.commit()
return
except Exception as exc:
send_error = str(exc)
async with AsyncSessionLocal() as db_retry:
row = (
await db_retry.execute(
select(FeishuReplyOutbox).where(
FeishuReplyOutbox.id == int(outbox_id)
)
)
).scalar_one_or_none()
if row is None:
return
attempts = int(row.attempts or 0)
if attempts >= OUTBOX_MAX_ATTEMPTS:
row.status = "failed"
row.next_retry_at = None
else:
row.status = "retrying"
delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1)))
row.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds)
row.last_error = (send_error or "unknown_send_error")[:1000]
await db_retry.commit()
if attempts >= OUTBOX_MAX_ATTEMPTS:
return
await asyncio.sleep(min(5, 2 * max(1, attempts)))
@classmethod @classmethod
async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int: async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int:
now = datetime.utcnow()
fetch_limit = max(1, min(200, int(limit))) fetch_limit = max(1, min(200, int(limit)))
dispatched = 0
for _ in range(fetch_limit):
claimed = await cls._claim_next_outbox_entry(now=datetime.utcnow())
if claimed is None:
break
await cls._dispatch_claimed_outbox_entry(claimed=claimed)
dispatched += 1
return dispatched
@classmethod
async def _claim_next_outbox_entry(cls, *, now: datetime) -> Optional[dict[str, Any]]:
stale_before = now - timedelta(seconds=OUTBOX_STALE_SENDING_SECONDS)
async with AsyncSessionLocal() as db: async with AsyncSessionLocal() as db:
due_rows = ( await db.execute(
update(FeishuReplyOutbox)
.where(
FeishuReplyOutbox.status == "sending",
FeishuReplyOutbox.updated_at <= stale_before,
)
.values(status="retrying", next_retry_at=now)
)
candidate_id = (
await db.execute( await db.execute(
select(FeishuReplyOutbox.id) select(FeishuReplyOutbox.id)
.where( .where(
@ -843,18 +813,121 @@ class FeishuIntegrationService:
), ),
) )
.order_by(FeishuReplyOutbox.id.asc()) .order_by(FeishuReplyOutbox.id.asc())
.limit(fetch_limit) .with_for_update(skip_locked=True)
.limit(1)
) )
).scalars().all() ).scalar_one_or_none()
if candidate_id is None:
await db.commit()
return None
if not due_rows: claimed = (
return 0 await db.execute(
update(FeishuReplyOutbox)
await asyncio.gather( .where(
*(cls._dispatch_outbox_entry_by_id(outbox_id=int(outbox_id)) for outbox_id in due_rows), FeishuReplyOutbox.id == int(candidate_id),
return_exceptions=True, FeishuReplyOutbox.status.in_(("pending", "retrying")),
or_(
FeishuReplyOutbox.next_retry_at.is_(None),
FeishuReplyOutbox.next_retry_at <= now,
),
) )
return len(due_rows) .values(
status="sending",
attempts=FeishuReplyOutbox.attempts + 1,
last_error=None,
next_retry_at=None,
)
.returning(
FeishuReplyOutbox.id,
FeishuReplyOutbox.tenant_id,
FeishuReplyOutbox.chat_id,
FeishuReplyOutbox.message_text,
FeishuReplyOutbox.reply_hash,
FeishuReplyOutbox.attempts,
)
)
).mappings().first()
await db.commit()
if claimed is None:
return None
return dict(claimed)
@classmethod
async def _dispatch_claimed_outbox_entry(cls, *, claimed: dict[str, Any]) -> None:
outbox_id = int(claimed.get("id") or 0)
if outbox_id <= 0:
return
tenant_id = int(claimed.get("tenant_id") or 1)
reply_hash = str(claimed.get("reply_hash") or "").strip()
if reply_hash:
async with AsyncSessionLocal() as db_check:
sent_duplicate = (
await db_check.execute(
select(FeishuReplyOutbox.id)
.where(
FeishuReplyOutbox.id != outbox_id,
FeishuReplyOutbox.tenant_id == tenant_id,
FeishuReplyOutbox.reply_hash == reply_hash,
FeishuReplyOutbox.status == "sent",
)
.limit(1)
)
).scalar_one_or_none()
if sent_duplicate is not None:
async with AsyncSessionLocal() as db_skip:
row = (
await db_skip.execute(
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
)
).scalar_one_or_none()
if row is not None and str(row.status or "").lower() == "sending":
row.status = "sent"
row.sent_at = datetime.utcnow()
row.next_retry_at = None
row.last_error = f"dedupe_skip_sent:{int(sent_duplicate)}"
await db_skip.commit()
return
send_error: Optional[str] = None
try:
async with AsyncSessionLocal() as db_send:
service = FeishuIntegrationService(db_send)
await service.load_config(tenant_id=int(claimed.get("tenant_id") or 1))
await service.send_message_to_group(
chat_id=str(claimed.get("chat_id") or ""),
message=str(claimed.get("message_text") or ""),
)
except Exception as exc:
send_error = str(exc)
async with AsyncSessionLocal() as db_update:
row = (
await db_update.execute(
select(FeishuReplyOutbox).where(FeishuReplyOutbox.id == outbox_id)
)
).scalar_one_or_none()
if row is None:
return
if str(row.status or "").lower() != "sending":
return
attempts = int(row.attempts or claimed.get("attempts") or 0)
if send_error is None:
row.status = "sent"
row.sent_at = datetime.utcnow()
row.next_retry_at = None
row.last_error = None
await db_update.commit()
return
if attempts >= OUTBOX_MAX_ATTEMPTS:
row.status = "failed"
row.next_retry_at = None
else:
row.status = "retrying"
delay_seconds = min(30, 2 * (2 ** max(0, attempts - 1)))
row.next_retry_at = datetime.utcnow() + timedelta(seconds=delay_seconds)
row.last_error = (send_error or "unknown_send_error")[:1000]
await db_update.commit()
@staticmethod @staticmethod
def _clean_reply_text(value: Optional[str]) -> str: def _clean_reply_text(value: Optional[str]) -> str:
@ -872,6 +945,53 @@ class FeishuIntegrationService:
return cls._build_error_reply_text() return cls._build_error_reply_text()
return text[:500] return text[:500]
@staticmethod
def _build_reply_hash(*, source_message_id: Optional[str], message_text: str) -> Optional[str]:
source = str(source_message_id or "").strip()
body = str(message_text or "").strip()
if not source or not body:
return None
raw = f"{source}{body}".encode("utf-8")
return hashlib.md5(raw, usedforsecurity=False).hexdigest()
@staticmethod
def _is_capability_question(message_content: Optional[str]) -> bool:
normalized = re.sub(r"\s+", "", str(message_content or "").lower())
if not normalized:
return False
capability_tokens = (
"你是谁",
"你是什么",
"你能做什么",
"你可以做什么",
"系统能力",
"能力介绍",
"介绍下你",
)
return any(token in normalized for token in capability_tokens)
@staticmethod
def _strip_identity_lead(reply_text: str) -> str:
text = str(reply_text or "").strip()
if not text:
return text
lowered = text.lower()
forbidden_prefixes = (
"我是",
"我可以",
"我现在",
"是的,我现在",
"可以在这里和我",
)
forbidden_contains = (
"通过 feishu",
"群聊接入 openclaw",
"你可以直接在这里和我对话",
)
if any(lowered.startswith(prefix) for prefix in forbidden_prefixes) or any(token in lowered for token in forbidden_contains):
return ""
return text
@staticmethod @staticmethod
def _looks_like_provider_error(text: str) -> bool: def _looks_like_provider_error(text: str) -> bool:
lowered = str(text or "").strip().lower() lowered = str(text or "").strip().lower()

View File

@ -106,7 +106,11 @@ FOLLOWUP_REFERENCE_RE = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
OBVIOUS_QUESTION_RE = re.compile( OBVIOUS_QUESTION_RE = re.compile(
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|在哪|哪一个|哪几个)", r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)",
re.IGNORECASE,
)
QUERY_INTENT_RE = re.compile(
r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)",
re.IGNORECASE, re.IGNORECASE,
) )
FEISHU_BOT_MENTION_RE = re.compile(r"(?:<at[^>]*>[^<]*</at>|@pmflow\b|@_user_1\b)", re.IGNORECASE) FEISHU_BOT_MENTION_RE = re.compile(r"(?:<at[^>]*>[^<]*</at>|@pmflow\b|@_user_1\b)", re.IGNORECASE)
@ -669,6 +673,16 @@ class FeishuSignalPipelineService:
"correction_match": correction_match, "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): if self._is_obvious_question(text):
return { return {
"intent": INTENT_QUERY, "intent": INTENT_QUERY,
@ -2944,12 +2958,28 @@ class FeishuSignalPipelineService:
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
return False return False
if cls._is_query_like_question(message):
return True
if cls._is_openclaw_business_command(text): if cls._is_openclaw_business_command(text):
return False return False
if cls._looks_like_project_lookup_question(text): if cls._looks_like_project_lookup_question(text):
return True return True
return bool(OBVIOUS_QUESTION_RE.search(text)) 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(OPENCLAW_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 @staticmethod
def _decorate_capture_result( def _decorate_capture_result(
result: dict[str, Any], result: dict[str, Any],
@ -3450,6 +3480,8 @@ class FeishuSignalPipelineService:
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
return False return False
if cls._is_query_like_question(message):
return False
if cls._looks_like_project_create_command(text): if cls._looks_like_project_create_command(text):
return True return True
return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text)) return bool(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text))