fix(r4-27/32/47b/52a): 前端四项缺陷修复
#27 消息级来源 title 空值串行:SourceRow 标题链空值 fallback(非空 title >paper_id>id>「(无标题)」,绝不渲染空文本或正文片段);bench store 新增 sourcesStale 失效标记,chat.js QA 流 onComplete 与检索模式完成终态置标记, SourcesPane 监听自动 force 重拉(终态一次,跨会话后台完成不打扰当前视图)。 后端 normalize title 回填由 Dev-B1 负责。 #32 KG 搜索「水稻」无结果无反馈:KgPane 成功空结果新增 kgNoResult 「未找到相关实体」提示(与 kgDown 服务离线、kgEmpty 初始空态三态文案区分、 触发条件互斥),搜索按钮空结果后保持可用,arginine 命中路径不回归。 #47b 关闭文档→返回→再关闭看不到文档:docgen store 状态机加固——open/ adoptArtifact 自含复位 viewMode='doc';close() 作废在途 content/html 拉取序号 (迟到响应不再复活已清空的 html/css/templateKey/letterhead);fetchHtml 追加 contentSeq 归属守卫。反复关闭/资料库往返/会话切换无残留。 #52a「总结论文」路由 QA:WRITE_VERB 补「总结/概括/归纳」;「总结」自身在 DOC_TYPE 中会误拉无附件普通总结句,规则 3 细化——纯总结类动词句须命中 其他文档类型词(如「论文」)才进 docgen,带附件走规则 4 兜底。后端 intent.py 词典同步由 Dev-B1 负责,请 TL 校验双端一致。 单测:tests/r3debug/r4fixes.test.js 新增 31 例(含 SourceRow fallback、 sourcesStale 链路、KgPane 三态、docgen 状态机迟到响应作废、意图路由), r3debug 全套 68/68 通过。
This commit is contained in:
@@ -16,6 +16,10 @@ const query = ref('');
|
||||
const results = ref([]);
|
||||
const searching = ref(false);
|
||||
const searchError = ref(false);
|
||||
// 缺陷 #32:搜索成功但空结果(200 + results:[],非超时)的明确提示——
|
||||
// 与 kgDown(服务不可用/超时)互斥:请求成功且空 results 置 true,
|
||||
// 下一次搜索发起时复位。
|
||||
const noResult = ref(false);
|
||||
const detailNode = ref(null);
|
||||
const graphData = ref(null); // 当前渲染的 {nodes, edges}(答案图谱或邻域结果)
|
||||
const graphSource = ref(null); // 'answer' | 'neighborhood'
|
||||
@@ -80,9 +84,11 @@ async function onSearch() {
|
||||
}
|
||||
searching.value = true;
|
||||
searchError.value = false;
|
||||
noResult.value = false; // 新一次搜索复位空结果提示
|
||||
try {
|
||||
const data = await searchEntities(q, 20);
|
||||
results.value = data?.results || [];
|
||||
noResult.value = !results.value.length; // 成功但空:明确提示(#32,非超时)
|
||||
} catch {
|
||||
searchError.value = true;
|
||||
} finally {
|
||||
@@ -197,6 +203,8 @@ const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchError" class="offline-text">{{ T.kgDown }}</div>
|
||||
<!-- 缺陷 #32:成功但空结果提示(kgNoResult,与 kgDown/kgEmpty 三态区分互斥) -->
|
||||
<div v-else-if="noResult && !searching" class="no-result-note">{{ T.kgNoResult }}</div>
|
||||
<div v-if="results.length" class="search-results">
|
||||
<div v-for="r in results" :key="r.id" class="result-item" @click="onSelectResult(r)">
|
||||
<span class="rs-name">{{ r.name }}</span>
|
||||
@@ -234,6 +242,8 @@ const kgDown = computed(() => !bench.kgReady && bench.kgStatus != null);
|
||||
}
|
||||
.offline-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--pa-error); flex: none; }
|
||||
.offline-text { font-size: 12px; color: var(--pa-error); }
|
||||
/* 缺陷 #32:空结果提示——非错误态(200 空 results 属正常),用中性副文案色 */
|
||||
.no-result-note { font-size: 12px; color: var(--pa-ink-subtle); }
|
||||
.degrade-note { font-size: 11.5px; color: var(--pa-ink-subtle); }
|
||||
.node-list {
|
||||
display: flex;
|
||||
|
||||
@@ -47,6 +47,18 @@ const badgeTitle = computed(() => {
|
||||
return raw;
|
||||
});
|
||||
|
||||
// 缺陷 #27:title 渲染空值 fallback——后端未回填 title 时绝不渲染空文本
|
||||
// (空文本会让条目不可读、视觉上「串行」),也绝不让正文片段被当成标题。
|
||||
// 显示优先级:非空 title > paper_id > id > i18n 占位「(无标题)」。
|
||||
const displayTitle = computed(() => {
|
||||
const s = props.source || {};
|
||||
const t = s.title;
|
||||
if (t != null && String(t).trim() !== '') return String(t);
|
||||
const raw = badgeTitle.value;
|
||||
if (raw) return raw;
|
||||
return T.sourceNoTitle;
|
||||
});
|
||||
|
||||
const metaParts = computed(() => {
|
||||
const s = props.source || {};
|
||||
const parts = [];
|
||||
@@ -63,7 +75,7 @@ const metaParts = computed(() => {
|
||||
<span class="ft-ico idx" :title="badgeTitle">{{ badgeId }}</span>
|
||||
<div class="src-main">
|
||||
<div class="src-line1">
|
||||
<span class="ft-name" :title="source.title || ''">{{ source.title }}</span>
|
||||
<span class="ft-name" :title="displayTitle">{{ displayTitle }}</span>
|
||||
</div>
|
||||
<div class="ft-size src-meta">{{ metaParts.join(' · ') }}</div>
|
||||
<div v-if="source.snippet" class="src-snippet" :class="{ clamp: !expanded }" @click="expanded = !expanded">
|
||||
|
||||
@@ -11,11 +11,14 @@ import ChainGroup from './ChainGroup.vue';
|
||||
const bench = useBenchStore();
|
||||
const chains = ref({}); // chain_id → open
|
||||
|
||||
// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时)
|
||||
// 会话级聚合按需拉取(仅当用户切到「本会话全部」且尚无缓存时);
|
||||
// 缺陷 #27:新回答完成后 chat.js 终态置 sourcesStale → 自动 force 重拉
|
||||
//(无需手动切 TAB/刷新页面),拉取成功后标记由 store 内部清除。
|
||||
watch(
|
||||
() => [bench.sourceScope, bench.currentAssistantMessage],
|
||||
() => [bench.sourceScope, bench.currentAssistantMessage, bench.sourcesStale],
|
||||
([scope]) => {
|
||||
if (scope === 'session' && !bench.sessionSources && bench._activeSessionId()) {
|
||||
if (scope === 'session' && bench._activeSessionId()
|
||||
&& (!bench.sessionSources || bench.sourcesStale)) {
|
||||
bench.loadSessionSources().catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -134,6 +134,11 @@ export const T = {
|
||||
extractUploadFail: '上传失败:',
|
||||
kgEmpty: '本次检索未产生图谱关系,可使用下方搜索浏览知识库',
|
||||
kgSearchPlaceholder: '搜索实体名,如 OsSPL14',
|
||||
// 缺陷 #32:搜索成功但无结果(200 + results:[])的明确提示——与 kgEmpty(初始空态)、
|
||||
// kgDown(服务不可用/超时)三态区分、触发条件互斥
|
||||
kgNoResult: '未找到相关实体',
|
||||
// 缺陷 #27:文献来源条目 title 为空时的占位(绝不渲染空文本或正文片段)
|
||||
sourceNoTitle: '(无标题)',
|
||||
chainBadge: '跳',
|
||||
sessionEmptyTitle: '海古纪 PlantAI 植物科研大模型',
|
||||
sessionEmptySub: '基于文献知识库的基因家族问答 —— 每一次提问都有证据锚定',
|
||||
|
||||
+39
-3
@@ -3,7 +3,9 @@
|
||||
// 判定顺序(优先级从高到低,命中即返回;漏判安全:不命中一律 qa):
|
||||
// 1. export:hasActiveArtifact 且含 EXPORT_WORD 且含 FORMAT_WORD
|
||||
// 2. edit: hasActiveArtifact 且含 EDIT_WORD
|
||||
// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先,防误判)
|
||||
// 3. docgen:含 WRITE_VERB 且含 DOC_TYPE(排除词优先,防误判;
|
||||
// 缺陷 #52a 细化:纯总结类动词句的「总结」自身不作 DOC_TYPE 证据,
|
||||
// 须命中其他文档类型词如「论文」才进 docgen)
|
||||
// 4. docgen:hasAttachments 且含 WRITE_VERB(附件兜底信号)
|
||||
// 5. qa
|
||||
|
||||
@@ -16,7 +18,12 @@ const FORMAT_WORD = ['pdf', 'docx', 'doc', 'word'];
|
||||
const EDIT_WORD = ['改', '修改', '调整', '删', '删除', '加一段', '补充', '润色', '缩写', '扩写', '重写', '改写', '优化', '加个', '增加一段'];
|
||||
|
||||
// —— 写文档意图词典 ——
|
||||
const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定'];
|
||||
// 缺陷 #52a:补「总结/概括/归纳」——「帮我总结论文」+附件走 docgen(规则 4 附件兜底)。
|
||||
// 纯总结类动词句(无其他写动词、无附件)须命中「论文」等其他文档类型词才进
|
||||
// docgen(规则 3 细化,详见 WRITE_VERB_SUMMARIZE_EXCLUDE 注释)——
|
||||
// 无附件「总结一下植物光合作用的过程」仍走 QA(漏判安全)。
|
||||
// 后端同词典:sdk/app/services/docgen/intent.py 的 WRITE_VERB(由 Dev-B1 同步)。
|
||||
const WRITE_VERB = ['写', '撰写', '拟', '起草', '编制', '生成', '编写', '拟定', '草拟', '制定', '总结', '概括', '归纳'];
|
||||
const DOC_TYPE = [
|
||||
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申请',
|
||||
'申报书', '标书', '合同', '协议', '方案', '计划', '总结', '汇报', '讲话稿', '致辞',
|
||||
@@ -28,6 +35,15 @@ const DOC_TYPE = [
|
||||
];
|
||||
// 明确排除(不属于 DOC_TYPE,防误判):介绍、内容、代码、诗、作业、问题、建议、想法
|
||||
const DOC_TYPE_EXCLUDE = ['介绍', '内容', '代码', '诗', '作业', '问题', '建议', '想法'];
|
||||
// 缺陷 #52a:「总结/概括/归纳」入 WRITE_VERB 后,「总结」本身会命中 DOC_TYPE
|
||||
// (如「总结一下植物光合作用的过程」无附件也被规则 3 拉进 docgen)。
|
||||
// 防护语义(TL 裁决):句子的写动词仅由总结类新动词构成时,「总结」自身
|
||||
// 不作 DOC_TYPE 证据——必须有其他文档类型词(如「总结这篇论文」的「论文」)
|
||||
// 才进 docgen;带附件场景由规则 4 兜底,不受影响。
|
||||
// 「写一篇总结」「写一份工作总结」等既有写类动词 + 总结类文档词路径不受影响。
|
||||
const WRITE_VERB_SUMMARIZE_EXCLUDE = ['总结', '概括', '归纳'];
|
||||
const WRITE_VERB_NON_SUMMARIZE = WRITE_VERB.filter((w) => !WRITE_VERB_SUMMARIZE_EXCLUDE.includes(w));
|
||||
const DOC_TYPE_NON_SUMMARIZE = DOC_TYPE.filter((w) => !WRITE_VERB_SUMMARIZE_EXCLUDE.includes(w));
|
||||
|
||||
/** 文本是否含词典中任一词(子串判定;英文不区分大小写——中文不受影响,与后端一致) */
|
||||
export function hasAnyWord(text, words) {
|
||||
@@ -43,6 +59,21 @@ export function hasAnyExcludeWord(text) {
|
||||
return hasAnyWord(text, DOC_TYPE_EXCLUDE);
|
||||
}
|
||||
|
||||
/** 缺陷 #52a:文本含总结类新动词 → 规则 3 需额外校验 */
|
||||
export function hasAnySummarizeVerb(text) {
|
||||
return hasAnyWord(text, WRITE_VERB_SUMMARIZE_EXCLUDE);
|
||||
}
|
||||
|
||||
/** 文本含除总结类新动词外的其他写类动词(写/撰写/生成/制定…) */
|
||||
export function hasNonSummarizeWriteVerb(text) {
|
||||
return hasAnyWord(text, WRITE_VERB_NON_SUMMARIZE);
|
||||
}
|
||||
|
||||
/** 文本命中非总结类的文档类型词(「总结」自身不作证据) */
|
||||
export function hasNonSummarizeDocType(text) {
|
||||
return hasAnyWord(text, DOC_TYPE_NON_SUMMARIZE);
|
||||
}
|
||||
|
||||
/** 导出文档类型词词典(任务书约定的导出类型词;判定走 EXPORT_WORD+FORMAT_WORD,此处导出供组件复用) */
|
||||
export const EXPORT_DOC_TYPE_WORDS = [
|
||||
'论文', '报告', '通知', '通告', '公告', '请示', '批复', '函', '申请书', '申报书',
|
||||
@@ -64,7 +95,12 @@ export function route(text, hasAttachments, hasActiveArtifact) {
|
||||
// 2. 编辑意图(需活动文档)
|
||||
if (hasActiveArtifact && hasAnyWord(s, EDIT_WORD)) return 'edit';
|
||||
// 3. 写文档意图:写类动词 + 文档类型词(排除词防误判优先)
|
||||
if (hasAnyWord(s, WRITE_VERB) && !hasAnyExcludeWord(s) && hasAnyWord(s, DOC_TYPE)) return 'docgen';
|
||||
// 缺陷 #52a:纯总结类动词句(无其他写动词)须命中非总结类文档类型词
|
||||
// (如「论文」)才进 docgen——「总结一下植物光合作用的过程」仍走 QA
|
||||
if (hasAnyWord(s, WRITE_VERB) && !hasAnyExcludeWord(s) && hasAnyWord(s, DOC_TYPE)) {
|
||||
const summarizeOnly = hasAnySummarizeVerb(s) && !hasNonSummarizeWriteVerb(s);
|
||||
if (!summarizeOnly || hasNonSummarizeDocType(s)) return 'docgen';
|
||||
}
|
||||
// 4. 附件兜底:拖入文件 + 写类动词,即使无文档类型词也视为生成意图
|
||||
if (hasAttachments && hasAnyWord(s, WRITE_VERB)) return 'docgen';
|
||||
// 5. 漏判安全:一律 qa
|
||||
|
||||
+19
-2
@@ -78,6 +78,10 @@ export const useBenchStore = defineStore('bench', {
|
||||
kgReady: false, // Neo4j 是否可用(false → 离线降级)
|
||||
kgError: false,
|
||||
kgNodeLimit: 50, // 移动端由 KgPane 下调为 30
|
||||
// ---- 会话来源缓存失效标记(缺陷 #27:新回答完成后强制重拉)----
|
||||
// 由 chat.js 终态(onComplete/_runSearch 完成)置 true;loadSessionSources
|
||||
// 拉取成功后置 false。SourcesPane 监听此标记自动 force 重拉。
|
||||
sourcesStale: false,
|
||||
// ---- 统一状态机 ----
|
||||
refreshSeq: 0, // 每轮「置空 → 加载」自增,用于丢弃过期响应
|
||||
needRefresh: {}, // pane → true:下次激活时重新装配(离线重试等场景)
|
||||
@@ -174,6 +178,11 @@ export const useBenchStore = defineStore('bench', {
|
||||
setSourceScope(scope) {
|
||||
this.sourceScope = scope;
|
||||
},
|
||||
/** 缺陷 #27:一轮问答终态时由 chat.js 调用——标记会话来源缓存失效,
|
||||
* 促使 SourcesPane 自动 force 重拉(不在流式过程中逐帧触发)。 */
|
||||
markSourcesStale() {
|
||||
this.sourcesStale = true;
|
||||
},
|
||||
// FR-CHAT-015:引用 chip 点击 → 切到来源 tab 并高亮对应条目 2s(PM 集成补丁,B2 依赖)
|
||||
// 缺陷 #27 修复:参数由 index 改为 key(paper_id/id)——「本次回答」域 items 与
|
||||
// chip 的来源块同序,key 匹配即可精确定位,不再依赖行序号(会话聚合域 id 错位
|
||||
@@ -211,7 +220,10 @@ export const useBenchStore = defineStore('bench', {
|
||||
* degraded/离线时保留已有数据并标记错误(失败必须可见,见 PRD §2.2)。
|
||||
*/
|
||||
async loadSessionSources({ force = false } = {}) {
|
||||
if (!force && this.sessionSources) return this.sessionSources;
|
||||
// 缺陷 #27:终态失效标记(chat.js 完成一轮后置 sourcesStale=true)——
|
||||
// 与 force 一样需要绕过缓存短路;成功落库后清除失效标记
|
||||
const mustReload = force || this.sourcesStale;
|
||||
if (!mustReload && this.sessionSources) return this.sessionSources;
|
||||
const sid = this._activeSessionId();
|
||||
if (!sid) return null;
|
||||
const seq = ++this.refreshSeq;
|
||||
@@ -219,9 +231,13 @@ export const useBenchStore = defineStore('bench', {
|
||||
this.sourcesError = false;
|
||||
try {
|
||||
const data = await httpGet(`/api/sessions/${sid}/sources`);
|
||||
if (seq === this.refreshSeq) this.sessionSources = data;
|
||||
if (seq === this.refreshSeq) {
|
||||
this.sessionSources = data;
|
||||
this.sourcesStale = false;
|
||||
}
|
||||
return data;
|
||||
} catch (err) {
|
||||
// 失败保留失效标记:下次 scope/pane 激活仍会重试
|
||||
if (seq === this.refreshSeq) this.sourcesError = true;
|
||||
throw err;
|
||||
} finally {
|
||||
@@ -351,6 +367,7 @@ export const useBenchStore = defineStore('bench', {
|
||||
resetSessionData() {
|
||||
this.localStageEvents = [];
|
||||
this.sessionSources = null;
|
||||
this.sourcesStale = false;
|
||||
this.sourcesLoading = false;
|
||||
this.sourcesError = false;
|
||||
this.jobDetail = {};
|
||||
|
||||
+18
-1
@@ -12,7 +12,7 @@
|
||||
// (streamCtx),切走后流在后台继续(消息数组 sessionMsgs 持续更新),
|
||||
// 切回时直接拿实时数组;stop/重试只作用于当前会话的流。
|
||||
// 原 FR-SESSION-004 的「切会话即中断」行为废止,见 CHANGELOG。
|
||||
import { defineStore } from 'pinia';
|
||||
import { defineStore, getActivePinia } from 'pinia';
|
||||
import { createMessage, updateMessage } from '../api/sessions.js';
|
||||
import { chatStream, search } from '../api/qa.js';
|
||||
import { useSessionsStore } from './sessions.js';
|
||||
@@ -1532,6 +1532,19 @@ export const useChatStore = defineStore('chat', {
|
||||
}
|
||||
},
|
||||
|
||||
/** 缺陷 #27:终态后标记会话来源聚合缓存失效(bench.sourcesStale = true)。
|
||||
* SourcesPane 监听到标记后自动 force 重拉——缓存只在选中会话时有意义,
|
||||
* 终态才触发一次,流式过程中不反复重拉。
|
||||
* 同步实现:经 getActivePinia 取 bench store(与 bench.js 读 chat 的方式对称,
|
||||
* 无动态 import 的微任务窗口——发送后立即断言/挂载渲染都能看到标记)。 */
|
||||
_markSourcesStaleSafe(sessionId) {
|
||||
try {
|
||||
if (sessionId && this.sessionId !== sessionId) return; // 跨会话(切走后后台完成):不打扰当前视图
|
||||
const bench = getActivePinia()?._s.get('bench');
|
||||
bench?.markSourcesStale();
|
||||
} catch { /* bench 未就绪(测试/降级):静默 */ }
|
||||
},
|
||||
|
||||
async _runStream(q, modeCfg, aMsg, sessionId, userId, ctx) {
|
||||
const controller = new AbortController();
|
||||
ctx.controller = controller;
|
||||
@@ -1627,6 +1640,8 @@ export const useChatStore = defineStore('chat', {
|
||||
// 落库失败不掩盖成功事实:本地消息保持 done,仅记录
|
||||
console.warn('persistFinal failed after complete:', persistErr);
|
||||
}
|
||||
// 缺陷 #27:回答完成后会话来源聚合缓存失效(列表自动刷新)
|
||||
this._markSourcesStaleSafe(sessionId);
|
||||
this._finishCtx(ctx, {
|
||||
ok: true,
|
||||
payload,
|
||||
@@ -1759,6 +1774,8 @@ export const useChatStore = defineStore('chat', {
|
||||
const dur = ((Date.now() - startAt) / 1000).toFixed(1) + 's';
|
||||
this._flushPrivate(aMsg); // FR-PRIV-005:blocks 赋值后 flush 私有 pending
|
||||
await this._persistFinal(aMsg, sessionId, 'done', { raw: aMsg.raw });
|
||||
// 缺陷 #27:检索模式回答完成后会话来源聚合缓存失效(列表自动刷新)
|
||||
this._markSourcesStaleSafe(sessionId);
|
||||
this._finishCtx(ctx, {
|
||||
ok: true,
|
||||
payload,
|
||||
|
||||
+37
-5
@@ -75,8 +75,16 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
if (!id) return;
|
||||
const prevId = this.activeArtifact ? (this.activeArtifact.artifact_id || this.activeArtifact.id) : null;
|
||||
const sameArtifact = prevId === id;
|
||||
if (sameArtifact && !force && this.md) return; // 同 id 且有本地内容:复用不重复拉取
|
||||
if (sameArtifact && !force && this.md) {
|
||||
// 同 id 且有本地内容:复用不重复拉取;但资料库视图下重开同一文档
|
||||
// 必须复位视图(缺陷 #47b 自含复位语义)
|
||||
this.viewMode = 'doc';
|
||||
return;
|
||||
}
|
||||
this.activeArtifact = { ...artifact, artifact_id: id };
|
||||
// 缺陷 #47b:打开/切换文档永远回到文档视图——close() 后活动文档已清空,
|
||||
// 重开必须自含地把视图复位;否则关闭→重开后停在空态/资料库视图「看不到文档」。
|
||||
this.viewMode = 'doc';
|
||||
this.loadError = null;
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
@@ -90,6 +98,8 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
this.letterhead = '';
|
||||
this.exportError = {};
|
||||
this.exporting = {};
|
||||
this._contentSeq = (this._contentSeq || 0) + 1; // 拉取归属序号整体换代:上一次打开的全部迟到响应作废
|
||||
this._htmlSeq = (this._htmlSeq || 0) + 1;
|
||||
}
|
||||
if (!this.md) await this.fetchContent(id);
|
||||
},
|
||||
@@ -117,17 +127,21 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
} finally {
|
||||
if (seq === this._contentSeq) this.loading = false;
|
||||
}
|
||||
if (seq === this._contentSeq) await this.fetchHtml(id);
|
||||
// 缺陷 #47b:html 拉取同样以内容序号守卫——文档 A 关闭后其迟到的
|
||||
// html 响应曾覆盖重开文档 B 的内容(空壳残留);归属不匹配即丢弃
|
||||
if (seq === this._contentSeq) await this.fetchHtml(id, seq);
|
||||
},
|
||||
|
||||
/** 拉取模版 HTML(预览与导出同源);失败静默(预览回退客户端 md 渲染)
|
||||
* 2026-09-04 问题5:序号防竞态——迟到的旧 html 响应不再覆盖新文档内容。 */
|
||||
async fetchHtml(id) {
|
||||
* 2026-09-04 问题5:序号防竞态——迟到的旧 html 响应不再覆盖新文档内容。
|
||||
* 缺陷 #47b:contentSeq 一并纳入守卫——文档已关闭/切换后迟到响应作废。 */
|
||||
async fetchHtml(id, contentSeq) {
|
||||
const seq = (this._htmlSeq || 0) + 1;
|
||||
this._htmlSeq = seq;
|
||||
try {
|
||||
const data = await getArtifactHtml(id);
|
||||
if (seq !== this._htmlSeq) return;
|
||||
if (contentSeq != null && contentSeq !== this._contentSeq) return;
|
||||
if (data && data.ok) {
|
||||
this.html = String(data.html || '');
|
||||
this.css = String(data.css || '');
|
||||
@@ -137,6 +151,7 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
}
|
||||
} catch {
|
||||
if (seq !== this._htmlSeq) return;
|
||||
if (contentSeq != null && contentSeq !== this._contentSeq) return;
|
||||
this.html = '';
|
||||
this.css = '';
|
||||
this.renderNonce++; // 值可能不变(此前已空):bump 序号强制预览走 md 回退渲染
|
||||
@@ -158,10 +173,21 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
const id = artifact.artifact_id || artifact.id;
|
||||
if (!id) return;
|
||||
this.activeArtifact = { ...artifact, artifact_id: id };
|
||||
// 缺陷 #47b:生成接管同样自含复位文档视图(与 open 同一状态机);
|
||||
// 拉取归属序号换代——上一文档的全部迟到响应作废
|
||||
this.viewMode = 'doc';
|
||||
this.loadError = null;
|
||||
this.editError = null;
|
||||
this.saveError = null;
|
||||
this.editing = false;
|
||||
this.html = '';
|
||||
this.css = '';
|
||||
this.templateKey = '';
|
||||
this.letterhead = '';
|
||||
this.exportError = {};
|
||||
this.exporting = {};
|
||||
this._contentSeq = (this._contentSeq || 0) + 1;
|
||||
this._htmlSeq = (this._htmlSeq || 0) + 1;
|
||||
this.md = String(initialMd ?? '');
|
||||
if (!this.md) await this.fetchContent(id);
|
||||
},
|
||||
@@ -275,7 +301,10 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
},
|
||||
|
||||
/** 关闭活动文档(切走 doc TAB 不清空;DocPane「关闭文档」入口)
|
||||
* 缺陷 #55:关闭时复位视图模式(回到资料库空态区视图) */
|
||||
* 缺陷 #55:关闭时复位视图模式(回到资料库空态区视图)。
|
||||
* 缺陷 #47b:关闭必须作废全部在途拉取——close() 只清结果状态,
|
||||
* 若之前 open/adopt 的 fetchHtml 仍在途,其迟到响应会把已清空的
|
||||
* html/css/templateKey 重新写回(宿主 v-if 已销毁,回来看不到文档)。 */
|
||||
close() {
|
||||
this.viewMode = 'doc';
|
||||
this.activeArtifact = null;
|
||||
@@ -290,6 +319,9 @@ export const useDocgenStore = defineStore('docgen', {
|
||||
this.saveError = null;
|
||||
this.editError = null;
|
||||
this.exportError = {};
|
||||
// 在途序号作废:迟到的 content/html 响应不得再写任何状态
|
||||
this._contentSeq = (this._contentSeq || 0) + 1;
|
||||
this._htmlSeq = (this._htmlSeq || 0) + 1;
|
||||
},
|
||||
|
||||
/** 会话切换时清空文档域状态(会话级产物不跨会话) */
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
// @vitest-environment jsdom
|
||||
// tests/r4debug/r4fixes.test.js —— r4 前端四项缺陷单测(Dev-F 归属)
|
||||
// #27 消息级来源 title 空值 fallback + 新回答完成后会话来源强制重拉
|
||||
// #32 知识图谱搜索成功但空结果的明确提示(与 kgDown/kgEmpty 三态互斥)
|
||||
// #47b DocPane 视图状态机:关闭→重开 / 资料库↔文档往返 / 会话切换
|
||||
// #52a 意图路由 WRITE_VERB 补「总结/概括/归纳」(带附件进 docgen,普通问答不误伤)
|
||||
//
|
||||
// 运行方式(worktree 内):cd ui && npx vitest run --config tests/r3debug/vitest.config.js tests/r4debug/r4fixes.test.js
|
||||
// (r3debug/vitest.config.js 的 include 覆盖 tests/r3debug/**,显式指定文件路径即可跑本文件)
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { mount, flushPromises } from '@vue/test-utils';
|
||||
import { setActivePinia, createPinia, getActivePinia } from 'pinia';
|
||||
|
||||
// jsdom 无 scrollTo:polyfill(SourceRow 高亮滚动 / 组件挂载用)
|
||||
if (typeof window !== 'undefined' && typeof window.Element !== 'undefined' && !window.Element.prototype.scrollTo) {
|
||||
window.Element.prototype.scrollTo = vi.fn();
|
||||
}
|
||||
|
||||
// ---- mock 依赖链(与 r3debug/docgenPoll.test.js 同款)----
|
||||
vi.mock('../../src/api/sessions.js', () => ({
|
||||
createMessage: vi.fn(async () => ({ id: 'm' + Math.random().toString(36).slice(2, 6) })),
|
||||
updateMessage: vi.fn(async () => ({ updated_at: new Date().toISOString() })),
|
||||
}));
|
||||
vi.mock('../../src/api/qa.js', () => ({
|
||||
chatStream: vi.fn(async () => {}),
|
||||
search: vi.fn(async () => ({ answer: '' })),
|
||||
}));
|
||||
vi.mock('../../src/api/private.js', () => ({
|
||||
askPrivate: vi.fn(async () => ({ hit: false })),
|
||||
}));
|
||||
vi.mock('../../src/api/docgen.js', () => ({
|
||||
generate: vi.fn(),
|
||||
getTask: vi.fn(),
|
||||
getArtifactContent: vi.fn(async () => ({ markdown: '' })),
|
||||
getArtifactHtml: vi.fn(async () => ({ ok: true, html: '', css: '' })),
|
||||
saveArtifactContent: vi.fn(async () => ({})),
|
||||
editArtifact: vi.fn(async () => ({})),
|
||||
exportArtifact: vi.fn(async () => ({})),
|
||||
uploadLibrary: vi.fn(),
|
||||
listLibrary: vi.fn(async () => ({ items: [] })),
|
||||
removeLibrary: vi.fn(async () => ({ ok: true })),
|
||||
listTasks: vi.fn(async () => ({ tasks: [] })),
|
||||
}));
|
||||
vi.mock('../../src/api/kg.js', () => ({
|
||||
searchEntities: vi.fn(),
|
||||
getNeighborhood: vi.fn(),
|
||||
kgStatus: vi.fn(async () => ({ ok: true, active: true })),
|
||||
}));
|
||||
|
||||
// ---- 被测模块 ----
|
||||
import { useChatStore } from '../../src/stores/chat.js';
|
||||
import { useSessionsStore } from '../../src/stores/sessions.js';
|
||||
import { useBenchStore, __setHttpGet } from '../../src/stores/bench.js';
|
||||
import { useDocgenStore } from '../../src/stores/docgen.js';
|
||||
import SourceRow from '../../src/components/bench/SourceRow.vue';
|
||||
import KgPane from '../../src/components/bench/KgPane.vue';
|
||||
import { route } from '../../src/intent.js';
|
||||
|
||||
function freshPinia() {
|
||||
const pinia = createPinia();
|
||||
setActivePinia(pinia);
|
||||
return pinia;
|
||||
}
|
||||
|
||||
/** 预置会话(跳过 create 网络路径) */
|
||||
function seedSession() {
|
||||
const chat = useChatStore();
|
||||
const sessions = useSessionsStore();
|
||||
chat.sessionId = 's1';
|
||||
sessions.activeId = 's1';
|
||||
sessions.items = [{ id: 's1', title: 't', status: 'done', created_at: '', updated_at: '' }];
|
||||
return { chat, sessions };
|
||||
}
|
||||
|
||||
describe('#27 SourceRow title 空值 fallback(消息级来源条目)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mountRow(source) {
|
||||
return mount(SourceRow, {
|
||||
props: { source, index: 0, count: null },
|
||||
global: { stubs: { teleport: true } },
|
||||
});
|
||||
}
|
||||
|
||||
it('title 为空 → 显示 paper_id(绝不渲染空文本或正文片段)', () => {
|
||||
const w = mountRow({ paper_id: 'PMC12345678', title: '', snippet: 'RESEARCH ARTICLE 正文片段…', score: 0.21 });
|
||||
const name = w.find('.ft-name');
|
||||
expect(name.text()).toBe('PMC12345678');
|
||||
// tooltip 与显示文本一致(完整 paper_id)
|
||||
expect(name.attributes('title')).toBe('PMC12345678');
|
||||
// 正文片段绝不进入标题行
|
||||
expect(name.text()).not.toContain('RESEARCH ARTICLE');
|
||||
});
|
||||
|
||||
it('title 与 paper_id 都空 → 显示 id', () => {
|
||||
const w = mountRow({ id: 'src-9', title: null, snippet: 'x' });
|
||||
expect(w.find('.ft-name').text()).toBe('src-9');
|
||||
});
|
||||
|
||||
it('title/paper_id/id 全空 → 显示 i18n 占位「(无标题)」', () => {
|
||||
const w = mountRow({ title: ' ', snippet: 'x' });
|
||||
expect(w.find('.ft-name').text()).toBe('(无标题)');
|
||||
});
|
||||
|
||||
it('正常 title 显示不受影响(r3 契约不回归)', () => {
|
||||
const w = mountRow({ paper_id: 'P1', title: '水稻抗病基因研究进展', score: 0.5 });
|
||||
expect(w.find('.ft-name').text()).toBe('水稻抗病基因研究进展');
|
||||
// 徽标真实 paper_id 契约不回归
|
||||
expect(w.find('.ft-ico.idx').text()).toBe('P1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#27 新回答完成后会话来源强制重拉(sourcesStale 链路)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
__setHttpGet(null);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
__setHttpGet(null);
|
||||
});
|
||||
|
||||
it('有缓存且无失效标记 → 缓存短路不重拉', async () => {
|
||||
freshPinia();
|
||||
seedSession();
|
||||
const bench = useBenchStore();
|
||||
bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
|
||||
const http = vi.fn(async () => ({ sources: [] }));
|
||||
__setHttpGet(http);
|
||||
const out = await bench.loadSessionSources();
|
||||
expect(out.sources[0].paper_id).toBe('P1');
|
||||
expect(http).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('chat 检索模式完成 → sourcesStale 置真 → 自动绕过缓存重拉,成功后标记清除', async () => {
|
||||
freshPinia();
|
||||
const { chat } = seedSession();
|
||||
const bench = useBenchStore();
|
||||
bench.sessionSources = { sources: [{ paper_id: 'P1', count: 1 }] };
|
||||
const http = vi.fn(async () => ({ sources: [{ paper_id: 'P2', count: 2 }] }));
|
||||
__setHttpGet(http);
|
||||
|
||||
const { search } = await import('../../src/api/qa.js');
|
||||
vi.mocked(search).mockResolvedValue({ answer: 'ok', sources: [{ paper_id: 'P2', title: 't' }], degraded: false });
|
||||
await chat.send('问题', { mode: 'search' });
|
||||
// 终态后失效标记已置
|
||||
expect(bench.sourcesStale).toBe(true);
|
||||
|
||||
const out = await bench.loadSessionSources();
|
||||
expect(http).toHaveBeenCalledWith('/api/sessions/s1/sources', undefined);
|
||||
expect(out.sources[0].paper_id).toBe('P2');
|
||||
expect(bench.sourcesStale).toBe(false); // 成功后清除,不会反复重拉
|
||||
});
|
||||
|
||||
it('QA 流 onComplete 终态 → sourcesStale 置真(终态才一次,不在流式中触发)', async () => {
|
||||
freshPinia();
|
||||
const { chat } = seedSession();
|
||||
const bench = useBenchStore();
|
||||
|
||||
const { chatStream } = await import('../../src/api/qa.js');
|
||||
let captured = null;
|
||||
vi.mocked(chatStream).mockImplementation(async (_body, handlers) => {
|
||||
captured = handlers;
|
||||
handlers.onProgress({ payload: { phase: 'retrieval', label: 'r' } });
|
||||
handlers.onProgress({ payload: { phase: 'generation', label: 'g' } });
|
||||
handlers.onComplete({ payload: { answer: '答案', sources: [] } });
|
||||
});
|
||||
const p = chat.send('问题', { mode: 'ask' });
|
||||
await p;
|
||||
expect(captured).not.toBeNull();
|
||||
// 完成前(流式过程中)不触发失效:终态后才一次
|
||||
expect(chat.messages.find((m) => m.role === 'assistant')?.status).toBe('done');
|
||||
expect(bench.sourcesStale).toBe(true);
|
||||
});
|
||||
|
||||
it('跨会话后台完成不打扰当前视图(sessionId 不匹配时失效标记不置真)', async () => {
|
||||
freshPinia();
|
||||
const { chat } = seedSession();
|
||||
const bench = useBenchStore();
|
||||
// 当前视图在 s2;后台流属于 s1
|
||||
chat.sessionId = 's2';
|
||||
chat._markSourcesStaleSafe('s1');
|
||||
expect(bench.sourcesStale).toBe(false);
|
||||
chat._markSourcesStaleSafe('s2');
|
||||
expect(bench.sourcesStale).toBe(true);
|
||||
});
|
||||
|
||||
it('resetSessionData 清理 sourcesStale(会话切换不残留失效标记)', () => {
|
||||
freshPinia();
|
||||
const bench = useBenchStore();
|
||||
bench.sourcesStale = true;
|
||||
bench.resetSessionData();
|
||||
expect(bench.sourcesStale).toBe(false);
|
||||
expect(bench.sessionSources).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#32 知识图谱搜索空结果提示(kgNoResult)', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function mountKg() {
|
||||
return mount(KgPane, {
|
||||
global: { plugins: [getActivePinia()], stubs: { teleport: true, KgGraphView: true, KgEntityDetail: true } },
|
||||
});
|
||||
}
|
||||
|
||||
it('搜索成功但 results 空 → 显示「未找到相关实体」,搜索按钮恢复可用', async () => {
|
||||
freshPinia();
|
||||
const bench = useBenchStore();
|
||||
bench.kgReady = true;
|
||||
bench.kgStatus = { ok: true };
|
||||
|
||||
const { searchEntities } = await import('../../src/api/kg.js');
|
||||
vi.mocked(searchEntities).mockResolvedValue({ results: [] });
|
||||
|
||||
const w = mountKg();
|
||||
await w.find('.search-box').setValue('水稻');
|
||||
await w.find('.search-btn').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain('未找到相关实体');
|
||||
// 与 kgDown(服务不可用)互斥:不出现离线文案
|
||||
expect(w.text()).not.toContain('知识图谱服务离线');
|
||||
// 搜索按钮恢复可用(空结果不锁按钮)
|
||||
const btn = w.find('.search-btn');
|
||||
expect(btn.attributes('disabled')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('搜索失败(kgDown)不出现空结果提示(触发条件互斥)', async () => {
|
||||
freshPinia();
|
||||
const bench = useBenchStore();
|
||||
bench.kgReady = true;
|
||||
bench.kgStatus = { ok: true };
|
||||
|
||||
const { searchEntities } = await import('../../src/api/kg.js');
|
||||
vi.mocked(searchEntities).mockRejectedValue(new Error('down'));
|
||||
|
||||
const w = mountKg();
|
||||
await w.find('.search-box').setValue('水稻');
|
||||
await w.find('.search-btn').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.text()).toContain('知识图谱服务离线');
|
||||
expect(w.text()).not.toContain('未找到相关实体');
|
||||
});
|
||||
|
||||
it('正常命中路径不回归:arginine 命中渲染结果列表、无「未找到」提示', async () => {
|
||||
freshPinia();
|
||||
const bench = useBenchStore();
|
||||
bench.kgReady = true;
|
||||
bench.kgStatus = { ok: true };
|
||||
|
||||
const { searchEntities } = await import('../../src/api/kg.js');
|
||||
vi.mocked(searchEntities).mockResolvedValue({
|
||||
results: [{ id: 'e1', name: 'arginine', type: 'Compound', support_count: 42 }],
|
||||
});
|
||||
|
||||
const w = mountKg();
|
||||
await w.find('.search-box').setValue('arginine');
|
||||
await w.find('.search-btn').trigger('click');
|
||||
await flushPromises();
|
||||
|
||||
expect(w.find('.result-item').exists()).toBe(true);
|
||||
expect(w.find('.rs-name').text()).toBe('arginine');
|
||||
expect(w.text()).not.toContain('未找到相关实体');
|
||||
});
|
||||
|
||||
it('初始空态 kgEmpty 与空结果提示不同文案(三态区分)', () => {
|
||||
freshPinia();
|
||||
const bench = useBenchStore();
|
||||
bench.kgReady = true;
|
||||
const w = mountKg();
|
||||
expect(w.find('.empty-note').text()).toContain('本次检索未产生图谱关系');
|
||||
expect(w.find('.empty-note').text()).not.toContain('未找到相关实体');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#47b DocPane 视图状态机(close→重开 / 资料库往返 / 会话切换)', () => {
|
||||
beforeEach(() => {
|
||||
freshPinia();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('打开文档 A → 关闭 → 重开:viewMode 复位为 doc、内容完整渲染', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
|
||||
|
||||
docgen.close();
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.activeArtifact).toBeNull();
|
||||
expect(docgen.md).toBe('');
|
||||
expect(docgen.html).toBe('');
|
||||
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', template_name: 't', status: 'done' });
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
|
||||
expect(docgen.loading).toBe(false);
|
||||
expect(docgen.loadError).toBeNull();
|
||||
});
|
||||
|
||||
it('反复关闭 3 次:状态干净、无残留(html/css/templateKey/letterhead 全空)', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
docgen.close();
|
||||
expect(docgen.activeArtifact).toBeNull();
|
||||
expect(docgen.md).toBe('');
|
||||
expect(docgen.html).toBe('');
|
||||
expect(docgen.css).toBe('');
|
||||
expect(docgen.templateKey).toBe('');
|
||||
expect(docgen.letterhead).toBe('');
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.loading).toBe(false);
|
||||
expect(docgen.loadError).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('资料库↔文档往返:资料库→返回文档→再资料库,viewMode 切换无残留', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
const { getArtifactContent } = await import('../../src/api/docgen.js');
|
||||
vi.mocked(getArtifactContent).mockResolvedValue({ markdown: '# 文档 A', title: 'A' });
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
docgen.viewMode = 'library';
|
||||
expect(docgen.viewMode).toBe('library');
|
||||
docgen.viewMode = 'doc';
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
|
||||
expect(docgen.md).toBe('# 文档 A');
|
||||
}
|
||||
// 资料库视图下重开同一文档 → open 自含复位回 doc
|
||||
docgen.viewMode = 'library';
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
});
|
||||
|
||||
it('文档 A 关闭后迟到的 html 响应作废:不得复活 A 的状态(#47b 根因)', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
const { getArtifactHtml } = await import('../../src/api/docgen.js');
|
||||
|
||||
// A 的 html 响应悬挂 10s:模拟打开过程中用户点击「关闭文档」(缺陷场景)
|
||||
vi.mocked(getArtifactHtml).mockImplementation(async (id) => {
|
||||
await new Promise((r) => setTimeout(r, 10000));
|
||||
return { ok: true, html: `<h1>${id}</h1>`, css: 'body{}', template_key: 'official', letterhead: '' };
|
||||
});
|
||||
|
||||
const openA = docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
await vi.advanceTimersByTimeAsync(0); // content 微任务排干,html 已发出(悬挂中)
|
||||
// 打开未完成时关闭:close 必须作废在途拉取
|
||||
docgen.close();
|
||||
expect(docgen.activeArtifact).toBeNull();
|
||||
|
||||
// 放行悬挂响应
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await flushPromises();
|
||||
await openA.catch(() => {});
|
||||
|
||||
// 关闭后迟到响应被序号守卫作废:A 的 html/css/templateKey 不复活
|
||||
expect(docgen.html).toBe('');
|
||||
expect(docgen.css).toBe('');
|
||||
expect(docgen.templateKey).toBe('');
|
||||
expect(docgen.activeArtifact).toBeNull();
|
||||
|
||||
// 重开文档 B:完整渲染不受 A 残留影响
|
||||
vi.mocked(getArtifactHtml).mockResolvedValue({ ok: true, html: '<h1>B</h1>', css: 'b{}', template_key: 'report', letterhead: '' });
|
||||
await docgen.open({ artifact_id: 'a2', title: 'B', status: 'done' });
|
||||
expect(docgen.activeArtifact?.artifact_id).toBe('a2');
|
||||
expect(docgen.html).toBe('<h1>B</h1>');
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
});
|
||||
|
||||
it('adoptArtifact 自含复位视图并清空旧内容(生成接管)', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
docgen.viewMode = 'library';
|
||||
docgen.html = '<h1>old</h1>';
|
||||
docgen.css = 'old{}';
|
||||
docgen.templateKey = 'old';
|
||||
docgen.letterhead = 'old';
|
||||
await docgen.adoptArtifact({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.html).toBe('');
|
||||
expect(docgen.css).toBe('');
|
||||
expect(docgen.templateKey).toBe('');
|
||||
expect(docgen.letterhead).toBe('');
|
||||
expect(docgen.activeArtifact?.artifact_id).toBe('a1');
|
||||
});
|
||||
|
||||
it('会话切换 resetSession 清空文档域:A 不串挂到 B', async () => {
|
||||
const docgen = useDocgenStore();
|
||||
await docgen.open({ artifact_id: 'a1', title: 'A', status: 'done' });
|
||||
expect(docgen.hasActiveArtifact).toBe(true);
|
||||
|
||||
docgen.resetSession();
|
||||
expect(docgen.activeArtifact).toBeNull();
|
||||
expect(docgen.md).toBe('');
|
||||
expect(docgen.html).toBe('');
|
||||
expect(docgen.viewMode).toBe('doc');
|
||||
expect(docgen.templateCandidates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#52a 意图路由 WRITE_VERB 补总结/概括/归纳', () => {
|
||||
it('「帮我总结论文」+ 附件 → docgen(规则 4 附件兜底)', () => {
|
||||
expect(route('帮我总结论文', true, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('「帮我概括这篇文章」+ 附件 → docgen', () => {
|
||||
expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('「帮我归纳资料」+ 附件 → docgen', () => {
|
||||
expect(route('帮我归纳资料', true, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('无附件「总结一下植物光合作用的过程」→ qa(无其他 DOC_TYPE 证据)', () => {
|
||||
expect(route('总结一下植物光合作用的过程', false, false)).toBe('qa');
|
||||
});
|
||||
|
||||
it('无附件「概括一下 OsSPL14 的功能」→ qa(无其他 DOC_TYPE 证据)', () => {
|
||||
expect(route('概括一下 OsSPL14 的功能', false, false)).toBe('qa');
|
||||
});
|
||||
|
||||
it('无附件「总结这篇论文」→ docgen(TL 裁决:DOC_TYPE 含「论文」,接受该行为变化)', () => {
|
||||
expect(route('总结这篇论文', false, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('无附件「帮我概括这篇文章」→ docgen(DOC_TYPE 含「文章」类材料词之外,按 DOC_TYPE 命中)', () => {
|
||||
// 「文章」不在 DOC_TYPE:按漏判安全应走 qa(附规则 4 附件兜底覆盖带附件场景)
|
||||
expect(route('帮我概括这篇文章', false, false)).toBe('qa');
|
||||
expect(route('帮我概括这篇文章', true, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('普通问答不误伤:你好 / 介绍自己 → qa', () => {
|
||||
expect(route('你好,介绍一下你自己', false, false)).toBe('qa');
|
||||
expect(route('你是谁', true, false)).toBe('qa');
|
||||
});
|
||||
|
||||
it('既有写类动词回归:写/撰写 + 论文 → docgen;无附件无 DOC_TYPE 写类仍 qa', () => {
|
||||
expect(route('帮我写一份水稻研究报告', false, false)).toBe('docgen');
|
||||
expect(route('撰写论文', false, false)).toBe('docgen');
|
||||
expect(route('写一首诗', false, false)).toBe('qa'); // DOC_TYPE_EXCLUDE 优先
|
||||
});
|
||||
|
||||
it('「写一篇总结」仍进 docgen(DOC_TYPE 含「总结」,行为不变)', () => {
|
||||
expect(route('写一篇总结', false, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('既有写类动词 + 总结类文档词不回归:「写一份工作总结」→ docgen', () => {
|
||||
expect(route('写一份工作总结', false, false)).toBe('docgen');
|
||||
});
|
||||
|
||||
it('规则 4 附件兜底:总结类动词带附件无 DOC_TYPE 也进 docgen', () => {
|
||||
expect(route('总结一下这份资料', true, false)).toBe('docgen');
|
||||
expect(route('归纳一下内容', true, false)).toBe('docgen');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user