分析 world model 并行查询
This commit is contained in:
parent
cf638e0a62
commit
7f837e7019
|
|
@ -39,6 +39,8 @@ backend/
|
|||
│ ├── config/ # 配置与 prompt
|
||||
│ └── utils/ # 工具函数
|
||||
├── alembic/ # 数据库迁移
|
||||
│ └── versions/
|
||||
│ └── 20260310_add_feishu_outbox_reply_hash.py # 飞书 outbox 回执哈希幂等字段
|
||||
├── requirements.txt # Python 依赖
|
||||
├── Dockerfile
|
||||
└── tests/
|
||||
|
|
@ -129,6 +131,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
- 禁止在 API 层写业务逻辑,业务逻辑归属 `services/`
|
||||
- 禁止在前端组件内直接调用 `fetch`,必须通过 `frontend/src/api/` 封装层
|
||||
- 前端状态管理:服务端数据用 React Query,本地 UI 状态用 `useState`
|
||||
- 飞书回执发送仅允许 `main.py` 的 drain_loop 作为消费者,禁止在业务路径即时派发 outbox 发送任务
|
||||
|
||||
## 9. 新增代码的参考模式
|
||||
- 新增后端接口:参考 `backend/app/api/sstc.py` 的结构
|
||||
|
|
@ -139,6 +142,7 @@ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
|||
## 10. 幂等与事务约束
|
||||
- 所有入站事件必须经过 `ingest_events` 幂等去重
|
||||
- 飞书回复必须经过 `feishu_reply_outbox`,禁止同步直接发送
|
||||
- 飞书回复发送幂等:`reply_hash = MD5(source_message_id + reply_text)`,同租户下 `status=sent` 的相同哈希不得重复发送
|
||||
- 跨表写操作必须在同一个数据库事务内完成
|
||||
|
||||
## 11. 已知 BOUNDARY(不要幻构行为)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
@ -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 = """
|
||||
你是 PMFlow 的项目需求信号分流器。
|
||||
请基于“项目列表上下文”和“飞书消息”,判断该消息是否为有效需求信号,并识别它属于哪个已有项目。
|
||||
|
|
|
|||
|
|
@ -4,23 +4,26 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from contextlib import asynccontextmanager, suppress
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.exc import OperationalError, ProgrammingError
|
||||
|
||||
from .api.router import api_router
|
||||
from .core.config import settings
|
||||
from .core.logging import configure_logging, get_logger
|
||||
from .core.response import success_response
|
||||
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 KnowledgeUrlRefreshScheduler
|
||||
from .services.scheduler_center import KnowledgeGraphIncrementalScheduler
|
||||
from .services.scheduler_center import AIPMOCadenceScheduler
|
||||
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.feishu_long_connection import init_feishu_long_connection_manager
|
||||
from .services.feishu_integration import dispatch_pending_feishu_replies
|
||||
|
|
@ -30,6 +33,17 @@ configure_logging(settings.debug)
|
|||
logger = get_logger(__name__)
|
||||
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:
|
||||
"""为历史 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_result", "JSON")
|
||||
_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:
|
||||
|
|
@ -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("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("source_message_id", sa.String(length=128), nullable=True),
|
||||
sa.Column("chat_id", sa.String(length=128), 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("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
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),
|
||||
)
|
||||
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(
|
||||
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_tenant_id ON feishu_reply_outbox (tenant_id)"
|
||||
)
|
||||
sync_conn.exec_driver_sql(
|
||||
"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(
|
||||
"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(
|
||||
"CREATE INDEX IF NOT EXISTS ix_feishu_reply_outbox_status ON feishu_reply_outbox (status)"
|
||||
)
|
||||
sync_conn.exec_driver_sql(
|
||||
"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)
|
||||
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:
|
||||
try:
|
||||
dispatched = await dispatch_pending_feishu_replies(limit=50)
|
||||
|
|
@ -323,6 +361,161 @@ async def _feishu_outbox_drain_loop() -> None:
|
|||
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
|
||||
async def lifespan(_: FastAPI):
|
||||
"""应用生命周期:初始化表与系统技能标签。"""
|
||||
|
|
@ -367,6 +560,10 @@ async def lifespan(_: FastAPI):
|
|||
_feishu_outbox_drain_loop(),
|
||||
name="feishu-outbox-drain-loop",
|
||||
)
|
||||
wm_warmup_task = asyncio.create_task(
|
||||
_warmup_world_models(),
|
||||
name="wm-world-model-warmup",
|
||||
)
|
||||
|
||||
yield
|
||||
outbox_task.cancel()
|
||||
|
|
@ -374,6 +571,10 @@ async def lifespan(_: FastAPI):
|
|||
await outbox_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if not wm_warmup_task.done():
|
||||
wm_warmup_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await wm_warmup_task
|
||||
await feishu_lc.stop()
|
||||
await ai_pmo_cadence_scheduler.stop()
|
||||
await kg_scheduler.stop()
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ class FeishuReplyOutbox(Base):
|
|||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=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)
|
||||
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)
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
next_retry_at: Mapped[Optional[datetime]] = mapped_column(TIMESTAMP, nullable=True, index=True)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,30 @@ RECEIPT_PHASE_ACTION = "action_applied"
|
|||
RECEIPT_PHASE_PENDING = "pending_manual"
|
||||
RECEIPT_PHASE_ERROR = "error"
|
||||
|
||||
QUESTION_HINTS = ("?", "?", "怎么", "为何", "为什么", "什么", "多少", "是否", "帮我看", "状态")
|
||||
QUESTION_HINTS = (
|
||||
"?",
|
||||
"?",
|
||||
"怎么",
|
||||
"如何",
|
||||
"为何",
|
||||
"为什么",
|
||||
"什么",
|
||||
"是什么",
|
||||
"哪些",
|
||||
"有哪些",
|
||||
"特点",
|
||||
"技术特点",
|
||||
"能力",
|
||||
"介绍",
|
||||
"说明",
|
||||
"多少",
|
||||
"是否",
|
||||
"帮我看",
|
||||
"状态",
|
||||
"风险",
|
||||
"能做什么",
|
||||
"可以做什么",
|
||||
)
|
||||
SMALLTALK_EXACT = {
|
||||
"收到",
|
||||
"收到啦",
|
||||
|
|
@ -161,6 +184,27 @@ class CanonicalIntent:
|
|||
class IntentResolver:
|
||||
"""把输入收口为 canonical intent。"""
|
||||
|
||||
OPERATION_VERBS = (
|
||||
"新增",
|
||||
"增加",
|
||||
"加一个",
|
||||
"加个",
|
||||
"新建",
|
||||
"创建",
|
||||
"建立",
|
||||
"立项",
|
||||
"接入",
|
||||
"修复",
|
||||
"优化",
|
||||
"改成",
|
||||
"改为",
|
||||
"更新",
|
||||
"分配",
|
||||
"指派",
|
||||
"归档",
|
||||
"执行",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def resolve(*, text: str, project_id: Optional[int]) -> CanonicalIntent:
|
||||
normalized = re.sub(r"\s+", "", str(text or "")).lower()
|
||||
|
|
@ -191,7 +235,7 @@ class IntentResolver:
|
|||
pretty[:300],
|
||||
{},
|
||||
)
|
||||
if any(token in pretty for token in QUESTION_HINTS):
|
||||
if IntentResolver._looks_like_query(text=pretty):
|
||||
return CanonicalIntent(
|
||||
INTENT_QUERY,
|
||||
0.88,
|
||||
|
|
@ -210,14 +254,31 @@ class IntentResolver:
|
|||
{},
|
||||
)
|
||||
return CanonicalIntent(
|
||||
INTENT_SIGNAL_CREATE,
|
||||
0.65,
|
||||
INTENT_QUERY,
|
||||
0.6,
|
||||
project_id,
|
||||
IntentResolver._build_signal_title(pretty, default="新信号"),
|
||||
"",
|
||||
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
|
||||
def _build_signal_title(text: str, *, default: str) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", str(text or "")).strip(" ,,::")
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ from uuid import uuid4
|
|||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
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.future import select
|
||||
|
||||
|
|
@ -35,10 +36,10 @@ from ..models import (
|
|||
AIProcessingLog,
|
||||
)
|
||||
from ..database import AsyncSessionLocal
|
||||
from ..config.ai_prompts import PROMPT_FEISHU_HUMANIZED_REPLY
|
||||
from .notification_service import NotificationService
|
||||
from .signal_capture import ProactiveSignalCaptureService
|
||||
from .llm import LLMService
|
||||
from .prompt_profile_service import prompt_profile_service
|
||||
|
||||
CN_TZ = timezone(timedelta(hours=8))
|
||||
WEEKDAY_CN = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"]
|
||||
|
|
@ -64,6 +65,29 @@ class FeishuIntegrationService:
|
|||
def _normalize_event_mode(_: Optional[str] = None) -> str:
|
||||
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):
|
||||
"""从数据库加载飞书配置"""
|
||||
self.tenant_id = tenant_id
|
||||
|
|
@ -229,6 +253,7 @@ class FeishuIntegrationService:
|
|||
mention_name=(sender.get('sender_name') or sender.get('name') or '你'),
|
||||
text="我已收到你的消息,但目前无法直接解析纯图片内容。请补充 1-2 句文字需求,我会立即给出分派建议。",
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
|
|
@ -292,6 +317,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=self._build_error_reply_text(),
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
self._emit_state_transition(
|
||||
trace_id=state_trace_id,
|
||||
|
|
@ -374,6 +400,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=deterministic_reply,
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
reply_route = "execute_template"
|
||||
elif scene == "qa":
|
||||
|
|
@ -389,6 +416,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=str(result.get("direct_reply_text") or "已收到,正在处理。"),
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
reply_route = "qa_direct"
|
||||
elif (
|
||||
|
|
@ -405,6 +433,7 @@ class FeishuIntegrationService:
|
|||
message_content=message_content,
|
||||
capture_result=result,
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
reply_route = "qa_llm"
|
||||
else:
|
||||
|
|
@ -421,6 +450,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=self._build_error_reply_text(),
|
||||
trace_id=state_trace_id,
|
||||
source_message_id=message_id,
|
||||
)
|
||||
reply_route = "error_template"
|
||||
|
||||
|
|
@ -558,6 +588,7 @@ class FeishuIntegrationService:
|
|||
message_content: str,
|
||||
capture_result: Dict[str, Any],
|
||||
trace_id: Optional[str] = None,
|
||||
source_message_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""被 @ 时回复一条有人味的执行反馈。"""
|
||||
deterministic_time_reply = self._build_time_truth_reply(message_content)
|
||||
|
|
@ -568,6 +599,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=deterministic_time_reply,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -586,6 +618,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=execution_receipt,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -597,20 +630,11 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=butler_reply,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
return
|
||||
|
||||
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")
|
||||
classification = str(capture_result.get("classification") or "unknown")
|
||||
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')}"
|
||||
)
|
||||
|
||||
prompt = f"""
|
||||
{system_prompt}
|
||||
|
||||
你正在飞书群里以「{role_name}」身份回复用户。请基于以下上下文,生成一条 35-140 字中文回复:
|
||||
- 当前系统时间(北京时间):{now_cn_text}
|
||||
- 提及用户:{sender_name}
|
||||
- 用户消息:{(message_content or '')[:600]}
|
||||
- 系统处理状态:{status}
|
||||
- 分类结果:{classification}
|
||||
- 处理原因:{reason or "无"}
|
||||
- 处理追踪ID:{ai_trace_id or "无"}
|
||||
- 关联项目ID:{linked_projects_text or '无'}
|
||||
|
||||
回复要求:
|
||||
1. 语气自然、专业,避免机器口吻。
|
||||
2. 先确认你已处理,再给 1-3 条下一步建议。
|
||||
3. 若信息不足,请明确要补充什么。
|
||||
4. 如果用户询问“日期/今天/时间/几点/星期”,必须基于“当前系统时间(北京时间)”直接回答,不得编造历史日期。
|
||||
5. 不要输出 Markdown,不要输出 JSON。
|
||||
只输出最终回复正文。
|
||||
""".strip()
|
||||
prompt = PROMPT_FEISHU_HUMANIZED_REPLY.format(
|
||||
now_cn_text=now_cn_text,
|
||||
sender_name=sender_name,
|
||||
user_message=(message_content or "")[:600],
|
||||
status=status,
|
||||
classification=classification,
|
||||
reason=(reason or "无"),
|
||||
ai_trace_id=(ai_trace_id or "无"),
|
||||
linked_projects_text=(linked_projects_text or "无"),
|
||||
)
|
||||
|
||||
llm = LLMService()
|
||||
raw_reply = await llm.generate(
|
||||
|
|
@ -655,6 +668,8 @@ class FeishuIntegrationService:
|
|||
max_tokens=180,
|
||||
)
|
||||
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):
|
||||
reply_text = self._fallback_reply_text(capture_result)
|
||||
except Exception as exc:
|
||||
|
|
@ -667,6 +682,7 @@ class FeishuIntegrationService:
|
|||
mention_name=sender_name,
|
||||
text=reply_text,
|
||||
trace_id=trace_id,
|
||||
source_message_id=source_message_id,
|
||||
)
|
||||
|
||||
async def _safe_reply_text(
|
||||
|
|
@ -677,6 +693,7 @@ class FeishuIntegrationService:
|
|||
mention_open_id: Optional[str] = None,
|
||||
mention_name: Optional[str] = None,
|
||||
trace_id: Optional[str] = None,
|
||||
source_message_id: Optional[str] = None,
|
||||
) -> None:
|
||||
try:
|
||||
plain_text = self._normalize_outgoing_reply_text(text)
|
||||
|
|
@ -685,23 +702,16 @@ class FeishuIntegrationService:
|
|||
display_name=mention_name,
|
||||
)
|
||||
final_text = f"{mention_prefix}{plain_text}".strip()
|
||||
outbox_id = await self._enqueue_reply_outbox(
|
||||
await self._enqueue_reply_outbox(
|
||||
chat_id=chat_id,
|
||||
message_text=final_text,
|
||||
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:
|
||||
print(f"[Feishu] enqueue reply outbox failed: chat_id={chat_id}, error={exc}")
|
||||
try:
|
||||
plain_text = self._normalize_outgoing_reply_text(text)
|
||||
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}")
|
||||
# 统一由 drain_loop 消费 outbox,禁止业务路径直发,避免双发送。
|
||||
return
|
||||
|
||||
async def _enqueue_reply_outbox(
|
||||
self,
|
||||
|
|
@ -709,130 +719,90 @@ class FeishuIntegrationService:
|
|||
chat_id: str,
|
||||
message_text: str,
|
||||
trace_id: Optional[str],
|
||||
source_message_id: Optional[str],
|
||||
) -> int:
|
||||
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(
|
||||
tenant_id=int(self.tenant_id or 1),
|
||||
trace_id=(trace_id or "")[:64] or None,
|
||||
source_message_id=normalized_source_message_id,
|
||||
chat_id=(chat_id or "")[:128],
|
||||
message_text=(message_text or "")[:2000] or "已收到,正在处理。",
|
||||
message_text=normalized_message_text,
|
||||
reply_hash=reply_hash,
|
||||
status="pending",
|
||||
attempts=0,
|
||||
next_retry_at=None,
|
||||
)
|
||||
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()
|
||||
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)
|
||||
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
|
||||
async def dispatch_pending_outbox_entries(cls, *, limit: int = 30) -> int:
|
||||
now = datetime.utcnow()
|
||||
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:
|
||||
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(
|
||||
select(FeishuReplyOutbox.id)
|
||||
.where(
|
||||
|
|
@ -843,18 +813,121 @@ class FeishuIntegrationService:
|
|||
),
|
||||
)
|
||||
.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:
|
||||
return 0
|
||||
|
||||
await asyncio.gather(
|
||||
*(cls._dispatch_outbox_entry_by_id(outbox_id=int(outbox_id)) for outbox_id in due_rows),
|
||||
return_exceptions=True,
|
||||
claimed = (
|
||||
await db.execute(
|
||||
update(FeishuReplyOutbox)
|
||||
.where(
|
||||
FeishuReplyOutbox.id == int(candidate_id),
|
||||
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
|
||||
def _clean_reply_text(value: Optional[str]) -> str:
|
||||
|
|
@ -872,6 +945,53 @@ class FeishuIntegrationService:
|
|||
return cls._build_error_reply_text()
|
||||
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
|
||||
def _looks_like_provider_error(text: str) -> bool:
|
||||
lowered = str(text or "").strip().lower()
|
||||
|
|
|
|||
|
|
@ -106,7 +106,11 @@ FOLLOWUP_REFERENCE_RE = re.compile(
|
|||
re.IGNORECASE,
|
||||
)
|
||||
OBVIOUS_QUESTION_RE = re.compile(
|
||||
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|在哪|哪一个|哪几个)",
|
||||
r"([??]$|吗$|呢$|呀$|吧$|怎么|如何|为什么|多少|几号|几点|周几|有没有|是否|是不是|是什么|什么|哪些|有哪些|在哪|哪一个|哪几个|特点|能力|介绍|说明|风险)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
QUERY_INTENT_RE = re.compile(
|
||||
r"(哪些|有哪些|特点|技术特点|能力|介绍|说明|怎么|如何|是什么|什么|能做什么|可以做什么|项目有哪些风险)",
|
||||
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,
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
@ -2944,12 +2958,28 @@ class FeishuSignalPipelineService:
|
|||
text = str(message or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if cls._is_query_like_question(message):
|
||||
return True
|
||||
if cls._is_openclaw_business_command(text):
|
||||
return False
|
||||
if cls._looks_like_project_lookup_question(text):
|
||||
return True
|
||||
return bool(OBVIOUS_QUESTION_RE.search(text))
|
||||
|
||||
@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
|
||||
def _decorate_capture_result(
|
||||
result: dict[str, Any],
|
||||
|
|
@ -3450,6 +3480,8 @@ class FeishuSignalPipelineService:
|
|||
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(OPENCLAW_COMMAND_VERB_RE.search(text) and OPENCLAW_COMMAND_OBJECT_RE.search(text))
|
||||
|
|
|
|||
Loading…
Reference in New Issue