fix: keep rectification questions context-aware
This commit is contained in:
@@ -1724,3 +1724,19 @@
|
||||
- 相关记录:BUG-076、BUG-091、BUG-095
|
||||
- 复发自:无
|
||||
- 修复版本:待本次 staging 修复提交与部署验收
|
||||
|
||||
## BUG-097 | 生时纠正分析状态停滞且教育事件被换词重问为迁居
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-29
|
||||
- 最近更新:2026-07-29
|
||||
- 影响面:V5 Agent 生时纠正运行状态、历史消息布局、Semantic Question Opportunity 排序与 Renderer 安全回退
|
||||
- 用户现象:任务实际已经经过“生成语义问题机会、选择下一步动作、生成安全回复”,页面运行中却一直显示“正在整理你刚才提到的经历”;完成后的“分析过程”显示在 Agent 正文下方;用户已经说明离家去外地上大学的年月后,下一问仍把同一经历换词问成搬到新城市或长期离乡。
|
||||
- 触发条件:Job 轮询返回新的 `phase`,但聊天消息继续读取首次 Case 响应中的旧 `data.job`;同时教育事件原文中的“离家、外地”命中迁居领域关键词并抬高 relocation 机会,Renderer 失败回退后直接使用带“以当前事件为时间参照”的模板。
|
||||
- 根因:Hook 同时维护 `data.job` 与独立 Job state,轮询只更新后者;恢复 processing Case 时 API 没有返回 active Job,刷新后无法继续轮询;`setInterval` 允许并发请求,较旧响应可能覆盖较新 phase;机会排序把零散地点词当成迁居主题信号,没有优先采用最新账本事件的已确认领域;迁居 fallback 和问题验证器没有禁止把当前事件改写成另一件事件继续追问。
|
||||
- 修复:轮询结果原子合并回 `data.job`,消息状态直接跟随服务端 `extracting_evidence / planning_question / reasoning / rendering`;Case Store 和 API 为 processing Case 恢复最新 pending/processing Job;轮询改为单飞 `setTimeout` 并拒绝较旧响应;把持久化分析收据移动到 Agent 正文上方并保留正文下方操作栏;迁居关键词收窄为明确搬迁动作,最新事件主题加权以账本领域为准;所有新事件 fallback 明确询问另一件独立事件,Renderer 拒绝旧跨事件模板、同义重复和与所选机会领域不匹配的问题。
|
||||
- 验证:组件测试锁定分析过程、正文、操作栏顺序、连续 Job phase 更新及乱序响应不倒退;服务测试锁定 processing 刷新恢复 active Job 和 shadow 年精度 legacy 细化;Agent 测试锁定外地上大学不会提升 relocation、事件数组顺序不改变排序、旧模板与跨领域实现触发安全 fallback;完整前端测试、lint、TypeScript、staging 构建、Python V5 服务测试和 staging 精确 SHA smoke 随本次发布执行。
|
||||
- 防复发:UI 只允许一个 Job 真源,恢复 processing Case 必须携带 active Job,轮询必须单飞且单调更新;新事件机会不得把 anchor 当作待细化目标;主题信号优先来自已验证事件领域,地点词不能单独代表迁居;Renderer 必须同时验证“另一件事件”、所选领域语义与安全边界。
|
||||
- 相关记录:BUG-090、BUG-093、BUG-094、BUG-095
|
||||
- 复发自:BUG-093、BUG-095
|
||||
- 修复版本:待本次 staging 修复提交与部署验收
|
||||
|
||||
@@ -39,9 +39,9 @@ const phaseLabels = {
|
||||
extracting_evidence: "正在整理你刚才提到的经历…",
|
||||
scoring_candidates: "正在扫描候选时间…",
|
||||
checking_robustness: "正在检查候选范围的稳定性…",
|
||||
planning_question: "正在选择下一条最有信息量的问题…",
|
||||
reasoning: "正在结合上下文决定下一步…",
|
||||
rendering: "正在组织下一条回复…",
|
||||
planning_question: "正在生成语义问题机会…",
|
||||
reasoning: "正在选择下一步动作…",
|
||||
rendering: "正在生成安全回复…",
|
||||
complete: "分析已完成",
|
||||
} as const;
|
||||
|
||||
@@ -380,12 +380,12 @@ export function RectificationV4Panel(props: RectificationV4PanelProps) {
|
||||
});
|
||||
return (
|
||||
<div className="rectification-message-entry" key={message.renderKey}>
|
||||
<RectificationMessageRow message={regenerating
|
||||
? { ...message, text: "", state: "thinking" }
|
||||
: message} />
|
||||
{message.role === "assistant" && message.state === "settled" && message.analysisTrace && (
|
||||
<RectificationAnalysisDetails trace={message.analysisTrace} />
|
||||
)}
|
||||
<RectificationMessageRow message={regenerating
|
||||
? { ...message, text: "", state: "thinking" }
|
||||
: message} />
|
||||
{showActions && !regenerating && (
|
||||
<div className="rectification-message-actions" aria-label="Agent 回答操作">
|
||||
<button
|
||||
|
||||
@@ -24,6 +24,16 @@ function friendly(error: unknown): string {
|
||||
return error instanceof Error ? error.message : "暂时无法处理,请稍后再试。";
|
||||
}
|
||||
|
||||
export function applyRectificationV4JobUpdate(
|
||||
data: RectificationV4ApiResponse | null,
|
||||
job: RectificationV4Job,
|
||||
): RectificationV4ApiResponse | null {
|
||||
if (data?.job?.id !== job.id) return data;
|
||||
if (["completed", "failed", "stale"].includes(data.job.status)) return data;
|
||||
if (job.updatedAt < data.job.updatedAt) return data;
|
||||
return { ...data, job };
|
||||
}
|
||||
|
||||
export function useRectificationV4(input: {
|
||||
readonly pendingConsultationQuestion?: string | null;
|
||||
readonly onPendingChange?: (pending: boolean) => void;
|
||||
@@ -31,13 +41,17 @@ export function useRectificationV4(input: {
|
||||
const onPendingChange = input.onPendingChange;
|
||||
const pendingConsultationQuestion = input.pendingConsultationQuestion?.trim() || null;
|
||||
const [data, setData] = useState<RectificationV4ApiResponse | null>(null);
|
||||
const [job, setJob] = useState<RectificationV4Job | null>(null);
|
||||
const [handoff, setHandoff] = useState<RectificationV4Handoff | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const mounted = useRef(true);
|
||||
|
||||
const job = data?.job ?? null;
|
||||
const jobId = job?.id ?? null;
|
||||
const jobStatus = job?.status ?? null;
|
||||
const caseId = data?.case.id ?? null;
|
||||
|
||||
const setBusy = useCallback((value: boolean) => {
|
||||
setPending(value);
|
||||
onPendingChange?.(value);
|
||||
@@ -47,7 +61,6 @@ export function useRectificationV4(input: {
|
||||
const result = caseId ? await loadRectificationV4(caseId) : await loadActiveRectificationV4();
|
||||
if (mounted.current) {
|
||||
setData(result);
|
||||
setJob(result?.job ?? null);
|
||||
}
|
||||
return result;
|
||||
}, []);
|
||||
@@ -74,7 +87,6 @@ export function useRectificationV4(input: {
|
||||
}
|
||||
if (mounted.current) {
|
||||
setData(result);
|
||||
setJob(result.job);
|
||||
setHandoff(nextHandoff);
|
||||
}
|
||||
} catch (caught) {
|
||||
@@ -87,23 +99,30 @@ export function useRectificationV4(input: {
|
||||
}, [pendingConsultationQuestion]);
|
||||
|
||||
useEffect(() => {
|
||||
const jobId = job?.id;
|
||||
if (!jobId || !["pending", "processing"].includes(job.status)) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadRectificationV4Job(jobId).then(async (next) => {
|
||||
if (!mounted.current) return;
|
||||
setJob(next);
|
||||
if (["completed", "failed", "stale"].includes(next.status) && data) {
|
||||
window.clearInterval(timer);
|
||||
const latest = await refresh(data.case.id);
|
||||
if (!jobId || !jobStatus || !["pending", "processing"].includes(jobStatus)) return;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const next = await loadRectificationV4Job(jobId);
|
||||
if (cancelled || !mounted.current) return;
|
||||
setData((current) => applyRectificationV4JobUpdate(current, next));
|
||||
if (["completed", "failed", "stale"].includes(next.status) && caseId) {
|
||||
const latest = await refresh(caseId);
|
||||
if (next.status === "failed" && latest) setError("这次比较没有完成,回答已经保留,请再试一次。");
|
||||
return;
|
||||
}
|
||||
}).catch((caught) => {
|
||||
if (mounted.current) setError(friendly(caught));
|
||||
});
|
||||
}, 1_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [data, job, refresh]);
|
||||
} catch (caught) {
|
||||
if (!cancelled && mounted.current) setError(friendly(caught));
|
||||
}
|
||||
if (!cancelled) timer = window.setTimeout(() => void poll(), 1_000);
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 1_000);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [caseId, jobId, jobStatus, refresh]);
|
||||
|
||||
const mutate = useCallback(async (operation: () => Promise<RectificationV4ApiResponse>) => {
|
||||
setBusy(true);
|
||||
@@ -112,7 +131,6 @@ export function useRectificationV4(input: {
|
||||
const result = await operation();
|
||||
if (mounted.current) {
|
||||
setData(result);
|
||||
setJob(result.job);
|
||||
}
|
||||
return result;
|
||||
} catch (caught) {
|
||||
|
||||
@@ -16,12 +16,12 @@ const domainPolicy: Readonly<Record<Exclude<EvidenceDomain, "family" | "other">,
|
||||
recallEase: number;
|
||||
privacyCost: number;
|
||||
}>>> = {
|
||||
education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之外,你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?` : "你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 },
|
||||
relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?` : "你哪次搬到新城市或长期离乡的年月最确定?", keywords: /搬家|迁居|离家|外地|城市|北京|上海|出国/, recallEase: .78, privacyCost: .04 },
|
||||
relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”这段时期,如果你愿意,哪次关系变化的大概年月还记得?` : "如果你愿意,哪次关系变化的大概年月还记得?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 },
|
||||
career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: (anchor) => anchor ? `在“${anchor}”之后,哪次工作或职责明显变化的年月你还记得?` : "哪次工作或职责明显变化的年月你还记得?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 },
|
||||
finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: (anchor) => anchor ? `以“${anchor}”为时间参照,如果方便,哪次财务状况明显变化的年月你还记得?` : "如果方便,哪次财务状况明显变化的年月你还记得?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 },
|
||||
health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: (anchor) => anchor ? `说到“${anchor}”前后,如果方便,你本人哪次健康变化的大概年月还记得?` : "如果方便,你本人哪次健康变化的大概年月还记得?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 },
|
||||
education: { goal: "收集一件有大致日期的教育转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?` : "你还记得哪次入学、毕业或专业变化大概发生在哪年哪月?", keywords: /大学|学校|入学|毕业|考试|专业|读书/, recallEase: .82, privacyCost: .03 },
|
||||
relocation: { goal: "收集一件有大致日期的迁居经历。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,你还记得哪次独立搬家或迁居大概发生在哪年哪月?` : "你还记得哪次搬家或迁居大概发生在哪年哪月?", keywords: /搬家|搬到|搬去|迁居|迁到|迁往|移居|定居|长期居住/, recallEase: .78, privacyCost: .04 },
|
||||
relationship: { goal: "在用户愿意的前提下收集一件有大致日期的关系转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果你愿意,哪次关系变化的大概年月还记得?` : "如果你愿意,哪次关系变化的大概年月还记得?", keywords: /恋爱|关系|结婚|离婚|分手|伴侣|对象/, recallEase: .62, privacyCost: .22 },
|
||||
career: { goal: "收集一件有大致日期的职业转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,哪次工作或职责明显变化的年月你还记得?` : "哪次工作或职责明显变化的年月你还记得?", keywords: /工作|实习|公司|研究院|职业|入职|离职|创业|负责/, recallEase: .85, privacyCost: .03 },
|
||||
finance: { goal: "在用户愿意的前提下收集一件有大致日期的财务转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果方便,哪次财务状况明显变化的年月你还记得?` : "如果方便,哪次财务状况明显变化的年月你还记得?", keywords: /收入|负债|投资|资产|财务|买房|卖房/, recallEase: .6, privacyCost: .18 },
|
||||
health_pressure: { goal: "在用户愿意的前提下收集一件本人有大致日期的健康转折。", fallbackPrompt: (anchor) => anchor ? `除了“${anchor}”,如果方便,你本人哪次健康变化的大概年月还记得?` : "如果方便,你本人哪次健康变化的大概年月还记得?", keywords: /住院|手术|事故|健康|生病|确诊|康复/, recallEase: .58, privacyCost: .28 },
|
||||
};
|
||||
|
||||
function stableUuid(value: string): string {
|
||||
@@ -182,13 +182,15 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
for (const [domain, policy] of Object.entries(domainPolicy) as [Exclude<EvidenceDomain, "family" | "other">, (typeof domainPolicy)[Exclude<EvidenceDomain, "family" | "other">]][]) {
|
||||
if (refusedDomains.has(domain)) continue;
|
||||
const covered = scoreableDomains.has(domain);
|
||||
const themeBonus = policy.keywords.test(latestContext) ? .22 : 0;
|
||||
const themeBonus = latestEvent
|
||||
? latestEvent.domain === domain ? .22 : 0
|
||||
: policy.keywords.test(latestContext) ? .22 : 0;
|
||||
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
|
||||
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal,
|
||||
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
|
||||
contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`],
|
||||
contextFacts: [`已有 ${scoreableCount} 件可评分事件。`, `该领域${covered ? "已有覆盖" : "尚未覆盖"}。`, "需要另一件独立事件,不要把最新事件换词重问。"],
|
||||
fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
|
||||
expectedInformationGain: covered ? .54 + themeBonus : .65 + themeBonus / 2,
|
||||
dateSensitivity: input.snapshot ? .5 : .35,
|
||||
|
||||
@@ -12,9 +12,19 @@ const bannedAcknowledgement = /(?:这个信息很有用|它不是单纯的|而
|
||||
const overinterpretedAcknowledgement = /(?:职业方向正式落地|人生意义|意味着你|说明你(?:已经|开始|正式)|标志着你)/;
|
||||
const internalTerms = /(?:opportunityId|snapshotId|eventId|targetEventId|requestedFields|fallbackPrompt|tool\s*call|tool_call|score|评分|模型名|opportunity|snapshot|D\d{1,2}|KP\b|Vimshottari)/i;
|
||||
const multiQuestionMoves = /(?:另外|还有|同时再说|并且告诉我|顺便|再告诉我)/;
|
||||
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对)/;
|
||||
const cannedQuestion = /(?:承接[“\"']?.{0,80}[”\"']?,?请再说一件|接下来请继续讲另一件|我会顺着你的叙述继续核对|以[“\"']?.{0,80}[”\"']?为(?:时间)?参照|搬到新城市|长期离乡)/;
|
||||
const exactClockMinute = /(?:[01]?\d|2[0-3])[::][0-5]\d|(?:[零〇一二两三四五六七八九十]{1,3}|(?:[01]?\d|2[0-3]))点(?:[零〇一二两三四五六七八九十]{1,3}|[0-5]?\d)分/;
|
||||
const exactMinuteClaim = /(?:唯一|准确|精确|确切|确认|确定|代表).{0,12}(?:出生|生时)?(?:时间|时刻|分钟)|(?:出生|生时)(?:时间|时刻|分钟)?.{0,12}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
|
||||
const distinctEventMove = /(?:除了|另一(?:件|次)|下(?:一|1)次|之后|后来|此后|还记得)/;
|
||||
const explicitAnchorReference = /(?:这次经历|这段经历|刚才那段|刚才这段|你刚说的|你刚提到的|刚说的|刚提到的|前面那段|这件事)/;
|
||||
const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"], RegExp>>> = {
|
||||
education: /(?:入学|升学|毕业|学校|大学|专业|考试|读书)/,
|
||||
relocation: /(?:搬家|搬到|搬去|迁居|迁到|迁往|移居|定居)/,
|
||||
relationship: /(?:恋爱|关系|结婚|离婚|分手|伴侣|对象)/,
|
||||
career: /(?:工作|实习|公司|研究院|职业|入职|离职|创业|职责|负责)/,
|
||||
finance: /(?:收入|负债|投资|资产|财务|买房|卖房)/,
|
||||
health_pressure: /(?:住院|手术|事故|健康|生病|确诊|康复)/,
|
||||
};
|
||||
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
|
||||
|
||||
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
|
||||
@@ -37,14 +47,32 @@ function normalized(value: string): string {
|
||||
return value.normalize("NFKC").replace(/[“”"'\s,,。.!!??::;;]/g, "");
|
||||
}
|
||||
|
||||
function includesAnchor(question: string, anchor: string): boolean {
|
||||
function matchingAnchorFragment(question: string, anchor: string): string | null {
|
||||
const normalizedQuestion = normalized(question);
|
||||
const normalizedAnchor = normalized(anchor);
|
||||
if (normalizedQuestion.includes(normalizedAnchor)) return true;
|
||||
for (let start = 0; start <= normalizedAnchor.length - 4; start += 1) {
|
||||
if (normalizedQuestion.includes(normalizedAnchor.slice(start, start + 4))) return true;
|
||||
if (!normalizedAnchor) return null;
|
||||
if (normalizedQuestion.includes(normalizedAnchor)) return normalizedAnchor;
|
||||
for (let length = Math.min(normalizedQuestion.length, normalizedAnchor.length); length >= 4; length -= 1) {
|
||||
for (let start = 0; start <= normalizedAnchor.length - length; start += 1) {
|
||||
const fragment = normalizedAnchor.slice(start, start + length);
|
||||
if (normalizedQuestion.includes(fragment)) return fragment;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function includesStrictAnchor(question: string, anchor: string): boolean {
|
||||
const normalizedAnchor = normalized(anchor);
|
||||
return normalizedAnchor.length > 0 && normalized(question).includes(normalizedAnchor);
|
||||
}
|
||||
|
||||
function withoutMatchedAnchors(question: string, anchors: readonly string[]): string {
|
||||
let remaining = normalized(question);
|
||||
for (const anchor of anchors) {
|
||||
const matched = matchingAnchorFragment(remaining, anchor);
|
||||
if (matched) remaining = remaining.replace(matched, "");
|
||||
}
|
||||
return remaining;
|
||||
}
|
||||
|
||||
function visibleTextSafetyIssues(value: string): string[] {
|
||||
@@ -65,8 +93,15 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques
|
||||
issues.push(...visibleTextSafetyIssues(value));
|
||||
if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction");
|
||||
if (cannedQuestion.test(value)) issues.push("canned_question_forbidden");
|
||||
if (opportunity.targetEventId || (opportunity.kind === "ask_new_event" && opportunity.anchors.length > 0)) {
|
||||
if (!opportunity.anchors.some((anchor) => includesAnchor(value, anchor))) issues.push("target_anchor_missing");
|
||||
if (opportunity.targetEventId) {
|
||||
if (!opportunity.anchors.some((anchor) => includesStrictAnchor(value, anchor))) issues.push("target_anchor_missing");
|
||||
} else if (opportunity.kind === "ask_new_event") {
|
||||
const anchorMatched = opportunity.anchors.some((anchor) => matchingAnchorFragment(value, anchor) !== null);
|
||||
if (opportunity.anchors.length > 0 && !anchorMatched && !explicitAnchorReference.test(value)) issues.push("target_anchor_missing");
|
||||
if (opportunity.anchors.length > 0 && !distinctEventMove.test(value)) issues.push("new_event_not_distinct");
|
||||
const domainTerms = newEventDomainTerms[opportunity.domain];
|
||||
const questionWithoutAnchor = withoutMatchedAnchors(value, opportunity.anchors);
|
||||
if (domainTerms && !domainTerms.test(questionWithoutAnchor)) issues.push("new_event_domain_mismatch");
|
||||
}
|
||||
for (const field of opportunity.requestedFields) {
|
||||
if (field === "event_subject" && !/(?:本人|你自己|家人|伴侣|配偶)/.test(value)) issues.push("event_subject_not_requested");
|
||||
|
||||
@@ -26,16 +26,19 @@ export function createRectificationV4CaseService(
|
||||
const realizeQuestion = options.regenerateQuestion ?? regenerateQuestionRealization;
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
const [events, turns, analysis] = await Promise.all([
|
||||
const [events, turns, analysis, job] = await Promise.all([
|
||||
store.loadEvents(userId, caseValue.id),
|
||||
store.loadTurns(userId, caseValue.id),
|
||||
caseValue.deploymentMode === "v5_agent"
|
||||
? store.loadAnalysisMessages(userId, caseValue.id)
|
||||
: Promise.resolve([]),
|
||||
jobId
|
||||
? store.loadJob(userId, jobId)
|
||||
: caseValue.status === "processing" ? store.loadActiveJob(userId, caseValue.id) : null,
|
||||
]);
|
||||
return {
|
||||
case: caseValue,
|
||||
job: jobId ? await store.loadJob(userId, jobId) : null,
|
||||
job,
|
||||
events: [...events],
|
||||
turns: [...turns],
|
||||
analysis: [...analysis],
|
||||
|
||||
@@ -230,6 +230,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
owned(userId, job.caseId);
|
||||
return job;
|
||||
},
|
||||
async loadActiveJob(userId, caseId) {
|
||||
owned(userId, caseId);
|
||||
return [...jobs.values()]
|
||||
.filter((job) => job.caseId === caseId && ["pending", "processing"].includes(job.status))
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt))[0] ?? null;
|
||||
},
|
||||
async updateJobPhase(input) {
|
||||
const job = jobs.get(input.jobId);
|
||||
if (!job || job.workerId !== input.workerId || job.status !== "processing") throw new RectificationV4StoreError("lease_lost");
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface RectificationV4Store {
|
||||
readonly now: string;
|
||||
}): Promise<RectificationV4Case>;
|
||||
loadJob(userId: string, jobId: string): Promise<RectificationV4Job | null>;
|
||||
loadActiveJob(userId: string, caseId: string): Promise<RectificationV4Job | null>;
|
||||
updateJobPhase(input: { readonly workerId: string; readonly jobId: string; readonly phase: RectificationV4Phase; readonly now: string }): Promise<void>;
|
||||
claimNextJob(workerId: string, now: string): Promise<ClaimedRectificationV4Job | null>;
|
||||
completeJob(input: CompleteRectificationV4JobInput, now: string): Promise<RectificationV4Case>;
|
||||
|
||||
@@ -345,6 +345,14 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
if (!row || row.user_id !== userId) return null;
|
||||
return jobValue(row);
|
||||
},
|
||||
async loadActiveJob(userId, caseId) {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_jobs")
|
||||
.select("*").eq("user_id", userId).eq("case_id", caseId)
|
||||
.in("status", ["pending", "processing"])
|
||||
.order("created_at", { ascending: false }).limit(1).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
return data ? jobValue(data as Row) : null;
|
||||
},
|
||||
async updateJobPhase(input) {
|
||||
await rpc("update_birth_time_rectification_v4_job_phase", {
|
||||
p_worker_id: input.workerId,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
rectificationPhaseLabel,
|
||||
toggleRectificationFeedback,
|
||||
} from "../src/components/rectification-v4-panel.tsx";
|
||||
import { applyRectificationV4JobUpdate } from "../src/hooks/use-rectification-v4.ts";
|
||||
import type { RectificationV4ApiResponse } from "../src/lib/rectification-v4/contracts.ts";
|
||||
|
||||
const id = "00000000-0000-4000-8000-000000000901";
|
||||
@@ -122,7 +123,7 @@ test("v4 rectification reuses the ordinary session message list, composer, and m
|
||||
assert.match(component, /<span>分析过程<\/span>/);
|
||||
assert.match(
|
||||
component,
|
||||
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<div className="rectification-message-actions"/,
|
||||
/<RectificationAnalysisDetails trace=\{message\.analysisTrace\} \/>[\s\S]*?<RectificationMessageRow[\s\S]*?<div className="rectification-message-actions"/,
|
||||
);
|
||||
assert.match(component, /caseValue\?\.deploymentMode === "v5_agent"/);
|
||||
assert.match(component, /controller\.regenerate\(\)/);
|
||||
@@ -229,28 +230,57 @@ test("legacy and shadow modes do not expose persisted analysis traces", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("processing shows the current server job phase in Chinese", () => {
|
||||
const base = response({ currentQuestion: null, status: "processing", phase: "checking_robustness" });
|
||||
const data = {
|
||||
...base,
|
||||
job: {
|
||||
id: "00000000-0000-4000-8000-000000000909",
|
||||
caseId: id,
|
||||
status: "processing",
|
||||
phase: "checking_robustness",
|
||||
expectedCaseVersion: 2,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
errorCode: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
} as unknown as RectificationV4ApiResponse;
|
||||
const messages = rectificationV4ChatMessages(data, true);
|
||||
assert.equal(messages.at(-1)?.role, "assistant");
|
||||
assert.equal(messages.at(-1)?.state, "thinking");
|
||||
assert.equal(messages.at(-1)?.text, "正在检查候选范围的稳定性…");
|
||||
assert.equal(rectificationPhaseLabel("planning_question"), "正在选择下一条最有信息量的问题…");
|
||||
test("processing follows every server job phase returned by polling", () => {
|
||||
const base = response({ currentQuestion: null, status: "processing", phase: "extracting_evidence" });
|
||||
const job = {
|
||||
id: "00000000-0000-4000-8000-000000000909",
|
||||
caseId: id,
|
||||
status: "processing",
|
||||
phase: "extracting_evidence",
|
||||
expectedCaseVersion: 2,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
errorCode: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
} as const;
|
||||
let data = { ...base, job } as unknown as RectificationV4ApiResponse;
|
||||
const phases = [
|
||||
["extracting_evidence", "正在整理你刚才提到的经历…"],
|
||||
["planning_question", "正在生成语义问题机会…"],
|
||||
["reasoning", "正在选择下一步动作…"],
|
||||
["rendering", "正在生成安全回复…"],
|
||||
] as const;
|
||||
|
||||
for (const [phase, label] of phases) {
|
||||
const updated = applyRectificationV4JobUpdate(data, { ...job, phase });
|
||||
assert.ok(updated);
|
||||
data = updated;
|
||||
const message = rectificationV4ChatMessages(data, true).at(-1);
|
||||
assert.equal(message?.role, "assistant");
|
||||
assert.equal(message?.state, "thinking");
|
||||
assert.equal(message?.text, label);
|
||||
}
|
||||
assert.equal(rectificationPhaseLabel("checking_robustness"), "正在检查候选范围的稳定性…");
|
||||
});
|
||||
|
||||
test("polling ignores an older job response so the visible phase cannot move backward", () => {
|
||||
const base = response({ currentQuestion: null, status: "processing", phase: "rendering" });
|
||||
const latest = {
|
||||
id: "00000000-0000-4000-8000-000000000909",
|
||||
caseId: id,
|
||||
status: "processing",
|
||||
phase: "rendering",
|
||||
expectedCaseVersion: 2,
|
||||
evidenceSetHash: "b".repeat(64),
|
||||
calculationSpecHash: "a".repeat(64),
|
||||
errorCode: null,
|
||||
createdAt: now,
|
||||
updatedAt: "2026-07-27T00:00:02.000Z",
|
||||
} as const;
|
||||
const data = { ...base, job: latest } as unknown as RectificationV4ApiResponse;
|
||||
const stale = { ...latest, phase: "planning_question" as const, updatedAt: "2026-07-27T00:00:01.000Z" };
|
||||
assert.equal(applyRectificationV4JobUpdate(data, stale)?.job?.phase, "rendering");
|
||||
});
|
||||
|
||||
test("analysis details render only public labels and preserve the message action icons", () => {
|
||||
|
||||
@@ -191,6 +191,66 @@ test("Builder 的领域排序不受事件输入数组顺序影响", () => {
|
||||
assert.deepEqual(domains([education, career]), domains([career, education]));
|
||||
});
|
||||
|
||||
test("外地上大学不会被换词提升为迁居问题", () => {
|
||||
const education = event({
|
||||
summary: "离家去外地上大学",
|
||||
rawText: "2016 年 9 月离家去外地上大学",
|
||||
});
|
||||
const opportunities = buildQuestionOpportunities({
|
||||
caseId,
|
||||
events: [education],
|
||||
turns: [turn({ answer: education.rawText })],
|
||||
snapshot: null,
|
||||
diagnostics: null,
|
||||
});
|
||||
|
||||
assert.notEqual(opportunities[0]?.domain, "relocation");
|
||||
const relocation = opportunities.find((item) => item.kind === "ask_new_event" && item.domain === "relocation");
|
||||
assert.ok(relocation);
|
||||
assert.doesNotMatch(relocation.fallbackPrompt, /搬到新城市|长期离乡|以.*为(?:时间)?参照/);
|
||||
assert.match(relocation.fallbackPrompt, /除了.*离家去外地上大学.*搬家或迁居/);
|
||||
|
||||
const repeated = "以“离家去外地上大学”为时间参照,你哪次搬到新城市或长期离乡的年月最确定?";
|
||||
assert.equal(validateQuestionRealization(repeated, relocation).valid, false);
|
||||
assert.equal(validateQuestionRealization("离家去外地上大学这件事大概发生在哪年哪月?", relocation).valid, false);
|
||||
assert.equal(validateQuestionRealization("除了离家去外地上大学,你哪次工作变化发生在哪年哪月?", relocation).valid, false);
|
||||
const message = realizePublicMessage({ acknowledgement: "你提到的是 2016 年 9 月离家去外地上大学。", candidateUpdate: null, limitation: null, question: repeated }, {
|
||||
latestAnswer: education.rawText,
|
||||
acceptedEvents: [education],
|
||||
pendingEvidence: [],
|
||||
snapshot: null,
|
||||
previousSnapshot: null,
|
||||
validated: validated(relocation),
|
||||
});
|
||||
assert.equal(message.question, relocation.fallbackPrompt);
|
||||
});
|
||||
|
||||
test("外地上大学场景的机会排序不受事件数组顺序影响", () => {
|
||||
const education = event({
|
||||
summary: "离家去外地上大学",
|
||||
rawText: "2016 年 9 月离家去外地上大学",
|
||||
createdAt: "2026-07-29T02:00:00.000Z",
|
||||
});
|
||||
const career = event({
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
summary: "开始第一份工作",
|
||||
rawText: "2019 年 7 月开始第一份工作",
|
||||
dateRange: { start: "2019-07-01", end: "2019-07-31", precision: "month", label: "2019年7月" },
|
||||
createdAt: "2026-07-29T01:00:00.000Z",
|
||||
});
|
||||
const ranked = (events: readonly LifeEventRevision[]) => buildQuestionOpportunities({
|
||||
caseId,
|
||||
events,
|
||||
turns: [turn({ answer: education.rawText, createdAt: education.createdAt })],
|
||||
snapshot: null,
|
||||
diagnostics: null,
|
||||
}).map((item) => [item.kind, item.domain, item.utility, item.fallbackPrompt]);
|
||||
|
||||
assert.deepEqual(ranked([education, career]), ranked([career, education]));
|
||||
assert.doesNotMatch(ranked([education, career]).map((item) => item[3]).join("\n"), /搬到新城市|长期离乡|以.*为(?:时间)?参照/);
|
||||
});
|
||||
|
||||
test("研究院实习后优先延续最新主题,不被旧教育事件的离家关键词拉回迁居问卷", () => {
|
||||
const education = event({
|
||||
summary: "离家去外地上大学",
|
||||
@@ -250,6 +310,51 @@ test("Renderer 接受锚定最新事件的自然新事件问题并拒绝旧固
|
||||
assert.notEqual(message.question, opportunity.fallbackPrompt);
|
||||
});
|
||||
|
||||
test("ask_new_event 领域验证忽略承接 anchor,拒绝实际询问的跨领域事件", () => {
|
||||
const latest = event({
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
summary: "去石油化工研究院实习做研究员",
|
||||
rawText: "2020年4月去石油化工研究院实习做研究员",
|
||||
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
|
||||
});
|
||||
const opportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null })
|
||||
.find((item) => item.kind === "ask_new_event" && item.domain === "career");
|
||||
assert.ok(opportunity);
|
||||
|
||||
const result = validateQuestionRealization("研究院实习之后,下一次升学大概发生在什么时候?", opportunity);
|
||||
assert.equal(result.valid, false);
|
||||
assert.ok(result.issues.includes("new_event_domain_mismatch"));
|
||||
});
|
||||
|
||||
test("ask_new_event 允许明确代词承接并识别教育领域的升学事件", () => {
|
||||
const latest = event({
|
||||
domain: "career",
|
||||
eventKind: "career_change",
|
||||
summary: "去石油化工研究院实习做研究员",
|
||||
rawText: "2020年4月去石油化工研究院实习做研究员",
|
||||
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
|
||||
});
|
||||
const baseOpportunity = buildQuestionOpportunities({ caseId, events: [latest], turns: [turn({ answer: latest.rawText })], snapshot: null, diagnostics: null })
|
||||
.find((item) => item.kind === "ask_new_event" && item.domain === "career");
|
||||
assert.ok(baseOpportunity);
|
||||
const opportunity: QuestionOpportunity = { ...baseOpportunity, domain: "education" };
|
||||
|
||||
for (const reference of ["这次经历", "刚才那段", "你刚说的"]) {
|
||||
const result = validateQuestionRealization(`${reference}之后,下一次升学大概发生在什么时候?`, opportunity);
|
||||
assert.equal(result.valid, true, `${reference}: ${result.issues.join(",")}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("targetEventId 非空时只接受完整真实 anchor,不接受代词或四字片段", () => {
|
||||
const target = event({ summary: "2020年4月研究院实习" });
|
||||
const opportunity = targetOpportunity(target);
|
||||
|
||||
assert.equal(validateQuestionRealization("关于2020年4月研究院实习,你还记得具体日期吗?", opportunity).valid, true);
|
||||
assert.equal(validateQuestionRealization("关于研究院实习,你还记得具体日期吗?", opportunity).valid, false);
|
||||
assert.equal(validateQuestionRealization("关于这次经历,你还记得具体日期吗?", opportunity).valid, false);
|
||||
});
|
||||
|
||||
test("Builder 和 Reasoner 按事件创建时间承接最近经历而不是 UUID 顺序", () => {
|
||||
const older = event({
|
||||
eventId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
|
||||
|
||||
@@ -53,7 +53,7 @@ test("same calculation spec resumes while a changed spec abandons the old case a
|
||||
assert.equal(store.jobs.get(queued.job.id)?.status, "stale");
|
||||
}));
|
||||
|
||||
test("answer is durably queued and polling does not mutate the job", async () => withMode("v5_agent", async () => {
|
||||
test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
@@ -66,8 +66,12 @@ test("answer is durably queued and polling does not mutate the job", async () =>
|
||||
});
|
||||
assert.equal(queued?.case.status, "processing");
|
||||
assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5");
|
||||
const queuedJob = queued?.job;
|
||||
assert.ok(queuedJob);
|
||||
const before = JSON.stringify([...store.jobs.values()]);
|
||||
assert.equal((await service.loadCase(userId, created.case.id))?.job, null);
|
||||
const restored = await service.loadCase(userId, created.case.id);
|
||||
assert.equal(restored?.job?.id, queuedJob.id);
|
||||
assert.equal(restored?.job?.status, "pending");
|
||||
assert.equal(JSON.stringify([...store.jobs.values()]), before);
|
||||
}));
|
||||
|
||||
@@ -78,7 +82,7 @@ test("V5 agent fallback persists Agent Run, Public Message and a server-owned op
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2016年离家去外地上大学",
|
||||
answer: "2016年9月离家去外地上大学",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
const worker = createRectificationV4Worker({
|
||||
@@ -109,7 +113,7 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2016年离家去外地上大学",
|
||||
answer: "2016年9月离家去外地上大学",
|
||||
});
|
||||
const worker = createRectificationV4Worker({
|
||||
store, now: fixedNow,
|
||||
@@ -123,11 +127,34 @@ test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible proj
|
||||
assert.equal(run.deploymentMode, "v5_shadow");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.kind, "ask_new_event");
|
||||
assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, null);
|
||||
assert.equal(done.case.currentQuestion?.targetEventId, event.eventId);
|
||||
assert.equal(done.case.currentQuestion?.targetEventId, null);
|
||||
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
|
||||
assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/);
|
||||
}));
|
||||
|
||||
test("V5 shadow keeps legacy year-precision refinement targeted to the original event", async () => withMode("v5_shadow", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
const queued = await service.answer({
|
||||
userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0,
|
||||
answer: "2016年离家去外地上大学",
|
||||
});
|
||||
const worker = createRectificationV4Worker({
|
||||
store, now: fixedNow,
|
||||
engine: { async score() { throw new Error("engine must not run before enough events"); } },
|
||||
});
|
||||
assert.equal(await worker.runOnce(), true);
|
||||
const done = await service.loadCase(userId, created.case.id);
|
||||
const event = done?.events[0];
|
||||
assert.ok(queued?.job && event);
|
||||
assert.equal(done.case.currentQuestion?.targetEventId, event.eventId);
|
||||
assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/);
|
||||
assert.ok([...store.agentRuns.values()].length > 0);
|
||||
assert.ok(store.publicMessages.has(queued.job.id));
|
||||
}));
|
||||
|
||||
test("legacy cases are not hard-switched to V5 even when flags change later", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createRectificationV4CaseService(store, { now: fixedNow });
|
||||
|
||||
Reference in New Issue
Block a user