Files
wecom_it_smart_desk/backend/app/services/vision_service.py
T
Simon ead5f83bee feat(backend): knowledge iteration + vision + neo4j + response contract source
dependencies.py 拆分为 dependencies/ 包; 新增 vision/ragflow_ingestion/neo4j 客户端与 h5_ai_task; alembic 045 图置信度迁移; 响应契约统一收尾。
2026-07-09 11:47:16 +08:00

293 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微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