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:
@@ -144,26 +144,32 @@ async def list_user_role_assignments(
|
||||
Returns:
|
||||
List of user role assignments with employee_id, role info, source, etc.
|
||||
"""
|
||||
# 使用 LEFT OUTER JOIN:即使 user_roles.role_id 在 roles 表中已不存在
|
||||
# (例如角色被重建导致 UUID 变化),也保留该条分配记录,避免已分配用户被静默隐藏。
|
||||
# assigned_at 定义为 NOT NULL,nulls_last() 无意义,直接降序即可(SQLite/PG 通用)。
|
||||
stmt = (
|
||||
select(UserRole, Role)
|
||||
.join(Role, UserRole.role_id == Role.id)
|
||||
.order_by(UserRole.assigned_at.desc().nulls_last())
|
||||
.outerjoin(Role, UserRole.role_id == Role.id)
|
||||
.order_by(UserRole.assigned_at.desc())
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
rows = result.all()
|
||||
|
||||
|
||||
assignments = []
|
||||
for user_role, role in rows:
|
||||
# role 可能为 None(孤儿记录),做兜底展示,而不是丢弃该用户
|
||||
role_name = role.name if role else "unknown"
|
||||
role_display = (role.display_name or role.name) if role else "未知角色"
|
||||
assignments.append({
|
||||
"employee_id": user_role.employee_id,
|
||||
"role_name": role.name,
|
||||
"role_display_name": role.display_name or role.name,
|
||||
"role_name": role_name,
|
||||
"role_display_name": role_display,
|
||||
"source": user_role.source or "manual",
|
||||
"assigned_by": user_role.assigned_by or "",
|
||||
"assigned_at": user_role.assigned_at.isoformat() if user_role.assigned_at else None,
|
||||
"expires_at": user_role.expires_at.isoformat() if user_role.expires_at else None,
|
||||
})
|
||||
|
||||
|
||||
return success_response(data=assignments)
|
||||
|
||||
|
||||
|
||||
@@ -78,11 +78,12 @@ async def get_current_admin_user(
|
||||
# 1. GET /api/admin/users — 获取管理员列表
|
||||
# =============================================================================
|
||||
@router.get("", response_model=None)
|
||||
@require_role("admin")
|
||||
async def list_admin_users(
|
||||
page: int = Query(1, ge=1, description="页码"),
|
||||
page_size: int = Query(20, ge=1, le=100, description="每页数量"),
|
||||
is_active: Optional[bool] = Query(None, description="是否激活(true=在线,false=离线)"),
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户列表。
|
||||
@@ -131,9 +132,10 @@ async def list_admin_users(
|
||||
# 2. POST /api/admin/users — 创建管理员
|
||||
# =============================================================================
|
||||
@router.post("", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def create_admin_user(
|
||||
body: AdminUserCreateRequest,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""创建管理员用户。
|
||||
@@ -184,9 +186,10 @@ async def create_admin_user(
|
||||
# 3. GET /api/admin/users/{id} — 获取管理员详情
|
||||
# =============================================================================
|
||||
@router.get("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def get_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取管理员用户详情。
|
||||
@@ -224,10 +227,11 @@ async def get_admin_user(
|
||||
# 4. PUT /api/admin/users/{id} — 更新管理员
|
||||
# =============================================================================
|
||||
@router.put("/{id}", response_model=None)
|
||||
@require_role("admin")
|
||||
async def update_admin_user(
|
||||
id: str,
|
||||
body: AdminUserUpdateRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""更新管理员用户。
|
||||
@@ -287,9 +291,10 @@ async def update_admin_user(
|
||||
# 5. DELETE /api/admin/users/{id} — 删除管理员
|
||||
# =============================================================================
|
||||
@router.delete("/{id}", response_model=None)
|
||||
@require_role("super_admin")
|
||||
async def delete_admin_user(
|
||||
id: str,
|
||||
current_user: UserInfo = Depends(require_role("super_admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除管理员用户。
|
||||
@@ -325,10 +330,11 @@ async def delete_admin_user(
|
||||
# 6. POST /api/admin/users/{id}/reset-password — 重置密码
|
||||
# =============================================================================
|
||||
@router.post("/{id}/reset-password", response_model=None)
|
||||
@require_role("admin")
|
||||
async def reset_password(
|
||||
id: str,
|
||||
body: AdminUserResetPasswordRequest,
|
||||
current_user: UserInfo = Depends(require_role("admin")),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""重置管理员密码。
|
||||
|
||||
@@ -291,14 +291,20 @@ async def agent_login(
|
||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
||||
from app.services.token_service import TokenService
|
||||
from app.services.role_mapping_service import RoleMappingService
|
||||
from app.dependencies import get_redis
|
||||
|
||||
redis_client = await get_redis()
|
||||
token_service = TokenService(redis_client)
|
||||
# BUGFIX: 从 UserRole 表查询真实角色,而非硬编码 ["agent"]
|
||||
role_service = RoleMappingService(db)
|
||||
roles = await role_service.get_user_roles(agent.user_id)
|
||||
if not roles:
|
||||
roles = ["agent"] # 无角色时默认 fallback
|
||||
bind_token = await token_service.create_token(
|
||||
employee_id=agent.user_id,
|
||||
name=agent.name,
|
||||
roles=["agent"],
|
||||
roles=roles,
|
||||
avatar=avatar,
|
||||
login_source="agent_pending_otp",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 独立审批队列 API(Tier1 新增)
|
||||
# =============================================================================
|
||||
# 说明:独立审批队列接口,管理超出会话上下文的待审批提案。
|
||||
# 1. GET /queued — 获取队列中的提案列表
|
||||
# 2. GET /queued/stats — 获取队列统计
|
||||
# 3. POST /queued/{id}/dequeue-approve — 队列中审批通过提案
|
||||
#
|
||||
# D7 硬约束:
|
||||
# - 提案默认 status=pending,不自动 applied
|
||||
# - 未处理的进入独立队列(queued)
|
||||
# - 72 小时超时 → expired
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import KnowledgeSuggestionResponse
|
||||
from app.schemas.enums import SuggestionStatusEnum
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取独立队列列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued
|
||||
@router.get("/queued")
|
||||
@require_admin
|
||||
async def list_queued_suggestions(
|
||||
status: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选状态:pending/queued(不传则返回 pending+queued)",
|
||||
),
|
||||
audience: Optional[str] = Query(
|
||||
default=None,
|
||||
description="筛选受众:employee_quick_reply/engineer_workguide",
|
||||
),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取独立队列中的提案列表。
|
||||
|
||||
默认返回 status=pending 和 status=queued 的提案。
|
||||
支持按 audience 筛选和分页。
|
||||
|
||||
- **status**: 筛选状态(pending/queued)
|
||||
- **audience**: 按受众类型筛选
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 构建查询:pending 或 queued 状态的提案
|
||||
target_statuses = [status] if status else [
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
]
|
||||
|
||||
stmt = (
|
||||
select(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
.order_by(
|
||||
# 按入队时间降序(queued 的提案在前),然后按创建时间
|
||||
KnowledgeSuggestion.queued_at.desc().nullslast(),
|
||||
KnowledgeSuggestion.created_at.desc(),
|
||||
)
|
||||
)
|
||||
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
stmt = stmt.offset(offset).limit(page_size)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
suggestions = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = (
|
||||
select(func.count())
|
||||
.select_from(KnowledgeSuggestion)
|
||||
.where(KnowledgeSuggestion.status.in_(target_statuses))
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"total": total,
|
||||
"items": [
|
||||
KnowledgeSuggestionResponse.model_validate(s) for s in suggestions
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取队列统计(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/approval-queue/queued/stats
|
||||
@router.get("/queued/stats")
|
||||
@require_admin
|
||||
async def get_queue_stats(
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取独立审批队列统计信息。
|
||||
|
||||
返回:
|
||||
- queued_total: 队列中提案数
|
||||
- pending_total: 待审核提案数
|
||||
- by_audience: 按受众分组统计
|
||||
- by_source_type: 按来源分组统计
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
stats = await service.get_queue_stats(db)
|
||||
|
||||
# 补充按来源分组统计
|
||||
source_stats_stmt = (
|
||||
select(
|
||||
KnowledgeSuggestion.source_type,
|
||||
func.count(),
|
||||
)
|
||||
.where(
|
||||
KnowledgeSuggestion.status.in_([
|
||||
SuggestionStatusEnum.pending.value,
|
||||
SuggestionStatusEnum.queued.value,
|
||||
])
|
||||
)
|
||||
.group_by(KnowledgeSuggestion.source_type)
|
||||
)
|
||||
source_result = await db.execute(source_stats_stmt)
|
||||
by_source_type = {row[0]: row[1] for row in source_result.fetchall()}
|
||||
|
||||
stats["by_source_type"] = by_source_type
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": stats,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/approval-queue/queued/{id}/dequeue-approve
|
||||
@router.post("/queued/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过提案。
|
||||
|
||||
流程:queued → approved → applied → graph_synced(同 approve_suggestion)。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从独立队列审批通过: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
@@ -43,7 +43,7 @@ from app.api.agents import get_current_agent
|
||||
from app.models.agent import Agent
|
||||
from app.schemas.automation import (
|
||||
CreateSessionRequest,
|
||||
ResolutionFeedbackRequest,
|
||||
ResolveFeedbackRequest,
|
||||
ScenarioConfigResponse,
|
||||
ScenarioConfigUpdate,
|
||||
SessionResponse,
|
||||
|
||||
@@ -115,9 +115,53 @@ async def list_conversations(
|
||||
for emp_id in employee_ids:
|
||||
employee_avatar_map[emp_id] = await session_service._get_employee_avatar(emp_id)
|
||||
|
||||
# ── BUGFIX: 批量回退查询员工信息 ──
|
||||
# 为什么需要:conversations 表中 employee_name/department/position 是冗余字段,
|
||||
# 在会话创建时可能为空(异步创建、企微回调延迟等),导致列表API返回空字符串。
|
||||
# 当这些字段为空时,从 employees 表批量查询并回填,确保坐席端能看到完整用户信息。
|
||||
# 何时触发:仅当 conversations 表中的 employee_name 为空字符串时才会去 employees 表查找。
|
||||
employee_name_map: dict[str, dict] = {}
|
||||
empty_name_conv_ids = [
|
||||
conv.employee_id for conv in conversations
|
||||
if not conv.employee_name and conv.employee_id
|
||||
]
|
||||
if empty_name_conv_ids:
|
||||
try:
|
||||
from app.models.employee import Employee
|
||||
stmt = select(Employee).where(Employee.employee_id.in_(empty_name_conv_ids))
|
||||
result = await db.execute(stmt)
|
||||
for emp in result.scalars().all():
|
||||
employee_name_map[emp.employee_id] = {
|
||||
"name": emp.name or "",
|
||||
"department": emp.department or "",
|
||||
"position": emp.position or "",
|
||||
"level": getattr(emp, "it_level", "") or "",
|
||||
}
|
||||
if employee_name_map:
|
||||
logger.info(
|
||||
f"从employees表批量回退获取员工信息: "
|
||||
f"请求={len(empty_name_conv_ids)}, 命中={len(employee_name_map)}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"从employees表批量回退获取员工信息失败: error={e}")
|
||||
|
||||
# 转换为响应 Schema,附加 is_mine / assigned_agent_name / can_grab / avatar 字段
|
||||
items = []
|
||||
for conv in conversations:
|
||||
# ── BUGFIX: 应用 employees 表回退信息 ──
|
||||
# 如果 conv 的 employee_name 为空,用批量查询结果回填
|
||||
# 这样 ConversationResponse.model_validate 序列化时就能拿到正确的值
|
||||
emp_fallback = employee_name_map.get(conv.employee_id, {})
|
||||
if emp_fallback:
|
||||
if not conv.employee_name and emp_fallback.get("name"):
|
||||
conv.employee_name = emp_fallback["name"]
|
||||
if not conv.department and emp_fallback.get("department"):
|
||||
conv.department = emp_fallback["department"]
|
||||
if not conv.position and emp_fallback.get("position"):
|
||||
conv.position = emp_fallback["position"]
|
||||
if not conv.level and emp_fallback.get("level"):
|
||||
conv.level = emp_fallback["level"]
|
||||
|
||||
conv_data = ConversationResponse.model_validate(conv).model_dump()
|
||||
# 员工头像(从缓存获取)
|
||||
conv_data["avatar"] = employee_avatar_map.get(conv.employee_id, "")
|
||||
@@ -188,7 +232,7 @@ async def get_conversation(
|
||||
conversation.employee_name = employee.name
|
||||
conversation.department = employee.department or ""
|
||||
conversation.position = employee.position or ""
|
||||
conversation.level = employee.level or ""
|
||||
conversation.level = getattr(employee, "it_level", "") or ""
|
||||
logger.info(
|
||||
f"从employees表回退获取会话详情员工信息: employee_id={conversation.employee_id}, "
|
||||
f"name={employee.name}"
|
||||
@@ -275,6 +319,7 @@ async def resolve_conversation(
|
||||
"""结单。
|
||||
|
||||
坐席点击"结单"按钮时调用,将会话状态改为 resolved。
|
||||
结单完成后异步触发知识建议生成(通道 A 全链路闭环)。
|
||||
|
||||
权限控制:只有主责坐席(assigned_agent_id)才能结单。
|
||||
协作坐席和其他坐席不能结单。
|
||||
@@ -303,6 +348,51 @@ async def resolve_conversation(
|
||||
conversation = await session_service.resolve_conversation(conversation_id)
|
||||
|
||||
response_data = ConversationResponse.model_validate(conversation).model_dump()
|
||||
|
||||
# ── 任务1(P0):会话关闭→异步触发知识建议生成 ──
|
||||
# 在结单响应返回后,异步调用 Dify 生成知识迭代建议。
|
||||
# 使用 FastAPI BackgroundTasks 确保不阻塞结单响应。
|
||||
try:
|
||||
from fastapi import BackgroundTasks
|
||||
import asyncio as _asyncio
|
||||
|
||||
async def _trigger_knowledge_suggestion():
|
||||
"""异步生成知识建议的后台任务(独立 db session)。"""
|
||||
from app.database import _get_session_factory
|
||||
from app.services.knowledge_iteration_service import KnowledgeIterationService
|
||||
|
||||
factory = _get_session_factory()
|
||||
async with factory() as bg_db:
|
||||
try:
|
||||
knowledge_service = KnowledgeIterationService()
|
||||
suggestion = await knowledge_service.generate_knowledge_suggestion(
|
||||
db=bg_db,
|
||||
source_type="conversation",
|
||||
source_data=[str(conversation_id)],
|
||||
reason=f"会话'{conversation_id}'已结单,自动生成知识迭代建议",
|
||||
)
|
||||
if suggestion:
|
||||
bg_db.add(suggestion)
|
||||
await bg_db.commit()
|
||||
logger.info(
|
||||
f"会话关闭→知识建议已生成: conv_id={conversation_id}, "
|
||||
f"suggestion_id={suggestion.id}, type={suggestion.suggestion_type}"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"会话关闭→无知识建议生成(Dify不可用或无需建议): "
|
||||
f"conv_id={conversation_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"会话关闭→知识建议生成失败: conv_id={conversation_id}, error={e}")
|
||||
|
||||
# 创建后台任务(不阻塞结单响应)
|
||||
_asyncio.ensure_future(_trigger_knowledge_suggestion())
|
||||
logger.info(f"会话结单完成,已触发异步知识建议生成: conv_id={conversation_id}")
|
||||
except Exception as e:
|
||||
# 知识建议生成失败不影响结单主流程
|
||||
logger.warning(f"触发异步知识建议生成失败(不影响结单): {e}")
|
||||
|
||||
return success_response(data=response_data)
|
||||
|
||||
|
||||
|
||||
+27
-75
@@ -45,7 +45,7 @@ limiter = Limiter(key_func=get_remote_address)
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.utils.env_gating import is_production
|
||||
from app.dependencies import dep_redis, dep_wecom_service, dep_ai_handler
|
||||
from app.dependencies import dep_redis, dep_wecom_service
|
||||
from app.models.approval_link import ApprovalLink
|
||||
from app.models.conversation import Conversation
|
||||
from app.models.message import Message
|
||||
@@ -58,7 +58,9 @@ from app.schemas.h5 import (
|
||||
)
|
||||
from app.schemas.conversation import ConversationResponse, JoinConversationRequest
|
||||
from app.schemas.message import MessageResponse
|
||||
from app.services.ai_handler import AIHandler
|
||||
import asyncio
|
||||
|
||||
from app.tasks.h5_ai_task import process_h5_ai_reply
|
||||
from app.services.funny_phrase_service import FunnyPhraseService
|
||||
from app.services.ws_manager import manager as ws_manager
|
||||
from app.services.wecom_service import WecomService
|
||||
@@ -822,7 +824,6 @@ async def h5_send_message(
|
||||
body: dict,
|
||||
employee_id: str = Depends(_get_current_employee),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
ai_handler: AIHandler = Depends(dep_ai_handler),
|
||||
):
|
||||
"""H5 用户发送消息(含 AI 回复与计数)。
|
||||
|
||||
@@ -893,47 +894,9 @@ async def h5_send_message(
|
||||
db.add(conversation)
|
||||
await db.flush()
|
||||
|
||||
# 3. 调用 AIHandler 统一处理(打招呼检测 → 呼叫人工拦截 → AI 调用)
|
||||
ai_result = await ai_handler.handle_message(
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
user_id=employee_id,
|
||||
)
|
||||
|
||||
# 4. 根据 AIHandler 返回结果更新会话状态
|
||||
# 更新 Dify 会话ID(多轮对话上下文)
|
||||
if ai_result.dify_conversation_id:
|
||||
conversation.dify_conversation_id = ai_result.dify_conversation_id
|
||||
|
||||
# 更新 AI 实质性回复计数(仅 AI 命中时 +1)
|
||||
if ai_result.should_count:
|
||||
conversation.ai_substantive_reply_count += 1
|
||||
|
||||
# 更新会话状态(未命中转人工时改为 queued)
|
||||
if ai_result.should_transfer:
|
||||
conversation.status = "queued"
|
||||
|
||||
db.add(conversation)
|
||||
|
||||
# 5. 创建 AI 回复消息
|
||||
ai_message = Message(
|
||||
conversation_id=conversation.id,
|
||||
sender_type="ai",
|
||||
sender_id="ai_bot",
|
||||
sender_name="AI智能助手",
|
||||
content=ai_result.content,
|
||||
msg_type="text",
|
||||
is_read=True,
|
||||
)
|
||||
db.add(ai_message)
|
||||
await db.flush()
|
||||
|
||||
# 6. WebSocket 广播:通知坐席端有新消息
|
||||
# 做什么:向所有在线坐席广播 new_message 事件,携带用户消息和 AI 回复
|
||||
# 为什么:坐席端需要实时看到员工的新消息和 AI 回复,
|
||||
# 仅靠3秒轮询会有延迟,WS 推送更实时
|
||||
# 3. 广播用户消息给坐席端(员工端靠乐观更新已显示自己消息)
|
||||
# 为什么:坐席端需实时看到员工新消息,仅依赖 3 秒轮询会有延迟
|
||||
try:
|
||||
# 广播用户消息
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
@@ -948,41 +911,30 @@ async def h5_send_message(
|
||||
"tags": conversation.tags,
|
||||
},
|
||||
})
|
||||
# 广播 AI 回复
|
||||
await ws_manager.broadcast({
|
||||
"type": "new_message",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"message_id": str(ai_message.id),
|
||||
"sender_type": "ai",
|
||||
"sender_id": "ai_bot",
|
||||
"sender_name": "AI智能助手",
|
||||
"content": ai_result.content,
|
||||
"msg_type": "text",
|
||||
},
|
||||
})
|
||||
# 如果会话状态变更(如新会话创建或转人工),也广播状态变更
|
||||
await ws_manager.broadcast({
|
||||
"type": "conversation_updated",
|
||||
"data": {
|
||||
"conversation_id": str(conversation.id),
|
||||
"status": conversation.status,
|
||||
"assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None,
|
||||
},
|
||||
})
|
||||
except Exception as ws_err:
|
||||
# WS 广播失败不阻塞消息存储,只记录 warning
|
||||
logger.warning(f"WS 广播新消息失败(消息已存储): {ws_err}")
|
||||
logger.warning(f"WS 广播用户消息失败(消息已存储): {ws_err}")
|
||||
|
||||
# 7. 返回用户消息 + AI 回复
|
||||
# 4. 启动后台 AI 任务(异步,不阻塞 HTTP 返回)
|
||||
# 为什么:AI 推理(Dify)慢(3~15s),放后台经 WS 流式推回,
|
||||
# 发送接口瞬时返回,前端不再卡"发送中"
|
||||
# 约束:后台任务使用独立 DB session,且需单 worker(见 h5_ai_task.py)
|
||||
asyncio.create_task(
|
||||
process_h5_ai_reply(
|
||||
conversation_id=str(conversation.id),
|
||||
employee_id=employee_id,
|
||||
content=content,
|
||||
dify_conversation_id=conversation.dify_conversation_id,
|
||||
)
|
||||
)
|
||||
|
||||
# 5. 立即返回用户消息(AI 回复经 WS 异步推送,不在此同步返回)
|
||||
user_msg_data = MessageResponse.model_validate(message).model_dump()
|
||||
ai_msg_data = MessageResponse.model_validate(ai_message).model_dump()
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"user_message": user_msg_data,
|
||||
"ai_reply": ai_msg_data,
|
||||
"is_guidance": ai_result.is_guidance,
|
||||
"ai_reply": None,
|
||||
"is_guidance": False,
|
||||
"ai_reply_count": conversation.ai_substantive_reply_count,
|
||||
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
|
||||
"conversation_status": conversation.status,
|
||||
@@ -1173,14 +1125,14 @@ async def shake(
|
||||
# 无活跃会话 → 拒绝,必须先与 AI 互动(前端按钮此时不应出现,这是后端兜底)
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 前置校验:必须满足 AI 实质性回复 >= 3 次才能呼叫坐席
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
1003,
|
||||
"请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
"请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
@@ -1327,14 +1279,14 @@ async def call_agent(
|
||||
if not conversation:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 2. 前置校验:必须满足 AI 实质性回复 >= 3 次
|
||||
if conversation.ai_substantive_reply_count < 3:
|
||||
raise AppException(
|
||||
code=1003,
|
||||
message="请先描述您的问题,AI助手需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
message="请先描述您的问题,Duckula(达寇拉)需要先帮您分析。至少互动3轮后才能呼叫人工坐席哦~"
|
||||
)
|
||||
|
||||
# 更新员工姓名
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API
|
||||
# 企微IT智能服务台 — 知识库自动迭代 API(Tier1 扩展)
|
||||
# =============================================================================
|
||||
# 说明:知识库自动迭代相关接口
|
||||
# 说明:知识库自动迭代相关接口(扩展版)。
|
||||
# 1. POST /api/admin/knowledge-iteration/analyze - 触发分析并生成建议
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表
|
||||
# 2. GET /api/admin/knowledge-iteration/suggestions - 获取建议列表(支持 audience/confidence 筛选)
|
||||
# 3. GET /api/admin/knowledge-iteration/suggestions/{id} - 获取建议详情
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过
|
||||
# 4. POST /api/admin/knowledge-iteration/suggestions/{id}/approve - 审核通过(触发Neo4j写图)
|
||||
# 5. POST /api/admin/knowledge-iteration/suggestions/{id}/reject - 审核拒绝
|
||||
# 6. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# 6. POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite - 改写提案(Tier1新增)
|
||||
# 7. POST /api/admin/knowledge-iteration/suggestions/{id}/queue - 放入独立队列(Tier1新增)
|
||||
# 8. POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve - 队列中审批(Tier1新增)
|
||||
# 9. GET /api/admin/knowledge-iteration/stats - 获取统计
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
@@ -17,19 +20,22 @@ from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models.user import User
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.knowledge_suggestion import (
|
||||
KnowledgeSuggestionListResponse,
|
||||
KnowledgeSuggestionResponse,
|
||||
KnowledgeSuggestionStatsResponse,
|
||||
KnowledgeSuggestionApprove,
|
||||
KnowledgeSuggestionReject,
|
||||
KnowledgeSuggestionRewrite,
|
||||
KnowledgeSuggestionMerge,
|
||||
)
|
||||
from app.services.knowledge_iteration_service import (
|
||||
KnowledgeIterationService,
|
||||
dep_knowledge_iteration_service,
|
||||
)
|
||||
from app.services.neo4j_client import get_neo4j_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -41,21 +47,22 @@ router = APIRouter()
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/analyze
|
||||
@router.post("/analyze")
|
||||
@require_admin
|
||||
async def trigger_analysis(
|
||||
days: int = Query(default=7, ge=1, le=90, description="分析过去N天的数据"),
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""触发知识库迭代分析。
|
||||
|
||||
分析过去N天的标注数据和会话数据,自动生成优化建议。
|
||||
分析过去N天的标注数据和会话数据,调用 Dify AI 自动生成优化建议。
|
||||
|
||||
- **days**: 分析过去N天的数据(默认7天,最大90天)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(f"管理员 {current_user.username} 触发了知识库迭代分析, days={days}")
|
||||
logger.info(f"管理员 {current_user.name} 触发了知识库迭代分析, days={days}")
|
||||
|
||||
result = await service.analyze_and_generate_suggestions(db, days=days)
|
||||
|
||||
@@ -67,23 +74,30 @@ async def trigger_analysis(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取建议列表
|
||||
# 获取建议列表(Tier1 扩展:audience/confidence 筛选)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions
|
||||
@router.get("/suggestions")
|
||||
@require_admin
|
||||
async def list_suggestions(
|
||||
status: Optional[str] = Query(default=None, description="筛选状态"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型"),
|
||||
status: Optional[str] = Query(default=None, description="筛选状态:pending/queued/approved/rejected/applied/graph_synced/expired"),
|
||||
suggestion_type: Optional[str] = Query(default=None, description="筛选类型:new_faq/update/outdated"),
|
||||
audience: Optional[str] = Query(default=None, description="筛选受众:employee_quick_reply/engineer_workguide"),
|
||||
confidence_min: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度下限"),
|
||||
confidence_max: Optional[float] = Query(default=None, ge=0.0, le=1.0, description="置信度上限"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议列表。
|
||||
"""获取知识库优化建议列表(Tier1 扩展:支持 audience/confidence 筛选)。
|
||||
|
||||
- **status**: 筛选状态(pending/approved/rejected/applied)
|
||||
- **suggestion_type**: 筛选类型(new_faq/update/outdated)
|
||||
- **status**: 筛选状态
|
||||
- **suggestion_type**: 筛选类型
|
||||
- **audience**: 按受众类型筛选(Tier1 新增)
|
||||
- **confidence_min**: 置信度下限(Tier1 新增)
|
||||
- **confidence_max**: 置信度上限(Tier1 新增)
|
||||
- **page**: 页码
|
||||
- **page_size**: 每页数量
|
||||
|
||||
@@ -100,6 +114,12 @@ async def list_suggestions(
|
||||
stmt = stmt.where(KnowledgeSuggestion.status == status)
|
||||
if suggestion_type:
|
||||
stmt = stmt.where(KnowledgeSuggestion.suggestion_type == suggestion_type)
|
||||
if audience:
|
||||
stmt = stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
stmt = stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
# 分页
|
||||
offset = (page - 1) * page_size
|
||||
@@ -116,6 +136,12 @@ async def list_suggestions(
|
||||
count_stmt = count_stmt.where(
|
||||
KnowledgeSuggestion.suggestion_type == suggestion_type
|
||||
)
|
||||
if audience:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.audience == audience)
|
||||
if confidence_min is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence >= confidence_min)
|
||||
if confidence_max is not None:
|
||||
count_stmt = count_stmt.where(KnowledgeSuggestion.confidence <= confidence_max)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar()
|
||||
@@ -137,9 +163,10 @@ async def list_suggestions(
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}
|
||||
@router.get("/suggestions/{suggestion_id}")
|
||||
@require_admin
|
||||
async def get_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""获取知识库优化建议详情。
|
||||
@@ -167,39 +194,47 @@ async def get_suggestion(
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 审核通过
|
||||
# 审核通过(Tier1 扩展:串联 Neo4j 写图)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/approve
|
||||
@router.post("/suggestions/{suggestion_id}/approve")
|
||||
@require_admin
|
||||
async def approve_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionApprove,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""审核通过知识库优化建议。
|
||||
"""审核通过知识库优化建议(Tier1:串联 Neo4j 写图 + 五态流转)。
|
||||
|
||||
审核通过后,如果是新FAQ或更新建议,将自动添加到知识库。
|
||||
审核通过后:
|
||||
1. 状态 pending/queued → approved → applied → graph_synced
|
||||
2. 自动创建 KnowledgeBase 条目(派生视图)
|
||||
3. 触发 Neo4j 图写入(D1 解读2 合一)
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 审核通过建议: {suggestion_id}"
|
||||
f"管理员 {current_user.name} 审核通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 尝试获取 Neo4j 客户端(可选,不影响审批主流程)
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.approve_suggestion(
|
||||
db, suggestion_id, current_user.id
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "审核通过,建议已应用到知识库",
|
||||
"message": "审核通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
@@ -209,10 +244,11 @@ async def approve_suggestion(
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/reject
|
||||
@router.post("/suggestions/{suggestion_id}/reject")
|
||||
@require_admin
|
||||
async def reject_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionReject,
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
@@ -223,12 +259,12 @@ async def reject_suggestion(
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.username} 拒绝建议: {suggestion_id}, "
|
||||
f"管理员 {current_user.name} 拒绝建议: {suggestion_id}, "
|
||||
f"理由: {body.reject_reason}"
|
||||
)
|
||||
|
||||
suggestion = await service.reject_suggestion(
|
||||
db, suggestion_id, current_user.id, body.reject_reason
|
||||
db, suggestion_id, current_user.employee_id, body.reject_reason
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
@@ -241,19 +277,141 @@ async def reject_suggestion(
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 改写提案(Tier1 新增 — D7 内联审批改写)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/rewrite
|
||||
@router.post("/suggestions/{suggestion_id}/rewrite")
|
||||
@require_admin
|
||||
async def rewrite_suggestion(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionRewrite,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""训练师改写知识库优化建议(Tier1 新增)。
|
||||
|
||||
改写后提案状态重置为 pending,重新走审批流程。
|
||||
可修改字段:title、content、category、tags、confidence、audience、
|
||||
issue、action、relation_type、parent_issue。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 改写建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
# 将非 None 的字段收集为改写数据
|
||||
rewrite_data = body.model_dump(exclude_none=True, exclude_unset=True)
|
||||
|
||||
suggestion = await service.rewrite_suggestion(
|
||||
db, suggestion_id, current_user.employee_id, rewrite_data
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "提案已改写,等待重新审批",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 放入独立队列(Tier1 新增 — D7 独立队列)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/queue
|
||||
@router.post("/suggestions/{suggestion_id}/queue")
|
||||
@require_admin
|
||||
async def queue_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""将建议放入独立审批队列(Tier1 新增)。
|
||||
|
||||
当会话关闭且提案仍处于 pending 时调用,将提案状态改为 queued。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 将建议放入独立队列: {suggestion_id}"
|
||||
)
|
||||
|
||||
suggestion = await service.queue_suggestion(db, suggestion_id)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议已放入独立审批队列",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 队列中审批通过(Tier1 新增 — D7 独立队列审批)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/dequeue-approve
|
||||
@router.post("/suggestions/{suggestion_id}/dequeue-approve")
|
||||
@require_admin
|
||||
async def dequeue_approve_suggestion(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""从独立队列中审批通过建议(Tier1 新增)。
|
||||
|
||||
流程与 approve 一致:状态流转 + KB 落库 + Neo4j 写图。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 从队列中审批通过建议: {suggestion_id}"
|
||||
)
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
suggestion = await service.dequeue_approve(
|
||||
db, suggestion_id, current_user.employee_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在或状态转换无效", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "队列审批通过,建议已应用到知识库并同步至知识图谱",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(suggestion),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 获取统计
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/stats
|
||||
@router.get("/stats")
|
||||
@require_admin
|
||||
async def get_stats(
|
||||
current_user: User = Depends(require_admin),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识库优化建议统计。
|
||||
|
||||
返回各状态的建议数量统计。
|
||||
返回各状态的建议数量统计(含 queued/graph_synced/expired)。
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
@@ -264,3 +422,146 @@ async def get_stats(
|
||||
"message": "success",
|
||||
"data": KnowledgeSuggestionStatsResponse(**stats),
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 知识图谱可视化(任务2:P2)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/graph
|
||||
@router.get("/graph")
|
||||
@require_admin
|
||||
async def get_knowledge_graph(
|
||||
limit: int = Query(default=100, ge=10, le=500, description="节点数量上限"),
|
||||
issue_name: Optional[str] = Query(default=None, description="指定Issue名称查询子图"),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""获取知识图谱数据(Neo4j 节点+关系 JSON 格式)。
|
||||
|
||||
返回 ECharts 力导向图兼容的节点和关系数据。
|
||||
支持全图查询(默认)和指定 Issue 的子图查询。
|
||||
|
||||
- **limit**: 节点数量上限(10-500)
|
||||
- **issue_name**: 指定 Issue 名称时查询子图(用于审批卡片预览)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
if not neo4j_client:
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "Neo4j 不可用,图数据为空",
|
||||
"data": {"nodes": [], "links": []},
|
||||
}
|
||||
|
||||
if issue_name:
|
||||
graph_data = await neo4j_client.query_issue_subgraph(
|
||||
issue_name=issue_name, depth=1
|
||||
)
|
||||
else:
|
||||
graph_data = await neo4j_client.query_full_graph(limit=limit)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": graph_data,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 检查重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/admin/knowledge-iteration/suggestions/{id}/duplicates
|
||||
@router.get("/suggestions/{suggestion_id}/duplicates")
|
||||
@require_admin
|
||||
async def check_duplicates(
|
||||
suggestion_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""检查指定建议是否存在重复(利用 Neo4j 图结构 + SQL 文本相似)。
|
||||
|
||||
在采纳建议前调用,检测是否有同名 Issue 或相似标题的已有条目。
|
||||
返回重复项列表供训练师参考。
|
||||
|
||||
- **suggestion_id**: 建议ID
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
|
||||
stmt = select(KnowledgeSuggestion).where(
|
||||
KnowledgeSuggestion.id == suggestion_id
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
suggestion = result.scalar_one_or_none()
|
||||
|
||||
if not suggestion:
|
||||
return {"code": 404, "message": "建议不存在", "data": None}
|
||||
|
||||
neo4j_client = await get_neo4j_client()
|
||||
|
||||
duplicates = await service.find_duplicates(
|
||||
db=db,
|
||||
issue_name=suggestion.issue,
|
||||
title=suggestion.title,
|
||||
suggestion_id=suggestion_id,
|
||||
neo4j_client=neo4j_client,
|
||||
)
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"suggestion_id": suggestion_id,
|
||||
"has_duplicates": len(duplicates) > 0,
|
||||
"duplicates": duplicates,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 合并重复建议(任务3:P2 知识去重)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/admin/knowledge-iteration/suggestions/{id}/merge
|
||||
@router.post("/suggestions/{suggestion_id}/merge")
|
||||
@require_admin
|
||||
async def merge_suggestions(
|
||||
suggestion_id: str,
|
||||
body: KnowledgeSuggestionMerge,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
service: KnowledgeIterationService = Depends(dep_knowledge_iteration_service),
|
||||
):
|
||||
"""合并重复建议(去重操作)。
|
||||
|
||||
将 duplicate_id 的建议合并到当前建议(primary),
|
||||
标签和元数据合并,重复建议标记为 rejected(合并归入)。
|
||||
|
||||
- **suggestion_id**: 主建议ID(保留)
|
||||
- **duplicate_id**: 重复建议ID(将被合并)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 合并建议: "
|
||||
f"primary={suggestion_id}, duplicate={body.duplicate_id}"
|
||||
)
|
||||
|
||||
merged = await service.merge_suggestions(
|
||||
db=db,
|
||||
primary_id=suggestion_id,
|
||||
duplicate_id=body.duplicate_id,
|
||||
reviewer_id=current_user.employee_id,
|
||||
)
|
||||
|
||||
if not merged:
|
||||
return {"code": 404, "message": "主建议不存在", "data": None}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "建议合并完成,重复建议已标记为已驳回(合并归入)",
|
||||
"data": KnowledgeSuggestionResponse.model_validate(merged),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — RAGFlow 文档摄入 API(Tier1 新增 / P1-5 / 通道 C)
|
||||
# =============================================================================
|
||||
# 说明:RAGFlow 文档摄入接口,训练师上传非标准格式文档,
|
||||
# 经 RAGFlow ETL 整理/结构化后生成 KnowledgeSuggestion 进审批队列。
|
||||
#
|
||||
# 1. POST /api/ragflow/ingest — 上传文档触发 RAGFlow 处理
|
||||
# 2. GET /api/ragflow/tasks/{task_id} — 查询处理任务状态
|
||||
#
|
||||
# P1-5 硬约束:
|
||||
# - 触发方式:训练师手动上传(非定时扫描)
|
||||
# - 支持格式:.docx/.pdf/.txt/.png/.jpg
|
||||
# - source_type=document_ragflow, audience=engineer_workguide
|
||||
# - 产出走 D7 审批流
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_admin, UserInfo
|
||||
from app.models.knowledge_suggestion import KnowledgeSuggestion
|
||||
from app.schemas.enums import (
|
||||
AudienceEnum,
|
||||
GraphSyncStatusEnum,
|
||||
SourceTypeEnum,
|
||||
SuggestionStatusEnum,
|
||||
)
|
||||
from app.services.ragflow_ingestion_service import RagflowIngestionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# 支持的文件格式
|
||||
ALLOWED_EXTENSIONS = {".docx", ".pdf", ".txt", ".png", ".jpg", ".jpeg"}
|
||||
ALLOWED_MIME_TYPES = {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", # .docx
|
||||
"application/pdf", # .pdf
|
||||
"text/plain", # .txt
|
||||
"image/png", # .png
|
||||
"image/jpeg", # .jpg/.jpeg
|
||||
}
|
||||
|
||||
# 文件大小上限(20MB)
|
||||
MAX_FILE_SIZE = 20 * 1024 * 1024
|
||||
|
||||
# 内存中的任务状态缓存(生产环境应迁移到 Redis)
|
||||
_task_cache: dict = {}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 上传文档触发 RAGFlow 处理(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/ragflow/ingest
|
||||
@router.post("/ingest")
|
||||
@require_admin
|
||||
async def ingest_document(
|
||||
file: UploadFile = File(..., description="文档文件(.docx/.pdf/.txt/.png/.jpg)"),
|
||||
category_hint: str = Form(
|
||||
default="其他",
|
||||
description="分类提示(可选,帮助RAGFlow归类):硬件/软件/网络/安全/账号/其他",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""上传非标准格式文档到 RAGFlow 进行 ETL 处理。
|
||||
|
||||
训练师上传文档后,RAGFlow 自动整理/筛选/结构化内容,
|
||||
生成 KnowledgeSuggestion 提案进入 D7 审批队列。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **file**: 文档文件(必填,支持 .docx/.pdf/.txt/.png/.jpg)
|
||||
- **category_hint**: 分类提示(可选,默认"其他")
|
||||
|
||||
**文件大小限制**: 最大 20MB
|
||||
**处理时间**: 最长等待 5 分钟,超时返回 pending 状态
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
# 校验文件扩展名
|
||||
file_name = file.filename or "unknown"
|
||||
ext = "." + file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的文件格式: {ext},仅支持 {', '.join(ALLOWED_EXTENSIONS)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 校验 MIME 类型(如可获取)
|
||||
if file.content_type and file.content_type not in ALLOWED_MIME_TYPES:
|
||||
logger.warning(
|
||||
f"文件 MIME 类型不在白名单中: {file.content_type},仍允许上传"
|
||||
)
|
||||
|
||||
# 读取文件内容
|
||||
file_data = await file.read()
|
||||
|
||||
# 校验文件大小
|
||||
if len(file_data) > MAX_FILE_SIZE:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"文件过大({len(file_data) / 1024 / 1024:.1f}MB),最大支持 20MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
if len(file_data) == 0:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": "文件内容为空",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用 RAGFlow Ingestion 服务
|
||||
service = RagflowIngestionService()
|
||||
|
||||
logger.info(
|
||||
f"管理员 {current_user.name} 上传文档到 RAGFlow: "
|
||||
f"file_name={file_name}, category_hint={category_hint}, size={len(file_data)}"
|
||||
)
|
||||
|
||||
result = await service.upload_and_process(file_data, file_name, category_hint)
|
||||
|
||||
# 将生成的 suggestions 写入数据库(pending 状态)
|
||||
saved_suggestions = []
|
||||
if result.get("suggestions"):
|
||||
for sug_data in result["suggestions"]:
|
||||
suggestion = KnowledgeSuggestion(
|
||||
suggestion_type=sug_data.get("suggestion_type", "new_faq"),
|
||||
status=SuggestionStatusEnum.pending.value,
|
||||
title=sug_data.get("title", ""),
|
||||
content=sug_data.get("content", ""),
|
||||
category=sug_data.get("category", category_hint),
|
||||
tags=sug_data.get("tags", []),
|
||||
source_type=SourceTypeEnum.document_ragflow.value,
|
||||
source_data=sug_data.get("source_data", []),
|
||||
reason=sug_data.get("reason", ""),
|
||||
confidence=sug_data.get("confidence", 0.85),
|
||||
audience=AudienceEnum.engineer_workguide.value, # 通道 C 默认
|
||||
issue=sug_data.get("issue", ""),
|
||||
action=sug_data.get("action", ""),
|
||||
relation_type=sug_data.get("relation_type", "LEADS_TO"),
|
||||
parent_issue=sug_data.get("parent_issue", ""),
|
||||
graph_meta=sug_data.get("graph_meta", {}),
|
||||
graph_sync_status=GraphSyncStatusEnum.pending.value,
|
||||
source_failed=sug_data.get("source_failed", False),
|
||||
)
|
||||
db.add(suggestion)
|
||||
saved_suggestions.append({
|
||||
"title": suggestion.title,
|
||||
"category": suggestion.category,
|
||||
"confidence": suggestion.confidence,
|
||||
})
|
||||
|
||||
await db.commit()
|
||||
logger.info(f"RAGFlow 生成 {len(saved_suggestions)} 条 KnowledgeSuggestion 待审批")
|
||||
|
||||
# 缓存任务状态
|
||||
task_id = result["task_id"]
|
||||
_task_cache[task_id] = {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"created_at": __import__("datetime").datetime.now().isoformat(),
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "文档已提交 RAGFlow 处理",
|
||||
"data": {
|
||||
"task_id": task_id,
|
||||
"status": result["status"],
|
||||
"file_name": file_name,
|
||||
"suggestions_count": len(saved_suggestions),
|
||||
"suggestions": saved_suggestions,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 查询处理任务状态(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/ragflow/tasks/{task_id}
|
||||
@router.get("/tasks/{task_id}")
|
||||
@require_admin
|
||||
async def get_ingestion_task_status(
|
||||
task_id: str,
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
):
|
||||
"""查询 RAGFlow 文档处理任务状态。
|
||||
|
||||
- **task_id**: 任务ID(来自 ingest 接口返回值)
|
||||
|
||||
**需要管理员权限。**
|
||||
"""
|
||||
task = _task_cache.get(task_id)
|
||||
if not task:
|
||||
return {
|
||||
"code": 404,
|
||||
"message": "任务不存在或已过期",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": task,
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
# =============================================================================
|
||||
# 企微IT智能服务台 — 视觉理解 API(Tier1 新增 / D5 / P1-3)
|
||||
# =============================================================================
|
||||
# 说明:截图视觉理解接口,调用本地 Qwen-VL(经 Dify vision workflow)
|
||||
# 分析员工截图,返回结构化描述文本。
|
||||
#
|
||||
# 1. POST /api/vision/analyze — 分析截图(multipart: image + conversation_id)
|
||||
# 2. GET /api/vision/models — 可用的视觉模型列表
|
||||
#
|
||||
# D5 硬约束:
|
||||
# - 视觉理解经 Dify 后端调用本地 Qwen-VL(Qwen3-VL-8B-Instruct)
|
||||
# - 预留 vision_model 参数以便后续升级
|
||||
# - 截图隐私仅保留接口(D6),不阻断消息
|
||||
# =============================================================================
|
||||
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, UploadFile
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user, require_any_user, UserInfo
|
||||
from app.services.vision_service import VisionService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 分析截图(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# POST /api/vision/analyze
|
||||
@router.post("/analyze")
|
||||
@require_any_user
|
||||
async def analyze_screenshot(
|
||||
image: UploadFile = File(..., description="截图文件(支持 PNG/JPG/GIF)"),
|
||||
conversation_id: str = Form(..., description="会话ID(用于上下文关联)"),
|
||||
vision_model: str = Form(
|
||||
default="",
|
||||
description="视觉模型名称(可选,默认使用配置中的模型)",
|
||||
),
|
||||
current_user: UserInfo = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""分析截图,返回 AI 视觉理解的结构化描述。
|
||||
|
||||
员工发送截图后,前端调用此接口将图片交给 Qwen-VL 视觉模型分析。
|
||||
分析结果将自动注入到对应会话的上下文中,参与后续 AI 推理。
|
||||
|
||||
**请求格式**: multipart/form-data
|
||||
|
||||
**字段说明**:
|
||||
- **image**: 截图文件(必填)
|
||||
- **conversation_id**: 会话ID(必填)
|
||||
- **vision_model**: 视觉模型名称(可选,默认使用 Qwen3-VL-8B-Instruct)
|
||||
|
||||
**支持的文件格式**: PNG、JPG、GIF、WebP
|
||||
**文件大小限制**: 最大 10MB
|
||||
|
||||
**D5 隐私说明**: 截图分析结果仅供 AI 理解上下文使用,
|
||||
隐私检测接口已预留(D6),当前不阻断消息。
|
||||
"""
|
||||
# 校验文件类型
|
||||
allowed_types = {"image/png", "image/jpeg", "image/gif", "image/webp"}
|
||||
if image.content_type and image.content_type not in allowed_types:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"不支持的图片格式: {image.content_type},仅支持 PNG/JPG/GIF/WebP",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 读取图片字节流
|
||||
image_bytes = await image.read()
|
||||
|
||||
# 校验文件大小(最大 10MB)
|
||||
max_size = 10 * 1024 * 1024
|
||||
if len(image_bytes) > max_size:
|
||||
return {
|
||||
"code": 400,
|
||||
"message": f"图片过大({len(image_bytes) / 1024 / 1024:.1f}MB),最大支持 10MB",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
# 调用视觉理解服务
|
||||
service = VisionService(
|
||||
model=vision_model if vision_model else None,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await service.analyze_screenshot(image_bytes, conversation_id)
|
||||
|
||||
# 将视觉描述注入会话上下文
|
||||
if result.get("description"):
|
||||
injected = await service.inject_to_conversation_context(
|
||||
result["description"], conversation_id
|
||||
)
|
||||
if injected:
|
||||
logger.info(
|
||||
f"视觉描述已注入会话 {conversation_id}: "
|
||||
f"confidence={result.get('confidence', 0):.2f}"
|
||||
)
|
||||
|
||||
await service.close()
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "视觉分析完成",
|
||||
"data": {
|
||||
"description": result.get("description", ""),
|
||||
"confidence": result.get("confidence", 0.0),
|
||||
"metadata": result.get("metadata", {}),
|
||||
"injected": result.get("description", "") != "",
|
||||
},
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
await service.close()
|
||||
logger.error(f"视觉分析异常: {e}")
|
||||
return {
|
||||
"code": 500,
|
||||
"message": f"视觉分析失败: {str(e)}",
|
||||
"data": None,
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# 可用的视觉模型列表(Tier1 新增)
|
||||
# -----------------------------------------------------------------------------
|
||||
# GET /api/vision/models
|
||||
@router.get("/models")
|
||||
async def list_vision_models():
|
||||
"""获取当前可用的视觉模型列表。
|
||||
|
||||
返回系统配置的视觉模型信息,包括当前默认模型和可升级选项。
|
||||
|
||||
**无需鉴权(公开查询)。**
|
||||
"""
|
||||
models: List[dict] = [
|
||||
{
|
||||
"id": "Qwen3-VL-8B-Instruct",
|
||||
"name": "Qwen3-VL-8B-Instruct(默认)",
|
||||
"provider": "Qwen",
|
||||
"description": "本地部署的千问视觉模型,8B 参数,适用于一般截图理解",
|
||||
},
|
||||
{
|
||||
"id": "Qwen3-VL-32B-Instruct",
|
||||
"name": "Qwen3-VL-32B-Instruct",
|
||||
"provider": "Qwen",
|
||||
"description": "千问视觉模型 32B 版本,精度更高但需要更多显存(≥48GB)",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"models": models,
|
||||
"default_model": settings.qwen_vl_model,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user