Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
965613319b | ||
|
|
0e1642eeab | ||
|
|
cb1b64d5b0 | ||
|
|
dfef3bc471 | ||
|
|
009997e479 | ||
|
|
aad79aea5f |
@@ -68,6 +68,7 @@ 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`。
|
||||
- PATCH 请求必须携带读取时获得的 `expected_version`,并至少提交一个修改字段;DELETE 使用查询参数 `expected_version`。版本过期返回 409,防止覆盖他人修改。
|
||||
- 审核请求字段见下方示例。`verification_status` 只允许 `pending/verified/rejected`;核验人和说明必填。通过审核前必须有来源网址及真实采集时间。这里只记录人工结论,不会自动核实网页。
|
||||
- 修改条目、关联来源或关联文档都会将相关知识转为 `pending`,清除当前审核字段并记录失效原因。历史快照保留当时的条目、来源和文档。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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 并独立核对。
|
||||
+9
-1
@@ -13,7 +13,7 @@
|
||||
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
|
||||
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | Responses 协议已合入(请求/解析与旧快照兼容已完成;生成模型选型与实际调用延期) |
|
||||
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
|
||||
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(迁移、认证、CSRF及浏览器核心流程已通过;聊天边界修正和312项分支回归待审,Docker/HTTPS/部署待验收) |
|
||||
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(聊天边界修正已合入;知识编辑浏览器复核与采集时间清空修正待审,Docker/HTTPS/部署待验收) |
|
||||
|
||||
## 2026-10-15 首次联调记录
|
||||
|
||||
@@ -134,3 +134,11 @@
|
||||
- 已复现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/多浏览器/改密及领域资料待办保留,生成模型和凭据继续延期。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 90 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` —— 见字段核对反馈,定稿后同步本清单的字段要求。
|
||||
- 以上数据库/前端改动以范涵宇、吉海燕评估为准。
|
||||
@@ -11,7 +11,7 @@ function validUrl(value:string){if(!value)return true;try{const url=new URL(valu
|
||||
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=''}
|
||||
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(),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))
|
||||
|
||||
Reference in New Issue
Block a user