瀏覽代碼

优化AI写作

zkn 2 天之前
父節點
當前提交
284cf651a9

+ 63 - 48
shudao-chat-py/routers/chat.py

@@ -246,8 +246,9 @@ async def send_deepseek_message(
                 except Exception:
                     response_text = qwen_response
             except Exception as e:
-                logger.error(f"[send_deepseek_message] AI问答异常: {e}")
-                response_text = f"处理失败: {str(e)}"
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] AI问答异常: {type(e).__name__}: {error_detail}")
+                response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 1:
             # PPT大纲生成
@@ -267,8 +268,9 @@ async def send_deepseek_message(
 
                 response_text = await qwen_service.chat(messages)
             except Exception as e:
-                logger.error(f"[send_deepseek_message] PPT大纲生成异常: {e}")
-                response_text = f"处理失败: {str(e)}"
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] PPT大纲生成异常: {type(e).__name__}: {error_detail}")
+                response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 2:
             # AI写作
@@ -288,8 +290,9 @@ async def send_deepseek_message(
 
                 response_text = await qwen_service.chat(messages)
             except Exception as e:
-                logger.error(f"[send_deepseek_message] AI写作异常: {e}")
-                response_text = f"处理失败: {str(e)}"
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] AI写作异常: {type(e).__name__}: {error_detail}")
+                response_text = f"处理失败: {error_detail}"
 
         elif data.business_type == 3:
             # 考试工坊:生成题目
@@ -352,8 +355,9 @@ async def send_deepseek_message(
                     )
                     db.commit()
             except Exception as e:
-                logger.error(f"[send_deepseek_message] 考试工坊异常: {e}")
-                response_text = f"处理失败: {str(e)}"
+                error_detail = str(e).strip() if str(e).strip() else f"未知错误({type(e).__name__})"
+                logger.error(f"[send_deepseek_message] 考试工坊异常: {type(e).__name__}: {error_detail}")
+                response_text = f"处理失败: {error_detail}"
 
         else:
             return {"statusCode": 400, "msg": f"不支持的业务类型: {data.business_type}"}
@@ -727,50 +731,61 @@ async def stream_chat_with_db(request: Request, data: StreamChatWithDBRequest):
             # 4. 发送 initial 事件
             yield f"data: {json.dumps({'type': 'initial', 'ai_conversation_id': conv_id, 'ai_message_id': ai_msg.id}, ensure_ascii=False)}\n\n"
 
-            # 5. RAG 检索
+            # 5. RAG search
             rag_context = await _rag_search(message, top_k=10)
 
-            # 6. 获取历史上下文(最近 4 条,2 轮对话)
-            history_msgs = (
-                db.query(AIMessage)
-                .filter(
-                    AIMessage.ai_conversation_id == conv_id,
-                    AIMessage.id < ai_msg.id,
-                    AIMessage.is_deleted == 0,
+            if data.business_type in (1, 2):
+                # PPT outline / AI writing: use dedicated prompt
+                prompt_name = "ppt_outline" if data.business_type == 1 else "document_writing"
+                system_content = load_prompt(
+                    prompt_name,
+                    userMessage=message,
+                    contextJSON=rag_context if rag_context else "?????????"
                 )
-                .order_by(AIMessage.updated_at.desc())
-                .limit(4)
-                .all()
-            )
-            history_msgs.reverse()
-
-            history_context = ""
-            for msg in history_msgs:
-                role = "用户" if msg.type == "user" else "助手"
-                history_context += f"{role}: {msg.content}\n\n"
-
-            # 7. 构建完整 prompt
-            # 构建上下文JSON
-            context_parts = []
-            if rag_context:
-                context_parts.append(f"知识库内容:\n{rag_context}")
-            if data.online_search_content:
-                context_parts.append(f"联网搜索结果:\n{data.online_search_content}")
-
-            context_json = "\n\n".join(
-                context_parts) if context_parts else "暂无相关知识库内容"
-
-            # 使用prompt加载器加载最终回答prompt
-            system_content = load_prompt(
-                "final_answer",
-                userMessage=message,
-                contextJSON=context_json,
-                historyContext=history_context if history_context else ""
-            )
 
-            messages = [
-                {"role": "user", "content": system_content},
-            ]
+                messages = [
+                    {"role": "user", "content": system_content},
+                ]
+            else:
+                # 6. History context (last 4 items, 2 turns)
+                history_msgs = (
+                    db.query(AIMessage)
+                    .filter(
+                        AIMessage.ai_conversation_id == conv_id,
+                        AIMessage.id < ai_msg.id,
+                        AIMessage.is_deleted == 0,
+                    )
+                    .order_by(AIMessage.updated_at.desc())
+                    .limit(4)
+                    .all()
+                )
+                history_msgs.reverse()
+
+                history_context = ""
+                for msg in history_msgs:
+                    role = "??" if msg.type == "user" else "??"
+                    history_context += f"{role}: {msg.content}\n\n"
+
+                # 7. Build final prompt
+                context_parts = []
+                if rag_context:
+                    context_parts.append(f"??????\n{rag_context}")
+                if data.online_search_content:
+                    context_parts.append(f"???????\n{data.online_search_content}")
+
+                context_json = "\n\n".join(
+                    context_parts) if context_parts else "?????????"
+
+                system_content = load_prompt(
+                    "final_answer",
+                    userMessage=message,
+                    contextJSON=context_json,
+                    historyContext=history_context if history_context else ""
+                )
+
+                messages = [
+                    {"role": "user", "content": system_content},
+                ]
 
             # 8. 流式输出并收集完整回复
             full_response = ""

+ 44 - 37
shudao-chat-py/routers/file.py

@@ -1,14 +1,10 @@
-from fastapi import APIRouter, Depends, Request, UploadFile, File
-from fastapi.responses import FileResponse
-from sqlalchemy.orm import Session
-from pydantic import BaseModel
 from typing import Optional
-from database import get_db
-from models.total import PolicyFile
-from services.oss_service import oss_service
-import time
 import json
-import os
+
+from fastapi import APIRouter, File, Request, UploadFile
+from pydantic import BaseModel
+
+from services.oss_service import oss_service
 
 router = APIRouter()
 
@@ -16,20 +12,20 @@ router = APIRouter()
 @router.post("/oss/upload")
 async def upload(
     request: Request,
-    file: UploadFile = File(...)
+    file: UploadFile = File(...),
 ):
-    """OSS上传 - 对齐Go版本函数名"""
+    """Upload a generic file to OSS."""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
         content = await file.read()
         file_url = oss_service.upload_file(content, file.filename)
         return {
             "statusCode": 200,
             "msg": "上传成功",
-            "data": {"file_url": file_url}
+            "data": {"file_url": file_url},
         }
     except Exception as e:
         return {"statusCode": 500, "msg": f"上传失败: {str(e)}"}
@@ -38,20 +34,38 @@ async def upload(
 @router.post("/oss/shudao/upload_image")
 async def upload_image(
     request: Request,
-    file: UploadFile = File(...)
+    file: Optional[UploadFile] = File(None),
+    image: Optional[UploadFile] = File(None),
 ):
-    """上传图片"""
+    """Upload an image to OSS.
+
+    Supports both the current `file` form field and the legacy `image` field
+    used by the existing frontend.
+    """
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
-        content = await file.read()
-        file_url = oss_service.upload_image(content, file.filename)
+        upload_file = image or file
+        if not upload_file:
+            return {"statusCode": 422, "msg": "缺少图片文件"}
+
+        content = await upload_file.read()
+        file_url = oss_service.upload_image(content, upload_file.filename)
         return {
             "statusCode": 200,
             "msg": "上传成功",
-            "data": {"image_url": file_url}
+            "fileUrl": file_url,
+            "fileURL": file_url,
+            "fileName": upload_file.filename,
+            "fileSize": len(content),
+            "data": {
+                "image_url": file_url,
+                "file_url": file_url,
+                "file_name": upload_file.filename,
+                "file_size": len(content),
+            },
         }
     except Exception as e:
         return {"statusCode": 500, "msg": f"上传失败: {str(e)}"}
@@ -65,20 +79,20 @@ class UploadJsonRequest(BaseModel):
 @router.post("/oss/shudao/upload_json")
 async def upload_ppt_json(
     request: Request,
-    data: UploadJsonRequest
+    data: UploadJsonRequest,
 ):
-    """上传JSON文件 - 对齐Go版本函数名"""
+    """Upload JSON content to OSS."""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
         json_str = json.dumps(data.content, ensure_ascii=False)
         file_url = oss_service.upload_json(json_str, data.filename)
         return {
             "statusCode": 200,
             "msg": "上传成功",
-            "data": {"file_url": file_url}
+            "data": {"file_url": file_url},
         }
     except Exception as e:
         return {"statusCode": 500, "msg": f"上传失败: {str(e)}"}
@@ -86,17 +100,17 @@ async def upload_ppt_json(
 
 @router.get("/oss/parse")
 async def parse_oss(url: str, request: Request):
-    """OSS解析 - 对齐Go版本函数名"""
+    """Resolve an OSS proxy URL."""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
         decrypted_url = oss_service.parse_url(url)
         return {
             "statusCode": 200,
             "msg": "success",
-            "data": {"url": decrypted_url}
+            "data": {"url": decrypted_url},
         }
     except Exception as e:
         return {"statusCode": 500, "msg": f"解析失败: {str(e)}"}
@@ -105,26 +119,19 @@ async def parse_oss(url: str, request: Request):
 @router.get("/get_file_link")
 async def get_file_link(
     filename: str,
-    request: Request
+    request: Request,
 ):
-    """获取文件链接"""
+    """Get a signed OSS URL by filename."""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
         file_url = oss_service.get_signed_url(filename)
         return {
             "statusCode": 200,
             "msg": "success",
-            "data": {"file_url": file_url}
+            "data": {"file_url": file_url},
         }
     except Exception as e:
         return {"statusCode": 500, "msg": f"获取失败: {str(e)}"}
-
-
-# 以下路由已在 total.py / chat.py 中实现(含完整逻辑),此处不重复定义:
-# - GET  /download_file       → routers/total.py(流式代理下载OSS)
-# - POST /policy_file_count   → routers/total.py(view/download计数,字段 count_type)
-# - POST /save_ppt_outline    → routers/chat.py(更新AIMessage.content)
-# - POST /save_edit_document  → routers/chat.py(更新AIMessage.content)

+ 244 - 145
shudao-chat-py/routers/hazard.py

@@ -1,112 +1,215 @@
 """
-隐患识别路由
+Hazard detection routes.
 """
-from fastapi import APIRouter, Depends, Request, File, UploadFile
-from sqlalchemy.orm import Session
+
+from typing import Any, Dict, List, Optional
+import io
+import json
+import time
+
+import httpx
+from fastapi import APIRouter, Depends, Request
 from pydantic import BaseModel
-from typing import Optional
+from sqlalchemy.orm import Session
+from PIL import Image, ImageDraw, ImageFont
+
 from database import get_db
 from models.scene import RecognitionRecord
-from services.yolo_service import yolo_service
 from services.oss_service import oss_service
-from utils.logger import logger
+from services.yolo_service import yolo_service
 from utils.crypto import decrypt_url
-from PIL import Image, ImageDraw, ImageFont
-import io
-import httpx
-import time
-import math
-import json
+from utils.logger import logger
 
 router = APIRouter()
 
 
 class HazardRequest(BaseModel):
-    """隐患识别请求"""
-    image_url: str
+    """Compatible request model for old and new frontend payloads."""
+
+    image_url: Optional[str] = None
+    image: Optional[str] = None
     scene_type: str = ""
+    scene_name: str = ""
     user_name: str = ""
+    username: str = ""
     user_account: str = ""
+    account: str = ""
+    date: str = ""
 
 
 class SaveStepRequest(BaseModel):
-    """保存步骤请求"""
+    """Save current step for a recognition record."""
+
     record_id: int
     current_step: int
 
 
+SCENE_KEY_ALIASES = {
+    "tunnel": "tunnel",
+    "隧道": "tunnel",
+    "隧道施工": "tunnel",
+    "隧道工程": "tunnel",
+    "simple_supported_bridge": "simple_supported_bridge",
+    "bridge": "simple_supported_bridge",
+    "桥梁": "simple_supported_bridge",
+    "桥梁施工": "simple_supported_bridge",
+    "桥梁工程": "simple_supported_bridge",
+    "gas_station": "gas_station",
+    "加油站": "gas_station",
+    "special_equipment": "special_equipment",
+    "特种设备": "special_equipment",
+    "operate_highway": "operate_highway",
+    "运营高速公路": "operate_highway",
+}
+
+SCENE_DISPLAY_NAMES = {
+    "tunnel": "隧道工程",
+    "simple_supported_bridge": "桥梁工程",
+    "gas_station": "加油站",
+    "special_equipment": "特种设备",
+    "operate_highway": "运营高速公路",
+}
+
+
+def _get_user_code(user: Any) -> str:
+    return (
+        getattr(user, "userCode", None)
+        or getattr(user, "user_code", None)
+        or getattr(user, "account", "")
+    )
+
+
+def _resolve_scene_key(scene_value: str) -> str:
+    if not scene_value:
+        return ""
+    return SCENE_KEY_ALIASES.get(scene_value.strip(), scene_value.strip())
+
+
+def _unique_ordered(items: List[str]) -> List[str]:
+    seen = set()
+    ordered = []
+    for item in items:
+        if not item or item in seen:
+            continue
+        seen.add(item)
+        ordered.append(item)
+    return ordered
+
+
+def _build_frontend_result(hazards: List[Dict[str, Any]]) -> Dict[str, Any]:
+    raw_labels: List[str] = []
+    element_hazards: Dict[str, List[str]] = {}
+    detections: List[Dict[str, Any]] = []
+
+    for hazard in hazards:
+        label = str(hazard.get("label") or "").strip()
+        if not label:
+            continue
+
+        raw_labels.append(label)
+        element_hazards.setdefault(label, [])
+        if label not in element_hazards[label]:
+            element_hazards[label].append(label)
+
+        box = hazard.get("bbox") or hazard.get("box") or []
+        detections.append(
+            {
+                "label": label,
+                "box": box,
+                "bbox": box,
+                "confidence": hazard.get("confidence", 0),
+            }
+        )
+
+    display_labels = _unique_ordered(raw_labels)
+    return {
+        "display_labels": display_labels,
+        "labels": display_labels,
+        "third_scenes": display_labels,
+        "element_hazards": element_hazards,
+        "detections": detections,
+    }
+
+
 @router.post("/hazard")
 async def hazard(
     request: Request,
     data: HazardRequest,
-    db: Session = Depends(get_db)
+    db: Session = Depends(get_db),
 ):
-    """
-    隐患识别接口
-    流程:
-    1. 从 OSS 代理 URL 解密获取真实 URL
-    2. 下载图片到内存
-    3. 调用 YOLO 服务识别
-    4. 绘制边界框 + 水印(用户名/账号/日期)
-    5. 上传结果图片到 OSS
-    6. 插入 RecognitionRecord
-    7. 返回结果
-    """
+    """Run hazard detection and return a frontend-compatible payload."""
+
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
-        # 1. 解密 OSS URL
+        source_image_url = data.image_url or data.image
+        if not source_image_url:
+            return {"statusCode": 422, "msg": "image_url 不能为空"}
+
+        scene_key = _resolve_scene_key(data.scene_type or data.scene_name)
+        user_code = _get_user_code(user)
+        user_name = (
+            data.user_name
+            or data.username
+            or getattr(user, "name", None)
+            or getattr(user, "username", None)
+            or getattr(user, "account", "")
+        )
+        user_account = (
+            data.user_account
+            or data.account
+            or getattr(user, "account", "")
+        )
+
         try:
-            real_image_url = decrypt_url(data.image_url)
-        except:
-            # 如果解密失败,可能是直接的 URL
-            real_image_url = data.image_url
-        
-        # 2. 下载图片到内存
+            real_image_url = decrypt_url(source_image_url)
+        except Exception:
+            real_image_url = source_image_url
+
         async with httpx.AsyncClient(timeout=30.0) as client:
             img_response = await client.get(real_image_url)
             img_response.raise_for_status()
             image_bytes = img_response.content
-        
-        # 3. 调用 YOLO 服务识别
-        # 先上传图片到临时位置,或者传递 URL
-        yolo_result = await yolo_service.detect_hazards(real_image_url, data.scene_type)
-        
-        hazards = yolo_result.get("hazards", [])
+
+        yolo_result = await yolo_service.detect_hazards(real_image_url, scene_key)
+        hazards = yolo_result.get("hazards", []) or []
         hazard_count = len(hazards)
-        
-        # 4. 绘制边界框和水印
+        frontend_result = _build_frontend_result(hazards)
+        current_ts = int(time.time())
+
         result_image_bytes = await _draw_boxes_and_watermark(
             image_bytes,
             hazards,
-            user_name=data.user_name or user.account,
-            user_account=user.account,
+            user_name=user_name,
+            user_account=user_account,
         )
-        
-        # 5. 上传结果图片到 OSS
-        result_filename = f"hazard_detection/{user.userCode}/{int(time.time())}.jpg"
+
+        result_filename = f"hazard_detection/{user_code}/{current_ts}.jpg"
         result_url = await oss_service.upload_bytes(result_image_bytes, result_filename)
-        
-        # 6. 插入 RecognitionRecord
+
+        scene_display_name = SCENE_DISPLAY_NAMES.get(scene_key, scene_key or "隐患提示")
         record = RecognitionRecord(
-            user_id=user.userCode,
-            scene_type=data.scene_type,
-            original_image_url=data.image_url,
+            user_id=user_code,
+            scene_type=scene_key,
+            original_image_url=source_image_url,
             recognition_image_url=result_url,
             hazard_count=hazard_count,
             hazard_details=json.dumps(hazards, ensure_ascii=False),
             current_step=1,
-            created_at=int(time.time()),
-            updated_at=int(time.time()),
-            is_deleted=0
+            title=f"{scene_display_name}隐患提示",
+            description=" ".join(frontend_result["third_scenes"]),
+            labels=",".join(frontend_result["display_labels"]),
+            tag_type=scene_key,
+            created_at=current_ts,
+            updated_at=current_ts,
+            is_deleted=0,
         )
         db.add(record)
         db.commit()
         db.refresh(record)
-        
-        # 7. 返回结果
+
         return {
             "statusCode": 200,
             "msg": "识别成功",
@@ -114,16 +217,25 @@ async def hazard(
                 "record_id": record.id,
                 "hazard_count": hazard_count,
                 "hazards": hazards,
+                "scene_name": scene_key,
+                "annotated_image": result_url,
+                "display_labels": frontend_result["display_labels"],
+                "labels": frontend_result["labels"],
+                "third_scenes": frontend_result["third_scenes"],
+                "element_hazards": frontend_result["element_hazards"],
+                "detections": frontend_result["detections"],
                 "result_image_url": result_url,
-                "original_image_url": data.image_url
-            }
+                "original_image_url": source_image_url,
+            },
         }
-    
+
     except httpx.HTTPError as e:
         logger.error(f"[hazard] 图片下载失败: {e}")
+        db.rollback()
         return {"statusCode": 500, "msg": f"图片下载失败: {str(e)}"}
     except Exception as e:
         logger.error(f"[hazard] 处理异常: {e}")
+        db.rollback()
         return {"statusCode": 500, "msg": f"处理失败: {str(e)}"}
 
 
@@ -131,40 +243,43 @@ async def hazard(
 async def save_step(
     request: Request,
     data: SaveStepRequest,
-    db: Session = Depends(get_db)
+    db: Session = Depends(get_db),
 ):
-    """
-    保存识别步骤
-    更新 RecognitionRecord.current_step
-    """
+    """Update RecognitionRecord.current_step."""
+
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
+
     try:
-        # 更新步骤
-        affected = db.query(RecognitionRecord).filter(
-            RecognitionRecord.id == data.record_id,
-            RecognitionRecord.user_id == user.userCode
-        ).update({
-            "current_step": data.current_step,
-            "updated_at": int(time.time())
-        })
-        
+        affected = (
+            db.query(RecognitionRecord)
+            .filter(
+                RecognitionRecord.id == data.record_id,
+                RecognitionRecord.user_id == _get_user_code(user),
+            )
+            .update(
+                {
+                    "current_step": data.current_step,
+                    "updated_at": int(time.time()),
+                }
+            )
+        )
+
         if affected == 0:
             return {"statusCode": 404, "msg": "记录不存在"}
-        
+
         db.commit()
-        
+
         return {
             "statusCode": 200,
             "msg": "保存成功",
             "data": {
                 "record_id": data.record_id,
-                "current_step": data.current_step
-            }
+                "current_step": data.current_step,
+            },
         }
-    
+
     except Exception as e:
         logger.error(f"[save_step] 异常: {e}")
         db.rollback()
@@ -173,109 +288,93 @@ async def save_step(
 
 async def _draw_boxes_and_watermark(
     image_bytes: bytes,
-    hazards: list,
+    hazards: List[Dict[str, Any]],
     user_name: str,
-    user_account: str
+    user_account: str,
 ) -> bytes:
-    """
-    在图片上绘制边界框和水印(对齐Go版本)
-    
-    功能:
-    1. 绘制检测边界框
-    2. 添加45度角水印(用户名、账号、日期)
-    
-    Args:
-        image_bytes: 原始图片字节
-        hazards: YOLO 检测结果列表,每项包含 bbox, label, confidence
-        user_name: 用户名
-        user_account: 用户账号
-    
-    Returns:
-        处理后的图片字节
-    """
+    """Draw detection boxes and a tiled watermark on the image."""
+
     try:
-        # 打开图片
         image = Image.open(io.BytesIO(image_bytes)).convert("RGBA")
         width, height = image.size
-        
-        # 创建透明图层用于绘制
+
         overlay = Image.new("RGBA", (width, height), (255, 255, 255, 0))
         draw = ImageDraw.Draw(overlay)
-        
-        # 尝试加载字体
+
         try:
-            font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
-            font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
-        except:
+            font = ImageFont.truetype(
+                "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20
+            )
+            font_small = ImageFont.truetype(
+                "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14
+            )
+        except Exception:
             try:
-                # Windows字体路径
                 font = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 20)
                 font_small = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", 14)
-            except:
+            except Exception:
                 font = ImageFont.load_default()
                 font_small = ImageFont.load_default()
-        
-        # 1. 绘制边界框
+
         for hazard in hazards:
-            bbox = hazard.get("bbox", [])
+            bbox = hazard.get("bbox", []) or hazard.get("box", [])
             label = hazard.get("label", "")
             confidence = hazard.get("confidence", 0)
-            
+
             if len(bbox) == 4:
                 x1, y1, x2, y2 = bbox
-                # 绘制矩形框(红色)
                 draw.rectangle([x1, y1, x2, y2], outline=(255, 0, 0, 255), width=3)
-                # 绘制标签
                 text = f"{label} {confidence:.2f}"
-                draw.text((x1, max(0, y1 - 25)), text, fill=(255, 0, 0, 255), font=font)
-        
-        # 2. 添加45度角水印(对齐Go版本)
+                draw.text(
+                    (x1, max(0, y1 - 25)),
+                    text,
+                    fill=(255, 0, 0, 255),
+                    font=font,
+                )
+
         current_date = time.strftime("%Y/%m/%d")
-        watermarks = [user_name, user_account, current_date]
-        
-        # 水印参数
+        watermarks = [user_name or "", user_account or "", current_date]
+        watermarks = [text for text in watermarks if text]
+        if not watermarks:
+            watermarks = [current_date]
+
         text_height_estimate = 50
         text_width_estimate = 150
         angle = 45
-        
-        # 创建水印文本图层
-        watermark_layer = Image.new("RGBA", (width * 2, height * 2), (255, 255, 255, 0))
+
+        watermark_layer = Image.new(
+            "RGBA", (width * 2, height * 2), (255, 255, 255, 0)
+        )
         watermark_draw = ImageDraw.Draw(watermark_layer)
-        
-        # 45度角平铺水印
+
         for y in range(-height, height * 2, text_height_estimate):
             for x in range(-width, width * 2, text_width_estimate):
-                # 计算当前行使用哪个水印文本
                 row_index = int(y / text_height_estimate) % len(watermarks)
-                text = watermarks[row_index]
-                
-                # 使用更深的灰色(对齐Go版本)
                 watermark_draw.text(
                     (x, y),
-                    text,
-                    fill=(128, 128, 128, 60),  # 半透明灰色
-                    font=font_small
+                    watermarks[row_index],
+                    fill=(128, 128, 128, 60),
+                    font=font_small,
                 )
-        
-        # 旋转水印层
-        watermark_layer = watermark_layer.rotate(angle, expand=False, fillcolor=(255, 255, 255, 0))
-        
-        # 裁剪到原始尺寸
+
+        watermark_layer = watermark_layer.rotate(
+            angle, expand=False, fillcolor=(255, 255, 255, 0)
+        )
+
         crop_x = (watermark_layer.width - width) // 2
         crop_y = (watermark_layer.height - height) // 2
-        watermark_layer = watermark_layer.crop((crop_x, crop_y, crop_x + width, crop_y + height))
-        
-        # 合并图层
+        watermark_layer = watermark_layer.crop(
+            (crop_x, crop_y, crop_x + width, crop_y + height)
+        )
+
         image = Image.alpha_composite(image, watermark_layer)
         image = Image.alpha_composite(image, overlay)
-        
-        # 转换为RGB并保存
+
         final_image = image.convert("RGB")
         output = io.BytesIO()
         final_image.save(output, format="JPEG", quality=95)
         return output.getvalue()
-    
+
     except Exception as e:
         logger.error(f"[_draw_boxes_and_watermark] 图片处理失败: {e}")
-        # 如果处理失败,返回原图
         return image_bytes

+ 319 - 184
shudao-chat-py/routers/scene.py

@@ -1,86 +1,211 @@
+import json
+import time
+from typing import Any, Optional
+
 from fastapi import APIRouter, Depends, Request
-from sqlalchemy.orm import Session
 from pydantic import BaseModel
-from typing import Optional
+from sqlalchemy.orm import Session
+
 from database import get_db
-from models.scene import Scene, FirstScene, SecondScene, ThirdScene, RecognitionRecord, SceneTemplate
-import time
+from models.scene import (
+    FirstScene,
+    RecognitionRecord,
+    Scene,
+    SceneTemplate,
+    SecondScene,
+    ThirdScene,
+)
 
 router = APIRouter()
 
 
+def _get_user_code(user: Any) -> str:
+    return (
+        getattr(user, "userCode", None)
+        or getattr(user, "user_code", None)
+        or getattr(user, "account", "")
+    )
+
+
+def _load_hazard_details(record: RecognitionRecord):
+    if not record.hazard_details:
+        return []
+    try:
+        data = json.loads(record.hazard_details)
+        return data if isinstance(data, list) else []
+    except Exception:
+        return []
+
+
+def _split_labels(labels):
+    if not labels:
+        return []
+    if isinstance(labels, list):
+        return [str(item).strip() for item in labels if str(item).strip()]
+    return [
+        item.strip()
+        for item in str(labels).replace(",", ",").split(",")
+        if item.strip()
+    ]
+
+
+def _unique_ordered(items):
+    seen = set()
+    ordered = []
+    for item in items:
+        if not item or item in seen:
+            continue
+        seen.add(item)
+        ordered.append(item)
+    return ordered
+
+
+def _build_record_view(record: RecognitionRecord):
+    hazard_details = _load_hazard_details(record)
+    derived_labels = _unique_ordered(
+        [
+            str(item.get("label") or "").strip()
+            for item in hazard_details
+            if str(item.get("label") or "").strip()
+        ]
+    )
+    display_labels = _split_labels(record.labels) or derived_labels
+
+    if record.description:
+        third_scenes = [item for item in str(record.description).split(" ") if item]
+    else:
+        third_scenes = derived_labels
+
+    detections = [
+        {
+            "label": item.get("label", ""),
+            "box": item.get("bbox") or item.get("box") or [],
+            "bbox": item.get("bbox") or item.get("box") or [],
+            "confidence": item.get("confidence", 0),
+        }
+        for item in hazard_details
+    ]
+
+    return {
+        "id": record.id,
+        "title": record.title or "隐患提示记录",
+        "description": record.description or " ".join(third_scenes),
+        "original_image_url": record.original_image_url,
+        "recognition_image_url": record.recognition_image_url,
+        "labels": record.labels or ",".join(display_labels),
+        "display_labels": display_labels,
+        "third_scenes": third_scenes,
+        "tag_type": record.tag_type or record.scene_type,
+        "scene_type": record.scene_type,
+        "effect_evaluation": record.effect_evaluation,
+        "hazard_details": hazard_details,
+        "detections": detections,
+    }
+
+
+def _resolve_record_id(
+    recognition_id: Optional[int] = None,
+    recognition_record_id: Optional[int] = None,
+):
+    return recognition_id or recognition_record_id
+
+
 @router.get("/get_scene_list")
 async def get_scene_list(db: Session = Depends(get_db)):
-    """获取场景列表"""
     scenes = db.query(Scene).filter(Scene.is_deleted == 0).all()
     return {
         "statusCode": 200,
         "msg": "success",
-        "data": [{"id": s.id, "scene_name": s.scene_name, "scene_en_name": s.scene_en_name} for s in scenes]
+        "data": [
+            {
+                "id": s.id,
+                "scene_name": s.scene_name,
+                "scene_en_name": s.scene_en_name,
+            }
+            for s in scenes
+        ],
     }
 
 
 @router.get("/get_first_scene_list")
 async def get_first_scene_list(scene_id: int, db: Session = Depends(get_db)):
-    """获取一级场景列表"""
-    scenes = db.query(FirstScene).filter(
-        FirstScene.scene_id == scene_id,
-        FirstScene.is_deleted == 0
-    ).all()
+    scenes = (
+        db.query(FirstScene)
+        .filter(FirstScene.scene_id == scene_id, FirstScene.is_deleted == 0)
+        .all()
+    )
     return {
         "statusCode": 200,
         "msg": "success",
-        "data": [{"id": s.id, "first_scene_name": s.first_scene_name} for s in scenes]
+        "data": [{"id": s.id, "first_scene_name": s.first_scene_name} for s in scenes],
     }
 
 
 @router.get("/get_second_scene_list")
-async def get_second_scene_list(first_scene_id: int, db: Session = Depends(get_db)):
-    """获取二级场景列表"""
-    scenes = db.query(SecondScene).filter(
-        SecondScene.first_scene_id == first_scene_id,
-        SecondScene.is_deleted == 0
-    ).all()
+async def get_second_scene_list(
+    first_scene_id: int, db: Session = Depends(get_db)
+):
+    scenes = (
+        db.query(SecondScene)
+        .filter(
+            SecondScene.first_scene_id == first_scene_id,
+            SecondScene.is_deleted == 0,
+        )
+        .all()
+    )
     return {
         "statusCode": 200,
         "msg": "success",
-        "data": [{"id": s.id, "second_scene_name": s.second_scene_name} for s in scenes]
+        "data": [{"id": s.id, "second_scene_name": s.second_scene_name} for s in scenes],
     }
 
 
 @router.get("/get_third_scene_list")
-async def get_third_scene_list(second_scene_id: int, db: Session = Depends(get_db)):
-    """获取三级场景列表"""
-    scenes = db.query(ThirdScene).filter(
-        ThirdScene.second_scene_id == second_scene_id,
-        ThirdScene.is_deleted == 0
-    ).all()
+async def get_third_scene_list(
+    second_scene_id: int, db: Session = Depends(get_db)
+):
+    scenes = (
+        db.query(ThirdScene)
+        .filter(
+            ThirdScene.second_scene_id == second_scene_id,
+            ThirdScene.is_deleted == 0,
+        )
+        .all()
+    )
     return {
         "statusCode": 200,
         "msg": "success",
-        "data": [{
-            "id": s.id,
-            "third_scene_name": s.third_scene_name,
-            "correct_example_image": s.correct_example_image,
-            "wrong_example_image": s.wrong_example_image
-        } for s in scenes]
+        "data": [
+            {
+                "id": s.id,
+                "third_scene_name": s.third_scene_name,
+                "correct_example_image": s.correct_example_image,
+                "wrong_example_image": s.wrong_example_image,
+            }
+            for s in scenes
+        ],
     }
 
 
 @router.get("/get_third_scene_example_image")
-async def get_third_scene_example_image(third_scene_name: str, db: Session = Depends(get_db)):
-    """获取三级场景示例图"""
+async def get_third_scene_example_image(
+    third_scene_name: str, db: Session = Depends(get_db)
+):
     if not third_scene_name:
         return {"statusCode": 400, "msg": "三级场景名称不能为空"}
-    
-    scene = db.query(ThirdScene).filter(
-        ThirdScene.third_scene_name == third_scene_name,
-        ThirdScene.is_deleted == 0
-    ).first()
-    
+
+    scene = (
+        db.query(ThirdScene)
+        .filter(
+            ThirdScene.third_scene_name == third_scene_name,
+            ThirdScene.is_deleted == 0,
+        )
+        .first()
+    )
+
     if not scene:
         return {"statusCode": 404, "msg": "三级场景不存在"}
-    
+
     return {
         "statusCode": 200,
         "msg": "success",
@@ -88,103 +213,122 @@ async def get_third_scene_example_image(third_scene_name: str, db: Session = Dep
             "id": scene.id,
             "third_scene_name": scene.third_scene_name,
             "correct_example_image": scene.correct_example_image,
-            "wrong_example_image": scene.wrong_example_image
-        }
+            "wrong_example_image": scene.wrong_example_image,
+        },
     }
 
 
 @router.get("/get_history_recognition_record")
-async def get_history_recognition_record(request: Request, db: Session = Depends(get_db)):
-    """获取隐患识别历史记录"""
+async def get_history_recognition_record(
+    request: Request, db: Session = Depends(get_db)
+):
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    # 获取所有记录(不限制数量)
-    records = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == user.userCode,
-        RecognitionRecord.is_deleted == 0
-    ).order_by(RecognitionRecord.updated_at.desc()).all()
-    
-    # 计算总数
-    total = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == user.userCode,
-        RecognitionRecord.is_deleted == 0
-    ).count()
-    
+
+    user_code = _get_user_code(user)
+    records = (
+        db.query(RecognitionRecord)
+        .filter(RecognitionRecord.user_id == user_code, RecognitionRecord.is_deleted == 0)
+        .order_by(RecognitionRecord.updated_at.desc())
+        .all()
+    )
+
+    total = (
+        db.query(RecognitionRecord)
+        .filter(RecognitionRecord.user_id == user_code, RecognitionRecord.is_deleted == 0)
+        .count()
+    )
+
     return {
         "statusCode": 200,
         "msg": "success",
-        "data": [{
-            "id": r.id,
-            "title": r.title,
-            "original_image_url": r.original_image_url,
-            "recognition_image_url": r.recognition_image_url,
-            "labels": r.labels,
-            "created_at": r.created_at
-        } for r in records],
-        "total": total
+        "data": [
+            {
+                **_build_record_view(record),
+                "created_at": record.created_at,
+            }
+            for record in records
+        ],
+        "total": total,
     }
 
 
 @router.get("/get_recognition_record_detail")
-async def get_recognition_record_detail(recognition_id: int, db: Session = Depends(get_db)):
-    """获取识别记录详情"""
-    record = db.query(RecognitionRecord).filter(
-        RecognitionRecord.id == recognition_id,
-        RecognitionRecord.is_deleted == 0
-    ).first()
+async def get_recognition_record_detail(
+    recognition_id: Optional[int] = None,
+    recognition_record_id: Optional[int] = None,
+    db: Session = Depends(get_db),
+):
+    record_id = _resolve_record_id(recognition_id, recognition_record_id)
+    if not record_id:
+        return {"statusCode": 422, "msg": "recognition_id 不能为空"}
+
+    record = (
+        db.query(RecognitionRecord)
+        .filter(RecognitionRecord.id == record_id, RecognitionRecord.is_deleted == 0)
+        .first()
+    )
     if not record:
         return {"statusCode": 404, "msg": "记录不存在"}
-    
-    # 将 Description 字符串转换为数组
-    third_scenes = []
-    if record.description:
-        third_scenes = record.description.split(" ")
-    
+
+    record_view = _build_record_view(record)
     return {
         "statusCode": 200,
         "msg": "success",
         "data": {
             "id": record.id,
             "user_id": record.user_id,
-            "title": record.title,
-            "description": record.description,
+            "title": record_view["title"],
+            "description": record_view["description"],
             "original_image_url": record.original_image_url,
             "recognition_image_url": record.recognition_image_url,
-            "labels": record.labels,
-            "third_scenes": third_scenes,
-            "tag_type": record.tag_type,
+            "labels": record_view["labels"],
+            "display_labels": record_view["display_labels"],
+            "third_scenes": record_view["third_scenes"],
+            "tag_type": record_view["tag_type"],
+            "scene_type": record.scene_type,
             "scene_match": record.scene_match,
             "tip_accuracy": record.tip_accuracy,
             "effect_evaluation": record.effect_evaluation,
             "user_remark": record.user_remark,
+            "hazard_details": record_view["hazard_details"],
+            "detections": record_view["detections"],
             "created_at": record.created_at,
-            "updated_at": record.updated_at
-        }
+            "updated_at": record.updated_at,
+        },
     }
 
 
 class DeleteRecognitionRequest(BaseModel):
-    recognition_id: int
+    recognition_id: Optional[int] = None
+    recognition_record_id: Optional[int] = None
 
 
 @router.post("/delete_recognition_record")
-async def delete_recognition_record(data: DeleteRecognitionRequest, request: Request, db: Session = Depends(get_db)):
-    """删除识别记录(软删除)"""
+async def delete_recognition_record(
+    data: DeleteRecognitionRequest,
+    request: Request,
+    db: Session = Depends(get_db),
+):
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    db.query(RecognitionRecord).filter(
-        RecognitionRecord.id == data.recognition_id,
-        RecognitionRecord.user_id == user.userCode
-    ).update({
-        "is_deleted": 1,
-        "deleted_at": int(time.time())
-    })
+
+    record_id = _resolve_record_id(data.recognition_id, data.recognition_record_id)
+    if not record_id:
+        return {"statusCode": 422, "msg": "recognition_id 不能为空"}
+
+    (
+        db.query(RecognitionRecord)
+        .filter(
+            RecognitionRecord.id == record_id,
+            RecognitionRecord.user_id == _get_user_code(user),
+        )
+        .update({"is_deleted": 1, "deleted_at": int(time.time())})
+    )
     db.commit()
-    
+
     return {"statusCode": 200, "msg": "删除成功"}
 
 
@@ -198,16 +342,15 @@ class EvaluationRequest(BaseModel):
 
 @router.post("/submit_evaluation")
 async def submit_evaluation(data: EvaluationRequest, db: Session = Depends(get_db)):
-    """提交点评"""
-    record = db.query(RecognitionRecord).filter(
-        RecognitionRecord.id == data.id,
-        RecognitionRecord.is_deleted == 0
-    ).first()
-    
+    record = (
+        db.query(RecognitionRecord)
+        .filter(RecognitionRecord.id == data.id, RecognitionRecord.is_deleted == 0)
+        .first()
+    )
+
     if not record:
         return {"statusCode": 404, "msg": "记录不存在"}
-    
-    # 更新评价字段
+
     if data.scene_match is not None:
         record.scene_match = data.scene_match
     if data.tip_accuracy is not None:
@@ -216,35 +359,38 @@ async def submit_evaluation(data: EvaluationRequest, db: Session = Depends(get_d
         record.effect_evaluation = data.effect_evaluation
     if data.user_remark is not None:
         record.user_remark = data.user_remark
-    
+
     record.updated_at = int(time.time())
     db.commit()
-    
+
     return {"statusCode": 200, "msg": "success"}
 
 
 @router.get("/get_latest_recognition_record")
-async def get_latest_recognition_record(request: Request, db: Session = Depends(get_db)):
-    """获取最新识别记录"""
+async def get_latest_recognition_record(
+    request: Request, db: Session = Depends(get_db)
+):
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    record = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == user.userCode,
-        RecognitionRecord.is_deleted == 0
-    ).order_by(RecognitionRecord.created_at.desc()).first()
-    
-    # 如果数据为空,则构建一个假数据 effect_evaluation=1 给前端
+
+    record = (
+        db.query(RecognitionRecord)
+        .filter(
+            RecognitionRecord.user_id == _get_user_code(user),
+            RecognitionRecord.is_deleted == 0,
+        )
+        .order_by(RecognitionRecord.created_at.desc())
+        .first()
+    )
+
     if not record:
         return {
             "statusCode": 200,
             "msg": "success",
-            "data": {
-                "effect_evaluation": 1
-            }
+            "data": {"effect_evaluation": 1},
         }
-    
+
     return {
         "statusCode": 200,
         "msg": "success",
@@ -255,17 +401,12 @@ async def get_latest_recognition_record(request: Request, db: Session = Depends(
             "recognition_image_url": record.recognition_image_url,
             "labels": record.labels,
             "created_at": record.created_at,
-            "effect_evaluation": record.effect_evaluation
-        }
+            "effect_evaluation": record.effect_evaluation,
+        },
     }
 
 
-# ============================================================
-# 场景模板接口(对齐Go版本)
-# ============================================================
-
 class SceneTemplateCreate(BaseModel):
-    """创建场景模板请求"""
     scene_name: str
     scene_type: str
     scene_desc: str = ""
@@ -273,8 +414,9 @@ class SceneTemplateCreate(BaseModel):
 
 
 @router.post("/scene_template")
-async def create_scene_template(data: SceneTemplateCreate, db: Session = Depends(get_db)):
-    """创建场景模板"""
+async def create_scene_template(
+    data: SceneTemplateCreate, db: Session = Depends(get_db)
+):
     template = SceneTemplate(
         scene_name=data.scene_name,
         scene_type=data.scene_type,
@@ -282,16 +424,16 @@ async def create_scene_template(data: SceneTemplateCreate, db: Session = Depends
         model_name=data.model_name,
         created_at=int(time.time()),
         updated_at=int(time.time()),
-        is_deleted=0
+        is_deleted=0,
     )
     db.add(template)
     db.commit()
     db.refresh(template)
-    
+
     return {
         "statusCode": 200,
         "msg": "创建成功",
-        "data": {"id": template.id}
+        "data": {"id": template.id},
     }
 
 
@@ -299,25 +441,22 @@ async def create_scene_template(data: SceneTemplateCreate, db: Session = Depends
 async def get_scene_templates(
     page: int = 1,
     page_size: int = 20,
-    db: Session = Depends(get_db)
+    db: Session = Depends(get_db),
 ):
-    """获取场景模板列表(分页)"""
-    # 限制page_size最大值
     if page_size > 100:
         page_size = 100
-    
+
     offset = (page - 1) * page_size
-    
-    # 查询总数
-    total = db.query(SceneTemplate).filter(
-        SceneTemplate.is_deleted == 0
-    ).count()
-    
-    # 查询列表
-    templates = db.query(SceneTemplate).filter(
-        SceneTemplate.is_deleted == 0
-    ).order_by(SceneTemplate.created_at.desc()).offset(offset).limit(page_size).all()
-    
+    total = db.query(SceneTemplate).filter(SceneTemplate.is_deleted == 0).count()
+    templates = (
+        db.query(SceneTemplate)
+        .filter(SceneTemplate.is_deleted == 0)
+        .order_by(SceneTemplate.created_at.desc())
+        .offset(offset)
+        .limit(page_size)
+        .all()
+    )
+
     return {
         "statusCode": 200,
         "msg": "success",
@@ -325,16 +464,16 @@ async def get_scene_templates(
             "total": total,
             "items": [
                 {
-                    "id": t.id,
-                    "scene_name": t.scene_name,
-                    "scene_type": t.scene_type,
-                    "scene_desc": t.scene_desc,
-                    "model_name": t.model_name,
-                    "created_at": t.created_at
+                    "id": template.id,
+                    "scene_name": template.scene_name,
+                    "scene_type": template.scene_type,
+                    "scene_desc": template.scene_desc,
+                    "model_name": template.model_name,
+                    "created_at": template.created_at,
                 }
-                for t in templates
-            ]
-        }
+                for template in templates
+            ],
+        },
     }
 
 
@@ -344,36 +483,32 @@ async def get_recognition_records(
     scene_type: str = "",
     page: int = 1,
     page_size: int = 20,
-    db: Session = Depends(get_db)
+    db: Session = Depends(get_db),
 ):
-    """获取识别记录列表(分页+筛选)- 符合REST规范"""
     user = request.state.user
     if not user:
         return {"statusCode": 401, "msg": "未授权"}
-    
-    # 限制page_size最大值
+
     if page_size > 100:
         page_size = 100
-    
-    # 构建查询条件
+
     query = db.query(RecognitionRecord).filter(
-        RecognitionRecord.user_id == user.userCode,
-        RecognitionRecord.is_deleted == 0
+        RecognitionRecord.user_id == _get_user_code(user),
+        RecognitionRecord.is_deleted == 0,
     )
-    
-    # 场景类型筛选
+
     if scene_type:
         query = query.filter(RecognitionRecord.scene_type == scene_type)
-    
-    # 查询总数
+
     total = query.count()
-    
-    # 分页查询
     offset = (page - 1) * page_size
-    records = query.order_by(
-        RecognitionRecord.created_at.desc()
-    ).offset(offset).limit(page_size).all()
-    
+    records = (
+        query.order_by(RecognitionRecord.created_at.desc())
+        .offset(offset)
+        .limit(page_size)
+        .all()
+    )
+
     return {
         "statusCode": 200,
         "msg": "success",
@@ -381,15 +516,15 @@ async def get_recognition_records(
             "total": total,
             "items": [
                 {
-                    "id": r.id,
-                    "scene_type": r.scene_type,
-                    "original_image_url": r.original_image_url,
-                    "result_image_url": r.recognition_image_url,
-                    "hazard_count": r.hazard_count,
-                    "current_step": r.current_step,
-                    "created_at": r.created_at
+                    "id": record.id,
+                    "scene_type": record.scene_type,
+                    "original_image_url": record.original_image_url,
+                    "result_image_url": record.recognition_image_url,
+                    "hazard_count": record.hazard_count,
+                    "current_step": record.current_step,
+                    "created_at": record.created_at,
                 }
-                for r in records
-            ]
-        }
+                for record in records
+            ],
+        },
     }

+ 1 - 1
shudao-chat-py/services/deepseek_service.py

@@ -28,7 +28,7 @@ class DeepSeekService:
         }
         
         try:
-            async with httpx.AsyncClient(timeout=60.0) as client:
+            async with httpx.AsyncClient(timeout=120.0) as client:
                 response = await client.post(
                     f"{self.base_url}/chat/completions",
                     headers=headers,

+ 37 - 3
shudao-chat-py/services/qwen_service.py

@@ -7,6 +7,7 @@ from typing import AsyncGenerator
 from utils.config import settings
 from utils.logger import logger
 from utils.prompt_loader import load_prompt
+from services.deepseek_service import deepseek_service
 
 
 class QwenService:
@@ -20,6 +21,18 @@ class QwenService:
         intent_base_url = settings.intent.api_url.rstrip('/')
         self.intent_api_url = f"{intent_base_url}/v1/chat/completions"
         self.intent_model = settings.intent.model
+
+    def _should_fallback(self, status_code: int) -> bool:
+        return status_code in (429, 500, 502, 503, 504)
+
+    async def _fallback_deepseek(self, messages: list) -> str:
+        try:
+            logger.warning("[Qwen API] Falling back to DeepSeek due to upstream error")
+            return await deepseek_service.chat(messages)
+        except Exception as e:
+            error_msg = str(e).strip() if str(e).strip() else type(e).__name__
+            logger.error(f"[Qwen API] DeepSeek fallback failed: {type(e).__name__}: {error_msg}")
+            raise RuntimeError(f"AI服务暂时不可用,主模型和备用模型均无法响应({type(e).__name__}),请稍后重试") from e
     
     async def extract_keywords(self, question: str) -> str:
         """从问题中提炼搜索关键词"""
@@ -103,6 +116,8 @@ class QwenService:
         
         # 使用指定的API URL,默认使用qwen3的URL
         target_url = api_url or self.api_url
+        normalized_target = target_url.rstrip("/")
+        is_qwen3_target = normalized_target == self.api_url.rstrip("/")
         
         # 详细请求日志
         logger.info(f"[Qwen API] 请求 URL: {target_url}")
@@ -116,16 +131,16 @@ class QwenService:
             }
             
             # 如果配置中有 token,添加到请求头(兼容需要认证的场景)
-            if hasattr(settings, 'intent') and hasattr(settings.intent, 'token') and api_url == self.intent_api_url:
+            if hasattr(settings, 'intent') and hasattr(settings.intent, 'token') and normalized_target == self.intent_api_url.rstrip("/"):
                 if settings.intent.token:
                     headers["Authorization"] = f"Bearer {settings.intent.token}"
                     logger.info("[Qwen API] 已添加 Intent API Authorization header")
-            elif hasattr(settings, 'qwen3') and hasattr(settings.qwen3, 'token') and api_url == self.api_url:
+            elif hasattr(settings, 'qwen3') and hasattr(settings.qwen3, 'token') and normalized_target == self.api_url.rstrip("/"):
                 if settings.qwen3.token:
                     headers["Authorization"] = f"Bearer {settings.qwen3.token}"
                     logger.info("[Qwen API] 已添加 Qwen3 API Authorization header")
             
-            async with httpx.AsyncClient(timeout=60.0) as client:
+            async with httpx.AsyncClient(timeout=120.0) as client:
                 response = await client.post(
                     target_url,
                     json=data,
@@ -179,9 +194,13 @@ class QwenService:
         except httpx.HTTPStatusError as e:
             logger.error(f"[Qwen API] HTTP 错误 - 状态码: {e.response.status_code}, URL: {target_url}")
             logger.error(f"[Qwen API] HTTP 错误响应: {e.response.text[:500]}")
+            if is_qwen3_target and self._should_fallback(e.response.status_code):
+                return await self._fallback_deepseek(messages)
             raise
         except httpx.RequestError as e:
             logger.error(f"[Qwen API] 请求错误 - URL: {target_url}, 错误: {type(e).__name__}: {str(e)}")
+            if is_qwen3_target:
+                return await self._fallback_deepseek(messages)
             raise
         except Exception as e:
             logger.error(f"[Qwen API] 未知错误 - URL: {target_url}, 模型: {data['model']}, 错误: {type(e).__name__}: {str(e)}")
@@ -220,6 +239,21 @@ class QwenService:
                                     yield content
                             except json.JSONDecodeError:
                                 continue
+        except httpx.HTTPStatusError as e:
+            status_code = e.response.status_code if e.response else 0
+            logger.error(f"Qwen stream HTTP error: {status_code}")
+            if self._should_fallback(status_code):
+                logger.warning("[Qwen API] Stream fallback to DeepSeek")
+                async for chunk in deepseek_service.stream_chat(messages):
+                    yield chunk
+                return
+            raise
+        except httpx.RequestError as e:
+            logger.error(f"Qwen stream request error: {type(e).__name__}: {e}")
+            logger.warning("[Qwen API] Stream fallback to DeepSeek")
+            async for chunk in deepseek_service.stream_chat(messages):
+                yield chunk
+            return
         except Exception as e:
             logger.error(f"Qwen 流式 API 调用失败: {e}")
             raise

+ 552 - 5
shudao-vue-frontend/src/views/Chat.vue

@@ -55,8 +55,9 @@
       </div>
     </div>
 
-    <!-- 右侧AI问答区域 -->
-    <div class="main-chat" :class="{ 'sidebar-open': webSearchSidebarVisible }">
+    <div class="chat-main-area">
+      <!-- 右侧AI问答区域 -->
+      <div class="main-chat" :class="{ 'sidebar-open': webSearchSidebarVisible }">
       <!-- 聊天头部 -->
       <div class="chat-header" v-if="currentMode !== 'exam-workshop' && currentQuestion">
         <div class="question-title-card">
@@ -286,6 +287,22 @@
                       </div>
                     </div>
 
+                    <!-- AI写作:状态文字 + 文件卡片 -->
+                    <div v-if="message.isAIWriting" class="ai-writing-block">
+                      <div class="ai-writing-status-text">
+                        {{ message.aiWritingStatusText || 'AI智能助手正在为您输出...' }}
+                      </div>
+                      <div class="ai-writing-file-card" @click="openAIWritingSidebar(message)">
+                        <div class="file-card-icon">
+                          <span class="word-icon">W</span>
+                        </div>
+                        <div class="file-card-info">
+                          <div class="file-card-title">{{ message.aiWritingTitle || currentQuestion || 'AI写作文档' }}</div>
+                          <div class="file-card-time">创建于 {{ formatTime(message.timestamp) }}</div>
+                        </div>
+                      </div>
+                    </div>
+
                     <!-- 原有的AI文本内容(如果没有报告数据时显示) -->
                     <div v-if="!message.reports || message.reports.length === 0" class="ai-text">
                     <div v-if="message.displayContent && message.displayContent.length > 0 && !message.isDocument" class="ai-markdown-content">
@@ -549,6 +566,43 @@
           </div>
         </div>
       </div>
+      </div>
+
+      <!-- AI写作侧边栏 -->
+      <transition name="sidebar-slide">
+        <div v-if="aiWritingSidebarVisible" class="ai-writing-sidebar">
+          <div class="sidebar-header">
+            <h3 class="sidebar-title">{{ aiWritingSidebarTitle }}</h3>
+            <div class="sidebar-header-actions">
+              <button class="sidebar-close-btn" @click="closeAIWritingSidebar">
+                <svg width="16" height="16" viewBox="0 0 16 16" fill="none">
+                  <path d="M12 4L4 12M4 4L12 12" stroke="#909399" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
+                </svg>
+              </button>
+            </div>
+          </div>
+          <div class="sidebar-toolbar" v-if="!aiWritingIsGenerating">
+            <span class="toolbar-label">正文</span>
+            <span class="toolbar-font-size">15px</span>
+            <div class="toolbar-divider"></div>
+            <button class="toolbar-btn"><b>B</b></button>
+            <button class="toolbar-btn"><s>S</s></button>
+            <button class="toolbar-btn"><u>U</u></button>
+            <button class="toolbar-btn"><i>I</i></button>
+          </div>
+          <div class="sidebar-content">
+            <div class="sidebar-doc-content" v-text="aiWritingSidebarContent"></div>
+            <div v-if="aiWritingIsGenerating" class="sidebar-generating-hint">
+              <span class="generating-text-hint">AI智能助手正在输出</span>
+              <span class="generating-dots">
+                <span class="dot">.</span>
+                <span class="dot">.</span>
+                <span class="dot">.</span>
+              </span>
+            </div>
+          </div>
+        </div>
+      </transition>
     </div>
     
     
@@ -930,6 +984,32 @@ const expandedOnlineSearchResults = ref({}) // 记录每个消息的联网搜索
 
 // 网络搜索结果数据(新增)
 const webSearchSidebarVisible = ref(false) // 网络搜索侧边栏显示状态
+
+// AI写作侧边栏状态
+const aiWritingSidebarVisible = ref(false)
+const aiWritingSidebarTitle = ref('')
+const aiWritingSidebarContent = ref('')
+const aiWritingIsGenerating = ref(false)
+const aiWritingFullContent = ref('') // 保存完整内容,用于点击文件卡片时重新打开
+
+const normalizeAiWritingText = (content) => {
+  if (!content) return ''
+  return String(content).replace(/\r\n/g, '\n')
+}
+
+// 关闭AI写作侧边栏
+const closeAIWritingSidebar = () => {
+  aiWritingSidebarVisible.value = false
+}
+
+// 打开AI写作侧边栏(点击文件卡片时)
+const openAIWritingSidebar = (message) => {
+  aiWritingSidebarTitle.value = message.aiWritingTitle || currentQuestion.value || 'AI写作文档'
+  aiWritingSidebarContent.value = normalizeAiWritingText(message.fullContent || message.content || '')
+  aiWritingIsGenerating.value = message.aiWritingStatus === 'generating' || message.isTyping
+  aiWritingSidebarVisible.value = true
+}
+
 const currentWebSearchData = ref({
   results: [],
   keywords: [],
@@ -1792,7 +1872,165 @@ const handleSendMessage = async () => {
 }
 
 // 处理非流式请求 (AI写作 和 安全培训)
+
+const handleAIWritingStream = async (data) => {
+  if (!isSending.value) {
+    isSending.value = true
+  }
+
+  currentQuestion.value = data.question
+
+  chatMessages.value.push({
+    id: Date.now(),
+    type: 'user',
+    content: data.question,
+    timestamp: new Date().toISOString()
+  })
+
+  const aiMessageIndex = chatMessages.value.length
+  const aiMsgTimestamp = new Date().toISOString()
+  chatMessages.value.push({
+    id: Date.now() + 1,
+    type: 'ai',
+    userQuestion: data.question,
+    isTyping: true,
+    content: '',
+    displayContent: '',
+    timestamp: aiMsgTimestamp,
+    reports: [],
+    isAIWriting: true,
+    aiWritingTitle: data.question,
+    aiWritingStatusText: 'AI智能助手正在为您输出...',
+    aiWritingStatus: 'generating',
+    ai_message_id: null
+  })
+
+  aiWritingSidebarTitle.value = data.question
+  aiWritingSidebarContent.value = ''
+  aiWritingIsGenerating.value = true
+  aiWritingSidebarVisible.value = true
+
+  const aiMessage = chatMessages.value[aiMessageIndex]
+  let fullContent = ''
+
+  try {
+    const apiPrefix = getApiPrefix()
+    const url = `${apiPrefix}/stream/chat-with-db`
+    const token = getToken()
+    const headers = {
+      'Content-Type': 'application/json'
+    }
+    if (token) {
+      headers['Authorization'] = `Bearer ${token}`
+    }
+
+    const response = await fetch(url, {
+      method: 'POST',
+      headers,
+      body: JSON.stringify({
+        message: data.question,
+        ai_conversation_id: ai_conversation_id.value,
+        business_type: data.businessType
+      })
+    })
+
+    if (!response.ok) {
+      throw new Error(`请求失败: ${response.status} ${response.statusText}`)
+    }
+
+    const reader = response.body.getReader()
+    const decoder = new TextDecoder('utf-8')
+    let buffer = ''
+    let isDone = false
+
+    while (!isDone) {
+      const { done, value } = await reader.read()
+      if (done) break
+
+      buffer += decoder.decode(value, { stream: true })
+      const lines = buffer.split('\n')
+      buffer = lines.pop() || ''
+
+      for (const line of lines) {
+        if (!line.startsWith('data:')) continue
+        const payload = line.slice(5).trim()
+        if (!payload) continue
+        if (payload === '[DONE]') {
+          isDone = true
+          break
+        }
+
+        let parsed = null
+        if (payload.startsWith('{') && payload.endsWith('}')) {
+          try {
+            parsed = JSON.parse(payload)
+          } catch (e) {
+            parsed = null
+          }
+        }
+
+        const isMeta =
+          parsed &&
+          (parsed.type ||
+            parsed.ai_conversation_id ||
+            parsed.ai_message_id ||
+            parsed.error)
+
+        if (isMeta) {
+          if (parsed.type === 'initial') {
+            if (parsed.ai_conversation_id) {
+              ai_conversation_id.value = parsed.ai_conversation_id
+            }
+            if (parsed.ai_message_id) {
+              aiMessage.ai_message_id = parsed.ai_message_id
+            }
+          }
+          if (parsed.error) {
+            throw new Error(parsed.error)
+          }
+          continue
+        }
+
+        const chunk = parsed && typeof parsed.content === 'string' ? parsed.content : payload
+        const normalizedChunk = chunk.replace(/\\n/g, '\n')
+
+        if (normalizedChunk) {
+          fullContent += normalizedChunk
+          aiMessage.fullContent = fullContent
+          aiMessage.content = fullContent
+          aiWritingSidebarContent.value = normalizeAiWritingText(fullContent)
+        }
+      }
+    }
+
+    aiMessage.isTyping = false
+    aiMessage.aiWritingStatus = 'completed'
+    aiMessage.aiWritingStatusText = '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
+    aiWritingIsGenerating.value = false
+    aiWritingFullContent.value = fullContent
+    aiWritingSidebarVisible.value = false
+
+    getHistoryRecordList()
+  } catch (error) {
+    console.error('AI写作流式请求失败:', error)
+    aiMessage.isTyping = false
+    aiMessage.aiWritingStatus = 'error'
+    aiMessage.aiWritingStatusText = '生成失败,请重试'
+    aiWritingIsGenerating.value = false
+    aiWritingSidebarContent.value = normalizeAiWritingText(fullContent || '生成失败,请重试。')
+    ElMessage.error(`请求失败: ${error.message}`)
+  } finally {
+    isSending.value = false
+  }
+}
+
 const handleNonStreamingSubmit = async (data) => {
+  if (data.businessType === 2) {
+    await handleAIWritingStream(data)
+    return
+  }
+
+
   if (!isSending.value) {
     isSending.value = true
   }
@@ -1809,6 +2047,7 @@ const handleNonStreamingSubmit = async (data) => {
   
   // 添加AI消息占位符
   const aiMessageIndex = chatMessages.value.length
+  const aiMsgTimestamp = new Date().toISOString()
   chatMessages.value.push({
     id: Date.now() + 1,
     type: 'ai',
@@ -1816,10 +2055,25 @@ const handleNonStreamingSubmit = async (data) => {
     isTyping: true, // 显示 loading 状态
     content: '',
     displayContent: '',
-    timestamp: new Date().toISOString(),
-    reports: []
+    timestamp: aiMsgTimestamp,
+    reports: [],
+    // AI写作模式下,立即显示文件卡片和状态文字
+    ...(data.businessType === 2 ? {
+      isAIWriting: true,
+      aiWritingTitle: data.question,
+      aiWritingStatusText: 'AI智能助手正在为您输出...',
+      aiWritingStatus: 'generating'
+    } : {})
   })
   
+  // AI写作模式:立即打开侧边栏,显示生成中状态
+  if (data.businessType === 2) {
+    aiWritingSidebarTitle.value = data.question
+    aiWritingSidebarContent.value = ''
+    aiWritingIsGenerating.value = true
+    aiWritingSidebarVisible.value = true
+  }
+  
   try {
     const response = await apis.sendDeepseekMessage({
       message: data.question,
@@ -1876,8 +2130,19 @@ const handleNonStreamingSubmit = async (data) => {
         </div>`
         // 实际内容保存起来,点击查看详情时可以使用
         aiMessage.fullContent = aiReply
+      } else if (data.businessType === 2) {
+        // AI写作: 显示文件卡片 + 打开侧边栏输出
+        aiMessage.isAIWriting = true
+        aiMessage.aiWritingTitle = data.question
+        aiMessage.fullContent = aiReply
+        aiMessage.aiWritingStatus = 'completed' // 标记为已完成
+        aiMessage.aiWritingStatusText = '已按照要求输出文章,你可以基于当前结果进一步编辑整理~'
+        
+        // 关闭侧边栏(生成完成)
+        aiWritingSidebarVisible.value = false
+        aiWritingIsGenerating.value = false
       } else {
-        // AI写作等: 正常打字机输出
+        // 其他: 正常打字机输出
         startTypewriterEffect(aiMessage, aiReply, 30)
       }
       
@@ -5049,6 +5314,14 @@ onActivated(async () => {
   font-family: 'Alibaba PuHuiTi 3.0', sans-serif;
 }
 
+.chat-main-area {
+  flex: 1;
+  display: flex;
+  min-width: 0;
+  height: 100vh;
+  overflow: hidden;
+}
+
 /* 中间历史记录栏 */
 .history-sidebar {
   width: 280px;
@@ -5254,6 +5527,8 @@ onActivated(async () => {
   display: flex;
   flex-direction: column;
   transition: margin-right 0.3s ease;
+  min-width: 0;
+  height: 100%;
 }
 
 .main-chat.sidebar-open {
@@ -7195,6 +7470,278 @@ onActivated(async () => {
   animation-delay: 0s;
 }
 
+/* AI写作文件卡片样式 */
+.ai-writing-block {
+  margin-top: 8px;
+  
+  .ai-writing-status-text {
+    font-size: 14px;
+    color: #606266;
+    margin-bottom: 12px;
+    line-height: 1.6;
+  }
+  
+  .ai-writing-file-card {
+    display: flex;
+    align-items: center;
+    background: #F5F7FA;
+    border: 1px solid #E4E7ED;
+    border-radius: 12px;
+    padding: 16px 20px;
+    cursor: pointer;
+    transition: all 0.3s ease;
+    max-width: 360px;
+    
+    &:hover {
+      background: #EDF2FC;
+      border-color: #3E7BFA;
+      box-shadow: 0 2px 12px rgba(62, 123, 250, 0.15);
+      transform: translateY(-1px);
+    }
+    
+    .file-card-icon {
+      width: 40px;
+      height: 40px;
+      background: #2B5797;
+      border-radius: 8px;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      margin-right: 14px;
+      flex-shrink: 0;
+      
+      .word-icon {
+        color: white;
+        font-size: 20px;
+        font-weight: 700;
+        font-family: 'Segoe UI', sans-serif;
+      }
+    }
+    
+    .file-card-info {
+      flex: 1;
+      min-width: 0;
+      
+      .file-card-title {
+        font-size: 15px;
+        font-weight: 600;
+        color: #1F2937;
+        margin-bottom: 4px;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      
+      .file-card-time {
+        font-size: 12px;
+        color: #9CA3AF;
+      }
+    }
+  }
+}
+
+/* AI写作侧边栏样式 */
+.ai-writing-sidebar {
+  position: relative;
+  flex: 0 0 40%;
+  width: 40%;
+  min-width: 360px;
+  max-width: 520px;
+  height: 100%;
+  background: white;
+  box-shadow: -4px 0 24px rgba(0, 0, 0, 0.1);
+  z-index: 1;
+  display: flex;
+  flex-direction: column;
+  overflow: hidden;
+  border-left: 1px solid #E4E7ED;
+  
+  .sidebar-header {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    padding: 20px 24px 16px 24px;
+    border-bottom: 1px solid #E4E7ED;
+    flex-shrink: 0;
+    
+    .sidebar-title {
+      font-size: 18px;
+      font-weight: 600;
+      color: #1F2937;
+      margin: 0;
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      flex: 1;
+      margin-right: 16px;
+    }
+    
+    .sidebar-header-actions {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      
+      .sidebar-close-btn {
+        width: 32px;
+        height: 32px;
+        border: none;
+        background: #F5F7FA;
+        border-radius: 8px;
+        cursor: pointer;
+        display: flex;
+        align-items: center;
+        justify-content: center;
+        transition: all 0.2s ease;
+        
+        &:hover {
+          background: #E4E7ED;
+        }
+      }
+    }
+  }
+  
+  .sidebar-toolbar {
+    display: flex;
+    align-items: center;
+    padding: 10px 24px;
+    border-bottom: 1px solid #F0F2F5;
+    gap: 12px;
+    flex-shrink: 0;
+    
+    .toolbar-label {
+      font-size: 14px;
+      color: #606266;
+      margin-right: 4px;
+    }
+    
+    .toolbar-font-size {
+      font-size: 13px;
+      color: #909399;
+      margin-right: 8px;
+    }
+    
+    .toolbar-divider {
+      width: 1px;
+      height: 20px;
+      background: #E4E7ED;
+      margin: 0 4px;
+    }
+    
+    .toolbar-btn {
+      width: 32px;
+      height: 32px;
+      border: none;
+      background: transparent;
+      border-radius: 6px;
+      cursor: pointer;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      font-size: 15px;
+      color: #606266;
+      transition: all 0.2s ease;
+      
+      &:hover {
+        background: #F5F7FA;
+      }
+    }
+  }
+  
+  .sidebar-content {
+    flex: 1;
+    overflow-y: auto;
+    padding: 24px;
+    
+    .sidebar-doc-content {
+      font-size: 15px;
+      line-height: 1.8;
+      color: #303133;
+      white-space: pre-wrap;
+      word-break: break-word;
+      
+      :deep(h1), :deep(h2), :deep(h3) {
+        margin: 16px 0 8px 0;
+        color: #1F2937;
+      }
+      
+      :deep(p) {
+        margin: 8px 0;
+      }
+      
+      :deep(ul), :deep(ol) {
+        padding-left: 24px;
+        margin: 8px 0;
+      }
+      
+      :deep(li) {
+        margin: 4px 0;
+      }
+    }
+    
+    .sidebar-generating-hint {
+      display: flex;
+      align-items: center;
+      margin-top: 24px;
+      color: #3E7BFA;
+      font-size: 14px;
+      
+      .generating-text-hint {
+        margin-right: 4px;
+      }
+      
+      .generating-dots {
+        display: flex;
+        gap: 2px;
+        
+        .dot {
+          animation: dot-blink 1.4s infinite ease-in-out;
+          
+          &:nth-child(1) { animation-delay: 0s; }
+          &:nth-child(2) { animation-delay: 0.2s; }
+          &:nth-child(3) { animation-delay: 0.4s; }
+        }
+      }
+    }
+  }
+}
+
+@media (max-width: 1200px) {
+  .chat-main-area {
+    flex-direction: column;
+  }
+
+  .ai-writing-sidebar {
+    flex: 0 0 auto;
+    width: 100%;
+    min-width: 0;
+    max-width: none;
+    height: 45vh;
+    border-left: none;
+    border-top: 1px solid #E4E7ED;
+  }
+}
+
+@keyframes dot-blink {
+  0%, 80%, 100% { opacity: 0.3; }
+  40% { opacity: 1; }
+}
+
+/* 侧边栏滑入滑出动画 */
+.sidebar-slide-enter-active,
+.sidebar-slide-leave-active {
+  transition: transform 0.3s ease;
+}
+
+.sidebar-slide-enter-from,
+.sidebar-slide-leave-to {
+  transform: translateX(100%);
+}
+
+.sidebar-slide-enter-to,
+.sidebar-slide-leave-from {
+  transform: translateX(0);
+}
+
 /* 规范引用样式 */
 :deep(.standard-reference) {
   background-color: #EAEAEE !important;