Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
461eccde0c | ||
|
|
1430d35196 | ||
|
|
7cd4dd2f38 | ||
|
|
8cb60cc53a | ||
|
|
88b1952107 | ||
|
|
a1b3147f09 | ||
|
|
965613319b | ||
|
|
0e1642eeab | ||
|
|
cb1b64d5b0 | ||
|
|
dfef3bc471 | ||
|
|
009997e479 | ||
|
|
01d006d55f | ||
|
|
60082532a9 | ||
|
|
aad79aea5f | ||
|
|
fb51081750 | ||
|
|
4efcf83e4c | ||
|
|
1398938437 |
@@ -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),在项目根目录运行:
|
||||
@@ -48,7 +48,7 @@ Vite 通常使用 http://localhost:5173。
|
||||
- 注册/登录/退出、当前会话、资料修改、改密与管理员用户列表,详见 [认证接口契约](docs/AUTH_API.md)
|
||||
- GET `/api/health`
|
||||
- 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`
|
||||
- POST `/api/knowledge`
|
||||
- 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` 为字面文本子串搜索,尚非论文中的关键词检索基线。
|
||||
- 创建知识沿用 `title/category/content/source_url`,新增可选 `source_id/document_id/collected_at`;状态固定为 `pending`。关联文档时自动解析其来源,填写的来源或网址若冲突会报错。直接来源链接的旧调用仍受支持。
|
||||
- 来源创建字段:`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,防止覆盖他人修改。
|
||||
- 审核请求字段见下方示例。`verification_status` 只允许 `pending/verified/rejected`;核验人和说明必填。通过审核前必须有来源网址及真实采集时间。这里只记录人工结论,不会自动核实网页。
|
||||
- 修改条目、关联来源或关联文档都会将相关知识转为 `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。
|
||||
|
||||
### 后端测试和迁移回滚
|
||||
|
||||
|
||||
@@ -26,9 +26,6 @@ def chat(payload: ChatRequest, db: Session = Depends(get_db)) -> ChatResponse:
|
||||
"因此系统仅返回可追溯证据,不生成或改写答案。"
|
||||
),
|
||||
status="retrieval_only",
|
||||
sources=[
|
||||
{"title": hit.title, "source_url": hit.source_url}
|
||||
for hit in result.results
|
||||
],
|
||||
sources=[{"title": hit.title, "source_url": hit.source_url} for hit in result.results],
|
||||
retrieval_run_id=result.run_id,
|
||||
)
|
||||
|
||||
@@ -41,8 +41,7 @@ class Patch(Input):
|
||||
@classmethod
|
||||
def disallow_required_null(cls, value, info):
|
||||
if (
|
||||
info.field_name
|
||||
in {"name", "title", "category", "content", "notes", "collected_at", "expected_version"}
|
||||
info.field_name in {"name", "title", "category", "content", "notes", "expected_version"}
|
||||
and value is None
|
||||
):
|
||||
raise ValueError("该字段不能为 null")
|
||||
@@ -92,6 +91,13 @@ class DocumentUpdate(Patch):
|
||||
collected_at: AwareDatetime | 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):
|
||||
source_id: int
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
from typing import Literal
|
||||
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):
|
||||
question: str = Field(min_length=1, max_length=2000)
|
||||
class ChatRequest(Input):
|
||||
question: QueryText
|
||||
|
||||
|
||||
class SourcePreview(BaseModel):
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.schemas.catalog import Category, Input
|
||||
|
||||
QueryText = Annotated[str, Field(min_length=1, max_length=500)]
|
||||
|
||||
|
||||
class RetrievalRequest(Input):
|
||||
query: str = Field(min_length=1, max_length=500)
|
||||
query: QueryText
|
||||
mode: Literal["keyword"] = "keyword"
|
||||
top_k: int = Field(default=5, ge=1, le=20)
|
||||
category: Category | None = None
|
||||
|
||||
@@ -156,6 +156,52 @@ def test_review_edit_and_history(client):
|
||||
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):
|
||||
src = source(client)
|
||||
doc = document(client, src["id"])
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -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 数据边界
|
||||
|
||||
|
||||
@@ -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失败复现与修正、参数拒绝不写记录、公开调用与历史权限、审核筛选、资料修订/历史不变、容量与事务失败,以及进程重启后的历史和导出。没有模型调用或新增浏览器验收。
|
||||
@@ -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 |
|
||||
|
||||
最终修正版本中,只改标题保留微秒的历史:
|
||||
|
||||

|
||||
|
||||
清空时间后记录 null 的历史:
|
||||
|
||||

|
||||
|
||||
## 发现的缺陷与修正
|
||||
|
||||
原知识页面清空时间后省略 `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 并独立核对。
|
||||
@@ -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 生成请求。
|
||||
|
||||
本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 仍保留原占位状态,尚未成为生成式问答接口;准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。
|
||||
本阶段提供管理员上下文准备与历史接口、完整证据快照、固定提示词及纯响应解析器。`POST /api/chat` 是独立的条目级关键词检索适配,返回检索状态和来源,尚未成为生成式问答接口(见 [CHAT_API.md](CHAT_API.md));准备记录固定为 `kind=prepared_request, model_called=false`,不能作为实际模型回答、耗时、费用或领域实验结果。
|
||||
|
||||
## 准备与读取
|
||||
|
||||
|
||||
@@ -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. 未关联文档的条目仍能选择或解除登记来源,解除后可保留独立网址。后端仍负责关联一致性、版本和权限校验;本页不增加文档解绑功能。
|
||||
|
||||

|
||||
|
||||
## 最终浏览器验收
|
||||
|
||||
| 操作 | 实际结果 |
|
||||
|---|---|
|
||||
| 管理员打开 `/knowledge`,载入101个来源 | 下拉框包含101个来源及“不关联”选项,覆盖来源API的两页数据 |
|
||||
| 普通知识选择来源API第二页的合成来源 | 网址正确回填,保存后列表显示正确来源名称与链接,条目回到 `pending` |
|
||||
| 该条目的来源网址改为与登记来源冲突的值 | 显示后端错误,编辑表单保留,不提示保存成功 |
|
||||
| 普通知识解除登记来源 | 列表变为“未关联”,仍保留原独立网址;重新查询确认 `source_id=null` |
|
||||
| 编辑文档关联知识 | 来源控件禁用、网址只读,说明清晰;仍能保存标题修订 |
|
||||
| 展开普通条目的历史 | 旧版本仍显示其当时来源名称及原微秒采集时间 |
|
||||
|
||||

|
||||
|
||||
## 独立 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
@@ -11,9 +11,9 @@
|
||||
| T05 | 知识库 | 设计 sources/documents/chunks/version 表 | 范涵宇 | 已完成(迁移、发布快照和接口已合入;待 Docker 联调) |
|
||||
| T06 | 检索 | 实现关键词检索基线 | 范涵宇 | 已完成(已核验知识 BM25 与检索历史已合入) |
|
||||
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
|
||||
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | 协议基础已合入(Responses 请求/解析调整已在独立分支通过验证,待审;生成模型选型与实际调用延期) |
|
||||
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | Responses 协议已合入(请求/解析与旧快照兼容已完成;生成模型选型与实际调用延期) |
|
||||
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
|
||||
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF、224项回归及浏览器核心流程已通过;Docker/HTTPS/部署待验收) |
|
||||
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(聊天边界及知识编辑修正已合入;来源选择浏览器复核与文档来源锁定修正待审,Docker/HTTPS/部署待验收) |
|
||||
|
||||
## 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检查/格式检查及前端类型/生产构建通过;未修改页面交互,不宣称新增浏览器验收。
|
||||
|
||||
- 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 |
@@ -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` —— 见字段核对反馈,定稿后同步本清单的字段要求。
|
||||
- 以上数据库/前端改动以范涵宇、吉海燕评估为准。
|
||||
@@ -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、获取/更新时间、核验状态、核验人、核验依据)系统均已支持,状态三值与审核规则一致。
|
||||
- 来源名称的独立登记与前端展示已完成;通用备注字段暂缓,摘要与正文的一致性仍须在核验时确认。
|
||||
- 数据库结构改动仍需独立评估,不由本文档直接执行。
|
||||
@@ -19,21 +19,23 @@
|
||||
|---|---|---|
|
||||
| 编号 | 是 | 唯一序号,格式 `K-0001`,按登记顺序递增 |
|
||||
| 标题 | 是 | 资料的简短标题,便于检索和引用 |
|
||||
| 类别 | 是 | 四选一:红色文化 / 旅游资源 / 民宿服务 / 交通出行 |
|
||||
| 来源名称 | 是 | 发布方,如机构/媒体/网站名称 |
|
||||
| URL | 是 | 来源链接;无法溯源时填「无链接」并在真实性判断中说明 |
|
||||
| 类别 | 是 | 四选一:红色文化 / 旅游资源 / 民宿服务 / 交通出行;测试条目用「其他」并注明测试 |
|
||||
| 来源名称 | 是 | 发布方,如机构/媒体/网站名称;无来源的测试条目填「无(测试)」 |
|
||||
| URL | 否 | 来源链接;无法溯源时留空并说明 |
|
||||
| 获取日期 | 是 | 采集日期,格式 `YYYY-MM-DD` |
|
||||
| 更新时间 | 是 | 最近一次更新该条目的日期 `YYYY-MM-DD` |
|
||||
| 正文摘要 | 是 | 资料关键内容的一两句话摘要,不改变原意 |
|
||||
| 真实性判断 | 是 | 核验结论依据,如「与官方公告一致」「与另一独立来源交叉印证」「无法核实」 |
|
||||
| 真实性判断 | 是 | 核验结论依据,如「与官方公告一致」「与另一独立来源交叉印证」「无法核实」;测试条目填「无需核验(测试)」 |
|
||||
| 核验日期 | 否 | 完成核验的日期 `YYYY-MM-DD`;`pending` 时可为空 |
|
||||
| 核验人 | 否 | 完成核验的人;`pending` 时可为空 |
|
||||
| 状态 | 是 | `pending` / `verified` / `rejected` |
|
||||
| 备注 | 否 | 补充说明,如过期时间、需二次确认的事项 |
|
||||
| 备注 | 否 | 补充说明,如过期时间、需二次确认的事项、测试标记 |
|
||||
|
||||
## 登记表
|
||||
|
||||
| 编号 | 标题 | 类别 | 来源名称 | URL | 获取日期 | 正文摘要 | 真实性判断 | 核验日期 | 核验人 | 状态 | 备注 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| K-0001 | 【示例·占位】某景区简介 | 旅游资源 | 【示例来源,非真实】示例站点 | https://example.com/ | 2026-10-15 | 【示例】此处为占位摘要,非真实资料 | 待核验 | | | pending | 示例行,仅演示填写格式,不构成真实资料 |
|
||||
| 编号 | 标题 | 类别 | 来源名称 | URL | 获取日期 | 更新时间 | 正文摘要 | 真实性判断 | 核验日期 | 核验人 | 状态 | 备注 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 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
@@ -10,7 +10,7 @@ export const createKnowledge = (payload:{title:string;category:string;content:st
|
||||
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 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 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}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
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'
|
||||
type Form={title:string;category:string;content:string;source_url:string;collected_at:string}
|
||||
const items=ref<KnowledgeItem[]>([]), selected=ref<KnowledgeItem>(), historyFor=ref<KnowledgeItem>(), historyItems=ref<AuditItem[]>([])
|
||||
type Form={title:string;category:string;content:string;source_id:string;source_url:string;collected_at:string}
|
||||
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 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 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}}
|
||||
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}
|
||||
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 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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user