fix: validate retrieval-only chat questions before dispatch

This commit is contained in:
2026-10-17 12:17:52 +08:00
parent 1398938437
commit 4efcf83e4c
9 changed files with 217 additions and 16 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
# 那拉提文旅智能问答 # 那拉提文旅智能问答
Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核、关键词/向量/RRF 检索、文本与向量发布、历史记录及认证/角色权限,问答仍为占位接口。 Vue 3 + TypeScript + Vite 前端;FastAPI + Pydantic + SQLAlchemy 后端;PostgreSQL + pgvector。当前后端提供知识管理、来源、文档、人工审核、关键词/向量/RRF 检索、文本与向量发布、历史记录及认证/角色权限,`/api/chat` 提供条目级关键词证据检索,不生成模型答案。
## 启动数据库 ## 启动数据库
需要可用的 Docker Engine(Windows 可使用 Docker Desktop),在项目根目录运行: 需要可用的 Docker Engine(Windows 可使用 Docker Desktop),在项目根目录运行:
@@ -48,7 +48,7 @@ Vite 通常使用 http://localhost:5173。
- 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md) - 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md)
- GET `/api/health` - GET `/api/health`
- GET `/api/ready`:检查数据库连接 - GET `/api/ready`:检查数据库连接
- POST `/api/chat`:占位接口,尚未接入检索和 LLM - POST `/api/chat`:已核验条目关键词检索,问题1–500字符;返回 retrieval_only/no_evidence 和来源,详见 [CHAT_API.md](docs/CHAT_API.md)
- GET `/api/knowledge` - GET `/api/knowledge`
- POST `/api/knowledge` - POST `/api/knowledge`
- GET `/api/knowledge/page`、GET/PATCH/DELETE `/api/knowledge/{id}` - GET `/api/knowledge/page`、GET/PATCH/DELETE `/api/knowledge/{id}`
@@ -107,7 +107,7 @@ Vite 通常使用 http://localhost:5173。
前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。 前端包括问答、知识库管理、来源与更新、实验评估四个页面。实验评估页面没有虚构数据;所有页面明确标记当前功能状态。
注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 仍是占位接口,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md) 与 [导出/采集时间复核](docs/EXPORT_BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。 注意:当前已实现本地 Embedding、文本/向量快照、精确向量及 RRF 混合检索;`/api/chat` 已接入独立关键词检索,生成模型实际调用暂缓,网络搜索、后台更新任务和真实领域评测尚未实现。管理接口由服务端认证与角色校验;审核表单的核验人仍是人工输入字段。已完成隔离环境的浏览器 Cookie/CSRF、角色与真实检索核心流程验收,见 [浏览器验收记录](docs/BROWSER_ACCEPTANCE.md) 与 [导出/采集时间复核](docs/EXPORT_BROWSER_ACCEPTANCE.md);完整生产部署、Docker 数据库与 HTTPS 验收仍待完成。`.env` 不要提交到 Git。
### 后端测试和迁移回滚 ### 后端测试和迁移回滚
+1 -4
View File
@@ -26,9 +26,6 @@ def chat(payload: ChatRequest, db: Session = Depends(get_db)) -> ChatResponse:
"因此系统仅返回可追溯证据,不生成或改写答案。" "因此系统仅返回可追溯证据,不生成或改写答案。"
), ),
status="retrieval_only", status="retrieval_only",
sources=[ sources=[{"title": hit.title, "source_url": hit.source_url} for hit in result.results],
{"title": hit.title, "source_url": hit.source_url}
for hit in result.results
],
retrieval_run_id=result.run_id, retrieval_run_id=result.run_id,
) )
+6 -3
View File
@@ -1,11 +1,14 @@
from typing import Literal from typing import Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel
from app.schemas.catalog import Input
from app.schemas.retrieval import QueryText
class ChatRequest(BaseModel): class ChatRequest(Input):
question: str = Field(min_length=1, max_length=2000) question: QueryText
class SourcePreview(BaseModel): class SourcePreview(BaseModel):
+4 -2
View File
@@ -1,14 +1,16 @@
from datetime import datetime from datetime import datetime
from typing import Literal from typing import Annotated, Literal
from uuid import UUID from uuid import UUID
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.schemas.catalog import Category, Input from app.schemas.catalog import Category, Input
QueryText = Annotated[str, Field(min_length=1, max_length=500)]
class RetrievalRequest(Input): class RetrievalRequest(Input):
query: str = Field(min_length=1, max_length=500) query: QueryText
mode: Literal["keyword"] = "keyword" mode: Literal["keyword"] = "keyword"
top_k: int = Field(default=5, ge=1, le=20) top_k: int = Field(default=5, ge=1, le=20)
category: Category | None = None category: Category | None = None
+161
View File
@@ -0,0 +1,161 @@
"""Retrieval-only chat integration; synthetic evidence, never a generation call."""
import copy
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select, text
from sqlalchemy.orm import Session
from test_catalog import document, source
from test_retrieval import add_item
from test_runtime import server
from app.models.rag import RagContext
from app.models.retrieval import RetrievalRun
from app.services import retrieval
def test_overlong_chat_question_is_rejected_at_the_http_boundary(client):
with TestClient(
client.app, base_url="https://testserver", raise_server_exceptions=False
) as public:
response = public.post("/api/chat", json={"question": "q" * 501})
assert response.status_code == 422, response.text
@pytest.mark.parametrize(
"payload",
[
{},
{"question": ""},
{"question": " \t\n "},
{"question": None},
{"question": 123},
{"question": "q" * 2000},
{"question": "q" * 2001},
{"question": "alpha", "model": "not-accepted"},
{"question": "alpha", "messages": []},
],
)
def test_invalid_chat_input_creates_no_retrieval_or_context(client, migrated_database, payload):
response = client.post("/api/chat", json=payload)
assert response.status_code == 422, response.text
assert all("input" not in error for error in response.json()["detail"])
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
def test_chat_query_boundary_normalization_and_public_history_permissions(client):
response = client.post("/api/chat", json={"question": "q" * 500})
assert response.status_code == 200, response.text
assert response.json()["status"] == "no_evidence"
client.cookies.clear()
public = client.post("/api/chat", json={"question": " alpha "})
assert public.status_code == 200
assert public.json()["status"] == "no_evidence" and public.json()["sources"] == []
assert client.get(f"/api/retrieval/runs/{public.json()['retrieval_run_id']}").status_code == 401
def test_chat_sources_match_audited_snapshot_and_history_stays_immutable(client, migrated_database):
approved = add_item(client, title="verified alpha", content="alpha synthetic evidence")
add_item(client, status="pending", title="pending alpha", content="alpha unreviewed")
add_item(client, status="rejected", title="rejected alpha", content="alpha rejected")
deleted = add_item(client, title="deleted alpha", content="alpha deleted")
assert (
client.delete(
f"/api/knowledge/{deleted['id']}?expected_version={deleted['version']}"
).status_code
== 204
)
result = client.post("/api/chat", json={"question": " alpha "}).json()
assert result["status"] == "retrieval_only" and "不生成" in result["answer"]
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
assert history["query"] == "alpha" and history["mode"] == "keyword"
assert [h["knowledge_id"] for h in history["results"]] == [approved["id"]]
assert result["sources"] == [
{"title": h["title"], "source_url": h["source_url"]} for h in history["results"]
]
saved = copy.deepcopy(history)
assert (
client.patch(
f"/api/knowledge/{approved['id']}",
json={"expected_version": approved["version"], "content": "revised alpha"},
).status_code
== 200
)
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "no_evidence"
assert client.get(f"/api/retrieval/runs/{history['run_id']}").json() == saved
assert (
client.post(
"/api/rag/contexts",
json={"retrieval_run_id": history["run_id"], "completion": {"model": "contract-model"}},
).status_code
== 422
)
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RagContext)) == 0
@pytest.mark.parametrize("change", ["source", "document"])
def test_chat_cannot_reuse_evidence_after_catalog_revision(client, change):
src = source(client)
doc = document(client, src["id"])
add_item(client, document_id=doc["id"], content="alpha synthetic catalog evidence")
assert client.post("/api/chat", json={"question": "alpha"}).json()["status"] == "retrieval_only"
path, version, fields = (
(f"sources/{src['id']}", src["version"], {"source_url": "https://example.com/new"})
if change == "source"
else (f"documents/{doc['id']}", doc["version"], {"content": "revised synthetic document"})
)
assert (
client.patch("/api/" + path, json={"expected_version": version, **fields}).status_code
== 200
)
result = client.post("/api/chat", json={"question": "alpha"}).json()
assert result["status"] == "no_evidence" and result["sources"] == []
def test_chat_capacity_failure_is_not_a_successful_empty_answer(
client, migrated_database, monkeypatch
):
add_item(client, content="alpha synthetic capacity evidence")
monkeypatch.setattr(retrieval, "MAX_ITEMS", 0)
response = client.post("/api/chat", json={"question": "alpha"})
assert response.status_code == 503 and "sources" not in response.json()
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_chat_failed_history_insert_does_not_leave_partial_rows(client, migrated_database):
with migrated_database.begin() as db:
db.execute(
text("ALTER TABLE retrieval_runs ADD CONSTRAINT reject_chat_run CHECK(false) NOT VALID")
)
assert client.post("/api/chat", json={"question": "alpha"}).status_code == 409
with Session(migrated_database) as db:
assert db.scalar(select(func.count()).select_from(RetrievalRun)) == 0
def test_chat_retrieval_history_survives_real_http_restart(migrated_database):
with server(migrated_database) as client:
assert client.post("/api/chat", json={"question": "q" * 501}).status_code == 422
item = add_item(client, content="alpha real HTTP synthetic evidence")
response = client.post("/api/chat", json={"question": " alpha "})
assert response.status_code == 200
result = response.json()
history = client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json()
assert history["results"][0]["knowledge_id"] == item["id"]
assert history["query"] == "alpha"
with server(migrated_database) as client:
assert client.get(f"/api/retrieval/runs/{result['retrieval_run_id']}").json() == history
exported = client.post(
"/api/retrieval/exports",
json={"run_ids": [result["retrieval_run_id"]], "include_contexts": True},
)
assert exported.status_code == 200
client.cookies.clear()
assert (
client.post("/api/chat", json={"question": "alpha"}).json()["status"]
== "retrieval_only"
)
+1 -1
View File
@@ -14,7 +14,7 @@ FastAPI + Pydantic
|-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本 |-- 来源获取 -> 人工核验 -> 文本切分 -> 生成向量 -> 发布版本
``` ```
当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;问答仍为占位接口。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及来源、文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核与浏览器 Cookie/PostgreSQL 联调仍需验收。 当前后端实现健康/数据库就绪检查、来源与文档管理、知识增删改查、分页筛选、人工审核、历史快照、认证会话/角色权限、文本/向量快照及独立关键词/向量/RRF 检索;`/api/chat` 为独立条目关键词检索适配,返回检索状态与来源,不调用生成模型(见 [CHAT_API.md](CHAT_API.md))。数据库由 Alembic 显式迁移并启用 pgvector,DeepSeek RAG 待明确恢复后实现。前端已接入登录/注册、个人中心、账户设置、会话恢复、路由守卫和角色菜单,以及来源、文档的创建、编辑、软删除、分页搜索与历史快照;实验页不伪造数据,知识审核与浏览器 Cookie/PostgreSQL 联调仍需验收。
## 阶段 2 数据边界 ## 阶段 2 数据边界
+30
View File
@@ -0,0 +1,30 @@
# 检索式聊天接口
`POST /api/chat` 是已核验知识的条目级关键词检索适配,不调用生成模型。它与问答页面直接使用的 chunk/RRF 接口不同,也不会创建 Responses 准备快照。生成模型与真实服务接入继续延期。
## 请求
```json
{"question":"那拉提示例关键词"}
```
接口公开可用,无需登录;只接受 question。服务端去除首尾空白后校验1–500个字符,与底层 RetrievalRequest 共用同一字段约束;字符数不代表模型token数。缺失、空白、错误类型、超长输入或额外字段(包括 model、messages、凭据)均返回422,不写检索记录。只有停用词或标点、没有有效检索词时同样返回422。错误不包含原始输入值。
原适配曾允许2000字符,内层检索只允许500字符,导致501–2000字符请求返回500。现在该范围在HTTP入参处明确返回422,不截断问题,也不绕过原检索限制。
## 响应与证据
成功返回 answer、status、sources 和 retrieval_run_id。answer 为固定的检索状态说明,**不是生成式回答**。
- status=retrieval_only:返回最多5条来源预览(title/source_url),来自同一条目关键词检索历史的排名结果。说明中的命中总数可以大于来源预览数;完整排名、正文片段、来源/知识版本、配置、语料指纹和耗时保存在原检索记录中。
- status=no_evidence:没有命中的有效已核验依据,sources为空;仍保存该次真实无命中检索的run ID。
待审核、拒绝、软删除、来源/文档失效或修订后等待重新审核的条目不作为来源。资料修订影响后续请求,不重写已经保存的历史快照。没有LLM调用量、生成答案或费用字段,不能把固定说明用于领域质量评分。
检索历史及JSONL导出仍要求管理员权限;公开获取run ID不授予历史读取权限。该run的mode=keyword、证据粒度为knowledge_item,不能传入只接收chunk检索的 `POST /api/rag/contexts`。需要Responses准备时先使用chunks/vector/hybrid检索。
容量超限/数据库不可用保留503,历史保存约束冲突返回409;错误不会伪装成200无命中,也不会保存部分历史。仅成功完成检索与历史事务后返回来源。无额外数据库迁移。
## 验证边界
测试使用合成条目、真实隔离PostgreSQL和默认应用HTTP生命周期,覆盖500字符边界、501字符HTTP失败复现与修正、参数拒绝不写记录、公开调用与历史权限、审核筛选、资料修订/历史不变、容量与事务失败,以及进程重启后的历史和导出。没有模型调用或新增浏览器验收。
+1 -1
View File
@@ -2,7 +2,7 @@
本阶段按用户要求先实现通用请求格式,生成模型选择和真实调用延期。新准备请求采用非流式 **Responses API**(服务端点为 `<base_url>/responses`):`model/instructions/input/max_output_tokens`,从结构化 `output` 读取 assistant 文本。依据 [OpenAI Responses 迁移说明](https://developers.openai.com/api/docs/guides/migrate-to-responses)、[DeepSeek Responses 兼容表](https://api-docs.deepseek.com/guides/responses_api/) 与[百炼 Responses 文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-responses)。当前仍为纯组包和解析,不绑定实际服务地址、密钥或模型,不发出 HTTP 生成请求。 本阶段按用户要求先实现通用请求格式,生成模型选择和真实调用延期。新准备请求采用非流式 **Responses API**(服务端点为 `<base_url>/responses`):`model/instructions/input/max_output_tokens`,从结构化 `output` 读取 assistant 文本。依据 [OpenAI Responses 迁移说明](https://developers.openai.com/api/docs/guides/migrate-to-responses)、[DeepSeek Responses 兼容表](https://api-docs.deepseek.com/guides/responses_api/) 与[百炼 Responses 文档](https://help.aliyun.com/zh/model-studio/qwen-api-via-openai-responses)。当前仍为纯组包和解析,不绑定实际服务地址、密钥或模型,不发出 HTTP 生成请求。
本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 仍保留原占位状态,尚未成为生成式问答接口;准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。 本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 是独立的条目级关键词检索适配,返回检索状态和来源,尚未成为生成式问答接口(见 [CHAT_API.md](CHAT_API.md));准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。
## 准备与读取 ## 准备与读取
+10 -2
View File
@@ -11,9 +11,9 @@
| T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) | | T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) |
| T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) | | T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) |
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) | | T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | 协议基础已合入(Responses 请求/解析调整已在独立分支通过验证,待审;生成模型选型与实际调用延期) | | T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | Responses 协议已合入(请求/解析与旧快照兼容已完成;生成模型选型与实际调用延期) |
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 | | T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF、224项回归及浏览器核心流程已通过;Docker/HTTPS/部署待验收) | | T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF及浏览器核心流程已通过;聊天边界修正和312项分支回归待审,Docker/HTTPS/部署待验收) |
## 2026-10-15 首次联调记录 ## 2026-10-15 首次联调记录
@@ -126,3 +126,11 @@
- 本轮 `pytest -q backend/tests/test_rag.py backend/tests/test_exports.py` **113 passed**;完整 `pytest -q backend/tests` **294 passed, 1 skipped**。skip 为显式启用的真实 Embedding 专项,本轮不运行模型。ruff检查/格式检查及前端类型/生产构建通过;未修改页面交互,不宣称新增浏览器验收。 - 本轮 `pytest -q backend/tests/test_rag.py backend/tests/test_exports.py` **113 passed**;完整 `pytest -q backend/tests` **294 passed, 1 skipped**。skip 为显式启用的真实 Embedding 专项,本轮不运行模型。ruff检查/格式检查及前端类型/生产构建通过;未修改页面交互,不宣称新增浏览器验收。
- PR #44 合入后同步最新 dev,保留两份阶段记录并解决本文件追加冲突。此次同步后重新执行前端时区6项、会话3项及类型/生产构建,均通过;backend树与此前294项回归时一致,本次未重复运行全量后端测试,也未新增模型调用或浏览器验收。 - PR #44 合入后同步最新 dev,保留两份阶段记录并解决本文件追加冲突。此次同步后重新执行前端时区6项、会话3项及类型/生产构建,均通过;backend树与此前294项回归时一致,本次未重复运行全量后端测试,也未新增模型调用或浏览器验收。
## 2026-10-17 T10:检索式聊天边界与后端验收
- PR #45 的 head/merge 均已核对进入 dev,再从最新 dev 创建 `fix/backend-chat-validation`。新合入 PR #46 已实现 `/api/chat` 的检索式适配,本轮补上后端边界修正和集成验收,不重复实现检索、不接入生成模型。
- 已复现501字符问题返回500:外层ChatRequest允许2000字符,内层RetrievalRequest只允许500字符。现在两者共用QueryText与输入规范,问题去首尾空白后1–500字符,超长/空白/错误类型/额外字段在HTTP边界返回422,不静默截断,也不写检索历史。
- 补充有效审核来源筛选、资料修订后重新审核、公开聊天/私有历史权限、历史与导出持久化、容量503及写入409完整回滚验证;真实HTTP重启后检索历史保持一致。Chat仅生成固定检索状态说明,条目级记录不能传给chunk级Responses准备。完整契约见 [CHAT_API.md](CHAT_API.md)。
- 专项 `pytest -q backend/tests/test_chat.py backend/tests/test_retrieval.py` **35 passed**;完整 `pytest -q backend/tests` **312 passed, 1 skipped**,skip为显式启用的真实Embedding专项,本轮未运行模型。ruff检查/格式检查、diff检查和最新前端类型/生产构建通过。API路径、响应字段和数据库迁移不变;README/架构文档更正已过期的聊天占位说明。无新增浏览器验收或领域分数。