fix: stabilize rectification staging release

This commit is contained in:
Jesse_Chen
2026-07-29 18:17:46 +08:00
parent ee9d432762
commit 163d3fd1be
12 changed files with 317 additions and 11 deletions
+28
View File
@@ -1606,3 +1606,31 @@
- 修复:删除客户端不应拥有的 `high_rigor` 输入;在异步请求结束后从 `globalThis` 重新读取身份缓存;按生产边界把 `Date` 归一化为日期字符串后再构建 onboarding cache identity。
- 验证:`npx tsc --noEmit`、相关前端测试和生产构建通过。
- 防复发:测试输入只使用公开类型拥有的字段;异步初始化的全局缓存不要依赖删除前的局部控制流;数据库日期联合类型在进入纯字符串合同前必须归一化。
## BUG-089 | Staging CI 自动升级 MCP 2.0 导致旧 FastMCP 导入失败
- 状态:resolved
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:`Staging Backend Quality Gate` 的 Python quick quality gate 与 staging 发布
- 用户现象:本地完整验证通过,但 staging push 的质量门在导入 `mcp_server.py` 时失败,报 `ModuleNotFoundError: No module named 'mcp.server.fastmcp'`
- 根因:`requirements.txt``pyproject.toml` 仅声明 `mcp>=1.0`CI 在 2026-07-29 安装了不兼容的 `mcp 2.0.0`,而仓库当前服务端仍使用 MCP 1.x 的 `mcp.server.fastmcp.FastMCP` 导入合同。本地环境保留 `mcp 1.25.0`,因此未复现依赖漂移。
- 修复:两个发布依赖入口统一限制为 `mcp>=1.0,<2`,继续使用已验证的 MCP 1.x API,不在本次发布中混入 MCP 2.0 迁移。
- 验证:新增依赖合同测试同时读取 `requirements.txt``pyproject.toml`,防止任一入口再次放宽到 MCP 2.xPython quick quality gate 与构建重新执行。
- 防复发:运行时依赖的主版本兼容边界必须在全部安装入口保持一致;升级 MCP 2.x 必须作为独立迁移处理并先替换导入/API 合同。
- 相关记录:BUG-087、BUG-088
- 修复版本:待本次 staging 修复提交与部署验收
## BUG-090 | V6 审查发现用户可见分钟注入、家庭事件越权和最新事件排序错误
- 状态:resolved
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:生时纠正 Renderer、模型辅助事件提取、Opportunity 与 Reasoner 最近事件上下文
- 用户现象:模型可能在 acknowledgement 或 limitation 中声称唯一出生分钟;家庭健康事件可能被矛盾模型字段伪装成本人可评分事件;UUID 排序与创建时间相反时,下一问可能承接较早经历。
- 根因:分钟安全验证只覆盖 question;辅助提取校验未拒绝 `subject=self` 与非空家庭 `relatedPerson` 的矛盾组合;账本的稳定 UUID 排序被误当作会话时间顺序。
- 修复:全部用户可见 Renderer 字段统一执行出生分钟和内部信息安全校验并回落到服务器确定性文案;辅助提取在服务器拒绝主体/亲属矛盾并保持家庭事件 `context_only` 边界;Builder 与 Reasoner 显式按 `createdAt``eventId`、revision 稳定排序最近事件,不改变证据哈希使用的账本排序。
- 验证:新增 acknowledgement/limitation 分钟注入、家庭 ICU 事件越权、UUID 与创建时间逆序的回归测试,并重新运行前端完整测试、lint、TypeScript 与构建。
- 防复发:所有模型可写用户文案共享同一安全边界;模型提取不能决定评分主体;用于哈希的稳定顺序不得被复用为会话时序。
- 相关记录:BUG-075、BUG-086、BUG-087
- 修复版本:待本次 staging 修复提交与部署验收
@@ -292,6 +292,18 @@ const allowedKindsByDomain: Readonly<Record<RectificationEvidenceDomain, readonl
other: ["other"],
};
const familyRelatedPeople = new Set<ModelAssistedEventExtraction["relatedPerson"]>([
"father",
"mother",
"grandparent",
"sibling",
]);
const explicitFamilySubjectMarkers = [
"父亲", "爸爸", "老爸", "母亲", "妈妈", "老妈",
"爷爷", "奶奶", "外公", "外婆", "祖父", "祖母", "外祖父", "外祖母",
"兄弟", "姐妹", "家里老人", "家中老人",
] as const;
export function validatedModelAssistedEvidence(input: Readonly<{
rawText: string;
sourceTurnId: string;
@@ -305,7 +317,14 @@ export function validatedModelAssistedEvidence(input: Readonly<{
const date = parseDeclaredDateText(dateText.normalize("NFKC"), input.asOfDate);
if (!date || dateIsFuture(date, input.asOfDate)) return null;
if (!allowedKindsByDomain[input.extraction.domain]?.includes(input.extraction.eventKind)) return null;
if (input.extraction.subject === "partner" && input.extraction.domain !== "relationship") return null;
const { subject, relatedPerson, domain } = input.extraction;
if (subject === "self" && relatedPerson !== null) return null;
if ((subject === "family") !== (domain === "family")) return null;
if (familyRelatedPeople.has(relatedPerson) && subject !== "family") return null;
if (relatedPerson === "partner" && (subject !== "partner" || domain !== "relationship")) return null;
if (subject === "partner" && (domain !== "relationship" || relatedPerson !== "partner")) return null;
if (explicitFamilySubjectMarkers.some((marker) => sourceSpan.includes(marker))
&& (subject !== "family" || domain !== "family")) return null;
const summary = eventSummary(sourceSpan);
if (summary === missingEventSummary) return null;
const familyContext = input.extraction.subject === "family" || input.extraction.domain === "family";
@@ -1,5 +1,6 @@
import { createHash } from "node:crypto";
import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts";
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
@@ -181,12 +182,12 @@ export function buildQuestionOpportunities(input: Readonly<{
}
const scoreableCount = input.events.filter((event) => event.scoreability === "scoreable").length;
const latestEvent = chronologicalEvents(input.events).at(-1);
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) ? .12 : 0;
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
const latestEvent = input.events.at(-1);
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
const prompt = latestAnchor
? `承接“${latestAnchor}”,请再说一件时间相对明确的经历:${policy.fallbackPrompt}`
@@ -4,6 +4,7 @@ import { createTool } from "@mastra/core/tools";
import { z } from "zod";
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
import { chronologicalEvents } from "../rectification-v4/evidence-ledger.ts";
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
import { deterministicDecision } from "./fallback-policy.ts";
import { recordRectificationAgentTelemetry } from "./telemetry.ts";
@@ -54,7 +55,7 @@ export function buildReasonerState(input: Readonly<{
candidateRangeChanged: input.candidateRangeChanged ?? false,
latestAnswer: input.recentTurns?.at(-1)?.answer ?? "",
recentTurns: (input.recentTurns ?? []).slice(-6).map((turn) => ({ question: turn.question, answer: turn.answer })),
recentEvents: (input.recentEvents ?? []).slice(-5).map((event) => ({ summary: event.summary, date: event.dateRange.label, domain: event.domain, subject: event.subject })),
recentEvents: chronologicalEvents(input.recentEvents ?? []).slice(-5).map((event) => ({ summary: event.summary, date: event.dateRange.label, domain: event.domain, subject: event.subject })),
currentTarget: input.currentTarget ? { summary: input.currentTarget.summary, date: input.currentTarget.dateRange.label, domain: input.currentTarget.domain } : null,
targetDisposition: input.targetDisposition ?? "not_applicable",
pendingEvidence: {
@@ -11,7 +11,8 @@ 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 birthMinute = /(?:出生|生时|几点).{0,12}(?:[01]\d|2[0-3]):[0-5]\d|(?:[01]\d|2[0-3]):[0-5]\d.{0,12}(?:出生|生时)/;
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}(?:唯一|准确|精确|确切|确认|确定|代表|就是)/;
function agentFor(modelId: string | null): { id: string; agent: Agent } | null {
const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel();
@@ -33,6 +34,13 @@ function normalized(value: string): string {
return value.normalize("NFKC").replace(/[“”"'\s,。.!?::;;]/g, "");
}
function visibleTextSafetyIssues(value: string): string[] {
const issues: string[] = [];
if (internalTerms.test(value)) issues.push("internal_information_exposed");
if (exactClockMinute.test(value) || exactMinuteClaim.test(value)) issues.push("birth_minute_injected");
return issues;
}
export function validateQuestionRealization(question: unknown, opportunity: QuestionOpportunity): Readonly<{ valid: boolean; issues: readonly string[] }> {
if (typeof question !== "string") return { valid: false, issues: ["question_missing"] };
const value = question.trim();
@@ -41,9 +49,8 @@ export function validateQuestionRealization(question: unknown, opportunity: Ques
if ((value.match(/[?]/g) ?? []).length > 1) issues.push("multiple_question_marks");
if ((value.match(/[。.!?]/g) ?? []).length > 2) issues.push("too_many_sentences");
if (/\n\s*(?:[-*•]|\d+[.)、])/.test(value)) issues.push("question_list_forbidden");
if (internalTerms.test(value)) issues.push("internal_information_exposed");
issues.push(...visibleTextSafetyIssues(value));
if (multiQuestionMoves.test(value)) issues.push("multiple_question_instruction");
if (birthMinute.test(value)) issues.push("birth_minute_injected");
if (opportunity.targetEventId) {
const questionText = normalized(value);
if (!opportunity.anchors.some((anchor) => questionText.includes(normalized(anchor)))) issues.push("target_anchor_missing");
@@ -111,9 +118,9 @@ export function realizePublicMessage(value: unknown, input: Parameters<typeof de
const parsed = publicMessageSchema.parse(value);
const opportunity = input.validated.selectedOpportunity;
const fallback = deterministic(input);
const acknowledgement = bannedAcknowledgement.test(parsed.acknowledgement)
const acknowledgement = visibleTextSafetyIssues(parsed.acknowledgement).length > 0
|| bannedAcknowledgement.test(parsed.acknowledgement)
|| overinterpretedAcknowledgement.test(parsed.acknowledgement)
|| internalTerms.test(parsed.acknowledgement)
|| (parsed.acknowledgement.match(/[。.!?]/g) ?? []).length > 2
|| (input.acceptedEvents.at(-1) && !normalized(parsed.acknowledgement).includes(normalized(input.acceptedEvents.at(-1)!.summary)))
? fallback.acknowledgement
@@ -124,7 +131,7 @@ export function realizePublicMessage(value: unknown, input: Parameters<typeof de
return {
acknowledgement,
candidateUpdate: fallback.candidateUpdate,
limitation: fallback.limitation ?? (parsed.limitation && !internalTerms.test(parsed.limitation) ? parsed.limitation : null),
limitation: fallback.limitation ?? (parsed.limitation && visibleTextSafetyIssues(parsed.limitation).length === 0 ? parsed.limitation : null),
question,
};
}
@@ -15,6 +15,12 @@ export function latestEventRevisions(revisions: readonly LifeEventRevision[]): r
return [...latest.values()].sort((left, right) => left.eventId.localeCompare(right.eventId));
}
export function chronologicalEvents(events: readonly LifeEventRevision[]): readonly LifeEventRevision[] {
return [...events].sort((left, right) => left.createdAt.localeCompare(right.createdAt)
|| left.eventId.localeCompare(right.eventId)
|| left.revision - right.revision);
}
export function appendEventRevision(
revisions: readonly LifeEventRevision[],
input: NewEventRevision,
@@ -191,6 +191,31 @@ test("Builder 的领域排序不受事件输入数组顺序影响", () => {
assert.deepEqual(domains([education, career]), domains([career, education]));
});
test("Builder 和 Reasoner 按事件创建时间承接最近经历而不是 UUID 顺序", () => {
const older = event({
eventId: "ffffffff-ffff-4fff-8fff-ffffffffffff",
summary: "较早的研究院实习",
createdAt: "2026-07-29T01:00:00.000Z",
});
const newer = event({
eventId: "00000000-0000-4000-8000-000000000000",
domain: "relocation",
eventKind: "relocation",
summary: "刚提到的搬到北京",
rawText: "2018年8月搬到北京",
dateRange: { start: "2018-08-01", end: "2018-08-31", precision: "month", label: "2018年8月" },
createdAt: "2026-07-29T02:00:00.000Z",
});
const opportunities = buildQuestionOpportunities({ caseId, events: [newer, older], turns: [], snapshot: null, diagnostics: null });
const askNewEvent = opportunities.find((item) => item.kind === "ask_new_event");
assert.ok(askNewEvent);
assert.match(askNewEvent.fallbackPrompt, /刚提到的搬到北京/);
assert.doesNotMatch(askNewEvent.fallbackPrompt, /较早的研究院实习/);
const state = buildReasonerState({ snapshot: null, diagnostics: diagnostics(), opportunities, recentEvents: [newer, older] });
assert.equal(state.recentEvents.at(-1)?.summary, "刚提到的搬到北京");
});
test("Reasoner 状态包含最近语义上下文但不包含贡献矩阵", () => {
const latestEvent = event();
const opportunity = targetOpportunity(latestEvent);
@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import { validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
test("模型辅助提取拒绝 self 与家庭 relatedPerson 的矛盾组合", () => {
const rawText = "2020年4月老爸进了ICU。";
const result = validatedModelAssistedEvidence({
rawText,
sourceTurnId: randomUUID(),
asOfDate: "2026-07-29",
extraction: {
sourceSpan: "2020年4月老爸进了ICU",
summary: "老爸进了ICU",
domain: "health_pressure",
eventKind: "self_health_event",
subject: "self",
relatedPerson: "father",
dateText: "2020年4月",
},
});
assert.equal(result, null);
});
test("明确家庭主体不能被模型伪装为 self scoreable", () => {
const rawText = "2021年6月家里老人病危。";
const result = validatedModelAssistedEvidence({
rawText,
sourceTurnId: randomUUID(),
asOfDate: "2026-07-29",
extraction: {
sourceSpan: "2021年6月家里老人病危",
summary: "家里老人病危",
domain: "health_pressure",
eventKind: "self_health_event",
subject: "self",
relatedPerson: null,
dateText: "2021年6月",
},
});
assert.equal(result, null);
});
test("合法家庭健康事件只作为 context_only", () => {
const rawText = "2020年4月老爸进了ICU。";
const result = validatedModelAssistedEvidence({
rawText,
sourceTurnId: randomUUID(),
asOfDate: "2026-07-29",
extraction: {
sourceSpan: "2020年4月老爸进了ICU",
summary: "老爸进了ICU",
domain: "family",
eventKind: "family_health_event",
subject: "family",
relatedPerson: "father",
dateText: "2020年4月",
},
});
assert.ok(result);
assert.equal(result.scoreability, "context_only");
assert.equal(result.scoreable, false);
});
@@ -0,0 +1,136 @@
import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import test from "node:test";
import type { QuestionOpportunity, ValidatedDecision } from "../src/lib/rectification-agent/contracts.ts";
import { realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts";
import type { CandidateSnapshot, LifeEventRevision, PendingEvidence } from "../src/lib/rectification-v4/contracts.ts";
const caseId = "00000000-0000-4000-8000-000000000701";
const now = "2026-07-29T00:00:00.000Z";
function event(): LifeEventRevision {
return {
id: randomUUID(),
eventId: randomUUID(),
revision: 1,
domain: "career",
eventKind: "career_change",
subject: "self",
relatedPerson: null,
summary: "2020年4月研究院实习",
rawText: "2020年4月去石油化工研究院实习做研究员。",
dateRange: { start: "2020-04-01", end: "2020-04-30", precision: "month", label: "2020年4月" },
scoreability: "scoreable",
supersedesRevisionId: null,
createdAt: now,
};
}
function opportunity(target: LifeEventRevision): QuestionOpportunity {
return {
contractVersion: "semantic-question-v2",
opportunityId: randomUUID(),
kind: "refine_event_date",
domain: "career",
targetEventId: target.eventId,
goal: "确认研究院实习发生的大概阶段。",
requestedFields: ["event_stage"],
anchors: [target.summary],
contextFacts: [],
forbiddenMoves: ["switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event", "invent_date", "expose_private_score", "expose_internal_id", "expose_technique_trace"],
fallbackPrompt: `关于“${target.summary}”,你更记得是开始、高峰还是结束阶段吗?`,
reason: "当前事件仍需区分发生阶段。",
expectedInformationGain: 0.7,
dateSensitivity: 0.7,
candidateSplitRelevance: 0.4,
domainCoverageGain: 0,
recallEase: 0.6,
novelty: 0.8,
repetitionPenalty: 0,
privacyCost: 0.05,
utility: 0.65,
active: true,
};
}
function validated(selectedOpportunity: QuestionOpportunity): ValidatedDecision {
return {
decision: { action: "ask_question", opportunityId: selectedOpportunity.opportunityId, narrativeFocus: ["latest_event"] },
mode: "agent",
validationIssues: [],
selectedOpportunity,
};
}
function snapshot(range: readonly [string, string]): CandidateSnapshot {
const [startTime, endTime] = range;
return {
id: randomUUID(),
caseId,
caseVersion: 3,
evidenceSetHash: "e".repeat(64),
calculationSpecHash: "c".repeat(64),
algorithmVersion: "rectification-v5-matrix-scoring-1",
candidates: [{ time: startTime, score: 10, supportingEventIds: [], conflictingEventIds: [] }],
clusters: [{ rank: 1, startTime, endTime, representativeTime: startTime, widthMinutes: 7, peakScore: 10, scoreMass: 1 }],
robustness: { neighborSupportMinutes: 8, leaveOneOutRetentionRate: 0.8, dateSensitivityRetentionRate: 0.8, calculationSpecHashMatched: true },
canConfirmExactMinute: false,
canAcceptRange: true,
gateReasons: [],
createdAt: now,
};
}
test("Renderer 对全部模型可见文本执行 exact-minute 和内部信息安全回落", () => {
const target = event();
const selectedOpportunity = opportunity(target);
const input = {
latestAnswer: target.rawText,
acceptedEvents: [target],
pendingEvidence: [] as PendingEvidence[],
snapshot: null,
previousSnapshot: null,
validated: validated(selectedOpportunity),
};
const message = realizePublicMessage({
acknowledgement: `你提到的是“${target.summary}”,所以准确出生分钟是05:13。`,
candidateUpdate: null,
limitation: "准确出生分钟是五点十三分,snapshotId 已确认。",
question: `关于${target.summary},唯一出生分钟是什么?`,
}, input);
assert.equal(message.acknowledgement, `你提到的是 ${target.dateRange.label} 的“${target.summary}”。`);
assert.equal(message.limitation, null);
assert.equal(message.question, selectedOpportunity.fallbackPrompt);
assert.doesNotMatch(JSON.stringify(message), /05:13|五点十三分|准确出生分钟|唯一出生分钟|snapshotId/);
for (const question of [
`关于${target.summary},你是不是五点十三分出生?`,
`关于${target.summary}eventId 是什么?`,
]) {
assert.equal(validateQuestionRealization(question, selectedOpportunity).valid, false, question);
}
});
test("Renderer 保留服务器生成的合法候选范围表达", () => {
const target = event();
const selectedOpportunity = opportunity(target);
const message = realizePublicMessage({
acknowledgement: `你提到的是“${target.summary}”。`,
candidateUpdate: "模型声称出生时间就是05:13。",
limitation: null,
question: `关于${target.summary},你更记得是开始、高峰还是结束阶段吗?`,
}, {
latestAnswer: target.rawText,
acceptedEvents: [target],
pendingEvidence: [],
snapshot: snapshot(["05:12", "05:18"]),
previousSnapshot: null,
validated: validated(selectedOpportunity),
});
assert.match(message.candidateUpdate ?? "", /候选范围.*05:12.*05:18/);
assert.match(message.candidateUpdate ?? "", /不代表其中某一分钟已被确认/);
assert.doesNotMatch(message.candidateUpdate ?? "", /就是05:13/);
});
+1 -1
View File
@@ -42,7 +42,7 @@ dev = [
"wheel>=0.40",
]
api = [
"mcp>=1.0",
"mcp>=1.0,<2",
]
[project.scripts]
+1 -1
View File
@@ -16,7 +16,7 @@ pandas>=1.3,<3
numpy>=1.20,<3
# 严格证据收集器通过 mcp_server 复用(API 运行时必需)
mcp>=1.0
mcp>=1.0,<2
# 以下为标准库,无需安装(仅供参考):
# argparse, json, sys, os, csv, math, sqlite3
+17
View File
@@ -0,0 +1,17 @@
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MCP_REQUIREMENT = "mcp>=1.0,<2"
def test_mcp_dependency_stays_on_compatible_major_version() -> None:
requirements = {
line.strip()
for line in (ROOT / "requirements.txt").read_text(encoding="utf-8").splitlines()
if line.strip() and not line.lstrip().startswith("#")
}
project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))
assert MCP_REQUIREMENT in requirements
assert MCP_REQUIREMENT in project["project"]["optional-dependencies"]["api"]