feat: complete verifiable birth-time rectification flow

This commit is contained in:
Jesse_Chen
2026-07-21 22:16:18 +08:00
parent eb8ed8bee4
commit bfc6870614
117 changed files with 6215 additions and 1038 deletions
+8
View File
@@ -48,3 +48,11 @@ export function parseAgentReply(value: string, theme: ReplyTheme) {
title,
};
}
export function resolveSessionTitle(question: string, modelTitle?: string): string {
if (modelTitle && modelTitle !== "一般占星咨询") return modelTitle;
const normalized = question.replace(/\s+/g, " ").trim().replace(/[?!,;:]+$/u, "");
if (!normalized) return "新对话";
const characters = Array.from(normalized);
return characters.length > 14 ? `${characters.slice(0, 14).join("")}` : normalized;
}
@@ -113,12 +113,12 @@ export function resolveBirthTimeConsultationRoute(
}
const reportedTime = unverifiedBirthTime(profile);
const consentMode = consultationModeForSession(state, sessionId);
if (consentMode === "general_no_birth_time") {
return { kind: "consult", mode: "general_no_birth_time", time: null };
}
if (reportedTime && consentMode === "unverified_birth_time") {
return { kind: "consult", mode: "unverified_birth_time", time: reportedTime };
}
if (!reportedTime && consentMode === "general_no_birth_time") {
return { kind: "consult", mode: "general_no_birth_time", time: null };
}
return { kind: "choice", canUseUnverifiedTime: reportedTime !== null };
}
@@ -8,10 +8,15 @@ import type { ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
const reusableCandidateModelSchema = z.object({
opportunity_model_version: z.literal("birth-time-opportunity-model-v2"),
opportunity_model_version: z.literal("birth-time-opportunity-model-v4"),
historical_event_fingerprint: z.string().trim().min(1),
range: z.object({ start_time: z.string(), end_time: z.string() }),
windows: z.array(z.object({
activations: z.record(z.string(), z.number().finite().nonnegative()),
fact_selection_priority: z.number().finite().min(0).max(1),
fact_priority_version: z.literal("birth-time-question-fact-priority-v1"),
event_fact_selection_priority: z.number().finite().min(0).max(1),
event_fact_priority_version: z.literal("birth-time-question-event-fact-priority-v1"),
}).passthrough()),
}).passthrough();
@@ -102,6 +107,7 @@ export function dynamicDifferenceInput(
caseId: stored.id,
asOfDate: stored.dynamicControl.asOfDate,
...dynamicChoiceScoreInputForRange(stored, range),
events: stored.lifeEvents ?? [],
dismissedOpportunityIds: stored.dynamicControl.dismissedOpportunityIds,
questionFingerprints: stored.dynamicControl.questionFingerprints,
partitionFingerprints: stored.dynamicControl.partitionFingerprints,
@@ -57,7 +57,7 @@ export function assertDynamicScoringResult(
const expected = high ? "high" : medium ? "medium" : "low";
if (!segmentIsCoherent(result, currentRange)
|| candidate.confidence !== expected
|| candidate.canApply !== high
|| candidate.canApply !== false
|| candidate.evidence.length !== 0) {
throw new BirthTimeScoringJobError("invalid_result");
}
@@ -39,7 +39,9 @@ export function decideDynamicStop(input: DynamicStopInput): DynamicStopDecision
? materiallyChanged(input.previousResult, input.result) ? 0 : input.priorPlateauCount + 1
: input.priorPlateauCount;
if (input.forcedReason !== null) return { kind: "finish", reason: input.forcedReason, plateauCount };
if (input.result?.confidence === "high") return { kind: "finish", reason: "high_confidence", plateauCount };
if (input.result?.confidence === "high" && input.result.canApply) {
return { kind: "finish", reason: "high_confidence", plateauCount };
}
if (input.effectiveAnswerCount >= 10) return { kind: "finish", reason: "safety_cap", plateauCount };
if (plateauCount >= 2) return { kind: "finish", reason: "plateau", plateauCount };
if (input.usefulOpportunityCount === 0) return { kind: "finish", reason: "no_information_gain", plateauCount };
@@ -170,11 +170,12 @@ export function completeDynamicScoreTransition(input: {
effectiveAnswerCount: stored.dynamicControl.effectiveAnswerCount,
forcedReason: null,
});
const action: DynamicNextAction = input.candidate.confidence === "high"
const mayConfirm = input.candidate.confidence === "high" && input.candidate.canApply;
const action: DynamicNextAction = mayConfirm
? { kind: "request_candidate_confirmation", resultId: input.candidate.resultId }
: decision.kind === "continue"
? { kind: "generate_dynamic_question" }
: input.candidate.confidence === "medium"
: input.candidate.confidence !== "low"
? { kind: "present_medium_result", resultId: input.candidate.resultId }
: { kind: "present_low_result", resultId: input.candidate.resultId };
const priorRange = stored.dynamicTurnState.progress.currentRange;
@@ -209,7 +210,7 @@ export function completeDynamicScoreTransition(input: {
previousRange,
plateauCount: decision.plateauCount,
},
permissions: { canConfirmCandidate: input.candidate.confidence === "high" },
permissions: { canConfirmCandidate: mayConfirm },
},
};
}
+20 -2
View File
@@ -92,6 +92,13 @@ const rectificationTechniqueReceiptSchema = z.object({
missingLayers: z.array(z.string()),
auxiliaryLayers: z.array(z.string()).default([]),
hardBlockers: z.array(z.string()),
canonicalInputHash: z.string().optional(),
confirmationAllowed: z.boolean().optional(),
decision: z.enum(["continue_rectification", "confirm_minute"]).optional(),
gates: z.record(z.string(), z.object({
status: z.enum(["pass", "fail", "blocked", "not_evaluated"]),
reason: z.string(),
}).strict()).optional(),
}).strict().readonly();
export const candidateResultSchema = z.object({
@@ -150,11 +157,11 @@ export const candidateResultSchema = z.object({
message: "high candidates require at least twenty percent margin",
});
}
if (value.canApply !== eligible) {
if (value.canApply && !eligible) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ["canApply"],
message: "only a high candidate with a winning segment can be confirmed",
message: "only a high candidate with a winning segment may enter confirmation",
});
}
});
@@ -206,6 +213,17 @@ export function withCandidateResult(
activeTime: null,
};
case "high":
if (!result.canApply) {
return {
...snapshot,
state: "candidate",
assistantIntent: "present_candidate_result",
input: "candidate_actions",
confidence: "high",
canApply: false,
activeTime: null,
};
}
return {
...snapshot,
state: "confirming",
@@ -51,16 +51,31 @@ const questionSchema = z.object({
const signSchema = z.object({ sign: z.string().trim().min(1) }).nullable().optional();
const sampleSchema = z.object({
time: z.string().trim().min(1).optional(),
ascendant: signSchema,
varga_lagna: z.object({
D4: signSchema,
D4_Chaturthamsa: signSchema,
D4_Turyamsa: signSchema,
D2: signSchema,
D2_Hora: signSchema,
D9: signSchema,
D9_Navamsa: signSchema,
D10: signSchema,
D10_Dasamsa: signSchema,
D11: signSchema,
D11_Rudramsa: signSchema,
D24: signSchema,
D24_Siddhamsa: signSchema,
D30: signSchema,
}).optional(),
});
D30_Trimsamsa: signSchema,
}).passthrough().optional(),
arudha: z.object({
A7: signSchema,
UL: signSchema,
A10: signSchema,
}).passthrough().optional(),
}).passthrough();
const questionnaireSchema = z.object({
questions: z.array(questionSchema),
@@ -117,6 +132,13 @@ const candidateResultApiSchema = z.object({
missing_layers: z.array(z.string()),
auxiliary_layers: z.array(z.string()).default([]),
hard_blockers: z.array(z.string()),
canonical_input_hash: z.string().optional(),
confirmation_allowed: z.boolean().optional(),
decision: z.enum(["continue_rectification", "confirm_minute"]).optional(),
gates: z.record(z.string(), z.object({
status: z.enum(["pass", "fail", "blocked", "not_evaluated"]),
reason: z.string(),
}).strict()).optional(),
}).optional(),
}).passthrough();
@@ -172,12 +194,23 @@ export function parseRectificationQuestionnaire(value: unknown): RectificationQu
questions: parsed.questions.map(normalizeQuestion),
samples: parsed.candidate_scan.samples.map((sample) => ({
ascendantSign: sample.ascendant?.sign ?? null,
...(sample.varga_lagna?.D2?.sign ? { d2Sign: sample.varga_lagna.D2.sign } : {}),
d4Sign: sample.varga_lagna?.D4?.sign ?? null,
d9Sign: sample.varga_lagna?.D9?.sign ?? null,
d10Sign: sample.varga_lagna?.D10?.sign ?? null,
d24Sign: sample.varga_lagna?.D24?.sign ?? null,
d30Sign: sample.varga_lagna?.D30?.sign ?? null,
...(sample.varga_lagna?.D2?.sign || sample.varga_lagna?.D2_Hora?.sign
? { d2Sign: sample.varga_lagna.D2?.sign ?? sample.varga_lagna.D2_Hora?.sign }
: {}),
d4Sign: sample.varga_lagna?.D4?.sign
?? sample.varga_lagna?.D4_Chaturthamsa?.sign
?? sample.varga_lagna?.D4_Turyamsa?.sign
?? null,
d9Sign: sample.varga_lagna?.D9?.sign ?? sample.varga_lagna?.D9_Navamsa?.sign ?? null,
d10Sign: sample.varga_lagna?.D10?.sign ?? sample.varga_lagna?.D10_Dasamsa?.sign ?? null,
...(sample.varga_lagna?.D11?.sign || sample.varga_lagna?.D11_Rudramsa?.sign
? { d11Sign: sample.varga_lagna.D11?.sign ?? sample.varga_lagna.D11_Rudramsa?.sign }
: {}),
d24Sign: sample.varga_lagna?.D24?.sign ?? sample.varga_lagna?.D24_Siddhamsa?.sign ?? null,
d30Sign: sample.varga_lagna?.D30?.sign ?? sample.varga_lagna?.D30_Trimsamsa?.sign ?? null,
a7Sign: sample.arudha?.A7?.sign ?? null,
ulSign: sample.arudha?.UL?.sign ?? null,
a10Sign: sample.arudha?.A10?.sign ?? null,
})),
raw: parsed,
};
@@ -215,7 +248,7 @@ function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>):
return candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
canApply: parsed.can_apply,
canApply: parsed.can_apply && parsed.technique_contract?.confirmation_allowed === true,
winningSegment: parsed.winning_segment
? {
startTime: parsed.winning_segment.start_time,
@@ -246,6 +279,10 @@ function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>):
missingLayers: parsed.technique_contract.missing_layers,
auxiliaryLayers: parsed.technique_contract.auxiliary_layers,
hardBlockers: parsed.technique_contract.hard_blockers,
canonicalInputHash: parsed.technique_contract.canonical_input_hash,
confirmationAllowed: parsed.technique_contract.confirmation_allowed,
decision: parsed.technique_contract.decision,
gates: parsed.technique_contract.gates,
} } : {}),
});
}
@@ -184,7 +184,7 @@ export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringR
const candidate = candidateResultSchema.parse({
resultId: parsed.result_id,
confidence: parsed.confidence,
canApply: parsed.can_apply,
canApply: false,
winningSegment: segment && {
startTime: segment.start_time,
endTime: segment.end_time,
@@ -196,7 +196,7 @@ export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringR
topScore: parsed.top_score,
secondScore: parsed.second_score,
marginPercent: parsed.margin_percent,
reasons: parsed.reasons,
reasons: [...new Set([...parsed.reasons, "minute_holdout_not_ready"])],
evidence: [],
algorithmVersion: parsed.algorithm_version,
});
@@ -87,7 +87,7 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
lat: input.lat,
lon: input.lon,
tz: input.tz,
events: input.events.map((event) => ({
events: (input.events ?? []).map((event) => ({
id: event.id,
domain: event.domain,
date: event.date,
@@ -118,6 +118,12 @@ export function differencePacketPayload(input: DifferencePacketInput) {
lon: input.lon,
tz: input.tz,
evidence: choiceEvidencePayload(input.evidence),
events: (input.events ?? []).map((event) => ({
id: event.id,
domain: event.domain,
date: event.date,
precision: event.precision,
})),
dismissed_opportunity_ids: input.dismissedOpportunityIds,
question_fingerprints: input.questionFingerprints,
partition_fingerprints: input.partitionFingerprints,
@@ -81,6 +81,7 @@ export type DifferencePacketInput = {
readonly lon: number;
readonly tz: number;
readonly evidence: readonly ServerChoiceEvidence[];
readonly events: readonly LifeEvent[];
readonly dismissedOpportunityIds: readonly string[];
readonly questionFingerprints: readonly string[];
readonly partitionFingerprints: readonly string[];
@@ -11,8 +11,12 @@ export type CandidateVargaSample = {
readonly d4Sign: string | null;
readonly d9Sign: string | null;
readonly d10Sign: string | null;
readonly d11Sign?: string | null;
readonly d24Sign: string | null;
readonly d30Sign: string | null;
readonly a7Sign?: string | null;
readonly ulSign?: string | null;
readonly a10Sign?: string | null;
};
export type QuestionPlannerInput = {
@@ -8,6 +8,7 @@ const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"relocation",
"relationship",
"family",
@@ -0,0 +1,69 @@
import type { RectificationTechnicalPacket } from "./technical-packet.ts";
export const MINIMUM_SCOREABLE_EVENTS = 3;
export const MAXIMUM_SCOREABLE_EVENTS = 8;
export const MAXIMUM_PLATEAU_ROUNDS = 2;
export type RangeCompletionReason =
| "evidence_limit"
| "no_discriminating_question"
| "range_plateau";
const plateauNotePrefix = "range_plateau_count:";
type CandidateProgress = Readonly<{
rangeStart?: string | null;
rangeEnd?: string | null;
workingState?: Readonly<{
notes: readonly string[];
}>;
}>;
function priorPlateauCount(candidate: CandidateProgress): number {
const note = candidate.workingState?.notes.find((item) => item.startsWith(plateauNotePrefix));
const value = Number(note?.slice(plateauNotePrefix.length));
return Number.isSafeInteger(value) && value >= 0 ? value : 0;
}
function sameRange(candidate: CandidateProgress, packet: RectificationTechnicalPacket): boolean {
return candidate.rangeStart === packet.candidate.range.startTime
&& candidate.rangeEnd === packet.candidate.range.endTime;
}
export function nextPlateauCount(
candidate: CandidateProgress,
packet: RectificationTechnicalPacket,
): number {
return sameRange(candidate, packet) ? priorPlateauCount(candidate) + 1 : 0;
}
export function convergenceNotes(candidate: CandidateProgress, plateauCount: number): string[] {
return [
...(candidate.workingState?.notes ?? []).filter((item) => !item.startsWith(plateauNotePrefix)),
`${plateauNotePrefix}${plateauCount}`,
];
}
export function rangeCompletionReason(input: Readonly<{
packet: RectificationTechnicalPacket;
scoreableEventCount: number;
plateauCount: number;
}>): RangeCompletionReason | null {
if (input.packet.candidate.status === "ready_for_confirmation") return null;
if (input.scoreableEventCount < MINIMUM_SCOREABLE_EVENTS) return null;
if (input.scoreableEventCount >= MAXIMUM_SCOREABLE_EVENTS) return "evidence_limit";
if (input.packet.suggestedDomains.length === 0) return "no_discriminating_question";
if (input.plateauCount >= MAXIMUM_PLATEAU_ROUNDS) return "range_plateau";
return null;
}
export function rangeCompletionCopy(reason: RangeCompletionReason): string {
switch (reason) {
case "evidence_limit":
return "已核对足够数量的真实经历,但现有证据仍不足以可靠确认某一分钟。";
case "no_discriminating_question":
return "当前候选之间已经没有可由真实经历继续区分的问题。";
case "range_plateau":
return "连续两轮补充经历后,候选范围没有继续稳定缩小。";
}
}
@@ -84,6 +84,7 @@ function classifyDomain(summary: string): RectificationEvidenceDomain {
if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation";
if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship";
if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family";
if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance";
if (/工作|入职|离职|辞职|升职|创业|职业|公司|项目/.test(summary)) return "career";
return "other";
}
@@ -48,6 +48,7 @@ export type ProjectedLegacyConversationalImport = Readonly<{
const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -55,7 +56,7 @@ const domainLabels = {
} as const;
function importedDomain(domain: LifeEvent["domain"]): LifeEventEvidence["domain"] {
return domain === "finance" || domain === "health_pressure" ? "other" : domain;
return domain === "health_pressure" ? "other" : domain;
}
function eventIsWithinHistoricalWindow(
@@ -10,7 +10,7 @@ export type RectificationNarrativePhase = "first" | "intermediate" | "final";
const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
const modelIdSchema = z.string().trim().min(1).max(120);
const validatorVersion = "rectification-narrative-grounding-v2";
const domainSchema = z.enum(["career", "education", "relocation", "relationship", "family", "other"]);
const domainSchema = z.enum(["career", "education", "finance", "relocation", "relationship", "family", "other"]);
const stableSemanticsPattern = /(?:稳定|保持|不变|一致|stable|unchanged)/i;
const sensitiveSemanticsPattern = /(?:敏感|变化|差异|切换|不同|sensitive|changes?|differs?)/i;
const discriminationSemanticsPattern = /(?:区分|辨别|判别|验证|差异|变化|discriminat|distinguish)/i;
@@ -21,6 +21,7 @@ const choiceQuestionPattern = /(?:哪(?:一|个)?(?:年|年份|年代|时间段|
const domainSemantics = {
career: /(?:事业|工作|职业|career)/i,
education: /(?:教育|学业|学校|education)/i,
finance: /(?:财富|财务|收入|投资|finance)/i,
relocation: /(?:搬迁|搬家|迁居|异地|居住|relocation)/i,
relationship: /(?:关系|婚恋|伴侣|relationship)/i,
family: /(?:家庭|家人|父母|孩子|family)/i,
@@ -29,6 +30,7 @@ const domainSemantics = {
const domainLabels = {
career: "事业",
education: "学业",
finance: "财富",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -427,7 +429,9 @@ export async function generateRectificationNarrative(input: {
output,
attempts: 2,
fallbackUsed: true,
allowEvidenceScoringAdvance: false,
// The fallback is rendered entirely from the validated deterministic packet.
// A prose-model failure must not discard scoreable evidence or block narrowing.
allowEvidenceScoringAdvance: true,
validationReceipt: {
modelId,
schemaValidated: false,
@@ -26,6 +26,13 @@ import {
type RectificationEvidenceDomain,
type RectificationTechnicalPacket,
} from "./technical-packet.ts";
import {
convergenceNotes,
nextPlateauCount,
rangeCompletionCopy,
rangeCompletionReason,
type RangeCompletionReason,
} from "./convergence.ts";
import type { ConversationalRectificationBilling } from "./billing.ts";
import {
projectLegacyCaseForConversationalImport,
@@ -287,6 +294,7 @@ function privateCandidateFromPacket(input: {
readonly resultId: string | null;
readonly iteration: number;
readonly forceCollecting?: boolean;
readonly notes?: readonly string[];
}): PrivateCandidate {
const packet = input.packet;
const parsed = privateCandidateSchema.safeParse({
@@ -310,13 +318,30 @@ function privateCandidateFromPacket(input: {
? "collecting_evidence"
: packet.candidate.status === "ready_for_confirmation" ? "ready" : "collecting_evidence",
iteration: input.iteration,
notes: [],
notes: [...(input.notes ?? [])],
},
});
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return parsed.data;
}
function completedRangeTurn(
turn: ConversationalRectificationTurn,
reason: RangeCompletionReason,
): ConversationalRectificationTurn {
const suffix = `${rangeCompletionCopy(reason)} 本次校正已结束并保存当前候选范围;候选代表时间不会替换当前排盘时间。`;
const parsed = conversationalRectificationTurnSchema.safeParse({
...turn,
status: "completed",
narrative: boundedNarrative(turn.narrative, suffix),
candidate: { ...turn.candidate, status: "pending_validation" },
evidenceRequest: null,
actions: turn.pendingConsultationQuestion ? ["continue_original_question"] : [],
});
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return parsed.data;
}
function changedTurn(input: {
readonly current: LoadedConversationalRectificationCase;
readonly status: "paused" | "abandoned" | "completed";
@@ -391,7 +416,6 @@ function nonScoringTurn(input: {
readonly newEvidence: ReadonlyArray<LifeEventEvidenceInput>;
readonly domain?: RectificationEvidenceDomain;
readonly directionChange: boolean;
readonly scoringFallback?: boolean;
readonly correctionReset?: Readonly<{
packet: RectificationTechnicalPacket;
reason: CorrectionResetReason;
@@ -401,7 +425,7 @@ function nonScoringTurn(input: {
const hasFuture = input.newEvidence.some((item) => item.extractionStatus !== "needs_clarification"
&& item.scoreable === false && item.dateValue !== null);
const correctionNarrative = input.correctionReset?.reason === "validation_fallback"
? "这条更正已保存,原记录已经停止参与候选评分候选已从声明范围重新计算,但新的专业解释未通过事实一致性校验;本轮不会保留旧候选的确认资格,请稍后重试或继续补充真实事件。"
? "这条更正已保存,原记录已经停止参与候选评分候选范围也已重新计算。为避免只凭一次修订直接确认出生分钟,本轮先保持待验证;请继续补充另一件已经发生的真实经历。"
: input.correctionReset?.reason === "direction_change"
? "这条更正已保存,原记录已经停止参与候选评分。我们会从声明范围重新开始核对,你可以换一个真实事件方向并尽量写明年月;本轮不会沿用旧候选推进确认。"
: input.correctionReset?.reason === "non_scoreable"
@@ -409,8 +433,6 @@ function nonScoringTurn(input: {
: "这条更正已保存,原记录已经停止参与候选评分。更正后的事件时间还不够清楚,候选已从声明范围重新计算;请补充大约年份、月份和发生了什么。";
const narrative = input.correctionReset
? correctionNarrative
: input.scoringFallback
? "本轮原文已安全保存,但新的专业解释未通过事实一致性校验,因此候选没有推进。请稍后重试,或继续补充一件已经发生并带有年月的事件。"
: input.directionChange
? "好的,我们不沿用不符合你的方向。你可以自由描述另一件已经发生的生活变化,尽量写明年月;我会根据事实继续,而不是让你选择宽泛年份。"
: hasFuture
@@ -446,9 +468,7 @@ function nonScoringTurn(input: {
if (!parsed.success) throw new ConversationalRectificationError("service_unavailable");
return {
turn: parsed.data,
receipt: transitionReceipt(input.scoringFallback
? "deterministic-scoring-safety-fallback"
: "deterministic-evidence-clarification"),
receipt: transitionReceipt("deterministic-evidence-clarification"),
};
}
@@ -933,7 +953,7 @@ export function createConversationalRectificationService(
packet: computed.packet,
generator: ports.narrativeGenerator,
});
if (!narrative.allowEvidenceScoringAdvance) {
if (narrative.fallbackUsed) {
const next = nonScoringTurn({
current,
newEvidence: evidence,
@@ -963,7 +983,6 @@ export function createConversationalRectificationService(
});
return publicTurn(saved);
}
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
@@ -1035,33 +1054,19 @@ export function createConversationalRectificationService(
packet: computed.packet,
generator: ports.narrativeGenerator,
});
if (!narrative.allowEvidenceScoringAdvance) {
const next = nonScoringTurn({
current,
newEvidence: evidence,
domain: command.domain,
directionChange: false,
scoringFallback: true,
});
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
expectedVersion: command.turnVersion,
actionId: command.actionId,
commandFingerprint: fingerprint,
turn: next.turn,
evidence,
validationReceipt: narrative.validationReceipt,
privateCandidate: current.privateCandidate,
});
return publicTurn(saved);
}
const plateauCount = nextPlateauCount(current.privateCandidate, computed.packet);
const completionReason = rangeCompletionReason({
packet: computed.packet,
scoreableEventCount: allScoreable.length,
plateauCount,
});
const privateCandidate = privateCandidateFromPacket({
packet: computed.packet,
resultId: computed.resultId,
iteration: (current.privateCandidate.workingState?.iteration ?? 0) + 1,
notes: convergenceNotes(current.privateCandidate, plateauCount),
});
const turn = turnFromNarrative({
const narratedTurn = turnFromNarrative({
caseId: command.caseId,
turnVersion: command.turnVersion + 1,
pendingConsultationQuestion: current.pendingConsultationQuestion,
@@ -1069,6 +1074,9 @@ export function createConversationalRectificationService(
narrative,
evidence: [...current.eventEvidence, ...evidence],
});
const turn = completionReason
? completedRangeTurn(narratedTurn, completionReason)
: narratedTurn;
const saved = await ports.store.saveTurn({
userId,
caseId: command.caseId,
@@ -121,6 +121,7 @@ export type DeclaredBirthInput = z.infer<typeof declaredBirthInputSchema>;
const evidenceDomainSchema = z.enum([
"career",
"education",
"finance",
"relocation",
"relationship",
"family",
@@ -348,7 +348,10 @@ export class ConversationalRectificationStore {
async saveTurn(
input: SaveConversationalRectificationTurnInput,
): Promise<StoredConversationalRectificationCase> {
const result = await this.callCaseRpc("save_conversational_rectification_turn", {
const functionName = input.turn.status === "completed"
? "complete_conversational_rectification_with_range"
: "save_conversational_rectification_turn";
const result = await this.callCaseRpc(functionName, {
...mutationArgs(input),
p_command_fingerprint: commandFingerprint(input),
p_turn: requirePublicTurn(input.turn),
@@ -5,6 +5,7 @@ import type { RectificationQuestionnaire } from "../birth-time-journey-service.t
export type RectificationEvidenceDomain =
| "career"
| "education"
| "finance"
| "relocation"
| "relationship"
| "family"
@@ -93,23 +94,34 @@ type TimeLinkedVargaSample = {
const layerFields = [
["D1", "ascendantSign"],
["D2", "d2Sign"],
["D4", "d4Sign"],
["D9", "d9Sign"],
["D10", "d10Sign"],
["D11", "d11Sign"],
["D24", "d24Sign"],
["D30", "d30Sign"],
["A7", "a7Sign"],
["UL", "ulSign"],
["A10", "a10Sign"],
] as const;
const domainByLayer = {
D9: "relationship",
D2: "finance",
D10: "career",
D11: "finance",
D24: "education",
D4: "relocation",
A7: "relationship",
UL: "relationship",
A10: "career",
} as const satisfies Readonly<Record<string, RectificationEvidenceDomain>>;
const domainLabels = {
career: "事业",
education: "教育",
finance: "财富",
relocation: "迁居",
relationship: "关系",
family: "家庭",
@@ -147,6 +159,10 @@ function timeIsInsideRange(time: string, startTime: string, endTime: string): bo
: minute >= start || minute <= end;
}
function isNextMinute(previous: string, current: string): boolean {
return timeToMinute(current) === (timeToMinute(previous) + 1) % 1_440;
}
function timeLinkedSamples(
scan: RectificationQuestionnaire,
links: ServerComputedRectificationConsultation["timeLinkedScanSamples"],
@@ -188,7 +204,7 @@ function candidateWeights(model: Readonly<Record<string, unknown>>): Readonly<Re
}
function eventDomain(domain: CandidateResult["evidence"][number]["domain"]): RectificationEvidenceDomain {
return domain === "finance" || domain === "health_pressure" ? "other" : domain;
return domain === "health_pressure" ? "other" : domain;
}
function layerEvidence(
@@ -202,15 +218,38 @@ function layerEvidence(
})).filter((item) => item.values.length > 0);
}
function suggestedDomains(layers: readonly RectificationLayerEvidence[]): SuggestedEvidenceDomain[] {
return layers.flatMap((item) => {
function suggestedDomains(
layers: readonly RectificationLayerEvidence[],
samples: readonly TimeLinkedVargaSample[],
): SuggestedEvidenceDomain[] {
const ranked = layers.flatMap((item) => {
const domain = domainByLayer[item.layer as keyof typeof domainByLayer];
if (!domain) return [];
const field = layerFields.find(([layer]) => layer === item.layer)?.[1];
const values = field ? samples.map(({ sample }) => sample[field] ?? "") : [];
const adjacentChanges = values.slice(1).filter((value, index) => (
isNextMinute(samples[index]?.time ?? "", samples[index + 1]?.time ?? "")
&& value.length > 0
&& Boolean(values[index]?.length)
&& value !== values[index]
)).length;
if (adjacentChanges === 0) return [];
return [{
domain,
layer: item.layer,
reason: `${item.layer} 在候选范围内呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`,
adjacentChanges,
valueCount: item.values.length,
reason: `${item.layer} 在相邻候选分钟中发生 ${adjacentChanges} 次实际切换,并呈现 ${item.values.join(" / ")} 差异,可用已发生的${domainLabels[domain]}事件区分。`,
}];
}).sort((left, right) => right.adjacentChanges - left.adjacentChanges
|| right.valueCount - left.valueCount
|| layerFields.findIndex(([layer]) => layer === left.layer)
- layerFields.findIndex(([layer]) => layer === right.layer));
const selected = new Set<RectificationEvidenceDomain>();
return ranked.flatMap((item) => {
if (selected.has(item.domain)) return [];
selected.add(item.domain);
return [{ domain: item.domain, layer: item.layer, reason: item.reason }];
});
}
@@ -225,7 +264,11 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
const representativeTime = eventSegment?.representativeTime
?? midpoint(range.startTime, range.endTime);
const selectedSamples = timeLinkedSamples(input.scan, input.consultation.timeLinkedScanSamples)
.filter((item) => timeIsInsideRange(item.time, range.startTime, range.endTime));
.filter((item) => timeIsInsideRange(item.time, range.startTime, range.endTime))
.sort((left, right) => (
(timeToMinute(left.time) - timeToMinute(range.startTime) + 1_440) % 1_440
- (timeToMinute(right.time) - timeToMinute(range.startTime) + 1_440) % 1_440
));
if (selectedSamples.length < 2) {
throw new TypeError(
"rectification packet requires two time-linked scan samples inside the selected candidate range",
@@ -238,7 +281,7 @@ export function buildRectificationTechnicalPacket(input: PacketInput): Rectifica
const sensitiveLayers = layers.filter((item) => item.layer !== "D1"
&& item.values.length > 1
&& available.has(item.layer));
const domains = suggestedDomains(sensitiveLayers);
const domains = suggestedDomains(sensitiveLayers, selectedSamples);
if (domains.length < 2) {
throw new TypeError(
"rectification packet requires two time-linked discriminating domains inside the selected candidate range",
@@ -7,7 +7,13 @@ type OnboardingProfileInput = {
readonly name: string | null;
readonly birthDate: string | null;
readonly birthTime: string | null;
readonly reportedBirthTime?: string | null;
readonly activeBirthTime: string | null;
readonly birthTimeSource?: string | null;
readonly birthTimePeriod?: string | null;
readonly birthTimeClue?: string | null;
readonly uncertaintyBeforeMinutes?: number | null;
readonly uncertaintyAfterMinutes?: number | null;
readonly birthTimeStatus: string | null;
readonly countryCode: string | null;
readonly provinceCode: string | null;
@@ -44,7 +50,13 @@ export function createOnboardingCacheIdentity(
profile.name,
profile.birthDate,
profile.birthTime,
profile.reportedBirthTime,
profile.activeBirthTime,
profile.birthTimeSource,
profile.birthTimePeriod,
profile.birthTimeClue,
profile.uncertaintyBeforeMinutes,
profile.uncertaintyAfterMinutes,
profile.birthTimeStatus,
profile.countryCode,
profile.provinceCode,
+1 -1
View File
@@ -41,7 +41,7 @@ const onboardingResponseSchema = z.object({
});
const defaultPolicy = {
requestTimeoutMs: 12_000,
requestTimeoutMs: 25_000,
retryDelayMs: 4_000,
maxAttempts: 3,
} as const;
+67 -11
View File
@@ -8,13 +8,24 @@ import {
parseOnboardingPayload,
parseOnboardingText,
} from "./onboarding-payload.ts";
import {
isDeclaredBirthProfileComplete,
type BirthTimeSource,
type BirthTimeStatus,
} from "./birth-time-intake-model.ts";
export type OnboardingProfileRow = {
readonly id: string;
readonly name: string | null;
readonly birth_date: string | null;
readonly birth_time: string | null;
readonly reported_birth_time: string | null;
readonly active_birth_time: string | null;
readonly birth_time_source: string | null;
readonly birth_time_period: string | null;
readonly birth_time_clue: string | null;
readonly uncertainty_before_minutes: number | null;
readonly uncertainty_after_minutes: number | null;
readonly birth_time_status: string | null;
readonly country_code: string | null;
readonly province_code: string | null;
@@ -60,23 +71,49 @@ type OnboardingSession = {
type OnboardingPostDependencies = {
readonly openSession: () => Promise<OnboardingSession>;
readonly generateText: (name: string) => Promise<string | null>;
readonly generateText: (name: string, signal: AbortSignal) => Promise<string | null>;
readonly generationTimeoutMs?: number;
readonly now: () => Date;
readonly warn: (message: string, detail: string) => void;
};
const DEFAULT_GENERATION_TIMEOUT_MS = 18_000;
function hasCompleteBirthProfile(profile: OnboardingProfileRow): boolean {
return Boolean(
profile.name
&& profile.birth_date
&& (profile.active_birth_time || profile.birth_time)
&& (profile.birth_time_status === "confirmed"
|| profile.birth_time_status === "candidate"
|| (!profile.birth_time_status && profile.birth_time))
const persistedTime = profile.active_birth_time || profile.birth_time || "";
const knownSources: readonly BirthTimeSource[] = [
"hospital_record", "family_exact", "approximate", "period_only", "unknown", "legacy_import",
];
const source = knownSources.find((item) => item === profile.birth_time_source)
?? (persistedTime ? "legacy_import" : "");
const knownStatuses: readonly BirthTimeStatus[] = [
"reported", "assessing", "rectifying", "candidate", "confirmed",
];
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (persistedTime ? "confirmed" : "");
const clock = (value: string | null) => value ? value.slice(0, 5) : "";
return Boolean(profile.name
&& profile.country_code
&& profile.province_code
&& profile.city_code,
);
&& profile.city_code
&& isDeclaredBirthProfileComplete({
date: profile.birth_date ?? "",
time: clock(persistedTime),
reportedTime: clock(profile.reported_birth_time)
|| (source === "legacy_import" ? clock(persistedTime) : ""),
birthTimeSource: source,
birthTimePeriod: profile.birth_time_period === "early_morning"
|| profile.birth_time_period === "morning"
|| profile.birth_time_period === "afternoon"
|| profile.birth_time_period === "evening"
|| profile.birth_time_period === "late_night"
? profile.birth_time_period
: "",
birthTimeClue: profile.birth_time_clue ?? "",
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
birthTimeStatus: status,
}));
}
export function createOnboardingPost(dependencies: OnboardingPostDependencies): () => Promise<Response> {
@@ -117,7 +154,13 @@ export function createOnboardingPost(dependencies: OnboardingPostDependencies):
name: profile.name,
birthDate: profile.birth_date,
birthTime: profile.birth_time,
reportedBirthTime: profile.reported_birth_time,
activeBirthTime: profile.active_birth_time,
birthTimeSource: profile.birth_time_source,
birthTimePeriod: profile.birth_time_period,
birthTimeClue: profile.birth_time_clue,
uncertaintyBeforeMinutes: profile.uncertainty_before_minutes,
uncertaintyAfterMinutes: profile.uncertainty_after_minutes,
birthTimeStatus: profile.birth_time_status,
countryCode: profile.country_code,
provinceCode: profile.province_code,
@@ -166,8 +209,19 @@ export function createOnboardingPost(dependencies: OnboardingPostDependencies):
let payload = fallbackOnboardingPayload;
let source: "agent" | "fallback" = "fallback";
const generationController = new AbortController();
let generationTimer: ReturnType<typeof setTimeout> | undefined;
try {
const text = await dependencies.generateText(profile.name ?? "");
const timeout = new Promise<null>((resolve) => {
generationTimer = setTimeout(() => {
resolve(null);
generationController.abort(new DOMException("Onboarding generation timed out", "TimeoutError"));
}, dependencies.generationTimeoutMs ?? DEFAULT_GENERATION_TIMEOUT_MS);
});
const text = await Promise.race([
dependencies.generateText(profile.name ?? "", generationController.signal),
timeout,
]);
const parsed = text === null ? null : parseOnboardingText(text);
if (parsed) {
payload = parsed;
@@ -178,6 +232,8 @@ export function createOnboardingPost(dependencies: OnboardingPostDependencies):
"[onboarding] agent generation failed; using safe fallback",
error instanceof Error ? error.message : "unknown error",
);
} finally {
if (generationTimer !== undefined) clearTimeout(generationTimer);
}
const completed = await session.repository.completeProfile({