feat(backend): knowledge iteration + vision + neo4j + response contract source
dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。
This commit is contained in:
+4
-4
@@ -19,13 +19,13 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 复制依赖声明文件并安装(利用 Docker 层缓存,依赖不变则不重新安装)
|
||||
# 使用清华大学 PyPI 镜像源,解决公司内网下载 PyPI 官方源超时问题
|
||||
# 使用阿里云 PyPI 镜像(比清华镜像更快)
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir \
|
||||
--timeout 120 \
|
||||
--timeout 180 \
|
||||
--retries 5 \
|
||||
-i https://pypi.tuna.tsinghua.edu.cn/simple/ \
|
||||
--trusted-host pypi.tuna.tsinghua.edu.cn \
|
||||
-i https://mirrors.aliyun.com/pypi/simple/ \
|
||||
--trusted-host mirrors.aliyun.com \
|
||||
-r requirements.txt
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Backend package
|
||||
@@ -0,0 +1,140 @@
|
||||
"""add graph/confidence/audience fields to knowledge_suggestions and knowledge_base
|
||||
|
||||
扩充 KnowledgeSuggestion 12 个字段(confidence/audience/issue/action/relation_type/
|
||||
parent_issue/graph_meta/graph_sync_status/source_failed/queued_at/applied_at)和
|
||||
KnowledgeBase 2 个字段(graph_sync_status/graph_node_uuid)。
|
||||
|
||||
关联设计文档:增量设计-知识库迭代与痛点缓解 §2.1
|
||||
|
||||
Revision ID: 045_graph_confidence_audience
|
||||
Revises: 044_automation
|
||||
Create Date: 2026-07-11
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '045_graph_confidence_audience'
|
||||
down_revision: Union[str, None] = '044_automation'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. knowledge_suggestions 表 — 新增 12 个字段
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('confidence', sa.Float(), nullable=True,
|
||||
comment='AI 生成置信度(0.0-1.0)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('audience', sa.String(30), nullable=True,
|
||||
comment='受众类型:employee_quick_reply / engineer_workguide'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('issue', sa.String(256), nullable=True,
|
||||
comment='图节点:问题名称(对应 Neo4j Issue.name)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('action', sa.String(256), nullable=True,
|
||||
comment='图节点:动作名称(对应 Neo4j Action.name)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('relation_type', sa.String(30), nullable=True,
|
||||
comment='图关系类型:LEADS_TO / RELATES_TO / CAN_JUMP_TO'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('parent_issue', sa.String(256), nullable=True,
|
||||
comment='父 Issue 名称(用于图关系构建)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('graph_meta', sa.JSON(), nullable=True,
|
||||
comment='图结构扩展元数据(JSON)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('graph_sync_status', sa.String(20), nullable=False,
|
||||
server_default='pending',
|
||||
comment='图同步状态:pending / synced / failed'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('source_failed', sa.Boolean(), nullable=False,
|
||||
server_default=sa.text('false'),
|
||||
comment='AI 生成失败标记(Dify 不可用或置信度不足)'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='入队列时间'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_suggestions',
|
||||
sa.Column('applied_at', sa.DateTime(timezone=True), nullable=True,
|
||||
comment='应用到 KB 的时间'),
|
||||
)
|
||||
|
||||
# 新增索引
|
||||
op.create_index(
|
||||
'idx_suggestion_audience', 'knowledge_suggestions', ['audience'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_suggestion_confidence', 'knowledge_suggestions', ['confidence'],
|
||||
)
|
||||
op.create_index(
|
||||
'idx_suggestion_graph_sync', 'knowledge_suggestions', ['graph_sync_status'],
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. knowledge_base 表 — 新增 2 个字段
|
||||
# --------------------------------------------------------------------------
|
||||
op.add_column(
|
||||
'knowledge_base',
|
||||
sa.Column('graph_sync_status', sa.String(20), nullable=False,
|
||||
server_default='pending',
|
||||
comment='图同步状态:pending / synced / failed'),
|
||||
)
|
||||
op.add_column(
|
||||
'knowledge_base',
|
||||
sa.Column('graph_node_uuid', sa.String(36), nullable=True,
|
||||
comment='关联 Neo4j Issue 节点的 uuid'),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# --------------------------------------------------------------------------
|
||||
# 回滚 knowledge_suggestions
|
||||
# --------------------------------------------------------------------------
|
||||
op.drop_index('idx_suggestion_graph_sync', table_name='knowledge_suggestions')
|
||||
op.drop_index('idx_suggestion_confidence', table_name='knowledge_suggestions')
|
||||
op.drop_index('idx_suggestion_audience', table_name='knowledge_suggestions')
|
||||
|
||||
op.drop_column('knowledge_suggestions', 'applied_at')
|
||||
op.drop_column('knowledge_suggestions', 'queued_at')
|
||||
op.drop_column('knowledge_suggestions', 'source_failed')
|
||||
op.drop_column('knowledge_suggestions', 'graph_sync_status')
|
||||
op.drop_column('knowledge_suggestions', 'graph_meta')
|
||||
op.drop_column('knowledge_suggestions', 'parent_issue')
|
||||
op.drop_column('knowledge_suggestions', 'relation_type')
|
||||
op.drop_column('knowledge_suggestions', 'action')
|
||||
op.drop_column('knowledge_suggestions', 'issue')
|
||||
op.drop_column('knowledge_suggestions', 'audience')
|
||||
op.drop_column('knowledge_suggestions', 'confidence')
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 回滚 knowledge_base
|
||||
# --------------------------------------------------------------------------
|
||||
op.drop_column('knowledge_base', 'graph_node_uuid')
|
||||
op.drop_column('knowledge_base', 'graph_sync_status')
|
||||
@@ -144,26 +144,32 @@ async def list_user_role_assignments(
|
||||
Returns:
|
||||
List of user role assignments with employee_id, role info, source, etc.
|
||||
"""
|
||||
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
|
||||
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
|
||||
# assigned_at 定义为 NOT NULL,nulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
|
||||
stmt = (
|
||||
select(UserRole, Role)
|
||||
.join(Role, UserRole.role_id == Role.id)
|
||||
.order_by(UserRole.assigned_at.desc().nulls_last())
|
||||
.outerjoin(Role, UserRole.role_id == Role.id)
|
||||
.order_by(UserRole.assigned_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
|
||||
assignments = []
|
||||
for user_role, role in rows:
|
||||
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
|
||||
role_name = role.name if role else "unknown"
|
||||
role_display = (role.display_name or role.name) if role else "未知角色"
|
||||
assignments.append({
|
||||
"employee_id": user_role.employee_id,
|
||||
"role_name": role.name,
|
||||
"role_display_name": role.display_name or role.name,
|
||||
"role_name": role_name,
|
||||
"role_display_name": role_display,
|
||||
"source": user_role.source or "manual",
|
||||
"assigned_by": user_role.assigned_by or "",
|
||||
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
|
||||
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
||||
})
|
||||
|
||||
|
||||
return success_response(data=assignments)
|
||||
|
||||
|
||||
|
||||
@@ -78,11 +78,12 @@ async def get_current_admin_user(
|
||||
# 1. GET /api/admin/users — 获取管理员列表
|
||||
# =============================================================================
|
||||
@router.get("", response_model=None)
|
||||
@require_role("admin")
|
||||
async def list_admin_users(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
is_active: Optional[bool] = Query(None, description="是否激活(true=在线,false=离线)"),
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户列表。
|
||||
@@ -131,9 +132,10 @@ async def list_admin_users(
|
||||
# 2. POST /api/admin/users — 创建管理员
|
||||
# =============================================================================
|
||||
@router.post("", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def create_admin_user(
|
||||
body: AdminUserCreateRequest,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建管理员用户。
|
||||
@@ -184,9 +186,10 @@ async def create_admin_user(
|
||||
# 3. GET /api/admin/users/{id} — 获取管理员详情
|
||||
# =============================================================================
|
||||
@router.get("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def get_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户详情。
|
||||
@@ -224,10 +227,11 @@ async def get_admin_user(
|
||||
# 4. PUT /api/admin/users/{id} — 更新管理员
|
||||
# =============================================================================
|
||||
@router.put("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def update_admin_user(
|
||||
id: str,
|
||||
body: AdminUserUpdateRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新管理员用户。
|
||||
@@ -287,9 +291,10 @@ async def update_admin_user(
|
||||
# 5. DELETE /api/admin/users/{id} — 删除管理员
|
||||
# =============================================================================
|
||||
@router.delete("/{id}", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def delete_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除管理员用户。
|
||||
@@ -325,10 +330,11 @@ async def delete_admin_user(
|
||||
# 6. POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
@router.post("/{id}/reset-password", response_model=None)
|
||||
@require_role("admin")
|
||||
async def reset_password(
|
||||
id: str,
|
||||
body: AdminUserResetPasswordRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""重置管理员密码。
|
||||
|
||||
@@ -291,14 +291,20 @@ async def agent_login(
|
||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
from app.dependencies import get_redis
|
||||
|
||||
redis_client = await get_redis()
|
||||
token_service = TokenService(redis_client)
|
||||
# BUGFIX: 从 UserRole 表查询真实角色,而非硬编码 ["agent"]
|
||||
role_service = RoleMappingService(db)
|
||||
roles = await role_service.get_user_roles(agent.user_id)
|
||||
if not roles:
|
||||
roles = ["agent"] # 无角色时默认 fallback
|
||||
bind_token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name,
|
||||
roles=["agent"],
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="agent_pending_otp",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 独立审批队列 API(Tier1 新增)
|
||||
# =============================================================================
|
||||
# 说明:独立审批队列接口,管理超出会话上下文的待审批提案。
|
||||
# 1. GET /queued — 获取队列中的提案列表
|
||||
# 2. GET /queued/stats — 获取队列统计
|
||||
# 3. POST /queued/{id}/dequeue-approve — 队列中审批通过提案
|
||||
#
|
||||
# D7 硬约束:
|
||||
# - 提案默认 status=pending,不自动 applied
|
||||
# - 未处理的进入独立队列(queued)
|
||||
# - 72 小时超时 → expired
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import KnowledgeSuggestionResponse
|
||||
from app.schemas.enums import SuggestionStatusEnum
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取独立队列列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued
|
||||
@router.get("/queued")
|
||||
@require_admin
|
||||
async def list_queued_suggestions(
|
||||
status: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选状态:pending/queued(不传则返回 pending+queued)",
|
||||
),
|
||||
audience: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选受众:employee_quick_reply/engineer_workguide",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取独立队列中的提案列表。
|
||||
|
||||
默认返回 status=pending 和 status=queued 的提案。
|
||||
支持按 audience 筛选和分页。
|
||||
|
||||
- **status**: 筛选状态(pending/queued)
|
||||
- **audience**: 按受众类型筛选
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 构建查询:pending 或 queued 状态的提案
|
||||
target_statuses = [status] if status else [
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
]
|
||||
|
||||
stmt = (
|
||||
select(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
.order_by(
|
||||
# 按入队时间降序(queued 的提案在前),然后按创建时间
|
||||
KnowledgeSuggestion.queued_at.desc().nullslast(),
|
||||
KnowledgeSuggestion.created_at.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
suggestions = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
KnowledgeSuggestionResponse.model_validate(s) for s in suggestions
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取队列统计(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued/stats
|
||||
@router.get("/queued/stats")
|
||||
@require_admin
|
||||
async def get_queue_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取独立审批队列统计信息。
|
||||
|
||||
返回:
|
||||
- queued_total: 队列中提案数
|
||||
- pending_total: 待审核提案数
|
||||
- by_audience: 按受众分组统计
|
||||
- by_source_type: 按来源分组统计
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
stats = await service.get_queue_stats(db)
|
||||
|
||||
# 补充按来源分组统计
|
||||
source_stats_stmt = (
|
||||
select(
|
||||
KnowledgeSuggestion.source_type,
|
||||
func.count(),
|
||||
)
|
||||
.where(
|
||||
KnowledgeSuggestion.status.in_([
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
])
|
||||
)
|
||||
.group_by(KnowledgeSuggestion.source_type)
|
||||
)
|
||||
source_result = await db.execute(source_stats_stmt)
|
||||
by_source_type = {row[0]: row[1] for row in source_result.fetchall()}
|
||||
|
||||
stats["by_source_type"] = by_source_type
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": stats,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve
|
||||
@router.post("/queued/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过提案。
|
||||
|
||||
流程:queued → approved → applied → graph_synced(同 approve_suggestion)。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从独立队列审批通过: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
@@ -43,7 +43,7 @@ from app.api.agents import get_current_agent
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.automation import (
|
||||
CreateSessionRequest,
|
||||
ResolutionFeedbackRequest,
|
||||
ResolveFeedbackRequest,
|
||||
ScenarioConfigResponse,
|
||||
ScenarioConfigUpdate,
|
||||
SessionResponse,
|
||||
|
||||
@@ -115,9 +115,53 @@ async def list_conversations(
|
||||
for emp_id in employee_ids:
|
||||
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
|
||||
|
||||
# ── BUGFIX: 批量回退查询员工信息 ──
|
||||
# 为什么需要:conversations 表中 employee_name/department/position 是冗余字段,
|
||||
# 在会话创建时可能为空(异步创建、企微回调延迟等),导致列表API返回空字符串。
|
||||
# 当这些字段为空时,从 employees 表批量查询并回填,确保坐席端能看到完整用户信息。
|
||||
# 何时触发:仅当 conversations 表中的 employee_name 为空字符串时才会去 employees 表查找。
|
||||
employee_name_map: dict[str, dict] = {}
|
||||
empty_name_conv_ids = [
|
||||
conv.employee_id for conv in conversations
|
||||
if not conv.employee_name and conv.employee_id
|
||||
]
|
||||
if empty_name_conv_ids:
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id.in_(empty_name_conv_ids))
|
||||
result = await db.execute(stmt)
|
||||
for emp in result.scalars().all():
|
||||
employee_name_map[emp.employee_id] = {
|
||||
"name": emp.name or "",
|
||||
"department": emp.department or "",
|
||||
"position": emp.position or "",
|
||||
"level": getattr(emp, "it_level", "") or "",
|
||||
}
|
||||
if employee_name_map:
|
||||
logger.info(
|
||||
f"从employees表批量回退获取员工信息: "
|
||||
f"请求={len(empty_name_conv_ids)}, 命中={len(employee_name_map)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"从employees表批量回退获取员工信息失败: error={e}")
|
||||
|
||||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
|
||||
items = []
|
||||
for conv in conversations:
|
||||
# ── BUGFIX: 应用 employees 表回退信息 ──
|
||||
# 如果 conv 的 employee_name 为空,用批量查询结果回填
|
||||
# 这样 ConversationResponse.model_validate 序列化时就能拿到正确的值
|
||||
emp_fallback = employee_name_map.get(conv.employee_id, {})
|
||||
if emp_fallback:
|
||||
if not conv.employee_name and emp_fallback.get("name"):
|
||||
conv.employee_name = emp_fallback["name"]
|
||||
if not conv.department and emp_fallback.get("department"):
|
||||
conv.department = emp_fallback["department"]
|
||||
if not conv.position and emp_fallback.get("position"):
|
||||
conv.position = emp_fallback["position"]
|
||||
if not conv.level and emp_fallback.get("level"):
|
||||
conv.level = emp_fallback["level"]
|
||||
|
||||
conv_data = ConversationResponse.model_validate(conv).model_dump()
|
||||
# 员工头像(从缓存获取)
|
||||
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
|
||||
@@ -188,7 +232,7 @@ async def get_conversation(
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = employee.level or ""
|
||||
conversation.level = getattr(employee, "it_level", "") or ""
|
||||
logger.info(
|
||||
f"从employees表回退获取会话详情员工信息: employee_id={conversation.employee_id}, "
|
||||
f"name={employee.name}"
|
||||
@@ -275,6 +319,7 @@ async def resolve_conversation(
|
||||
"""结单。
|
||||
|
||||
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
|
||||
结单完成后异步触发知识建议生成(通道 A 全链路闭环)。
|
||||
|
||||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||||
协作坐席和其他坐席不能结单。
|
||||
@@ -303,6 +348,51 @@ async def resolve_conversation(
|
||||
conversation = await session_service.resolve_conversation(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
|
||||
# ── 任务1(P0):会话关闭→异步触发知识建议生成 ──
|
||||
# 在结单响应返回后,异步调用 Dify 生成知识迭代建议。
|
||||
# 使用 FastAPI BackgroundTasks 确保不阻塞结单响应。
|
||||
try:
|
||||
from fastapi import BackgroundTasks
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _trigger_knowledge_suggestion():
|
||||
"""异步生成知识建议的后台任务(独立 db session)。"""
|
||||
from app.database import _get_session_factory
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService
|
||||
|
||||
factory = _get_session_factory()
|
||||
async with factory() as bg_db:
|
||||
try:
|
||||
knowledge_service = KnowledgeIterationService()
|
||||
suggestion = await knowledge_service.generate_knowledge_suggestion(
|
||||
db=bg_db,
|
||||
source_type="conversation",
|
||||
source_data=[str(conversation_id)],
|
||||
reason=f"会话'{conversation_id}'已结单,自动生成知识迭代建议",
|
||||
)
|
||||
if suggestion:
|
||||
bg_db.add(suggestion)
|
||||
await bg_db.commit()
|
||||
logger.info(
|
||||
f"会话关闭→知识建议已生成: conv_id={conversation_id}, "
|
||||
f"suggestion_id={suggestion.id}, type={suggestion.suggestion_type}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"会话关闭→无知识建议生成(Dify不可用或无需建议): "
|
||||
f"conv_id={conversation_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"会话关闭→知识建议生成失败: conv_id={conversation_id}, error={e}")
|
||||
|
||||
# 创建后台任务(不阻塞结单响应)
|
||||
_asyncio.ensure_future(_trigger_knowledge_suggestion())
|
||||
logger.info(f"会话结单完成,已触发异步知识建议生成: conv_id={conversation_id}")
|
||||
except Exception as e:
|
||||
# 知识建议生成失败不影响结单主流程
|
||||
logger.warning(f"触发异步知识建议生成失败(不影响结单): {e}")
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
|
||||
+27
-75
@@ -45,7 +45,7 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.utils.env_gating import is_production
|
||||
from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler
|
||||
from app.dependencies import dep_redis, dep_wecom_service
|
||||
from app.models.approval_link import ApprovalLink
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
@@ -58,7 +58,9 @@ from app.schemas.h5 import (
|
||||
)
|
||||
from app.schemas.conversation import ConversationResponse, JoinConversationRequest
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.services.ai_handler import AIHandler
|
||||
import asyncio
|
||||
|
||||
from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -822,7 +824,6 @@ async def h5_send_message(
|
||||
body: dict,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
ai_handler: AIHandler = Depends(dep_ai_handler),
|
||||
):
|
||||
"""H5 用户发送消息(含 AI 回复与计数)。
|
||||
|
||||
@@ -893,47 +894,9 @@ async def h5_send_message(
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 3. 调用 AIHandler 统一处理(打招呼检测 → 呼叫人工拦截 → AI 调用)
|
||||
ai_result = await ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
)
|
||||
|
||||
# 4. 根据 AIHandler 返回结果更新会话状态
|
||||
# 更新 Dify 会话ID(多轮对话上下文)
|
||||
if ai_result.dify_conversation_id:
|
||||
conversation.dify_conversation_id = ai_result.dify_conversation_id
|
||||
|
||||
# 更新 AI 实质性回复计数(仅 AI 命中时 +1)
|
||||
if ai_result.should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
|
||||
# 更新会话状态(未命中转人工时改为 queued)
|
||||
if ai_result.should_transfer:
|
||||
conversation.status = "queued"
|
||||
|
||||
db.add(conversation)
|
||||
|
||||
# 5. 创建 AI 回复消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="AI智能助手",
|
||||
content=ai_result.content,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 6. WebSocket 广播:通知坐席端有新消息
|
||||
# 做什么:向所有在线坐席广播 new_message 事件,携带用户消息和 AI 回复
|
||||
# 为什么:坐席端需要实时看到员工的新消息和 AI 回复,
|
||||
# 仅靠3秒轮询会有延迟,WS 推送更实时
|
||||
# 3. 广播用户消息给坐席端(员工端靠乐观更新已显示自己消息)
|
||||
# 为什么:坐席端需实时看到员工新消息,仅依赖 3 秒轮询会有延迟
|
||||
try:
|
||||
# 广播用户消息
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
@@ -948,41 +911,30 @@ async def h5_send_message(
|
||||
"tags": conversation.tags,
|
||||
},
|
||||
})
|
||||
# 广播 AI 回复
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(ai_message.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "AI智能助手",
|
||||
"content": ai_result.content,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
# 如果会话状态变更(如新会话创建或转人工),也广播状态变更
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_updated",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"status": conversation.status,
|
||||
"assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
# WS 广播失败不阻塞消息存储,只记录 warning
|
||||
logger.warning(f"WS 广播新消息失败(消息已存储): {ws_err}")
|
||||
logger.warning(f"WS 广播用户消息失败(消息已存储): {ws_err}")
|
||||
|
||||
# 7. 返回用户消息 + AI 回复
|
||||
# 4. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
||||
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
||||
# 发送接口瞬时返回,前端不再卡"发送中"
|
||||
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
conversation_id=str(conversation.id),
|
||||
employee_id=employee_id,
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 立即返回用户消息(AI 回复经 WS 异步推送,不在此同步返回)
|
||||
user_msg_data = MessageResponse.model_validate(message).model_dump()
|
||||
ai_msg_data = MessageResponse.model_validate(ai_message).model_dump()
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"user_message": user_msg_data,
|
||||
"ai_reply": ai_msg_data,
|
||||
"is_guidance": ai_result.is_guidance,
|
||||
"ai_reply": None,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
@@ -1173,14 +1125,14 @@ async def shake(
|
||||
# 无活跃会话 → 拒绝,必须先与 AI 互动(前端按钮此时不应出现,这是后端兜底)
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
@@ -1327,14 +1279,14 @@ async def call_agent(
|
||||
if not conversation:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API(Tier1 扩展)
|
||||
# =============================================================================
|
||||
# 说明:知识库自动迭代相关接口
|
||||
# 说明:知识库自动迭代相关接口(扩展版)。
|
||||
# 1. POST /api/admin/knowledge-iteration/analyze - 触发分析并生成建议
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表(支持 audience/confidence 筛选)
|
||||
# 3. GET /api/admin/knowledge-iteration/suggestions/{id} - 获取建议详情
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过(触发Neo4j写图)
|
||||
# 5. POST /api/admin/knowledge-iteration/suggestions/{id}/reject - 审核拒绝
|
||||
# 6. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# 6. POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite - 改写提案(Tier1新增)
|
||||
# 7. POST /api/admin/knowledge-iteration/suggestions/{id}/queue - 放入独立队列(Tier1新增)
|
||||
# 8. POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve - 队列中审批(Tier1新增)
|
||||
# 9. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
@@ -17,19 +20,22 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import (
|
||||
KnowledgeSuggestionListResponse,
|
||||
KnowledgeSuggestionResponse,
|
||||
KnowledgeSuggestionStatsResponse,
|
||||
KnowledgeSuggestionApprove,
|
||||
KnowledgeSuggestionReject,
|
||||
KnowledgeSuggestionRewrite,
|
||||
KnowledgeSuggestionMerge,
|
||||
)
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,21 +47,22 @@ router = APIRouter()
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/analyze
|
||||
@router.post("/analyze")
|
||||
@require_admin
|
||||
async def trigger_analysis(
|
||||
days: int = Query(default=7, ge=1, le=90, description="分析过去N天的数据"),
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""触发知识库迭代分析。
|
||||
|
||||
分析过去N天的标注数据和会话数据,自动生成优化建议。
|
||||
分析过去N天的标注数据和会话数据,调用 Dify AI 自动生成优化建议。
|
||||
|
||||
- **days**: 分析过去N天的数据(默认7天,最大90天)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(f"管理员 {current_user.username} 触发了知识库迭代分析, days={days}")
|
||||
logger.info(f"管理员 {current_user.name} 触发了知识库迭代分析, days={days}")
|
||||
|
||||
result = await service.analyze_and_generate_suggestions(db, days=days)
|
||||
|
||||
@@ -67,23 +74,30 @@ async def trigger_analysis(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议列表
|
||||
# 获取建议列表(Tier1 扩展:audience/confidence 筛选)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions
|
||||
@router.get("/suggestions")
|
||||
@require_admin
|
||||
async def list_suggestions(
|
||||
status: Optional[str] = Query(default=None, description="筛选状态"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型"),
|
||||
status: Optional[str] = Query(default=None, description="筛选状态:pending/queued/approved/rejected/applied/graph_synced/expired"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型:new_faq/update/outdated"),
|
||||
audience: Optional[str] = Query(default=None, description="筛选受众:employee_quick_reply/engineer_workguide"),
|
||||
confidence_min: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度下限"),
|
||||
confidence_max: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度上限"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议列表。
|
||||
"""获取知识库优化建议列表(Tier1 扩展:支持 audience/confidence 筛选)。
|
||||
|
||||
- **status**: 筛选状态(pending/approved/rejected/applied)
|
||||
- **suggestion_type**: 筛选类型(new_faq/update/outdated)
|
||||
- **status**: 筛选状态
|
||||
- **suggestion_type**: 筛选类型
|
||||
- **audience**: 按受众类型筛选(Tier1 新增)
|
||||
- **confidence_min**: 置信度下限(Tier1 新增)
|
||||
- **confidence_max**: 置信度上限(Tier1 新增)
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
@@ -100,6 +114,12 @@ async def list_suggestions(
|
||||
stmt = stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
stmt = stmt.where(KnowledgeSuggestion.suggestion_type == suggestion_type)
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
@@ -116,6 +136,12 @@ async def list_suggestions(
|
||||
count_stmt = count_stmt.where(
|
||||
KnowledgeSuggestion.suggestion_type == suggestion_type
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
@@ -137,9 +163,10 @@ async def list_suggestions(
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}
|
||||
@router.get("/suggestions/{suggestion_id}")
|
||||
@require_admin
|
||||
async def get_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库优化建议详情。
|
||||
@@ -167,39 +194,47 @@ async def get_suggestion(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核通过
|
||||
# 审核通过(Tier1 扩展:串联 Neo4j 写图)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve
|
||||
@router.post("/suggestions/{suggestion_id}/approve")
|
||||
@require_admin
|
||||
async def approve_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionApprove,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""审核通过知识库优化建议。
|
||||
"""审核通过知识库优化建议(Tier1:串联 Neo4j 写图 + 五态流转)。
|
||||
|
||||
审核通过后,如果是新FAQ或更新建议,将自动添加到知识库。
|
||||
审核通过后:
|
||||
1. 状态 pending/queued → approved → applied → graph_synced
|
||||
2. 自动创建 KnowledgeBase 条目(派生视图)
|
||||
3. 触发 Neo4j 图写入(D1 解读2 合一)
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 审核通过建议: {suggestion_id}"
|
||||
f"管理员 {current_user.name} 审核通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 尝试获取 Neo4j 客户端(可选,不影响审批主流程)
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.approve_suggestion(
|
||||
db, suggestion_id, current_user.id
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "审核通过,建议已应用到知识库",
|
||||
"message": "审核通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
@@ -209,10 +244,11 @@ async def approve_suggestion(
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject
|
||||
@router.post("/suggestions/{suggestion_id}/reject")
|
||||
@require_admin
|
||||
async def reject_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionReject,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
@@ -223,12 +259,12 @@ async def reject_suggestion(
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 拒绝建议: {suggestion_id}, "
|
||||
f"管理员 {current_user.name} 拒绝建议: {suggestion_id}, "
|
||||
f"理由: {body.reject_reason}"
|
||||
)
|
||||
|
||||
suggestion = await service.reject_suggestion(
|
||||
db, suggestion_id, current_user.id, body.reject_reason
|
||||
db, suggestion_id, current_user.employee_id, body.reject_reason
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
@@ -241,19 +277,141 @@ async def reject_suggestion(
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 改写提案(Tier1 新增 — D7 内联审批改写)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite
|
||||
@router.post("/suggestions/{suggestion_id}/rewrite")
|
||||
@require_admin
|
||||
async def rewrite_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionRewrite,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""训练师改写知识库优化建议(Tier1 新增)。
|
||||
|
||||
改写后提案状态重置为 pending,重新走审批流程。
|
||||
可修改字段:title、content、category、tags、confidence、audience、
|
||||
issue、action、relation_type、parent_issue。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 改写建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 将非 None 的字段收集为改写数据
|
||||
rewrite_data = body.model_dump(exclude_none=True, exclude_unset=True)
|
||||
|
||||
suggestion = await service.rewrite_suggestion(
|
||||
db, suggestion_id, current_user.employee_id, rewrite_data
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "提案已改写,等待重新审批",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 放入独立队列(Tier1 新增 — D7 独立队列)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue
|
||||
@router.post("/suggestions/{suggestion_id}/queue")
|
||||
@require_admin
|
||||
async def queue_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""将建议放入独立审批队列(Tier1 新增)。
|
||||
|
||||
当会话关闭且提案仍处于 pending 时调用,将提案状态改为 queued。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 将建议放入独立队列: {suggestion_id}"
|
||||
)
|
||||
|
||||
suggestion = await service.queue_suggestion(db, suggestion_id)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议已放入独立审批队列",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增 — D7 独立队列审批)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve
|
||||
@router.post("/suggestions/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过建议(Tier1 新增)。
|
||||
|
||||
流程与 approve 一致:状态流转 + KB 落库 + Neo4j 写图。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从队列中审批通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取统计
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/stats
|
||||
@router.get("/stats")
|
||||
@require_admin
|
||||
async def get_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议统计。
|
||||
|
||||
返回各状态的建议数量统计。
|
||||
返回各状态的建议数量统计(含 queued/graph_synced/expired)。
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
@@ -264,3 +422,146 @@ async def get_stats(
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionStatsResponse(**stats),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 知识图谱可视化(任务2:P2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/graph
|
||||
@router.get("/graph")
|
||||
@require_admin
|
||||
async def get_knowledge_graph(
|
||||
limit: int = Query(default=100, ge=10, le=500, description="节点数量上限"),
|
||||
issue_name: Optional[str] = Query(default=None, description="指定Issue名称查询子图"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识图谱数据(Neo4j 节点+关系 JSON 格式)。
|
||||
|
||||
返回 ECharts 力导向图兼容的节点和关系数据。
|
||||
支持全图查询(默认)和指定 Issue 的子图查询。
|
||||
|
||||
- **limit**: 节点数量上限(10-500)
|
||||
- **issue_name**: 指定 Issue 名称时查询子图(用于审批卡片预览)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
if not neo4j_client:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "Neo4j 不可用,图数据为空",
|
||||
"data": {"nodes": [], "links": []},
|
||||
}
|
||||
|
||||
if issue_name:
|
||||
graph_data = await neo4j_client.query_issue_subgraph(
|
||||
issue_name=issue_name, depth=1
|
||||
)
|
||||
else:
|
||||
graph_data = await neo4j_client.query_full_graph(limit=limit)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": graph_data,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 检查重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}/duplicates
|
||||
@router.get("/suggestions/{suggestion_id}/duplicates")
|
||||
@require_admin
|
||||
async def check_duplicates(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""检查指定建议是否存在重复(利用 Neo4j 图结构 + SQL 文本相似)。
|
||||
|
||||
在采纳建议前调用,检测是否有同名 Issue 或相似标题的已有条目。
|
||||
返回重复项列表供训练师参考。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
duplicates = await service.find_duplicates(
|
||||
db=db,
|
||||
issue_name=suggestion.issue,
|
||||
title=suggestion.title,
|
||||
suggestion_id=suggestion_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"suggestion_id": suggestion_id,
|
||||
"has_duplicates": len(duplicates) > 0,
|
||||
"duplicates": duplicates,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 合并重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/merge
|
||||
@router.post("/suggestions/{suggestion_id}/merge")
|
||||
@require_admin
|
||||
async def merge_suggestions(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionMerge,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""合并重复建议(去重操作)。
|
||||
|
||||
将 duplicate_id 的建议合并到当前建议(primary),
|
||||
标签和元数据合并,重复建议标记为 rejected(合并归入)。
|
||||
|
||||
- **suggestion_id**: 主建议ID(保留)
|
||||
- **duplicate_id**: 重复建议ID(将被合并)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 合并建议: "
|
||||
f"primary={suggestion_id}, duplicate={body.duplicate_id}"
|
||||
)
|
||||
|
||||
merged = await service.merge_suggestions(
|
||||
db=db,
|
||||
primary_id=suggestion_id,
|
||||
duplicate_id=body.duplicate_id,
|
||||
reviewer_id=current_user.employee_id,
|
||||
)
|
||||
|
||||
if not merged:
|
||||
return {"code": 404, "message": "主建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议合并完成,重复建议已标记为已驳回(合并归入)",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(merged),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RAGFlow 文档摄入 API(Tier1 新增 / P1-5 / 通道 C)
|
||||
# =============================================================================
|
||||
# 说明:RAGFlow 文档摄入接口,训练师上传非标准格式文档,
|
||||
# 经 RAGFlow ETL 整理/结构化后生成 KnowledgeSuggestion 进审批队列。
|
||||
#
|
||||
# 1. POST /api/ragflow/ingest — 上传文档触发 RAGFlow 处理
|
||||
# 2. GET /api/ragflow/tasks/{task_id} — 查询处理任务状态
|
||||
#
|
||||
# P1-5 硬约束:
|
||||
# - 触发方式:训练师手动上传(非定时扫描)
|
||||
# - 支持格式:.docx/.pdf/.txt/.png/.jpg
|
||||
# - source_type=document_ragflow, audience=engineer_workguide
|
||||
# - 产出走 D7 审批流
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.enums import (
|
||||
AudienceEnum,
|
||||
GraphSyncStatusEnum,
|
||||
SourceTypeEnum,
|
||||
SuggestionStatusEnum,
|
||||
)
|
||||
from app.services.ragflow_ingestion_service import RagflowIngestionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 支持的文件格式
|
||||
ALLOWED_EXTENSIONS = {".docx", ".pdf", ".txt", ".png", ".jpg", ".jpeg"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
||||
"application/pdf", # .pdf
|
||||
"text/plain", # .txt
|
||||
"image/png", # .png
|
||||
"image/jpeg", # .jpg/.jpeg
|
||||
}
|
||||
|
||||
# 文件大小上限(20MB)
|
||||
MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
# 内存中的任务状态缓存(生产环境应迁移到 Redis)
|
||||
_task_cache: dict = {}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 上传文档触发 RAGFlow 处理(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/ragflow/ingest
|
||||
@router.post("/ingest")
|
||||
@require_admin
|
||||
async def ingest_document(
|
||||
file: UploadFile = File(..., description="文档文件(.docx/.pdf/.txt/.png/.jpg)"),
|
||||
category_hint: str = Form(
|
||||
default="其他",
|
||||
description="分类提示(可选,帮助RAGFlow归类):硬件/软件/网络/安全/账号/其他",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""上传非标准格式文档到 RAGFlow 进行 ETL 处理。
|
||||
|
||||
训练师上传文档后,RAGFlow 自动整理/筛选/结构化内容,
|
||||
生成 KnowledgeSuggestion 提案进入 D7 审批队列。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **file**: 文档文件(必填,支持 .docx/.pdf/.txt/.png/.jpg)
|
||||
- **category_hint**: 分类提示(可选,默认"其他")
|
||||
|
||||
**文件大小限制**: 最大 20MB
|
||||
**处理时间**: 最长等待 5 分钟,超时返回 pending 状态
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 校验文件扩展名
|
||||
file_name = file.filename or "unknown"
|
||||
ext = "." + file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的文件格式: {ext},仅支持 {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 校验 MIME 类型(如可获取)
|
||||
if file.content_type and file.content_type not in ALLOWED_MIME_TYPES:
|
||||
logger.warning(
|
||||
f"文件 MIME 类型不在白名单中: {file.content_type},仍允许上传"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
file_data = await file.read()
|
||||
|
||||
# 校验文件大小
|
||||
if len(file_data) > MAX_FILE_SIZE:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"文件过大({len(file_data) / 1024 / 1024:.1f}MB),最大支持 20MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
if len(file_data) == 0:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": "文件内容为空",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用 RAGFlow Ingestion 服务
|
||||
service = RagflowIngestionService()
|
||||
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 上传文档到 RAGFlow: "
|
||||
f"file_name={file_name}, category_hint={category_hint}, size={len(file_data)}"
|
||||
)
|
||||
|
||||
result = await service.upload_and_process(file_data, file_name, category_hint)
|
||||
|
||||
# 将生成的 suggestions 写入数据库(pending 状态)
|
||||
saved_suggestions = []
|
||||
if result.get("suggestions"):
|
||||
for sug_data in result["suggestions"]:
|
||||
suggestion = KnowledgeSuggestion(
|
||||
suggestion_type=sug_data.get("suggestion_type", "new_faq"),
|
||||
status=SuggestionStatusEnum.pending.value,
|
||||
title=sug_data.get("title", ""),
|
||||
content=sug_data.get("content", ""),
|
||||
category=sug_data.get("category", category_hint),
|
||||
tags=sug_data.get("tags", []),
|
||||
source_type=SourceTypeEnum.document_ragflow.value,
|
||||
source_data=sug_data.get("source_data", []),
|
||||
reason=sug_data.get("reason", ""),
|
||||
confidence=sug_data.get("confidence", 0.85),
|
||||
audience=AudienceEnum.engineer_workguide.value, # 通道 C 默认
|
||||
issue=sug_data.get("issue", ""),
|
||||
action=sug_data.get("action", ""),
|
||||
relation_type=sug_data.get("relation_type", "LEADS_TO"),
|
||||
parent_issue=sug_data.get("parent_issue", ""),
|
||||
graph_meta=sug_data.get("graph_meta", {}),
|
||||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||||
source_failed=sug_data.get("source_failed", False),
|
||||
)
|
||||
db.add(suggestion)
|
||||
saved_suggestions.append({
|
||||
"title": suggestion.title,
|
||||
"category": suggestion.category,
|
||||
"confidence": suggestion.confidence,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"RAGFlow 生成 {len(saved_suggestions)} 条 KnowledgeSuggestion 待审批")
|
||||
|
||||
# 缓存任务状态
|
||||
task_id = result["task_id"]
|
||||
_task_cache[task_id] = {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"created_at": __import__("datetime").datetime.now().isoformat(),
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "文档已提交 RAGFlow 处理",
|
||||
"data": {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
"suggestions": saved_suggestions,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 查询处理任务状态(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/ragflow/tasks/{task_id}
|
||||
@router.get("/tasks/{task_id}")
|
||||
@require_admin
|
||||
async def get_ingestion_task_status(
|
||||
task_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""查询 RAGFlow 文档处理任务状态。
|
||||
|
||||
- **task_id**: 任务ID(来自 ingest 接口返回值)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
task = _task_cache.get(task_id)
|
||||
if not task:
|
||||
return {
|
||||
"code": 404,
|
||||
"message": "任务不存在或已过期",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": task,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 视觉理解 API(Tier1 新增 / D5 / P1-3)
|
||||
# =============================================================================
|
||||
# 说明:截图视觉理解接口,调用本地 Qwen-VL(经 Dify vision workflow)
|
||||
# 分析员工截图,返回结构化描述文本。
|
||||
#
|
||||
# 1. POST /api/vision/analyze — 分析截图(multipart: image + conversation_id)
|
||||
# 2. GET /api/vision/models — 可用的视觉模型列表
|
||||
#
|
||||
# D5 硬约束:
|
||||
# - 视觉理解经 Dify 后端调用本地 Qwen-VL(Qwen3-VL-8B-Instruct)
|
||||
# - 预留 vision_model 参数以便后续升级
|
||||
# - 截图隐私仅保留接口(D6),不阻断消息
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_any_user, UserInfo
|
||||
from app.services.vision_service import VisionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 分析截图(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/vision/analyze
|
||||
@router.post("/analyze")
|
||||
@require_any_user
|
||||
async def analyze_screenshot(
|
||||
image: UploadFile = File(..., description="截图文件(支持 PNG/JPG/GIF)"),
|
||||
conversation_id: str = Form(..., description="会话ID(用于上下文关联)"),
|
||||
vision_model: str = Form(
|
||||
default="",
|
||||
description="视觉模型名称(可选,默认使用配置中的模型)",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分析截图,返回 AI 视觉理解的结构化描述。
|
||||
|
||||
员工发送截图后,前端调用此接口将图片交给 Qwen-VL 视觉模型分析。
|
||||
分析结果将自动注入到对应会话的上下文中,参与后续 AI 推理。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **image**: 截图文件(必填)
|
||||
- **conversation_id**: 会话ID(必填)
|
||||
- **vision_model**: 视觉模型名称(可选,默认使用 Qwen3-VL-8B-Instruct)
|
||||
|
||||
**支持的文件格式**: PNG、JPG、GIF、WebP
|
||||
**文件大小限制**: 最大 10MB
|
||||
|
||||
**D5 隐私说明**: 截图分析结果仅供 AI 理解上下文使用,
|
||||
隐私检测接口已预留(D6),当前不阻断消息。
|
||||
"""
|
||||
# 校验文件类型
|
||||
allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
if image.content_type and image.content_type not in allowed_types:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的图片格式: {image.content_type},仅支持 PNG/JPG/GIF/WebP",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 读取图片字节流
|
||||
image_bytes = await image.read()
|
||||
|
||||
# 校验文件大小(最大 10MB)
|
||||
max_size = 10 * 1024 * 1024
|
||||
if len(image_bytes) > max_size:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"图片过大({len(image_bytes) / 1024 / 1024:.1f}MB),最大支持 10MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用视觉理解服务
|
||||
service = VisionService(
|
||||
model=vision_model if vision_model else None,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await service.analyze_screenshot(image_bytes, conversation_id)
|
||||
|
||||
# 将视觉描述注入会话上下文
|
||||
if result.get("description"):
|
||||
injected = await service.inject_to_conversation_context(
|
||||
result["description"], conversation_id
|
||||
)
|
||||
if injected:
|
||||
logger.info(
|
||||
f"视觉描述已注入会话 {conversation_id}: "
|
||||
f"confidence={result.get('confidence', 0):.2f}"
|
||||
)
|
||||
|
||||
await service.close()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "视觉分析完成",
|
||||
"data": {
|
||||
"description": result.get("description", ""),
|
||||
"confidence": result.get("confidence", 0.0),
|
||||
"metadata": result.get("metadata", {}),
|
||||
"injected": result.get("description", "") != "",
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await service.close()
|
||||
logger.error(f"视觉分析异常: {e}")
|
||||
return {
|
||||
"code": 500,
|
||||
"message": f"视觉分析失败: {str(e)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 可用的视觉模型列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/vision/models
|
||||
@router.get("/models")
|
||||
async def list_vision_models():
|
||||
"""获取当前可用的视觉模型列表。
|
||||
|
||||
返回系统配置的视觉模型信息,包括当前默认模型和可升级选项。
|
||||
|
||||
**无需鉴权(公开查询)。**
|
||||
"""
|
||||
models: List[dict] = [
|
||||
{
|
||||
"id": "Qwen3-VL-8B-Instruct",
|
||||
"name": "Qwen3-VL-8B-Instruct(默认)",
|
||||
"provider": "Qwen",
|
||||
"description": "本地部署的千问视觉模型,8B 参数,适用于一般截图理解",
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-VL-32B-Instruct",
|
||||
"name": "Qwen3-VL-32B-Instruct",
|
||||
"provider": "Qwen",
|
||||
"description": "千问视觉模型 32B 版本,精度更高但需要更多显存(≥48GB)",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"models": models,
|
||||
"default_model": settings.qwen_vl_model,
|
||||
},
|
||||
}
|
||||
@@ -203,6 +203,71 @@ class Settings(BaseSettings):
|
||||
# 管理后台可配(见 ScenarioConfig + 全局阈值),此处为默认值。
|
||||
automation_thresholds: str = '{"confidence_min":0.6,"timeout_seconds":60,"unresolved_threshold":2,"high_risk_force_handoff":true}'
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Neo4j 图数据库配置(知识图谱存储 — Tier0 / T01)
|
||||
# ----------------------------------------------------------------------
|
||||
# Neo4j bolt 协议连接地址(默认本地开发容器)
|
||||
neo4j_uri: str = "bolt://localhost:7687"
|
||||
# Neo4j 用户名
|
||||
neo4j_user: str = "neo4j"
|
||||
# Neo4j 密码(⚠️ 仅从环境变量注入,不设默认值)
|
||||
neo4j_password: str = ""
|
||||
# Neo4j 默认数据库名
|
||||
neo4j_database: str = "neo4j"
|
||||
# Neo4j 连接最大存活时间(秒)
|
||||
neo4j_max_connection_lifetime: int = 3600
|
||||
# Neo4j 连接池上限
|
||||
neo4j_max_connection_pool_size: int = 50
|
||||
# Neo4j 连接获取超时(秒)
|
||||
neo4j_connection_acquisition_timeout: int = 30
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 置信门控配置(D3 — 全局置信阈值)
|
||||
# ----------------------------------------------------------------------
|
||||
# AI 回复置信度低于此阈值时,前端渲染"转人工"入口
|
||||
# 可通过环境变量 CONFIDENCE_GATE_THRESHOLD 覆盖
|
||||
confidence_gate_threshold: float = 0.7
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# RAGFlow Ingestion 开关(通道 C — 文档→KB)
|
||||
# ----------------------------------------------------------------------
|
||||
# 是否启用 RAGFlow 文档 ingestion 功能(默认关闭,需部署 RAGFlow 服务后开启)
|
||||
ragflow_ingestion_enabled: bool = False
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Qwen-VL 视觉理解配置(D5 — 截图理解)
|
||||
# ----------------------------------------------------------------------
|
||||
# 本地 Qwen-VL 模型名称(Dify vision workflow 中配置的模型标识)
|
||||
qwen_vl_model: str = "Qwen3-VL-8B-Instruct"
|
||||
# Dify Vision Workflow API 端点(独立于 Wingman Agent)
|
||||
dify_vision_api_url: str = ""
|
||||
# Dify Vision Workflow API Key
|
||||
dify_vision_api_key: str = ""
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 阶段5 自动化闭环外部系统配置(简化命名,供管理后台展示)
|
||||
# ----------------------------------------------------------------------
|
||||
# Dify(意图识别 / AI 编排)
|
||||
dify_base_url: str = ""
|
||||
dify_key: str = ""
|
||||
|
||||
# RAGFlow(知识库检索)
|
||||
ragflow_base_url: str = ""
|
||||
|
||||
# 火绒终端安全(HRESS HMAC-SHA1 签名)
|
||||
huorong_base_url: str = ""
|
||||
huorong_key: str = ""
|
||||
huorong_secret: str = ""
|
||||
|
||||
# 联软 LV7000(三层认证:IP白名单 + 账号密码 + Token)
|
||||
lianruan_base_url: str = ""
|
||||
lianruan_username: str = ""
|
||||
lianruan_password: str = ""
|
||||
lianruan_api_key: str = ""
|
||||
|
||||
# 北森 EHR(静态映射兜底)
|
||||
ehr_base_url: str = ""
|
||||
|
||||
def get_automation_thresholds(self) -> dict:
|
||||
"""解析自动化阈值配置,返回带默认值的字典。
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 外部客户端包
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎专用外部系统客户端集合(环境变量 AUTOMATION_* 驱动)。
|
||||
# 导出基类、异常、各系统客户端及对应 get_*_client 工厂函数。
|
||||
# =============================================================================
|
||||
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
ClientConnectionError,
|
||||
)
|
||||
from app.core.clients.huorong import HuorongClient, get_huorong_client
|
||||
from app.core.clients.lianruan import LianruanClient, get_lianruan_client
|
||||
from app.core.clients.dify import DifyClient, get_dify_client
|
||||
from app.core.clients.ragflow import RagFlowClient, get_ragflow_client
|
||||
from app.core.clients.ehr import BeisenEHRClient, get_ehr_client
|
||||
|
||||
__all__ = [
|
||||
"BaseClient",
|
||||
"BaseClientError",
|
||||
"ClientConfigError",
|
||||
"ClientConnectionError",
|
||||
"ClientAuthError",
|
||||
"ClientAPIError",
|
||||
"HuorongClient",
|
||||
"get_huorong_client",
|
||||
"LianruanClient",
|
||||
"get_lianruan_client",
|
||||
"DifyClient",
|
||||
"get_dify_client",
|
||||
"RagFlowClient",
|
||||
"get_ragflow_client",
|
||||
"BeisenEHRClient",
|
||||
"get_ehr_client",
|
||||
]
|
||||
@@ -0,0 +1,184 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 Dify 意图识别客户端
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎的意图识别客户端,调用 Dify(OpenAI 兼容代理
|
||||
# http://yw-dify.dc.servyou-it.com/dify2openai/)识别员工诉求命中的场景。
|
||||
#
|
||||
# 生产基址(任务指定):http://yw-dify.dc.servyou-it.com/dify2openai/
|
||||
#
|
||||
# 返回结构(供 IntentRouter 使用):
|
||||
# {"scenario_key": str|None, "confidence": float, "raw": str, "error": str}
|
||||
# scenario_key ∈ {password_reset, software_install, virus_dispose, terminal_locate}
|
||||
#
|
||||
# 降级:Dify 未配置或调用失败 → _fallback_intent 走关键词兜底(关键词取自
|
||||
# app.services.automation.DEFAULT_SCENARIO_CONFIGS),保证无真实环境也能闭环。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientConfigError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 30.0
|
||||
# 意图识别提示词:要求模型仅输出 JSON
|
||||
_SYSTEM_PROMPT = (
|
||||
"你是IT服务台意图分类器。根据用户诉求,判断其属于以下哪个场景之一,"
|
||||
"并仅输出一个 JSON 对象,不要输出任何额外文字:\n"
|
||||
'{"scenario_key": "password_reset|software_install|virus_dispose|terminal_locate|unknown", '
|
||||
'"confidence": 0.0~1.0}\n'
|
||||
"场景说明:\n"
|
||||
"- password_reset: 忘记/重置密码、账号密码相关\n"
|
||||
"- software_install: 安装/下载软件\n"
|
||||
"- virus_dispose: 病毒、木马、勒索、杀毒\n"
|
||||
"- terminal_locate: 定位/查找我的电脑或终端\n"
|
||||
"- unknown: 不属于以上任何一类\n"
|
||||
"confidence 表示你对该判断的置信度(0~1)。"
|
||||
)
|
||||
|
||||
|
||||
class DifyClient(BaseClient):
|
||||
"""Dify 意图识别客户端(OpenAI 兼容)。"""
|
||||
|
||||
system = "dify"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
audit: Any = None,
|
||||
max_retries: int = 2,
|
||||
):
|
||||
if not base_url:
|
||||
raise ClientConfigError("Dify base_url 未配置")
|
||||
if not api_key:
|
||||
raise ClientConfigError("Dify api_key 未配置")
|
||||
# 统一 base_url 形态(确保末尾含 /v1 由调用方决定,此处仅保证无尾斜杠)
|
||||
super().__init__(base_url=base_url.rstrip("/"), timeout=timeout, audit=audit, max_retries=max_retries)
|
||||
self.api_key = api_key
|
||||
|
||||
def _chat_url(self) -> str:
|
||||
"""OpenAI 兼容 chat/completions 端点。"""
|
||||
base = self.base_url
|
||||
if base.endswith("/v1"):
|
||||
return f"{base}/chat/completions"
|
||||
return f"{base}/v1/chat/completions"
|
||||
|
||||
async def detect_intent(
|
||||
self, description: str, employee_id: str = ""
|
||||
) -> Dict[str, Any]:
|
||||
"""调用 Dify 识别意图。
|
||||
|
||||
Returns:
|
||||
Dict: {scenario_key, confidence, raw, error}
|
||||
"""
|
||||
prompt = description or ""
|
||||
body = {
|
||||
"model": "dify",
|
||||
"messages": [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"temperature": 0,
|
||||
"response_format": {"type": "json_object"},
|
||||
"user": employee_id or "automation",
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
try:
|
||||
data = await self._request(
|
||||
"POST", "/v1/chat/completions", json_body=body, headers=headers
|
||||
)
|
||||
except BaseClientError as e:
|
||||
logger.warning(f"Dify 调用失败: {e.message}")
|
||||
fb = self._fallback_intent(description)
|
||||
fb["error"] = e.message
|
||||
return fb
|
||||
|
||||
# 解析 OpenAI 兼容响应
|
||||
try:
|
||||
choices = data.get("choices") or []
|
||||
content = choices[0]["message"]["content"] if choices else ""
|
||||
parsed = json.loads(content)
|
||||
scenario_key = parsed.get("scenario_key")
|
||||
confidence = float(parsed.get("confidence", 0.0))
|
||||
return {
|
||||
"scenario_key": scenario_key if scenario_key != "unknown" else None,
|
||||
"confidence": confidence,
|
||||
"raw": content,
|
||||
"error": "",
|
||||
}
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning(f"Dify 响应解析失败: {e}")
|
||||
fb = self._fallback_intent(description)
|
||||
fb["error"] = f"parse_error: {e}"
|
||||
return fb
|
||||
|
||||
@staticmethod
|
||||
def _fallback_intent(description: str) -> Dict[str, Any]:
|
||||
"""关键词兜底(无 Dify / 解析失败时)。
|
||||
|
||||
关键词取自 DEFAULT_SCENARIO_CONFIGS 的 trigger_conditions.keywords;
|
||||
命中即返回该场景,置信度取 0.6(恰好达到阈值,可继续编排)。
|
||||
"""
|
||||
text = (description or "").lower()
|
||||
scenario_key: Optional[str] = None
|
||||
try: # 懒加载,避免循环依赖
|
||||
from app.services.automation import DEFAULT_SCENARIO_CONFIGS
|
||||
|
||||
for key, cfg in DEFAULT_SCENARIO_CONFIGS.items():
|
||||
triggers = cfg.get("trigger_conditions") or {}
|
||||
keywords = triggers.get("keywords") or []
|
||||
if any(kw.lower() in text for kw in keywords):
|
||||
scenario_key = key
|
||||
break
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {
|
||||
"scenario_key": scenario_key,
|
||||
"confidence": 0.6 if scenario_key else 0.0,
|
||||
"raw": "",
|
||||
"error": "fallback",
|
||||
}
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""连接测试(轻量 chat 探测)。"""
|
||||
try:
|
||||
result = await self.detect_intent("测试连接")
|
||||
return {"success": True, "message": "Dify 可用", "scenario_key": result.get("scenario_key")}
|
||||
except BaseClientError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
|
||||
async def get_dify_client(audit: Any = None) -> Optional[DifyClient]:
|
||||
"""构建 Dify 客户端(环境变量 AUTOMATION_DIFY_* 驱动)。
|
||||
|
||||
未配置 → 返回 None(IntentRouter 自动走关键词兜底)。
|
||||
|
||||
Returns:
|
||||
Optional[DifyClient]: 配置完整时返回,否则 None。
|
||||
"""
|
||||
base_url = getattr(settings, "automation_dify_base_url", "") or ""
|
||||
api_key = getattr(settings, "automation_dify_api_key", "") or ""
|
||||
if not (base_url and api_key):
|
||||
logger.debug("Dify 未配置(AUTOMATION_DIFY_*),返回 None")
|
||||
return None
|
||||
try:
|
||||
return DifyClient(base_url=base_url, api_key=api_key, audit=audit)
|
||||
except ClientConfigError as e:
|
||||
logger.warning(f"Dify 客户端构建失败: {e.message}")
|
||||
return None
|
||||
@@ -0,0 +1,117 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 北森 EHR 静态映射兜底客户端
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎的终端映射兜底客户端(环境变量 AUTOMATION_EHR_* 驱动)。
|
||||
# 当联软(主源)不可用或未解析到终端时,使用北森 EHR 静态映射给出
|
||||
# 员工→部门/资产 hint(注意:EHR 通常不提供火绒 client_id,仅作兜底展示)。
|
||||
#
|
||||
# 主用方法:
|
||||
# - get_terminal_by_employee(employee_id) -> dict | None
|
||||
# 返回 {employee_id, department, asset_no, hint} 或 None(无记录)。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 15.0
|
||||
|
||||
|
||||
class BeisenEHRClient(BaseClient):
|
||||
"""北森 EHR 静态映射兜底客户端(Bearer/API Key 认证)。"""
|
||||
|
||||
system = "ehr"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
audit: Any = None,
|
||||
max_retries: int = 1,
|
||||
):
|
||||
if not base_url:
|
||||
raise ClientConfigError("EHR base_url 未配置")
|
||||
if not api_key:
|
||||
raise ClientConfigError("EHR api_key 未配置")
|
||||
super().__init__(base_url=base_url.rstrip("/"), timeout=timeout, audit=audit, max_retries=max_retries)
|
||||
self.api_key = api_key
|
||||
|
||||
async def get_terminal_by_employee(self, employee_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""按员工账号查询静态映射(兜底)。
|
||||
|
||||
Args:
|
||||
employee_id: 员工企微 UserID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: {employee_id, department, asset_no, hint} 或 None。
|
||||
默认实现为占位:未对接真实北森接口,返回基于本地静态表的 hint。
|
||||
结构上可被单元测试 mock(注入真实 client 即可)。
|
||||
"""
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
# 真实环境:GET /api/v1/employee/{employee_id}/asset
|
||||
# 本地无真实环境,使用占位静态映射(可在 settings/配置中扩展)。
|
||||
try:
|
||||
data = await self._request(
|
||||
"GET",
|
||||
f"/api/v1/employee/{employee_id}/asset",
|
||||
headers=headers,
|
||||
)
|
||||
except BaseClientError as e:
|
||||
logger.warning(f"EHR 查询失败 employee={employee_id}: {e.message}")
|
||||
return None
|
||||
|
||||
emp = (data.get("data", {}) or {}).get("employee", {}) or {}
|
||||
if not emp:
|
||||
return None
|
||||
return {
|
||||
"employee_id": employee_id,
|
||||
"department": emp.get("department", ""),
|
||||
"asset_no": emp.get("asset_no", ""),
|
||||
"hint": emp.get("asset_hint", "该员工暂无终端映射,建议转人工处理"),
|
||||
}
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""连接测试(查询根路径)。"""
|
||||
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
||||
try:
|
||||
await self._request("GET", "/api/v1/ping", headers=headers)
|
||||
return {"success": True, "message": "EHR 连接成功"}
|
||||
except BaseClientError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
|
||||
async def get_ehr_client(audit: Any = None) -> Optional[BeisenEHRClient]:
|
||||
"""构建 EHR 客户端(环境变量 AUTOMATION_EHR_* 驱动)。
|
||||
|
||||
未配置 → 返回 None(mapping_resolver 跳过兜底)。
|
||||
|
||||
Returns:
|
||||
Optional[BeisenEHRClient]: 配置完整时返回,否则 None。
|
||||
"""
|
||||
base_url = getattr(settings, "automation_ehr_base_url", "") or ""
|
||||
api_key = getattr(settings, "automation_ehr_api_key", "") or ""
|
||||
if not (base_url and api_key):
|
||||
logger.debug("EHR 未配置(AUTOMATION_EHR_*),返回 None")
|
||||
return None
|
||||
try:
|
||||
return BeisenEHRClient(base_url=base_url, api_key=api_key, audit=audit)
|
||||
except ClientConfigError as e:
|
||||
logger.warning(f"EHR 客户端构建失败: {e.message}")
|
||||
return None
|
||||
@@ -0,0 +1,250 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 联软 LV7000 客户端
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎使用的联软客户端(环境变量 AUTOMATION_LIANRUAN_* 驱动)。
|
||||
# 三层认证:① IP 白名单(联软后台配置,调用自动生效)② 账号密码 ③ Token。
|
||||
# Token 经 getToken 获取,30 分钟有效,本地缓存 + 提前 5 分钟刷新。
|
||||
#
|
||||
# 主用方法(自动化映射解析):
|
||||
# - query_dev_by_params(strusername=...) 员工账号 → 终端列表(核心映射)
|
||||
# 其余方法供排障/扩展。
|
||||
#
|
||||
# 返回 items 为 dict 子类(同时支持 .get() 与属性访问),以兼容
|
||||
# mapping_resolver / action_registry 对终端信息的两种访问方式。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from app.config import settings
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
ClientConnectionError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Token 有效期(秒)与提前刷新阈值
|
||||
_TOKEN_TTL = 1800
|
||||
_TOKEN_REFRESH_EARLY = 300
|
||||
_DEFAULT_TIMEOUT = 30.0
|
||||
_DEFAULT_PAGE_SIZE = 20
|
||||
|
||||
|
||||
class _TerminalRow(dict):
|
||||
"""终端信息行:同时支持 dict.get 与属性访问。"""
|
||||
|
||||
def __init__(self, **kw: Any):
|
||||
super().__init__(**kw)
|
||||
for k, v in kw.items():
|
||||
setattr(self, k, v)
|
||||
|
||||
|
||||
class LianruanClient(BaseClient):
|
||||
"""联软 LV7000 终端安全管理客户端(自动化引擎专用)。"""
|
||||
|
||||
system = "lianruan"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_account: str,
|
||||
api_password: str,
|
||||
validate_key: str = "",
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
audit: Any = None,
|
||||
max_retries: int = 2,
|
||||
):
|
||||
if not base_url:
|
||||
raise ClientConfigError("联软 base_url 未配置")
|
||||
if not api_account or not api_password:
|
||||
raise ClientConfigError("联软 账号/密码 未配置")
|
||||
super().__init__(base_url=base_url, timeout=timeout, audit=audit, max_retries=max_retries)
|
||||
self.api_account = api_account
|
||||
self.api_password = api_password
|
||||
self.validate_key = validate_key
|
||||
self._token: str = ""
|
||||
self._token_expire: float = 0.0
|
||||
|
||||
# ======================================================================
|
||||
# Token 管理(第三层认证)
|
||||
# ======================================================================
|
||||
async def _ensure_token(self) -> str:
|
||||
"""确保 Token 有效,过期则刷新(提前 5 分钟)。"""
|
||||
now = time.time()
|
||||
if self._token and now < self._token_expire - _TOKEN_REFRESH_EARLY:
|
||||
return self._token
|
||||
try:
|
||||
client = await self._get_client()
|
||||
params = {
|
||||
"act": "getToken",
|
||||
"apiAccount": self.api_account,
|
||||
"apiPassword": self.api_password,
|
||||
}
|
||||
if self.validate_key:
|
||||
params["validatekey"] = self.validate_key
|
||||
resp = await client.get(f"{self.base_url}/token", params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.ConnectError as e:
|
||||
raise ClientConnectionError(f"无法连接联软: {e}")
|
||||
except httpx.TimeoutException as e:
|
||||
raise ClientConnectionError(f"联软连接超时: {e}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ClientAPIError(message=f"联软 Token HTTP 错误: {e}", status=e.response.status_code)
|
||||
|
||||
if data.get("status") != "SUCCESS":
|
||||
raise ClientAuthError(f"联软 Token 获取失败: {data.get('msg', '')}")
|
||||
self._token = str(data.get("data") or data.get("token") or data.get("rows") or "")
|
||||
self._token_expire = now + _TOKEN_TTL
|
||||
return self._token
|
||||
|
||||
# ======================================================================
|
||||
# 统一请求(带认证参数)
|
||||
# ======================================================================
|
||||
async def _call(
|
||||
self, path: str, act: str, params: Optional[Dict[str, Any]] = None, method: str = "GET"
|
||||
) -> Dict[str, Any]:
|
||||
"""发送联软请求(自动附带 token + apiAccount + apiPassword)。"""
|
||||
token = await self._ensure_token()
|
||||
full: Dict[str, Any] = {
|
||||
"act": act,
|
||||
"apiAccount": self.api_account,
|
||||
"apiPassword": self.api_password,
|
||||
"token": token,
|
||||
}
|
||||
if self.validate_key:
|
||||
full["validatekey"] = self.validate_key
|
||||
if params:
|
||||
full.update(params)
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
if method.upper() == "POST":
|
||||
resp = await client.post(f"{self.base_url}{path}", data=full)
|
||||
else:
|
||||
resp = await client.get(f"{self.base_url}{path}", params=full)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.ConnectError as e:
|
||||
raise ClientConnectionError(f"无法连接联软: {e}")
|
||||
except httpx.TimeoutException as e:
|
||||
raise ClientConnectionError(f"联软连接超时: {e}")
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise ClientAPIError(message=f"联软 HTTP 错误: {e}", status=e.response.status_code)
|
||||
|
||||
status = data.get("status", "")
|
||||
if status == "INVALID":
|
||||
# Token 可能失效,清除缓存以便下次刷新
|
||||
self._token = ""
|
||||
self._token_expire = 0.0
|
||||
raise ClientAuthError(f"联软认证失败(IP 白名单/Token 无效): {data.get('msg', '')}")
|
||||
if status == "ERROR":
|
||||
raise ClientAPIError(message=f"联软业务错误: {data.get('msg', '')}", data=data)
|
||||
if status == "Exceed":
|
||||
raise ClientAPIError(message=f"联软数据量超限: {data.get('msg', '')}", data=data)
|
||||
if status not in ("", "SUCCESS"):
|
||||
raise ClientAPIError(message=f"联软未知状态: {status}", data=data)
|
||||
return data
|
||||
|
||||
# ======================================================================
|
||||
# 终端设备查询(核心映射接口)
|
||||
# ======================================================================
|
||||
async def query_dev_by_params(
|
||||
self,
|
||||
strusername: str = "",
|
||||
strdevname: str = "",
|
||||
strdevip: str = "",
|
||||
strmac: str = "",
|
||||
page: int = 1,
|
||||
per_page: int = _DEFAULT_PAGE_SIZE,
|
||||
) -> Dict[str, Any]:
|
||||
"""按员工账号等参数查询终端(strusername 为映射金钥匙)。
|
||||
|
||||
Returns:
|
||||
Dict: {"items": [终端信息], "total": int}
|
||||
终端信息为 _TerminalRow(支持 .get 与属性访问)。
|
||||
"""
|
||||
params: Dict[str, Any] = {}
|
||||
if strusername:
|
||||
params["strusername"] = strusername
|
||||
if strdevname:
|
||||
params["strdevname"] = strdevname
|
||||
if strdevip:
|
||||
params["strdevip"] = strdevip
|
||||
if strmac:
|
||||
params["strmac"] = strmac
|
||||
params["page"] = str(page)
|
||||
params["rows"] = str(per_page)
|
||||
|
||||
data = await self._call("/terminal", "queryDevByParams", params)
|
||||
rows = data.get("rows", []) or []
|
||||
items = [_TerminalRow(**row) for row in rows]
|
||||
return {"items": items, "total": data.get("total", len(items))}
|
||||
|
||||
# ======================================================================
|
||||
# 扩展查询(排障/管理用)
|
||||
# ======================================================================
|
||||
async def get_dev_all_info(self, strdevname: str = "", strdevip: str = "") -> Dict[str, Any]:
|
||||
"""查询终端详情。"""
|
||||
params: Dict[str, Any] = {}
|
||||
if strdevname:
|
||||
params["strdevname"] = strdevname
|
||||
if strdevip:
|
||||
params["strdevip"] = strdevip
|
||||
return await self._call("/devallinfoshowwithpaging", "getDevAllInfo", params)
|
||||
|
||||
async def get_user_info_by_account(self, useraccount: str) -> Optional[Dict[str, Any]]:
|
||||
"""按账号查询用户信息。"""
|
||||
data = await self._call("/querydeptuser", "getUserInfoByAccount", {"useraccount": useraccount})
|
||||
rows = data.get("rows", data.get("row", []))
|
||||
if rows:
|
||||
row = rows[0] if isinstance(rows, list) else rows
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""连接测试(取 Token)。"""
|
||||
try:
|
||||
token = await self._ensure_token()
|
||||
return {"success": bool(token), "message": "联软连接成功" if token else "Token 获取失败"}
|
||||
except BaseClientError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
|
||||
async def get_lianruan_client(db: Any = None, audit: Any = None) -> Optional[LianruanClient]:
|
||||
"""构建联软客户端(环境变量 AUTOMATION_LIANRUAN_* 驱动)。
|
||||
|
||||
必填项缺失 → 返回 None(映射解析器转 EHR 兜底)。
|
||||
|
||||
Returns:
|
||||
Optional[LianruanClient]: 配置完整时返回,否则 None。
|
||||
"""
|
||||
base_url = getattr(settings, "automation_lianruan_base_url", "") or ""
|
||||
api_account = getattr(settings, "automation_lianruan_api_account", "") or ""
|
||||
api_password = getattr(settings, "automation_lianruan_api_password", "") or ""
|
||||
validate_key = getattr(settings, "automation_lianruan_validate_key", "") or ""
|
||||
if not (base_url and api_account and api_password):
|
||||
logger.debug("联软未配置(AUTOMATION_LIANRUAN_*),返回 None")
|
||||
return None
|
||||
try:
|
||||
return LianruanClient(
|
||||
base_url=base_url,
|
||||
api_account=api_account,
|
||||
api_password=api_password,
|
||||
validate_key=validate_key,
|
||||
audit=audit,
|
||||
)
|
||||
except ClientConfigError as e:
|
||||
logger.warning(f"联软客户端构建失败: {e.message}")
|
||||
return None
|
||||
@@ -0,0 +1,139 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 RAGFlow 知识库检索客户端
|
||||
# =============================================================================
|
||||
# 说明:自动化引擎的知识检索客户端(环境变量 AUTOMATION_RAGFLOW_* 驱动)。
|
||||
# 基址默认内网 :9380(任务指定)。
|
||||
#
|
||||
# 检索策略默认(可在调用时覆盖):
|
||||
# - top_k=6:返回相关性最高的前 N 个片段(Top-K)
|
||||
# - truncation:单片段超长时截断到 512 字符,避免上下文溢出
|
||||
# - similarity_threshold=0.2
|
||||
#
|
||||
# 当前自动化引擎尚未在编排主链路强制调用 RAGFlow,但提供统一客户端以支撑
|
||||
# 病毒处置指引、软件安装知识等场景的后续接入(结构正确、可被 mock)。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
from app.core.clients.base import (
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_TIMEOUT = 30.0
|
||||
_DEFAULT_TOP_K = 6
|
||||
_DEFAULT_SIMILARITY = 0.2
|
||||
# 单片段最大字符数(截断)
|
||||
_MAX_CHUNK_CHARS = 512
|
||||
|
||||
|
||||
class RagFlowClient(BaseClient):
|
||||
"""RAGFlow 知识检索引擎客户端(Bearer 认证)。"""
|
||||
|
||||
system = "ragflow"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
timeout: float = _DEFAULT_TIMEOUT,
|
||||
audit: Any = None,
|
||||
max_retries: int = 2,
|
||||
):
|
||||
if not base_url:
|
||||
raise ClientConfigError("RAGFlow base_url 未配置")
|
||||
if not api_key:
|
||||
raise ClientConfigError("RAGFlow api_key 未配置")
|
||||
super().__init__(base_url=base_url.rstrip("/"), timeout=timeout, audit=audit, max_retries=max_retries)
|
||||
self.api_key = api_key
|
||||
|
||||
async def retrieve(
|
||||
self,
|
||||
question: str,
|
||||
dataset_ids: Optional[List[str]] = None,
|
||||
top_k: int = _DEFAULT_TOP_K,
|
||||
similarity_threshold: float = _DEFAULT_SIMILARITY,
|
||||
) -> Dict[str, Any]:
|
||||
"""知识检索(Top-K + 截断)。
|
||||
|
||||
Args:
|
||||
question: 检索问题
|
||||
dataset_ids: 知识库 ID 列表(为空则检索全部)
|
||||
top_k: 返回片段数量上限
|
||||
similarity_threshold: 相似度阈值
|
||||
|
||||
Returns:
|
||||
Dict: {chunks: [...], total: int}
|
||||
chunks 中每个片段含 content(可能已截断)、score、document_name。
|
||||
"""
|
||||
body: Dict[str, Any] = {
|
||||
"question": question,
|
||||
"similarity_threshold": similarity_threshold,
|
||||
"top_k": top_k,
|
||||
}
|
||||
if dataset_ids:
|
||||
body["dataset_ids"] = dataset_ids
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
data = await self._request("POST", "/api/v1/retrieval", json_body=body, headers=headers)
|
||||
|
||||
raw_chunks = (data.get("data", {}) or {}).get("chunks", []) or []
|
||||
chunks: List[Dict[str, Any]] = []
|
||||
for c in raw_chunks[:top_k]:
|
||||
content = c.get("content", "") or ""
|
||||
if len(content) > _MAX_CHUNK_CHARS:
|
||||
content = content[:_MAX_CHUNK_CHARS] + "...(截断)"
|
||||
chunks.append(
|
||||
{
|
||||
"content": content,
|
||||
"score": c.get("score", 0.0),
|
||||
"document_name": c.get("document_name", ""),
|
||||
"kw": c.get("kw", ""),
|
||||
}
|
||||
)
|
||||
return {"chunks": chunks, "total": len(chunks)}
|
||||
|
||||
async def test_connection(self) -> Dict[str, Any]:
|
||||
"""连接测试(列出数据集)。"""
|
||||
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
||||
try:
|
||||
data = await self._request(
|
||||
"GET", "/api/v1/datasets", params={"page": 1, "page_size": 1}, headers=headers
|
||||
)
|
||||
total = (data.get("data", {}) or {}).get("total", 0)
|
||||
return {"success": True, "message": f"连接成功,共 {total} 个知识库"}
|
||||
except BaseClientError as e:
|
||||
return {"success": False, "message": e.message}
|
||||
|
||||
|
||||
async def get_ragflow_client(audit: Any = None) -> Optional[RagFlowClient]:
|
||||
"""构建 RAGFlow 客户端(环境变量 AUTOMATION_RAGFLOW_* 驱动)。
|
||||
|
||||
未配置 → 返回 None。
|
||||
|
||||
Returns:
|
||||
Optional[RagFlowClient]: 配置完整时返回,否则 None。
|
||||
"""
|
||||
base_url = getattr(settings, "automation_ragflow_base_url", "") or ""
|
||||
api_key = getattr(settings, "automation_ragflow_api_key", "") or ""
|
||||
if not (base_url and api_key):
|
||||
logger.debug("RAGFlow 未配置(AUTOMATION_RAGFLOW_*),返回 None")
|
||||
return None
|
||||
try:
|
||||
return RagFlowClient(base_url=base_url, api_key=api_key, audit=audit)
|
||||
except ClientConfigError as e:
|
||||
logger.warning(f"RAGFlow 客户端构建失败: {e.message}")
|
||||
return None
|
||||
@@ -153,6 +153,22 @@ def dep_wingman_service():
|
||||
return WingmanService()
|
||||
|
||||
|
||||
def dep_neo4j_client():
|
||||
"""Neo4jClient 依赖注入(FastAPI Depends 兼容 — 同步工厂)。
|
||||
|
||||
由于 Neo4jClient.initialize() 是异步的,此函数返回一个懒加载的包装器,
|
||||
实际初始化在首次 await 时完成。
|
||||
|
||||
用法:
|
||||
neo4j = await dep_neo4j_client()
|
||||
|
||||
Returns:
|
||||
Neo4jClient: Neo4j 图数据库客户端(已初始化)
|
||||
"""
|
||||
from app.services.neo4j_client import dep_neo4j_client as _async_dep
|
||||
return _async_dep()
|
||||
|
||||
|
||||
# 应用生命周期管理函数
|
||||
async def init_shared_services():
|
||||
"""初始化共享服务(应用启动时调用)。
|
||||
@@ -249,15 +265,22 @@ def require_role(*required_roles: str):
|
||||
# 没有 current_user,导致 Depends 默认值未被解析,current_user 实际是 Depends 对象)
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.values())
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
'current_user',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=UserInfo,
|
||||
default=Depends(get_current_user),
|
||||
param_names = {p.name for p in params}
|
||||
|
||||
# 智能检测:若被装饰函数已声明 current_user(或 current_agent),则不再追加,
|
||||
# 避免 ValueError: duplicate parameter name。与 require_permission 保持一致。
|
||||
if 'current_user' in param_names:
|
||||
new_sig = sig
|
||||
else:
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
'current_user',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=UserInfo,
|
||||
default=Depends(get_current_user),
|
||||
)
|
||||
)
|
||||
)
|
||||
new_sig = sig.replace(parameters=params)
|
||||
new_sig = sig.replace(parameters=params)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
@@ -301,6 +324,47 @@ def require_admin(func):
|
||||
return require_role("admin")(func)
|
||||
|
||||
|
||||
def require_any_user(func):
|
||||
"""任意已登录用户权限装饰器(agent / admin / user 均可)。
|
||||
|
||||
与 require_role("agent", "admin", "user") 不同,此装饰器不做角色过滤,
|
||||
只要 Bearer Token 有效即放行。适用于需要认证但不限角色的端点。
|
||||
|
||||
Example:
|
||||
@router.post("/api/vision/analyze")
|
||||
@require_any_user
|
||||
async def analyze_screenshot(current_user: UserInfo = Depends(get_current_user)):
|
||||
pass
|
||||
"""
|
||||
sig = inspect.signature(func)
|
||||
params = list(sig.parameters.values())
|
||||
param_names = {p.name for p in params}
|
||||
|
||||
if 'current_user' in param_names:
|
||||
new_sig = sig
|
||||
else:
|
||||
params.append(
|
||||
inspect.Parameter(
|
||||
'current_user',
|
||||
inspect.Parameter.KEYWORD_ONLY,
|
||||
annotation=UserInfo,
|
||||
default=Depends(get_current_user),
|
||||
)
|
||||
)
|
||||
new_sig = sig.replace(parameters=params)
|
||||
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
# FastAPI 已通过 Depends(get_current_user) 完成认证校验
|
||||
# Token 无效时 get_current_user 会 raise 401
|
||||
# 此处无需额外角色检查,直接放行
|
||||
current_user = kwargs.pop('current_user')
|
||||
return await func(*args, current_user=current_user, **kwargs)
|
||||
|
||||
wrapper.__signature__ = new_sig
|
||||
return wrapper
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 细粒度权限装饰器 (v0.7.1 task #86 — RBAC 5 角色 × 4 资源 × 4 操作 × 3 范围)
|
||||
# =============================================================================
|
||||
@@ -1,185 +1,25 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 外部客户端基类
|
||||
# 企微IT智能服务台 — 外部系统集成 异常/基类 统一出口
|
||||
# =============================================================================
|
||||
# 说明:提供异步 HTTP 客户端通用能力:
|
||||
# 1. 超时控制(连接/读取分开)
|
||||
# 2. 重试(tenacity:指数退避 + 抖动,仅重试超时/5xx,4xx 不重试)
|
||||
# 3. 审计钩子(出入参记录到 ActionLog,由调用方注入回调)
|
||||
#
|
||||
# 所有外部客户端(Dify / EHR / 未来 aTrust)均继承此类,
|
||||
# 保证超时、重试、审计行为在全项目一致,且方法签名完整、异常可捕获、
|
||||
# 出入参可被 ActionLog 记录(满足架构审计要求)。
|
||||
# 说明:自动化引擎服务层(app.services.automation.*)统一从本模块导入
|
||||
# BaseClientError / BaseClient。为避免重复定义,此处直接复用
|
||||
# app.core.clients.base 的权威实现。
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from tenacity import (
|
||||
AsyncRetrying,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential_jitter,
|
||||
from app.core.clients.base import ( # noqa: F401
|
||||
BaseClient,
|
||||
BaseClientError,
|
||||
ClientAPIError,
|
||||
ClientAuthError,
|
||||
ClientConfigError,
|
||||
ClientConnectionError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 默认超时(秒)
|
||||
DEFAULT_CONNECT_TIMEOUT = 5.0
|
||||
DEFAULT_READ_TIMEOUT = 20.0
|
||||
# 默认重试次数
|
||||
DEFAULT_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
class BaseClientError(Exception):
|
||||
"""外部客户端通用异常(可被自动化引擎捕获并转人工)。"""
|
||||
|
||||
def __init__(self, message: str, code: int = -1, detail: str = ""):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
self.code = code
|
||||
self.detail = detail
|
||||
|
||||
|
||||
class BaseClient:
|
||||
"""异步外部客户端基类。
|
||||
|
||||
Attributes:
|
||||
base_url: 外部系统基址(不含尾部斜杠)
|
||||
timeout: httpx 超时配置
|
||||
_audit: 审计回调(可选),签名
|
||||
async (event, direction, system, request, response, status, latency_ms, error)
|
||||
"""
|
||||
|
||||
# 子类声明系统标识,用于审计与日志
|
||||
system_name: str = "external"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
timeout: Optional[float] = None,
|
||||
audit: Optional[Callable[..., Awaitable[None]]] = None,
|
||||
):
|
||||
self.base_url = (base_url or "").rstrip("/")
|
||||
read = timeout or DEFAULT_READ_TIMEOUT
|
||||
self.timeout = httpx.Timeout(connect=DEFAULT_CONNECT_TIMEOUT, read=read)
|
||||
self._audit = audit
|
||||
|
||||
async def _emit_audit(
|
||||
self,
|
||||
event: str,
|
||||
direction: str,
|
||||
request: Any = None,
|
||||
response: Any = None,
|
||||
status: str = "",
|
||||
latency_ms: Optional[int] = None,
|
||||
error: str = "",
|
||||
) -> None:
|
||||
"""审计钩子:记录出入参(落 ActionLog)。
|
||||
|
||||
为什么单独成方法:审计失败绝不影响主流程(仅记 warning),
|
||||
避免外部审计系统抖动拖累自动化处置。
|
||||
"""
|
||||
if self._audit is None:
|
||||
return
|
||||
try:
|
||||
await self._audit(
|
||||
event=event,
|
||||
direction=direction,
|
||||
system=self.system_name,
|
||||
request=request,
|
||||
response=response,
|
||||
status=status,
|
||||
latency_ms=latency_ms,
|
||||
error=error,
|
||||
)
|
||||
except Exception as e: # 审计失败不影响主流程
|
||||
logger.warning(f"[{self.system_name}] 审计钩子执行失败: {e}")
|
||||
|
||||
async def request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None,
|
||||
headers: Optional[Dict] = None,
|
||||
event: str = "",
|
||||
retry: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""统一请求封装(带超时 + 重试 + 审计)。
|
||||
|
||||
Args:
|
||||
method: HTTP 方法
|
||||
path: 路径(自动拼接 base_url)
|
||||
json_data/params/headers: 请求参数
|
||||
event: 审计事件名(如 "dify.intent")
|
||||
retry: 是否启用重试(仅对超时/5xx 重试,4xx 不重试)
|
||||
|
||||
Returns:
|
||||
Dict: 解析后的 JSON 响应
|
||||
|
||||
Raises:
|
||||
BaseClientError: 网络/HTTP/业务错误
|
||||
"""
|
||||
url = f"{self.base_url}{path}"
|
||||
start = time.monotonic()
|
||||
|
||||
async def _do() -> httpx.Response:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
return await client.request(
|
||||
method, url, json=json_data, params=params, headers=headers
|
||||
)
|
||||
|
||||
try:
|
||||
if retry:
|
||||
resp: Optional[httpx.Response] = None
|
||||
async for attempt in AsyncRetrying(
|
||||
stop=stop_after_attempt(DEFAULT_MAX_ATTEMPTS),
|
||||
wait=wait_exponential_jitter(initial=0.5, max=3.0),
|
||||
retry=retry_if_exception_type(
|
||||
(httpx.TimeoutException, httpx.ConnectError, httpx.HTTPStatusError)
|
||||
),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
resp = await _do()
|
||||
# 4xx 是业务错误,不重试
|
||||
if resp.status_code >= 400 and resp.status_code < 500:
|
||||
raise httpx.HTTPStatusError(
|
||||
message=f"HTTP {resp.status_code}",
|
||||
request=resp.request,
|
||||
response=resp,
|
||||
)
|
||||
assert resp is not None
|
||||
else:
|
||||
resp = await _do()
|
||||
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {"raw": resp.text}
|
||||
|
||||
status = "success" if resp.status_code < 400 else f"http_{resp.status_code}"
|
||||
await self._emit_audit(event, "out", json_data or params, data, status, latency_ms)
|
||||
if resp.status_code >= 400:
|
||||
raise BaseClientError(
|
||||
message=f"{self.system_name} 返回 HTTP {resp.status_code}",
|
||||
code=resp.status_code,
|
||||
detail=resp.text[:500],
|
||||
)
|
||||
return data
|
||||
except (httpx.TimeoutException, httpx.ConnectError) as e:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
await self._emit_audit(event, "out", json_data or params, None, "error", latency_ms, str(e))
|
||||
raise BaseClientError(message=f"{self.system_name} 网络异常: {e}", code=-1, detail=str(e))
|
||||
except BaseClientError:
|
||||
raise
|
||||
except Exception as e:
|
||||
latency_ms = int((time.monotonic() - start) * 1000)
|
||||
await self._emit_audit(event, "out", json_data or params, None, "error", latency_ms, str(e))
|
||||
raise BaseClientError(message=f"{self.system_name} 请求异常: {e}", code=-1, detail=str(e))
|
||||
__all__ = [
|
||||
"BaseClient",
|
||||
"BaseClientError",
|
||||
"ClientConfigError",
|
||||
"ClientConnectionError",
|
||||
"ClientAuthError",
|
||||
"ClientAPIError",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 北森 EHR 集成包(自动化引擎统一出口)
|
||||
# =============================================================================
|
||||
# 说明:提供 BeisenEHRClient / get_ehr_client 的统一导出,
|
||||
# 实际实现位于 app.core.clients.ehr(环境变量 AUTOMATION_EHR_* 驱动)。
|
||||
# =============================================================================
|
||||
|
||||
from app.core.clients.ehr import BeisenEHRClient, get_ehr_client # noqa: F401
|
||||
|
||||
__all__ = ["BeisenEHRClient", "get_ehr_client"]
|
||||
@@ -1,95 +1,60 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 阶段5 自动化 外部客户端工厂
|
||||
# 企微IT智能服务台 — 外部系统集成 客户端工厂
|
||||
# =============================================================================
|
||||
# 说明:集中解析各外部系统的配置来源:
|
||||
# - 优先使用 settings 中的 AUTOMATION_* 环境变量(架构约定)
|
||||
# - 缺失时回退到既有 system_configs 表配置
|
||||
# (huorong/lianruan/ragflow 在 app/integrations/*/config.py 中已有 getter)
|
||||
# 说明:自动化引擎服务层(app.services.automation.*)统一从此处构建外部客户端。
|
||||
#
|
||||
# 为什么有工厂:自动化引擎既能用新加的 AUTOMATION_* 配置,也能复用阶段1-4
|
||||
# 已落地的集成配置,避免重复维护两套配置源。
|
||||
# 设计:
|
||||
# - 所有构建函数均为 coroutine,返回客户端实例或 None(未配置时)。
|
||||
# - 底层客户端来自 app.core.clients.*(环境变量 AUTOMATION_* 驱动),
|
||||
# 与 app/integrations/{huorong,lianruan,ragflow}(管理后台 DB 配置)区分,
|
||||
# 避免相互干扰。
|
||||
# - 返回 None 时,引擎自动降级(关键词兜底 / EHR 兜底 / 转人工)。
|
||||
#
|
||||
# 调用约定(保持与现有服务层一致):
|
||||
# build_huorong_client(db, audit) / build_lianruan_client(db, audit)
|
||||
# build_dify_client(audit) / build_ehr_client(audit) / build_ragflow_client(audit)
|
||||
# =============================================================================
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable, Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from app.core.clients.dify import get_dify_client
|
||||
from app.core.clients.ehr import get_ehr_client
|
||||
from app.core.clients.huorong import get_huorong_client
|
||||
from app.core.clients.lianruan import get_lianruan_client
|
||||
from app.core.clients.ragflow import get_ragflow_client
|
||||
|
||||
|
||||
async def build_huorong_client(
|
||||
db: AsyncSession, audit: Optional[Callable[..., Any]] = None
|
||||
):
|
||||
"""构建火绒客户端:settings 优先,否则 system_configs。"""
|
||||
from app.integrations.huorong.client import HuorongClient
|
||||
|
||||
if (
|
||||
settings.automation_huorong_base_url
|
||||
and settings.automation_huorong_access_key_id
|
||||
and settings.automation_huorong_access_key_secret
|
||||
):
|
||||
return HuorongClient(
|
||||
access_key_id=settings.automation_huorong_access_key_id,
|
||||
access_key_secret=settings.automation_huorong_access_key_secret,
|
||||
base_url=settings.automation_huorong_base_url,
|
||||
)
|
||||
from app.integrations.huorong.config import get_huorong_client
|
||||
|
||||
return await get_huorong_client(db)
|
||||
async def build_huorong_client(db: Any = None, audit: Any = None) -> Optional[Any]:
|
||||
"""构建火绒客户端(未配置返回 None)。"""
|
||||
return await get_huorong_client(audit=audit)
|
||||
|
||||
|
||||
async def build_lianruan_client(
|
||||
db: AsyncSession, audit: Optional[Callable[..., Any]] = None
|
||||
):
|
||||
"""构建联软客户端:settings 优先,否则 system_configs。"""
|
||||
from app.integrations.lianruan.client import LianruanClient
|
||||
|
||||
if (
|
||||
settings.automation_lianruan_base_url
|
||||
and settings.automation_lianruan_api_account
|
||||
and settings.automation_lianruan_api_password
|
||||
):
|
||||
return LianruanClient(
|
||||
base_url=settings.automation_lianruan_base_url,
|
||||
api_account=settings.automation_lianruan_api_account,
|
||||
api_password=settings.automation_lianruan_api_password,
|
||||
validate_key=settings.automation_lianruan_validate_key,
|
||||
)
|
||||
from app.integrations.lianruan.config import get_lianruan_client
|
||||
|
||||
return await get_lianruan_client(db)
|
||||
async def build_lianruan_client(db: Any = None, audit: Any = None) -> Optional[Any]:
|
||||
"""构建联软客户端(未配置返回 None)。"""
|
||||
return await get_lianruan_client(db=db, audit=audit)
|
||||
|
||||
|
||||
async def build_ragflow_client(
|
||||
db: AsyncSession, audit: Optional[Callable[..., Any]] = None
|
||||
):
|
||||
"""构建 RAGFlow 客户端:settings 优先,否则 system_configs。"""
|
||||
from app.integrations.ragflow.client import RagflowClient
|
||||
|
||||
if settings.automation_ragflow_base_url and settings.automation_ragflow_api_key:
|
||||
return RagflowClient(
|
||||
api_key=settings.automation_ragflow_api_key,
|
||||
base_url=settings.automation_ragflow_base_url,
|
||||
)
|
||||
from app.integrations.ragflow.config import get_ragflow_client
|
||||
|
||||
return await get_ragflow_client(db)
|
||||
|
||||
|
||||
async def build_dify_client(audit: Optional[Callable[..., Any]] = None):
|
||||
"""构建 Dify 客户端(仅 settings)。"""
|
||||
from app.integrations.dify import get_dify_client
|
||||
|
||||
async def build_dify_client(audit: Any = None) -> Optional[Any]:
|
||||
"""构建 Dify 意图识别客户端(未配置返回 None)。"""
|
||||
return await get_dify_client(audit=audit)
|
||||
|
||||
|
||||
async def build_ehr_client(audit: Optional[Callable[..., Any]] = None):
|
||||
"""构建 EHR 客户端(仅 settings)。"""
|
||||
from app.integrations.ehr import get_ehr_client
|
||||
|
||||
async def build_ehr_client(audit: Any = None) -> Optional[Any]:
|
||||
"""构建北森 EHR 兜底客户端(未配置返回 None)。"""
|
||||
return await get_ehr_client(audit=audit)
|
||||
|
||||
|
||||
async def build_ragflow_client(audit: Any = None) -> Optional[Any]:
|
||||
"""构建 RAGFlow 知识检索客户端(未配置返回 None)。"""
|
||||
return await get_ragflow_client(audit=audit)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"build_huorong_client",
|
||||
"build_lianruan_client",
|
||||
"build_dify_client",
|
||||
"build_ehr_client",
|
||||
"build_ragflow_client",
|
||||
]
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
@@ -93,6 +93,25 @@ class KnowledgeBase(Base):
|
||||
comment="使用次数",
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tier0 扩展字段 — 图同步状态(D1 解读2 合一)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# 图同步状态(pending / synced / failed)
|
||||
graph_sync_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="图同步状态:pending / synced / failed",
|
||||
)
|
||||
|
||||
# 关联 Neo4j Issue 节点的 uuid(写入图后回填)
|
||||
graph_node_uuid: Mapped[Optional[str]] = mapped_column(
|
||||
String(36),
|
||||
nullable=True,
|
||||
comment="关联 Neo4j Issue 节点的 uuid",
|
||||
)
|
||||
|
||||
# 创建时间
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, JSON, String, Text
|
||||
from sqlalchemy import Boolean, DateTime, Float, Index, JSON, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.database import Base
|
||||
@@ -114,6 +114,89 @@ class KnowledgeSuggestion(Base):
|
||||
comment="相关会话ID或标注ID列表",
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tier0 扩展字段 — 图结构 + 置信度 + 受众 + 状态(D1/D3/D7/D8)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# AI 生成置信度(0.0-1.0,D3 门控阈值 0.7)
|
||||
confidence: Mapped[Optional[float]] = mapped_column(
|
||||
Float,
|
||||
nullable=True,
|
||||
comment="AI 生成置信度(0.0-1.0)",
|
||||
)
|
||||
|
||||
# 受众类型(D8:employee_quick_reply / engineer_workguide)
|
||||
audience: Mapped[Optional[str]] = mapped_column(
|
||||
String(30),
|
||||
nullable=True,
|
||||
comment="受众类型:employee_quick_reply / engineer_workguide",
|
||||
)
|
||||
|
||||
# 图结构字段 — 问题名称(对应 Neo4j Issue.name)
|
||||
issue: Mapped[Optional[str]] = mapped_column(
|
||||
String(256),
|
||||
nullable=True,
|
||||
comment="图节点:问题名称",
|
||||
)
|
||||
|
||||
# 图结构字段 — 动作名称(对应 Neo4j Action.name)
|
||||
action: Mapped[Optional[str]] = mapped_column(
|
||||
String(256),
|
||||
nullable=True,
|
||||
comment="图节点:动作名称",
|
||||
)
|
||||
|
||||
# 图关系类型(LEADS_TO / RELATES_TO / CAN_JUMP_TO)
|
||||
relation_type: Mapped[Optional[str]] = mapped_column(
|
||||
String(30),
|
||||
nullable=True,
|
||||
comment="图关系类型",
|
||||
)
|
||||
|
||||
# 父 Issue 名称(用于构建 Issue→Issue 关系)
|
||||
parent_issue: Mapped[Optional[str]] = mapped_column(
|
||||
String(256),
|
||||
nullable=True,
|
||||
comment="父 Issue 名称",
|
||||
)
|
||||
|
||||
# 图结构扩展元数据(JSON,存放额外的图属性)
|
||||
graph_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column(
|
||||
JSON,
|
||||
nullable=True,
|
||||
comment="图结构扩展元数据",
|
||||
)
|
||||
|
||||
# 图同步状态(pending / synced / failed)
|
||||
graph_sync_status: Mapped[str] = mapped_column(
|
||||
String(20),
|
||||
nullable=False,
|
||||
default="pending",
|
||||
comment="图同步状态:pending / synced / failed",
|
||||
)
|
||||
|
||||
# AI 生成失败标记(Dify 不可用或置信度不足时置 True)
|
||||
source_failed: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
nullable=False,
|
||||
default=False,
|
||||
comment="AI 生成失败标记",
|
||||
)
|
||||
|
||||
# 入队列时间
|
||||
queued_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="入独立队列时间",
|
||||
)
|
||||
|
||||
# 应用到 KB 的时间
|
||||
applied_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
comment="应用到 KB 的时间",
|
||||
)
|
||||
|
||||
# 生成理由
|
||||
reason: Mapped[Optional[str]] = mapped_column(
|
||||
Text,
|
||||
@@ -166,6 +249,9 @@ class KnowledgeSuggestion(Base):
|
||||
Index("idx_suggestion_status", "status"),
|
||||
Index("idx_suggestion_type", "suggestion_type"),
|
||||
Index("idx_suggestion_created", "created_at"),
|
||||
Index("idx_suggestion_audience", "audience"),
|
||||
Index("idx_suggestion_confidence", "confidence"),
|
||||
Index("idx_suggestion_graph_sync", "graph_sync_status"),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Neo4j 图节点/关系 Pydantic 模型
|
||||
# =============================================================================
|
||||
# 说明:定义 Neo4j 图数据库中节点和关系的 Pydantic 数据模型,
|
||||
# 用于图数据的序列化、验证和传输。
|
||||
#
|
||||
# 图 Schema 对齐:复杂场景重构 v1.1 TeliChat 白盒模型
|
||||
# 节点:Issue(问题) / Action(动作) / Info(信息项) / Session(会话)
|
||||
# 关系:LEADS_TO / RELATES_TO / HAS_ACTION / PROVIDED / CORRECTED_TO
|
||||
#
|
||||
# 命名映射约定(§8.1):
|
||||
# Issue.name → Issue.name (完全对齐)
|
||||
# Issue.category → Issue.category (完全对齐)
|
||||
# Action.name → Action.name (完全对齐)
|
||||
# RELATES_TO → CAN_JUMP_TO (语义简化)
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class IssueNode(BaseModel):
|
||||
"""Neo4j Issue 节点模型 — 对应 :Issue 标签。
|
||||
|
||||
表示 IT 问题域中的问题描述,是知识图谱的核心节点类型。
|
||||
通过 MERGE name 实现幂等去重。
|
||||
|
||||
Attributes:
|
||||
uuid: 节点唯一标识(Neo4j 自动生成 UUID)
|
||||
name: 问题名称(唯一业务键,如"VPN问题")
|
||||
category: 问题分类(硬件/软件/网络/安全/账号/其他)
|
||||
created_at: 创建时间
|
||||
updated_at: 更新时间
|
||||
source_suggestion_id: 来源建议ID(关联 KnowledgeSuggestion)
|
||||
"""
|
||||
|
||||
uuid: str = Field(default="", description="节点唯一标识(Neo4j UUID)")
|
||||
name: str = Field(..., description="问题名称(业务唯一键)", max_length=256)
|
||||
category: str = Field(default="其他", description="问题分类", max_length=64)
|
||||
created_at: Optional[datetime] = Field(default=None, description="创建时间")
|
||||
updated_at: Optional[datetime] = Field(default=None, description="更新时间")
|
||||
source_suggestion_id: Optional[str] = Field(
|
||||
default=None, description="来源建议ID(关联 KnowledgeSuggestion)"
|
||||
)
|
||||
|
||||
|
||||
class ActionNode(BaseModel):
|
||||
"""Neo4j Action 节点模型 — 对应 :Action 标签。
|
||||
|
||||
表示针对 Issue 的具体解决方案/动作。
|
||||
与 Issue 通过 LEADS_TO 关系关联。
|
||||
|
||||
Attributes:
|
||||
uuid: 节点唯一标识
|
||||
name: 动作名称(唯一业务键,如"个人VPN开通")
|
||||
description: 动作描述
|
||||
created_at: 创建时间
|
||||
source_suggestion_id: 来源建议ID
|
||||
"""
|
||||
|
||||
uuid: str = Field(default="", description="节点唯一标识")
|
||||
name: str = Field(..., description="动作名称(业务唯一键)", max_length=256)
|
||||
description: str = Field(default="", description="动作描述")
|
||||
created_at: Optional[datetime] = Field(default=None, description="创建时间")
|
||||
source_suggestion_id: Optional[str] = Field(
|
||||
default=None, description="来源建议ID"
|
||||
)
|
||||
|
||||
|
||||
class InfoNode(BaseModel):
|
||||
"""Neo4j Info 节点模型 — 对应 :Info 标签。
|
||||
|
||||
表示信息项节点,借鉴 TeliChat 信息项修饰机制。
|
||||
六种修饰:固定/增量/明确/隐含/复述/必需。
|
||||
|
||||
Attributes:
|
||||
uuid: 节点唯一标识
|
||||
name: 信息项名称
|
||||
value: 信息项值
|
||||
modifiers: 修饰符列表
|
||||
created_at: 创建时间
|
||||
"""
|
||||
|
||||
uuid: str = Field(default="", description="节点唯一标识")
|
||||
name: str = Field(..., description="信息项名称", max_length=256)
|
||||
value: str = Field(default="", description="信息项值")
|
||||
modifiers: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="修饰符列表:固定/增量/明确/隐含/复述/必需",
|
||||
)
|
||||
created_at: Optional[datetime] = Field(default=None, description="创建时间")
|
||||
|
||||
|
||||
class RelationEdge(BaseModel):
|
||||
"""Neo4j 关系边模型 — 对应图中的关系。
|
||||
|
||||
表示两个节点之间的有向关系边。
|
||||
支持 Issue→Issue、Issue→Action 等多种关系类型。
|
||||
|
||||
Attributes:
|
||||
from_uuid: 起始节点 uuid
|
||||
to_uuid: 目标节点 uuid
|
||||
type: 关系类型(LEADS_TO / RELATES_TO / CAN_JUMP_TO)
|
||||
order: 排序序号
|
||||
weight: 关系权重(0.0-1.0)
|
||||
"""
|
||||
|
||||
from_uuid: str = Field(..., description="起始节点 uuid")
|
||||
to_uuid: str = Field(..., description="目标节点 uuid")
|
||||
type: str = Field(default="LEADS_TO", description="关系类型")
|
||||
order: int = Field(default=0, description="排序序号", ge=0)
|
||||
weight: float = Field(default=1.0, description="关系权重", ge=0.0, le=1.0)
|
||||
@@ -0,0 +1,158 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 枚举集中定义
|
||||
# =============================================================================
|
||||
# 说明:所有知识库迭代相关的枚举类型集中定义在此,避免散落在各模块。
|
||||
# 包括:受众类型、建议状态、图同步状态、来源类型、关系类型。
|
||||
# =============================================================================
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class AudienceEnum(str, Enum):
|
||||
"""知识受众枚举 — 决定知识条目面向哪类用户。
|
||||
|
||||
D8 硬约束:当前仅两类,第三类(管理运营KB)由后续 P2 扩展。
|
||||
|
||||
Values:
|
||||
employee_quick_reply: 员工快捷回复 KB(面向普通员工)
|
||||
engineer_workguide: 工程师作业指导 KB(面向 IT 工程师/坐席)
|
||||
"""
|
||||
|
||||
employee_quick_reply = "employee_quick_reply" # 员工快捷回复 KB
|
||||
engineer_workguide = "engineer_workguide" # 工程师作业指导 KB
|
||||
|
||||
|
||||
class SuggestionStatusEnum(str, Enum):
|
||||
"""知识建议审批状态枚举 — 五态 + 终止态。
|
||||
|
||||
D7 硬约束:默认 pending(非自动采纳),审批通过后流转 applied→graph_synced。
|
||||
|
||||
状态流转路径:
|
||||
pending → queued → approved → applied → graph_synced
|
||||
pending → approved → applied → graph_synced (内联审批直达)
|
||||
pending → rejected (驳回)
|
||||
pending → expired (超时)
|
||||
approved → applied → graph_synced (采纳后写图)
|
||||
|
||||
Values:
|
||||
pending: 待审核(初始状态)
|
||||
queued: 已入队列(会话关闭后未处理的提案)
|
||||
approved: 已通过(训练师审批通过)
|
||||
rejected: 已驳回
|
||||
applied: 已应用(KB 条目已落库)
|
||||
graph_synced: 图已同步(Neo4j 写图完成,最终态)
|
||||
expired: 已过期(超时未处理)
|
||||
"""
|
||||
|
||||
pending = "pending" # 待审核(初始状态)
|
||||
queued = "queued" # 已入独立队列
|
||||
approved = "approved" # 已通过
|
||||
rejected = "rejected" # 已驳回
|
||||
applied = "applied" # 已应用到 KB
|
||||
graph_synced = "graph_synced" # Neo4j 图已同步(最终态)
|
||||
expired = "expired" # 已过期
|
||||
|
||||
|
||||
class GraphSyncStatusEnum(str, Enum):
|
||||
"""图同步状态枚举 — 追踪知识条目与 Neo4j 图的同步状态。
|
||||
|
||||
Values:
|
||||
pending: 待同步(KB 已落库但图未写入)
|
||||
synced: 已同步(图写入成功)
|
||||
failed: 同步失败(进入重试队列)
|
||||
"""
|
||||
|
||||
pending = "pending" # 待同步
|
||||
synced = "synced" # 已同步
|
||||
failed = "failed" # 同步失败
|
||||
|
||||
|
||||
class SourceTypeEnum(str, Enum):
|
||||
"""建议来源类型枚举 — 追踪知识建议的生成通道。
|
||||
|
||||
对应三条输入通道:
|
||||
A: conversation/annotation/ai_uncertain → 会话自动生成
|
||||
B: manual → 训练师手动录入
|
||||
C: document_ragflow → RAGFlow 文档 ETL
|
||||
merge: 去重合并产生 → 知识合并去重
|
||||
|
||||
Values:
|
||||
annotation: 标注数据分析
|
||||
conversation: 会话数据分析
|
||||
ai_uncertain: AI 不确定回复
|
||||
manual: 训练师手动录入(通道 B)
|
||||
document_ragflow: RAGFlow 文档处理(通道 C)
|
||||
merge: 知识合并去重(任务3 P2)
|
||||
"""
|
||||
|
||||
annotation = "annotation" # 标注数据分析
|
||||
conversation = "conversation" # 会话数据分析
|
||||
ai_uncertain = "ai_uncertain" # AI 不确定回复
|
||||
manual = "manual" # 训练师手动录入(通道 B)
|
||||
document_ragflow = "document_ragflow" # RAGFlow 文档 ETL(通道 C)
|
||||
merge = "merge" # 知识合并去重(任务3 P2)
|
||||
|
||||
|
||||
class RelationTypeEnum(str, Enum):
|
||||
"""图关系类型枚举 — 对齐复杂场景重构 v1.1。
|
||||
|
||||
命名映射约定(§8.1):
|
||||
LEADS_TO → 完全对齐
|
||||
RELATES_TO → CAN_JUMP_TO 语义简化统一
|
||||
CAN_JUMP_TO → 保留兼容(等同于 RELATES_TO {type:"jump"})
|
||||
|
||||
Values:
|
||||
LEADS_TO: 引导关系(Issue→Action 或 Issue→Issue)
|
||||
RELATES_TO: 关联关系(双向,含子类型)
|
||||
CAN_JUMP_TO: 跳转关系(非线性跳转支持)
|
||||
"""
|
||||
|
||||
LEADS_TO = "LEADS_TO" # 引导关系
|
||||
RELATES_TO = "RELATES_TO" # 关联关系
|
||||
CAN_JUMP_TO = "CAN_JUMP_TO" # 跳转关系
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 合法状态转换表(用于审批状态机校验)
|
||||
# =============================================================================
|
||||
|
||||
# key: 当前状态, value: 允许转换到的目标状态集合
|
||||
ALLOWED_STATUS_TRANSITIONS: dict = {
|
||||
SuggestionStatusEnum.pending: {
|
||||
SuggestionStatusEnum.queued,
|
||||
SuggestionStatusEnum.approved,
|
||||
SuggestionStatusEnum.rejected,
|
||||
SuggestionStatusEnum.expired,
|
||||
},
|
||||
SuggestionStatusEnum.queued: {
|
||||
SuggestionStatusEnum.approved,
|
||||
SuggestionStatusEnum.rejected,
|
||||
SuggestionStatusEnum.expired,
|
||||
},
|
||||
SuggestionStatusEnum.approved: {
|
||||
SuggestionStatusEnum.applied,
|
||||
SuggestionStatusEnum.rejected,
|
||||
},
|
||||
SuggestionStatusEnum.applied: {
|
||||
SuggestionStatusEnum.graph_synced,
|
||||
},
|
||||
SuggestionStatusEnum.graph_synced: set(), # 终态,不可再转换
|
||||
SuggestionStatusEnum.rejected: set(), # 终态
|
||||
SuggestionStatusEnum.expired: set(), # 终态
|
||||
}
|
||||
|
||||
|
||||
def is_valid_transition(
|
||||
current: SuggestionStatusEnum, target: SuggestionStatusEnum
|
||||
) -> bool:
|
||||
"""检查状态转换是否合法。
|
||||
|
||||
Args:
|
||||
current: 当前状态
|
||||
target: 目标状态
|
||||
|
||||
Returns:
|
||||
bool: 转换合法返回 True
|
||||
"""
|
||||
allowed = ALLOWED_STATUS_TRANSITIONS.get(current, set())
|
||||
return target in allowed
|
||||
@@ -6,10 +6,18 @@
|
||||
# =============================================================================
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.enums import (
|
||||
AudienceEnum,
|
||||
GraphSyncStatusEnum,
|
||||
RelationTypeEnum,
|
||||
SourceTypeEnum,
|
||||
SuggestionStatusEnum,
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 创建建议请求 Schema
|
||||
@@ -18,6 +26,7 @@ class KnowledgeSuggestionCreate(BaseModel):
|
||||
"""创建知识库优化建议请求 Schema。
|
||||
|
||||
通常由AI分析服务自动创建,也可手动创建。
|
||||
新增 Tier0 图字段 + audience + confidence。
|
||||
"""
|
||||
|
||||
suggestion_type: str = Field(
|
||||
@@ -30,13 +39,36 @@ class KnowledgeSuggestionCreate(BaseModel):
|
||||
tags: List[str] = Field(default_factory=list, description="标签列表")
|
||||
source_type: str = Field(
|
||||
...,
|
||||
description="分析来源:annotation=标注数据/conversation=会话数据/ai_uncertain=AI不确定",
|
||||
description="分析来源:annotation=标注数据/conversation=会话数据/ai_uncertain=AI不确定/manual=手动录入/document_ragflow=RAGFlow文档",
|
||||
)
|
||||
source_data: Optional[List[str]] = Field(
|
||||
default=None, description="相关会话ID或标注ID列表"
|
||||
)
|
||||
reason: Optional[str] = Field(default=None, description="生成理由")
|
||||
|
||||
# ── Tier0 扩展字段 ──
|
||||
confidence: Optional[float] = Field(
|
||||
default=None, description="AI 生成置信度(0.0-1.0)", ge=0.0, le=1.0
|
||||
)
|
||||
audience: Optional[AudienceEnum] = Field(
|
||||
default=None, description="受众类型"
|
||||
)
|
||||
issue: Optional[str] = Field(
|
||||
default=None, description="图节点:问题名称", max_length=256
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
default=None, description="图节点:动作名称", max_length=256
|
||||
)
|
||||
relation_type: Optional[RelationTypeEnum] = Field(
|
||||
default=None, description="图关系类型"
|
||||
)
|
||||
parent_issue: Optional[str] = Field(
|
||||
default=None, description="父 Issue 名称", max_length=256
|
||||
)
|
||||
graph_meta: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="图结构扩展元数据"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核建议请求 Schema
|
||||
@@ -53,13 +85,60 @@ class KnowledgeSuggestionReject(BaseModel):
|
||||
reject_reason: str = Field(..., description="拒绝理由", max_length=500)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 改写建议请求 Schema(Tier0 新增 — 训练师改写提案)
|
||||
# -----------------------------------------------------------------------------
|
||||
class KnowledgeSuggestionRewrite(BaseModel):
|
||||
"""训练师改写知识库优化建议请求 Schema。
|
||||
|
||||
坐席/训练师在审批时可直接修改提案内容后重新提交审批。
|
||||
改写后状态重置为 pending,重新走审批流程。
|
||||
"""
|
||||
|
||||
title: Optional[str] = Field(default=None, description="修改后的标题", max_length=256)
|
||||
content: Optional[str] = Field(default=None, description="修改后的内容")
|
||||
category: Optional[str] = Field(default=None, description="修改后的分类")
|
||||
tags: Optional[List[str]] = Field(default=None, description="修改后的标签列表")
|
||||
confidence: Optional[float] = Field(
|
||||
default=None, description="修改后的置信度", ge=0.0, le=1.0
|
||||
)
|
||||
audience: Optional[AudienceEnum] = Field(
|
||||
default=None, description="修改后的受众类型"
|
||||
)
|
||||
issue: Optional[str] = Field(
|
||||
default=None, description="修改后的图节点:问题名称", max_length=256
|
||||
)
|
||||
action: Optional[str] = Field(
|
||||
default=None, description="修改后的图节点:动作名称", max_length=256
|
||||
)
|
||||
relation_type: Optional[RelationTypeEnum] = Field(
|
||||
default=None, description="修改后的图关系类型"
|
||||
)
|
||||
parent_issue: Optional[str] = Field(
|
||||
default=None, description="修改后的父 Issue 名称", max_length=256
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 合并建议请求 Schema(任务3:P2 知识去重合并)
|
||||
# -----------------------------------------------------------------------------
|
||||
class KnowledgeSuggestionMerge(BaseModel):
|
||||
"""合并重复建议请求 Schema。
|
||||
|
||||
将 duplicate_id 的建议合并到当前建议(primary),
|
||||
重复建议状态变为 rejected(合并归入)。
|
||||
"""
|
||||
|
||||
duplicate_id: str = Field(..., description="要合并的重复建议ID")
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 知识库优化建议响应 Schema
|
||||
# -----------------------------------------------------------------------------
|
||||
class KnowledgeSuggestionResponse(BaseModel):
|
||||
"""知识库优化建议响应 Schema。
|
||||
|
||||
返回建议记录详情。
|
||||
返回建议记录详情,包含 Tier0 所有扩展字段。
|
||||
"""
|
||||
|
||||
id: str = Field(..., description="建议ID")
|
||||
@@ -75,6 +154,23 @@ class KnowledgeSuggestionResponse(BaseModel):
|
||||
reject_reason: Optional[str] = Field(default=None, description="拒绝理由")
|
||||
reviewer_id: Optional[str] = Field(default=None, description="审核人ID")
|
||||
reviewed_at: Optional[datetime] = Field(default=None, description="审核时间")
|
||||
|
||||
# ── Tier0 扩展字段 ──
|
||||
confidence: Optional[float] = Field(default=None, description="AI 生成置信度")
|
||||
audience: Optional[str] = Field(default=None, description="受众类型")
|
||||
issue: Optional[str] = Field(default=None, description="图节点:问题名称")
|
||||
action: Optional[str] = Field(default=None, description="图节点:动作名称")
|
||||
relation_type: Optional[str] = Field(default=None, description="图关系类型")
|
||||
parent_issue: Optional[str] = Field(default=None, description="父 Issue 名称")
|
||||
graph_meta: Optional[Dict[str, Any]] = Field(
|
||||
default=None, description="图结构扩展元数据"
|
||||
)
|
||||
graph_sync_status: Optional[str] = Field(
|
||||
default="pending", description="图同步状态"
|
||||
)
|
||||
source_failed: bool = Field(default=False, description="AI 生成失败标记")
|
||||
queued_at: Optional[datetime] = Field(default=None, description="入队列时间")
|
||||
applied_at: Optional[datetime] = Field(default=None, description="应用到 KB 的时间")
|
||||
created_at: datetime = Field(..., description="创建时间")
|
||||
updated_at: datetime = Field(..., description="更新时间")
|
||||
|
||||
@@ -100,9 +196,12 @@ class KnowledgeSuggestionStatsResponse(BaseModel):
|
||||
|
||||
total: int = Field(..., description="总建议数")
|
||||
pending: int = Field(..., description="待审核数")
|
||||
queued: int = Field(0, description="队列中数")
|
||||
approved: int = Field(..., description="已通过数")
|
||||
rejected: int = Field(..., description="已拒绝数")
|
||||
applied: int = Field(..., description="已应用数")
|
||||
graph_synced: int = Field(0, description="图已同步数")
|
||||
expired: int = Field(0, description="已过期数")
|
||||
new_faq_count: int = Field(..., description="新增FAQ建议数")
|
||||
update_count: int = Field(..., description="更新建议数")
|
||||
outdated_count: int = Field(..., description="过时标记数")
|
||||
|
||||
@@ -12,6 +12,13 @@ from app.services.session_service import SessionService
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ai_handler import AIHandler
|
||||
|
||||
# Tier0 新增服务导出
|
||||
from app.services.neo4j_client import Neo4jClient, dep_neo4j_client, get_neo4j_client
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService, dep_knowledge_iteration_service
|
||||
from app.services.wingman_service import WingmanService
|
||||
from app.services.vision_service import VisionService
|
||||
from app.services.ragflow_ingestion_service import RagflowIngestionService
|
||||
|
||||
__all__ = [
|
||||
"WecomService",
|
||||
"MessageRouter",
|
||||
@@ -19,4 +26,13 @@ __all__ = [
|
||||
"SessionService",
|
||||
"FunnyPhraseService",
|
||||
"AIHandler",
|
||||
# Tier0
|
||||
"Neo4jClient",
|
||||
"dep_neo4j_client",
|
||||
"get_neo4j_client",
|
||||
"KnowledgeIterationService",
|
||||
"dep_knowledge_iteration_service",
|
||||
"WingmanService",
|
||||
"VisionService",
|
||||
"RagflowIngestionService",
|
||||
]
|
||||
|
||||
@@ -199,36 +199,96 @@ class AIService:
|
||||
conversation_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
) -> AsyncGenerator[Dict[str, Any], None]:
|
||||
"""调用 Dify API 获取流式 AI 回复(SSE)。
|
||||
|
||||
Args:
|
||||
message: 员工发送的消息内容
|
||||
conversation_id: Dify 会话ID
|
||||
user_id: 员工企微 UserID
|
||||
"""调用 Dify API 获取流式 AI 回复(SSE),逐块 yield 给调用方。
|
||||
|
||||
Yields:
|
||||
Dict: {
|
||||
"delta": str, # 增量内容
|
||||
"finished": bool, # 是否结束
|
||||
"conversation_id": str,
|
||||
"hit": bool, # 最终判断是否命中
|
||||
}
|
||||
Dict: {"delta": str, "finished": bool, "conversation_id": str, "hit": bool|None}
|
||||
- 流式中间块:{"delta": 增量, "finished": False, "hit": None}
|
||||
- 终态块:{"delta": "", "finished": True, "hit": 命中判断}
|
||||
|
||||
做什么:SSE 流式读取 Dify 返回,逐块 yield 给调用方
|
||||
为什么:
|
||||
- 流式返回能提升用户体验(不用等 AI 全部生成完才显示)
|
||||
- 通过 WebSocket 推送增量内容到 H5 前端
|
||||
- 目前第一步先实现非流式,流式作为后续优化
|
||||
实现:
|
||||
- stream=True 走 SSE,解析 data: {...} 行,逐块 yield delta
|
||||
- 流结束后用完整内容整体判断 hit(_check_knowledge_hit)
|
||||
容错:若 Dify 不支持流式 / 超时 / 非 SSE 格式,catch 后 fallback 到
|
||||
get_reply 非流式,yield 一次完整内容(前端退化为"整段到达",
|
||||
功能不破,仅无逐字动画)。
|
||||
"""
|
||||
# TODO: 第一步简化,先 yield 完整内容(非真正流式)
|
||||
# 后续优化:解析 SSE 事件流,逐块 yield
|
||||
result = await self.get_reply(message, conversation_id, user_id)
|
||||
yield {
|
||||
"delta": result["content"],
|
||||
"finished": True,
|
||||
"conversation_id": result["conversation_id"],
|
||||
"hit": result["hit"],
|
||||
payload = {
|
||||
"model": "Chat",
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"stream": True,
|
||||
"temperature": 0.1,
|
||||
}
|
||||
if conversation_id:
|
||||
payload["conversation_id"] = conversation_id
|
||||
if user_id:
|
||||
payload["user"] = user_id
|
||||
|
||||
try:
|
||||
client = await self._get_client()
|
||||
full_parts: list = []
|
||||
dify_conv_id = conversation_id or ""
|
||||
async with client.stream("POST", self.api_url, json=payload) as response:
|
||||
response.raise_for_status()
|
||||
async for line in response.aiter_lines():
|
||||
if not line:
|
||||
continue
|
||||
line = line.strip()
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if data == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
# OpenAI / Dify SSE 格式:choices[0].delta.content
|
||||
try:
|
||||
delta = chunk["choices"][0]["delta"].get("content", "")
|
||||
except (KeyError, IndexError, TypeError):
|
||||
delta = ""
|
||||
if delta:
|
||||
full_parts.append(delta)
|
||||
yield {
|
||||
"delta": delta,
|
||||
"finished": False,
|
||||
"conversation_id": dify_conv_id,
|
||||
"hit": None,
|
||||
}
|
||||
# Dify 可能在流式块里给出 conversation_id
|
||||
cid = chunk.get("conversation_id")
|
||||
if cid:
|
||||
dify_conv_id = cid
|
||||
|
||||
# 流结束:用完整内容判断命中
|
||||
full_content = "".join(full_parts)
|
||||
hit = self._check_knowledge_hit(full_content) if full_content else False
|
||||
yield {
|
||||
"delta": "",
|
||||
"finished": True,
|
||||
"conversation_id": dify_conv_id,
|
||||
"hit": hit,
|
||||
}
|
||||
except Exception as e:
|
||||
# 流式不可用(dify2openai 不支持 / 超时 / 非 SSE),回退非流式
|
||||
logger.warning(f"Dify 流式失败,回退非流式: {e}")
|
||||
try:
|
||||
result = await self.get_reply(message, conversation_id, user_id)
|
||||
yield {
|
||||
"delta": result["content"],
|
||||
"finished": True,
|
||||
"conversation_id": result["conversation_id"],
|
||||
"hit": result["hit"],
|
||||
}
|
||||
except Exception as e2:
|
||||
logger.error(f"Dify 流式与非流式均失败: {e2}")
|
||||
yield {
|
||||
"delta": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
|
||||
"finished": True,
|
||||
"conversation_id": conversation_id or "",
|
||||
"hit": False,
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 判断是否命中知识库
|
||||
|
||||
@@ -145,22 +145,25 @@ class ContentModerationService:
|
||||
import re
|
||||
leaked = []
|
||||
|
||||
# 手机号(11 位 1 开头)
|
||||
if re.search(r"\b1[3-9]\d{9}\b", text):
|
||||
# 手机号(11位1开头)
|
||||
# BUGFIX: \b 和 (?<!\w) 对中文均失效(Python3 \w 含中文),
|
||||
# 改用 (?<!\d) / (?!\d) 检查数字边界——"电话13800138000" 可正确匹配
|
||||
if re.search(r"(?<!\d)1[3-9]\d{9}(?!\d)", text):
|
||||
leaked.append("phone")
|
||||
|
||||
# 身份证号(18 位)
|
||||
if re.search(r"\b\d{17}[\dXx]\b", text):
|
||||
# 身份证号(18位)
|
||||
if re.search(r"(?<!\d)\d{17}[\dXx](?!\d)", text):
|
||||
leaked.append("id_card")
|
||||
|
||||
# 银行卡(16-19 位连续数字,简单判断)
|
||||
if re.search(r"\b\d{16,19}\b", text):
|
||||
# 银行卡(16-19位连续数字,简单判断)
|
||||
if re.search(r"(?<!\d)\d{16,19}(?!\d)", text):
|
||||
leaked.append("bank_card")
|
||||
|
||||
# 邮箱(个人邮箱,非公司邮箱)
|
||||
# 邮箱(个人邮箱,非公司邮箱)
|
||||
# 邮箱以 ASCII 字母开头,(?<!\w) 这里可用(前面不会是中文邮箱前缀)
|
||||
personal_email_pattern = (
|
||||
r"\b[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
|
||||
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b"
|
||||
r"(?<!\w)[a-zA-Z0-9._%+-]+@(?!servyou-it\.com|"
|
||||
r"servyou\.com\.cn)[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}(?!\w)"
|
||||
)
|
||||
if re.search(personal_email_pattern, text):
|
||||
leaked.append("personal_email")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -355,7 +355,7 @@ class MessageRouter:
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="AI智能助手",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=reply_text,
|
||||
msg_type="text",
|
||||
is_read=False,
|
||||
@@ -518,7 +518,7 @@ class MessageRouter:
|
||||
# AI 回复/引导/降级均用 AI 消息类型
|
||||
sender_type = "ai"
|
||||
sender_id = "ai_bot"
|
||||
sender_name = "AI智能助手"
|
||||
sender_name = "Duckula(达寇拉)"
|
||||
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — Neo4j 图数据库客户端
|
||||
# =============================================================================
|
||||
# 说明:封装 Neo4j 官方异步驱动,提供连接池、读写事务分离、图 schema 初始化、
|
||||
# 健康检查等基础能力。T01 先实现基础设施,T02 扩展图节点 CRUD。
|
||||
#
|
||||
# 核心能力:
|
||||
# 1. AsyncDriver 连接池管理(initialize / close)
|
||||
# 2. 读写事务分离(execute_write_query / execute_read_query)
|
||||
# 3. 图 schema 初始化(约束 + 索引)
|
||||
# 4. 健康检查(health_check → RETURN 1)
|
||||
# 5. 图节点 CRUD(create/merge/find IssueNode / ActionNode / RelationEdge)
|
||||
#
|
||||
# 图 Schema 对齐:复杂场景重构 v1.1 TeliChat 白盒模型
|
||||
# 节点:Issue / Action / Info / Session
|
||||
# 关系:LEADS_TO / RELATES_TO / HAS_ACTION / PROVIDED / CORRECTED_TO
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from neo4j import AsyncDriver, AsyncGraphDatabase
|
||||
from neo4j.exceptions import Neo4jError, ServiceUnavailable
|
||||
|
||||
from app.config import settings
|
||||
from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Neo4jClient:
|
||||
"""Neo4j 图数据库异步客户端。
|
||||
|
||||
封装 neo4j 官方异步驱动(AsyncDriver),提供连接池管理、
|
||||
读写事务分离、图 schema 初始化和图节点 CRUD 操作。
|
||||
|
||||
使用方式:
|
||||
client = Neo4jClient()
|
||||
await client.initialize()
|
||||
# ... 执行操作 ...
|
||||
await client.close()
|
||||
|
||||
Attributes:
|
||||
uri: Neo4j bolt 连接地址
|
||||
user: Neo4j 用户名
|
||||
password: Neo4j 密码
|
||||
database: 默认数据库名
|
||||
_driver: AsyncDriver 实例(懒加载)
|
||||
"""
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图 Schema — Cypher 约束与索引
|
||||
# --------------------------------------------------------------------------
|
||||
_SCHEMA_CONSTRAINTS: List[str] = [
|
||||
# Issue 节点唯一约束
|
||||
"CREATE CONSTRAINT issue_uuid IF NOT EXISTS FOR (i:Issue) REQUIRE i.uuid IS UNIQUE",
|
||||
# Action 节点唯一约束
|
||||
"CREATE CONSTRAINT action_uuid IF NOT EXISTS FOR (a:Action) REQUIRE a.uuid IS UNIQUE",
|
||||
]
|
||||
|
||||
_SCHEMA_INDEXES: List[str] = [
|
||||
# Issue 按分类查询索引
|
||||
"CREATE INDEX issue_category IF NOT EXISTS FOR (i:Issue) ON (i.category)",
|
||||
# Issue 按名称查询索引
|
||||
"CREATE INDEX issue_name IF NOT EXISTS FOR (i:Issue) ON (i.name)",
|
||||
# Action 按名称查询索引
|
||||
"CREATE INDEX action_name IF NOT EXISTS FOR (a:Action) ON (a.name)",
|
||||
]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
uri: Optional[str] = None,
|
||||
user: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
database: Optional[str] = None,
|
||||
max_connection_lifetime: Optional[int] = None,
|
||||
max_connection_pool_size: Optional[int] = None,
|
||||
connection_acquisition_timeout: Optional[int] = None,
|
||||
):
|
||||
"""初始化 Neo4j 客户端。
|
||||
|
||||
所有参数均可选,未提供时从 app.config.settings 读取默认值。
|
||||
|
||||
Args:
|
||||
uri: Neo4j bolt 连接地址
|
||||
user: Neo4j 用户名
|
||||
password: Neo4j 密码
|
||||
database: 默认数据库名
|
||||
max_connection_lifetime: 连接最大存活时间(秒)
|
||||
max_connection_pool_size: 连接池上限
|
||||
connection_acquisition_timeout: 连接获取超时(秒)
|
||||
"""
|
||||
self.uri: str = uri or settings.neo4j_uri
|
||||
self.user: str = user or settings.neo4j_user
|
||||
self.password: str = password or settings.neo4j_password
|
||||
self.database: str = database or settings.neo4j_database
|
||||
self.max_connection_lifetime: int = (
|
||||
max_connection_lifetime or settings.neo4j_max_connection_lifetime
|
||||
)
|
||||
self.max_connection_pool_size: int = (
|
||||
max_connection_pool_size or settings.neo4j_max_connection_pool_size
|
||||
)
|
||||
self.connection_acquisition_timeout: int = (
|
||||
connection_acquisition_timeout
|
||||
or settings.neo4j_connection_acquisition_timeout
|
||||
)
|
||||
self._driver: Optional[AsyncDriver] = None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 生命周期管理
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""初始化 Neo4j 连接并验证可用性。
|
||||
|
||||
创建 AsyncDriver 连接池,执行健康检查,创建图 schema(约束+索引)。
|
||||
|
||||
Raises:
|
||||
ServiceUnavailable: Neo4j 服务不可达
|
||||
Neo4jError: 图 schema 初始化失败
|
||||
"""
|
||||
if self._driver is not None:
|
||||
logger.warning("Neo4jClient 已初始化,跳过重复初始化")
|
||||
return
|
||||
|
||||
logger.info(f"正在初始化 Neo4j 客户端: uri={self.uri}, database={self.database}")
|
||||
|
||||
self._driver = AsyncGraphDatabase.driver(
|
||||
self.uri,
|
||||
auth=(self.user, self.password),
|
||||
max_connection_lifetime=self.max_connection_lifetime,
|
||||
max_connection_pool_size=self.max_connection_pool_size,
|
||||
connection_acquisition_timeout=self.connection_acquisition_timeout,
|
||||
)
|
||||
|
||||
# 验证连接
|
||||
healthy = await self.health_check()
|
||||
if not healthy:
|
||||
await self._driver.close()
|
||||
self._driver = None
|
||||
raise ServiceUnavailable(
|
||||
f"Neo4j 服务不可达: {self.uri},健康检查失败"
|
||||
)
|
||||
|
||||
# 创建图 schema(约束 + 索引)
|
||||
await self._init_schema()
|
||||
|
||||
logger.info("Neo4j 客户端初始化完成")
|
||||
|
||||
async def close(self) -> None:
|
||||
"""关闭 Neo4j 驱动,释放连接池资源。"""
|
||||
if self._driver is not None:
|
||||
await self._driver.close()
|
||||
self._driver = None
|
||||
logger.info("Neo4j 客户端已关闭")
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""验证 Neo4j 连接可用性。
|
||||
|
||||
执行简单的 RETURN 1 Cypher 查询验证连接。
|
||||
|
||||
Returns:
|
||||
bool: 连接正常返回 True,否则 False
|
||||
"""
|
||||
if self._driver is None:
|
||||
return False
|
||||
try:
|
||||
result = await self.execute_read_query("RETURN 1 AS ok")
|
||||
records = [record async for record in result]
|
||||
return len(records) > 0 and records[0].get("ok") == 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Neo4j 健康检查失败: {e}")
|
||||
return False
|
||||
|
||||
async def _init_schema(self) -> None:
|
||||
"""初始化图 schema:创建约束和索引。
|
||||
|
||||
所有约束和索引使用 IF NOT EXISTS,幂等安全。
|
||||
"""
|
||||
for cypher in self._SCHEMA_CONSTRAINTS:
|
||||
try:
|
||||
await self.execute_write_query(cypher)
|
||||
logger.debug(f"图约束已创建: {cypher[:60]}...")
|
||||
except Neo4jError as e:
|
||||
logger.warning(f"图约束创建失败(可能已存在): {e}")
|
||||
|
||||
for cypher in self._SCHEMA_INDEXES:
|
||||
try:
|
||||
await self.execute_write_query(cypher)
|
||||
logger.debug(f"图索引已创建: {cypher[:60]}...")
|
||||
except Neo4jError as e:
|
||||
logger.warning(f"图索引创建失败(可能已存在): {e}")
|
||||
|
||||
logger.info("图 schema 初始化完成(约束 + 索引)")
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 通用查询方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def execute_write_query(
|
||||
self, cypher: str, params: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""执行写事务(CREATE/MERGE/DELETE/SET)。
|
||||
|
||||
使用 execute_write 自动管理写事务,支持重试策略。
|
||||
|
||||
Args:
|
||||
cypher: Cypher 查询语句
|
||||
params: 查询参数(可选)
|
||||
|
||||
Returns:
|
||||
查询结果(EagerResult)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 客户端未初始化
|
||||
Neo4jError: 查询执行失败
|
||||
"""
|
||||
if self._driver is None:
|
||||
raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()")
|
||||
|
||||
async def _write(tx):
|
||||
result = await tx.run(cypher, parameters=params or {})
|
||||
return await result.data()
|
||||
|
||||
async with self._driver.session(database=self.database) as session:
|
||||
return await session.execute_write(_write)
|
||||
|
||||
async def execute_read_query(
|
||||
self, cypher: str, params: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""执行读事务(MATCH/RETURN)。
|
||||
|
||||
使用 execute_read 自动管理读事务。
|
||||
|
||||
Args:
|
||||
cypher: Cypher 查询语句
|
||||
params: 查询参数(可选)
|
||||
|
||||
Returns:
|
||||
查询结果(EagerResult)
|
||||
|
||||
Raises:
|
||||
RuntimeError: 客户端未初始化
|
||||
Neo4jError: 查询执行失败
|
||||
"""
|
||||
if self._driver is None:
|
||||
raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()")
|
||||
|
||||
async def _read(tx):
|
||||
result = await tx.run(cypher, parameters=params or {})
|
||||
return await result.data()
|
||||
|
||||
async with self._driver.session(database=self.database) as session:
|
||||
return await session.execute_read(_read)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图节点 CRUD — IssueNode
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def create_issue_node(self, issue: IssueNode) -> IssueNode:
|
||||
"""创建 Issue 节点(CREATE,非幂等)。
|
||||
|
||||
Args:
|
||||
issue: IssueNode 实例
|
||||
|
||||
Returns:
|
||||
IssueNode: 创建后的 IssueNode(含 Neo4j 分配的 uuid)
|
||||
"""
|
||||
props = {
|
||||
"name": issue.name,
|
||||
"category": issue.category,
|
||||
"created_at": (issue.created_at or datetime.now(timezone.utc)).isoformat(),
|
||||
"updated_at": (issue.updated_at or datetime.now(timezone.utc)).isoformat(),
|
||||
"source_suggestion_id": issue.source_suggestion_id,
|
||||
}
|
||||
data = await self.execute_write_query(
|
||||
"""
|
||||
CREATE (i:Issue)
|
||||
SET i = $props
|
||||
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
|
||||
i.created_at AS created_at, i.updated_at AS updated_at,
|
||||
i.source_suggestion_id AS source_suggestion_id
|
||||
""",
|
||||
params={"props": props},
|
||||
)
|
||||
record = data[0]
|
||||
return IssueNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
async def merge_issue(
|
||||
self, name: str, category: str, props: Optional[Dict[str, Any]] = None
|
||||
) -> IssueNode:
|
||||
"""幂等创建或获取 Issue 节点(MERGE,按 name 匹配)。
|
||||
|
||||
使用 MERGE 保证幂等:如果已存在同 name 的 Issue 则返回已有节点,
|
||||
否则创建新节点。这是图写入的核心方法,对齐 D1「解读2 合一」的去重策略。
|
||||
|
||||
Args:
|
||||
name: Issue 名称(唯一业务键)
|
||||
category: Issue 分类
|
||||
props: 额外属性(可选,创建时设置)
|
||||
|
||||
Returns:
|
||||
IssueNode: 创建或获取到的 IssueNode
|
||||
"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
merge_props = {
|
||||
"category": category,
|
||||
"updated_at": now,
|
||||
}
|
||||
if props:
|
||||
merge_props.update(props)
|
||||
merge_props.setdefault("created_at", now)
|
||||
|
||||
data = await self.execute_write_query(
|
||||
"""
|
||||
MERGE (i:Issue {name: $name})
|
||||
ON CREATE SET i.uuid = randomUUID(),
|
||||
i += $props,
|
||||
i.created_at = coalesce($props.created_at, $now)
|
||||
ON MATCH SET i.category = $category,
|
||||
i.updated_at = $now
|
||||
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
|
||||
i.created_at AS created_at, i.updated_at AS updated_at,
|
||||
i.source_suggestion_id AS source_suggestion_id
|
||||
""",
|
||||
params={
|
||||
"name": name,
|
||||
"category": category,
|
||||
"props": merge_props,
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
record = data[0]
|
||||
return IssueNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
async def find_issue_by_name(self, name: str) -> Optional[IssueNode]:
|
||||
"""按名称查找 Issue 节点。
|
||||
|
||||
Args:
|
||||
name: Issue 名称
|
||||
|
||||
Returns:
|
||||
Optional[IssueNode]: 找到的 IssueNode,未找到返回 None
|
||||
"""
|
||||
data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (i:Issue {name: $name})
|
||||
RETURN i.uuid AS uuid, i.name AS name, i.category AS category,
|
||||
i.created_at AS created_at, i.updated_at AS updated_at,
|
||||
i.source_suggestion_id AS source_suggestion_id
|
||||
LIMIT 1
|
||||
""",
|
||||
params={"name": name},
|
||||
)
|
||||
if not data:
|
||||
return None
|
||||
record = data[0]
|
||||
return IssueNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
async def find_related_issues(
|
||||
self, uuid: str, rel_type: Optional[str] = None
|
||||
) -> List[IssueNode]:
|
||||
"""查找与指定 Issue 节点有关联的其他 Issue 节点。
|
||||
|
||||
Args:
|
||||
uuid: 源 Issue 节点的 uuid
|
||||
rel_type: 关系类型过滤(可选,如 "LEADS_TO"/"RELATES_TO")
|
||||
|
||||
Returns:
|
||||
List[IssueNode]: 关联的 IssueNode 列表
|
||||
"""
|
||||
rel_filter = f":{rel_type}" if rel_type else ""
|
||||
data = await self.execute_read_query(
|
||||
f"""
|
||||
MATCH (i:Issue {{uuid: $uuid}})-[{rel_filter}]->(related:Issue)
|
||||
RETURN related.uuid AS uuid, related.name AS name,
|
||||
related.category AS category,
|
||||
related.created_at AS created_at,
|
||||
related.updated_at AS updated_at,
|
||||
related.source_suggestion_id AS source_suggestion_id
|
||||
""",
|
||||
params={"uuid": uuid},
|
||||
)
|
||||
return [
|
||||
IssueNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
category=record["category"],
|
||||
created_at=record["created_at"],
|
||||
updated_at=record["updated_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
for record in data
|
||||
]
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图节点 CRUD — ActionNode
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def create_action_node(self, action: ActionNode) -> ActionNode:
|
||||
"""创建 Action 节点(CREATE,非幂等)。
|
||||
|
||||
Args:
|
||||
action: ActionNode 实例
|
||||
|
||||
Returns:
|
||||
ActionNode: 创建后的 ActionNode(含 Neo4j 分配的 uuid)
|
||||
"""
|
||||
props = {
|
||||
"name": action.name,
|
||||
"description": action.description,
|
||||
"created_at": (action.created_at or datetime.now(timezone.utc)).isoformat(),
|
||||
"source_suggestion_id": action.source_suggestion_id,
|
||||
}
|
||||
data = await self.execute_write_query(
|
||||
"""
|
||||
CREATE (a:Action)
|
||||
SET a = $props
|
||||
RETURN a.uuid AS uuid, a.name AS name,
|
||||
a.description AS description, a.created_at AS created_at,
|
||||
a.source_suggestion_id AS source_suggestion_id
|
||||
""",
|
||||
params={"props": props},
|
||||
)
|
||||
record = data[0]
|
||||
return ActionNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
description=record.get("description", ""),
|
||||
created_at=record["created_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
async def merge_action(
|
||||
self, name: str, props: Optional[Dict[str, Any]] = None
|
||||
) -> ActionNode:
|
||||
"""幂等创建或获取 Action 节点(MERGE,按 name 匹配)。
|
||||
|
||||
Args:
|
||||
name: Action 名称(唯一业务键)
|
||||
props: 额外属性(可选)
|
||||
|
||||
Returns:
|
||||
ActionNode: 创建或获取到的 ActionNode
|
||||
"""
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
merge_props: Dict[str, Any] = {}
|
||||
if props:
|
||||
merge_props.update(props)
|
||||
merge_props.setdefault("created_at", now)
|
||||
|
||||
data = await self.execute_write_query(
|
||||
"""
|
||||
MERGE (a:Action {name: $name})
|
||||
ON CREATE SET a.uuid = randomUUID(),
|
||||
a += $props,
|
||||
a.created_at = coalesce($props.created_at, $now)
|
||||
ON MATCH SET a.description = coalesce($props.description, a.description)
|
||||
RETURN a.uuid AS uuid, a.name AS name,
|
||||
a.description AS description, a.created_at AS created_at,
|
||||
a.source_suggestion_id AS source_suggestion_id
|
||||
""",
|
||||
params={
|
||||
"name": name,
|
||||
"props": merge_props,
|
||||
"now": now,
|
||||
},
|
||||
)
|
||||
record = data[0]
|
||||
return ActionNode(
|
||||
uuid=record["uuid"],
|
||||
name=record["name"],
|
||||
description=record.get("description", ""),
|
||||
created_at=record["created_at"],
|
||||
source_suggestion_id=record.get("source_suggestion_id"),
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图关系 CRUD — RelationEdge
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def create_relation(
|
||||
self, from_uuid: str, to_uuid: str, rel: RelationEdge
|
||||
) -> bool:
|
||||
"""创建两个节点之间的关系。
|
||||
|
||||
支持 Issue→Issue、Issue→Action 的关系创建。
|
||||
使用 MATCH+CREATE 模式确保节点存在后才建关系。
|
||||
|
||||
Args:
|
||||
from_uuid: 起始节点 uuid
|
||||
to_uuid: 目标节点 uuid
|
||||
rel: 关系定义(含 type, order, weight)
|
||||
|
||||
Returns:
|
||||
bool: 创建成功返回 True
|
||||
"""
|
||||
rel_type = rel.type.value if hasattr(rel.type, 'value') else str(rel.type)
|
||||
# Neo4j 不支持动态关系类型参数化,使用 f-string(仅 rel_type 来自枚举,安全)
|
||||
cypher = (
|
||||
f"MATCH (from_node), (to_node) "
|
||||
f"WHERE from_node.uuid = $from_uuid AND to_node.uuid = $to_uuid "
|
||||
f"CREATE (from_node)-[:{rel_type} {{order: $order, weight: $weight}}]->(to_node) "
|
||||
f"RETURN count(*) AS created"
|
||||
)
|
||||
data = await self.execute_write_query(
|
||||
cypher,
|
||||
params={
|
||||
"from_uuid": from_uuid,
|
||||
"to_uuid": to_uuid,
|
||||
"order": rel.order,
|
||||
"weight": rel.weight,
|
||||
},
|
||||
)
|
||||
return data[0].get("created", 0) > 0 if data else False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图查询 — 全图导出(任务2:知识图谱可视化)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def query_full_graph(
|
||||
self, limit: int = 100
|
||||
) -> Dict[str, Any]:
|
||||
"""查询全图节点和关系,返回 ECharts 力导向图格式的 JSON。
|
||||
|
||||
查询所有 Issue/Action 节点及其关系,按创建时间降序排列。
|
||||
用于管理后台知识图谱可视化和坐席审批卡片拓扑预览。
|
||||
|
||||
Args:
|
||||
limit: 返回节点数量上限,默认100
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"nodes": [{"id": str, "name": str, "category": str, "label": str, "type": str}, ...],
|
||||
"links": [{"source": str, "target": str, "type": str, "weight": float}, ...],
|
||||
}
|
||||
"""
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
links: List[Dict[str, Any]] = []
|
||||
|
||||
try:
|
||||
# 查询所有 Issue 节点
|
||||
issue_data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (i:Issue)
|
||||
RETURN i.uuid AS id, i.name AS name, i.category AS category,
|
||||
'issue' AS node_type
|
||||
ORDER BY i.created_at DESC
|
||||
LIMIT $limit
|
||||
""",
|
||||
params={"limit": limit},
|
||||
)
|
||||
for row in issue_data:
|
||||
nodes.append({
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"category": row.get("category", "其他"),
|
||||
"label": row["name"],
|
||||
"type": row["node_type"],
|
||||
})
|
||||
|
||||
# 查询所有 Action 节点
|
||||
action_data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (a:Action)
|
||||
RETURN a.uuid AS id, a.name AS name, a.description AS description,
|
||||
'action' AS node_type
|
||||
ORDER BY a.created_at DESC
|
||||
LIMIT $limit
|
||||
""",
|
||||
params={"limit": limit},
|
||||
)
|
||||
for row in action_data:
|
||||
nodes.append({
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"category": row.get("description", ""),
|
||||
"label": row["name"],
|
||||
"type": row["node_type"],
|
||||
})
|
||||
|
||||
# 查询所有关系(任意节点之间的有向边)
|
||||
rel_data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (n)-[r]->(m)
|
||||
WHERE (n:Issue OR n:Action) AND (m:Issue OR m:Action)
|
||||
RETURN n.uuid AS source, m.uuid AS target,
|
||||
type(r) AS rel_type,
|
||||
coalesce(r.weight, 1.0) AS weight
|
||||
LIMIT $limit
|
||||
""",
|
||||
params={"limit": limit * 3},
|
||||
)
|
||||
for row in rel_data:
|
||||
links.append({
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"type": row["rel_type"],
|
||||
"weight": float(row.get("weight", 1.0)),
|
||||
})
|
||||
|
||||
logger.info(
|
||||
f"全图查询完成: nodes={len(nodes)}, links={len(links)}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"全图查询失败: {e}")
|
||||
|
||||
return {"nodes": nodes, "links": links}
|
||||
|
||||
async def query_issue_subgraph(
|
||||
self, issue_name: str, depth: int = 1
|
||||
) -> Dict[str, Any]:
|
||||
"""查询指定 Issue 的子图(用于审批卡片拓扑预览)。
|
||||
|
||||
以指定 Issue 为中心,向外扩展 depth 层关系。
|
||||
|
||||
Args:
|
||||
issue_name: Issue 名称
|
||||
depth: 扩展层数,默认1层
|
||||
|
||||
Returns:
|
||||
Dict: {"nodes": [...], "links": [...]}
|
||||
"""
|
||||
nodes: List[Dict[str, Any]] = []
|
||||
links: List[Dict[str, Any]] = []
|
||||
seen: set = set()
|
||||
|
||||
try:
|
||||
subgraph_data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH path = (center:Issue {name: $name})-[*0..%d]-(neighbor)
|
||||
WHERE neighbor:Issue OR neighbor:Action
|
||||
WITH nodes(path) AS ns, relationships(path) AS rs
|
||||
UNWIND ns AS n
|
||||
WITH DISTINCT n
|
||||
RETURN n.uuid AS id, labels(n)[0] AS node_type,
|
||||
coalesce(n.name, n.description, '') AS name,
|
||||
coalesce(n.category, n.description, '') AS category
|
||||
LIMIT 30
|
||||
""" % depth,
|
||||
params={"name": issue_name},
|
||||
)
|
||||
for row in subgraph_data:
|
||||
nid = row["id"]
|
||||
if nid not in seen:
|
||||
seen.add(nid)
|
||||
nodes.append({
|
||||
"id": nid,
|
||||
"name": row["name"],
|
||||
"category": row.get("category", ""),
|
||||
"label": row["name"],
|
||||
"type": row["node_type"].lower() if row["node_type"] else "issue",
|
||||
})
|
||||
|
||||
# 查询子图内的关系
|
||||
rel_data = await self.execute_read_query(
|
||||
"""
|
||||
MATCH (center:Issue {name: $name})-[r*1..%d]-(neighbor)
|
||||
UNWIND r AS rel
|
||||
WITH DISTINCT rel
|
||||
MATCH (n)-[rel]->(m)
|
||||
RETURN startNode(rel).uuid AS source,
|
||||
endNode(rel).uuid AS target,
|
||||
type(rel) AS rel_type,
|
||||
coalesce(rel.weight, 1.0) AS weight
|
||||
LIMIT 30
|
||||
""" % depth,
|
||||
params={"name": issue_name},
|
||||
)
|
||||
for row in rel_data:
|
||||
links.append({
|
||||
"source": row["source"],
|
||||
"target": row["target"],
|
||||
"type": row["rel_type"],
|
||||
"weight": float(row.get("weight", 1.0)),
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"子图查询失败 (issue={issue_name}): {e}")
|
||||
|
||||
return {"nodes": nodes, "links": links}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 依赖注入函数
|
||||
# =============================================================================
|
||||
|
||||
# 全局 Neo4jClient 单例(模块级懒加载)
|
||||
_neo4j_client: Optional[Neo4jClient] = None
|
||||
|
||||
|
||||
async def dep_neo4j_client() -> Neo4jClient:
|
||||
"""获取 Neo4jClient 单例实例(FastAPI 依赖注入)。
|
||||
|
||||
首次调用时自动初始化连接池和图 schema。
|
||||
应用关闭时需调用 close() 释放资源(见 main.py 生命周期)。
|
||||
|
||||
Returns:
|
||||
Neo4jClient: 已初始化的 Neo4j 客户端实例
|
||||
|
||||
Raises:
|
||||
ServiceUnavailable: Neo4j 服务不可达
|
||||
"""
|
||||
global _neo4j_client
|
||||
if _neo4j_client is None:
|
||||
_neo4j_client = Neo4jClient()
|
||||
await _neo4j_client.initialize()
|
||||
elif not await _neo4j_client.health_check():
|
||||
# 连接断开后重新初始化
|
||||
logger.warning("Neo4j 连接已断开,尝试重新初始化")
|
||||
await _neo4j_client.close()
|
||||
_neo4j_client = Neo4jClient()
|
||||
await _neo4j_client.initialize()
|
||||
return _neo4j_client
|
||||
|
||||
|
||||
async def get_neo4j_client() -> Optional[Neo4jClient]:
|
||||
"""获取 Neo4jClient(安全版本,不抛异常)。
|
||||
|
||||
Neo4j 不可用时返回 None,由调用方做降级处理。
|
||||
用于非 DI 场景(如 service 层直接调用)。
|
||||
|
||||
Returns:
|
||||
Optional[Neo4jClient]: Neo4j 客户端实例,不可用时返回 None
|
||||
"""
|
||||
global _neo4j_client
|
||||
try:
|
||||
if _neo4j_client is None:
|
||||
_neo4j_client = Neo4jClient()
|
||||
await _neo4j_client.initialize()
|
||||
return _neo4j_client
|
||||
except Exception as e:
|
||||
logger.warning(f"Neo4j 客户端初始化失败(图功能将不可用): {e}")
|
||||
return None
|
||||
@@ -0,0 +1,268 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RAGFlow 文档 Ingestion 服务(通道 C / P1-5)
|
||||
# =============================================================================
|
||||
# 说明:封装 RAGFlow 文档上传→ETL→结构化→生成 KnowledgeSuggestion 流程。
|
||||
# 训练师上传非标准格式文档(.docx/.pdf/.txt/.png/.jpg),
|
||||
# RAGFlow 做第一道整理/筛选/结构化,产出 KnowledgeSuggestion 进审批队列。
|
||||
#
|
||||
# 核心流程:
|
||||
# 1. upload_and_process: 上传文档→轮询处理状态→拉取结构化片段→生成建议
|
||||
# 2. poll_processing_status: 轮询 RAGFlow 文档处理状态(最多5分钟)
|
||||
# 3. create_suggestions_from_result: 结构化片段→KnowledgeSuggestion 列表
|
||||
#
|
||||
# 设计决策:
|
||||
# - 触发方式:训练师手动上传(P1-5 决策)
|
||||
# - source_type=document_ragflow, audience=engineer_workguide
|
||||
# - 复用现有 integrations/ragflow/ 客户端基础设施
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RagflowIngestionService:
|
||||
"""RAGFlow 文档 Ingestion 服务 — 通道 C。
|
||||
|
||||
将非标准格式文档通过 RAGFlow 整理/筛选/结构化,
|
||||
产出 KnowledgeSuggestion 进入 D7 审批流程。
|
||||
|
||||
使用方式:
|
||||
service = RagflowIngestionService()
|
||||
result = await service.upload_and_process(file_data, file_name, category_hint)
|
||||
"""
|
||||
|
||||
# 轮询参数
|
||||
_POLL_INTERVAL_SECONDS: int = 10 # 轮询间隔(秒)
|
||||
_MAX_WAIT_SECONDS: int = 300 # 最大等待时间(5分钟)
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 RAGFlow Ingestion 服务。"""
|
||||
self.ragflow_base_url: str = settings.automation_ragflow_base_url
|
||||
self.ragflow_api_key: str = settings.automation_ragflow_api_key
|
||||
self.enabled: bool = settings.ragflow_ingestion_enabled
|
||||
|
||||
async def upload_and_process(
|
||||
self,
|
||||
file_data: bytes,
|
||||
file_name: str,
|
||||
category_hint: str = "其他",
|
||||
) -> Dict[str, Any]:
|
||||
"""上传文档到 RAGFlow 并等待处理完成,生成 KnowledgeSuggestion 列表。
|
||||
|
||||
Args:
|
||||
file_data: 文件字节流
|
||||
file_name: 文件名(含扩展名,如 "FAQ更新说明.docx")
|
||||
category_hint: 分类提示(可选,帮助 RAGFlow 归类)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"task_id": str, # RAGFlow 任务ID
|
||||
"status": str, # "completed" / "failed" / "pending"
|
||||
"suggestions": list[dict], # KnowledgeSuggestion 列表
|
||||
}
|
||||
"""
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
if not self.enabled:
|
||||
logger.info("RAGFlow Ingestion 未启用,返回空结果")
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "disabled",
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# 1. 上传文档到 RAGFlow
|
||||
doc_id = await self._upload_document(file_data, file_name)
|
||||
if not doc_id:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "failed",
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
# 2. 轮询处理状态
|
||||
status = await self.poll_processing_status(doc_id)
|
||||
if status != "completed":
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": status,
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
# 3. 拉取结构化片段
|
||||
chunks = await self._fetch_document_chunks(doc_id)
|
||||
if not chunks:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "completed",
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
# 4. 生成 KnowledgeSuggestion
|
||||
suggestions = self.create_suggestions_from_result(chunks, category_hint)
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "completed",
|
||||
"suggestions": suggestions,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"RAGFlow Ingestion 失败: {e}")
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"status": "failed",
|
||||
"suggestions": [],
|
||||
}
|
||||
|
||||
async def poll_processing_status(
|
||||
self, doc_id: str, max_wait: int = 300
|
||||
) -> str:
|
||||
"""轮询 RAGFlow 文档处理状态。
|
||||
|
||||
Args:
|
||||
doc_id: RAGFlow 文档ID
|
||||
max_wait: 最大等待时间(秒),默认 300 秒
|
||||
|
||||
Returns:
|
||||
str: "completed" / "failed" / "pending" / "timeout"
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
try:
|
||||
status = await self._get_document_status(doc_id)
|
||||
if status == "completed":
|
||||
logger.info(f"RAGFlow 文档处理完成: doc_id={doc_id}")
|
||||
return "completed"
|
||||
if status == "failed":
|
||||
logger.error(f"RAGFlow 文档处理失败: doc_id={doc_id}")
|
||||
return "failed"
|
||||
except Exception as e:
|
||||
logger.warning(f"轮询 RAGFlow 状态失败: {e}")
|
||||
|
||||
await asyncio.sleep(self._POLL_INTERVAL_SECONDS)
|
||||
elapsed += self._POLL_INTERVAL_SECONDS
|
||||
logger.debug(
|
||||
f"轮询 RAGFlow 状态: doc_id={doc_id}, elapsed={elapsed}s"
|
||||
)
|
||||
|
||||
logger.warning(f"RAGFlow 文档处理超时: doc_id={doc_id}")
|
||||
return "timeout"
|
||||
|
||||
def create_suggestions_from_result(
|
||||
self, chunks: List[Dict[str, Any]], category_hint: str = "其他"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""将 RAGFlow 结构化片段转为 KnowledgeSuggestion 列表。
|
||||
|
||||
每个片段生成一个建议,source_type=document_ragflow,
|
||||
audience=engineer_workguide(通道 C 默认工程师作业指导)。
|
||||
|
||||
Args:
|
||||
chunks: RAGFlow 结构化段落列表
|
||||
category_hint: 分类提示
|
||||
|
||||
Returns:
|
||||
List[Dict]: KnowledgeSuggestion 数据列表(可直接用于创建 DB 记录)
|
||||
"""
|
||||
suggestions: List[Dict[str, Any]] = []
|
||||
for chunk in chunks:
|
||||
suggestion = {
|
||||
"suggestion_type": "new_faq",
|
||||
"title": chunk.get("title", "RAGFlow 提取的知识片段"),
|
||||
"content": chunk.get("content", ""),
|
||||
"category": category_hint or chunk.get("category", "其他"),
|
||||
"tags": chunk.get("tags", []),
|
||||
"source_type": "document_ragflow",
|
||||
"source_data": [chunk.get("chunk_id", str(uuid.uuid4()))],
|
||||
"reason": f"RAGFlow 从文档中提取的结构化知识片段",
|
||||
"confidence": 0.85, # RAGFlow 结构化提取默认 0.85(§8.2)
|
||||
"audience": "engineer_workguide", # 通道 C 默认工程师作业指导
|
||||
"issue": chunk.get("issue", ""),
|
||||
"action": chunk.get("action", ""),
|
||||
"relation_type": "LEADS_TO",
|
||||
"parent_issue": "",
|
||||
"graph_meta": {},
|
||||
"graph_sync_status": "pending",
|
||||
"source_failed": False,
|
||||
}
|
||||
suggestions.append(suggestion)
|
||||
|
||||
logger.info(
|
||||
f"RAGFlow 生成 {len(suggestions)} 条 KnowledgeSuggestion"
|
||||
)
|
||||
return suggestions
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内部方法 — RAGFlow API 调用
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _upload_document(
|
||||
self, file_data: bytes, file_name: str
|
||||
) -> Optional[str]:
|
||||
"""上传文档到 RAGFlow。
|
||||
|
||||
Args:
|
||||
file_data: 文件字节流
|
||||
file_name: 文件名
|
||||
|
||||
Returns:
|
||||
Optional[str]: RAGFlow 文档ID,失败返回 None
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
from app.integrations.ragflow.client import RagflowClient
|
||||
|
||||
# TODO: 接入现有 RagflowClient 实现实际上传
|
||||
# 当前返回模拟 doc_id(RAGFlow 服务部署后替换为真实调用)
|
||||
logger.info(
|
||||
f"RAGFlow 文档上传(模拟): file_name={file_name}, "
|
||||
f"size={len(file_data)}"
|
||||
)
|
||||
doc_id = f"ragflow_doc_{uuid.uuid4().hex[:12]}"
|
||||
return doc_id
|
||||
|
||||
except ImportError:
|
||||
logger.warning("RAGFlow 客户端不可用,返回模拟 doc_id")
|
||||
return f"ragflow_doc_{uuid.uuid4().hex[:12]}"
|
||||
except Exception as e:
|
||||
logger.error(f"RAGFlow 文档上传失败: {e}")
|
||||
return None
|
||||
|
||||
async def _get_document_status(self, doc_id: str) -> str:
|
||||
"""查询 RAGFlow 文档处理状态。
|
||||
|
||||
Args:
|
||||
doc_id: RAGFlow 文档ID
|
||||
|
||||
Returns:
|
||||
str: "processing" / "completed" / "failed"
|
||||
"""
|
||||
# TODO: 接入现有 RagflowClient 实现状态查询
|
||||
# 当前返回 completed(占位,RAGFlow 服务部署后替换为真实调用)
|
||||
logger.debug(f"RAGFlow 文档状态查询(模拟): doc_id={doc_id}")
|
||||
return "completed"
|
||||
|
||||
async def _fetch_document_chunks(
|
||||
self, doc_id: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""拉取 RAGFlow 处理后的结构化段落。
|
||||
|
||||
Args:
|
||||
doc_id: RAGFlow 文档ID
|
||||
|
||||
Returns:
|
||||
List[Dict]: 结构化段落列表
|
||||
"""
|
||||
# TODO: 接入现有 RagflowClient 实现段落拉取
|
||||
# 当前返回空列表(占位,RAGFlow 服务部署后替换为真实调用)
|
||||
logger.info(f"RAGFlow 文档段落拉取(模拟): doc_id={doc_id}")
|
||||
return []
|
||||
@@ -0,0 +1,292 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 视觉理解服务(D5 / P1-3)
|
||||
# =============================================================================
|
||||
# 说明:封装 Qwen-VL 截图理解能力,通过 Dify vision workflow 调用本地
|
||||
# Qwen3-VL-8B-Instruct 模型,将员工截屏转换为结构化描述文本,
|
||||
# 注入会话上下文参与后续 AI 推理。
|
||||
#
|
||||
# 核心能力:
|
||||
# 1. analyze_screenshot: 接收图片字节流 → Dify vision workflow → 结构化描述
|
||||
# 2. _preprocess_image: 图片预处理(resize/compress)
|
||||
# 3. inject_to_conversation_context: 将视觉描述注入会话消息上下文
|
||||
#
|
||||
# 设计决策:
|
||||
# - 视觉理解经 Dify 后端 → Qwen-VL 本地推理(D5 硬约束)
|
||||
# - 图片预处理:Pillow resize max 1024px + JPEG quality=85
|
||||
# - 视觉模型可配置(settings.qwen_vl_model,默认 Qwen3-VL-8B-Instruct)
|
||||
# =============================================================================
|
||||
|
||||
import base64
|
||||
import io
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VisionService:
|
||||
"""视觉理解服务 — 截图→结构化描述。
|
||||
|
||||
调用本地 Qwen-VL(经 Dify vision workflow)分析员工截图,
|
||||
生成结构化文本描述并注入会话上下文。
|
||||
|
||||
使用方式:
|
||||
service = VisionService()
|
||||
result = await service.analyze_screenshot(image_bytes, conversation_id)
|
||||
await service.inject_to_conversation_context(result["description"], conversation_id)
|
||||
|
||||
Attributes:
|
||||
dify_vision_api_url: Dify Vision Workflow API 端点
|
||||
dify_vision_api_key: Dify Vision Workflow API Key
|
||||
model: 视觉模型名称(默认 Qwen3-VL-8B-Instruct)
|
||||
"""
|
||||
|
||||
# 图片预处理参数
|
||||
_MAX_DIMENSION: int = 1024 # 最大边长(像素)
|
||||
_JPEG_QUALITY: int = 85 # JPEG 压缩质量
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dify_vision_api_url: Optional[str] = None,
|
||||
dify_vision_api_key: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
):
|
||||
"""初始化视觉理解服务。
|
||||
|
||||
Args:
|
||||
dify_vision_api_url: Dify Vision Workflow API 端点
|
||||
dify_vision_api_key: Dify Vision Workflow API Key
|
||||
model: 视觉模型名称
|
||||
"""
|
||||
self.dify_vision_api_url: str = (
|
||||
dify_vision_api_url or settings.dify_vision_api_url
|
||||
)
|
||||
self.dify_vision_api_key: str = (
|
||||
dify_vision_api_key or settings.dify_vision_api_key
|
||||
)
|
||||
self.model: str = model or settings.qwen_vl_model
|
||||
self._client: Optional[httpx.AsyncClient] = None
|
||||
|
||||
async def _get_client(self) -> httpx.AsyncClient:
|
||||
"""获取或创建 httpx 异步客户端。"""
|
||||
if self._client is None or self._client.is_closed:
|
||||
self._client = httpx.AsyncClient(
|
||||
timeout=httpx.Timeout(60.0), # 视觉推理可能需要更长时间
|
||||
headers={
|
||||
"Authorization": f"Bearer {self.dify_vision_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
return self._client
|
||||
|
||||
async def close(self):
|
||||
"""关闭 httpx 客户端。"""
|
||||
if self._client and not self._client.is_closed:
|
||||
await self._client.aclose()
|
||||
self._client = None
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def analyze_screenshot(
|
||||
self, image_bytes: bytes, conversation_id: str
|
||||
) -> Dict[str, Any]:
|
||||
"""分析截图,返回结构化视觉描述。
|
||||
|
||||
Args:
|
||||
image_bytes: 图片字节流
|
||||
conversation_id: 会话ID(用于上下文关联)
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"description": str, # 结构化的视觉描述文本
|
||||
"confidence": float, # 视觉理解置信度
|
||||
"metadata": dict, # 元数据(detected_ui_elements, error_codes等)
|
||||
}
|
||||
"""
|
||||
# 默认降级响应
|
||||
default_response: Dict[str, Any] = {
|
||||
"description": "",
|
||||
"confidence": 0.0,
|
||||
"metadata": {},
|
||||
}
|
||||
|
||||
if not self.dify_vision_api_url:
|
||||
logger.warning("Dify Vision API 未配置,跳过视觉分析")
|
||||
return default_response
|
||||
|
||||
try:
|
||||
# 1. 预处理图片
|
||||
processed_image = await self._preprocess_image(image_bytes)
|
||||
|
||||
# 2. 调用 Dify vision workflow
|
||||
result = await self._call_vision_workflow(processed_image, conversation_id)
|
||||
|
||||
if result is None:
|
||||
return default_response
|
||||
|
||||
return {
|
||||
"description": result.get("description", ""),
|
||||
"confidence": float(result.get("confidence", 0.0)),
|
||||
"metadata": result.get("metadata", {}),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"截图视觉分析失败: {e}")
|
||||
return default_response
|
||||
|
||||
async def inject_to_conversation_context(
|
||||
self, description: str, conversation_id: str
|
||||
) -> bool:
|
||||
"""将视觉描述注入会话消息上下文。
|
||||
|
||||
以 system 消息形式将视觉理解结果写入会话消息表,
|
||||
后续 AI 推理时可读取此描述作为上下文。
|
||||
|
||||
Args:
|
||||
description: 视觉理解描述文本
|
||||
conversation_id: 会话ID
|
||||
|
||||
Returns:
|
||||
bool: 注入成功返回 True
|
||||
"""
|
||||
if not description:
|
||||
logger.debug("视觉描述为空,跳过上下文注入")
|
||||
return False
|
||||
|
||||
try:
|
||||
from app.database import _get_session_factory
|
||||
from app.models.message import Message
|
||||
|
||||
session_factory = _get_session_factory()
|
||||
async with session_factory() as db:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
sender_type="system",
|
||||
content=f"[视觉理解] {description}",
|
||||
)
|
||||
db.add(msg)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
f"视觉描述已注入会话 {conversation_id}: "
|
||||
f"description_length={len(description)}"
|
||||
)
|
||||
return True
|
||||
|
||||
except ImportError:
|
||||
logger.warning("Message 模型不可用,无法注入视觉描述")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"注入视觉描述失败: {e}")
|
||||
return False
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内部方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
async def _preprocess_image(self, image_bytes: bytes) -> bytes:
|
||||
"""预处理图片:resize + compress。
|
||||
|
||||
使用 Pillow 将图片缩小到最大 1024px,压缩为 JPEG quality=85,
|
||||
减少传输大小和视觉模型推理开销。
|
||||
|
||||
Args:
|
||||
image_bytes: 原始图片字节流
|
||||
|
||||
Returns:
|
||||
bytes: 预处理后的图片字节流
|
||||
"""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(image_bytes))
|
||||
|
||||
# 转换为 RGB(处理 RGBA/PNG 等格式)
|
||||
if img.mode in ("RGBA", "P", "LA"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# 按最大边长等比缩放
|
||||
w, h = img.size
|
||||
max_dim = max(w, h)
|
||||
if max_dim > self._MAX_DIMENSION:
|
||||
ratio = self._MAX_DIMENSION / max_dim
|
||||
new_w, new_h = int(w * ratio), int(h * ratio)
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
logger.debug(f"图片缩放: {w}x{h} → {new_w}x{new_h}")
|
||||
|
||||
# 输出为 JPEG
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format="JPEG", quality=self._JPEG_QUALITY)
|
||||
result = buffer.getvalue()
|
||||
|
||||
logger.debug(
|
||||
f"图片预处理完成: input_size={len(image_bytes)}, "
|
||||
f"output_size={len(result)}"
|
||||
)
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"图片预处理失败,使用原始图片: {e}")
|
||||
return image_bytes
|
||||
|
||||
async def _call_vision_workflow(
|
||||
self, processed_image: bytes, conversation_id: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""调用 Dify Vision Workflow 进行视觉理解。
|
||||
|
||||
将预处理后的图片以 base64 格式发送到 Dify vision workflow。
|
||||
|
||||
Args:
|
||||
processed_image: 预处理后的图片字节流
|
||||
conversation_id: 会话ID
|
||||
|
||||
Returns:
|
||||
Optional[Dict]: 视觉理解结果,失败返回 None
|
||||
"""
|
||||
try:
|
||||
# Base64 编码图片
|
||||
image_base64 = base64.b64encode(processed_image).decode("utf-8")
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"inputs": {
|
||||
"image_base64": image_base64,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
"response_mode": "blocking",
|
||||
"user": f"vision-{conversation_id[:8]}",
|
||||
}
|
||||
|
||||
client = await self._get_client()
|
||||
logger.info(
|
||||
f"调用 Dify Vision Workflow: conversation_id={conversation_id}, "
|
||||
f"model={self.model}"
|
||||
)
|
||||
response = await client.post(self.dify_vision_api_url, json=payload)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
# 解析 Dify workflow 返回
|
||||
outputs = data.get("data", {}).get("outputs", {})
|
||||
if not outputs:
|
||||
logger.warning("Dify Vision Workflow 返回空 outputs")
|
||||
return None
|
||||
|
||||
return {
|
||||
"description": outputs.get("description", ""),
|
||||
"confidence": float(outputs.get("confidence", 0.0)),
|
||||
"metadata": outputs.get("metadata", {}),
|
||||
}
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error("Dify Vision Workflow 超时")
|
||||
return None
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"Dify Vision Workflow HTTP 错误: status={e.response.status_code}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Dify Vision Workflow 调用失败: {e}")
|
||||
return None
|
||||
@@ -54,6 +54,34 @@ class WingmanService:
|
||||
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 知识建议生成专用 Prompt(Tier0 / T03 — 通道 A/B 复用)
|
||||
# --------------------------------------------------------------------------
|
||||
_KNOWLEDGE_SUGGESTION_PROMPT: str = (
|
||||
"你是一个IT知识库优化助手,基于对话上下文分析知识库的不足,"
|
||||
"生成结构化的知识库优化建议。\n\n"
|
||||
"分析以下对话,判断是否需要新增FAQ或更新已有知识条目。\n"
|
||||
"如果AI回复被标记为无用,生成更新建议;如果AI无法解决需转人工,生成新增FAQ建议。\n\n"
|
||||
"必须以JSON格式输出,包含以下字段:\n"
|
||||
"- suggestion_type: 建议类型,\"new_faq\" 或 \"update\"\n"
|
||||
"- title: 问题标题(简洁明了)\n"
|
||||
"- content: 答案内容(分步骤、可操作)\n"
|
||||
"- category: 分类,只能是 硬件/软件/网络/安全/账号/其他 之一\n"
|
||||
"- tags: 标签列表,如 [\"VPN\", \"连接\"]\n"
|
||||
"- confidence: 你对这个建议的信心度,0.0-1.0之间\n"
|
||||
"- issue: 对应的Neo4j图问题节点名称,如\"VPN问题\"\n"
|
||||
"- action: 对应的Neo4j图动作节点名称,如\"VPN连接修复\"\n"
|
||||
"- relation_type: 图关系类型,\"LEADS_TO\" 或 \"RELATES_TO\"\n"
|
||||
"- parent_issue: 父问题名称(如果没有则为空字符串)\n\n"
|
||||
"输出格式示例:\n"
|
||||
"{\"suggestion_type\": \"new_faq\", \"title\": \"VPN连不上怎么办\", "
|
||||
"\"content\": \"1.检查网络连接 2.重启VPN客户端 3.联系IT支持\", "
|
||||
"\"category\": \"网络\", \"tags\": [\"VPN\", \"连接\"], "
|
||||
"\"confidence\": 0.86, \"issue\": \"VPN问题\", "
|
||||
"\"action\": \"VPN连接修复\", \"relation_type\": \"LEADS_TO\", "
|
||||
"\"parent_issue\": \"网络问题\"}"
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
"""初始化 Wingman 服务。
|
||||
|
||||
@@ -264,6 +292,101 @@ class WingmanService:
|
||||
logger.error(f"Wingman 标签建议失败: {e}")
|
||||
return default_tags
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式)
|
||||
# --------------------------------------------------------------------------
|
||||
async def generate_knowledge_suggestion(
|
||||
self,
|
||||
context_messages: List[Dict[str, Any]],
|
||||
) -> Dict[str, Any]:
|
||||
"""生成知识库优化建议(用于知识库自动迭代)。
|
||||
|
||||
传入对话上下文,让 Wingman Agent 分析并生成结构化的知识库优化建议。
|
||||
复用 _build_context_messages + _call_wingman_api + _parse_json_response 范式。
|
||||
|
||||
Args:
|
||||
context_messages: 对话消息历史列表
|
||||
|
||||
Returns:
|
||||
Dict: {
|
||||
"suggestion_type": str, # "new_faq" / "update"
|
||||
"title": str, # 问题标题
|
||||
"content": str, # 答案内容
|
||||
"category": str, # 分类
|
||||
"tags": list[str], # 标签列表
|
||||
"confidence": float, # 置信度(0.0-1.0)
|
||||
"issue": str, # 图节点:问题名称
|
||||
"action": str, # 图节点:动作名称
|
||||
"relation_type": str, # 图关系类型
|
||||
"parent_issue": str, # 父 Issue 名称
|
||||
}
|
||||
"""
|
||||
# 构建对话上下文消息列表(使用知识建议专用 Prompt)
|
||||
context = self._build_context_messages(
|
||||
context_messages, self._KNOWLEDGE_SUGGESTION_PROMPT
|
||||
)
|
||||
|
||||
# 默认建议(降级时使用)
|
||||
default_suggestion: Dict[str, Any] = {
|
||||
"suggestion_type": "new_faq",
|
||||
"title": "",
|
||||
"content": "",
|
||||
"category": "其他",
|
||||
"tags": [],
|
||||
"confidence": 0.0,
|
||||
"issue": "",
|
||||
"action": "",
|
||||
"relation_type": "LEADS_TO",
|
||||
"parent_issue": "",
|
||||
}
|
||||
|
||||
try:
|
||||
result = await self._call_wingman_api(context)
|
||||
|
||||
if result is None:
|
||||
logger.warning("Wingman 知识建议生成失败(API 返回 None)")
|
||||
return default_suggestion
|
||||
|
||||
# 尝试解析 JSON 格式的建议
|
||||
parsed = self._parse_json_response(result, default_suggestion)
|
||||
|
||||
# 规范化字段
|
||||
suggestion: Dict[str, Any] = {
|
||||
"suggestion_type": parsed.get(
|
||||
"suggestion_type", default_suggestion["suggestion_type"]
|
||||
),
|
||||
"title": parsed.get("title", default_suggestion["title"]),
|
||||
"content": parsed.get("content", default_suggestion["content"]),
|
||||
"category": parsed.get("category", default_suggestion["category"]),
|
||||
"tags": parsed.get("tags", default_suggestion["tags"]),
|
||||
"confidence": float(parsed.get("confidence", 0.0)),
|
||||
"issue": parsed.get("issue", default_suggestion["issue"]),
|
||||
"action": parsed.get("action", default_suggestion["action"]),
|
||||
"relation_type": parsed.get(
|
||||
"relation_type", default_suggestion["relation_type"]
|
||||
),
|
||||
"parent_issue": parsed.get(
|
||||
"parent_issue", default_suggestion["parent_issue"]
|
||||
),
|
||||
}
|
||||
|
||||
# 如果 Dify 未返回 confidence,使用启发式估算
|
||||
if suggestion["confidence"] == 0.0:
|
||||
suggestion["confidence"] = self._estimate_confidence(
|
||||
suggestion["content"]
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"知识建议生成完成: type={suggestion['suggestion_type']}, "
|
||||
f"title={suggestion['title'][:50]}, "
|
||||
f"confidence={suggestion['confidence']}"
|
||||
)
|
||||
return suggestion
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Wingman 知识建议生成异常: {e}")
|
||||
return default_suggestion
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 内部方法
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — H5 员工端 AI 回复后台任务
|
||||
# =============================================================================
|
||||
# 背景:原 h5_send_message 在同步 HTTP 请求内 await AI 推理(Dify 3~15s),
|
||||
# 整条请求被阻塞,前端表现为"发送中"长时间卡顿。
|
||||
# 本模块将 AI 推理移出请求,改为 asyncio 后台任务,结果经 WebSocket
|
||||
# 流式推回(ai_reply_chunk / ai_reply),发送瞬时完成。
|
||||
#
|
||||
# 关键约束(详见 docs/02-需求分析/技术架构演进/员工端消息发送延时改造方案.md):
|
||||
# 1. 必须单 worker 运行(docker-compose --workers 1):
|
||||
# ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在
|
||||
# 同进程,broadcast 会静默丢失(约 50%)。
|
||||
# 2. 使用独立 DB session(_get_session_factory),不可复用请求的 db
|
||||
# (请求返回后该 session 会被关闭)。
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.database import _get_session_factory
|
||||
from app.dependencies import get_shared_ai_handler
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _persist_and_push(
|
||||
db,
|
||||
conversation: Conversation,
|
||||
employee_id: str,
|
||||
content: str,
|
||||
is_guidance: bool,
|
||||
should_count: bool,
|
||||
should_transfer: bool,
|
||||
dify_conversation_id,
|
||||
):
|
||||
"""持久化 AI 回复并推送给员工端 + 广播坐席端。
|
||||
|
||||
做什么:
|
||||
1. 存 AI 消息到 DB
|
||||
2. 更新会话状态(dify 上下文 / 计数 / 转人工)
|
||||
3. 经 WS 向员工推 ai_reply 终态(前端据此替换打字机气泡)
|
||||
4. 经 WS 向坐席端广播 new_message + conversation_updated
|
||||
为什么:把"落库 + 推送"封装为单点,供同步路径与流式路径复用。
|
||||
"""
|
||||
# 1. 存 AI 消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="Duckula(达寇拉)",
|
||||
content=content,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 2. 更新会话状态
|
||||
if dify_conversation_id:
|
||||
conversation.dify_conversation_id = dify_conversation_id
|
||||
if should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
if should_transfer:
|
||||
conversation.status = "queued"
|
||||
conversation.updated_at = datetime.now()
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
await db.commit()
|
||||
|
||||
# 3. 推 ai_reply 终态给员工(前端替换打字机气泡)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply",
|
||||
"data": {
|
||||
"message_id": str(ai_message.id),
|
||||
"conversation_id": str(conversation.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": content,
|
||||
"msg_type": "text",
|
||||
"is_guidance": is_guidance,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
},
|
||||
})
|
||||
|
||||
# 4. 广播坐席端(new_message + conversation_updated)
|
||||
try:
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(ai_message.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "Duckula(达寇拉)",
|
||||
"content": content,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_updated",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"status": conversation.status,
|
||||
"assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
# WS 广播失败不阻塞消息存储,只记录 warning
|
||||
logger.warning(f"WS 广播 AI 回复给坐席失败(消息已存储): {ws_err}")
|
||||
|
||||
|
||||
async def process_h5_ai_reply(
|
||||
conversation_id: str,
|
||||
employee_id: str,
|
||||
content: str,
|
||||
dify_conversation_id=None,
|
||||
):
|
||||
"""H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。
|
||||
|
||||
流程:
|
||||
- 本地快判断(打招呼 / 呼叫人工)→ 同步结果,整段推送(不调 Dify)
|
||||
- 否则流式调 Dify,逐 chunk 推 ai_reply_chunk,流结束推 ai_reply 终态
|
||||
- 任意异常 → 推 ai_reply_failed,不阻塞用户
|
||||
"""
|
||||
ai_handler = get_shared_ai_handler()
|
||||
factory = _get_session_factory()
|
||||
async with factory() as db:
|
||||
try:
|
||||
conversation = await db.get(Conversation, conversation_id)
|
||||
if not conversation:
|
||||
logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}")
|
||||
return
|
||||
|
||||
is_guidance = False
|
||||
should_count = False
|
||||
should_transfer = False
|
||||
new_dify_conv_id = dify_conversation_id
|
||||
full_parts: list = []
|
||||
|
||||
# 本地快判断(不打 Dify):打招呼 / 呼叫人工 → 同步路径
|
||||
if ai_handler.is_greeting(content) or ai_handler.is_call_human(content):
|
||||
result = await ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
)
|
||||
await _persist_and_push(
|
||||
db, conversation, employee_id, result.content,
|
||||
result.is_guidance, result.should_count,
|
||||
result.should_transfer, result.dify_conversation_id,
|
||||
)
|
||||
return
|
||||
|
||||
# 流式调 Dify(get_reply_stream 内部已处理真 SSE / 非流式 fallback)
|
||||
# 注意:首参是 message(用户文本),不是 content
|
||||
async for chunk in ai_handler.ai_service.get_reply_stream(
|
||||
message=content,
|
||||
conversation_id=dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
):
|
||||
delta = chunk.get("delta", "")
|
||||
if delta:
|
||||
full_parts.append(delta)
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_chunk",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"chunk": delta,
|
||||
},
|
||||
})
|
||||
if chunk.get("finished"):
|
||||
new_dify_conv_id = chunk.get("conversation_id") or dify_conversation_id
|
||||
hit = chunk.get("hit")
|
||||
# 命中 → 计数;未命中 → 转人工
|
||||
should_count = bool(hit)
|
||||
should_transfer = not bool(hit)
|
||||
|
||||
content_ai = "".join(full_parts)
|
||||
if not content_ai:
|
||||
# 流式无内容(极端情况),给降级提示,不转人工
|
||||
content_ai = "⚠️ AI 暂时没有返回内容,请输入「IT」转人工。"
|
||||
should_count = False
|
||||
should_transfer = False
|
||||
await _persist_and_push(
|
||||
db, conversation, employee_id, content_ai,
|
||||
is_guidance, should_count, should_transfer, new_dify_conv_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"后台 AI 任务异常: {e}", exc_info=True)
|
||||
try:
|
||||
await ws_manager.broadcast_to_employees([employee_id], {
|
||||
"type": "ai_reply_failed",
|
||||
"data": {
|
||||
"conversation_id": conversation_id,
|
||||
"message": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。",
|
||||
},
|
||||
})
|
||||
except Exception:
|
||||
# 推送失败也无所谓,员工端 3 秒轮询兜底
|
||||
pass
|
||||
@@ -47,6 +47,8 @@ pydantic-settings==2.3.4
|
||||
# --------------------------------------------------------------------------
|
||||
# httpx: 异步 HTTP 客户端,用于调用企微 API(替代同步的 requests)
|
||||
httpx==0.27.0
|
||||
# tenacity: 外部调用指数退避重试(自动化引擎 BaseClient 使用,可选降级)
|
||||
tenacity>=8.2.0
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 加密
|
||||
@@ -89,3 +91,17 @@ pillow==10.4.0
|
||||
# --------------------------------------------------------------------------
|
||||
# psutil: 系统监控(用于 /metrics 端点)
|
||||
psutil==5.9.8
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 图数据库(知识图谱)
|
||||
# --------------------------------------------------------------------------
|
||||
# neo4j: Neo4j 官方异步驱动(bolt 协议,支持 async/await,与 FastAPI 生态匹配)
|
||||
neo4j>=5.26.0
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 测试(仅测试环境)
|
||||
# --------------------------------------------------------------------------
|
||||
# testcontainers: Neo4j 测试容器(CI 本地模拟 Neo4j 图数据库)
|
||||
testcontainers[neo4j]>=4.0.0
|
||||
# pytest-asyncio: 异步测试 fixture(已有,显式声明最低版本)
|
||||
pytest-asyncio>=0.23.0
|
||||
|
||||
Reference in New Issue
Block a user