diff --git a/ai_knowledge_base_v2/apps/backend/.env.example b/ai_knowledge_base_v2/apps/backend/.env.example index 266184e..bfc59a8 100644 --- a/ai_knowledge_base_v2/apps/backend/.env.example +++ b/ai_knowledge_base_v2/apps/backend/.env.example @@ -13,6 +13,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES=43200 MOCK_SMS_ENABLED=true MOCK_SMS_CODE=123456 SMS_CODE_EXPIRE_MINUTES=5 +MOCK_RAG_ENABLED=true DEFAULT_DAILY_CHAT_LIMIT=100 DEFAULT_USER_NAME_PREFIX=用户 diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py index 02e9302..aa6f37f 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -72,7 +72,7 @@ def completions( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ) -> StreamingResponse: - answer = ChatService.create_mock_answer(db, current_user, payload.sessionId, payload.message) + answer = ChatService.create_answer(db, current_user, payload.sessionId, payload.message) return StreamingResponse(_sse_chunks(answer), media_type="text/event-stream") 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 af6804c..258d751 100644 --- a/ai_knowledge_base_v2/apps/backend/app/core/config.py +++ b/ai_knowledge_base_v2/apps/backend/app/core/config.py @@ -32,6 +32,7 @@ class Settings(BaseSettings): mock_sms_enabled: bool = True mock_sms_code: str = "123456" sms_code_expire_minutes: int = 5 + mock_rag_enabled: bool = True 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 new file mode 100644 index 0000000..768b91e --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/ai_request_log_service.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from sqlalchemy.orm import Session + +from app.models.logs import AiRequestLog + + +class AiRequestLogService: + @staticmethod + def write_success( + db: Session, + *, + session_id: int, + message_id: int | None, + user_id: int, + model_name: str, + prompt: str, + knowledge_ids: str, + retrieve_count: int, + input_token: int, + output_token: int, + cost_ms: int, + ) -> 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, + input_token=input_token, + output_token=output_token, + total_token=input_token + output_token, + cost_ms=cost_ms, + status="SUCCESS", + ) + ) 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 b12887e..f35937e 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 @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import UTC, datetime +from time import perf_counter from fastapi import HTTPException, status from sqlalchemy import select @@ -8,6 +9,9 @@ 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.model_service import ModelClientService +from app.services.rag_service import RagService class ChatService: @@ -64,36 +68,65 @@ class ChatService: db.commit() @staticmethod - def create_mock_answer(db: Session, user: User, session_id: int, question: str) -> str: + def create_answer(db: Session, user: User, session_id: int, question: str) -> str: session = ChatService._get_user_session(db, user, session_id) + ChatService._ensure_quota(user) + now = _now() - answer = ( - "这是 mock AI 回复。当前阶段已经跑通后端聊天链路,后续会替换为:权限过滤、飞书实时检索、" - "Prompt 组装和真实大模型 SSE 输出。" - ) + normalized_question = question.strip() user_message = ChatMessage( session_id=session.id, user_id=user.id, role="user", - content=question.strip(), + content=normalized_question, message_status="FINISHED", created_at=now, ) + db.add(user_message) + db.flush() + + started_at = perf_counter() + rag_result = RagService.build_result(db, user, normalized_question) + completion = ModelClientService.complete(db, rag_result) + cost_ms = int((perf_counter() - started_at) * 1000) + assistant_message = ChatMessage( session_id=session.id, user_id=user.id, role="assistant", - content=answer, + content=completion.answer, message_status="FINISHED", + token_input=completion.input_token, + token_output=completion.output_token, + response_time_ms=cost_ms, + model_id=completion.model_id, created_at=now, ) + db.add(assistant_message) + db.flush() + session.message_count += 2 session.last_message_at = now if session.title == "新聊天": - session.title = _title_from_question(question) - db.add_all([user_message, assistant_message, session]) + session.title = _title_from_question(normalized_question) + + user.daily_chat_used += 1 + db.add_all([session, user]) + AiRequestLogService.write_success( + db, + session_id=session.id, + message_id=assistant_message.id, + user_id=user.id, + model_name=completion.model_name, + prompt=rag_result.prompt, + knowledge_ids=rag_result.knowledge_ids, + retrieve_count=len(rag_result.chunks), + input_token=completion.input_token, + output_token=completion.output_token, + cost_ms=cost_ms, + ) db.commit() - return answer + return completion.answer @staticmethod def stop_generation(db: Session, user: User, session_id: int) -> None: @@ -106,6 +139,11 @@ class ChatService: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") return session + @staticmethod + def _ensure_quota(user: User) -> None: + if user.daily_chat_used >= user.daily_chat_limit: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="今日提问次数已用完") + def _now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py new file mode 100644 index 0000000..9521a05 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/knowledge_service.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import UTC, datetime + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import get_settings +from app.models.knowledge import Knowledge, UserKnowledgePermission +from app.models.user import User + + +@dataclass(frozen=True) +class KnowledgeScope: + id: int + name: str + feishu_space_id: str + feishu_node_id: str + is_mock: bool = False + + +class KnowledgeAccessService: + @staticmethod + def get_allowed_knowledge(db: Session, user: User) -> list[KnowledgeScope]: + now = datetime.now(UTC).replace(tzinfo=None) + rows = db.execute( + select(Knowledge) + .join(UserKnowledgePermission, UserKnowledgePermission.knowledge_id == Knowledge.id) + .where( + UserKnowledgePermission.user_id == user.id, + Knowledge.status == 1, + (UserKnowledgePermission.effective_at.is_(None)) + | (UserKnowledgePermission.effective_at <= now), + (UserKnowledgePermission.expired_at.is_(None)) + | (UserKnowledgePermission.expired_at >= now), + ) + .order_by(Knowledge.id.asc()) + ).scalars() + scopes = [ + KnowledgeScope( + id=knowledge.id, + name=knowledge.name, + feishu_space_id=knowledge.feishu_space_id, + feishu_node_id=knowledge.feishu_node_id, + ) + for knowledge in rows + ] + if scopes or not get_settings().mock_rag_enabled: + return scopes + + return [ + KnowledgeScope( + id=0, + name="开发阶段默认知识库", + feishu_space_id="mock-space", + feishu_node_id="mock-node", + is_mock=True, + ) + ] 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 new file mode 100644 index 0000000..038f608 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/model_service.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.ai_config import ModelConfig +from app.services.rag_service import NO_HIT_ANSWER, RagResult + + +@dataclass(frozen=True) +class ModelCompletion: + answer: str + model_id: int | None + model_name: str + input_token: int + output_token: int + + +class ModelClientService: + @staticmethod + def complete(db: Session, rag_result: RagResult) -> ModelCompletion: + model = db.scalar( + select(ModelConfig) + .where(ModelConfig.enabled == 1) + .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) + return ModelCompletion( + answer=answer, + model_id=model.id if model is not None else None, + model_name=model_name, + input_token=_rough_token_count(rag_result.prompt), + output_token=_rough_token_count(answer), + ) + + +def _mock_answer(rag_result: RagResult) -> str: + if not rag_result.is_hit: + return NO_HIT_ANSWER + + summaries = "\n".join( + f"- {chunk.content}\n 来源:{chunk.knowledge_name} / {chunk.title}" for chunk in rag_result.chunks + ) + return ( + "根据当前已授权知识库,整理到的信息如下:\n\n" + f"{summaries}\n\n" + "当前仍是 mock RAG + mock 模型阶段,后续会把检索服务替换为飞书实时检索," + "把模型服务替换为后台启用的大模型配置。" + ) + + +def _rough_token_count(text: str) -> int: + return max(1, len(text.strip()) // 2) 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 new file mode 100644 index 0000000..ab73d4f --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/rag_service.py @@ -0,0 +1,124 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.ai_config import Prompt +from app.models.user import User +from app.services.knowledge_service import KnowledgeAccessService, KnowledgeScope + +NO_HIT_ANSWER = "当前知识库中未检索到相关内容,请联系管理员补充相关知识。" + + +@dataclass(frozen=True) +class RetrievedChunk: + knowledge_id: int + knowledge_name: str + title: str + content: str + source_url: str | None = None + + +@dataclass(frozen=True) +class RagResult: + question: str + knowledge_scopes: list[KnowledgeScope] + chunks: list[RetrievedChunk] + prompt: str + + @property + def is_hit(self) -> bool: + return len(self.chunks) > 0 + + @property + def knowledge_ids(self) -> str: + return ",".join(str(scope.id) for scope in self.knowledge_scopes) + + +class RagService: + @staticmethod + def build_result(db: Session, user: User, question: str) -> RagResult: + scopes = KnowledgeAccessService.get_allowed_knowledge(db, user) + chunks = FeishuKnowledgeService.retrieve(question, scopes) + prompt = PromptService.build_prompt(db, question, chunks) + 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 助手。你只能基于提供的知识片段回答,不能编造。" + "如果知识片段之间有冲突,需要说明不同来源的观点。" + ) + + @classmethod + def build_prompt(cls, db: Session, question: str, chunks: list[RetrievedChunk]) -> str: + prompt = cls._load_active_prompt(db) + context = "\n\n".join( + f"[知识片段 {index}] 来源:{chunk.knowledge_name} / {chunk.title}\n{chunk.content}" + for index, chunk in enumerate(chunks, start=1) + ) + if not context: + context = "未检索到相关知识片段。" + return f"{prompt}\n\n{context}\n\n用户问题:{question.strip()}" + + @classmethod + def _load_active_prompt(cls, db: Session) -> str: + prompt = db.scalar( + select(Prompt) + .order_by(Prompt.updated_at.desc(), Prompt.id.desc()) + .limit(1) + ) + return prompt.prompt_content if prompt else cls._default_prompt diff --git a/ai_knowledge_base_v2/development_records/2026-07-06-phase2-rag-skeleton.md b/ai_knowledge_base_v2/development_records/2026-07-06-phase2-rag-skeleton.md new file mode 100644 index 0000000..8840d0b --- /dev/null +++ b/ai_knowledge_base_v2/development_records/2026-07-06-phase2-rag-skeleton.md @@ -0,0 +1,60 @@ +# 阶段二记录:RAG 问答链路骨架 + +日期:2026-07-06 + +## 本次目标 + +把用户端 `/chat/completions` 从单一 mock 回复,升级为可替换的问答服务链路。当前仍不接真实飞书和真实大模型,但先把权限过滤、检索、Prompt、模型调用和 AI 请求日志的职责拆出来。 + +## 已完成 + +- 新增知识库权限服务 `KnowledgeAccessService`: + - 优先读取用户已授权、未禁用、未过期的知识库。 + - 开发阶段如果没有配置知识库授权,允许通过 `MOCK_RAG_ENABLED=true` 使用默认 mock 知识库,方便本地演示。 +- 新增 RAG 服务 `RagService`: + - 负责构建用户可访问知识库范围。 + - 负责调用 mock 飞书知识库检索。 + - 负责组装 Prompt。 +- 新增模型服务 `ModelClientService`: + - 优先读取当前启用模型配置。 + - 当前返回 mock 模型答案,后续替换为真实大模型流式调用。 +- 新增 AI 请求日志服务 `AiRequestLogService`: + - 每次问答记录 session、message、user、model、prompt、knowledge_ids、命中数、token 估算、耗时和状态。 +- 更新聊天服务: + - `/chat/completions` 已接入 RAG 骨架。 + - 提问前校验用户每日额度。 + - 提问成功后增加 `daily_chat_used`。 + - 保存用户消息、助手消息和 AI 请求日志。 + +## 当前 mock 规则 + +- 问题命中 mock 文档关键词时,返回基于知识片段整理的 Markdown 文本。 +- 问题没有命中任何知识片段时,固定返回: + +```text +当前知识库中未检索到相关内容,请联系管理员补充相关知识。 +``` + +## 当前边界 + +- 真实飞书知识库 API 尚未接入。 +- 真实大模型 API 尚未接入。 +- 每日额度目前使用 `sys_user.daily_chat_used` 简单计数,尚未做按自然日自动重置。 +- 停止生成接口仍是占位能力,后续接真实流式模型时再实现中断状态。 + +## 后续建议 + +1. 补前端 Markdown 渲染,让 mock RAG 的结构化回答在手机端可读。 +2. 做飞书知识库接口技术验证脚本,确认 SpaceID、NodeID、权限和返回结构。 +3. 接入后台 Prompt 和模型配置管理页面。 +4. 把每日额度改为按日期维度统计,避免长期累加。 + +## 验证记录 + +- `python -m compileall app` 已通过。 +- FastAPI OpenAPI 导入检查已通过,当前仍为 13 个接口路径。 +- Alembic MySQL 静态迁移 SQL 生成已通过。 +- RAG/模型服务级验证已通过: + - 命中问题可以返回带来源的 mock 回答。 + - 无命中问题会返回固定兜底文案。 +- SQLite 内存库不适合完整模拟当前 MySQL `BIGINT AUTO_INCREMENT` 主键行为,因此本次未使用 SQLite 做完整聊天端到端写库测试。 diff --git a/ai_knowledge_base_v2/development_records/README.md b/ai_knowledge_base_v2/development_records/README.md index 863deb5..5c39b4a 100644 --- a/ai_knowledge_base_v2/development_records/README.md +++ b/ai_knowledge_base_v2/development_records/README.md @@ -28,3 +28,4 @@ | `2026-07-06-phase2-backend-foundation.md` | 阶段二后端基础工程第一批记录。 | | `2026-07-06-phase2-user-client.md` | 阶段二用户端 Vue 3 H5 第一批记录。 | | `2026-07-06-phase2-dev-compose.md` | 阶段二本地开发编排记录。 | +| `2026-07-06-phase2-rag-skeleton.md` | 阶段二 RAG 问答链路骨架记录。 | diff --git a/ai_knowledge_base_v2/docker-compose.dev.yml b/ai_knowledge_base_v2/docker-compose.dev.yml index fec8be9..7080ab8 100644 --- a/ai_knowledge_base_v2/docker-compose.dev.yml +++ b/ai_knowledge_base_v2/docker-compose.dev.yml @@ -32,6 +32,7 @@ services: JWT_SECRET_KEY: local-dev-secret-change-before-production MOCK_SMS_ENABLED: "true" MOCK_SMS_CODE: "123456" + MOCK_RAG_ENABLED: "true" AUTO_CREATE_TABLES: "false" ports: - "8100:8100"