Add V2 external service providers
This commit is contained in:
@@ -14,6 +14,11 @@ MOCK_SMS_ENABLED=true
|
|||||||
MOCK_SMS_CODE=123456
|
MOCK_SMS_CODE=123456
|
||||||
SMS_CODE_EXPIRE_MINUTES=5
|
SMS_CODE_EXPIRE_MINUTES=5
|
||||||
MOCK_RAG_ENABLED=true
|
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_DAILY_CHAT_LIMIT=100
|
||||||
DEFAULT_USER_NAME_PREFIX=用户
|
DEFAULT_USER_NAME_PREFIX=用户
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ class Settings(BaseSettings):
|
|||||||
mock_sms_code: str = "123456"
|
mock_sms_code: str = "123456"
|
||||||
sms_code_expire_minutes: int = 5
|
sms_code_expire_minutes: int = 5
|
||||||
mock_rag_enabled: bool = True
|
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_daily_chat_limit: int = 100
|
||||||
default_user_name_prefix: str = "用户"
|
default_user_name_prefix: str = "用户"
|
||||||
|
|||||||
@@ -37,3 +37,32 @@ class AiRequestLogService:
|
|||||||
status="SUCCESS",
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.models.chat import ChatMessage, ChatSession
|
from app.models.chat import ChatMessage, ChatSession
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.services.ai_request_log_service import AiRequestLogService
|
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.model_service import ModelClientService
|
||||||
from app.services.rag_service import RagService
|
from app.services.rag_service import RagService
|
||||||
|
|
||||||
@@ -86,8 +87,28 @@ class ChatService:
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
started_at = perf_counter()
|
started_at = perf_counter()
|
||||||
|
try:
|
||||||
rag_result = RagService.build_result(db, user, normalized_question)
|
rag_result = RagService.build_result(db, user, normalized_question)
|
||||||
completion = ModelClientService.complete(db, rag_result)
|
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)
|
cost_ms = int((perf_counter() - started_at) * 1000)
|
||||||
|
|
||||||
assistant_message = ChatMessage(
|
assistant_message = ChatMessage(
|
||||||
|
|||||||
@@ -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
|
||||||
122
ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
Normal file
122
ai_knowledge_base_v2/apps/backend/app/services/feishu_service.py
Normal 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")
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.models.ai_config import ModelConfig
|
from app.models.ai_config import ModelConfig
|
||||||
|
from app.services.external_errors import ExternalServiceError
|
||||||
from app.services.rag_service import NO_HIT_ANSWER, RagResult
|
from app.services.rag_service import NO_HIT_ANSWER, RagResult
|
||||||
|
|
||||||
|
|
||||||
@@ -27,8 +31,14 @@ class ModelClientService:
|
|||||||
.order_by(ModelConfig.id.desc())
|
.order_by(ModelConfig.id.desc())
|
||||||
.limit(1)
|
.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"
|
model_name = model.model_name if model is not None else "mock-model"
|
||||||
answer = _mock_answer(rag_result)
|
answer = _mock_answer(rag_result)
|
||||||
|
else:
|
||||||
|
model_name = model.model_name
|
||||||
|
answer = _call_openai_compatible_model(model, rag_result)
|
||||||
|
|
||||||
return ModelCompletion(
|
return ModelCompletion(
|
||||||
answer=answer,
|
answer=answer,
|
||||||
model_id=model.id if model is not None else None,
|
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:
|
def _rough_token_count(text: str) -> int:
|
||||||
return max(1, len(text.strip()) // 2)
|
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("模型响应缺少回答内容")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from app.models.ai_config import Prompt
|
from app.models.ai_config import Prompt
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.feishu_service import FeishuKnowledgeService
|
||||||
from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope
|
from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope
|
||||||
|
|
||||||
NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。"
|
NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。"
|
||||||
@@ -46,57 +47,6 @@ class RagService:
|
|||||||
return RagResult(question=question, knowledge_scopes=scopes, chunks=chunks, prompt=prompt)
|
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:
|
class PromptService:
|
||||||
_default_prompt = (
|
_default_prompt = (
|
||||||
"你是企业飞书知识库 AI 助手。你只能基于提供的知识片段回答,不能编造。"
|
"你是企业飞书知识库 AI 助手。你只能基于提供的知识片段回答,不能编造。"
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ python-dotenv>=1.1.0
|
|||||||
PyJWT>=2.10.0
|
PyJWT>=2.10.0
|
||||||
PyMySQL>=1.1.1
|
PyMySQL>=1.1.1
|
||||||
cryptography>=45.0.0
|
cryptography>=45.0.0
|
||||||
|
httpx>=0.28.0
|
||||||
|
|||||||
@@ -31,6 +31,8 @@
|
|||||||
| D-008 | 生产密钥不明文展示,不写入日志。 | 默认采用 | 降低模型 Key、飞书密钥、短信密钥泄露风险。 |
|
| D-008 | 生产密钥不明文展示,不写入日志。 | 默认采用 | 降低模型 Key、飞书密钥、短信密钥泄露风险。 |
|
||||||
| D-009 | 用户端 Markdown 渲染使用 `markdown-it`,不手写 Markdown 解析器。 | 默认采用 | 减少列表、代码块、表格等格式兼容问题,降低后续维护成本。 |
|
| D-009 | 用户端 Markdown 渲染使用 `markdown-it`,不手写 Markdown 解析器。 | 默认采用 | 减少列表、代码块、表格等格式兼容问题,降低后续维护成本。 |
|
||||||
| D-010 | 用户端停止生成先使用浏览器 `AbortController` 中断 SSE 读取,后端真实中断等接入真实模型时实现。 | 默认采用 | 当前后端仍是 mock 模型,前端先保证用户操作反馈真实有效。 |
|
| 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-003 | 大模型供应商、API URL、模型名和鉴权方式是什么? | 先按 OpenAI 兼容接口 | 接入真实模型前。 |
|
||||||
| Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 |
|
| Q-004 | 飞书知识库实时检索 API 是否满足 SpaceID/NodeID 检索要求? | 先做 mock + 技术验证 | 阶段二后端基础工程完成后尽快验证。 |
|
||||||
| Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 |
|
| Q-005 | 模型 API Key 生产环境如何保存? | 优先环境变量引用或加密存储 | 做模型管理功能前。 |
|
||||||
|
| Q-006 | 飞书检索是否直接调飞书原生 API,还是先由独立适配服务封装? | 当前先预留 `FEISHU_SEARCH_URL` 适配服务入口 | 飞书账号、权限和 API 返回结构确认后。 |
|
||||||
|
|
||||||
## 当前可进入阶段二的判断
|
## 当前可进入阶段二的判断
|
||||||
|
|
||||||
|
|||||||
@@ -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. 无命中兜底规则已生效。
|
||||||
@@ -30,3 +30,4 @@
|
|||||||
| `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 |
|
| `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 |
|
||||||
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
|
| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 |
|
||||||
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
| `2026-07-06-phase3-user-client-completion.md` | 阶段三用户端 H5 主链路补齐记录。 |
|
||||||
|
| `2026-07-06-phase4-rag-external-services.md` | 阶段四 RAG 和外部服务接入骨架记录。 |
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ services:
|
|||||||
MOCK_SMS_ENABLED: "true"
|
MOCK_SMS_ENABLED: "true"
|
||||||
MOCK_SMS_CODE: "123456"
|
MOCK_SMS_CODE: "123456"
|
||||||
MOCK_RAG_ENABLED: "true"
|
MOCK_RAG_ENABLED: "true"
|
||||||
|
MOCK_MODEL_ENABLED: "true"
|
||||||
|
FEISHU_MOCK_ENABLED: "true"
|
||||||
AUTO_CREATE_TABLES: "false"
|
AUTO_CREATE_TABLES: "false"
|
||||||
ports:
|
ports:
|
||||||
- "8100:8100"
|
- "8100:8100"
|
||||||
|
|||||||
Reference in New Issue
Block a user