Add V2 external service providers

This commit is contained in:
2026-07-06 18:03:29 +08:00
parent 86cbed2437
commit 301661e7b4
13 changed files with 318 additions and 55 deletions

View File

@@ -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=用户

View File

@@ -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 = "用户"

View File

@@ -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,
)
)

View File

@@ -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()
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(

View File

@@ -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

View File

@@ -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")

View File

@@ -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)
)
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("模型响应缺少回答内容")

View File

@@ -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 助手。你只能基于提供的知识片段回答,不能编造。"

View File

@@ -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

View File

@@ -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 返回结构确认后。 |
## 当前可进入阶段二的判断

View File

@@ -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. 无命中兜底规则已生效。

View File

@@ -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 和外部服务接入骨架记录。 |

View File

@@ -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"