feat(rectification): drive questions from candidate contrast
This commit is contained in:
@@ -1849,3 +1849,20 @@
|
||||
- 防复发:拒答保护必须有 Orchestrator/Director 级回归;Pending resolution 必须验证缺口已被对应 Revision 补齐;评分政策变化必须同时处理历史 Snapshot;公开技术层过滤按完整技术命名空间测试。
|
||||
- 相关记录:BUG-099、BUG-102、BUG-103
|
||||
- 修复版本:`birth-time-rectification-v6` / `rectification-director-v2`
|
||||
|
||||
## BUG-105 | 候选差异未驱动下一问、Event Kind 未进入评分且不可回答 Case 被错误复用
|
||||
|
||||
- 状态:resolved
|
||||
- 首次发现:2026-07-31
|
||||
- 最近更新:2026-07-31
|
||||
- 影响面:V6 Agent 问题排序、V5 Python 贡献矩阵、V4 Case 创建/复用、Staging 新建校正后的首次回答
|
||||
- 用户现象:诊断已经显示候选簇在特定技术层存在差异时,访谈仍可能继续做通用领域轮询;关系确立与关系变化在评分中缺少语义差异;新建校正后首次提交回答可能返回“当前没有待回答的问题,请刷新后重试。”;已知主体的非评分事件还可能被错误追问“发生在谁身上”。
|
||||
- 触发条件:候选分歧只携带技术层而没有可行动的缺失证据;评分仅按 Domain 汇总;创建 Case 时复用 `paused`、没有 Active Job 的 `processing`、或没有 `currentQuestion` 的 `awaiting_answer` Case;`pending_review` 被等同于主体不明确。
|
||||
- 根因:Director Dossier 缺少 Candidate Contrast Packet,问题排序对同领域历史提问施加通用重复惩罚;共享评分入口没有按 Event Kind 和真实命中 Rule ID 调整贡献;Service、Memory Store 与 Supabase RPC 的可恢复 Case 条件不一致且没有算法版本隔离;主体澄清条件错误地依赖整个 Scoreability 状态。
|
||||
- 修复:共享诊断层按主候选与次候选的静态特征差异计算区分层、按事件贡献差值计算相关事件,再生成不暴露候选分钟的 Candidate Contrast Packet,用 Cluster Rank、区分层、相关事件和缺失 Event Kind 驱动问题机会;候选驱动的不同 Event Kind 不受通用同领域重复惩罚,并优先于无诊断依据的领域轮询,非评分事件不作为 Candidate Split Target;共享 Python 贡献矩阵按真实 Rule ID 对 `relationship_start` 与 `relationship_change` 应用不同 Profile,零 Activation 不凭空加分,`relationship_end` 继续 Fail Closed;算法版本升级到 `rectification-v5-matrix-scoring-2`;三层 Case 复用统一为仅恢复可回答 Case 或拥有 Active Job 的 Processing Case,且必须匹配算法版本;主体澄清仅针对 `subject=other`。
|
||||
- 数据库:新增向前迁移,更新新 Case 默认算法版本、保留 range-scoring v1 与 matrix-scoring v1 历史 Candidate Snapshot 解析兼容、废弃未完成的旧算法 Case、标记其活跃 Job 为 Stale,并重建带可恢复状态和算法版本检查的 Case 创建 RPC;不写入 Profile 出生时间。
|
||||
- 验证:真实用户回放覆盖复读、大学入学并离家、开始工作、分手和负债,断言关系结束保持 `pending_review`、不会把大学入学换词重问为迁居、D9 内部差异转为关系确立/状态变化的自然存在性问题、公开问题不泄露技术层或候选分钟,并允许“没有、不知道、不想回答、换方向”;Service 回归覆盖 Paused、孤儿 Processing、空问题 Awaiting Answer、新 Case 首次回答与旧算法 Case 替换;Python 回归覆盖关系 Event Kind 反转候选排序及零 Activation。
|
||||
- 安全边界:Candidate Contrast 只在服务器内部使用,不向用户公开技术层、分数、代表分钟或第二候选簇;Agent 不能把 `relationship_end` 变为可评分事件,不能确认精确出生分钟,也不能自动写入 Profile。
|
||||
- 防复发:完整回放测试必须同时覆盖事件账本、问题排序、退出方式和公开文本;评分版本变化必须同步 Case 复用、数据库默认值和历史快照兼容;Case 创建测试必须随后真实调用一次 Answer。
|
||||
- 相关记录:BUG-101、BUG-102、BUG-104
|
||||
- 修复版本:local follow-up / `rectification-v5-matrix-scoring-2`
|
||||
|
||||
@@ -90,6 +90,19 @@ export const rectificationTurnPlanSchema = z.object({
|
||||
}).strict();
|
||||
export type RectificationTurnPlan = z.infer<typeof rectificationTurnPlanSchema>;
|
||||
|
||||
export const candidateContrastPacketSchema = z.object({
|
||||
primaryClusterRank: z.number().int().positive().nullable(),
|
||||
secondaryClusterRank: z.number().int().positive().nullable(),
|
||||
discriminatingLayers: z.array(nonblank(80)).max(40),
|
||||
relevantEventIds: z.array(uuid).max(100),
|
||||
missingEvidence: z.array(z.object({
|
||||
domain: evidenceDomainSchema,
|
||||
eventKind: eventKindSchema,
|
||||
reason: z.literal("highest_candidate_separation"),
|
||||
}).strict()).max(8),
|
||||
}).strict();
|
||||
export type CandidateContrastPacket = z.infer<typeof candidateContrastPacketSchema>;
|
||||
|
||||
export const rectificationCaseDossierSchema = z.object({
|
||||
case: z.object({
|
||||
candidateWindow: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(),
|
||||
@@ -140,6 +153,7 @@ export const rectificationCaseDossierSchema = z.object({
|
||||
rangeChanged: z.boolean(),
|
||||
topClusters: z.array(z.object({ rank: z.number().int(), widthMinutes: z.number().int(), stability: z.enum(["stable", "unstable"]) }).strict()).max(4),
|
||||
contrasts: z.array(z.object({ techniqueLayers: z.array(z.string()), relevantEventIds: z.array(uuid) }).strict()).max(8),
|
||||
contrastIntelligence: candidateContrastPacketSchema.nullable(),
|
||||
eventDiagnostics: z.array(z.object({ eventId: uuid, winnerRetentionRate: z.number(), scoreVariance: z.number() }).strict()).max(100),
|
||||
gateReasons: z.array(z.string()).max(20),
|
||||
currentSnapshotId: uuid.nullable(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import type { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, PendingEvidence, RectificationV4Case, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import { hasPolicyInvalidScoreableEvents } from "../rectification-v4/evidence-ledger.ts";
|
||||
import { buildCandidateContrastPacket } from "./opportunity-builder.ts";
|
||||
import { rectificationCaseDossierSchema, rectificationTurnPlanSchema, type DiagnosticsSummary, type RectificationCaseDossier, type RectificationDiagnostic, type RectificationTurnPlan, type ToolCallTrace } from "./contracts.ts";
|
||||
|
||||
const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification");
|
||||
@@ -55,7 +56,7 @@ export function buildRectificationCaseDossier(input: Readonly<{ caseValue: Recti
|
||||
conversation: { recentRawTurns: recent.map(({ question, answer }) => ({ question, answer })), earlierConversationSummary: summarizeEarlierTurns(input.turns) },
|
||||
eventLedger: input.events.map((event) => ({ eventId: event.eventId, revision: event.revision, summary: event.summary, rawText: event.rawText, domain: event.domain, eventKind: event.eventKind, subject: event.subject, relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, status: latest.get(event.eventId) === event.revision ? "active" : "superseded" })),
|
||||
interviewState: { currentTargetEventId: input.currentTargetEventId, declinedDomains: [...new Set(input.turns.flatMap((turn) => turn.questionDomain && declinedPattern.test(turn.answer) ? [turn.questionDomain] : []))], unresolvedTargets: [...new Set([...(input.currentTargetEventId && ["unresolved", "answered_other_event"].includes(input.targetDisposition) ? [input.currentTargetEventId] : []), ...(input.pendingEvidence ?? []).flatMap((item) => item.targetEventId ? [item.targetEventId] : [])])], pendingEvidence: (input.pendingEvidence ?? []).filter((item) => !item.resolvedAt).map(({ rawText, reasonCode, targetEventId, createdAt }) => ({ rawText, reasonCode, targetEventId, createdAt })), askedTopics: input.turns.slice(-50).map((turn) => turn.question), turnCount: input.turns.length, targetDisposition: input.targetDisposition },
|
||||
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
|
||||
candidateState: { hasSnapshot: Boolean(input.snapshot), publicRangeAllowed, rangeChanged: input.previousSnapshot?.clusters[0]?.startTime !== input.snapshot?.clusters[0]?.startTime || input.previousSnapshot?.clusters[0]?.endTime !== input.snapshot?.clusters[0]?.endTime, topClusters: (input.snapshot?.clusters ?? []).slice(0, 4).map((cluster) => ({ rank: cluster.rank, widthMinutes: cluster.widthMinutes, stability: publicRangeAllowed ? "stable" : "unstable" })), contrasts: (input.diagnostics?.candidateSplits ?? []).map((split) => ({ techniqueLayers: split.techniqueLayers, relevantEventIds: split.eventIds })), contrastIntelligence: buildCandidateContrastPacket({ events: input.events, snapshot: input.snapshot, diagnostics: input.diagnostics }), eventDiagnostics: (input.diagnostics?.eventDateSensitivity ?? []).map((item) => ({ eventId: item.eventId, winnerRetentionRate: item.winnerRetentionRate, scoreVariance: item.scoreVariance })), gateReasons: input.snapshot?.gateReasons ?? [], currentSnapshotId: input.snapshot?.id ?? null },
|
||||
capabilities: { supportedDomains: domains, supportedEventKinds: kinds, maxQuestionsPerTurn: 1, maxDiagnosticsPerRun: 2, forbiddenPublicClaims: ["exact_birth_minute", "private_scores", "internal_ids", "technique_trace"] },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
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 { CandidateSnapshot, EvidenceDomain, EventKind, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts";
|
||||
import { domainScorerRegistry } from "../rectification-v4/domain-scorers.ts";
|
||||
import { chronologicalEvents, latestEventRevisions } from "../rectification-v4/evidence-ledger.ts";
|
||||
import type { TargetDisposition } from "../rectification-v4/extraction.ts";
|
||||
import type { DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
|
||||
import type { CandidateContrastPacket, DiagnosticsSummary, QuestionOpportunity, SemanticQuestionOpportunity } from "./contracts.ts";
|
||||
|
||||
const forbiddenMoves: SemanticQuestionOpportunity["forbiddenMoves"] = [
|
||||
"switch_target_event", "ask_multiple_questions", "claim_exact_birth_minute", "invent_event",
|
||||
@@ -86,21 +87,21 @@ const routingValue: Record<QuestionOpportunity["kind"], number> = {
|
||||
|
||||
type OpportunityInput = Omit<SemanticQuestionOpportunity, "contractVersion" | "opportunityId" | "utility" | "active" | "forbiddenMoves">;
|
||||
|
||||
function utility(value: OpportunityInput): number {
|
||||
function utility(value: OpportunityInput, contrastPriority = 0): number {
|
||||
return Number((
|
||||
.35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance
|
||||
+ .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty
|
||||
+ routingValue[value.kind] - value.repetitionPenalty - value.privacyCost
|
||||
+ routingValue[value.kind] + contrastPriority - value.repetitionPenalty - value.privacyCost
|
||||
).toFixed(6));
|
||||
}
|
||||
|
||||
function opportunity(caseId: string, input: OpportunityInput): QuestionOpportunity {
|
||||
function opportunity(caseId: string, input: OpportunityInput, contrastPriority = 0): QuestionOpportunity {
|
||||
return {
|
||||
contractVersion: "semantic-question-v2",
|
||||
...input,
|
||||
forbiddenMoves,
|
||||
opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.goal}:${input.fallbackPrompt}`),
|
||||
utility: utility(input),
|
||||
utility: utility(input, contrastPriority),
|
||||
active: true,
|
||||
};
|
||||
}
|
||||
@@ -121,6 +122,47 @@ function declinedSensitiveDomains(turns: readonly RectificationV4Turn[]): Readon
|
||||
return result;
|
||||
}
|
||||
|
||||
const genericTechniqueLayers = new Set(["vimshottari", "narayana"]);
|
||||
|
||||
export function buildCandidateContrastPacket(input: Readonly<{
|
||||
events: readonly LifeEventRevision[];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
diagnostics: DiagnosticsSummary | null;
|
||||
}>): CandidateContrastPacket | null {
|
||||
const split = input.diagnostics?.candidateSplits[0];
|
||||
if (!split) return null;
|
||||
const discriminatingLayers = split.techniqueLayers.filter((layer) => !genericTechniqueLayers.has(layer.toLowerCase()));
|
||||
const existingKinds = new Set(latestEventRevisions(input.events)
|
||||
.filter((event) => event.scoreability === "scoreable")
|
||||
.map((event) => event.eventKind));
|
||||
const missingEvidence = (Object.entries(domainScorerRegistry) as [EvidenceDomain, (typeof domainScorerRegistry)[EvidenceDomain]][])
|
||||
.flatMap(([domain, policy]) => {
|
||||
if (!policy.techniqueLayers.some((layer) => discriminatingLayers.includes(layer))) return [];
|
||||
const eventKind = policy.supportedKinds.find((kind) => !existingKinds.has(kind));
|
||||
return eventKind ? [{ domain, eventKind, reason: "highest_candidate_separation" as const }] : [];
|
||||
});
|
||||
return {
|
||||
primaryClusterRank: input.snapshot?.clusters[0]?.rank ?? null,
|
||||
secondaryClusterRank: input.snapshot?.clusters[1]?.rank ?? null,
|
||||
discriminatingLayers,
|
||||
relevantEventIds: [...split.eventIds],
|
||||
missingEvidence,
|
||||
};
|
||||
}
|
||||
|
||||
function contrastQuestion(eventKind: EventKind, anchor: string | null): Readonly<{ goal: string; fallbackPrompt: string }> | null {
|
||||
const prefix = anchor ? `在“${anchor}”之外,` : "";
|
||||
if (eventKind === "relationship_start") return {
|
||||
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系正式确立或开始共同生活的经历及其大致年月,不预设一定发生。`,
|
||||
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系正式确立或开始共同生活的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
|
||||
};
|
||||
if (eventKind === "relationship_change") return {
|
||||
goal: `${prefix}在用户愿意的前提下,询问是否有一段关系状态明显改变的经历及其大致年月,不预设一定发生。`,
|
||||
fallbackPrompt: `${prefix}如果你愿意,有没有一段关系状态明显改变的经历;如果有,大概是哪年哪月,没有、不知道或不想回答也可以换方向?`,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
export function buildQuestionOpportunities(input: Readonly<{
|
||||
caseId: string;
|
||||
events: readonly LifeEventRevision[];
|
||||
@@ -140,6 +182,7 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
const latestEvent = chronologicalEvents(input.events).at(-1);
|
||||
const latestContext = input.turns.at(-1)?.answer ?? latestEvent?.rawText ?? "";
|
||||
const opportunities: QuestionOpportunity[] = [];
|
||||
const contrastPacket = buildCandidateContrastPacket(input);
|
||||
|
||||
if (input.targetDisposition === "answered_other_event") {
|
||||
for (const eventId of retryTargets) {
|
||||
@@ -165,7 +208,7 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
if (targetClosed && retryTargets.has(event.eventId)) continue;
|
||||
const attemptCount = targetAttempts.get(event.eventId) ?? 0;
|
||||
const anchor = anchorFor(event);
|
||||
if ((event.scoreability === "pending_review" || event.subject === "other") && attemptCount === 0) {
|
||||
if (event.subject === "other" && attemptCount === 0) {
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId,
|
||||
goal: `确认“${anchor}”发生在本人、家人还是伴侣。`, requestedFields: ["event_subject"],
|
||||
@@ -205,16 +248,17 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
|
||||
const split = input.diagnostics?.candidateSplits[0];
|
||||
if (split) {
|
||||
const target = input.events.find((event) => split.eventIds.includes(event.eventId)
|
||||
const target = input.events.find((event) => event.scoreability === "scoreable"
|
||||
&& split.eventIds.includes(event.eventId)
|
||||
&& (targetAttempts.get(event.eventId) ?? 0) === 0
|
||||
&& !(targetClosed && retryTargets.has(event.eventId)));
|
||||
const anchor = target ? anchorFor(target) : null;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
if (target) opportunities.push(opportunity(input.caseId, {
|
||||
kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null,
|
||||
goal: target ? `确认“${anchor}”更接近开始、高峰还是正式结束。` : "确认一件现有事件的发生阶段。",
|
||||
requestedFields: ["event_stage"], anchors: anchor ? [anchor] : [],
|
||||
goal: `确认“${anchor}”更接近开始、高峰还是正式结束。`,
|
||||
requestedFields: ["event_stage"], anchors: [anchor!],
|
||||
contextFacts: [`候选分歧涉及 ${split.techniqueLayers.length} 个已计算技术层。`],
|
||||
fallbackPrompt: target ? `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?` : "那件经历更接近开始、达到高峰,还是正式结束?",
|
||||
fallbackPrompt: `“${anchor}”当时更接近事情开始、达到高峰,还是正式结束?`,
|
||||
reason: "候选簇在现有诊断中出现可检验分歧。",
|
||||
expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0,
|
||||
recallEase: .65, novelty: .9, repetitionPenalty: 0, privacyCost: .1,
|
||||
@@ -231,8 +275,10 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
const pendingThemeBonus = !latestEvent && policy.signals.test(latestContext) ? .12 : 0;
|
||||
const alreadyAsked = input.turns.some((turn) => turn.questionDomain === domain && !turn.questionTargetEventId);
|
||||
const latestAnchor = latestEvent ? anchorFor(latestEvent) : null;
|
||||
const contrastEvidence = contrastPacket?.missingEvidence.find((item) => item.domain === domain) ?? null;
|
||||
const targetedQuestion = contrastEvidence ? contrastQuestion(contrastEvidence.eventKind, latestAnchor) : null;
|
||||
opportunities.push(opportunity(input.caseId, {
|
||||
kind: "ask_new_event", domain, targetEventId: null, goal: policy.goal(latestAnchor),
|
||||
kind: "ask_new_event", domain, targetEventId: null, goal: targetedQuestion?.goal ?? policy.goal(latestAnchor),
|
||||
requestedFields: ["new_dated_event"], anchors: latestAnchor ? [latestAnchor] : [],
|
||||
contextFacts: [
|
||||
`已有 ${scoreableCount} 件可评分事件。`,
|
||||
@@ -242,16 +288,17 @@ export function buildQuestionOpportunities(input: Readonly<{
|
||||
"只询问一件带大致年月的新事件,不要求用户逐项回答例子。",
|
||||
"允许用户回答没有、记不清、不想回答或换方向。",
|
||||
"不得发明年龄或日期窗口,只能引用 anchors 中已确认的经历。",
|
||||
...(contrastEvidence ? ["该类证据对当前候选区分力最高,应优先确认是否存在。"] : []),
|
||||
...(semanticOverlap ? ["该领域与最新事件语义重叠,必须降低优先级,避免把同一经历换词重问。"] : []),
|
||||
],
|
||||
fallbackPrompt: policy.fallbackPrompt(latestAnchor), reason: covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
|
||||
expectedInformationGain: covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
|
||||
fallbackPrompt: targetedQuestion?.fallbackPrompt ?? policy.fallbackPrompt(latestAnchor), reason: contrastEvidence ? "补足当前候选分离所需的关键证据。" : covered ? "继续收集可区分候选的独立事件。" : "补足证据领域覆盖。",
|
||||
expectedInformationGain: contrastEvidence ? .95 : covered ? .54 + latestDomainContinuity + pendingThemeBonus : .65 + pendingThemeBonus,
|
||||
dateSensitivity: input.snapshot ? .5 : .35,
|
||||
candidateSplitRelevance: input.diagnostics?.candidateSplits.length ? .58 : .42,
|
||||
domainCoverageGain: covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
|
||||
recallEase: policy.recallEase, novelty: semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
|
||||
repetitionPenalty: (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
|
||||
}));
|
||||
candidateSplitRelevance: contrastEvidence ? .98 : input.diagnostics?.candidateSplits.length ? .58 : .42,
|
||||
domainCoverageGain: contrastEvidence ? 1 : covered ? 0 : scoreableDomains.size < 2 ? 1 : .15,
|
||||
recallEase: policy.recallEase, novelty: contrastEvidence ? .95 : semanticOverlap ? .45 : alreadyAsked ? .35 : .9,
|
||||
repetitionPenalty: contrastEvidence ? 0 : (alreadyAsked ? .3 : 0) + (semanticOverlap ? .2 : 0), privacyCost: policy.privacyCost,
|
||||
}, contrastEvidence ? .08 : 0));
|
||||
}
|
||||
|
||||
return opportunities
|
||||
|
||||
@@ -27,7 +27,7 @@ const newEventDomainTerms: Readonly<Partial<Record<QuestionOpportunity["domain"]
|
||||
};
|
||||
const questionRealizationSchema = z.object({ question: z.string().trim().min(1).max(1_000) }).strict();
|
||||
const openingMessageSchema = z.object({ message: z.string().trim().min(1).max(1_000) }).strict();
|
||||
const domainChecklistTerms = /(?:学业|教育|搬家|迁居|感情|婚姻|工作|职业|财务|健康)/g;
|
||||
const fixedChoiceStructure = /(?:从|在)[^。!??\n]{1,80}(?:、|,|,|或|或者)[^。!??\n]{1,80}(?:(?:中|里|方面)(?:选|选择|挑|说|讲|开始)|(?:选|选择|挑)(?:一|1)?(?:个|件|段))|按[^。!??\n]{1,80}(?:依次|逐一|分别)(?:回答|说|讲)/;
|
||||
|
||||
export type OpeningQuestionGenerator = (prompt: string, phase: "generate" | "repair") => Promise<Readonly<{ object: unknown }>>;
|
||||
|
||||
@@ -60,7 +60,7 @@ function validateOpeningMessage(value: unknown, range: Readonly<{ start: string;
|
||||
if (internalTerms.test(message)) issues.push("private_detail_exposed");
|
||||
const positiveClaims = message.split(/[。;;!??!]/).filter((sentence) => !/(?:不是|并非|尚未|还未|不能)/.test(sentence)).join(" ");
|
||||
if (exactMinuteClaim.test(positiveClaims)) issues.push("exact_minute_claimed");
|
||||
if ((message.match(domainChecklistTerms) ?? []).length >= 3) issues.push("fixed_domain_checklist");
|
||||
if (fixedChoiceStructure.test(message)) issues.push("fixed_domain_checklist");
|
||||
return { message: issues.length ? null : message, issues };
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import { generateOpeningQuestion, regenerateQuestionRealization } from "../recti
|
||||
import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts";
|
||||
import { hasPolicyInvalidScoreableEvents } from "./evidence-ledger.ts";
|
||||
import { openingQuestion } from "./opening-question.ts";
|
||||
import type { RectificationV4Store } from "./store.ts";
|
||||
import { canResumeRectificationCase, type RectificationV4Store } from "./store.ts";
|
||||
|
||||
const regenerationInFlight = new Map<string, Promise<RectificationV4Case | null>>();
|
||||
|
||||
@@ -58,7 +58,10 @@ export function createRectificationV4CaseService(
|
||||
|
||||
const specHash = calculationSpecHash(input.calculationSpec);
|
||||
const active = await store.findActiveCase(input.userId);
|
||||
if (active?.calculationSpecHash === specHash) {
|
||||
const activeJob = active?.status === "processing" ? await store.loadActiveJob(input.userId, active.id) : null;
|
||||
if (active?.calculationSpecHash === specHash
|
||||
&& active.algorithmVersion === rectificationV4AlgorithmVersion
|
||||
&& canResumeRectificationCase(active, Boolean(activeJob))) {
|
||||
return response(input.userId, await store.createCase({ case: active, actionId: input.actionId }));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,11 @@ export const rectificationV4Protocol = "rectification-evidence-v4" as const;
|
||||
export const rectificationAgentV5Protocol = "rectification-evidence-v5" as const;
|
||||
export const rectificationDeploymentModeSchema = z.enum(["v4_legacy", "v5_shadow", "v5_agent"]);
|
||||
export type RectificationDeploymentMode = z.infer<typeof rectificationDeploymentModeSchema>;
|
||||
export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-1" as const;
|
||||
export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-2" as const;
|
||||
const rectificationLegacyAlgorithmVersions = [
|
||||
"rectification-v4-range-scoring-1",
|
||||
"rectification-v5-matrix-scoring-1",
|
||||
] as const;
|
||||
|
||||
export const rectificationV4CaseStatusSchema = z.enum([
|
||||
"awaiting_answer",
|
||||
@@ -182,7 +186,7 @@ const candidateSnapshotBaseSchema = z.object({
|
||||
caseVersion: z.number().int().nonnegative(),
|
||||
evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
calculationSpecHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
algorithmVersion: z.literal(rectificationV4AlgorithmVersion),
|
||||
algorithmVersion: z.enum([...rectificationLegacyAlgorithmVersions, rectificationV4AlgorithmVersion]),
|
||||
candidates: z.array(candidateMinuteSchema).min(1).max(1_440),
|
||||
clusters: z.array(candidateClusterSchema).max(20),
|
||||
robustness: robustnessSchema,
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
RectificationV4Store,
|
||||
RectificationV4Turn,
|
||||
} from "./store.ts";
|
||||
import { RectificationV4StoreError } from "./store.ts";
|
||||
import { canResumeRectificationCase, RectificationV4StoreError } from "./store.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
|
||||
export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
@@ -93,7 +93,10 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
if (replay) return owned(input.case.userId, replay.caseId);
|
||||
const active = [...cases.values()].find((value) => value.userId === input.case.userId
|
||||
&& value.status !== "abandoned" && value.acceptedRange === null);
|
||||
if (active?.calculationSpecHash === input.case.calculationSpecHash) {
|
||||
const hasActiveJob = active ? [...jobs.values()].some((job) => job.caseId === active.id && ["pending", "processing"].includes(job.status)) : false;
|
||||
if (active?.calculationSpecHash === input.case.calculationSpecHash
|
||||
&& active.algorithmVersion === input.case.algorithmVersion
|
||||
&& canResumeRectificationCase(active, hasActiveJob)) {
|
||||
actionResults.set(`${input.case.userId}:${input.actionId}`, { caseId: active.id, jobId: null });
|
||||
return active;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
} from "./contracts.ts";
|
||||
export type { RectificationV4Turn } from "./contracts.ts";
|
||||
|
||||
export function canResumeRectificationCase(caseValue: RectificationV4Case, hasActiveJob: boolean): boolean {
|
||||
return (caseValue.status === "awaiting_answer" && caseValue.currentQuestion !== null)
|
||||
|| (caseValue.status === "processing" && hasActiveJob);
|
||||
}
|
||||
|
||||
export type ClaimedRectificationV4Job = Readonly<{
|
||||
job: RectificationV4Job;
|
||||
case: RectificationV4Case;
|
||||
|
||||
@@ -97,7 +97,7 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific
|
||||
narrationModelId: row.narration_model_id ? String(row.narration_model_id) : null,
|
||||
skillVersion: row.skill_version ? String(row.skill_version) : "birth-time-rectification-v5",
|
||||
promptVersion: row.prompt_version ? String(row.prompt_version) : "rectification-agent-v5-1",
|
||||
algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-1",
|
||||
algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-2",
|
||||
deploymentMode: row.deployment_mode === "v5_agent" || row.deployment_mode === "v5_shadow" ? row.deployment_mode : "v4_legacy",
|
||||
agentMode: row.agent_mode === "agent" ? "agent" : "deterministic_fallback",
|
||||
featureSnapshotId: row.feature_snapshot_id ? String(row.feature_snapshot_id) : null,
|
||||
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
-- A Case is resumable only when the client can answer it or a worker still owns active work.
|
||||
-- Scoring v2 changes Event Kind semantics, so unfinished v1 Cases must not be resumed under v2.
|
||||
|
||||
alter table public.birth_time_rectification_v4_cases
|
||||
alter column algorithm_version set default 'rectification-v5-matrix-scoring-2';
|
||||
|
||||
alter table public.birth_time_rectification_v4_candidate_snapshots
|
||||
alter column algorithm_version set default 'rectification-v5-matrix-scoring-2';
|
||||
|
||||
do $$
|
||||
declare value record;
|
||||
begin
|
||||
for value in
|
||||
select constraint_value.conname
|
||||
from pg_catalog.pg_constraint constraint_value
|
||||
where constraint_value.conrelid = 'public.birth_time_rectification_v4_candidate_snapshots'::regclass
|
||||
and constraint_value.contype = 'c'
|
||||
and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%algorithm_version%'
|
||||
loop
|
||||
execute pg_catalog.format(
|
||||
'alter table public.birth_time_rectification_v4_candidate_snapshots drop constraint %I',
|
||||
value.conname
|
||||
);
|
||||
end loop;
|
||||
end $$;
|
||||
|
||||
alter table public.birth_time_rectification_v4_candidate_snapshots
|
||||
add constraint birth_time_rectification_scoring_v2_candidate_snapshots_algorithm_check
|
||||
check (algorithm_version in (
|
||||
'rectification-v4-range-scoring-1',
|
||||
'rectification-v5-matrix-scoring-1',
|
||||
'rectification-v5-matrix-scoring-2'
|
||||
));
|
||||
|
||||
update public.birth_time_rectification_v4_jobs job
|
||||
set status = 'stale', lease_expires_at = null, updated_at = pg_catalog.now()
|
||||
from public.birth_time_rectification_v4_cases case_value
|
||||
where job.case_id = case_value.id
|
||||
and job.status in ('pending', 'processing')
|
||||
and case_value.algorithm_version <> 'rectification-v5-matrix-scoring-2'
|
||||
and case_value.accepted_range_start is null
|
||||
and case_value.status <> 'abandoned';
|
||||
|
||||
update public.birth_time_rectification_v4_cases
|
||||
set status = 'abandoned', phase = 'complete', current_question = null, updated_at = pg_catalog.now()
|
||||
where algorithm_version <> 'rectification-v5-matrix-scoring-2'
|
||||
and accepted_range_start is null
|
||||
and status <> 'abandoned';
|
||||
|
||||
create or replace function public.create_birth_time_rectification_v5_case(
|
||||
p_user_id uuid,
|
||||
p_case_id uuid,
|
||||
p_action_id uuid,
|
||||
p_status text,
|
||||
p_phase text,
|
||||
p_calculation_spec jsonb,
|
||||
p_calculation_spec_hash text,
|
||||
p_evidence_set_hash text,
|
||||
p_current_question jsonb,
|
||||
p_orchestration_model_id text,
|
||||
p_narration_model_id text,
|
||||
p_skill_version text,
|
||||
p_prompt_version text,
|
||||
p_algorithm_version text,
|
||||
p_deployment_mode text,
|
||||
p_now timestamptz
|
||||
) returns uuid
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
declare
|
||||
v_case public.birth_time_rectification_v4_cases%rowtype;
|
||||
v_case_id uuid;
|
||||
v_protocol text;
|
||||
begin
|
||||
if p_deployment_mode not in ('v4_legacy', 'v5_shadow', 'v5_agent') then
|
||||
raise exception 'invalid_rectification_v5_deployment_mode';
|
||||
end if;
|
||||
if p_algorithm_version <> 'rectification-v5-matrix-scoring-2' then
|
||||
raise exception 'invalid_rectification_v5_algorithm_version';
|
||||
end if;
|
||||
v_protocol := case when p_deployment_mode = 'v4_legacy'
|
||||
then 'rectification-evidence-v4' else 'rectification-evidence-v5' end;
|
||||
|
||||
select action.case_id into v_case_id
|
||||
from public.birth_time_rectification_v4_actions action
|
||||
where action.user_id = p_user_id and action.action_id = p_action_id;
|
||||
if v_case_id is not null then return v_case_id; end if;
|
||||
|
||||
perform pg_catalog.pg_advisory_xact_lock(
|
||||
pg_catalog.hashtextextended(p_user_id::text || ':rectification-v5-case', 0)
|
||||
);
|
||||
select value.* into v_case
|
||||
from public.birth_time_rectification_v4_cases value
|
||||
where value.user_id = p_user_id
|
||||
and value.status <> 'abandoned'
|
||||
and value.accepted_range_start is null
|
||||
order by value.created_at desc
|
||||
limit 1
|
||||
for update;
|
||||
|
||||
if found
|
||||
and v_case.calculation_spec_hash = p_calculation_spec_hash
|
||||
and v_case.algorithm_version = p_algorithm_version
|
||||
and (
|
||||
(v_case.status = 'awaiting_answer' and v_case.current_question is not null)
|
||||
or (
|
||||
v_case.status = 'processing'
|
||||
and exists (
|
||||
select 1
|
||||
from public.birth_time_rectification_v4_jobs job
|
||||
where job.case_id = v_case.id
|
||||
and job.status in ('pending', 'processing')
|
||||
)
|
||||
)
|
||||
) then
|
||||
insert into public.birth_time_rectification_v4_actions(
|
||||
user_id, action_id, case_id, created_at
|
||||
) values (
|
||||
p_user_id, p_action_id, v_case.id, p_now
|
||||
);
|
||||
return v_case.id;
|
||||
end if;
|
||||
|
||||
if found then
|
||||
update public.birth_time_rectification_v4_cases
|
||||
set status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now
|
||||
where id = v_case.id;
|
||||
update public.birth_time_rectification_v4_jobs
|
||||
set status = 'stale', lease_expires_at = null, updated_at = p_now
|
||||
where case_id = v_case.id and status in ('pending', 'processing');
|
||||
end if;
|
||||
|
||||
insert into public.birth_time_rectification_v4_cases (
|
||||
id, user_id, protocol, status, phase, calculation_spec, calculation_spec_hash,
|
||||
evidence_set_hash, current_question, orchestration_model_id, narration_model_id,
|
||||
skill_version, prompt_version, algorithm_version, deployment_mode, agent_mode,
|
||||
created_at, updated_at
|
||||
) values (
|
||||
p_case_id, p_user_id, v_protocol, p_status, p_phase, p_calculation_spec,
|
||||
p_calculation_spec_hash, p_evidence_set_hash, p_current_question,
|
||||
nullif(btrim(p_orchestration_model_id), ''), nullif(btrim(p_narration_model_id), ''),
|
||||
p_skill_version, p_prompt_version, p_algorithm_version, p_deployment_mode,
|
||||
'deterministic_fallback', p_now, p_now
|
||||
);
|
||||
insert into public.birth_time_rectification_v4_actions(
|
||||
user_id, action_id, case_id, created_at
|
||||
) values (
|
||||
p_user_id, p_action_id, p_case_id, p_now
|
||||
);
|
||||
return p_case_id;
|
||||
end;
|
||||
$$;
|
||||
@@ -50,8 +50,8 @@ test("opening Agent generates and validates the first rectification message", as
|
||||
generate: async (_prompt, phase) => {
|
||||
phases.push(phase);
|
||||
return phase === "generate"
|
||||
? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请按学业、搬家、感情、工作依次回答。" } }
|
||||
: { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。你愿意先从一段自己记得清楚的经历开始说吗?" } };
|
||||
? { object: { message: "目前核对的是 00:00–23:59 候选范围,尚未确认出生分钟。请从学习、工作或感情中选一段经历来说。" } }
|
||||
: { object: { message: "目前核对的是 00:00–23:59 候选范围,并不是已确认的出生分钟。请讲一段你最清楚的经历,也可以连续讲几件相关的事;记不清或想换方向都可以。" } };
|
||||
},
|
||||
});
|
||||
assert.deepEqual(phases, ["generate", "repair"]);
|
||||
@@ -475,7 +475,7 @@ test("V6 agent conversation follows dated events, respects direction change, and
|
||||
assert.ok(store.diagnostics.size > 0);
|
||||
assert.equal(fourth.case.latestSnapshot?.robustness.leaveOneDomainOutRetentionRate, 1);
|
||||
assert.equal(fourth.case.latestSnapshot?.canConfirmExactMinute, false);
|
||||
assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-1");
|
||||
assert.equal(fourth.case.algorithmVersion, "rectification-v5-matrix-scoring-2");
|
||||
const finalMessage = [...store.publicMessages.values()].at(-1);
|
||||
assert.doesNotMatch(`${finalMessage?.candidateUpdate ?? ""}${finalMessage?.limitation ?? ""}`, /唯一分钟|准确分钟|代表分钟|05:13/);
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
type QuestionOpportunity,
|
||||
type ValidatedDecision,
|
||||
} from "../src/lib/rectification-agent/contracts.ts";
|
||||
import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts";
|
||||
import { buildCandidateContrastPacket, buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts";
|
||||
import { buildReasonerState } from "../src/lib/rectification-agent/reasoner-agent.ts";
|
||||
import { candidateUpdateFor, realizePublicMessage, validateQuestionRealization } from "../src/lib/rectification-agent/renderer-agent.ts";
|
||||
import { extractLifeEventEvidence, validatedModelAssistedEvidence } from "../src/lib/conversational-rectification/evidence-extractor.ts";
|
||||
@@ -464,6 +464,139 @@ test("新事件机会提供具体回忆线索和退出方式,不把离家上
|
||||
assert.equal(validateQuestionRealization(career.fallbackPrompt, career).valid, true);
|
||||
});
|
||||
|
||||
test("D9 候选差异优先请求缺失的关系事件语义而不是继续领域轮询", () => {
|
||||
const events = [
|
||||
event({ summary: "2015年复读", rawText: "2015年复读" }),
|
||||
event({ eventId: randomUUID(), domain: "career", eventKind: "career_change", summary: "2020年开始工作", rawText: "2020年开始工作" }),
|
||||
event({ eventId: randomUUID(), domain: "finance", eventKind: "finance_change", summary: "2026年开始负债", rawText: "2026年开始负债" }),
|
||||
];
|
||||
const splitDiagnostics = diagnostics({
|
||||
candidateSplits: [{
|
||||
leftCluster: { start: "05:10", end: "05:14" },
|
||||
rightCluster: { start: "05:16", end: "05:20" },
|
||||
techniqueLayers: ["D9", "vimshottari"],
|
||||
eventIds: [],
|
||||
}],
|
||||
});
|
||||
const currentSnapshot = snapshot(["05:10", "05:14"], {
|
||||
clusters: [
|
||||
{ rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:12", widthMinutes: 5, peakScore: 10, scoreMass: .55 },
|
||||
{ rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:18", widthMinutes: 5, peakScore: 9.8, scoreMass: .45 },
|
||||
],
|
||||
});
|
||||
|
||||
const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
|
||||
assert.deepEqual(packet?.missingEvidence[0], {
|
||||
domain: "relationship",
|
||||
eventKind: "relationship_start",
|
||||
reason: "highest_candidate_separation",
|
||||
});
|
||||
assert.deepEqual(packet?.discriminatingLayers, ["D9"]);
|
||||
|
||||
const opportunities = buildQuestionOpportunities({ caseId, events, turns: [], snapshot: currentSnapshot, diagnostics: splitDiagnostics });
|
||||
assert.equal(opportunities.some((item) => item.kind === "disambiguate_candidate_split" && item.targetEventId === null), false);
|
||||
assert.equal(opportunities[0]?.kind, "ask_new_event");
|
||||
assert.equal(opportunities[0]?.domain, "relationship");
|
||||
assert.ok(opportunities[0]?.contextFacts.some((fact) => /候选区分力最高/.test(fact)));
|
||||
assert.doesNotMatch(opportunities[0]?.contextFacts.join(" ") ?? "", /D9|05:1/);
|
||||
|
||||
const withStart = buildCandidateContrastPacket({
|
||||
events: [...events, event({ eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", summary: "2022年确定关系", rawText: "2022年确定关系" })],
|
||||
snapshot: currentSnapshot,
|
||||
diagnostics: splitDiagnostics,
|
||||
});
|
||||
assert.equal(withStart?.missingEvidence[0]?.eventKind, "relationship_change");
|
||||
});
|
||||
|
||||
test("真实用户回放按候选差异追问关系证据且不泄露内部候选", () => {
|
||||
const replay = [
|
||||
{ rawText: "2015年复读", domain: "education", eventKind: "education_milestone" },
|
||||
{ rawText: "2016年离家去外地上大学", domain: "education", eventKind: "education_milestone" },
|
||||
{ rawText: "2020年开始工作", domain: "career", eventKind: "career_change" },
|
||||
{ rawText: "2024年分手", domain: "relationship", eventKind: "relationship_end" },
|
||||
{ rawText: "2026年开始负债", domain: "finance", eventKind: "finance_change" },
|
||||
] as const;
|
||||
let events: LifeEventRevision[] = [];
|
||||
const turns: RectificationV4Turn[] = [];
|
||||
|
||||
replay.forEach((item, index) => {
|
||||
const sourceTurnId = randomUUID();
|
||||
const dateText = item.rawText.slice(0, 5);
|
||||
const assisted = validatedModelAssistedEvidence({
|
||||
rawText: item.rawText,
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-31",
|
||||
extraction: {
|
||||
sourceSpan: item.rawText,
|
||||
summary: item.rawText.slice(5),
|
||||
domain: item.domain,
|
||||
eventKind: item.eventKind,
|
||||
subject: "self",
|
||||
relatedPerson: null,
|
||||
dateText,
|
||||
},
|
||||
});
|
||||
assert.ok(assisted);
|
||||
const reconciled = reconcileV4Evidence({
|
||||
caseId,
|
||||
answer: item.rawText,
|
||||
sourceTurnId,
|
||||
asOfDate: "2026-07-31",
|
||||
existing: events,
|
||||
assistedEvidence: [assisted],
|
||||
now: new Date(`2026-07-31T0${index}:00:00.000Z`),
|
||||
});
|
||||
assert.equal(reconciled.pending.length, 0);
|
||||
events = [...events, ...reconciled.revisions];
|
||||
turns.push(turn({
|
||||
id: sourceTurnId,
|
||||
caseVersion: index + 1,
|
||||
questionDomain: item.domain,
|
||||
answer: item.rawText,
|
||||
createdAt: `2026-07-31T0${index}:00:00.000Z`,
|
||||
}));
|
||||
});
|
||||
|
||||
const breakup = events.find((item) => item.eventKind === "relationship_end");
|
||||
assert.ok(breakup);
|
||||
assert.equal(breakup.scoreability, "pending_review");
|
||||
assert.equal(events.some((item) => item.eventKind === "relationship_end" && item.scoreability === "scoreable"), false);
|
||||
assert.equal(events.some((item) => item.domain === "relocation"), false);
|
||||
|
||||
const currentSnapshot = snapshot(["05:10", "05:14"], {
|
||||
canAcceptRange: false,
|
||||
gateReasons: ["insufficient_candidate_separation"],
|
||||
clusters: [
|
||||
{ rank: 1, startTime: "05:10", endTime: "05:14", representativeTime: "05:13", widthMinutes: 5, peakScore: 10, scoreMass: .51 },
|
||||
{ rank: 2, startTime: "05:16", endTime: "05:20", representativeTime: "05:17", widthMinutes: 5, peakScore: 9.9, scoreMass: .49 },
|
||||
],
|
||||
});
|
||||
const splitDiagnostics = diagnostics({
|
||||
candidateSplits: [{
|
||||
leftCluster: { start: "05:10", end: "05:14" },
|
||||
rightCluster: { start: "05:16", end: "05:20" },
|
||||
techniqueLayers: ["D9", "vimshottari"],
|
||||
eventIds: [breakup.eventId],
|
||||
}],
|
||||
});
|
||||
const packet = buildCandidateContrastPacket({ events, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
|
||||
assert.equal(packet?.missingEvidence[0]?.eventKind, "relationship_start");
|
||||
|
||||
const opportunities = buildQuestionOpportunities({ caseId, events, turns, snapshot: currentSnapshot, diagnostics: splitDiagnostics });
|
||||
const next = opportunities[0];
|
||||
assert.ok(next);
|
||||
assert.equal(next.kind, "ask_new_event");
|
||||
assert.equal(next.domain, "relationship");
|
||||
assert.match(next.fallbackPrompt, /关系正式确立|开始共同生活/);
|
||||
assert.match(next.fallbackPrompt, /没有/);
|
||||
assert.match(next.fallbackPrompt, /不知道/);
|
||||
assert.match(next.fallbackPrompt, /不想回答/);
|
||||
assert.match(next.fallbackPrompt, /换方向/);
|
||||
assert.doesNotMatch(next.fallbackPrompt, /搬家|迁居|离乡|外地/);
|
||||
assert.doesNotMatch([next.goal, next.fallbackPrompt, ...next.contextFacts].join(" "), /D9|vimshottari|05:1[037]/i);
|
||||
assert.equal(validateQuestionRealization(next.fallbackPrompt, next).valid, true);
|
||||
});
|
||||
|
||||
test("研究院实习后的迁居机会以存在性问题主动引导,不要求用户自己发明事件", () => {
|
||||
const internship = event({
|
||||
domain: "career",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const migration = readFileSync(new URL(
|
||||
"../supabase/migrations/20260731010000_rectification_case_resume_and_scoring_v2.sql",
|
||||
import.meta.url,
|
||||
), "utf8");
|
||||
|
||||
test("scoring v2 migration only resumes an answerable or actively processing Case", () => {
|
||||
assert.match(migration, /v_case\.algorithm_version = p_algorithm_version/);
|
||||
assert.match(migration, /v_case\.status = 'awaiting_answer'[\s\S]*v_case\.current_question is not null/);
|
||||
assert.match(migration, /v_case\.status = 'processing'[\s\S]*birth_time_rectification_v4_jobs[\s\S]*status in \('pending', 'processing'\)/);
|
||||
assert.doesNotMatch(migration, /v_case\.status = 'paused'/);
|
||||
});
|
||||
|
||||
test("scoring v2 migration retires incompatible unfinished work without touching profile birth time", () => {
|
||||
assert.match(migration, /algorithm_version <> 'rectification-v5-matrix-scoring-2'[\s\S]*accepted_range_start is null/);
|
||||
assert.match(migration, /set status = 'stale'/);
|
||||
assert.match(migration, /set status = 'abandoned', phase = 'complete', current_question = null/);
|
||||
assert.doesNotMatch(migration, /profiles\.active_birth_time|update\s+public\.profiles/i);
|
||||
});
|
||||
|
||||
test("scoring v2 migration permits historical snapshots but defaults new artifacts to v2", () => {
|
||||
assert.match(migration, /alter column algorithm_version set default 'rectification-v5-matrix-scoring-2'/);
|
||||
assert.match(migration, /rectification-v4-range-scoring-1[\s\S]*rectification-v5-matrix-scoring-1[\s\S]*rectification-v5-matrix-scoring-2/);
|
||||
});
|
||||
@@ -88,6 +88,14 @@ function snapshotInput(overrides: Record<string, unknown> = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("pre-matrix historical snapshots remain readable", () => {
|
||||
const parsed = candidateSnapshotSchema.parse(snapshotInput({
|
||||
algorithmVersion: "rectification-v4-range-scoring-1",
|
||||
robustness,
|
||||
}));
|
||||
assert.equal(parsed.algorithmVersion, "rectification-v4-range-scoring-1");
|
||||
});
|
||||
|
||||
test("legacy snapshots without domain retention still parse without retroactive rejection", () => {
|
||||
const parsed = candidateSnapshotSchema.parse(snapshotInput({
|
||||
robustness: {
|
||||
|
||||
@@ -83,6 +83,48 @@ 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("new case replaces paused or orphaned processing state and can be answered immediately", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createTestCaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
|
||||
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
await service.transition({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, kind: "pause" });
|
||||
const afterPause = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.notEqual(afterPause.case.id, first.case.id);
|
||||
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
|
||||
assert.ok(afterPause.case.currentQuestion);
|
||||
|
||||
store.cases.set(afterPause.case.id, { ...afterPause.case, status: "processing", phase: "reasoning", currentQuestion: null });
|
||||
const replacement = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
assert.notEqual(replacement.case.id, afterPause.case.id);
|
||||
assert.equal(store.cases.get(afterPause.case.id)?.status, "abandoned");
|
||||
assert.ok(replacement.case.currentQuestion);
|
||||
|
||||
const queued = await service.answer({
|
||||
userId,
|
||||
caseId: replacement.case.id,
|
||||
actionId: randomUUID(),
|
||||
expectedCaseVersion: replacement.case.version,
|
||||
answer: "2016 年离家去外地上大学",
|
||||
});
|
||||
assert.ok(queued?.job);
|
||||
}));
|
||||
|
||||
test("new scoring version replaces a resumable Case created by an older algorithm", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createTestCaseService(store, { now: fixedNow });
|
||||
const userId = randomUUID();
|
||||
const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
store.cases.set(first.case.id, { ...first.case, algorithmVersion: "rectification-v5-matrix-scoring-1" });
|
||||
|
||||
const second = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec });
|
||||
|
||||
assert.notEqual(second.case.id, first.case.id);
|
||||
assert.equal(second.case.algorithmVersion, "rectification-v5-matrix-scoring-2");
|
||||
assert.equal(store.cases.get(first.case.id)?.status, "abandoned");
|
||||
}));
|
||||
|
||||
test("answer is durably queued and a processing case reload restores its active job", async () => withMode("v5_agent", async () => {
|
||||
const store = createRectificationV4MemoryStore();
|
||||
const service = createTestCaseService(store, { now: fixedNow });
|
||||
|
||||
@@ -11,7 +11,7 @@ DatePrecision = Literal["day", "month", "quarter", "year", "range"]
|
||||
SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = {
|
||||
"education": frozenset({"education_milestone"}),
|
||||
"relocation": frozenset({"relocation"}),
|
||||
"relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}),
|
||||
"relationship": frozenset({"relationship_start", "relationship_change"}),
|
||||
"career": frozenset({"career_change"}),
|
||||
"finance": frozenset({"finance_change"}),
|
||||
"health_pressure": frozenset({"self_health_event"}),
|
||||
|
||||
@@ -40,6 +40,48 @@ def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[
|
||||
return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows]
|
||||
|
||||
|
||||
def _candidate_feature_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> list[str]:
|
||||
features = {
|
||||
value["time"]: value
|
||||
for context in built.get("static_contexts") or []
|
||||
if isinstance((value := context.get("feature")), dict) and isinstance(value.get("time"), str)
|
||||
}
|
||||
primary = features.get(primary_time)
|
||||
secondary = features.get(secondary_time)
|
||||
if not primary or not secondary:
|
||||
return []
|
||||
layers = []
|
||||
for section in ("varga_ascendants", "arudha_signs"):
|
||||
primary_values = primary.get(section) or {}
|
||||
secondary_values = secondary.get(section) or {}
|
||||
layers.extend(
|
||||
key for key in set(primary_values) | set(secondary_values)
|
||||
if primary_values.get(key) != secondary_values.get(key)
|
||||
)
|
||||
fingerprints = (("ashtakavarga", "Ashtakavarga"), ("shadbala", "Shadbala"))
|
||||
primary_fingerprints = primary.get("fingerprints") or {}
|
||||
secondary_fingerprints = secondary.get("fingerprints") or {}
|
||||
layers.extend(
|
||||
layer for key, layer in fingerprints
|
||||
if primary_fingerprints.get(key) != secondary_fingerprints.get(key)
|
||||
)
|
||||
return sorted(set(layers))[:8]
|
||||
|
||||
|
||||
def _candidate_contrast(built: dict[str, Any], primary_time: str, secondary_time: str) -> tuple[list[str], list[str]]:
|
||||
event_deltas: list[tuple[float, str]] = []
|
||||
for event_id, candidates in built["matrix"].items():
|
||||
primary = candidates.get(primary_time)
|
||||
secondary = candidates.get(secondary_time)
|
||||
if not primary or not secondary:
|
||||
continue
|
||||
delta = abs(float(primary["points"]) - float(secondary["points"]))
|
||||
if delta > 1e-9:
|
||||
event_deltas.append((delta, event_id))
|
||||
events = [event_id for _, event_id in sorted(event_deltas, key=lambda item: (-item[0], item[1]))]
|
||||
return _candidate_feature_contrast(built, primary_time, secondary_time), events
|
||||
|
||||
|
||||
def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]:
|
||||
primary = set(_primary_cluster(rows))
|
||||
event_runs = []
|
||||
@@ -74,11 +116,12 @@ def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow]
|
||||
clusters = [_primary_cluster(rows)]
|
||||
candidate_splits = []
|
||||
if secondary and clusters[0]:
|
||||
contrast_layers, contrast_event_ids = _candidate_contrast(built, top[0]["time"], secondary["time"])
|
||||
candidate_splits.append({
|
||||
"left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]},
|
||||
"right_cluster": {"start": secondary["time"], "end": secondary["time"]},
|
||||
"technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]],
|
||||
"event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0],
|
||||
"technique_layers": contrast_layers,
|
||||
"event_ids": contrast_event_ids,
|
||||
})
|
||||
return {
|
||||
"primary_cluster_retention_rate": 1.0 if primary else 0.0,
|
||||
|
||||
@@ -11,7 +11,7 @@ from scripts.active_rectification_event_engine import compute_candidate_static_c
|
||||
from scripts.active_rectification_events import CandidateScoreRow
|
||||
from scripts.rectification.contracts import LifeEvent, RectificationRequest
|
||||
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1"
|
||||
ALGORITHM_VERSION = "rectification-v5-matrix-scoring-2"
|
||||
INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4"
|
||||
|
||||
|
||||
@@ -82,6 +82,42 @@ def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]:
|
||||
return tuple(compute_event_candidate_rows(json.loads(serialized)))
|
||||
|
||||
|
||||
_RELATIONSHIP_SUPPORT_RULES = (
|
||||
"functional_benefic_auxiliary",
|
||||
"arudha_auxiliary",
|
||||
"ashtakavarga_target_house_support_auxiliary",
|
||||
"shadbala_sthana_drik_naisargika_support_auxiliary",
|
||||
"controlled_transit_jupiter_domain_house",
|
||||
)
|
||||
_RELATIONSHIP_CHANGE_RULES = (
|
||||
"functional_malefic_auxiliary",
|
||||
"ashtakavarga_target_house_pressure_auxiliary",
|
||||
"shadbala_sthana_drik_naisargika_pressure_auxiliary",
|
||||
"controlled_transit_saturn_domain_house",
|
||||
)
|
||||
|
||||
|
||||
def _relationship_kind_factor(event_kind: str, rule_ids: Sequence[str]) -> float:
|
||||
if event_kind not in {"relationship_start", "relationship_change"}:
|
||||
return 1.0
|
||||
support = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_SUPPORT_RULES) for rule in rule_ids)
|
||||
change = sum(any(rule.endswith(marker) for marker in _RELATIONSHIP_CHANGE_RULES) for rule in rule_ids)
|
||||
direction = support - change if event_kind == "relationship_start" else change - support
|
||||
return max(0.8, min(1.2, 1 + 0.08 * direction))
|
||||
|
||||
|
||||
def _kind_adjusted_evidence(event: LifeEvent, evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
if event["domain"] != "relationship":
|
||||
return evidence
|
||||
event_kind = event["event_kind"]
|
||||
rules = list(evidence["rule_ids"])
|
||||
return {
|
||||
**evidence,
|
||||
"rule_ids": [*rules, f"event_kind_profile:{event_kind}"],
|
||||
"points": round(float(evidence["points"]) * _relationship_kind_factor(event_kind, rules), 4),
|
||||
}
|
||||
|
||||
|
||||
def build_event_contribution_matrix(
|
||||
request: RectificationRequest,
|
||||
row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None,
|
||||
@@ -94,7 +130,14 @@ def build_event_contribution_matrix(
|
||||
candidate_grid: list[str] | None = None
|
||||
for event in request["events"]:
|
||||
samples = sample_event_dates(event)
|
||||
sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples]
|
||||
sample_rows = []
|
||||
for sampled in samples:
|
||||
rows = list(provider(_legacy_request(request, event, sampled)))
|
||||
sample_rows.append([
|
||||
{**row, "score": adjusted["points"], "evidence": [adjusted]}
|
||||
for row in rows
|
||||
for adjusted in [_kind_adjusted_evidence(event, row["evidence"][0])]
|
||||
])
|
||||
grids = [[row["time"] for row in rows] for rows in sample_rows]
|
||||
if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid):
|
||||
raise ValueError("candidate_grid_mismatch")
|
||||
@@ -109,7 +152,12 @@ def build_event_contribution_matrix(
|
||||
matrix[event["id"]][candidate_time] = {
|
||||
"points": round(sum(points) / len(points), 4),
|
||||
"rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}),
|
||||
"technique_layers": sorted({rule.split(":", 1)[0] for item in evidences for rule in item["rule_ids"]}),
|
||||
"technique_layers": sorted({
|
||||
rule.split(":", 1)[0]
|
||||
for item in evidences
|
||||
for rule in item["rule_ids"]
|
||||
if not rule.startswith(("event_kind:", "event_kind_profile:"))
|
||||
}),
|
||||
}
|
||||
winner = max(set(winners), key=winners.count)
|
||||
mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid)
|
||||
|
||||
@@ -48,6 +48,56 @@ class RectificationDiagnosticsClustersTest(unittest.TestCase):
|
||||
"end": "05:16",
|
||||
})
|
||||
|
||||
def test_candidate_split_reports_actual_candidate_deltas_not_global_activation(self):
|
||||
stable_event = "00000000-0000-4000-8000-000000000001"
|
||||
separating_event = "00000000-0000-4000-8000-000000000002"
|
||||
rows = [
|
||||
{
|
||||
"time": "05:13", "score": 100, "missing_layers": [],
|
||||
"evidence": [
|
||||
{"event_id": stable_event, "domain": "relationship", "candidate_time": "05:13", "rule_ids": ["D9:a"], "points": 50},
|
||||
{"event_id": separating_event, "domain": "education", "candidate_time": "05:13", "rule_ids": ["D24:a"], "points": 50},
|
||||
],
|
||||
},
|
||||
{
|
||||
"time": "05:14", "score": 90, "missing_layers": [],
|
||||
"evidence": [
|
||||
{"event_id": stable_event, "domain": "relationship", "candidate_time": "05:14", "rule_ids": ["D9:b"], "points": 50},
|
||||
{"event_id": separating_event, "domain": "education", "candidate_time": "05:14", "rule_ids": ["D24:b"], "points": 40},
|
||||
],
|
||||
},
|
||||
]
|
||||
built = {
|
||||
"date_sensitivity": [],
|
||||
"static_contexts": [
|
||||
{"feature": {
|
||||
"time": "05:13",
|
||||
"varga_ascendants": {"D9": 1, "D24": 2},
|
||||
"arudha_signs": {},
|
||||
"fingerprints": {"ashtakavarga": "same", "shadbala": "same"},
|
||||
}},
|
||||
{"feature": {
|
||||
"time": "05:14",
|
||||
"varga_ascendants": {"D9": 1, "D24": 3},
|
||||
"arudha_signs": {},
|
||||
"fingerprints": {"ashtakavarga": "same", "shadbala": "same"},
|
||||
}},
|
||||
],
|
||||
"matrix": {
|
||||
stable_event: {
|
||||
"05:13": {"points": 50, "technique_layers": ["D9", "D24"]},
|
||||
"05:14": {"points": 50, "technique_layers": ["D9", "D24"]},
|
||||
},
|
||||
separating_event: {
|
||||
"05:13": {"points": 50, "technique_layers": ["D9", "D24"]},
|
||||
"05:14": {"points": 40, "technique_layers": ["D9", "D24"]},
|
||||
},
|
||||
},
|
||||
}
|
||||
result = run_diagnostics({"events": [{"id": stable_event, "domain": "relationship"}, {"id": separating_event, "domain": "education"}]}, rows, built)
|
||||
self.assertEqual(result["candidate_splits"][0]["technique_layers"], ["D24"])
|
||||
self.assertEqual(result["candidate_splits"][0]["event_ids"], [separating_event])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -113,6 +113,71 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28))
|
||||
self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event")
|
||||
|
||||
def test_relationship_end_is_not_scoreable(self):
|
||||
with self.assertRaisesRegex(ValueError, "event_kind does not match domain"):
|
||||
normalize_rectification_request(
|
||||
request(domain="relationship", event_kind="relationship_end"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
|
||||
def test_relationship_start_and_change_use_distinct_rule_conditioned_profiles(self):
|
||||
def relationship_rows(_value):
|
||||
return [
|
||||
{
|
||||
"time": "05:13", "score": 10,
|
||||
"evidence": [{
|
||||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
|
||||
"rule_ids": ["vim_md_domain_house", "vim_ad_functional_benefic_auxiliary"],
|
||||
"points": 10,
|
||||
}],
|
||||
"missing_layers": [],
|
||||
},
|
||||
{
|
||||
"time": "05:14", "score": 9,
|
||||
"evidence": [{
|
||||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:14",
|
||||
"rule_ids": ["controlled_transit_saturn_domain_house", "vim_ad_functional_malefic_auxiliary"],
|
||||
"points": 9,
|
||||
}],
|
||||
"missing_layers": [],
|
||||
},
|
||||
]
|
||||
|
||||
start = normalize_rectification_request(
|
||||
request(domain="relationship", event_kind="relationship_start"), today=date(2026, 7, 28)
|
||||
)
|
||||
change = normalize_rectification_request(
|
||||
request(domain="relationship", event_kind="relationship_change"), today=date(2026, 7, 28)
|
||||
)
|
||||
|
||||
start_matrix = build_event_contribution_matrix(start, row_provider=relationship_rows)
|
||||
change_matrix = build_event_contribution_matrix(change, row_provider=relationship_rows)
|
||||
|
||||
self.assertGreater(start_matrix["matrix"][EVENT_ID]["05:13"]["points"], start_matrix["matrix"][EVENT_ID]["05:14"]["points"] )
|
||||
self.assertGreater(change_matrix["matrix"][EVENT_ID]["05:14"]["points"], change_matrix["matrix"][EVENT_ID]["05:13"]["points"] )
|
||||
self.assertEqual(change_matrix["date_sensitivity"][0]["sample_winners"], ["05:14", "05:14", "05:14"])
|
||||
self.assertNotIn("event_kind_profile", change_matrix["matrix"][EVENT_ID]["05:14"]["technique_layers"])
|
||||
|
||||
def test_relationship_kind_profile_does_not_create_points_without_activation(self):
|
||||
normalized = normalize_rectification_request(
|
||||
request(domain="relationship", event_kind="relationship_change", precision="day"),
|
||||
today=date(2026, 7, 28),
|
||||
)
|
||||
|
||||
def rows(_value):
|
||||
return [{
|
||||
"time": "05:13", "score": 0,
|
||||
"evidence": [{
|
||||
"event_id": EVENT_ID, "domain": "relationship", "candidate_time": "05:13",
|
||||
"rule_ids": ["no_domain_activation", "event_kind:relationship_change"],
|
||||
"points": 0,
|
||||
}],
|
||||
"missing_layers": [],
|
||||
}]
|
||||
|
||||
built = build_event_contribution_matrix(normalized, row_provider=rows)
|
||||
self.assertEqual(built["matrix"][EVENT_ID]["05:13"]["points"], 0)
|
||||
|
||||
def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self):
|
||||
base = request()["events"][0]
|
||||
self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"])
|
||||
@@ -167,7 +232,7 @@ class RectificationV5ServicesTest(unittest.TestCase):
|
||||
}
|
||||
feature = {
|
||||
"calculation_spec_hash": "0" * 64,
|
||||
"algorithm_version": "rectification-v5-matrix-scoring-1",
|
||||
"algorithm_version": "rectification-v5-matrix-scoring-2",
|
||||
"candidate_count": 2,
|
||||
"feature_hash": "1" * 64,
|
||||
"features": [{"time": "05:13"}, {"time": "05:14"}],
|
||||
|
||||
Reference in New Issue
Block a user