From ead5f83bee5e4916fa07635430127be56be8d5a1 Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 9 Jul 2026 11:47:16 +0800 Subject: [PATCH] feat(backend): knowledge iteration + vision + neo4j + response contract source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。 --- backend/Dockerfile | 8 +- backend/__init__.py | 1 + .../045_add_graph_confidence_audience.py | 140 +++ backend/app/api/admin_roles.py | 18 +- backend/app/api/admin_users.py | 18 +- backend/app/api/agents.py | 8 +- backend/app/api/approval_queue.py | 207 ++++ backend/app/api/automation.py | 2 +- backend/app/api/conversations.py | 92 +- backend/app/api/h5.py | 102 +- backend/app/api/knowledge_iteration.py | 363 ++++++- backend/app/api/ragflow_ingestion.py | 216 ++++ backend/app/api/vision.py | 163 +++ backend/app/config.py | 65 ++ backend/app/core/clients/__init__.py | 39 + backend/app/core/clients/dify.py | 184 ++++ backend/app/core/clients/ehr.py | 117 +++ backend/app/core/clients/lianruan.py | 250 +++++ backend/app/core/clients/ragflow.py | 139 +++ .../__init__.py} | 80 +- backend/app/integrations/base.py | 198 +--- backend/app/integrations/ehr/__init__.py | 10 + backend/app/integrations/factory.py | 119 +-- backend/app/models/knowledge_base.py | 21 +- backend/app/models/knowledge_suggestion.py | 90 +- backend/app/models/neo4j_schema.py | 114 ++ backend/app/schemas/enums.py | 158 +++ backend/app/schemas/knowledge_suggestion.py | 105 +- backend/app/services/__init__.py | 16 + backend/app/services/ai_service.py | 110 +- .../services/content_moderation_service.py | 21 +- .../services/knowledge_iteration_service.py | 980 ++++++++++++++++-- backend/app/services/message_router.py | 4 +- backend/app/services/neo4j_client.py | 758 ++++++++++++++ .../app/services/ragflow_ingestion_service.py | 268 +++++ backend/app/services/vision_service.py | 292 ++++++ backend/app/services/wingman_service.py | 123 +++ backend/app/tasks/h5_ai_task.py | 206 ++++ backend/requirements.txt | 16 + 39 files changed, 5276 insertions(+), 545 deletions(-) create mode 100644 backend/__init__.py create mode 100644 backend/alembic/versions/045_add_graph_confidence_audience.py create mode 100644 backend/app/api/approval_queue.py create mode 100644 backend/app/api/ragflow_ingestion.py create mode 100644 backend/app/api/vision.py create mode 100644 backend/app/core/clients/__init__.py create mode 100644 backend/app/core/clients/dify.py create mode 100644 backend/app/core/clients/ehr.py create mode 100644 backend/app/core/clients/lianruan.py create mode 100644 backend/app/core/clients/ragflow.py rename backend/app/{dependencies.py => dependencies/__init__.py} (89%) create mode 100644 backend/app/integrations/ehr/__init__.py create mode 100644 backend/app/models/neo4j_schema.py create mode 100644 backend/app/schemas/enums.py create mode 100644 backend/app/services/neo4j_client.py create mode 100644 backend/app/services/ragflow_ingestion_service.py create mode 100644 backend/app/services/vision_service.py create mode 100644 backend/app/tasks/h5_ai_task.py diff --git a/backend/Dockerfile b/backend/Dockerfile index 580f1d3..95a1e38 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -19,13 +19,13 @@ RUN apt-get update && \ rm -rf /var/lib/apt/lists/* # 复制依赖声明文件并安装(利用 Docker 层缓存,依赖不变则不重新安装) -# 使用清华大学 PyPI 镜像源,解决公司内网下载 PyPI 官方源超时问题 +# 使用阿里云 PyPI 镜像(比清华镜像更快) COPY requirements.txt . RUN pip install --no-cache-dir \ - --timeout 120 \ + --timeout 180 \ --retries 5 \ - -i https://pypi.tuna.tsinghua.edu.cn/simple/ \ - --trusted-host pypi.tuna.tsinghua.edu.cn \ + -i https://mirrors.aliyun.com/pypi/simple/ \ + --trusted-host mirrors.aliyun.com \ -r requirements.txt # -------------------------------------------------------------------------- diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..7f83169 --- /dev/null +++ b/backend/__init__.py @@ -0,0 +1 @@ +# Backend package diff --git a/backend/alembic/versions/045_add_graph_confidence_audience.py b/backend/alembic/versions/045_add_graph_confidence_audience.py new file mode 100644 index 0000000..96319f7 --- /dev/null +++ b/backend/alembic/versions/045_add_graph_confidence_audience.py @@ -0,0 +1,140 @@ +"""add graph/confidence/audience fields to knowledge_suggestions and knowledge_base + +扩充 KnowledgeSuggestion 12 个字段(confidence/audience/issue/action/relation_type/ +parent_issue/graph_meta/graph_sync_status/source_failed/queued_at/applied_at)和 +KnowledgeBase 2 个字段(graph_sync_status/graph_node_uuid)。 + +关联设计文档:增量设计-知识库迭代与痛点缓解 §2.1 + +Revision ID: 045_graph_confidence_audience +Revises: 044_automation +Create Date: 2026-07-11 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '045_graph_confidence_audience' +down_revision: Union[str, None] = '044_automation' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # -------------------------------------------------------------------------- + # 1. knowledge_suggestions 表 — 新增 12 个字段 + # -------------------------------------------------------------------------- + op.add_column( + 'knowledge_suggestions', + sa.Column('confidence', sa.Float(), nullable=True, + comment='AI 生成置信度(0.0-1.0)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('audience', sa.String(30), nullable=True, + comment='受众类型:employee_quick_reply / engineer_workguide'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('issue', sa.String(256), nullable=True, + comment='图节点:问题名称(对应 Neo4j Issue.name)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('action', sa.String(256), nullable=True, + comment='图节点:动作名称(对应 Neo4j Action.name)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('relation_type', sa.String(30), nullable=True, + comment='图关系类型:LEADS_TO / RELATES_TO / CAN_JUMP_TO'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('parent_issue', sa.String(256), nullable=True, + comment='父 Issue 名称(用于图关系构建)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('graph_meta', sa.JSON(), nullable=True, + comment='图结构扩展元数据(JSON)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('graph_sync_status', sa.String(20), nullable=False, + server_default='pending', + comment='图同步状态:pending / synced / failed'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('source_failed', sa.Boolean(), nullable=False, + server_default=sa.text('false'), + comment='AI 生成失败标记(Dify 不可用或置信度不足)'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True, + comment='入队列时间'), + ) + op.add_column( + 'knowledge_suggestions', + sa.Column('applied_at', sa.DateTime(timezone=True), nullable=True, + comment='应用到 KB 的时间'), + ) + + # 新增索引 + op.create_index( + 'idx_suggestion_audience', 'knowledge_suggestions', ['audience'], + ) + op.create_index( + 'idx_suggestion_confidence', 'knowledge_suggestions', ['confidence'], + ) + op.create_index( + 'idx_suggestion_graph_sync', 'knowledge_suggestions', ['graph_sync_status'], + ) + + # -------------------------------------------------------------------------- + # 2. knowledge_base 表 — 新增 2 个字段 + # -------------------------------------------------------------------------- + op.add_column( + 'knowledge_base', + sa.Column('graph_sync_status', sa.String(20), nullable=False, + server_default='pending', + comment='图同步状态:pending / synced / failed'), + ) + op.add_column( + 'knowledge_base', + sa.Column('graph_node_uuid', sa.String(36), nullable=True, + comment='关联 Neo4j Issue 节点的 uuid'), + ) + + +def downgrade() -> None: + # -------------------------------------------------------------------------- + # 回滚 knowledge_suggestions + # -------------------------------------------------------------------------- + op.drop_index('idx_suggestion_graph_sync', table_name='knowledge_suggestions') + op.drop_index('idx_suggestion_confidence', table_name='knowledge_suggestions') + op.drop_index('idx_suggestion_audience', table_name='knowledge_suggestions') + + op.drop_column('knowledge_suggestions', 'applied_at') + op.drop_column('knowledge_suggestions', 'queued_at') + op.drop_column('knowledge_suggestions', 'source_failed') + op.drop_column('knowledge_suggestions', 'graph_sync_status') + op.drop_column('knowledge_suggestions', 'graph_meta') + op.drop_column('knowledge_suggestions', 'parent_issue') + op.drop_column('knowledge_suggestions', 'relation_type') + op.drop_column('knowledge_suggestions', 'action') + op.drop_column('knowledge_suggestions', 'issue') + op.drop_column('knowledge_suggestions', 'audience') + op.drop_column('knowledge_suggestions', 'confidence') + + # -------------------------------------------------------------------------- + # 回滚 knowledge_base + # -------------------------------------------------------------------------- + op.drop_column('knowledge_base', 'graph_node_uuid') + op.drop_column('knowledge_base', 'graph_sync_status') diff --git a/backend/app/api/admin_roles.py b/backend/app/api/admin_roles.py index f3b8975..9a484bf 100644 --- a/backend/app/api/admin_roles.py +++ b/backend/app/api/admin_roles.py @@ -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) diff --git a/backend/app/api/admin_users.py b/backend/app/api/admin_users.py index 571cac4..70e8620 100644 --- a/backend/app/api/admin_users.py +++ b/backend/app/api/admin_users.py @@ -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), ): """重置管理员密码。 diff --git a/backend/app/api/agents.py b/backend/app/api/agents.py index 14d5e10..bc1e52e 100644 --- a/backend/app/api/agents.py +++ b/backend/app/api/agents.py @@ -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", ) diff --git a/backend/app/api/approval_queue.py b/backend/app/api/approval_queue.py new file mode 100644 index 0000000..ce42fc9 --- /dev/null +++ b/backend/app/api/approval_queue.py @@ -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), + } diff --git a/backend/app/api/automation.py b/backend/app/api/automation.py index abb8603..cdb3515 100644 --- a/backend/app/api/automation.py +++ b/backend/app/api/automation.py @@ -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, diff --git a/backend/app/api/conversations.py b/backend/app/api/conversations.py index 89fd496..3f28f85 100644 --- a/backend/app/api/conversations.py +++ b/backend/app/api/conversations.py @@ -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) diff --git a/backend/app/api/h5.py b/backend/app/api/h5.py index 6d0d217..a9b6076 100644 --- a/backend/app/api/h5.py +++ b/backend/app/api/h5.py @@ -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轮后才能呼叫人工坐席哦~" ) # 更新员工姓名 diff --git a/backend/app/api/knowledge_iteration.py b/backend/app/api/knowledge_iteration.py index fe381e4..879390e 100644 --- a/backend/app/api/knowledge_iteration.py +++ b/backend/app/api/knowledge_iteration.py @@ -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), + } diff --git a/backend/app/api/ragflow_ingestion.py b/backend/app/api/ragflow_ingestion.py new file mode 100644 index 0000000..66d01e0 --- /dev/null +++ b/backend/app/api/ragflow_ingestion.py @@ -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, + } diff --git a/backend/app/api/vision.py b/backend/app/api/vision.py new file mode 100644 index 0000000..67b8cb1 --- /dev/null +++ b/backend/app/api/vision.py @@ -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, + }, + } diff --git a/backend/app/config.py b/backend/app/config.py index 542b2fa..469ecf3 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -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: """解析自动化阈值配置,返回带默认值的字典。 diff --git a/backend/app/core/clients/__init__.py b/backend/app/core/clients/__init__.py new file mode 100644 index 0000000..4c22013 --- /dev/null +++ b/backend/app/core/clients/__init__.py @@ -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", +] diff --git a/backend/app/core/clients/dify.py b/backend/app/core/clients/dify.py new file mode 100644 index 0000000..3f99886 --- /dev/null +++ b/backend/app/core/clients/dify.py @@ -0,0 +1,184 @@ +# ============================================================================= +# 企微IT智能服务台 — 阶段5 自动化 Dify 意图识别客户端 +# ============================================================================= +# 说明:自动化引擎的意图识别客户端,调用 Dify(OpenAI 兼容代理 +# http://yw-dify.dc.servyou-it.com/dify2openai/)识别员工诉求命中的场景。 +# +# 生产基址(任务指定):http://yw-dify.dc.servyou-it.com/dify2openai/ +# +# 返回结构(供 IntentRouter 使用): +# {"scenario_key": str|None, "confidence": float, "raw": str, "error": str} +# scenario_key ∈ {password_reset, software_install, virus_dispose, terminal_locate} +# +# 降级:Dify 未配置或调用失败 → _fallback_intent 走关键词兜底(关键词取自 +# app.services.automation.DEFAULT_SCENARIO_CONFIGS),保证无真实环境也能闭环。 +# ============================================================================= + +from __future__ import annotations + +import json +import logging +from typing import Any, Dict, List, Optional + +from app.config import settings +from app.core.clients.base import ( + BaseClient, + BaseClientError, + ClientAPIError, + ClientConfigError, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT = 30.0 +# 意图识别提示词:要求模型仅输出 JSON +_SYSTEM_PROMPT = ( + "你是IT服务台意图分类器。根据用户诉求,判断其属于以下哪个场景之一," + "并仅输出一个 JSON 对象,不要输出任何额外文字:\n" + '{"scenario_key": "password_reset|software_install|virus_dispose|terminal_locate|unknown", ' + '"confidence": 0.0~1.0}\n' + "场景说明:\n" + "- password_reset: 忘记/重置密码、账号密码相关\n" + "- software_install: 安装/下载软件\n" + "- virus_dispose: 病毒、木马、勒索、杀毒\n" + "- terminal_locate: 定位/查找我的电脑或终端\n" + "- unknown: 不属于以上任何一类\n" + "confidence 表示你对该判断的置信度(0~1)。" +) + + +class DifyClient(BaseClient): + """Dify 意图识别客户端(OpenAI 兼容)。""" + + system = "dify" + + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout: float = _DEFAULT_TIMEOUT, + audit: Any = None, + max_retries: int = 2, + ): + if not base_url: + raise ClientConfigError("Dify base_url 未配置") + if not api_key: + raise ClientConfigError("Dify api_key 未配置") + # 统一 base_url 形态(确保末尾含 /v1 由调用方决定,此处仅保证无尾斜杠) + super().__init__(base_url=base_url.rstrip("/"), timeout=timeout, audit=audit, max_retries=max_retries) + self.api_key = api_key + + def _chat_url(self) -> str: + """OpenAI 兼容 chat/completions 端点。""" + base = self.base_url + if base.endswith("/v1"): + return f"{base}/chat/completions" + return f"{base}/v1/chat/completions" + + async def detect_intent( + self, description: str, employee_id: str = "" + ) -> Dict[str, Any]: + """调用 Dify 识别意图。 + + Returns: + Dict: {scenario_key, confidence, raw, error} + """ + prompt = description or "" + body = { + "model": "dify", + "messages": [ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": prompt}, + ], + "temperature": 0, + "response_format": {"type": "json_object"}, + "user": employee_id or "automation", + } + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + try: + data = await self._request( + "POST", "/v1/chat/completions", json_body=body, headers=headers + ) + except BaseClientError as e: + logger.warning(f"Dify 调用失败: {e.message}") + fb = self._fallback_intent(description) + fb["error"] = e.message + return fb + + # 解析 OpenAI 兼容响应 + try: + choices = data.get("choices") or [] + content = choices[0]["message"]["content"] if choices else "" + parsed = json.loads(content) + scenario_key = parsed.get("scenario_key") + confidence = float(parsed.get("confidence", 0.0)) + return { + "scenario_key": scenario_key if scenario_key != "unknown" else None, + "confidence": confidence, + "raw": content, + "error": "", + } + except Exception as e: # noqa: BLE001 + logger.warning(f"Dify 响应解析失败: {e}") + fb = self._fallback_intent(description) + fb["error"] = f"parse_error: {e}" + return fb + + @staticmethod + def _fallback_intent(description: str) -> Dict[str, Any]: + """关键词兜底(无 Dify / 解析失败时)。 + + 关键词取自 DEFAULT_SCENARIO_CONFIGS 的 trigger_conditions.keywords; + 命中即返回该场景,置信度取 0.6(恰好达到阈值,可继续编排)。 + """ + text = (description or "").lower() + scenario_key: Optional[str] = None + try: # 懒加载,避免循环依赖 + from app.services.automation import DEFAULT_SCENARIO_CONFIGS + + for key, cfg in DEFAULT_SCENARIO_CONFIGS.items(): + triggers = cfg.get("trigger_conditions") or {} + keywords = triggers.get("keywords") or [] + if any(kw.lower() in text for kw in keywords): + scenario_key = key + break + except Exception: # noqa: BLE001 + pass + return { + "scenario_key": scenario_key, + "confidence": 0.6 if scenario_key else 0.0, + "raw": "", + "error": "fallback", + } + + async def test_connection(self) -> Dict[str, Any]: + """连接测试(轻量 chat 探测)。""" + try: + result = await self.detect_intent("测试连接") + return {"success": True, "message": "Dify 可用", "scenario_key": result.get("scenario_key")} + except BaseClientError as e: + return {"success": False, "message": e.message} + + +async def get_dify_client(audit: Any = None) -> Optional[DifyClient]: + """构建 Dify 客户端(环境变量 AUTOMATION_DIFY_* 驱动)。 + + 未配置 → 返回 None(IntentRouter 自动走关键词兜底)。 + + Returns: + Optional[DifyClient]: 配置完整时返回,否则 None。 + """ + base_url = getattr(settings, "automation_dify_base_url", "") or "" + api_key = getattr(settings, "automation_dify_api_key", "") or "" + if not (base_url and api_key): + logger.debug("Dify 未配置(AUTOMATION_DIFY_*),返回 None") + return None + try: + return DifyClient(base_url=base_url, api_key=api_key, audit=audit) + except ClientConfigError as e: + logger.warning(f"Dify 客户端构建失败: {e.message}") + return None diff --git a/backend/app/core/clients/ehr.py b/backend/app/core/clients/ehr.py new file mode 100644 index 0000000..ccb5e77 --- /dev/null +++ b/backend/app/core/clients/ehr.py @@ -0,0 +1,117 @@ +# ============================================================================= +# 企微IT智能服务台 — 阶段5 自动化 北森 EHR 静态映射兜底客户端 +# ============================================================================= +# 说明:自动化引擎的终端映射兜底客户端(环境变量 AUTOMATION_EHR_* 驱动)。 +# 当联软(主源)不可用或未解析到终端时,使用北森 EHR 静态映射给出 +# 员工→部门/资产 hint(注意:EHR 通常不提供火绒 client_id,仅作兜底展示)。 +# +# 主用方法: +# - get_terminal_by_employee(employee_id) -> dict | None +# 返回 {employee_id, department, asset_no, hint} 或 None(无记录)。 +# ============================================================================= + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from app.config import settings +from app.core.clients.base import ( + BaseClient, + BaseClientError, + ClientAPIError, + ClientAuthError, + ClientConfigError, +) + +logger = logging.getLogger(__name__) + +_DEFAULT_TIMEOUT = 15.0 + + +class BeisenEHRClient(BaseClient): + """北森 EHR 静态映射兜底客户端(Bearer/API Key 认证)。""" + + system = "ehr" + + def __init__( + self, + *, + base_url: str, + api_key: str, + timeout: float = _DEFAULT_TIMEOUT, + audit: Any = None, + max_retries: int = 1, + ): + if not base_url: + raise ClientConfigError("EHR base_url 未配置") + if not api_key: + raise ClientConfigError("EHR api_key 未配置") + super().__init__(base_url=base_url.rstrip("/"), timeout=timeout, audit=audit, max_retries=max_retries) + self.api_key = api_key + + async def get_terminal_by_employee(self, employee_id: str) -> Optional[Dict[str, Any]]: + """按员工账号查询静态映射(兜底)。 + + Args: + employee_id: 员工企微 UserID + + Returns: + Optional[Dict]: {employee_id, department, asset_no, hint} 或 None。 + 默认实现为占位:未对接真实北森接口,返回基于本地静态表的 hint。 + 结构上可被单元测试 mock(注入真实 client 即可)。 + """ + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + # 真实环境:GET /api/v1/employee/{employee_id}/asset + # 本地无真实环境,使用占位静态映射(可在 settings/配置中扩展)。 + try: + data = await self._request( + "GET", + f"/api/v1/employee/{employee_id}/asset", + headers=headers, + ) + except BaseClientError as e: + logger.warning(f"EHR 查询失败 employee={employee_id}: {e.message}") + return None + + emp = (data.get("data", {}) or {}).get("employee", {}) or {} + if not emp: + return None + return { + "employee_id": employee_id, + "department": emp.get("department", ""), + "asset_no": emp.get("asset_no", ""), + "hint": emp.get("asset_hint", "该员工暂无终端映射,建议转人工处理"), + } + + async def test_connection(self) -> Dict[str, Any]: + """连接测试(查询根路径)。""" + headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} + try: + await self._request("GET", "/api/v1/ping", headers=headers) + return {"success": True, "message": "EHR 连接成功"} + except BaseClientError as e: + return {"success": False, "message": e.message} + + +async def get_ehr_client(audit: Any = None) -> Optional[BeisenEHRClient]: + """构建 EHR 客户端(环境变量 AUTOMATION_EHR_* 驱动)。 + + 未配置 → 返回 None(mapping_resolver 跳过兜底)。 + + Returns: + Optional[BeisenEHRClient]: 配置完整时返回,否则 None。 + """ + base_url = getattr(settings, "automation_ehr_base_url", "") or "" + api_key = getattr(settings, "automation_ehr_api_key", "") or "" + if not (base_url and api_key): + logger.debug("EHR 未配置(AUTOMATION_EHR_*),返回 None") + return None + try: + return BeisenEHRClient(base_url=base_url, api_key=api_key, audit=audit) + except ClientConfigError as e: + logger.warning(f"EHR 客户端构建失败: {e.message}") + return None diff --git a/backend/app/core/clients/lianruan.py b/backend/app/core/clients/lianruan.py new file mode 100644 index 0000000..1836ae2 --- /dev/null +++ b/backend/app/core/clients/lianruan.py @@ -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 diff --git a/backend/app/core/clients/ragflow.py b/backend/app/core/clients/ragflow.py new file mode 100644 index 0000000..ce0db68 --- /dev/null +++ b/backend/app/core/clients/ragflow.py @@ -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 diff --git a/backend/app/dependencies.py b/backend/app/dependencies/__init__.py similarity index 89% rename from backend/app/dependencies.py rename to backend/app/dependencies/__init__.py index 4f7de97..5b5204b 100644 --- a/backend/app/dependencies.py +++ b/backend/app/dependencies/__init__.py @@ -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 范围) # ============================================================================= diff --git a/backend/app/integrations/base.py b/backend/app/integrations/base.py index e4b2eac..45732c9 100644 --- a/backend/app/integrations/base.py +++ b/backend/app/integrations/base.py @@ -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", +] diff --git a/backend/app/integrations/ehr/__init__.py b/backend/app/integrations/ehr/__init__.py new file mode 100644 index 0000000..5a4dc36 --- /dev/null +++ b/backend/app/integrations/ehr/__init__.py @@ -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"] diff --git a/backend/app/integrations/factory.py b/backend/app/integrations/factory.py index 734e94a..0c6c474 100644 --- a/backend/app/integrations/factory.py +++ b/backend/app/integrations/factory.py @@ -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", +] diff --git a/backend/app/models/knowledge_base.py b/backend/app/models/knowledge_base.py index 7a0ce64..a495f58 100644 --- a/backend/app/models/knowledge_base.py +++ b/backend/app/models/knowledge_base.py @@ -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), diff --git a/backend/app/models/knowledge_suggestion.py b/backend/app/models/knowledge_suggestion.py index 4c7cabe..c02c389 100644 --- a/backend/app/models/knowledge_suggestion.py +++ b/backend/app/models/knowledge_suggestion.py @@ -8,9 +8,9 @@ import uuid from datetime import datetime -from typing import List, Optional +from typing import Any, Dict, List, Optional -from sqlalchemy import DateTime, Index, Integer, JSON, String, Text +from sqlalchemy import Boolean, DateTime, Float, Index, JSON, String, Text from sqlalchemy.orm import Mapped, mapped_column from app.database import Base @@ -114,6 +114,89 @@ class KnowledgeSuggestion(Base): comment="相关会话ID或标注ID列表", ) + # -------------------------------------------------------------------------- + # Tier0 扩展字段 — 图结构 + 置信度 + 受众 + 状态(D1/D3/D7/D8) + # -------------------------------------------------------------------------- + + # AI 生成置信度(0.0-1.0,D3 门控阈值 0.7) + confidence: Mapped[Optional[float]] = mapped_column( + Float, + nullable=True, + comment="AI 生成置信度(0.0-1.0)", + ) + + # 受众类型(D8:employee_quick_reply / engineer_workguide) + audience: Mapped[Optional[str]] = mapped_column( + String(30), + nullable=True, + comment="受众类型:employee_quick_reply / engineer_workguide", + ) + + # 图结构字段 — 问题名称(对应 Neo4j Issue.name) + issue: Mapped[Optional[str]] = mapped_column( + String(256), + nullable=True, + comment="图节点:问题名称", + ) + + # 图结构字段 — 动作名称(对应 Neo4j Action.name) + action: Mapped[Optional[str]] = mapped_column( + String(256), + nullable=True, + comment="图节点:动作名称", + ) + + # 图关系类型(LEADS_TO / RELATES_TO / CAN_JUMP_TO) + relation_type: Mapped[Optional[str]] = mapped_column( + String(30), + nullable=True, + comment="图关系类型", + ) + + # 父 Issue 名称(用于构建 Issue→Issue 关系) + parent_issue: Mapped[Optional[str]] = mapped_column( + String(256), + nullable=True, + comment="父 Issue 名称", + ) + + # 图结构扩展元数据(JSON,存放额外的图属性) + graph_meta: Mapped[Optional[Dict[str, Any]]] = mapped_column( + JSON, + nullable=True, + comment="图结构扩展元数据", + ) + + # 图同步状态(pending / synced / failed) + graph_sync_status: Mapped[str] = mapped_column( + String(20), + nullable=False, + default="pending", + comment="图同步状态:pending / synced / failed", + ) + + # AI 生成失败标记(Dify 不可用或置信度不足时置 True) + source_failed: Mapped[bool] = mapped_column( + Boolean, + nullable=False, + default=False, + comment="AI 生成失败标记", + ) + + # 入队列时间 + queued_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="入独立队列时间", + ) + + # 应用到 KB 的时间 + applied_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), + nullable=True, + comment="应用到 KB 的时间", + ) + # 生成理由 reason: Mapped[Optional[str]] = mapped_column( Text, @@ -166,6 +249,9 @@ class KnowledgeSuggestion(Base): Index("idx_suggestion_status", "status"), Index("idx_suggestion_type", "suggestion_type"), Index("idx_suggestion_created", "created_at"), + Index("idx_suggestion_audience", "audience"), + Index("idx_suggestion_confidence", "confidence"), + Index("idx_suggestion_graph_sync", "graph_sync_status"), ) def __repr__(self) -> str: diff --git a/backend/app/models/neo4j_schema.py b/backend/app/models/neo4j_schema.py new file mode 100644 index 0000000..5144e75 --- /dev/null +++ b/backend/app/models/neo4j_schema.py @@ -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) diff --git a/backend/app/schemas/enums.py b/backend/app/schemas/enums.py new file mode 100644 index 0000000..db0a1f4 --- /dev/null +++ b/backend/app/schemas/enums.py @@ -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 diff --git a/backend/app/schemas/knowledge_suggestion.py b/backend/app/schemas/knowledge_suggestion.py index 664b3bf..225d07a 100644 --- a/backend/app/schemas/knowledge_suggestion.py +++ b/backend/app/schemas/knowledge_suggestion.py @@ -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="过时标记数") diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py index ff0e9d4..c80f8d4 100644 --- a/backend/app/services/__init__.py +++ b/backend/app/services/__init__.py @@ -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", ] diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 9c9b38c..736dcc8 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -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, + } # -------------------------------------------------------------------------- # 判断是否命中知识库 diff --git a/backend/app/services/content_moderation_service.py b/backend/app/services/content_moderation_service.py index a5845f1..6c52cc4 100644 --- a/backend/app/services/content_moderation_service.py +++ b/backend/app/services/content_moderation_service.py @@ -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 和 (? Optional[KnowledgeSuggestion]: + """单会话/单事件的知识建议生成(通道 A 全链路闭环)。 + + 供会话关闭、标注触发等场景直接调用,生成单条结构化知识建议。 + 状态默认 pending,等待训练师审核。 + + Args: + db: 数据库会话 + source_type: 来源类型(conversation/annotation/ai_uncertain) + source_data: 来源ID列表(如 [conversation_id]) + reason: 生成理由(如"会话已结单,自动生成") + + Returns: + Optional[KnowledgeSuggestion]: 建议对象,Dify不可用或无需建议时返回None + """ + # 检查是否已存在待处理的建议(避免重复生成) + existing = await self._check_existing_suggestion(db, source_data[0] if source_data else "") + if existing: + logger.info(f"来源 {source_data} 已有待处理建议,跳过重复生成") + return None + + # 根据来源类型调用对应的生成方法 + if source_type == "conversation": + return await self._generate_new_faq_suggestion( + db=db, + source_type=source_type, + source_data=source_data, + reason=reason, + ) + else: + return await self._generate_update_suggestion( + db=db, + source_type=source_type, + source_data=source_data, + reason=reason, + ) + async def analyze_and_generate_suggestions( self, db: AsyncSession, @@ -53,7 +108,7 @@ class KnowledgeIterationService: ) -> Dict[str, Any]: """分析并生成知识库优化建议。 - 分析过去N天的标注数据和会话数据,生成优化建议。 + 分析过去N天的标注数据和会话数据,调用 WingmanService 真 AI 生成优化建议。 Args: db: 数据库会话 @@ -61,9 +116,9 @@ class KnowledgeIterationService: Returns: Dict: { - "annotations_analyzed": int, # 分析的标注数量 - "conversations_analyzed": int, # 分析的会话数量 - "suggestions_generated": int, # 生成的建议数量 + "annotations_analyzed": int, + "conversations_analyzed": int, + "suggestions_generated": int, } """ result = { @@ -75,18 +130,12 @@ class KnowledgeIterationService: # 1. 分析错误标注的高频问题 annotation_suggestions = await self._analyze_annotation_data(db, days) result["annotations_analyzed"] = annotation_suggestions.get("analyzed_count", 0) - result["suggestions_generated"] += annotation_suggestions.get( - "suggestions_count", 0 - ) + result["suggestions_generated"] += annotation_suggestions.get("suggestions_count", 0) # 2. 分析未命中知识库的会话 conversation_suggestions = await self._analyze_conversation_data(db, days) - result["conversations_analyzed"] = conversation_suggestions.get( - "analyzed_count", 0 - ) - result["suggestions_generated"] += conversation_suggestions.get( - "suggestions_count", 0 - ) + result["conversations_analyzed"] = conversation_suggestions.get("analyzed_count", 0) + result["suggestions_generated"] += conversation_suggestions.get("suggestions_count", 0) logger.info( f"知识库迭代分析完成: " @@ -100,21 +149,9 @@ class KnowledgeIterationService: async def _analyze_annotation_data( self, db: AsyncSession, days: int ) -> Dict[str, Any]: - """分析标注数据,生成优化建议。 - - 查找被标记为"无用"的AI回复,分析高频错误原因, - 尝试生成FAQ更新建议。 - - Args: - db: 数据库会话 - days: 分析过去N天的数据 - - Returns: - Dict: 分析结果统计 - """ + """分析标注数据,生成优化建议。""" since = datetime.now() - timedelta(days=days) - # 查询过去N天的无效标注 stmt = ( select(ConversationAnnotation) .where(ConversationAnnotation.feedback == "useless") @@ -132,7 +169,6 @@ class KnowledgeIterationService: msg_id = ann.message_id message_error_counts[msg_id] = message_error_counts.get(msg_id, 0) + 1 - # 找出高频错误(被标注3次以上) frequent_errors = { msg_id: count for msg_id, count in message_error_counts.items() @@ -142,10 +178,8 @@ class KnowledgeIterationService: if not frequent_errors: return {"analyzed_count": len(annotations), "suggestions_count": 0} - # 调用AI分析错误模式,生成更新建议 suggestions_count = 0 for msg_id, error_count in frequent_errors.items(): - # 生成优化建议 suggestion = await self._generate_update_suggestion( db=db, source_type="annotation", @@ -157,26 +191,14 @@ class KnowledgeIterationService: suggestions_count += 1 await db.commit() - return {"analyzed_count": len(annotations), "suggestions_count": suggestions_count} async def _analyze_conversation_data( self, db: AsyncSession, days: int ) -> Dict[str, Any]: - """分析会话数据,生成新增FAQ建议。 - - 查找AI无法解决(转人工)的会话,分析生成新FAQ建议。 - - Args: - db: 数据库会话 - days: 分析过去N天的数据 - - Returns: - Dict: 分析结果统计 - """ + """分析会话数据,生成新增FAQ建议。""" since = datetime.now() - timedelta(days=days) - # 查询过去N天的AI未解决会话(转人工的会话) stmt = ( select(Conversation) .where(Conversation.created_at >= since) @@ -188,19 +210,15 @@ class KnowledgeIterationService: if not conversations: return {"analyzed_count": 0, "suggestions_count": 0} - # 抽样分析(避免一次性处理太多) sample_size = min(20, len(conversations)) sampled = conversations[:sample_size] - # 对每个会话进行分析 suggestions_count = 0 for conv in sampled: - # 检查是否已存在类似建议 existing = await self._check_existing_suggestion(db, conv.id) if existing: continue - # 生成新FAQ建议 suggestion = await self._generate_new_faq_suggestion( db=db, source_type="conversation", @@ -212,11 +230,10 @@ class KnowledgeIterationService: suggestions_count += 1 await db.commit() - return {"analyzed_count": len(conversations), "suggestions_count": suggestions_count} # -------------------------------------------------------------------------- - # 辅助方法 + # 辅助方法 — AI 生成 # -------------------------------------------------------------------------- async def _generate_update_suggestion( @@ -226,7 +243,10 @@ class KnowledgeIterationService: source_data: List[str], reason: str, ) -> Optional[KnowledgeSuggestion]: - """生成知识库更新建议。 + """生成知识库更新建议(真 AI 生成 — 替代 [待AI生成] 占位)。 + + 调用 WingmanService.generate_knowledge_suggestion() 生成结构化建议。 + Dify 不可用时设 source_failed=True,不写伪数据。 Args: db: 数据库会话 @@ -237,21 +257,72 @@ class KnowledgeIterationService: Returns: Optional[KnowledgeSuggestion]: 建议对象 """ - # TODO: 调用AI生成具体的更新内容 - # 当前返回示例数据,实际应调用 Dify API + # 构建对话上下文(从 source_data 中获取消息内容) + context_messages = await self._build_context_from_source(db, source_type, source_data) - return KnowledgeSuggestion( + # 调用 WingmanService 生成建议 + ai_result: Dict[str, Any] = {} + source_failed = False + try: + from app.services.wingman_service import WingmanService + + wingman = WingmanService() + ai_result = await wingman.generate_knowledge_suggestion(context_messages) + await wingman.close() + except Exception as e: + logger.error(f"Dify 知识建议生成失败,标记 source_failed: {e}") + source_failed = True + + # 如果 Dify 不可用或返回空内容,标记 source_failed + if not source_failed and not ai_result.get("title") and not ai_result.get("content"): + source_failed = True + logger.warning("Dify 返回空建议内容,标记 source_failed") + + # 构建 KnowledgeSuggestion 对象 + title = ai_result.get("title", "") if not source_failed else "[生成失败] 优化建议" + content = ai_result.get("content", "") if not source_failed else reason + category = ai_result.get("category", "其他") + tags = ai_result.get("tags", []) + confidence = ai_result.get("confidence", 0.0) + issue = ai_result.get("issue", "") + action = ai_result.get("action", "") + relation_type = ai_result.get("relation_type", "LEADS_TO") + parent_issue = ai_result.get("parent_issue", "") + + # D3 置信门控:confidence < 0.7 标记 source_failed + if not source_failed and confidence < self.confidence_gate_threshold: + source_failed = True + logger.info( + f"置信度 {confidence} 低于门控阈值 {self.confidence_gate_threshold}," + f"标记 source_failed" + ) + + # D8 audience 自动标注 + audience = await self._auto_tag_audience(db, source_type, source_data) + + suggestion = KnowledgeSuggestion( suggestion_type="update", - status="pending", - title="[待AI生成] 优化建议", - content="请通过AI分析生成具体的更新内容", - category="其他", - tags=[], + status=SuggestionStatusEnum.pending.value, + title=title, + content=content, + category=category, + tags=tags, source_type=source_type, source_data=source_data, reason=reason, + confidence=confidence, + audience=audience.value if isinstance(audience, AudienceEnum) else audience, + issue=issue, + action=action, + relation_type=relation_type, + parent_issue=parent_issue, + graph_meta={}, + graph_sync_status=GraphSyncStatusEnum.pending.value, + source_failed=source_failed, ) + return suggestion + async def _generate_new_faq_suggestion( self, db: AsyncSession, @@ -259,7 +330,10 @@ class KnowledgeIterationService: source_data: List[str], reason: str, ) -> Optional[KnowledgeSuggestion]: - """生成新FAQ建议。 + """生成新FAQ建议(真 AI 生成 — 替代 [待AI生成] 占位)。 + + 与 _generate_update_suggestion 使用相同的 AI 调用流程, + 但 suggestion_type 固定为 "new_faq"。 Args: db: 数据库会话 @@ -270,36 +344,214 @@ class KnowledgeIterationService: Returns: Optional[KnowledgeSuggestion]: 建议对象 """ - # TODO: 调用AI生成具体的FAQ内容 - # 当前返回示例数据,实际应调用 Dify API + # 构建对话上下文 + context_messages = await self._build_context_from_source(db, source_type, source_data) - return KnowledgeSuggestion( + # 调用 WingmanService 生成建议 + ai_result: Dict[str, Any] = {} + source_failed = False + try: + from app.services.wingman_service import WingmanService + + wingman = WingmanService() + ai_result = await wingman.generate_knowledge_suggestion(context_messages) + await wingman.close() + except Exception as e: + logger.error(f"Dify 知识建议生成失败,标记 source_failed: {e}") + source_failed = True + + if not source_failed and not ai_result.get("title") and not ai_result.get("content"): + source_failed = True + logger.warning("Dify 返回空建议内容,标记 source_failed") + + title = ai_result.get("title", "") if not source_failed else "[生成失败] 新FAQ建议" + content = ai_result.get("content", "") if not source_failed else reason + category = ai_result.get("category", "其他") + tags = ai_result.get("tags", []) + confidence = ai_result.get("confidence", 0.0) + issue = ai_result.get("issue", "") + action = ai_result.get("action", "") + relation_type = ai_result.get("relation_type", "LEADS_TO") + parent_issue = ai_result.get("parent_issue", "") + + # D3 置信门控 + if not source_failed and confidence < self.confidence_gate_threshold: + source_failed = True + logger.info( + f"置信度 {confidence} 低于门控阈值 {self.confidence_gate_threshold}," + f"标记 source_failed" + ) + + # D8 audience 自动标注 + audience = await self._auto_tag_audience(db, source_type, source_data) + + suggestion = KnowledgeSuggestion( suggestion_type="new_faq", - status="pending", - title="[待AI生成] 新FAQ建议", - content="请通过AI分析生成具体的问题和答案", - category="其他", - tags=[], + status=SuggestionStatusEnum.pending.value, + title=title, + content=content, + category=category, + tags=tags, source_type=source_type, source_data=source_data, reason=reason, + confidence=confidence, + audience=audience.value if isinstance(audience, AudienceEnum) else audience, + issue=issue, + action=action, + relation_type=relation_type, + parent_issue=parent_issue, + graph_meta={}, + graph_sync_status=GraphSyncStatusEnum.pending.value, + source_failed=source_failed, ) + return suggestion + + async def _build_context_from_source( + self, db: AsyncSession, source_type: str, source_data: List[str] + ) -> List[Dict[str, Any]]: + """从来源数据构建对话上下文消息列表。 + + Args: + db: 数据库会话 + source_type: 来源类型 + source_data: 来源ID列表 + + Returns: + List[Dict]: 消息历史列表(兼容 WingmanService._build_context_messages 输入格式) + """ + context_messages: List[Dict[str, Any]] = [] + try: + from app.models.message import Message + + for source_id in source_data: + if source_type == "conversation": + stmt = ( + select(Message) + .where(Message.conversation_id == source_id) + .order_by(Message.created_at.asc()) + .limit(20) + ) + result = await db.execute(stmt) + messages = result.scalars().all() + for msg in messages: + context_messages.append({ + "sender_type": getattr(msg, "sender_type", "employee"), + "content": getattr(msg, "content", ""), + }) + elif source_type == "annotation": + # 根据 message_id 获取对应消息 + stmt = select(Message).where(Message.id == source_id) + result = await db.execute(stmt) + msg = result.scalar_one_or_none() + if msg: + context_messages.append({ + "sender_type": getattr(msg, "sender_type", "ai"), + "content": getattr(msg, "content", ""), + }) + except ImportError: + # Message 模型不可用,返回空上下文 + logger.warning("Message 模型导入失败,使用空上下文") + except Exception as e: + logger.warning(f"构建对话上下文失败: {e}") + + # 如果无上下文,放入 reason 作为最小上下文 + if not context_messages: + context_messages.append({ + "sender_type": "system", + "content": f"分析来源: {source_type}, 数据: {source_data}", + }) + + return context_messages + + # -------------------------------------------------------------------------- + # 辅助方法 — audience 自动标注(D8) + # -------------------------------------------------------------------------- + + async def _auto_tag_audience( + self, + db: AsyncSession, + source_type: str, + source_data: List[str], + ) -> AudienceEnum: + """自动标注知识受众类型。 + + D8 判定规则(§8.3): + - manual / document_ragflow → engineer_workguide + - annotation / conversation / ai_uncertain → 查会话类型 + - employee 会话 → employee_quick_reply + - engineer 会话 → engineer_workguide + - 其他/未知 → employee_quick_reply(保守默认) + + Args: + db: 数据库会话 + source_type: 来源类型 + source_data: 来源数据 + + Returns: + AudienceEnum: 受众类型 + """ + # 手动录入和 RAGFlow → 工程师作业指导 + if source_type in (SourceTypeEnum.manual.value, SourceTypeEnum.document_ragflow.value): + return AudienceEnum.engineer_workguide + + # 会话/标注/AI不确定 → 查 Conversation + if source_type in ( + SourceTypeEnum.annotation.value, + SourceTypeEnum.conversation.value, + SourceTypeEnum.ai_uncertain.value, + ): + if source_data: + # 取第一个 source_id 查对应会话 + conv_id = source_data[0] + try: + stmt = select(Conversation).where(Conversation.id == conv_id) + result = await db.execute(stmt) + conv = result.scalar_one_or_none() + if conv: + # Conversation 模型无 session_type 字段, + # 通过 status/employee_name 推断: + # waiting_agent/agentServing 视为需要人工介入, + # 这些更可能是工程师关注的 + # 默认 employee_quick_reply(保守) + return AudienceEnum.employee_quick_reply + except Exception as e: + logger.warning(f"查询会话确定 audience 失败: {e}") + + # 保守默认:员工快捷回复 + return AudienceEnum.employee_quick_reply + + # -------------------------------------------------------------------------- + # 置信门控(D3) + # -------------------------------------------------------------------------- + + def _apply_confidence_gate(self, confidence: Optional[float]) -> bool: + """检查置信度是否低于门控阈值。 + + D3 硬约束:confidence < 0.7 → 触发门控。 + + Args: + confidence: AI 生成的置信度 + + Returns: + bool: True = 通过门控(置信度达标),False = 未通过 + """ + if confidence is None: + return False + return confidence >= self.confidence_gate_threshold + + # -------------------------------------------------------------------------- + # 查重 + # -------------------------------------------------------------------------- + async def _check_existing_suggestion( self, db: AsyncSession, source_id: str ) -> bool: - """检查是否已存在相关建议。 - - Args: - db: 数据库会话 - source_id: 来源ID - - Returns: - bool: 是否已存在 - """ + """检查是否已存在相关建议。""" stmt = ( select(KnowledgeSuggestion) - .where(KnowledgeSuggestion.status == "pending") + .where(KnowledgeSuggestion.status == SuggestionStatusEnum.pending.value) .where(KnowledgeSuggestion.source_data.contains(source_id)) ) result = await db.execute(stmt) @@ -307,7 +559,222 @@ class KnowledgeIterationService: return existing is not None # -------------------------------------------------------------------------- - # 审核与应用 + # 知识去重 — Neo4j 图结构相似检测(任务3:P2) + # -------------------------------------------------------------------------- + + async def find_duplicates( + self, + db: AsyncSession, + issue_name: Optional[str] = None, + title: Optional[str] = None, + suggestion_id: Optional[str] = None, + neo4j_client=None, + ) -> List[Dict[str, Any]]: + """利用 Neo4j 图结构查询同名或相似节点,检测重复 Issue。 + + 查询规则: + 1. 同名 Issue:Neo4j 中已存在 name 完全相同的 Issue 节点 + 2. 相似标题:title 文本相似度(基于前缀匹配/包含匹配) + 3. 排除已 reject/expired 的建议关联节点 + + Args: + db: 数据库会话 + issue_name: 待检查的 Issue 名称(可选) + title: 待检查的标题(可选,用于文本相似匹配) + suggestion_id: 当前建议ID(可选,排除自身关联的节点) + neo4j_client: Neo4j 客户端(提供则查图,不提供则只查 SQL) + + Returns: + List[Dict]: 重复项列表,每项包含 {type, name, suggestion, similarity} + """ + duplicates: List[Dict[str, Any]] = [] + + # 1. SQL 层面:按 title 模糊匹配已应用的 KB 条目 + if title: + search_keywords = title[:50] # 取前50字符作关键词 + stmt = ( + select(KnowledgeSuggestion) + .where(KnowledgeSuggestion.status.in_([ + SuggestionStatusEnum.approved.value, + SuggestionStatusEnum.applied.value, + SuggestionStatusEnum.graph_synced.value, + ])) + .where(KnowledgeSuggestion.title.ilike(f"%{search_keywords[:20]}%")) + .limit(10) + ) + if suggestion_id: + stmt = stmt.where(KnowledgeSuggestion.id != suggestion_id) + result = await db.execute(stmt) + sql_duplicates = result.scalars().all() + for s in sql_duplicates: + duplicates.append({ + "type": "title_similar", + "name": s.title, + "suggestion_id": s.id, + "suggestion_type": s.suggestion_type, + "issue": s.issue, + "action": s.action, + "status": s.status, + "similarity": self._calc_similarity(title, s.title), + "source": "sql", + }) + + # 2. Neo4j 层面:按 issue_name 查同名节点 + if issue_name and neo4j_client is not None: + try: + # 查同名 Issue 节点 + existing_issue = await neo4j_client.find_issue_by_name(issue_name) + if existing_issue: + # 查该 Issue 关联的 suggestion + source_sid = existing_issue.source_suggestion_id + if source_sid and (not suggestion_id or source_sid != suggestion_id): + stmt = select(KnowledgeSuggestion).where( + KnowledgeSuggestion.id == source_sid + ) + result = await db.execute(stmt) + related_s = result.scalar_one_or_none() + if related_s: + duplicates.append({ + "type": "same_issue", + "name": issue_name, + "suggestion_id": related_s.id, + "suggestion_type": related_s.suggestion_type, + "issue": related_s.issue, + "action": related_s.action, + "status": related_s.status, + "similarity": 1.0, + "source": "neo4j", + }) + + # 3. 查名称相似节点(前缀匹配) + name_prefix = issue_name[:min(10, len(issue_name))] + if len(name_prefix) >= 3: + similar_data = await neo4j_client.execute_read_query( + """ + MATCH (i:Issue) + WHERE i.name STARTS WITH $prefix AND i.name <> $exact_name + RETURN i.name AS name, i.uuid AS uuid, i.category AS category, + i.source_suggestion_id AS source_suggestion_id + LIMIT 5 + """, + params={"prefix": name_prefix, "exact_name": issue_name}, + ) + for row in similar_data: + sid = row.get("source_suggestion_id") + if sid and (not suggestion_id or sid != suggestion_id): + duplicates.append({ + "type": "similar_issue", + "name": row["name"], + "suggestion_id": sid, + "issue": row["name"], + "similarity": round( + self._calc_similarity(issue_name, row["name"]), 2 + ), + "source": "neo4j", + }) + except Exception as e: + logger.warning(f"Neo4j 去重查询失败(降级继续): {e}") + + # 按相似度降序排列 + duplicates.sort(key=lambda x: x.get("similarity", 0), reverse=True) + return duplicates + + def _calc_similarity(self, a: str, b: str) -> float: + """计算两个文本的简单相似度(前缀匹配 + 长度比)。 + + Args: + a: 文本A + b: 文本B + + Returns: + float: 相似度(0.0-1.0) + """ + if not a or not b: + return 0.0 + a_lower = a.lower().strip() + b_lower = b.lower().strip() + # 完全匹配 + if a_lower == b_lower: + return 1.0 + # 包含匹配 + if a_lower in b_lower or b_lower in a_lower: + shorter = min(len(a_lower), len(b_lower)) + longer = max(len(a_lower), len(b_lower)) + return 0.5 + 0.5 * (shorter / longer) + # 公共前缀匹配 + common_prefix_len = 0 + for ca, cb in zip(a_lower, b_lower): + if ca == cb: + common_prefix_len += 1 + else: + break + max_len = max(len(a_lower), len(b_lower)) + return round(common_prefix_len / max_len, 2) if max_len > 0 else 0.0 + + async def merge_suggestions( + self, + db: AsyncSession, + primary_id: str, + duplicate_id: str, + reviewer_id: str, + ) -> Optional[KnowledgeSuggestion]: + """合并重复建议:将重复建议合并到主建议,重复建议标记为 rejected。 + + Args: + db: 数据库会话 + primary_id: 主建议ID(保留) + duplicate_id: 重复建议ID(将被标记为 rejected) + reviewer_id: 审核人ID + + Returns: + Optional[KnowledgeSuggestion]: 主建议对象(合并后) + """ + # 获取主建议 + primary_stmt = select(KnowledgeSuggestion).where( + KnowledgeSuggestion.id == primary_id + ) + result = await db.execute(primary_stmt) + primary = result.scalar_one_or_none() + if not primary: + logger.warning(f"合并失败:主建议 {primary_id} 不存在") + return None + + # 获取重复建议 + dup_stmt = select(KnowledgeSuggestion).where( + KnowledgeSuggestion.id == duplicate_id + ) + result = await db.execute(dup_stmt) + duplicate = result.scalar_one_or_none() + + if duplicate: + # 合并标签(去重) + merged_tags = list(set((primary.tags or []) + (duplicate.tags or []))) + primary.tags = merged_tags + + # 合并 graph_meta + if duplicate.graph_meta and primary.graph_meta: + primary.graph_meta.update(duplicate.graph_meta) + elif duplicate.graph_meta: + primary.graph_meta = duplicate.graph_meta + + # 标记重复建议为 rejected(合并归入) + duplicate.status = SuggestionStatusEnum.rejected.value + duplicate.reviewer_id = reviewer_id + duplicate.reviewed_at = datetime.now() + duplicate.reject_reason = f"已合并至建议 {primary_id}(去重)" + db.add(duplicate) + + logger.info( + f"知识合并完成: primary={primary_id}, duplicate={duplicate_id}, " + f"reviewer={reviewer_id}" + ) + + await db.commit() + await db.refresh(primary) + return primary + + # -------------------------------------------------------------------------- + # 审核与应用 — 审批状态机五态流转(D7) # -------------------------------------------------------------------------- async def approve_suggestion( @@ -315,47 +782,85 @@ class KnowledgeIterationService: db: AsyncSession, suggestion_id: str, reviewer_id: str, + neo4j_client=None, ) -> Optional[KnowledgeSuggestion]: - """审核通过建议并应用到知识库。 + """审核通过建议并应用到知识库(五态流转 + Neo4j 写图)。 + + 审批状态机: + pending/queued → approved → applied → graph_synced + + D1 解读2 合一:approved 后直接写 Neo4j 图节点和关系, + flat KnowledgeBase 降为派生视图,同步创建。 Args: db: 数据库会话 suggestion_id: 建议ID reviewer_id: 审核人ID + neo4j_client: Neo4j 客户端(可选,提供则自动写图) Returns: Optional[KnowledgeSuggestion]: 更新后的建议对象 """ - # 获取建议 - stmt = select(KnowledgeSuggestion).where( - KnowledgeSuggestion.id == suggestion_id - ) + stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db.execute(stmt) suggestion = result.scalar_one_or_none() if not suggestion: return None - # 更新状态 - suggestion.status = "approved" + # 校验状态转换合法性 + current_status = SuggestionStatusEnum(suggestion.status) + target_status = SuggestionStatusEnum.approved + + if not is_valid_transition(current_status, target_status): + logger.warning( + f"无效的状态转换: {current_status.value} → {target_status.value} " + f"(suggestion_id={suggestion_id})" + ) + return None + + # 1. approved — 审批通过 + suggestion.status = SuggestionStatusEnum.approved.value suggestion.reviewer_id = reviewer_id suggestion.reviewed_at = datetime.now() - # 如果是新FAQ或更新,创建对应的知识库条目 + # 2. applied — 创建 flat KB 条目(派生视图) if suggestion.suggestion_type in ("new_faq", "update"): kb = KnowledgeBase( title=suggestion.title, content=suggestion.content, category=suggestion.category, tags=suggestion.tags, + graph_sync_status=GraphSyncStatusEnum.pending.value, ) db.add(kb) - suggestion.status = "applied" + await db.flush() # 获取 kb.id + + suggestion.status = SuggestionStatusEnum.applied.value + suggestion.applied_at = datetime.now() + + # 3. Neo4j 写图(D1 解读2 合一) + if neo4j_client is not None: + sync_ok = await self.sync_to_neo4j(neo4j_client, suggestion) + if sync_ok: + suggestion.status = SuggestionStatusEnum.graph_synced.value + suggestion.graph_sync_status = GraphSyncStatusEnum.synced.value + kb.graph_sync_status = GraphSyncStatusEnum.synced.value + # 回填图节点 uuid + if suggestion.issue: + issue_node = await neo4j_client.find_issue_by_name(suggestion.issue) + if issue_node: + kb.graph_node_uuid = issue_node.uuid + logger.info(f"建议 {suggestion_id} 已同步到 Neo4j 图") + else: + suggestion.graph_sync_status = GraphSyncStatusEnum.failed.value + kb.graph_sync_status = GraphSyncStatusEnum.failed.value + logger.warning(f"建议 {suggestion_id} Neo4j 图同步失败") await db.commit() await db.refresh(suggestion) - logger.info(f"建议已审核通过并应用: {suggestion_id}") + logger.info(f"建议已审核通过并应用: {suggestion_id}, status={suggestion.status}") return suggestion async def reject_suggestion( @@ -376,16 +881,14 @@ class KnowledgeIterationService: Returns: Optional[KnowledgeSuggestion]: 更新后的建议对象 """ - stmt = select(KnowledgeSuggestion).where( - KnowledgeSuggestion.id == suggestion_id - ) + stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) result = await db.execute(stmt) suggestion = result.scalar_one_or_none() if not suggestion: return None - suggestion.status = "rejected" + suggestion.status = SuggestionStatusEnum.rejected.value suggestion.reviewer_id = reviewer_id suggestion.reviewed_at = datetime.now() suggestion.reject_reason = reject_reason @@ -396,30 +899,238 @@ class KnowledgeIterationService: logger.info(f"建议已拒绝: {suggestion_id}, 理由: {reject_reason}") return suggestion + async def rewrite_suggestion( + self, + db: AsyncSession, + suggestion_id: str, + reviewer_id: str, + data: Dict[str, Any], + ) -> Optional[KnowledgeSuggestion]: + """训练师改写建议内容(改写后重置为 pending 重新走审批)。 + + Args: + db: 数据库会话 + suggestion_id: 建议ID + reviewer_id: 审核人ID + data: 改写数据(title/content/category/tags/confidence/audience/issue/action/等) + + Returns: + Optional[KnowledgeSuggestion]: 更新后的建议对象 + """ + stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) + result = await db.execute(stmt) + suggestion = result.scalar_one_or_none() + + if not suggestion: + return None + + # 更新可改写的字段 + for field in ( + "title", "content", "category", "tags", "confidence", + "audience", "issue", "action", "relation_type", "parent_issue", + ): + if field in data and data[field] is not None: + setattr(suggestion, field, data[field]) + + # 重置为 pending,重新走审批流程 + suggestion.status = SuggestionStatusEnum.pending.value + suggestion.reviewer_id = reviewer_id + suggestion.reviewed_at = datetime.now() + + await db.commit() + await db.refresh(suggestion) + + logger.info(f"建议已改写并重置为 pending: {suggestion_id}") + return suggestion + + # -------------------------------------------------------------------------- + # 独立队列操作(D7) + # -------------------------------------------------------------------------- + + async def queue_suggestion( + self, db: AsyncSession, suggestion_id: str + ) -> Optional[KnowledgeSuggestion]: + """将建议放入独立队列(queued 状态)。 + + 当会话关闭且提案仍处于 pending 时调用。 + + Args: + db: 数据库会话 + suggestion_id: 建议ID + + Returns: + Optional[KnowledgeSuggestion]: 更新后的建议对象 + """ + stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id) + result = await db.execute(stmt) + suggestion = result.scalar_one_or_none() + + if not suggestion: + return None + + current_status = SuggestionStatusEnum(suggestion.status) + if not is_valid_transition(current_status, SuggestionStatusEnum.queued): + logger.warning( + f"无效的状态转换: {current_status.value} → queued " + f"(suggestion_id={suggestion_id})" + ) + return None + + suggestion.status = SuggestionStatusEnum.queued.value + suggestion.queued_at = datetime.now() + + await db.commit() + await db.refresh(suggestion) + + logger.info(f"建议已进入独立队列: {suggestion_id}") + return suggestion + + async def dequeue_approve( + self, + db: AsyncSession, + suggestion_id: str, + reviewer_id: str, + neo4j_client=None, + ) -> Optional[KnowledgeSuggestion]: + """从独立队列中审批通过建议(同 approve_suggestion 流程)。 + + Args: + db: 数据库会话 + suggestion_id: 建议ID + reviewer_id: 审核人ID + neo4j_client: Neo4j 客户端(可选) + + Returns: + Optional[KnowledgeSuggestion]: 更新后的建议对象 + """ + return await self.approve_suggestion( + db=db, + suggestion_id=suggestion_id, + reviewer_id=reviewer_id, + neo4j_client=neo4j_client, + ) + + # -------------------------------------------------------------------------- + # Neo4j 图同步(D1 解读2 合一) + # -------------------------------------------------------------------------- + + async def sync_to_neo4j( + self, + neo4j_client, + suggestion: KnowledgeSuggestion, + ) -> bool: + """将建议写入 Neo4j 图(D1 解读2 合一:图即真相源)。 + + 对齐复杂场景重构 v1.1 命名约定: + - Issue.name / Action.name 作为唯一业务键,MERGE 幂等写入 + - 关系类型对齐 RelationTypeEnum(LEADS_TO / RELATES_TO / CAN_JUMP_TO) + + 写图步骤: + 1. merge_issue → IssueNode + 2. merge_action → ActionNode(如提供) + 3. create_relation → (Issue)-[LEADS_TO/RELATES_TO]->(Action) 或 (Issue)-[:]->(Issue) + + Args: + neo4j_client: Neo4jClient 实例 + suggestion: KnowledgeSuggestion 对象 + + Returns: + bool: 写图成功返回 True,失败返回 False + """ + if not suggestion.issue: + logger.debug(f"建议 {suggestion.id} 无 issue 字段,跳过图同步") + return True # 无图字段不视为失败 + + try: + from app.models.neo4j_schema import IssueNode as N4JIssue, ActionNode as N4JAction, RelationEdge + + # 1. 写入 Issue 节点(MERGE 幂等) + issue_props: Dict[str, Any] = { + "source_suggestion_id": suggestion.id, + } + if suggestion.graph_meta: + issue_props.update(suggestion.graph_meta) + + issue_node = await neo4j_client.merge_issue( + name=suggestion.issue, + category=suggestion.category, + props=issue_props, + ) + logger.debug(f"Issue 节点已同步: {issue_node.name} (uuid={issue_node.uuid})") + + # 2. 如果有父 Issue,先写入父 Issue 并创建关系 + if suggestion.parent_issue: + parent_node = await neo4j_client.merge_issue( + name=suggestion.parent_issue, + category=suggestion.category, + props={"source_suggestion_id": suggestion.id}, + ) + + parent_rel = RelationEdge( + from_uuid=parent_node.uuid, + to_uuid=issue_node.uuid, + type=suggestion.relation_type or "LEADS_TO", + order=0, + weight=1.0, + ) + await neo4j_client.create_relation( + parent_node.uuid, issue_node.uuid, parent_rel + ) + logger.debug( + f"父 Issue 关系已创建: {suggestion.parent_issue} " + f"-[:{parent_rel.type}]-> {suggestion.issue}" + ) + + # 3. 写入 Action 节点(如果提供) + if suggestion.action: + action_node = await neo4j_client.merge_action( + name=suggestion.action, + props={ + "description": suggestion.title, + "source_suggestion_id": suggestion.id, + }, + ) + + action_rel = RelationEdge( + from_uuid=issue_node.uuid, + to_uuid=action_node.uuid, + type=suggestion.relation_type or "LEADS_TO", + order=1, + weight=suggestion.confidence or 1.0, + ) + await neo4j_client.create_relation( + issue_node.uuid, action_node.uuid, action_rel + ) + logger.debug( + f"Action 节点已同步: {action_node.name} " + f"-[:{action_rel.type}]-> 关系已创建" + ) + + return True + + except Exception as e: + logger.error(f"Neo4j 图同步失败 (suggestion_id={suggestion.id}): {e}") + return False + # -------------------------------------------------------------------------- # 查询统计 # -------------------------------------------------------------------------- async def get_suggestion_stats(self, db: AsyncSession) -> Dict[str, int]: - """获取建议统计。 - - Args: - db: 数据库会话 - - Returns: - Dict: 统计数据 - """ - # 总数 + """获取建议统计(含新增状态 queued/graph_synced/expired)。""" stmt = select(KnowledgeSuggestion) result = await db.execute(stmt) all_suggestions = result.scalars().all() - stats = { + stats: Dict[str, int] = { "total": len(all_suggestions), "pending": 0, + "queued": 0, "approved": 0, "rejected": 0, "applied": 0, + "graph_synced": 0, + "expired": 0, "new_faq_count": 0, "update_count": 0, "outdated_count": 0, @@ -437,8 +1148,49 @@ class KnowledgeIterationService: return stats + async def get_queue_stats(self, db: AsyncSession) -> Dict[str, int]: + """获取独立队列统计。 + Returns: + Dict: { + "queued_total": int, # 队列中总数 + "pending_total": int, # 待审核总数 + "by_audience": dict, # 按 audience 分组统计 + } + """ + # 队列中统计 + queued_stmt = ( + select(KnowledgeSuggestion) + .where(KnowledgeSuggestion.status == SuggestionStatusEnum.queued.value) + ) + queued_result = await db.execute(queued_stmt) + queued = queued_result.scalars().all() + + pending_stmt = ( + select(KnowledgeSuggestion) + .where(KnowledgeSuggestion.status == SuggestionStatusEnum.pending.value) + ) + pending_result = await db.execute(pending_stmt) + pending = pending_result.scalars().all() + + # 按 audience 分组 + by_audience: Dict[str, int] = {} + for s in queued + pending: + aud = s.audience or "unknown" + by_audience[aud] = by_audience.get(aud, 0) + 1 + + return { + "queued_total": len(queued), + "pending_total": len(pending), + "by_audience": by_audience, + } + + +# ============================================================================= # 依赖注入函数 +# ============================================================================= + + async def dep_knowledge_iteration_service() -> KnowledgeIterationService: """获取知识库迭代服务实例。""" return KnowledgeIterationService() diff --git a/backend/app/services/message_router.py b/backend/app/services/message_router.py index ce58f3c..d99204a 100644 --- a/backend/app/services/message_router.py +++ b/backend/app/services/message_router.py @@ -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, diff --git a/backend/app/services/neo4j_client.py b/backend/app/services/neo4j_client.py new file mode 100644 index 0000000..dd2dae9 --- /dev/null +++ b/backend/app/services/neo4j_client.py @@ -0,0 +1,758 @@ +# ============================================================================= +# 企微IT智能服务台 — Neo4j 图数据库客户端 +# ============================================================================= +# 说明:封装 Neo4j 官方异步驱动,提供连接池、读写事务分离、图 schema 初始化、 +# 健康检查等基础能力。T01 先实现基础设施,T02 扩展图节点 CRUD。 +# +# 核心能力: +# 1. AsyncDriver 连接池管理(initialize / close) +# 2. 读写事务分离(execute_write_query / execute_read_query) +# 3. 图 schema 初始化(约束 + 索引) +# 4. 健康检查(health_check → RETURN 1) +# 5. 图节点 CRUD(create/merge/find IssueNode / ActionNode / RelationEdge) +# +# 图 Schema 对齐:复杂场景重构 v1.1 TeliChat 白盒模型 +# 节点:Issue / Action / Info / Session +# 关系:LEADS_TO / RELATES_TO / HAS_ACTION / PROVIDED / CORRECTED_TO +# ============================================================================= + +import logging +import uuid as _uuid +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from neo4j import AsyncDriver, AsyncGraphDatabase +from neo4j.exceptions import Neo4jError, ServiceUnavailable + +from app.config import settings +from app.models.neo4j_schema import ActionNode, IssueNode, RelationEdge + +logger = logging.getLogger(__name__) + + +class Neo4jClient: + """Neo4j 图数据库异步客户端。 + + 封装 neo4j 官方异步驱动(AsyncDriver),提供连接池管理、 + 读写事务分离、图 schema 初始化和图节点 CRUD 操作。 + + 使用方式: + client = Neo4jClient() + await client.initialize() + # ... 执行操作 ... + await client.close() + + Attributes: + uri: Neo4j bolt 连接地址 + user: Neo4j 用户名 + password: Neo4j 密码 + database: 默认数据库名 + _driver: AsyncDriver 实例(懒加载) + """ + + # -------------------------------------------------------------------------- + # 图 Schema — Cypher 约束与索引 + # -------------------------------------------------------------------------- + _SCHEMA_CONSTRAINTS: List[str] = [ + # Issue 节点唯一约束 + "CREATE CONSTRAINT issue_uuid IF NOT EXISTS FOR (i:Issue) REQUIRE i.uuid IS UNIQUE", + # Action 节点唯一约束 + "CREATE CONSTRAINT action_uuid IF NOT EXISTS FOR (a:Action) REQUIRE a.uuid IS UNIQUE", + ] + + _SCHEMA_INDEXES: List[str] = [ + # Issue 按分类查询索引 + "CREATE INDEX issue_category IF NOT EXISTS FOR (i:Issue) ON (i.category)", + # Issue 按名称查询索引 + "CREATE INDEX issue_name IF NOT EXISTS FOR (i:Issue) ON (i.name)", + # Action 按名称查询索引 + "CREATE INDEX action_name IF NOT EXISTS FOR (a:Action) ON (a.name)", + ] + + def __init__( + self, + uri: Optional[str] = None, + user: Optional[str] = None, + password: Optional[str] = None, + database: Optional[str] = None, + max_connection_lifetime: Optional[int] = None, + max_connection_pool_size: Optional[int] = None, + connection_acquisition_timeout: Optional[int] = None, + ): + """初始化 Neo4j 客户端。 + + 所有参数均可选,未提供时从 app.config.settings 读取默认值。 + + Args: + uri: Neo4j bolt 连接地址 + user: Neo4j 用户名 + password: Neo4j 密码 + database: 默认数据库名 + max_connection_lifetime: 连接最大存活时间(秒) + max_connection_pool_size: 连接池上限 + connection_acquisition_timeout: 连接获取超时(秒) + """ + self.uri: str = uri or settings.neo4j_uri + self.user: str = user or settings.neo4j_user + self.password: str = password or settings.neo4j_password + self.database: str = database or settings.neo4j_database + self.max_connection_lifetime: int = ( + max_connection_lifetime or settings.neo4j_max_connection_lifetime + ) + self.max_connection_pool_size: int = ( + max_connection_pool_size or settings.neo4j_max_connection_pool_size + ) + self.connection_acquisition_timeout: int = ( + connection_acquisition_timeout + or settings.neo4j_connection_acquisition_timeout + ) + self._driver: Optional[AsyncDriver] = None + + # -------------------------------------------------------------------------- + # 生命周期管理 + # -------------------------------------------------------------------------- + + async def initialize(self) -> None: + """初始化 Neo4j 连接并验证可用性。 + + 创建 AsyncDriver 连接池,执行健康检查,创建图 schema(约束+索引)。 + + Raises: + ServiceUnavailable: Neo4j 服务不可达 + Neo4jError: 图 schema 初始化失败 + """ + if self._driver is not None: + logger.warning("Neo4jClient 已初始化,跳过重复初始化") + return + + logger.info(f"正在初始化 Neo4j 客户端: uri={self.uri}, database={self.database}") + + self._driver = AsyncGraphDatabase.driver( + self.uri, + auth=(self.user, self.password), + max_connection_lifetime=self.max_connection_lifetime, + max_connection_pool_size=self.max_connection_pool_size, + connection_acquisition_timeout=self.connection_acquisition_timeout, + ) + + # 验证连接 + healthy = await self.health_check() + if not healthy: + await self._driver.close() + self._driver = None + raise ServiceUnavailable( + f"Neo4j 服务不可达: {self.uri},健康检查失败" + ) + + # 创建图 schema(约束 + 索引) + await self._init_schema() + + logger.info("Neo4j 客户端初始化完成") + + async def close(self) -> None: + """关闭 Neo4j 驱动,释放连接池资源。""" + if self._driver is not None: + await self._driver.close() + self._driver = None + logger.info("Neo4j 客户端已关闭") + + async def health_check(self) -> bool: + """验证 Neo4j 连接可用性。 + + 执行简单的 RETURN 1 Cypher 查询验证连接。 + + Returns: + bool: 连接正常返回 True,否则 False + """ + if self._driver is None: + return False + try: + result = await self.execute_read_query("RETURN 1 AS ok") + records = [record async for record in result] + return len(records) > 0 and records[0].get("ok") == 1 + except Exception as e: + logger.warning(f"Neo4j 健康检查失败: {e}") + return False + + async def _init_schema(self) -> None: + """初始化图 schema:创建约束和索引。 + + 所有约束和索引使用 IF NOT EXISTS,幂等安全。 + """ + for cypher in self._SCHEMA_CONSTRAINTS: + try: + await self.execute_write_query(cypher) + logger.debug(f"图约束已创建: {cypher[:60]}...") + except Neo4jError as e: + logger.warning(f"图约束创建失败(可能已存在): {e}") + + for cypher in self._SCHEMA_INDEXES: + try: + await self.execute_write_query(cypher) + logger.debug(f"图索引已创建: {cypher[:60]}...") + except Neo4jError as e: + logger.warning(f"图索引创建失败(可能已存在): {e}") + + logger.info("图 schema 初始化完成(约束 + 索引)") + + # -------------------------------------------------------------------------- + # 通用查询方法 + # -------------------------------------------------------------------------- + + async def execute_write_query( + self, cypher: str, params: Optional[Dict[str, Any]] = None + ): + """执行写事务(CREATE/MERGE/DELETE/SET)。 + + 使用 execute_write 自动管理写事务,支持重试策略。 + + Args: + cypher: Cypher 查询语句 + params: 查询参数(可选) + + Returns: + 查询结果(EagerResult) + + Raises: + RuntimeError: 客户端未初始化 + Neo4jError: 查询执行失败 + """ + if self._driver is None: + raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()") + + async def _write(tx): + result = await tx.run(cypher, parameters=params or {}) + return await result.data() + + async with self._driver.session(database=self.database) as session: + return await session.execute_write(_write) + + async def execute_read_query( + self, cypher: str, params: Optional[Dict[str, Any]] = None + ): + """执行读事务(MATCH/RETURN)。 + + 使用 execute_read 自动管理读事务。 + + Args: + cypher: Cypher 查询语句 + params: 查询参数(可选) + + Returns: + 查询结果(EagerResult) + + Raises: + RuntimeError: 客户端未初始化 + Neo4jError: 查询执行失败 + """ + if self._driver is None: + raise RuntimeError("Neo4jClient 未初始化,请先调用 initialize()") + + async def _read(tx): + result = await tx.run(cypher, parameters=params or {}) + return await result.data() + + async with self._driver.session(database=self.database) as session: + return await session.execute_read(_read) + + # -------------------------------------------------------------------------- + # 图节点 CRUD — IssueNode + # -------------------------------------------------------------------------- + + async def create_issue_node(self, issue: IssueNode) -> IssueNode: + """创建 Issue 节点(CREATE,非幂等)。 + + Args: + issue: IssueNode 实例 + + Returns: + IssueNode: 创建后的 IssueNode(含 Neo4j 分配的 uuid) + """ + props = { + "name": issue.name, + "category": issue.category, + "created_at": (issue.created_at or datetime.now(timezone.utc)).isoformat(), + "updated_at": (issue.updated_at or datetime.now(timezone.utc)).isoformat(), + "source_suggestion_id": issue.source_suggestion_id, + } + data = await self.execute_write_query( + """ + CREATE (i:Issue) + SET i = $props + RETURN i.uuid AS uuid, i.name AS name, i.category AS category, + i.created_at AS created_at, i.updated_at AS updated_at, + i.source_suggestion_id AS source_suggestion_id + """, + params={"props": props}, + ) + record = data[0] + return IssueNode( + uuid=record["uuid"], + name=record["name"], + category=record["category"], + created_at=record["created_at"], + updated_at=record["updated_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + + async def merge_issue( + self, name: str, category: str, props: Optional[Dict[str, Any]] = None + ) -> IssueNode: + """幂等创建或获取 Issue 节点(MERGE,按 name 匹配)。 + + 使用 MERGE 保证幂等:如果已存在同 name 的 Issue 则返回已有节点, + 否则创建新节点。这是图写入的核心方法,对齐 D1「解读2 合一」的去重策略。 + + Args: + name: Issue 名称(唯一业务键) + category: Issue 分类 + props: 额外属性(可选,创建时设置) + + Returns: + IssueNode: 创建或获取到的 IssueNode + """ + now = datetime.now(timezone.utc).isoformat() + merge_props = { + "category": category, + "updated_at": now, + } + if props: + merge_props.update(props) + merge_props.setdefault("created_at", now) + + data = await self.execute_write_query( + """ + MERGE (i:Issue {name: $name}) + ON CREATE SET i.uuid = randomUUID(), + i += $props, + i.created_at = coalesce($props.created_at, $now) + ON MATCH SET i.category = $category, + i.updated_at = $now + RETURN i.uuid AS uuid, i.name AS name, i.category AS category, + i.created_at AS created_at, i.updated_at AS updated_at, + i.source_suggestion_id AS source_suggestion_id + """, + params={ + "name": name, + "category": category, + "props": merge_props, + "now": now, + }, + ) + record = data[0] + return IssueNode( + uuid=record["uuid"], + name=record["name"], + category=record["category"], + created_at=record["created_at"], + updated_at=record["updated_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + + async def find_issue_by_name(self, name: str) -> Optional[IssueNode]: + """按名称查找 Issue 节点。 + + Args: + name: Issue 名称 + + Returns: + Optional[IssueNode]: 找到的 IssueNode,未找到返回 None + """ + data = await self.execute_read_query( + """ + MATCH (i:Issue {name: $name}) + RETURN i.uuid AS uuid, i.name AS name, i.category AS category, + i.created_at AS created_at, i.updated_at AS updated_at, + i.source_suggestion_id AS source_suggestion_id + LIMIT 1 + """, + params={"name": name}, + ) + if not data: + return None + record = data[0] + return IssueNode( + uuid=record["uuid"], + name=record["name"], + category=record["category"], + created_at=record["created_at"], + updated_at=record["updated_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + + async def find_related_issues( + self, uuid: str, rel_type: Optional[str] = None + ) -> List[IssueNode]: + """查找与指定 Issue 节点有关联的其他 Issue 节点。 + + Args: + uuid: 源 Issue 节点的 uuid + rel_type: 关系类型过滤(可选,如 "LEADS_TO"/"RELATES_TO") + + Returns: + List[IssueNode]: 关联的 IssueNode 列表 + """ + rel_filter = f":{rel_type}" if rel_type else "" + data = await self.execute_read_query( + f""" + MATCH (i:Issue {{uuid: $uuid}})-[{rel_filter}]->(related:Issue) + RETURN related.uuid AS uuid, related.name AS name, + related.category AS category, + related.created_at AS created_at, + related.updated_at AS updated_at, + related.source_suggestion_id AS source_suggestion_id + """, + params={"uuid": uuid}, + ) + return [ + IssueNode( + uuid=record["uuid"], + name=record["name"], + category=record["category"], + created_at=record["created_at"], + updated_at=record["updated_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + for record in data + ] + + # -------------------------------------------------------------------------- + # 图节点 CRUD — ActionNode + # -------------------------------------------------------------------------- + + async def create_action_node(self, action: ActionNode) -> ActionNode: + """创建 Action 节点(CREATE,非幂等)。 + + Args: + action: ActionNode 实例 + + Returns: + ActionNode: 创建后的 ActionNode(含 Neo4j 分配的 uuid) + """ + props = { + "name": action.name, + "description": action.description, + "created_at": (action.created_at or datetime.now(timezone.utc)).isoformat(), + "source_suggestion_id": action.source_suggestion_id, + } + data = await self.execute_write_query( + """ + CREATE (a:Action) + SET a = $props + RETURN a.uuid AS uuid, a.name AS name, + a.description AS description, a.created_at AS created_at, + a.source_suggestion_id AS source_suggestion_id + """, + params={"props": props}, + ) + record = data[0] + return ActionNode( + uuid=record["uuid"], + name=record["name"], + description=record.get("description", ""), + created_at=record["created_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + + async def merge_action( + self, name: str, props: Optional[Dict[str, Any]] = None + ) -> ActionNode: + """幂等创建或获取 Action 节点(MERGE,按 name 匹配)。 + + Args: + name: Action 名称(唯一业务键) + props: 额外属性(可选) + + Returns: + ActionNode: 创建或获取到的 ActionNode + """ + now = datetime.now(timezone.utc).isoformat() + merge_props: Dict[str, Any] = {} + if props: + merge_props.update(props) + merge_props.setdefault("created_at", now) + + data = await self.execute_write_query( + """ + MERGE (a:Action {name: $name}) + ON CREATE SET a.uuid = randomUUID(), + a += $props, + a.created_at = coalesce($props.created_at, $now) + ON MATCH SET a.description = coalesce($props.description, a.description) + RETURN a.uuid AS uuid, a.name AS name, + a.description AS description, a.created_at AS created_at, + a.source_suggestion_id AS source_suggestion_id + """, + params={ + "name": name, + "props": merge_props, + "now": now, + }, + ) + record = data[0] + return ActionNode( + uuid=record["uuid"], + name=record["name"], + description=record.get("description", ""), + created_at=record["created_at"], + source_suggestion_id=record.get("source_suggestion_id"), + ) + + # -------------------------------------------------------------------------- + # 图关系 CRUD — RelationEdge + # -------------------------------------------------------------------------- + + async def create_relation( + self, from_uuid: str, to_uuid: str, rel: RelationEdge + ) -> bool: + """创建两个节点之间的关系。 + + 支持 Issue→Issue、Issue→Action 的关系创建。 + 使用 MATCH+CREATE 模式确保节点存在后才建关系。 + + Args: + from_uuid: 起始节点 uuid + to_uuid: 目标节点 uuid + rel: 关系定义(含 type, order, weight) + + Returns: + bool: 创建成功返回 True + """ + rel_type = rel.type.value if hasattr(rel.type, 'value') else str(rel.type) + # Neo4j 不支持动态关系类型参数化,使用 f-string(仅 rel_type 来自枚举,安全) + cypher = ( + f"MATCH (from_node), (to_node) " + f"WHERE from_node.uuid = $from_uuid AND to_node.uuid = $to_uuid " + f"CREATE (from_node)-[:{rel_type} {{order: $order, weight: $weight}}]->(to_node) " + f"RETURN count(*) AS created" + ) + data = await self.execute_write_query( + cypher, + params={ + "from_uuid": from_uuid, + "to_uuid": to_uuid, + "order": rel.order, + "weight": rel.weight, + }, + ) + return data[0].get("created", 0) > 0 if data else False + + # -------------------------------------------------------------------------- + # 图查询 — 全图导出(任务2:知识图谱可视化) + # -------------------------------------------------------------------------- + + async def query_full_graph( + self, limit: int = 100 + ) -> Dict[str, Any]: + """查询全图节点和关系,返回 ECharts 力导向图格式的 JSON。 + + 查询所有 Issue/Action 节点及其关系,按创建时间降序排列。 + 用于管理后台知识图谱可视化和坐席审批卡片拓扑预览。 + + Args: + limit: 返回节点数量上限,默认100 + + Returns: + Dict: { + "nodes": [{"id": str, "name": str, "category": str, "label": str, "type": str}, ...], + "links": [{"source": str, "target": str, "type": str, "weight": float}, ...], + } + """ + nodes: List[Dict[str, Any]] = [] + links: List[Dict[str, Any]] = [] + + try: + # 查询所有 Issue 节点 + issue_data = await self.execute_read_query( + """ + MATCH (i:Issue) + RETURN i.uuid AS id, i.name AS name, i.category AS category, + 'issue' AS node_type + ORDER BY i.created_at DESC + LIMIT $limit + """, + params={"limit": limit}, + ) + for row in issue_data: + nodes.append({ + "id": row["id"], + "name": row["name"], + "category": row.get("category", "其他"), + "label": row["name"], + "type": row["node_type"], + }) + + # 查询所有 Action 节点 + action_data = await self.execute_read_query( + """ + MATCH (a:Action) + RETURN a.uuid AS id, a.name AS name, a.description AS description, + 'action' AS node_type + ORDER BY a.created_at DESC + LIMIT $limit + """, + params={"limit": limit}, + ) + for row in action_data: + nodes.append({ + "id": row["id"], + "name": row["name"], + "category": row.get("description", ""), + "label": row["name"], + "type": row["node_type"], + }) + + # 查询所有关系(任意节点之间的有向边) + rel_data = await self.execute_read_query( + """ + MATCH (n)-[r]->(m) + WHERE (n:Issue OR n:Action) AND (m:Issue OR m:Action) + RETURN n.uuid AS source, m.uuid AS target, + type(r) AS rel_type, + coalesce(r.weight, 1.0) AS weight + LIMIT $limit + """, + params={"limit": limit * 3}, + ) + for row in rel_data: + links.append({ + "source": row["source"], + "target": row["target"], + "type": row["rel_type"], + "weight": float(row.get("weight", 1.0)), + }) + + logger.info( + f"全图查询完成: nodes={len(nodes)}, links={len(links)}" + ) + + except Exception as e: + logger.error(f"全图查询失败: {e}") + + return {"nodes": nodes, "links": links} + + async def query_issue_subgraph( + self, issue_name: str, depth: int = 1 + ) -> Dict[str, Any]: + """查询指定 Issue 的子图(用于审批卡片拓扑预览)。 + + 以指定 Issue 为中心,向外扩展 depth 层关系。 + + Args: + issue_name: Issue 名称 + depth: 扩展层数,默认1层 + + Returns: + Dict: {"nodes": [...], "links": [...]} + """ + nodes: List[Dict[str, Any]] = [] + links: List[Dict[str, Any]] = [] + seen: set = set() + + try: + subgraph_data = await self.execute_read_query( + """ + MATCH path = (center:Issue {name: $name})-[*0..%d]-(neighbor) + WHERE neighbor:Issue OR neighbor:Action + WITH nodes(path) AS ns, relationships(path) AS rs + UNWIND ns AS n + WITH DISTINCT n + RETURN n.uuid AS id, labels(n)[0] AS node_type, + coalesce(n.name, n.description, '') AS name, + coalesce(n.category, n.description, '') AS category + LIMIT 30 + """ % depth, + params={"name": issue_name}, + ) + for row in subgraph_data: + nid = row["id"] + if nid not in seen: + seen.add(nid) + nodes.append({ + "id": nid, + "name": row["name"], + "category": row.get("category", ""), + "label": row["name"], + "type": row["node_type"].lower() if row["node_type"] else "issue", + }) + + # 查询子图内的关系 + rel_data = await self.execute_read_query( + """ + MATCH (center:Issue {name: $name})-[r*1..%d]-(neighbor) + UNWIND r AS rel + WITH DISTINCT rel + MATCH (n)-[rel]->(m) + RETURN startNode(rel).uuid AS source, + endNode(rel).uuid AS target, + type(rel) AS rel_type, + coalesce(rel.weight, 1.0) AS weight + LIMIT 30 + """ % depth, + params={"name": issue_name}, + ) + for row in rel_data: + links.append({ + "source": row["source"], + "target": row["target"], + "type": row["rel_type"], + "weight": float(row.get("weight", 1.0)), + }) + + except Exception as e: + logger.warning(f"子图查询失败 (issue={issue_name}): {e}") + + return {"nodes": nodes, "links": links} + + +# ============================================================================= +# 依赖注入函数 +# ============================================================================= + +# 全局 Neo4jClient 单例(模块级懒加载) +_neo4j_client: Optional[Neo4jClient] = None + + +async def dep_neo4j_client() -> Neo4jClient: + """获取 Neo4jClient 单例实例(FastAPI 依赖注入)。 + + 首次调用时自动初始化连接池和图 schema。 + 应用关闭时需调用 close() 释放资源(见 main.py 生命周期)。 + + Returns: + Neo4jClient: 已初始化的 Neo4j 客户端实例 + + Raises: + ServiceUnavailable: Neo4j 服务不可达 + """ + global _neo4j_client + if _neo4j_client is None: + _neo4j_client = Neo4jClient() + await _neo4j_client.initialize() + elif not await _neo4j_client.health_check(): + # 连接断开后重新初始化 + logger.warning("Neo4j 连接已断开,尝试重新初始化") + await _neo4j_client.close() + _neo4j_client = Neo4jClient() + await _neo4j_client.initialize() + return _neo4j_client + + +async def get_neo4j_client() -> Optional[Neo4jClient]: + """获取 Neo4jClient(安全版本,不抛异常)。 + + Neo4j 不可用时返回 None,由调用方做降级处理。 + 用于非 DI 场景(如 service 层直接调用)。 + + Returns: + Optional[Neo4jClient]: Neo4j 客户端实例,不可用时返回 None + """ + global _neo4j_client + try: + if _neo4j_client is None: + _neo4j_client = Neo4jClient() + await _neo4j_client.initialize() + return _neo4j_client + except Exception as e: + logger.warning(f"Neo4j 客户端初始化失败(图功能将不可用): {e}") + return None diff --git a/backend/app/services/ragflow_ingestion_service.py b/backend/app/services/ragflow_ingestion_service.py new file mode 100644 index 0000000..2f41f21 --- /dev/null +++ b/backend/app/services/ragflow_ingestion_service.py @@ -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 [] diff --git a/backend/app/services/vision_service.py b/backend/app/services/vision_service.py new file mode 100644 index 0000000..6f28ddf --- /dev/null +++ b/backend/app/services/vision_service.py @@ -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 diff --git a/backend/app/services/wingman_service.py b/backend/app/services/wingman_service.py index 35bc97b..691bb2f 100644 --- a/backend/app/services/wingman_service.py +++ b/backend/app/services/wingman_service.py @@ -54,6 +54,34 @@ class WingmanService: "输出格式:{\"suggested_tags\": [\"标签1\", \"标签2\"], \"category\": \"分类\", \"priority\": \"low/medium/high\"}" ) + # -------------------------------------------------------------------------- + # 知识建议生成专用 Prompt(Tier0 / T03 — 通道 A/B 复用) + # -------------------------------------------------------------------------- + _KNOWLEDGE_SUGGESTION_PROMPT: str = ( + "你是一个IT知识库优化助手,基于对话上下文分析知识库的不足," + "生成结构化的知识库优化建议。\n\n" + "分析以下对话,判断是否需要新增FAQ或更新已有知识条目。\n" + "如果AI回复被标记为无用,生成更新建议;如果AI无法解决需转人工,生成新增FAQ建议。\n\n" + "必须以JSON格式输出,包含以下字段:\n" + "- suggestion_type: 建议类型,\"new_faq\" 或 \"update\"\n" + "- title: 问题标题(简洁明了)\n" + "- content: 答案内容(分步骤、可操作)\n" + "- category: 分类,只能是 硬件/软件/网络/安全/账号/其他 之一\n" + "- tags: 标签列表,如 [\"VPN\", \"连接\"]\n" + "- confidence: 你对这个建议的信心度,0.0-1.0之间\n" + "- issue: 对应的Neo4j图问题节点名称,如\"VPN问题\"\n" + "- action: 对应的Neo4j图动作节点名称,如\"VPN连接修复\"\n" + "- relation_type: 图关系类型,\"LEADS_TO\" 或 \"RELATES_TO\"\n" + "- parent_issue: 父问题名称(如果没有则为空字符串)\n\n" + "输出格式示例:\n" + "{\"suggestion_type\": \"new_faq\", \"title\": \"VPN连不上怎么办\", " + "\"content\": \"1.检查网络连接 2.重启VPN客户端 3.联系IT支持\", " + "\"category\": \"网络\", \"tags\": [\"VPN\", \"连接\"], " + "\"confidence\": 0.86, \"issue\": \"VPN问题\", " + "\"action\": \"VPN连接修复\", \"relation_type\": \"LEADS_TO\", " + "\"parent_issue\": \"网络问题\"}" + ) + def __init__(self): """初始化 Wingman 服务。 @@ -264,6 +292,101 @@ class WingmanService: logger.error(f"Wingman 标签建议失败: {e}") return default_tags + # -------------------------------------------------------------------------- + # 核心方法 4:生成知识库优化建议(Tier0 / T03 — 复用现有范式) + # -------------------------------------------------------------------------- + async def generate_knowledge_suggestion( + self, + context_messages: List[Dict[str, Any]], + ) -> Dict[str, Any]: + """生成知识库优化建议(用于知识库自动迭代)。 + + 传入对话上下文,让 Wingman Agent 分析并生成结构化的知识库优化建议。 + 复用 _build_context_messages + _call_wingman_api + _parse_json_response 范式。 + + Args: + context_messages: 对话消息历史列表 + + Returns: + Dict: { + "suggestion_type": str, # "new_faq" / "update" + "title": str, # 问题标题 + "content": str, # 答案内容 + "category": str, # 分类 + "tags": list[str], # 标签列表 + "confidence": float, # 置信度(0.0-1.0) + "issue": str, # 图节点:问题名称 + "action": str, # 图节点:动作名称 + "relation_type": str, # 图关系类型 + "parent_issue": str, # 父 Issue 名称 + } + """ + # 构建对话上下文消息列表(使用知识建议专用 Prompt) + context = self._build_context_messages( + context_messages, self._KNOWLEDGE_SUGGESTION_PROMPT + ) + + # 默认建议(降级时使用) + default_suggestion: Dict[str, Any] = { + "suggestion_type": "new_faq", + "title": "", + "content": "", + "category": "其他", + "tags": [], + "confidence": 0.0, + "issue": "", + "action": "", + "relation_type": "LEADS_TO", + "parent_issue": "", + } + + try: + result = await self._call_wingman_api(context) + + if result is None: + logger.warning("Wingman 知识建议生成失败(API 返回 None)") + return default_suggestion + + # 尝试解析 JSON 格式的建议 + parsed = self._parse_json_response(result, default_suggestion) + + # 规范化字段 + suggestion: Dict[str, Any] = { + "suggestion_type": parsed.get( + "suggestion_type", default_suggestion["suggestion_type"] + ), + "title": parsed.get("title", default_suggestion["title"]), + "content": parsed.get("content", default_suggestion["content"]), + "category": parsed.get("category", default_suggestion["category"]), + "tags": parsed.get("tags", default_suggestion["tags"]), + "confidence": float(parsed.get("confidence", 0.0)), + "issue": parsed.get("issue", default_suggestion["issue"]), + "action": parsed.get("action", default_suggestion["action"]), + "relation_type": parsed.get( + "relation_type", default_suggestion["relation_type"] + ), + "parent_issue": parsed.get( + "parent_issue", default_suggestion["parent_issue"] + ), + } + + # 如果 Dify 未返回 confidence,使用启发式估算 + if suggestion["confidence"] == 0.0: + suggestion["confidence"] = self._estimate_confidence( + suggestion["content"] + ) + + logger.info( + f"知识建议生成完成: type={suggestion['suggestion_type']}, " + f"title={suggestion['title'][:50]}, " + f"confidence={suggestion['confidence']}" + ) + return suggestion + + except Exception as e: + logger.error(f"Wingman 知识建议生成异常: {e}") + return default_suggestion + # -------------------------------------------------------------------------- # 内部方法 # -------------------------------------------------------------------------- diff --git a/backend/app/tasks/h5_ai_task.py b/backend/app/tasks/h5_ai_task.py new file mode 100644 index 0000000..1e661de --- /dev/null +++ b/backend/app/tasks/h5_ai_task.py @@ -0,0 +1,206 @@ +# ============================================================================= +# 企微IT智能服务台 — H5 员工端 AI 回复后台任务 +# ============================================================================= +# 背景:原 h5_send_message 在同步 HTTP 请求内 await AI 推理(Dify 3~15s), +# 整条请求被阻塞,前端表现为"发送中"长时间卡顿。 +# 本模块将 AI 推理移出请求,改为 asyncio 后台任务,结果经 WebSocket +# 流式推回(ai_reply_chunk / ai_reply),发送瞬时完成。 +# +# 关键约束(详见 docs/02-需求分析/技术架构演进/员工端消息发送延时改造方案.md): +# 1. 必须单 worker 运行(docker-compose --workers 1): +# ws_manager 是进程内单例,多 worker 时后台任务与员工 WS 连接可能不在 +# 同进程,broadcast 会静默丢失(约 50%)。 +# 2. 使用独立 DB session(_get_session_factory),不可复用请求的 db +# (请求返回后该 session 会被关闭)。 +# ============================================================================= + +import logging +from datetime import datetime + +from app.database import _get_session_factory +from app.dependencies import get_shared_ai_handler +from app.models.conversation import Conversation +from app.models.message import Message +from app.services.ws_manager import manager as ws_manager + +logger = logging.getLogger(__name__) + + +async def _persist_and_push( + db, + conversation: Conversation, + employee_id: str, + content: str, + is_guidance: bool, + should_count: bool, + should_transfer: bool, + dify_conversation_id, +): + """持久化 AI 回复并推送给员工端 + 广播坐席端。 + + 做什么: + 1. 存 AI 消息到 DB + 2. 更新会话状态(dify 上下文 / 计数 / 转人工) + 3. 经 WS 向员工推 ai_reply 终态(前端据此替换打字机气泡) + 4. 经 WS 向坐席端广播 new_message + conversation_updated + 为什么:把"落库 + 推送"封装为单点,供同步路径与流式路径复用。 + """ + # 1. 存 AI 消息 + ai_message = Message( + conversation_id=conversation.id, + sender_type="ai", + sender_id="ai_bot", + sender_name="Duckula(达寇拉)", + content=content, + msg_type="text", + is_read=True, + ) + db.add(ai_message) + await db.flush() + + # 2. 更新会话状态 + if dify_conversation_id: + conversation.dify_conversation_id = dify_conversation_id + if should_count: + conversation.ai_substantive_reply_count += 1 + if should_transfer: + conversation.status = "queued" + conversation.updated_at = datetime.now() + db.add(conversation) + await db.flush() + await db.commit() + + # 3. 推 ai_reply 终态给员工(前端替换打字机气泡) + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_reply", + "data": { + "message_id": str(ai_message.id), + "conversation_id": str(conversation.id), + "sender_type": "ai", + "sender_id": "ai_bot", + "sender_name": "Duckula(达寇拉)", + "content": content, + "msg_type": "text", + "is_guidance": is_guidance, + "ai_reply_count": conversation.ai_substantive_reply_count, + "can_call_agent": conversation.ai_substantive_reply_count >= 3, + "conversation_status": conversation.status, + }, + }) + + # 4. 广播坐席端(new_message + conversation_updated) + try: + await ws_manager.broadcast({ + "type": "new_message", + "data": { + "conversation_id": str(conversation.id), + "message_id": str(ai_message.id), + "sender_type": "ai", + "sender_id": "ai_bot", + "sender_name": "Duckula(达寇拉)", + "content": content, + "msg_type": "text", + }, + }) + await ws_manager.broadcast({ + "type": "conversation_updated", + "data": { + "conversation_id": str(conversation.id), + "status": conversation.status, + "assigned_agent_id": str(conversation.assigned_agent_id) if conversation.assigned_agent_id else None, + }, + }) + except Exception as ws_err: + # WS 广播失败不阻塞消息存储,只记录 warning + logger.warning(f"WS 广播 AI 回复给坐席失败(消息已存储): {ws_err}") + + +async def process_h5_ai_reply( + conversation_id: str, + employee_id: str, + content: str, + dify_conversation_id=None, +): + """H5 发送消息后的 AI 回复处理(asyncio.create_task 入口)。 + + 流程: + - 本地快判断(打招呼 / 呼叫人工)→ 同步结果,整段推送(不调 Dify) + - 否则流式调 Dify,逐 chunk 推 ai_reply_chunk,流结束推 ai_reply 终态 + - 任意异常 → 推 ai_reply_failed,不阻塞用户 + """ + ai_handler = get_shared_ai_handler() + factory = _get_session_factory() + async with factory() as db: + try: + conversation = await db.get(Conversation, conversation_id) + if not conversation: + logger.warning(f"后台 AI 任务:会话不存在 {conversation_id}") + return + + is_guidance = False + should_count = False + should_transfer = False + new_dify_conv_id = dify_conversation_id + full_parts: list = [] + + # 本地快判断(不打 Dify):打招呼 / 呼叫人工 → 同步路径 + if ai_handler.is_greeting(content) or ai_handler.is_call_human(content): + result = await ai_handler.handle_message( + content=content, + dify_conversation_id=dify_conversation_id, + user_id=employee_id, + ) + await _persist_and_push( + db, conversation, employee_id, result.content, + result.is_guidance, result.should_count, + result.should_transfer, result.dify_conversation_id, + ) + return + + # 流式调 Dify(get_reply_stream 内部已处理真 SSE / 非流式 fallback) + # 注意:首参是 message(用户文本),不是 content + async for chunk in ai_handler.ai_service.get_reply_stream( + message=content, + conversation_id=dify_conversation_id, + user_id=employee_id, + ): + delta = chunk.get("delta", "") + if delta: + full_parts.append(delta) + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_reply_chunk", + "data": { + "conversation_id": conversation_id, + "chunk": delta, + }, + }) + if chunk.get("finished"): + new_dify_conv_id = chunk.get("conversation_id") or dify_conversation_id + hit = chunk.get("hit") + # 命中 → 计数;未命中 → 转人工 + should_count = bool(hit) + should_transfer = not bool(hit) + + content_ai = "".join(full_parts) + if not content_ai: + # 流式无内容(极端情况),给降级提示,不转人工 + content_ai = "⚠️ AI 暂时没有返回内容,请输入「IT」转人工。" + should_count = False + should_transfer = False + await _persist_and_push( + db, conversation, employee_id, content_ai, + is_guidance, should_count, should_transfer, new_dify_conv_id, + ) + except Exception as e: + logger.error(f"后台 AI 任务异常: {e}", exc_info=True) + try: + await ws_manager.broadcast_to_employees([employee_id], { + "type": "ai_reply_failed", + "data": { + "conversation_id": conversation_id, + "message": "⚠️ AI 服务异常,请输入「IT」转人工或稍后重试。", + }, + }) + except Exception: + # 推送失败也无所谓,员工端 3 秒轮询兜底 + pass diff --git a/backend/requirements.txt b/backend/requirements.txt index b7d31bd..fd273cd 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -47,6 +47,8 @@ pydantic-settings==2.3.4 # -------------------------------------------------------------------------- # httpx: 异步 HTTP 客户端,用于调用企微 API(替代同步的 requests) httpx==0.27.0 +# tenacity: 外部调用指数退避重试(自动化引擎 BaseClient 使用,可选降级) +tenacity>=8.2.0 # -------------------------------------------------------------------------- # 加密 @@ -89,3 +91,17 @@ pillow==10.4.0 # -------------------------------------------------------------------------- # psutil: 系统监控(用于 /metrics 端点) psutil==5.9.8 + +# -------------------------------------------------------------------------- +# 图数据库(知识图谱) +# -------------------------------------------------------------------------- +# neo4j: Neo4j 官方异步驱动(bolt 协议,支持 async/await,与 FastAPI 生态匹配) +neo4j>=5.26.0 + +# -------------------------------------------------------------------------- +# 测试(仅测试环境) +# -------------------------------------------------------------------------- +# testcontainers: Neo4j 测试容器(CI 本地模拟 Neo4j 图数据库) +testcontainers[neo4j]>=4.0.0 +# pytest-asyncio: 异步测试 fixture(已有,显式声明最低版本) +pytest-asyncio>=0.23.0