feat: complete verifiable birth-time rectification flow
This commit is contained in:
@@ -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 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -41,7 +41,7 @@ const onboardingResponseSchema = z.object({
|
||||
});
|
||||
|
||||
const defaultPolicy = {
|
||||
requestTimeoutMs: 12_000,
|
||||
requestTimeoutMs: 25_000,
|
||||
retryDelayMs: 4_000,
|
||||
maxAttempts: 3,
|
||||
} as const;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user