Compare commits

..
Author SHA1 Message Date
Kronecker 461eccde0c fix: respect document-bound knowledge sources 2026-10-20 00:09:34 +08:00
saint 1430d35196 docs: record knowledge traceability decisions
Reviewed documentation-only decision record.
2026-10-19 22:46:54 +08:00
saint 7cd4dd2f38 docs: record knowledge traceability decisions 2026-10-19 22:46:22 +08:00
saint 8cb60cc53a feat: support knowledge source selection
Reviewed frontend source selection and display.
2026-10-19 22:44:50 +08:00
saint 88b1952107 feat: support knowledge source selection 2026-10-19 22:44:02 +08:00
saint a1b3147f09 merge: knowledge collection review batch
Reviewed and verified knowledge collection batch.
2026-10-19 22:26:52 +08:00
saint 965613319b docs: clarify stage 2 collection acceptance 2026-10-19 22:15:22 +08:00
saint 0e1642eeab merge: accept stage 2 knowledge collection plan 2026-10-19 22:10:05 +08:00
saint cb1b64d5b0 merge: accept knowledge registration documentation 2026-10-19 22:10:05 +08:00
saint dfef3bc471 merge: accept knowledge collection timestamp persistence fix 2026-10-19 22:10:05 +08:00
yxxandClaude Code 009997e479 docs: add stage 2 knowledge collection plan
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-18 22:03:22 +08:00
yxxandClaude Code 01d006d55f docs: add material traceability field feedback
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 21:32:09 +08:00
yxxandClaude Code 60082532a9 docs: register phase 1 test entry and add update-time field
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-17 21:32:09 +08:00
Kronecker aad79aea5f fix: persist cleared knowledge collection timestamps 2026-10-17 15:31:03 +08:00
saint fb51081750 Merge pull request 'fix: 统一检索式聊天输入校验并补齐后端验收' (#48) from kronecker/fix/backend-chat-validation into dev 2026-10-17 14:29:12 +08:00
Kronecker 4efcf83e4c fix: validate retrieval-only chat questions before dispatch 2026-10-17 12:17:52 +08:00
saint 1398938437 Merge pull request 'style: 统一注册页与登录页视觉布局' (#47) from feat/register-layout-clean into dev 2026-10-17 11:29:16 +08:00
22 changed files with 520 additions and 36 deletions
+5 -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}`
@@ -68,6 +68,8 @@ Vite 通常使用 http://localhost:5173。
- 来源分页支持 `q`;文档分页支持 `q`、`source_id`、`category`;知识分页支持 `q`、`category`、`verification_status`、`source_id`、`document_id`。`q` 为字面文本子串搜索,尚非论文中的关键词检索基线。 - 来源分页支持 `q`;文档分页支持 `q`、`source_id`、`category`;知识分页支持 `q`、`category`、`verification_status`、`source_id`、`document_id`。`q` 为字面文本子串搜索,尚非论文中的关键词检索基线。
- 创建知识沿用 `title/category/content/source_url`,新增可选 `source_id/document_id/collected_at`;状态固定为 `pending`。关联文档时自动解析其来源,填写的来源或网址若冲突会报错。直接来源链接的旧调用仍受支持。 - 创建知识沿用 `title/category/content/source_url`,新增可选 `source_id/document_id/collected_at`;状态固定为 `pending`。关联文档时自动解析其来源,填写的来源或网址若冲突会报错。直接来源链接的旧调用仍受支持。
- 来源创建字段:`name`、可选 `source_url`、`notes`。文档创建字段:`source_id/title/content/collected_at`,可选 `category/notes`。文档来源归属固定,换来源需新建文档。 - 来源创建字段:`name`、可选 `source_url`、`notes`。文档创建字段:`source_id/title/content/collected_at`,可选 `category/notes`。文档来源归属固定,换来源需新建文档。
- 知识修改时省略 `collected_at` 表示保留原值,明确传 `null` 表示清空;清空后回到待审核,补全真实采集时间前不能核验通过。文档的采集时间仍是必填字段,不接受 `null`。
- 知识管理页可为未关联文档的条目选择或解除登记来源;关联文档的条目沿用文档所属来源,来源选择和网址只读,保存其他字段时保留关联。来源选择与边界验收见 [SOURCE_SELECTION_ACCEPTANCE.md](docs/SOURCE_SELECTION_ACCEPTANCE.md)。
- PATCH 请求必须携带读取时获得的 `expected_version`,并至少提交一个修改字段;DELETE 使用查询参数 `expected_version`。版本过期返回 409,防止覆盖他人修改。 - PATCH 请求必须携带读取时获得的 `expected_version`,并至少提交一个修改字段;DELETE 使用查询参数 `expected_version`。版本过期返回 409,防止覆盖他人修改。
- 审核请求字段见下方示例。`verification_status` 只允许 `pending/verified/rejected`;核验人和说明必填。通过审核前必须有来源网址及真实采集时间。这里只记录人工结论,不会自动核实网页。 - 审核请求字段见下方示例。`verification_status` 只允许 `pending/verified/rejected`;核验人和说明必填。通过审核前必须有来源网址及真实采集时间。这里只记录人工结论,不会自动核实网页。
- 修改条目、关联来源或关联文档都会将相关知识转为 `pending`,清除当前审核字段并记录失效原因。历史快照保留当时的条目、来源和文档。 - 修改条目、关联来源或关联文档都会将相关知识转为 `pending`,清除当前审核字段并记录失效原因。历史快照保留当时的条目、来源和文档。
@@ -107,7 +109,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,
) )
+8 -2
View File
@@ -41,8 +41,7 @@ class Patch(Input):
@classmethod @classmethod
def disallow_required_null(cls, value, info): def disallow_required_null(cls, value, info):
if ( if (
info.field_name info.field_name in {"name", "title", "category", "content", "notes", "expected_version"}
in {"name", "title", "category", "content", "notes", "collected_at", "expected_version"}
and value is None and value is None
): ):
raise ValueError("该字段不能为 null") raise ValueError("该字段不能为 null")
@@ -92,6 +91,13 @@ class DocumentUpdate(Patch):
collected_at: AwareDatetime | None = None collected_at: AwareDatetime | None = None
notes: Notes | None = None notes: Notes | None = None
@field_validator("collected_at", mode="before")
@classmethod
def collection_time_is_required(cls, value):
if value is None:
raise ValueError("文档采集时间不能为 null")
return value
class DocumentRead(Read): class DocumentRead(Read):
source_id: int source_id: int
+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
+46
View File
@@ -156,6 +156,52 @@ def test_review_edit_and_history(client):
assert events["items"][1]["snapshot"]["item"]["verification_status"] == "verified" assert events["items"][1]["snapshot"]["item"]["verification_status"] == "verified"
def test_clear_knowledge_collection_time_requires_new_evidence(client):
verified = review(client, knowledge(client)).json()
path = f"/api/knowledge/{verified['id']}"
before = client.get(path + "/history").json()["items"]
response = client.patch(
path, json={"expected_version": verified["version"], "collected_at": None}
)
assert response.status_code == 200
cleared = response.json()
assert cleared["collected_at"] is None
assert cleared["version"] == verified["version"] + 1
assert cleared["verification_status"] == "pending"
assert cleared["reviewer"] is None and cleared["reviewed_at"] is None
assert cleared["verification_notes"] is None
assert review(client, cleared).status_code == 422
assert client.get(path).json() == cleared
events = client.get(path + "/history").json()["items"]
assert events[0]["snapshot"]["item"]["collected_at"] is None
assert events[1:] == before
assert (
client.patch(
path, json={"expected_version": verified["version"], "collected_at": COLLECTED}
).status_code
== 409
)
restored = client.patch(
path, json={"expected_version": cleared["version"], "collected_at": COLLECTED}
).json()
assert review(client, restored).status_code == 200
def test_document_collection_time_cannot_be_cleared(client):
doc = document(client, source(client)["id"])
path = f"/api/documents/{doc['id']}"
before = client.get(path + "/history").json()
response = client.patch(path, json={"expected_version": doc["version"], "collected_at": None})
assert response.status_code == 422
assert client.get(path).json() == doc
assert client.get(path + "/history").json() == before
def test_source_document_changes_invalidate_evidence(client): def test_source_document_changes_invalidate_evidence(client):
src = source(client) src = source(client)
doc = document(client, src["id"]) doc = document(client, src["id"])
+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失败复现与修正、参数拒绝不写记录、公开调用与历史权限、审核筛选、资料修订/历史不变、容量与事务失败,以及进程重启后的历史和导出。没有模型调用或新增浏览器验收。
+45
View File
@@ -0,0 +1,45 @@
# 2026-10-17 知识编辑与采集时间验收
PR #48 已真正合入 dev,head 与 merge 均已核对为最新 dev 祖先。本轮从该基线创建独立阶段分支,补齐此前仅通过共享函数测试的知识编辑页实际浏览器验收。
使用隔离 PostgreSQL/pgvector、默认生命周期 FastAPI、Vite 和真实浏览器。账号、来源、知识和查询全部是合成数据;没有运行 Embedding 或生成模型。初始化来源、两条知识、文本发布和一条检索记录属于独立 HTTP 准备,不算浏览器录入。
## 实际浏览器结果
| 操作 | 页面结果与独立核对 |
|---|---|
| 匿名打开知识管理,合成管理员登录 | 回跳知识管理页,读取两条真实保存的合成知识 |
| 编辑已有采集时间的知识 | 原 `2026-10-16T12:34:56.789123+08:00` 按当地时钟回填为 `12:34:56.789` |
| 只改标题并保存 | 页面显示成功并由 verified 变为 pending;API 确认版本递增、原微秒时间字符串完整保留、审核字段清空 |
| 打开历史并展开快照 | 页面显示新标题、pending、原微秒采集时间和版本;旧审核快照继续保留 |
| 编辑原采集时间为空的草稿正文 | 空表单保存成功,API 确认仍为 null,没有补写当前时间 |
| 明确修改当地采集时间 | 页面保存成功,`13:45:12.345` 存为 `2026-10-16T13:45:12.345000+08:00` |
| 清空已有采集时间 | 修正后保存成功,历史显示 null;再次打开编辑器仍为空,API 确认持久化 null |
最终修正版本中,只改标题保留微秒的历史:
![知识编辑保留微秒采集时间](assets/knowledge-edit-acceptance/title-history.png)
清空时间后记录 null 的历史:
![知识采集时间清空并保留历史](assets/knowledge-edit-acceptance/cleared-history.png)
## 发现的缺陷与修正
原知识页面清空时间后省略 `collected_at`,服务端按 PATCH 语义保留旧值;页面却提示保存成功。明确传 null 后,又暴露出共享 PATCH 校验错误地把知识的可空时间按文档必填字段拒绝,返回 422。
现在知识编辑器在空输入时明确传 `collected_at: null`;后端允许清空知识采集时间,仍执行版本检查、审核失效和历史保存。文档的非空时间校验放到文档更新模型,文档传 null 继续返回 422,不产生修改或历史。未改时间仍复用原字符串保留微秒,明确改时仍按本地时钟转换。没有猜测或批量修订业务资料。
缺陷复现和最终修正分别使用独立的合成 Schema。修正后重新执行了上表全部编辑路径,没有把首次环境的成功结果代替最终版本复核。接口路径、字段和迁移不变;知识 PATCH 中省略时间与明确 null 的行为见 README。
## 独立 HTTP 与自动化验证
- 修改知识后,旧文本发布检索返回 409;旧检索记录和此前全部审核快照保持原值。
- 清空后核验通过请求返回 422,当前版本和历史不变;过期编辑返回 409,不能覆盖新值。补回真实时间并重新审核的恢复路径由后端回归覆盖。
- 文档采集时间传 null 返回 422,文档与历史均不变。
- `pytest -q backend/tests/test_catalog.py backend/tests/test_indexing.py backend/tests/test_exports.py`:**75 passed**。新增回归在修正前复现知识清空返回 422,修正后通过;同时覆盖审核前提、旧快照、过期版本与文档必填约束。
- `npm run test:datetime`:**6 passed**;`npm run build`(含类型检查)、`ruff check backend`、`ruff format --check backend` 和 `git diff --check`:通过。本轮没有重跑此前 PR #48 的全量 312 项测试,不沿用为本轮结果。
本轮完成知识编辑页的实际修订验收。原生删除确认、Docker、生产 HTTPS、多浏览器和浏览器改密仍按既有待办处理;没有重试原生确认失败方法或代填新密码。正式领域评测仍需要已核验资料和独立问题集,生成模型实际接入继续延期。
复核时在隔离测试库准备一条已核验、带微秒时间的知识和一条无时间草稿,依次执行上表操作,再读取知识详情、历史和旧检索记录比较。原始 HTTP 结果、环境、密码、日志和数据库仅保存在本机 Git 元数据目录;截图只有合成数据。结束后关闭测试服务,删除两轮隔离 Schema 并独立核对。
+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`,不能作为实际模型回答、耗时、费用或领域实验结果。
## 准备与读取 ## 准备与读取
+48
View File
@@ -0,0 +1,48 @@
# 来源选择与文档关联边界验收
## 范围与前置
原知识编辑修正 PR #50 已由真正合入 dev 的替代 PR #56 完整交付;PR #58 增加来源选择与名称展示,PR #59 维持草稿时间可空、审核前必填,并暂缓知识通用备注。本轮针对新来源选择路径开展浏览器及独立 HTTP 验收,承接 Issue #57 和 #52,不重复实现已有后端字段。
使用隔离数据库 Schema、默认 FastAPI 生命周期与 Vite;账户、101个来源、一个文档和三个知识条目均为明确标注的合成数据。网址使用 `example.com`,不代表真实文旅资料或领域质量。修正前的复现与最终验收分别使用两个全新 Schema。
## 复现与修正
1. 为一个已核验知识条目关联文档;该文档属于一个已登记来源。
2. 原页面允许将来源选为“不关联已登记来源”并保存,提示成功,但后端会根据文档恢复原来源。条目还会回到待审核;页面没有解释无法解除的原因。
3. 现在,文档关联知识的来源选择禁用、网址只读,并显示文档决定来源的说明。保存标题等其他字段时省略 `source_id` 和 `source_url`,不会提交来源重绑意图。
4. 未关联文档的条目仍能选择或解除登记来源,解除后可保留独立网址。后端仍负责关联一致性、版本和权限校验;本页不增加文档解绑功能。
![文档关联来源锁定说明](assets/source-selection-acceptance/document-source-locked.png)
## 最终浏览器验收
| 操作 | 实际结果 |
|---|---|
| 管理员打开 `/knowledge`,载入101个来源 | 下拉框包含101个来源及“不关联”选项,覆盖来源API的两页数据 |
| 普通知识选择来源API第二页的合成来源 | 网址正确回填,保存后列表显示正确来源名称与链接,条目回到 `pending` |
| 该条目的来源网址改为与登记来源冲突的值 | 显示后端错误,编辑表单保留,不提示保存成功 |
| 普通知识解除登记来源 | 列表变为“未关联”,仍保留原独立网址;重新查询确认 `source_id=null` |
| 编辑文档关联知识 | 来源控件禁用、网址只读,说明清晰;仍能保存标题修订 |
| 展开普通条目的历史 | 旧版本仍显示其当时来源名称及原微秒采集时间 |
![第二页来源实际保存后的名称与链接](assets/source-selection-acceptance/source-selected.png)
## 独立 HTTP 核对
- 来源切换、解除与文档关联标题修订的版本、来源ID/网址、审核回退均与页面结果一致;标题修订和来源操作保留原采集时间的全部微秒。
- 文档来源冲突、登记来源网址冲突返回422;过期编辑返回409;这些失败请求不改变当前条目或审计历史。
- 普通用户不能读取管理来源列表或修改知识(403);客户端控件不代替后端权限与关联校验。
- 修改后旧文本发布返回409;保存的检索记录及原有审计事件保持一致。独立网址草稿未受其他条目操作影响。
HTTP仅用于准备合成数据、独立查询及负向接口验证,不冒充浏览器交互;浏览器操作与截图均来自实际页面。
## 验证与清理
- `cd frontend && node --test tests/datetime.test.mjs`:**6 passed**。
- `cd frontend && npm run build`:类型检查与生产构建通过。
- `git diff --check`:通过。
- 本轮没有后端代码或迁移变更,未重跑全量pytest;未运行Embedding或生成模型。
- 两个隔离Schema已分别独立查询确认不存在;自有FastAPI、Vite及数据库服务停止,临时浏览器标签关闭。原始合成记录、运行环境、账户和本机进度不入库。
仅覆盖上述来源编辑路径。原生删除确认、Docker/生产HTTPS/多浏览器/浏览器改密与真实领域资料验收继续单列;Issue #5、#52、#57 已关闭的远程状态不能替代这些剩余验收。
+28 -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、鉴权与部署 | 范涵宇(主)、全员 | 进行中(聊天边界及知识编辑修正已合入;来源选择浏览器复核与文档来源锁定修正待审,Docker/HTTPS/部署待验收) |
## 2026-10-15 首次联调记录 ## 2026-10-15 首次联调记录
@@ -126,3 +126,29 @@
- 本轮 `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/架构文档更正已过期的聊天占位说明。无新增浏览器验收或领域分数。
## 2026-10-17 T05/T10:知识编辑页实际验收
- PR #48 真正合入 dev,head/merge 均已核对为 dev 祖先,再创建唯一当前阶段分支 `test/backend-knowledge-edit-acceptance`。
- 真实浏览器完成知识编辑的当地时间回填、只改标题保留微秒、审核回退、空时间草稿保存、明确改时、清空时间与历史展开;独立 HTTP 核对版本、原时间与历史快照不变、旧发布409和缺时间审核422。
- 修正清空知识时间却静默保留旧值的问题:前端明确发送 null,后端将文档必填时间约束与知识可空时间分开;文档仍拒绝 null,审核、版本和历史保护保留。无迁移或模型调用。
- 后端目录/索引/导出专项 **75 passed**、前端时区 **6 passed**,ruff检查/格式检查、前端类型/生产构建与diff检查通过;没有把此前312项全量结果当作本轮重跑。可复现步骤、合成截图和浏览器/HTTP证据边界见 [KNOWLEDGE_EDIT_ACCEPTANCE.md](KNOWLEDGE_EDIT_ACCEPTANCE.md)。
- 本阶段提交后等待真正合入,再继续剩余合理后端工作;原生确认、Docker/HTTPS/多浏览器/改密及领域资料待办保留,生成模型和凭据继续延期。
## 2026-10-20 T05/T10:来源选择与文档关联边界验收
- 原 PR #50 关闭未标记合并,但全部修正通过真正合入 dev 的 PR #56 进入集成分支;已核对精确 head、完整阶段树、8个改动文件及替代 head/merge 的祖先关系。保留原编号与替代依据,再从最新 dev 创建唯一后端验收分支。
- PR #58 已实现知识管理页来源选择与名称展示,PR #59 已记录字段决定。本轮仅验收新来源路径及修正其文档关联边界;草稿时间仍可空,审核前真实时间必填,知识通用 notes 暂缓。
- 浏览器复现:文档关联知识选择“不关联来源”后提示保存成功,但后端按文档恢复原来源。现在页面锁定此类条目的来源选择与网址并解释原因;保存其他字段时省略来源重绑字段。后端关联、版本和审核规则保持原有行为,无迁移。
- 在全新隔离合成数据中,真实浏览器完成101个来源跨两页加载、第二页来源选择/名称/链接保存、普通条目解除来源并保留独立网址、文档关联条目的只读说明及标题保存、冲突链接错误提示与历史快照展示。HTTP独立核对微秒保留、旧历史/检索记录不变、来源/网址冲突422、过期编辑409、旧发布409和普通用户403;失败请求不改变条目或审计历史。
- 前端时区 **6 passed**、类型/生产构建与 `git diff --check` 通过;本轮没有后端代码变更或全量pytest重跑,也没有Embedding/生成调用。复现、截图及验证边界见 [SOURCE_SELECTION_ACCEPTANCE.md](SOURCE_SELECTION_ACCEPTANCE.md)。
- 修正前及最终两轮Schema均经独立查询确认删除,自有服务停止、临时浏览器标签关闭。此阶段提交后等待真正合入;Docker/生产HTTPS/多浏览器/浏览器改密、原生确认和领域资料等剩余条件单列保留,Issue关闭不替代实际验收。
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

+64
View File
@@ -0,0 +1,64 @@
# 阶段 2 知识收集清单(计划)
> 阶段:2026-10-19 至 10-23(阶段 2:知识管理)
> 目标:收集并登记首批 30–50 条可核验资料,完成初步分类与来源核验
> 维护人:杨星萱(知识库、实验与文档负责人)
## 一、阶段 1 知识交付物汇总(截至 10-18)
| 交付物 | 位置 | 状态 |
|---|---|---|
| 资料台账模板 | [material-ledger.md](material-ledger.md) | 已合入 `dev`;包含「更新时间」列与明确标注的测试条目 K-0002。 |
| 审核规则 | [review-rules.md](review-rules.md) | 草案,已合入 dev,待定稿 |
| 联调验收记录模板 | [acceptance-record.md](acceptance-record.md) | 已合入 dev |
| 字段核对反馈 | [field-feedback.md](field-feedback.md) | 已合入 `dev`;3 项字段建议待范涵宇/吉海燕确认。 |
## 二、收集目标与口径
- 总量:30–50 条,覆盖红色文化 / 旅游资源 / 民宿服务 / 交通出行四类。
- 每条资料必须来源可回访、事实可核验;未核验保持 `pending`,不进入正式检索。
- 只登记真实资料;不编造来源、事实或数据。演示/测试条目明确标注,不与真实资料混淆。
## 三、四类收集方向与数量分配
| 类别 | 建议数量 | 收集方向 | 来源优先级 |
|---|---|---|---|
| 红色文化 | 8–12 条 | 边疆红驿相关历史背景、红色纪念地、戍边/英烈事迹、红色场馆介绍 | 政府/文旅局/纪念馆官方 > 权威媒体 |
| 旅游资源 | 10–15 条 | 那拉提景区概况、景点介绍、游览路线、开放时间、票务/预约说明(静态)、适宜季节 | 景区官网/文旅局 > 权威媒体 |
| 民宿服务 | 6–10 条 | 民宿类型、服务内容、位置、入住须知(静态说明) | 官方平台/文旅局 > 权威媒体 |
| 交通出行 | 6–10 条 | 抵达交通方式、班车/自驾路线、景区内交通、天气与安全提示 | 交通部门/文旅局 > 权威媒体 |
> 首版不承诺实时房态、实时价格、自动联网核验;此类内容不纳入收集范围。
## 四、来源类型与核验要点
1. 官方/权威优先:文旅局、景区官网、政府公告、交通部门。
2. 权威媒体次之;一般网站/自媒体仅作线索,不作为 `verified` 依据,除非能追溯到官方来源。
3. 关键事实需至少一个独立来源交叉印证后方可 `verified`(详见 [审核规则](review-rules.md))。独立来源应来自不同发布主体或不同原始材料;转载同一公告的多个页面不视为独立来源。
4. 记录资料版本与更新时间,便于后续向量重建与数据集说明。
## 五、字段与登记要求
- 沿用 [资料台账](material-ledger.md) 字段:标题、类别、来源名称、URL、获取日期、更新时间、正文摘要、真实性判断、核验日期、核验人、状态、备注。
- 状态三值 `pending` / `verified` / `rejected`;只把 `verified` 条目交给范涵宇入库。
- 每条先登记为 `pending`,完成来源与真实性核验后再定状态。
## 六、执行顺序(建议)
1. 优先收集官方来源的景区概况与红色文化(权威性高、量大)。
2. 补充交通与民宿的静态说明。
3. 逐条交叉核验;信息缺失或来源存疑保持 `pending`。
## 七、阶段验收标准
阶段结束时应提交以下可复核交付物:
1. 30–50 条台账记录及四类资料的数量统计,真实资料与测试条目必须分开标识。
2. 每条资料的 URL 可访问检查结果;无法回访或来源存疑的记录维持 `pending` 或标记 `rejected`。
3. `pending` / `verified` / `rejected` 的数量统计,以及可交给后端入库的 `verified` 清单。
4. 已入库 `verified` 条目的联调验收记录,使用 [acceptance-record.md](acceptance-record.md) 记录环境、操作、结果与证据位置。
## 八、待定事项(依赖确认)
- 字段是否调整:知识表单暴露 `source_id` 选择、`collected_at` 是否必填、知识条目是否增加 `notes` —— 见字段核对反馈,定稿后同步本清单的字段要求。
- 以上数据库/前端改动以范涵宇、吉海燕评估为准。
+49
View File
@@ -0,0 +1,49 @@
# 资料追溯字段核对(阶段1 · 2026-10-17)
> 用途:核对资料台账所需字段与系统(后端数据模型 + 前端页面)实际字段的对应关系,确认是否满足「来源可回访、核验可追溯」的要求,并给出需补齐的字段建议。
> 对照对象:[资料台账](material-ledger.md) 的字段 vs 后端 `knowledge_items` / `sources` / `documents` 三张表,以及前端知识管理、来源管理、知识审核页面。
> 结论:核心追溯字段已基本覆盖,存在 5 处需补齐/确认的差异。
## 一、字段对照表
| 台账字段 | 系统对应字段(表) | 覆盖情况 |
|---|---|---|
| 编号 | `id`(自动生成) | ✅ |
| 标题 | `title`(knowledge / documents) | ✅ |
| 类别 | `category`(knowledge / documents,默认「未分类」) | ✅ |
| 来源名称 | `sources.name`(经 `knowledge.source_id` 关联) | ⚠️ 见差异 1 |
| URL | `source_url`(knowledge / sources) | ✅ |
| 获取日期 | `collected_at`(documents 必填,knowledge 可空) | ⚠️ 见差异 2 |
| 更新时间 | `updated_at`(自动维护) | ✅ 系统有,台账此前缺此列,已补 |
| 正文摘要 | `content`(正文,最长 10 万字符) | ⚠️ 见差异 3 |
| 真实性判断 | `verification_notes`(核验说明) | ✅ |
| 核验日期 | `reviewed_at` | ✅ |
| 核验人 | `reviewer` | ✅ |
| 状态 | `verification_status`(pending / verified / rejected) | ✅ 三值与台账完全一致 |
| 备注 | 无独立字段(sources / documents 有 `notes`,knowledge 无) | ⚠️ 见差异 4 |
## 二、差异与建议
1. **来源名称是独立实体**:知识条目本身只有 `source_url`,来源名称需先在「来源管理」登记 `sources`,再通过 `source_id` 关联。前端知识管理页目前只展示「来源网址」链接,未显示来源名称,也未在知识表单暴露 `source_id` 选择。建议:知识录入时允许选择已登记来源(`source_id`),并在列表同时显示来源名称与网址。
2. **采集时间在知识条目上可空**:`documents.collected_at` 必填、`knowledge.collected_at` 可空。前端知识表单已有「采集时间」输入,但后端允许为空。为满足追溯,建议知识条目也要求填写采集时间,或强制关联到含采集时间的文档。
3. **「正文摘要」无独立字段**:系统只有正文 `content`,无单独摘要列。台账中「正文摘要」作为登记用摘要;正式入库时以 `content`(正文/摘要原文)为准,两者须在核验时对齐,避免摘要与正文不一致。
4. **知识条目缺通用「备注」**:`knowledge_items` 没有 `notes`,只有 `verification_notes`(核验说明)。若需在审核之外记录备注(如过期时间、待二次确认),目前只能写入核验说明或关联文档的 `notes`。建议:给知识条目增加 `notes` 字段(需范涵宇评估,仅建议,不直接改库)。
5. **来源名称回填**:现有直接填 `source_url` 的条目,来源名称缺失;后续入库前应补登记到 `sources` 并关联。
## 三、确认决定(2026-10-19)
1. **来源选择与展示:采纳。** 管理员知识管理页已支持选择已登记来源并展示来源名称与链接;仍由后端校验来源、链接与权限。
2. **采集时间:维持现有分层约束。** 草稿知识条目的 `collected_at` 可以为空;标记为 `verified` 前必须具备可核验的来源网址与采集时间。文档采集时间继续为必填。
3. **通用备注:暂缓。** 当前不为 `knowledge_items` 新增 `notes` 字段;待出现稳定业务需求后再评估数据库迁移。审核说明与关联文档备注按现有职责使用。
以上确认仅涉及字段与流程,不包含真实资料、账号、凭据或环境信息。
## 四、核对结论
- 追溯核心字段(标题、类别、URL、获取/更新时间、核验状态、核验人、核验依据)系统均已支持,状态三值与审核规则一致。
- 来源名称的独立登记与前端展示已完成;通用备注字段暂缓,摘要与正文的一致性仍须在核验时确认。
- 数据库结构改动仍需独立评估,不由本文档直接执行。
+11 -9
View File
@@ -19,21 +19,23 @@
|---|---|---| |---|---|---|
| 编号 | 是 | 唯一序号,格式 `K-0001`,按登记顺序递增 | | 编号 | 是 | 唯一序号,格式 `K-0001`,按登记顺序递增 |
| 标题 | 是 | 资料的简短标题,便于检索和引用 | | 标题 | 是 | 资料的简短标题,便于检索和引用 |
| 类别 | 是 | 四选一:红色文化 / 旅游资源 / 民宿服务 / 交通出行 | | 类别 | 是 | 四选一:红色文化 / 旅游资源 / 民宿服务 / 交通出行;测试条目用「其他」并注明测试 |
| 来源名称 | 是 | 发布方,如机构/媒体/网站名称 | | 来源名称 | 是 | 发布方,如机构/媒体/网站名称;无来源的测试条目填「无(测试)」 |
| URL | 是 | 来源链接;无法溯源时填「无链接」并在真实性判断中说明 | | URL | 否 | 来源链接;无法溯源时留空并说明 |
| 获取日期 | 是 | 采集日期,格式 `YYYY-MM-DD` | | 获取日期 | 是 | 采集日期,格式 `YYYY-MM-DD` |
| 更新时间 | 是 | 最近一次更新该条目的日期 `YYYY-MM-DD` |
| 正文摘要 | 是 | 资料关键内容的一两句话摘要,不改变原意 | | 正文摘要 | 是 | 资料关键内容的一两句话摘要,不改变原意 |
| 真实性判断 | 是 | 核验结论依据,如「与官方公告一致」「与另一独立来源交叉印证」「无法核实」 | | 真实性判断 | 是 | 核验结论依据,如「与官方公告一致」「与另一独立来源交叉印证」「无法核实」;测试条目填「无需核验(测试)」 |
| 核验日期 | 否 | 完成核验的日期 `YYYY-MM-DD`;`pending` 时可为空 | | 核验日期 | 否 | 完成核验的日期 `YYYY-MM-DD`;`pending` 时可为空 |
| 核验人 | 否 | 完成核验的人;`pending` 时可为空 | | 核验人 | 否 | 完成核验的人;`pending` 时可为空 |
| 状态 | 是 | `pending` / `verified` / `rejected` | | 状态 | 是 | `pending` / `verified` / `rejected` |
| 备注 | 否 | 补充说明,如过期时间、需二次确认的事项 | | 备注 | 否 | 补充说明,如过期时间、需二次确认的事项、测试标记 |
## 登记表 ## 登记表
| 编号 | 标题 | 类别 | 来源名称 | URL | 获取日期 | 正文摘要 | 真实性判断 | 核验日期 | 核验人 | 状态 | 备注 | | 编号 | 标题 | 类别 | 来源名称 | URL | 获取日期 | 更新时间 | 正文摘要 | 真实性判断 | 核验日期 | 核验人 | 状态 | 备注 |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|---|
| K-0001 | 【示例·占位】某景区简介 | 旅游资源 | 【示例来源,非真实】示例站点 | https://example.com/ | 2026-10-15 | 【示例】此处为占位摘要,非真实资料 | 待核验 | | | pending | 示例行,仅演示填写格式,不构成真实资料 | | K-0001 | 【示例·占位】某景区简介 | 旅游资源 | 【示例来源,非真实】示例站点 | https://example.com/ | 2026-10-15 | 2026-10-15 | 【示例】此处为占位摘要,非真实资料 | 待核验 | | | pending | 示例行,仅演示填写格式,不构成真实资料 |
| K-0002 | 阶段1联调测试条目 | 其他(测试) | 无(测试) | | 2026-10-17 | 2026-10-17 | 仅用于验证资料登记与持久化流程,不构成任何那拉提文旅事实 | 无需核验(测试) | | | pending | 阶段1测试条目,仅验证登记流程,不进入正式检索库,不伪装为真实资料 |
> 上表仅含一条**示例占位行**,用于演示填写格式,不包含任何真实文旅信息。正式收集的资料按此格式逐行追加。 > 上表 K-0001 为**示例占位行**,K-0002 为**测试条目**,均不包含真实文旅信息。正式收集的资料按此格式逐行追加。
+1 -1
View File
@@ -10,7 +10,7 @@ export const createKnowledge = (payload:{title:string;category:string;content:st
export type VerificationStatus='pending'|'verified'|'rejected' export type VerificationStatus='pending'|'verified'|'rejected'
export type KnowledgeItem={id:number;version:number;title:string;category:string;content:string;source_url:string|null;source_id:number|null;document_id:number|null;collected_at:string|null;verification_status:VerificationStatus;reviewer:string|null;reviewed_at:string|null;verification_notes:string|null;created_at:string;updated_at:string;is_deleted:boolean} export type KnowledgeItem={id:number;version:number;title:string;category:string;content:string;source_url:string|null;source_id:number|null;document_id:number|null;collected_at:string|null;verification_status:VerificationStatus;reviewer:string|null;reviewed_at:string|null;verification_notes:string|null;created_at:string;updated_at:string;is_deleted:boolean}
export function getKnowledgePage(filters:{q?:string;category?:string;verification_status?:VerificationStatus;page?:number;page_size?:number}={}){const params=new URLSearchParams();for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!=='')params.set(key,String(value))}return apiRequest<{items:KnowledgeItem[];total:number;page:number;page_size:number}>(`/api/knowledge/page?${params}`)} export function getKnowledgePage(filters:{q?:string;category?:string;verification_status?:VerificationStatus;page?:number;page_size?:number}={}){const params=new URLSearchParams();for(const [key,value] of Object.entries(filters)){if(value!==undefined&&value!=='')params.set(key,String(value))}return apiRequest<{items:KnowledgeItem[];total:number;page:number;page_size:number}>(`/api/knowledge/page?${params}`)}
export function updateKnowledge(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;source_url?:string|null;collected_at?:string|null}){return apiRequest<KnowledgeItem>(`/api/knowledge/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)} export function updateKnowledge(id:number,payload:{expected_version:number;title?:string;category?:string;content?:string;source_id?:number|null;source_url?:string|null;collected_at?:string|null}){return apiRequest<KnowledgeItem>(`/api/knowledge/${id}`,{method:'PATCH',body:JSON.stringify(payload)},true)}
export function deleteKnowledge(id:number,expectedVersion:number){return apiRequest<void>(`/api/knowledge/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)} export function deleteKnowledge(id:number,expectedVersion:number){return apiRequest<void>(`/api/knowledge/${id}?expected_version=${expectedVersion}`,{method:'DELETE'},true)}
export function getKnowledgeHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/knowledge/${id}/history?page=${page}&page_size=${pageSize}`)} export function getKnowledgeHistory(id:number,page=1,pageSize=20){return apiRequest<{items:AuditItem[];total:number;page:number;page_size:number}>(`/api/knowledge/${id}/history?page=${page}&page_size=${pageSize}`)}
export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string} export type SourceItem={id:number;version:number;name:string;source_url:string|null;notes:string;created_at:string}
+11 -8
View File
@@ -1,19 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, updateKnowledge, type AuditItem, type KnowledgeItem, type VerificationStatus } from '../api' import { deleteKnowledge, getKnowledgeHistory, getKnowledgePage, getSources, updateKnowledge, type AuditItem, type KnowledgeItem, type SourceItem, type VerificationStatus } from '../api'
import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime' import { collectedAtFromInput, localDateTimeInput } from '../utils/datetime'
type Form={title:string;category:string;content:string;source_url:string;collected_at:string} type Form={title:string;category:string;content:string;source_id:string;source_url:string;collected_at:string}
const items=ref<KnowledgeItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([]) const items=ref<KnowledgeItem[]>([]), sources=ref<SourceItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([])
const query=ref(''), category=ref(''), status=ref<VerificationStatus|''>(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('') const query=ref(''), category=ref(''), status=ref<VerificationStatus|''>(''), page=ref(1), total=ref(0), loading=ref(true), saving=ref(false), historyLoading=ref(false), error=ref(''), notice=ref('')
const form=ref<Form>({title:'',category:'',content:'',source_url:'',collected_at:''}) const form=ref<Form>({title:'',category:'',content:'',source_id:'',source_url:'',collected_at:''})
function failure(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback} function failure(cause:unknown,fallback:string){return cause instanceof Error?cause.message:fallback}
function validUrl(value:string){if(!value)return true;try{const url=new URL(value);return url.protocol==='http:'||url.protocol==='https:'}catch{return false}} function validUrl(value:string){if(!value)return true;try{const url=new URL(value);return url.protocol==='http:'||url.protocol==='https:'}catch{return false}}
async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getKnowledgePage({q:query.value,category:category.value,verification_status:status.value||undefined,page:next});items.value=result.items;page.value=result.page;total.value=result.total}catch(cause){error.value=failure(cause,'无法读取知识条目')}finally{loading.value=false}} async function refresh(next=page.value){loading.value=true;error.value='';try{const result=await getKnowledgePage({q:query.value,category:category.value,verification_status:status.value||undefined,page:next});items.value=result.items;page.value=result.page;total.value=result.total}catch(cause){error.value=failure(cause,'无法读取知识条目')}finally{loading.value=false}}
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_url:item.source_url??'',collected_at:item.collected_at?localDateTimeInput(item.collected_at):''};error.value='';notice.value=''} async function loadSources(){try{const collected:SourceItem[]=[];let next=1,total=0;do{const result=await getSources('',next,100);collected.push(...result.items);total=result.total;next+=1}while(collected.length<total);sources.value=collected}catch(cause){error.value=failure(cause,'无法读取已登记来源')}}
function sourceName(item:KnowledgeItem){if(!item.source_id)return '未关联';return sources.value.find(source=>source.id===item.source_id)?.name??`已关联来源 #${item.source_id}`}
function edit(item:KnowledgeItem){selected.value=item;form.value={title:item.title,category:item.category,content:item.content,source_id:item.source_id?String(item.source_id):'',source_url:item.source_url??'',collected_at:item.collected_at?localDateTimeInput(item.collected_at):''};error.value='';notice.value=''}
function chooseSource(){const source=sources.value.find(entry=>entry.id===Number(form.value.source_id));if(source)form.value.source_url=source.source_url??''}
function cancel(){selected.value=undefined} function cancel(){selected.value=undefined}
async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),source_url:form.value.source_url.trim()||null,...(form.value.collected_at?{collected_at:collectedAtFromInput(form.value.collected_at,selected.value.collected_at??undefined)}: {})};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}} async function save(){if(!selected.value)return;error.value='';notice.value='';if(!form.value.title.trim()||!form.value.category.trim()||!form.value.content.trim()){error.value='标题、分类和正文不能为空。';return}if(!validUrl(form.value.source_url.trim())){error.value='来源网址必须是有效的 http(s) 地址。';return}saving.value=true;try{const payload={expected_version:selected.value.version,title:form.value.title.trim(),category:form.value.category.trim(),content:form.value.content.trim(),...(selected.value.document_id===null?{source_id:form.value.source_id?Number(form.value.source_id):null,source_url:form.value.source_url.trim()||null}:{}),collected_at:form.value.collected_at?collectedAtFromInput(form.value.collected_at,selected.value.collected_at??undefined):null};await updateKnowledge(selected.value.id,payload);notice.value='知识条目已更新,后端已将其重新置为待审核。';cancel();await refresh()}catch(cause){error.value=failure(cause,'更新知识条目失败')}finally{saving.value=false}}
async function remove(item:KnowledgeItem){if(!window.confirm(`确定删除“${item.title}”吗?该操作为软删除,历史记录会保留。`))return;error.value='';notice.value='';try{await deleteKnowledge(item.id,item.version);notice.value='知识条目已软删除。';if(selected.value?.id===item.id)cancel();await refresh(items.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除知识条目失败')}} async function remove(item:KnowledgeItem){if(!window.confirm(`确定删除“${item.title}”吗?该操作为软删除,历史记录会保留。`))return;error.value='';notice.value='';try{await deleteKnowledge(item.id,item.version);notice.value='知识条目已软删除。';if(selected.value?.id===item.id)cancel();await refresh(items.value.length===1&&page.value>1?page.value-1:page.value)}catch(cause){error.value=failure(cause,'删除知识条目失败')}}
async function showHistory(item:KnowledgeItem){historyFor.value=item;historyLoading.value=true;error.value='';try{historyItems.value=(await getKnowledgeHistory(item.id)).items}catch(cause){error.value=failure(cause,'无法读取知识历史');historyFor.value=undefined}finally{historyLoading.value=false}} async function showHistory(item:KnowledgeItem){historyFor.value=item;historyLoading.value=true;error.value='';try{historyItems.value=(await getKnowledgeHistory(item.id)).items}catch(cause){error.value=failure(cause,'无法读取知识历史');historyFor.value=undefined}finally{historyLoading.value=false}}
onMounted(()=>refresh(1)) onMounted(()=>{void refresh(1);void loadSources()})
</script> </script>
<template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template> <template><main><RouterLink to="/admin">返回管理员工作台</RouterLink><h1>知识管理</h1><p>列表、编辑、删除和历史均直接调用管理员受保护接口;编辑后条目会由后端重新置为待审核。</p><p v-if="error" role="alert">{{error}}</p><p v-if="notice" role="status">{{notice}}</p><form @submit.prevent="refresh(1)"><label>关键词<input v-model="query"/></label><label>分类<input v-model="category"/></label><label>状态<select v-model="status"><option value="">全部</option><option value="pending">待审核</option><option value="verified">已核验</option><option value="rejected">已驳回</option></select></label><button>筛选</button></form><p v-if="loading">正在读取知识条目…</p><template v-else><table><thead><tr><th>标题</th><th>分类</th><th>状态</th><th>来源</th><th>操作</th></tr></thead><tbody><tr v-for="item in items" :key="item.id"><td><b>{{item.title}}</b><p>{{item.content.slice(0,120)}}</p></td><td>{{item.category}}</td><td>{{item.verification_status}}</td><td><p>{{sourceName(item)}}</p><a v-if="item.source_url" :href="item.source_url" target="_blank" rel="noreferrer">打开来源</a><span v-else>未填写</span></td><td><button @click="edit(item)">编辑</button><button @click="showHistory(item)">历史</button><button @click="remove(item)">删除</button></td></tr><tr v-if="!items.length"><td colspan="5">暂无符合条件的知识条目。</td></tr></tbody></table><div><button :disabled="page===1" @click="refresh(page-1)">上一页</button><span>第 {{page}} 页,共 {{total}} 条</span><button :disabled="page*20>=total" @click="refresh(page+1)">下一页</button></div></template><form v-if="selected" @submit.prevent="save"><h2>编辑:{{selected.title}}</h2><label>标题<input v-model.trim="form.title" required maxlength="250"/></label><label>分类<input v-model.trim="form.category" required maxlength="80"/></label><label>正文<textarea v-model.trim="form.content" required maxlength="100000" rows="6"/></label><label>已登记来源<select v-model="form.source_id" :disabled="selected.document_id!==null" @change="chooseSource"><option value="">不关联已登记来源</option><option v-for="source in sources" :key="source.id" :value="String(source.id)">{{source.name}}</option></select></label><p v-if="selected.document_id!==null" role="note">已关联文档 #{{selected.document_id}},来源由该文档确定,本页仅可修改其他知识字段。</p><label>来源网址<input v-model.trim="form.source_url" :readonly="selected.document_id!==null" type="url" maxlength="1000" placeholder="https://..."/><small v-if="selected.document_id!==null">来源网址由文档所属来源维护。</small><small v-else>选择已登记来源时会自动回填其链接;可保留为空,由后端按关联来源处理。</small></label><label>采集时间<input v-model="form.collected_at" type="datetime-local" step="any"/></label><button type="button" :disabled="saving" @click="cancel">取消</button><button :disabled="saving">{{saving?'保存中…':'保存修改'}}</button></form><section v-if="historyFor"><h2>“{{historyFor.title}}”的历史</h2><button @click="historyFor=undefined">关闭历史</button><p v-if="historyLoading">正在读取历史…</p><table v-else><thead><tr><th>操作</th><th>时间</th><th>操作者</th><th>版本快照</th></tr></thead><tbody><tr v-for="item in historyItems" :key="item.id"><td>{{item.action}}</td><td>{{new Date(item.created_at).toLocaleString()}}</td><td>{{item.actor||'系统'}}</td><td><details><summary>查看快照</summary><pre>{{JSON.stringify(item.snapshot,null,2)}}</pre></details></td></tr><tr v-if="!historyItems.length"><td colspan="4">暂无历史记录。</td></tr></tbody></table></section></main></template>