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:
Simon
2026-07-09 11:47:16 +08:00
parent f5374fce9b
commit ead5f83bee
39 changed files with 5276 additions and 545 deletions
+12 -6
View File
@@ -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 NULLnulls_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)
+12 -6
View File
@@ -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),
):
"""重置管理员密码。
+7 -1
View File
@@ -291,14 +291,20 @@ async def agent_login(
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
# 这些端点需要 Bearer tokenget_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",
)
+207
View File
@@ -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),
}
+1 -1
View File
@@ -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,
+91 -1
View File
@@ -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
View File
@@ -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轮后才能呼叫人工坐席哦~"
)
# 更新员工姓名
+332 -31
View File
@@ -1,13 +1,16 @@
# =============================================================================
# 企微IT智能服务台 — 知识库自动迭代 API
# 企微IT智能服务台 — 知识库自动迭代 APITier1 扩展)
# =============================================================================
# 说明:知识库自动迭代相关接口
# 说明:知识库自动迭代相关接口(扩展版)。
# 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),
}
# -----------------------------------------------------------------------------
# 知识图谱可视化(任务2P2
# -----------------------------------------------------------------------------
# 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),
}
+216
View File
@@ -0,0 +1,216 @@
# =============================================================================
# 企微IT智能服务台 — RAGFlow 文档摄入 APITier1 新增 / 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,
}
+163
View File
@@ -0,0 +1,163 @@
# =============================================================================
# 企微IT智能服务台 — 视觉理解 APITier1 新增 / 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-VLQwen3-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,
},
}
+65
View File
@@ -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:
"""解析自动化阈值配置,返回带默认值的字典。
+39
View File
@@ -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",
]
+184
View File
@@ -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_* 驱动)。
未配置 → 返回 NoneIntentRouter 自动走关键词兜底)。
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
+117
View File
@@ -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_* 驱动)。
未配置 → 返回 Nonemapping_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
+250
View File
@@ -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
+139
View File
@@ -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 范围)
# =============================================================================
+19 -179
View File
@@ -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",
]
+10
View File
@@ -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"]
+42 -77
View File
@@ -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",
]
+20 -1
View File
@@ -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),
+88 -2
View File
@@ -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.0D3 门控阈值 0.7
confidence: Mapped[Optional[float]] = mapped_column(
Float,
nullable=True,
comment="AI 生成置信度(0.0-1.0",
)
# 受众类型(D8employee_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:
+114
View File
@@ -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)
+158
View File
@@ -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
+102 -3
View File
@@ -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="过时标记数")
+16
View File
@@ -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",
]
+85 -25
View File
@@ -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
+2 -2
View File
@@ -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,
+758
View File
@@ -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. 图节点 CRUDcreate/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 []
+292
View File
@@ -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
+123
View File
@@ -54,6 +54,34 @@ class WingmanService:
"输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}"
)
# --------------------------------------------------------------------------
# 知识建议生成专用 PromptTier0 / 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
# --------------------------------------------------------------------------
# 内部方法
# --------------------------------------------------------------------------
+206
View File
@@ -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
# 流式调 Difyget_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