소스 검색

feat:新增文档映射表

FanHong 1 일 전
부모
커밋
6808c22882

+ 1 - 1
README.md

@@ -76,7 +76,7 @@ nohup ./shudao-go-backend > nohup.out 2>&1 &
 | MySQL | 21000 |
 | 认证网关 | 28004 |
 | AI对话服务 | 28002 |
-| ChromaDB | 24000 |
+| ChromaDB | 23000 |
 | AI模型 | 8000 |
 | YOLO | 18080 |
 

+ 5 - 5
docs/ai-intent-recognition-flow.md

@@ -741,18 +741,18 @@ QwenService 初始化时给意图识别单独配置了:
 
 ### 相关代码位置
 
-- [_rag_search](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L529-L553)
+- [_rag_search](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L575-L609)
 - 非流式问答中的调用:[chat.py](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L971-L979)
 - 无 DB 流式问答中的调用:[chat.py](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L1383-L1392)
 - 带 DB 主聊天中的调用:[chat.py](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L1612-L1623)
 
 ### 执行逻辑
 
-1. 读取 `settings.search.api_url`
-2. 调用外部检索服务
+1. 读取 `settings.aichat.api_url`
+2. 调用 AIChat 的 `/knowledge/search`
 3. 请求体:
-   - `query`
-   - `n_results`
+   - `query_str`
+   - `n`
 4. 从返回结果提取文档内容
 5. 拼成一大段 `rag_context`
 

+ 9 - 10
docs/ai-qa-api-call-chain.md

@@ -39,7 +39,7 @@
 | 目标服务 | 调用点 | 典型接口 | 用途 |
 |---|---|---|---|
 | 4A Auth | [verify_external_token](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/utils/token.py#L175-L228) | `POST settings.auth.api_url` | 校验外部 token,解析 account 信息 |
-| 搜索服务(RAG) | [_rag_search](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L529-L553) | `POST settings.search.api_url` | 向量检索/知识库检索,拼接上下文 |
+| AIChat 知识检索 | [_rag_search](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/routers/chat.py#L575-L609) | `POST {settings.aichat.api_url}/knowledge/search` | 通过 aichat 统一做向量检索/知识库检索,拼接上下文 |
 | Qwen3(主模型) | [QwenService.chat](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/qwen_service.py#L117-L207)、[QwenService.stream_chat](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/qwen_service.py#L208-L256) | `POST {settings.qwen3.api_url}/v1/chat/completions` | 主问答生成与流式输出 |
 | 意图识别模型 | [intent_recognition](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/qwen_service.py#L73-L116) | `POST {settings.intent.api_url}/v1/chat/completions` | 识别 greeting/faq/知识库查询 等 |
 | DeepSeek(备用模型) | [DeepSeekService.chat](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/deepseek_service.py#L24-L48)、[stream_chat](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/deepseek_service.py#L50-L89) | `POST {settings.deepseek.api_url}/v1/chat/completions` | Qwen3 失败时回退 |
@@ -102,7 +102,7 @@
    - 写入 `AIMessage(type=ai, content='')` 占位
    - SSE 推送 `initial`(返回会话/消息 ID)
 4. 触发 RAG:
-   - `POST settings.search.api_url`,请求体 `{"query": message, "n_results": top_k}`
+   - `POST {settings.aichat.api_url}/knowledge/search`,请求体 `{"query_str": message, "n": top_k}`
    - 将结果拼成 `rag_context`
 5. 拼装 Prompt:
    - `final_answer`(含 userMessage、rag_context、historyContext、可选 online_search_content)
@@ -127,7 +127,7 @@ sequenceDiagram
     participant AU as 4A Auth
     participant RT as chat.py:/stream/chat-with-db
     participant DB as MySQL
-    participant SS as 搜索服务
+    participant AS as AIChat知识检索
     participant QW as Qwen3
     participant DS as DeepSeek(回退)
 
@@ -141,8 +141,8 @@ sequenceDiagram
     RT->>DB: insert ai placeholder AIMessage
     RT-->>FE: SSE data: {"type":"initial",...}
 
-    RT->>SS: POST settings.search.api_url
-    SS-->>RT: docs
+    RT->>AS: POST {settings.aichat.api_url}/knowledge/search
+    AS-->>RT: docs
     RT->>QW: POST /v1/chat/completions (stream=true)
     alt Qwen3 失败
         RT->>DS: POST /v1/chat/completions (stream=true)
@@ -171,7 +171,7 @@ sequenceDiagram
    - 调用 [QwenService.intent_recognition](file:///Users/fanhong/UGIT/shudao-main/shudao-chat-py/services/qwen_service.py#L73-L116)
    - 出站:`POST {settings.intent.api_url}/v1/chat/completions`
 5. 若意图命中“知识库查询/技术咨询”,触发 RAG:
-   - `POST settings.search.api_url`
+   - `POST {settings.aichat.api_url}/knowledge/search`
 6. 组装 `final_answer` Prompt
 7. 非流式生成:
    - `POST {settings.qwen3.api_url}/v1/chat/completions`(`stream=false`)
@@ -187,7 +187,7 @@ sequenceDiagram
     participant MW as 中间件
     participant RT as chat.py:/send_deepseek_message
     participant IM as Intent模型
-    participant SS as 搜索服务
+    participant AS as AIChat知识检索
     participant QW as Qwen3
     participant DS as DeepSeek(回退)
     participant DB as MySQL
@@ -198,8 +198,8 @@ sequenceDiagram
     RT->>IM: POST intent /v1/chat/completions
     IM-->>RT: intent_type
     opt 命中知识库查询
-        RT->>SS: POST settings.search.api_url
-        SS-->>RT: docs
+        RT->>AS: POST {settings.aichat.api_url}/knowledge/search
+        AS-->>RT: docs
     end
     RT->>QW: POST qwen3 /v1/chat/completions (stream=false)
     alt Qwen3 失败
@@ -369,4 +369,3 @@ sequenceDiagram
 - `deepseek.api_url`
 - `dify.workflow_url` / `dify.workflow_id` / `dify.auth_token`
 - `aichat.api_url`
-

+ 1 - 1
nginx-prod.conf

@@ -7,7 +7,7 @@
 # - 28000: 管理后台 API
 # - 28002: ReportGenerator (AI对话服务)
 # - 28004: auth-server (统一认证网关,集成原28003~28006服务)
-# - 24000: ChromaDB (向量搜索)
+# - 23000: ChromaDB (由 aichat 直连的向量搜索服务)
 # - 172.16.35.50:8000: TTS/语音服务
 # ============================================================
 

+ 1 - 1
nginx.conf

@@ -7,7 +7,7 @@
 # - 28000: 管理后台 API
 # - 28002: ReportGenerator (AI对话服务)
 # - 28004: auth-server (统一认证网关,集成原28003~28006服务)
-# - 24000: ChromaDB (向量搜索)
+# - 23000: ChromaDB (由 aichat 直连的向量搜索服务)
 # - 172.16.35.50:8000: TTS/语音服务
 # ============================================================
 

+ 127 - 0
shudao-chat-py/app.py

@@ -0,0 +1,127 @@
+#!/usr/bin/env python3
+from PIL import Image
+from ultralytics import YOLO
+from fastapi.responses import JSONResponse
+from fastapi import FastAPI, Request
+import base64
+import os
+import io
+from setproctitle import setproctitle
+setproctitle("yolo-router")
+
+BASE_DIR = "/tmp/yolov11"
+
+MODEL_PATHS = {
+    "jianzhiliang": os.path.join(BASE_DIR, "jianzhiliang/models/trained/jianzhiliang.pt"),
+    "gsgl": os.path.join(BASE_DIR, "gsgl/models/trained/gsgl.pt"),
+    "suidao": os.path.join(BASE_DIR, "suidao/models/trained/suidao.pt"),
+    "tezhongshebei": os.path.join(BASE_DIR, "tezhongshebei/models/trained/tezhongshebei.pt"),
+    "jiayouzhan": os.path.join(BASE_DIR, "jiayouzhan/models/trained/jiayouzhan.pt"),
+}
+
+MODELTYPE_MAP = {
+    "simple_supported_bridge": "jianzhiliang",
+    "special_equipment": "tezhongshebei",
+    "gas_station": "jiayouzhan",
+    "operate_highway": "gsgl",
+    "tunnel": "suidao",
+}
+
+models = {}
+for name, path in MODEL_PATHS.items():
+    if os.path.exists(path):
+        print(f"Loading model {name} from {path}")
+        models[name] = YOLO(path)
+    else:
+        print(f"WARNING: model path not found: {path}")
+
+app = FastAPI(title="YOLO Router", version="1.0")
+
+
+def _parse_conf_threshold(raw_value):
+    try:
+        if raw_value is None or raw_value == "":
+            return 0.5
+        threshold = float(raw_value)
+        if threshold < 0:
+            return 0.0
+        if threshold > 1:
+            return 1.0
+        return threshold
+    except (TypeError, ValueError):
+        return 0.5
+
+
+@app.post("/predict")
+async def predict(request: Request):
+    try:
+        data = await request.json()
+    except Exception as e:
+        return JSONResponse(status_code=400, content={"error": f"Invalid JSON: {str(e)}"})
+
+    modeltype = data.get("modeltype")
+    image_b64 = data.get("image")
+    conf_threshold = _parse_conf_threshold(data.get("conf_threshold"))
+
+    if not modeltype or not image_b64:
+        return JSONResponse(status_code=422, content={"error": "Missing modeltype or image"})
+
+    model_name = MODELTYPE_MAP.get(modeltype)
+    if not model_name or model_name not in models:
+        return JSONResponse(status_code=400, content={"error": f"Unknown modeltype '{modeltype}' or model not loaded"})
+
+    try:
+        img_bytes = base64.b64decode(image_b64)
+        image = Image.open(io.BytesIO(img_bytes)).convert("RGB")
+    except Exception as e:
+        return JSONResponse(status_code=422, content={"error": f"Image decode error: {str(e)}"})
+
+    try:
+        results = models[model_name](image, conf=conf_threshold, verbose=False)
+        detections = []
+        labels = []
+        boxes_list = []
+        scores = []
+        if len(results) > 0:
+            result = results[0]
+            boxes = result.boxes
+            if boxes is not None:
+                for box in boxes:
+                    cls_id = int(box.cls[0])
+                    conf = float(box.conf[0])
+                    xyxy = box.xyxy[0].tolist()
+                    label = result.names[cls_id]
+                    detections.append({
+                        "label": label,
+                        "confidence": round(conf, 4),
+                        "bbox": [round(x, 2) for x in xyxy]
+                    })
+                    labels.append(label)
+                    boxes_list.append([round(x, 2) for x in xyxy])
+                    scores.append(round(conf, 4))
+
+        # ★ 打印目标数量到日志
+        print(f"[{modeltype}] Detected {len(detections)} objects")
+
+        # 同时兼容项目后端协议和当前调试使用的 detections 结构。
+        return {
+            "model_type": modeltype,
+            "modeltype": modeltype,
+            "labels": labels,
+            "boxes": boxes_list,
+            "scores": scores,
+            "count": len(detections),
+            "detections": detections
+        }
+    except Exception as e:
+        print(f"Inference error: {e}")
+        return JSONResponse(status_code=500, content={"error": f"Inference error: {str(e)}"})
+
+
+@app.get("/health")
+async def health():
+    return {"status": "ok", "loaded_models": list(models.keys())}
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=18080)

+ 5 - 5
shudao-chat-py/config.yaml

@@ -35,17 +35,17 @@ intent:
 yolo:
   base_url: http://172.16.35.50:18080
 
-# 搜索API配置
-search:
-  api_url: http://127.0.0.1:24000/api/search
-  heartbeat_url: http://127.0.0.1:24000/api/health
-
 # Dify Workflow配置
 dify:
   workflow_url: http://172.16.35.50:8000/v1/workflows/run
   workflow_id: 4wfh1PPDderMtCeb
   auth_token: app-55CyO4lmDv1VeXK4QmFpt4ng
 
+# AIChat 服务配置
+aichat:
+  api_url: http://127.0.0.1:28002/api/v1
+  timeout: 600
+
 # 基础URL配置
 base_url: https://aqai.shudaodsj.com:22000
 

+ 23 - 11
shudao-chat-py/routers/chat.py

@@ -572,27 +572,39 @@ def _refresh_conversation_snapshot(db: Session, conversation_id: int, user_id: i
 # 辅助函数
 # ─────────────────────────────────────────────────────────────────────────
 
+def _get_knowledge_search_api_url() -> str:
+    aichat_config = getattr(settings, "aichat", None)
+    aichat_base_url = getattr(aichat_config, "api_url", "").rstrip("/")
+    if aichat_base_url:
+        return f"{aichat_base_url}/knowledge/search"
+
+    return "http://127.0.0.1:28002/api/v1/knowledge/search"
+
+
 async def _rag_search(message: str, top_k: int = 5) -> str:
-    """调用 search API 做 RAG 检索,返回上下文文本"""
+    """调用 aichat 知识库检索接口做 RAG 检索,返回上下文文本"""
     try:
-        search_cfg = getattr(settings, 'search', None)
-        if not search_cfg or not hasattr(search_cfg, 'api_url'):
-            return ""
-        search_url = search_cfg.api_url
-        if not search_url:
-            return ""
+        search_url = _get_knowledge_search_api_url()
         async with httpx.AsyncClient(timeout=10.0) as client:
             resp = await client.post(
                 search_url,
-                json={"query": message, "n_results": top_k},
+                json={"query_str": message, "n": top_k},
             )
             if resp.status_code == 200:
                 data = resp.json()
-                docs = data.get("results") or data.get("documents") or []
+                docs = data.get("results")
+                if docs is None:
+                    docs = data.get("data", [])
+                if isinstance(docs, dict):
+                    docs = docs.get("items", [])
+                if not isinstance(docs, list):
+                    docs = []
                 return "\n\n".join(
-                    d.get("content") or d.get("text") or str(d)
+                    d.get("document") or d.get("content") or d.get("text") or str(d)
                     for d in docs[:top_k]
-                    if d.get("content") or d.get("text")
+                    if isinstance(d, dict) and (
+                        d.get("document") or d.get("content") or d.get("text")
+                    )
                 )
     except Exception as e:
         logger.warning(f"[RAG] 检索失败(可忽略): {e}")

+ 2 - 2
shudao-chat-py/tests/test_chat.md

@@ -1078,12 +1078,12 @@ POST /apiv1/save_edit_document
 | `services.qwen_service` | Qwen AI 服务(chat, stream_chat, intent_recognition, extract_keywords) |
 | `utils.prompt_loader.load_prompt` | Prompt 模板加载器(final_answer, ppt_outline, document_writing, guess_questions) |
 | `httpx` | HTTP 异步客户端(用于 RAG 检索和 Dify 调用) |
-| `settings.search.api_url` | RAG 搜索 API 地址 |
+| `settings.aichat.api_url` | AIChat 基础 API 地址(RAG 检索复用 `/knowledge/search`) |
 | `settings.dify` | Dify 工作流配置(workflow_url, auth_token, workflow_id) |
 
 ## 辅助函数说明
 
 | 函数 | 说明 |
 |------|------|
-| `_rag_search(message, top_k=5)` | 调用搜索 API 做 RAG 检索,返回知识库上下文文本。失败时静默返回空字符串。 |
+| `_rag_search(message, top_k=5)` | 调用 AIChat 的 `/knowledge/search` 做 RAG 检索,返回知识库上下文文本。失败时静默返回空字符串。 |
 | `_build_history_messages(conv_id, limit=10)` | 从数据库读取最近对话历史,构建 `messages` 列表(注意:此函数在文件中定义但未在任何接口中直接调用)。 |

+ 0 - 6
shudao-chat-py/utils/config.py

@@ -40,11 +40,6 @@ class YoloConfig(BaseSettings):
     base_url: str
 
 
-class SearchConfig(BaseSettings):
-    api_url: str
-    heartbeat_url: str
-
-
 class DifyConfig(BaseSettings):
     workflow_url: str
     workflow_id: str
@@ -185,7 +180,6 @@ class Settings:
         self.qwen3 = Qwen3Config(**config_data.get('qwen3', {}))
         self.intent = IntentConfig(**config_data.get('intent', {}))
         self.yolo = YoloConfig(**config_data.get('yolo', {}))
-        self.search = SearchConfig(**config_data.get('search', {}))
         self.dify = DifyConfig(**config_data.get('dify', {}))
         self.auth = AuthConfig(**config_data.get('auth', {}))
         self.oss = OSSConfig(**config_data.get('oss', {}))