Compare commits

...
Author SHA1 Message Date
Kronecker 461eccde0c fix: respect document-bound knowledge sources 2026-10-20 00:09:34 +08:00
6 changed files with 62 additions and 3 deletions
+1
View File
@@ -69,6 +69,7 @@ Vite 通常使用 http://localhost:5173。
- 创建知识沿用 `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`,清除当前审核字段并记录失效原因。历史快照保留当时的条目、来源和文档。
+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 已关闭的远程状态不能替代这些剩余验收。
+11 -1
View File
@@ -13,7 +13,7 @@
| T07 | 向量 | 评估 Embedding 并实现 pgvector | 范涵宇 | 已完成(本地模型、精确向量和 RRF 已合入;待真实领域评测) |
| T08 | RAG | 集成 LLM 与来源展示 | 范涵宇 | Responses 协议已合入(请求/解析与旧快照兼容已完成;生成模型选型与实际调用延期) |
| T09 | 实验 | 构建可核验问题集及评分规范 | 杨星萱 | 待办 |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(聊天边界修正已合入;知识编辑浏览器复核与采集时间清空修正待审,Docker/HTTPS/部署待验收) |
| T10 | 工程 | 补充测试、Alembic、鉴权与部署 | 范涵宇(主)、全员 | 进行中(聊天边界及知识编辑修正已合入;来源选择浏览器复核与文档来源锁定修正待审,Docker/HTTPS/部署待验收) |
## 2026-10-15 首次联调记录
@@ -142,3 +142,13 @@
- 修正清空知识时间却静默保留旧值的问题:前端明确发送 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: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

@@ -14,9 +14,9 @@ function sourceName(item:KnowledgeItem){if(!item.source_id)return '未关联';re
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_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 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(()=>{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><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" @change="chooseSource"><option value="">不关联已登记来源</option><option v-for="source in sources" :key="source.id" :value="String(source.id)">{{source.name}}</option></select></label><label>来源网址<input v-model.trim="form.source_url" type="url" maxlength="1000" placeholder="https://..."/><small>选择已登记来源时会自动回填其链接;可保留为空,由后端按关联来源处理。</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>
<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>