From 301661e7b441110ec22058d08214ee35b72dec83 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Mon, 6 Jul 2026 18:03:29 +0800 Subject: [PATCH] Add V2 external service providers --- .../apps/backend/.env.example | 5 + .../apps/backend/app/core/config.py | 5 + .../app/services/ai_request_log_service.py | 29 +++++ .../apps/backend/app/services/chat_service.py | 25 +++- .../backend/app/services/external_errors.py | 7 + .../backend/app/services/feishu_service.py | 122 ++++++++++++++++++ .../backend/app/services/model_service.py | 66 +++++++++- .../apps/backend/app/services/rag_service.py | 52 +------- .../apps/backend/requirements.txt | 1 + .../2026-07-06-decision-register.md | 3 + ...2026-07-06-phase4-rag-external-services.md | 55 ++++++++ .../development_records/README.md | 1 + ai_knowledge_base_v2/docker-compose.dev.yml | 2 + 13 files changed, 318 insertions(+), 55 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/external_errors.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py create mode 100644 ai_knowledge_base_v2/development_records/2026-07-06-phase4-rag-external-services.md diff --git a/ai_knowledge_base_v2/apps/backend/.env.example b/ai_knowledge_base_v2/apps/backend/.env.example index bfc59a8..7e15518 100644 --- a/ai_knowledge_base_v2/apps/backend/.env.example +++ b/ai_knowledge_base_v2/apps/backend/.env.example @@ -14,6 +14,11 @@ MOCK_SMS_ENABLED=true MOCK_SMS_CODE=123456 SMS_CODE_EXPIRE_MINUTES=5 MOCK_RAG_ENABLED=true +MOCK_MODEL_ENABLED=true +FEISHU_MOCK_ENABLED=true +FEISHU_SEARCH_URL= +FEISHU_TIMEOUT_SECONDS=20 +FEISHU_RETRY_COUNT=2 DEFAULT_DAILY_CHAT_LIMIT=100 DEFAULT_USER_NAME_PREFIX=用户 diff --git a/ai_knowledge_base_v2/apps/backend/app/core/config.py b/ai_knowledge_base_v2/apps/backend/app/core/config.py index 258d751..df44857 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -33,6 +33,11 @@ class Settings(BaseSettings): mock_sms_code: str = "123456" sms_code_expire_minutes: int = 5 mock_rag_enabled: bool = True + mock_model_enabled: bool = True + feishu_mock_enabled: bool = True + feishu_search_url: str = "" + feishu_timeout_seconds: int = 20 + feishu_retry_count: int = 2 default_daily_chat_limit: int = 100 default_user_name_prefix: str = "用户" diff --git a/ai_knowledge_base_v2/apps/backend/app/services/ai_request_log_service.py b/ai_knowledge_base_v2/apps/backend/app/services/ai_request_log_service.py index 768b91e..bd773ae 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/ai_request_log_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/ai_request_log_service.py @@ -37,3 +37,32 @@ class AiRequestLogService: status="SUCCESS", ) ) + + @staticmethod + def write_failed( + db: Session, + *, + session_id: int, + message_id: int | None, + user_id: int, + model_name: str | None, + prompt: str | None, + knowledge_ids: str | None, + retrieve_count: int, + cost_ms: int, + error_message: str, + ) -> None: + db.add( + AiRequestLog( + session_id=session_id, + message_id=message_id, + user_id=user_id, + model_name=model_name, + prompt=prompt, + knowledge_ids=knowledge_ids, + retrieve_count=retrieve_count, + cost_ms=cost_ms, + status="FAILED", + error_message=error_message, + ) + ) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py index f35937e..bb77b85 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_service.py @@ -10,6 +10,7 @@ from sqlalchemy.orm import Session from app.models.chat import ChatMessage, ChatSession from app.models.user import User from app.services.ai_request_log_service import AiRequestLogService +from app.services.external_errors import ExternalServiceError from app.services.model_service import ModelClientService from app.services.rag_service import RagService @@ -86,8 +87,28 @@ class ChatService: db.flush() started_at = perf_counter() - rag_result = RagService.build_result(db, user, normalized_question) - completion = ModelClientService.complete(db, rag_result) + try: + rag_result = RagService.build_result(db, user, normalized_question) + completion = ModelClientService.complete(db, rag_result) + except ExternalServiceError as exc: + cost_ms = int((perf_counter() - started_at) * 1000) + AiRequestLogService.write_failed( + db, + session_id=session.id, + message_id=user_message.id, + user_id=user.id, + model_name=None, + prompt=normalized_question, + knowledge_ids=None, + retrieve_count=0, + cost_ms=cost_ms, + error_message=str(exc), + ) + db.commit() + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail="外部服务暂时不可用,请稍后再试", + ) from exc cost_ms = int((perf_counter() - started_at) * 1000) assistant_message = ChatMessage( diff --git a/ai_knowledge_base_v2/apps/backend/app/services/external_errors.py b/ai_knowledge_base_v2/apps/backend/app/services/external_errors.py new file mode 100644 index 0000000..209a7d6 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/external_errors.py @@ -0,0 +1,7 @@ +from __future__ import annotations + + +class ExternalServiceError(RuntimeError): + def __init__(self, message: str, *, provider: str) -> None: + super().__init__(message) + self.provider = provider diff --git a/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py new file mode 100644 index 0000000..8f952a9 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +import httpx + +from app.core.config import get_settings +from app.services.external_errors import ExternalServiceError +from app.services.knowledge_service import KnowledgeScope + +if TYPE_CHECKING: + from app.services.rag_service import RetrievedChunk + + +class FeishuKnowledgeService: + _mock_documents = [ + { + "title": "一期产品目标", + "keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"}, + "content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、权限内知识库回答和后台管理能力。", + }, + { + "title": "权限过滤规则", + "keywords": {"权限", "授权", "越权", "用户", "知识库", "过期", "禁用"}, + "content": "RAG 检索前必须先过滤用户授权知识库,只允许未禁用、未过期、当前用户有权限的知识库参与回答。", + }, + { + "title": "无命中兜底规则", + "keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"}, + "content": "当知识库没有命中相关内容时,系统必须固定返回无命中兜底文案。", + }, + { + "title": "技术实现边界", + "keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"}, + "content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。", + }, + ] + + @classmethod + def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]: + if not scopes: + return [] + + settings = get_settings() + if settings.feishu_mock_enabled: + return cls._retrieve_mock(question, scopes) + return cls._retrieve_remote(question, scopes) + + @classmethod + def _retrieve_mock(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]: + from app.services.rag_service import RetrievedChunk + + normalized_question = question.lower() + matched_documents = [] + for document in cls._mock_documents: + if any(keyword.lower() in normalized_question for keyword in document["keywords"]): + matched_documents.append(document) + + if not matched_documents: + return [] + + primary_scope = scopes[0] + return [ + RetrievedChunk( + knowledge_id=primary_scope.id, + knowledge_name=primary_scope.name, + title=document["title"], + content=document["content"], + source_url=None, + ) + for document in matched_documents[:3] + ] + + @classmethod + def _retrieve_remote(cls, question: str, scopes: list[KnowledgeScope]) -> list["RetrievedChunk"]: + from app.services.rag_service import RetrievedChunk + + settings = get_settings() + if not settings.feishu_search_url: + raise ExternalServiceError("飞书检索地址未配置", provider="feishu") + + payload = { + "query": question, + "knowledgeScopes": [ + { + "id": scope.id, + "spaceId": scope.feishu_space_id, + "nodeId": scope.feishu_node_id, + "name": scope.name, + } + for scope in scopes + ], + } + data = cls._post_with_retry(settings.feishu_search_url, payload) + chunks = data.get("chunks", []) + return [ + RetrievedChunk( + knowledge_id=int(item.get("knowledgeId") or 0), + knowledge_name=str(item.get("knowledgeName") or "飞书知识库"), + title=str(item.get("title") or "未命名片段"), + content=str(item.get("content") or ""), + source_url=item.get("sourceUrl"), + ) + for item in chunks + if item.get("content") + ] + + @staticmethod + def _post_with_retry(url: str, payload: dict[str, Any]) -> dict[str, Any]: + settings = get_settings() + last_error: Exception | None = None + for _ in range(settings.feishu_retry_count + 1): + try: + response = httpx.post(url, json=payload, timeout=settings.feishu_timeout_seconds) + response.raise_for_status() + data = response.json() + if not isinstance(data, dict): + raise ExternalServiceError("飞书检索响应格式不正确", provider="feishu") + return data + except (httpx.HTTPError, ValueError, ExternalServiceError) as exc: + last_error = exc + raise ExternalServiceError(f"飞书检索失败:{last_error}", provider="feishu") diff --git a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py index 038f608..8d2307e 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/model_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -1,11 +1,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import Any +import httpx from sqlalchemy import select from sqlalchemy.orm import Session +from app.core.config import get_settings from app.models.ai_config import ModelConfig +from app.services.external_errors import ExternalServiceError from app.services.rag_service import NO_HIT_ANSWER, RagResult @@ -27,8 +31,14 @@ class ModelClientService: .order_by(ModelConfig.id.desc()) .limit(1) ) - model_name = model.model_name if model is not None else "mock-model" - answer = _mock_answer(rag_result) + settings = get_settings() + if settings.mock_model_enabled or model is None: + model_name = model.model_name if model is not None else "mock-model" + answer = _mock_answer(rag_result) + else: + model_name = model.model_name + answer = _call_openai_compatible_model(model, rag_result) + return ModelCompletion( answer=answer, model_id=model.id if model is not None else None, @@ -55,3 +65,55 @@ def _mock_answer(rag_result: RagResult) -> str: def _rough_token_count(text: str) -> int: return max(1, len(text.strip()) // 2) + + +def _call_openai_compatible_model(model: ModelConfig, rag_result: RagResult) -> str: + if not rag_result.is_hit: + return NO_HIT_ANSWER + if not model.api_url or not model.api_key: + raise ExternalServiceError("模型 API URL 或 API Key 未配置", provider="model") + + payload = { + "model": model.model_name, + "messages": [ + {"role": "system", "content": "你是企业知识库问答助手,只能基于已提供的知识片段回答。"}, + {"role": "user", "content": rag_result.prompt}, + ], + "temperature": float(model.temperature) if model.temperature is not None else 0.2, + "max_tokens": model.max_token or 1024, + "stream": False, + } + headers = { + "Authorization": f"Bearer {model.api_key}", + "Content-Type": "application/json", + } + try: + response = httpx.post( + model.api_url, + json=payload, + headers=headers, + timeout=model.timeout_second, + ) + response.raise_for_status() + return _extract_answer(response.json()) + except (httpx.HTTPError, ValueError, KeyError, TypeError) as exc: + raise ExternalServiceError(f"模型调用失败:{exc}", provider="model") from exc + + +def _extract_answer(data: dict[str, Any]) -> str: + choices = data.get("choices") + if not isinstance(choices, list) or not choices: + raise ValueError("模型响应缺少 choices") + + first_choice = choices[0] + if not isinstance(first_choice, dict): + raise ValueError("模型响应 choices 格式不正确") + + message = first_choice.get("message") + if isinstance(message, dict) and message.get("content"): + return str(message["content"]) + + if first_choice.get("text"): + return str(first_choice["text"]) + + raise ValueError("模型响应缺少回答内容") diff --git a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py index ab73d4f..51ecb4d 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py @@ -7,6 +7,7 @@ from sqlalchemy.orm import Session from app.models.ai_config import Prompt from app.models.user import User +from app.services.feishu_service import FeishuKnowledgeService from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。" @@ -46,57 +47,6 @@ class RagService: return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt) -class FeishuKnowledgeService: - _mock_documents = [ - { - "title": "一期产品目标", - "keywords": {"一期", "目标", "问答", "登录", "知识库", "飞书", "用户端", "后台"}, - "content": "一期要交付企业飞书知识库 AI 问答系统,核心包括用户登录、AI 问答、权限内知识库回答和后台管理能力。", - }, - { - "title": "权限过滤规则", - "keywords": {"权限", "授权", "越权", "用户", "知识库", "过期", "禁用"}, - "content": "RAG 检索前必须先过滤用户授权知识库,只允许未禁用、未过期、当前用户有权限的知识库参与回答。", - }, - { - "title": "无命中兜底规则", - "keywords": {"无命中", "兜底", "编造", "检索", "答案", "命中"}, - "content": f"当知识库没有命中相关内容时,系统必须固定返回:{NO_HIT_ANSWER}", - }, - { - "title": "技术实现边界", - "keywords": {"模型", "prompt", "大模型", "日志", "sse", "流式", "追溯"}, - "content": "后端需要组装系统 Prompt、检索片段、历史上下文和当前问题,通过 SSE 流式输出,并记录 AI 请求日志。", - }, - ] - - @classmethod - def retrieve(cls, question: str, scopes: list[KnowledgeScope]) -> list[RetrievedChunk]: - if not scopes: - return [] - - normalized_question = question.lower() - matched_documents = [] - for document in cls._mock_documents: - if any(keyword.lower() in normalized_question for keyword in document["keywords"]): - matched_documents.append(document) - - if not matched_documents: - return [] - - primary_scope = scopes[0] - return [ - RetrievedChunk( - knowledge_id=primary_scope.id, - knowledge_name=primary_scope.name, - title=document["title"], - content=document["content"], - source_url=None, - ) - for document in matched_documents[:3] - ] - - class PromptService: _default_prompt = ( "你是企业飞书知识库 AI 助手。你只能基于提供的知识片段回答,不能编造。" diff --git a/ai_knowledge_base_v2/apps/backend/requirements.txt b/ai_knowledge_base_v2/apps/backend/requirements.txt index 4f0fcb4..ee6684e 100644 --- a/ai_knowledge_base_v2/apps/backend/requirements.txt +++ b/ai_knowledge_base_v2/apps/backend/requirements.txt @@ -8,3 +8,4 @@ python-dotenv>=1.1.0 PyJWT>=2.10.0 PyMySQL>=1.1.1 cryptography>=45.0.0 +httpx>=0.28.0 diff --git a/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md b/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md index 6908553..819964d 100644 --- a/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md +++ b/ai_knowledge_base_v2/development_records/2026-07-06-decision-register.md @@ -31,6 +31,8 @@ | D-008 | 生产密钥不明文展示,不写入日志。 | 默认采用 | 降低模型 Key、飞书密钥、短信密钥泄露风险。 | | D-009 | 用户端 Markdown 渲染使用 `markdown-it`,不手写 Markdown 解析器。 | 默认采用 | 减少列表、代码块、表格等格式兼容问题,降低后续维护成本。 | | D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 | +| D-011 | 阶段四外部服务默认保持 mock 开启,通过配置关闭 mock 后再走真实 provider。 | 默认采用 | 当前缺少真实飞书检索地址和模型凭证,默认 mock 可以保证开发、演示和测试连续。 | +| D-012 | 大模型真实调用先按 OpenAI 兼容 `chat/completions` 非流式响应接入。 | 默认采用 | 先打通配置化真实模型调用和失败日志,后续再升级为模型原生流式透传。 | ## 待确认决策 @@ -41,6 +43,7 @@ | Q-003 | 大模型供应商、API URL、模型名和鉴权方式是什么? | 先按 OpenAI 兼容接口 | 接入真实模型前。 | | Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 | | Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 | +| Q-006 | 飞书检索是否直接调飞书原生 API,还是先由独立适配服务封装? | 当前先预留 `FEISHU_SEARCH_URL` 适配服务入口 | 飞书账号、权限和 API 返回结构确认后。 | ## 当前可进入阶段二的判断 diff --git a/ai_knowledge_base_v2/development_records/2026-07-06-phase4-rag-external-services.md b/ai_knowledge_base_v2/development_records/2026-07-06-phase4-rag-external-services.md new file mode 100644 index 0000000..f9cdd73 --- /dev/null +++ b/ai_knowledge_base_v2/development_records/2026-07-06-phase4-rag-external-services.md @@ -0,0 +1,55 @@ +# 阶段四记录:RAG 和外部服务接入骨架 + +日期:2026-07-06 + +## 本次目标 + +把阶段四从纯 mock 推进到可替换的外部服务接入骨架:飞书检索、模型调用、外部异常、失败日志和配置开关都要有明确边界。 + +## 已完成 + +- 新增统一外部服务异常 `ExternalServiceError`。 +- 拆出 `FeishuKnowledgeService`: + - 默认 `FEISHU_MOCK_ENABLED=true`,继续使用本地 mock 文档。 + - 关闭 mock 后通过 `FEISHU_SEARCH_URL` 调用远端检索适配服务。 + - 支持 `FEISHU_TIMEOUT_SECONDS` 和 `FEISHU_RETRY_COUNT`。 + - 远端返回统一解析为 `RetrievedChunk`。 +- 增强 `ModelClientService`: + - 默认 `MOCK_MODEL_ENABLED=true`,继续使用 mock 模型。 + - 关闭 mock 且后台存在启用模型时,按 OpenAI 兼容 `chat/completions` 非流式格式调用。 + - 支持读取模型表中的 `api_url`、`api_key`、`model_name`、`temperature`、`max_token` 和 `timeout_second`。 +- 增强 AI 请求日志: + - 成功时记录完整请求日志。 + - 飞书或模型异常时记录失败日志。 +- 增强 `/chat/completions`: + - 外部服务异常会返回明确错误。 + - 失败时不消耗用户每日额度。 + +## 当前配置 + +```env +MOCK_RAG_ENABLED=true +MOCK_MODEL_ENABLED=true +FEISHU_MOCK_ENABLED=true +FEISHU_SEARCH_URL= +FEISHU_TIMEOUT_SECONDS=20 +FEISHU_RETRY_COUNT=2 +``` + +## 当前边界 + +- 真实飞书原生 API 尚未直接接入,因为还需要确认账号权限、SpaceID/NodeID 检索方式和返回结构。 +- 当前预留的是 `FEISHU_SEARCH_URL` 适配服务入口,后续可以选择直接接飞书原生 API,也可以由单独适配服务封装飞书复杂鉴权。 +- 真实模型当前先支持 OpenAI 兼容非流式响应;用户端仍通过后端 SSE 分块输出。 +- 模型原生流式透传和后端真实停止生成需要在模型供应商和 SDK/协议确认后继续补。 + +## 阶段四判断 + +阶段四的工程边界已经建立,可以支持后续真实接入: + +1. 权限过滤在检索前执行。 +2. 飞书检索 provider 可替换。 +3. Prompt 组装已独立。 +4. 模型 provider 可替换。 +5. 成功和失败 AI 请求日志都有记录路径。 +6. 无命中兜底规则已生效。 diff --git a/ai_knowledge_base_v2/development_records/README.md b/ai_knowledge_base_v2/development_records/README.md index 48924fd..9e57981 100644 --- a/ai_knowledge_base_v2/development_records/README.md +++ b/ai_knowledge_base_v2/development_records/README.md @@ -30,3 +30,4 @@ | `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 | | `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 | | `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 | +| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 | diff --git a/ai_knowledge_base_v2/docker-compose.dev.yml b/ai_knowledge_base_v2/docker-compose.dev.yml index 7080ab8..92d31cf 100644 --- a/ai_knowledge_base_v2/docker-compose.dev.yml +++ b/ai_knowledge_base_v2/docker-compose.dev.yml @@ -33,6 +33,8 @@ services: MOCK_SMS_ENABLED: "true" MOCK_SMS_CODE: "123456" MOCK_RAG_ENABLED: "true" + MOCK_MODEL_ENABLED: "true" + FEISHU_MOCK_ENABLED: "true" AUTO_CREATE_TABLES: "false" ports: - "8100:8100"