407 lines
15 KiB
Python
407 lines
15 KiB
Python
"""PMFlow FastAPI 应用入口。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import os
|
|
from contextlib import asynccontextmanager
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
import sqlalchemy as sa
|
|
|
|
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 .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.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
|
|
from .services.context_cache import register_context_cache_invalidation_hooks
|
|
|
|
configure_logging(settings.debug)
|
|
logger = get_logger(__name__)
|
|
register_context_cache_invalidation_hooks()
|
|
|
|
|
|
def _sqlite_backfill_columns(sync_conn) -> None:
|
|
"""为历史 SQLite 库补齐新增列,避免运行时 500。"""
|
|
dialect_name = str(sync_conn.dialect.name or "").lower()
|
|
if dialect_name != "sqlite":
|
|
return
|
|
|
|
def _columns(table_name: str) -> set[str]:
|
|
try:
|
|
rows = sync_conn.exec_driver_sql(f"PRAGMA table_info({table_name})").fetchall()
|
|
return {str(row[1]) for row in rows}
|
|
except Exception:
|
|
return set()
|
|
|
|
def _add_column(table_name: str, column_name: str, column_ddl: str) -> None:
|
|
cols = _columns(table_name)
|
|
if not cols or column_name in cols:
|
|
return
|
|
sync_conn.exec_driver_sql(
|
|
f"ALTER TABLE {table_name} ADD COLUMN {column_name} {column_ddl}"
|
|
)
|
|
|
|
# truth_nodes
|
|
_add_column("truth_nodes", "signal_id", "BIGINT")
|
|
|
|
# signals
|
|
_add_column("signals", "feishu_item_id", "BIGINT")
|
|
_add_column("signals", "matched_project_id", "BIGINT")
|
|
_add_column("signals", "title", "VARCHAR(200)")
|
|
_add_column("signals", "signal_type", "VARCHAR(50)")
|
|
_add_column("signals", "summary", "TEXT")
|
|
_add_column("signals", "reasoning", "TEXT")
|
|
_add_column("signals", "confidence", "FLOAT")
|
|
_add_column("signals", "recommended_action", "VARCHAR(50)")
|
|
_add_column("signals", "status", "VARCHAR(50) NOT NULL DEFAULT '待处理'")
|
|
|
|
# qa_records
|
|
_add_column("qa_records", "session_id", "INTEGER")
|
|
_add_column("qa_records", "turn_index", "INTEGER NOT NULL DEFAULT 1")
|
|
|
|
# ai_processing_logs
|
|
_add_column("ai_processing_logs", "normalized_text", "TEXT")
|
|
_add_column("ai_processing_logs", "route", "VARCHAR(80)")
|
|
_add_column("ai_processing_logs", "intent", "VARCHAR(40)")
|
|
_add_column("ai_processing_logs", "executor", "VARCHAR(40)")
|
|
_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)")
|
|
|
|
|
|
def _normalize_dialect(value: str) -> str:
|
|
raw = str(value or "").strip().lower()
|
|
if not raw:
|
|
return ""
|
|
if raw.startswith("postgres"):
|
|
return "postgresql"
|
|
if raw.startswith("sqlite"):
|
|
return "sqlite"
|
|
if raw.startswith("mysql"):
|
|
return "mysql"
|
|
if "+" in raw:
|
|
raw = raw.split("+", 1)[0]
|
|
if ":" in raw:
|
|
raw = raw.split(":", 1)[0]
|
|
return raw
|
|
|
|
|
|
def _mask_db_url(url: str) -> str:
|
|
value = str(url or "").strip()
|
|
if "@" not in value:
|
|
return value
|
|
prefix, suffix = value.split("@", 1)
|
|
if "://" not in prefix:
|
|
return f"***@{suffix}"
|
|
scheme, creds = prefix.split("://", 1)
|
|
user = creds.split(":", 1)[0] if ":" in creds else creds
|
|
return f"{scheme}://{user}:***@{suffix}"
|
|
|
|
|
|
def _check_database_dialect_guard(sync_conn) -> None:
|
|
configured = _normalize_dialect(settings.database_url)
|
|
actual = _normalize_dialect(sync_conn.dialect.name)
|
|
expected = _normalize_dialect(os.getenv("PMFLOW_EXPECTED_DB_DIALECT", ""))
|
|
|
|
env_urls = [
|
|
os.getenv("DATABASE_URL", ""),
|
|
os.getenv("SQLALCHEMY_DATABASE_URL", ""),
|
|
os.getenv("SYNC_DATABASE_URL", ""),
|
|
]
|
|
configured_families = {_normalize_dialect(item) for item in env_urls if str(item or "").strip()}
|
|
configured_families.discard("")
|
|
|
|
logger.info(
|
|
"database_dialect_guard",
|
|
configured=configured,
|
|
actual=actual,
|
|
expected=expected or "unset",
|
|
database_url=_mask_db_url(settings.database_url),
|
|
)
|
|
print(
|
|
"[DB-Guard] "
|
|
f"configured={configured}, actual={actual}, expected={expected or 'unset'}, "
|
|
f"url={_mask_db_url(settings.database_url)}"
|
|
)
|
|
if configured and actual and configured != actual:
|
|
raise RuntimeError(
|
|
f"数据库方言不一致: configured={configured}, actual={actual}。"
|
|
"请确保同一服务只连接单一数据库方言。"
|
|
)
|
|
if expected and actual and expected != actual:
|
|
raise RuntimeError(
|
|
f"数据库方言与 PMFLOW_EXPECTED_DB_DIALECT 不一致: expected={expected}, actual={actual}。"
|
|
)
|
|
if len(configured_families) > 1:
|
|
raise RuntimeError(
|
|
f"检测到多种数据库方言配置: {sorted(configured_families)}。"
|
|
"禁止同一服务混用 SQLite/Postgres。"
|
|
)
|
|
|
|
|
|
def _ensure_feishu_integrity_guards(sync_conn) -> None:
|
|
dialect = _normalize_dialect(sync_conn.dialect.name)
|
|
if dialect not in {"postgresql", "sqlite"}:
|
|
return
|
|
|
|
# Outbox 表兜底,兼容未跑 Alembic 的环境。
|
|
outbox_meta = sa.MetaData()
|
|
outbox_table = sa.Table(
|
|
"feishu_reply_outbox",
|
|
outbox_meta,
|
|
sa.Column("id", sa.Integer(), primary_key=True, autoincrement=True),
|
|
sa.Column("tenant_id", sa.BigInteger(), nullable=False, server_default=sa.text("1")),
|
|
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("chat_id", sa.String(length=128), nullable=False),
|
|
sa.Column("message_text", sa.Text(), nullable=False),
|
|
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),
|
|
sa.Column("sent_at", sa.TIMESTAMP(), nullable=True),
|
|
sa.Column("last_error", sa.Text(), nullable=True),
|
|
)
|
|
outbox_table.create(sync_conn, checkfirst=True)
|
|
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_chat_id ON feishu_reply_outbox (chat_id)"
|
|
)
|
|
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)"
|
|
)
|
|
|
|
inspector = sa.inspect(sync_conn)
|
|
if not inspector.has_table("feishu_items"):
|
|
return
|
|
|
|
duplicate_groups = sync_conn.execute(
|
|
sa.text(
|
|
"""
|
|
SELECT tenant_id, feishu_message_id, split_from_item_id, MIN(id) AS keep_id
|
|
FROM feishu_items
|
|
WHERE feishu_message_id IS NOT NULL
|
|
GROUP BY tenant_id, feishu_message_id, split_from_item_id
|
|
HAVING COUNT(*) > 1
|
|
"""
|
|
)
|
|
).fetchall()
|
|
|
|
table_exists = {
|
|
"signals": inspector.has_table("signals"),
|
|
"project_suggestions": inspector.has_table("project_suggestions"),
|
|
"ai_processing_logs": inspector.has_table("ai_processing_logs"),
|
|
"feishu_items": True,
|
|
}
|
|
|
|
for tenant_id, message_id, split_from_item_id, keep_id in duplicate_groups:
|
|
if split_from_item_id is None:
|
|
duplicate_ids = sync_conn.execute(
|
|
sa.text(
|
|
"""
|
|
SELECT id
|
|
FROM feishu_items
|
|
WHERE tenant_id = :tenant_id
|
|
AND feishu_message_id = :message_id
|
|
AND split_from_item_id IS NULL
|
|
AND id <> :keep_id
|
|
ORDER BY id ASC
|
|
"""
|
|
),
|
|
{
|
|
"tenant_id": int(tenant_id),
|
|
"message_id": str(message_id),
|
|
"keep_id": int(keep_id),
|
|
},
|
|
).fetchall()
|
|
else:
|
|
duplicate_ids = sync_conn.execute(
|
|
sa.text(
|
|
"""
|
|
SELECT id
|
|
FROM feishu_items
|
|
WHERE tenant_id = :tenant_id
|
|
AND feishu_message_id = :message_id
|
|
AND split_from_item_id = :split_from_item_id
|
|
AND id <> :keep_id
|
|
ORDER BY id ASC
|
|
"""
|
|
),
|
|
{
|
|
"tenant_id": int(tenant_id),
|
|
"message_id": str(message_id),
|
|
"split_from_item_id": int(split_from_item_id),
|
|
"keep_id": int(keep_id),
|
|
},
|
|
).fetchall()
|
|
|
|
for (duplicate_id,) in duplicate_ids:
|
|
duplicate_id = int(duplicate_id)
|
|
canonical_id = int(keep_id)
|
|
if table_exists["feishu_items"]:
|
|
sync_conn.execute(
|
|
sa.text(
|
|
"UPDATE feishu_items SET split_from_item_id = :new_id "
|
|
"WHERE split_from_item_id = :old_id"
|
|
),
|
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
|
)
|
|
if table_exists["signals"]:
|
|
sync_conn.execute(
|
|
sa.text(
|
|
"UPDATE signals SET feishu_item_id = :new_id "
|
|
"WHERE feishu_item_id = :old_id"
|
|
),
|
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
|
)
|
|
if table_exists["project_suggestions"]:
|
|
sync_conn.execute(
|
|
sa.text(
|
|
"UPDATE project_suggestions SET feishu_item_id = :new_id "
|
|
"WHERE feishu_item_id = :old_id"
|
|
),
|
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
|
)
|
|
if table_exists["ai_processing_logs"]:
|
|
sync_conn.execute(
|
|
sa.text(
|
|
"UPDATE ai_processing_logs SET feishu_item_id = :new_id "
|
|
"WHERE feishu_item_id = :old_id"
|
|
),
|
|
{"new_id": canonical_id, "old_id": duplicate_id},
|
|
)
|
|
sync_conn.execute(
|
|
sa.text("DELETE FROM feishu_items WHERE id = :id"),
|
|
{"id": duplicate_id},
|
|
)
|
|
|
|
# 幂等唯一约束(含 split 维度)硬化;若存在重复历史数据将直接失败,避免静默错乱。
|
|
sync_conn.exec_driver_sql(
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_root
|
|
ON feishu_items (tenant_id, feishu_message_id)
|
|
WHERE split_from_item_id IS NULL AND feishu_message_id IS NOT NULL
|
|
"""
|
|
)
|
|
sync_conn.exec_driver_sql(
|
|
"""
|
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_feishu_items_tenant_message_split
|
|
ON feishu_items (tenant_id, feishu_message_id, split_from_item_id)
|
|
WHERE split_from_item_id IS NOT NULL AND feishu_message_id IS NOT NULL
|
|
"""
|
|
)
|
|
|
|
|
|
async def _feishu_outbox_drain_loop() -> None:
|
|
while True:
|
|
try:
|
|
dispatched = await dispatch_pending_feishu_replies(limit=50)
|
|
await asyncio.sleep(0.3 if dispatched > 0 else 2.0)
|
|
except asyncio.CancelledError:
|
|
break
|
|
except Exception as exc:
|
|
logger.warning("feishu_outbox_drain_failed", error=str(exc))
|
|
await asyncio.sleep(2.0)
|
|
|
|
|
|
@asynccontextmanager
|
|
async def lifespan(_: FastAPI):
|
|
"""应用生命周期:初始化表与系统技能标签。"""
|
|
auto_create_tables = os.getenv("AUTO_CREATE_TABLES", "true").lower() in {
|
|
"1",
|
|
"true",
|
|
"yes",
|
|
}
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(_check_database_dialect_guard)
|
|
if auto_create_tables:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
await conn.run_sync(_sqlite_backfill_columns)
|
|
await conn.run_sync(_ensure_feishu_integrity_guards)
|
|
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
created = await init_system_skill_tags(db, tenant_id=1)
|
|
if created:
|
|
logger.info("system_skill_tags_seeded", created=created)
|
|
except Exception as exc: # pragma: no cover
|
|
logger.warning("seed_system_skills_failed", error=str(exc))
|
|
try:
|
|
interrupted = await AIOrchestrationRunService(db).mark_stale_runs_interrupted(older_than_minutes=10)
|
|
if interrupted:
|
|
await db.commit()
|
|
logger.info("stale_ai_orchestration_runs_interrupted", count=interrupted)
|
|
except Exception as exc: # pragma: no cover
|
|
logger.warning("interrupt_stale_ai_orchestration_runs_failed", error=str(exc))
|
|
|
|
scheduler = WorkloadRecalcScheduler(AsyncSessionLocal)
|
|
scheduler.start()
|
|
knowledge_scheduler = KnowledgeUrlRefreshScheduler(AsyncSessionLocal)
|
|
knowledge_scheduler.start()
|
|
kg_scheduler = KnowledgeGraphIncrementalScheduler(AsyncSessionLocal)
|
|
kg_scheduler.start()
|
|
ai_pmo_cadence_scheduler = AIPMOCadenceScheduler(AsyncSessionLocal)
|
|
ai_pmo_cadence_scheduler.start()
|
|
feishu_lc = init_feishu_long_connection_manager(AsyncSessionLocal)
|
|
await feishu_lc.start()
|
|
outbox_task = asyncio.create_task(
|
|
_feishu_outbox_drain_loop(),
|
|
name="feishu-outbox-drain-loop",
|
|
)
|
|
|
|
yield
|
|
outbox_task.cancel()
|
|
try:
|
|
await outbox_task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
await feishu_lc.stop()
|
|
await ai_pmo_cadence_scheduler.stop()
|
|
await kg_scheduler.stop()
|
|
await knowledge_scheduler.stop()
|
|
await scheduler.stop()
|
|
await engine.dispose()
|
|
|
|
|
|
app = FastAPI(
|
|
title=settings.app_name,
|
|
debug=settings.debug,
|
|
version="0.1.0",
|
|
lifespan=lifespan,
|
|
)
|
|
|
|
origins = [item.strip() for item in settings.cors_origins.split(",") if item.strip()]
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=origins or ["*"],
|
|
allow_credentials=True,
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
|
|
app.include_router(api_router)
|
|
|
|
|
|
@app.get("/healthz")
|
|
async def healthz() -> dict:
|
|
return success_response({"status": "ok"})
|