feat: add click-first birth time questions
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
PublicDynamicChoiceQuestion,
|
||||
TimeRange,
|
||||
} from "./birth-time-dynamic-choice.ts";
|
||||
|
||||
type ChoiceOption = PublicDynamicChoiceQuestion["options"][number];
|
||||
|
||||
export type ChoiceQuestionGroups = {
|
||||
readonly primary: readonly ChoiceOption[];
|
||||
readonly unknown: ChoiceOption;
|
||||
readonly unmatched: ChoiceOption;
|
||||
};
|
||||
|
||||
export type ChoiceSelectionIntent = {
|
||||
readonly kind: "submit";
|
||||
readonly optionId: string;
|
||||
readonly effective: boolean;
|
||||
};
|
||||
|
||||
export class ChoiceQuestionModelError extends Error {
|
||||
readonly name = "ChoiceQuestionModelError";
|
||||
}
|
||||
|
||||
export function choiceQuestionGroups(
|
||||
question: PublicDynamicChoiceQuestion,
|
||||
): ChoiceQuestionGroups {
|
||||
const primary = question.options.filter((option) => option.kind === "primary");
|
||||
const unknown = question.options.find((option) => option.kind === "unknown");
|
||||
const unmatched = question.options.find((option) => option.kind === "unmatched");
|
||||
if (!unknown || !unmatched) {
|
||||
throw new ChoiceQuestionModelError("Dynamic question is missing a reserved choice");
|
||||
}
|
||||
return { primary, unknown, unmatched };
|
||||
}
|
||||
|
||||
export function choiceSelectionIntent(option: ChoiceOption): ChoiceSelectionIntent {
|
||||
return {
|
||||
kind: "submit",
|
||||
optionId: option.optionId,
|
||||
effective: option.kind === "primary",
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeUnmatchedNote(note: string): string {
|
||||
return Array.from(note.trim()).slice(0, 240).join("");
|
||||
}
|
||||
|
||||
export function rangeLabel(range: TimeRange): string {
|
||||
return `${range.startTime}—${range.endTime}`;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { parseJourneyResponse } from "./birth-time-journey-client.ts";
|
||||
import type { DynamicJourneyClientResponse } from "./birth-time-journey-response-schema.ts";
|
||||
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
|
||||
import type { CandidateResult } from "./birth-time-evidence.ts";
|
||||
|
||||
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
|
||||
const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7";
|
||||
const jobId = "5da741ba-4713-4c27-9cf4-a85e52dc4658";
|
||||
|
||||
class DynamicPreviewInvariantError extends Error {
|
||||
readonly name = "DynamicPreviewInvariantError";
|
||||
}
|
||||
|
||||
const questions = [
|
||||
{
|
||||
questionId: "education-shift",
|
||||
prompt: "哪段时间更接近一次明显的升学、转学或学习方向变化?",
|
||||
options: [
|
||||
{ optionId: "earlier", label: "更接近 2007—2009 年", kind: "primary" },
|
||||
{ optionId: "later", label: "更接近 2010—2012 年", kind: "primary" },
|
||||
{ optionId: "unknown", label: "不记得", kind: "unknown" },
|
||||
{ optionId: "unmatched", label: "都不符合", kind: "unmatched" },
|
||||
],
|
||||
},
|
||||
{
|
||||
questionId: "relocation-shift",
|
||||
prompt: "第一次明显离开熟悉环境,更接近下面哪个阶段?",
|
||||
options: [
|
||||
{ optionId: "school", label: "求学阶段", kind: "primary" },
|
||||
{ optionId: "work", label: "开始工作之后", kind: "primary" },
|
||||
{ optionId: "unknown-2", label: "不记得", kind: "unknown" },
|
||||
{ optionId: "unmatched-2", label: "都不符合", kind: "unmatched" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
const candidate = {
|
||||
resultId,
|
||||
confidence: "medium",
|
||||
canApply: false,
|
||||
winningSegment: {
|
||||
startTime: "05:38",
|
||||
endTime: "05:49",
|
||||
representativeTime: "05:43",
|
||||
widthMinutes: 12,
|
||||
},
|
||||
eventCount: 3,
|
||||
domainCount: 0,
|
||||
topScore: 14.4,
|
||||
secondScore: 12.1,
|
||||
marginPercent: 15.97,
|
||||
reasons: [],
|
||||
evidence: [],
|
||||
algorithmVersion: "birth-time-dynamic-scoring-v2",
|
||||
} satisfies CandidateResult;
|
||||
|
||||
const highCandidate = {
|
||||
...candidate,
|
||||
confidence: "high",
|
||||
canApply: true,
|
||||
winningSegment: { ...candidate.winningSegment, widthMinutes: 5 },
|
||||
eventCount: 4,
|
||||
domainCount: 3,
|
||||
marginPercent: 24,
|
||||
} satisfies CandidateResult;
|
||||
|
||||
function response(input: {
|
||||
readonly nextAction: DynamicNextAction;
|
||||
readonly phase: "question" | "clarification" | "scoring" | "result" | "ready" | "paused";
|
||||
readonly turnVersion?: number;
|
||||
readonly answeredCount?: number;
|
||||
readonly effectiveAnswerCount?: number;
|
||||
readonly candidateResult?: CandidateResult | null;
|
||||
readonly snapshotState?: "rectifying" | "candidate" | "confirming" | "ready";
|
||||
readonly activeTime?: string | null;
|
||||
}): DynamicJourneyClientResponse {
|
||||
const parsed = parseJourneyResponse({
|
||||
caseId,
|
||||
snapshot: {
|
||||
state: input.snapshotState ?? "rectifying",
|
||||
assistantIntent: "start_standard_rectification",
|
||||
input: input.phase === "ready" ? "none" : input.phase === "result" ? "candidate_actions" : "rectification_questions",
|
||||
route: input.phase === "ready" ? "direct_chart" : "rectification",
|
||||
confidence: input.candidateResult?.confidence ?? null,
|
||||
canApply: input.nextAction.kind === "request_candidate_confirmation",
|
||||
activeTime: input.activeTime ?? null,
|
||||
reportedRange: { label: "04:00—07:59", startTime: "04:00", endTime: "07:59" },
|
||||
},
|
||||
questionnaire: null,
|
||||
scoring: null,
|
||||
answers: {},
|
||||
lifeEvents: [],
|
||||
candidateResult: input.candidateResult ?? null,
|
||||
journeyProtocol: "dynamic-choice-v2",
|
||||
turnVersion: input.turnVersion ?? 1,
|
||||
nextAction: input.nextAction,
|
||||
progress: {
|
||||
phase: input.phase,
|
||||
answeredCount: input.answeredCount ?? 0,
|
||||
effectiveAnswerCount: input.effectiveAnswerCount ?? 0,
|
||||
currentRange: { startTime: "04:00", endTime: "07:59" },
|
||||
previousRange: null,
|
||||
plateauCount: 0,
|
||||
},
|
||||
permissions: { canConfirmCandidate: input.nextAction.kind === "request_candidate_confirmation" },
|
||||
evidenceDraft: null,
|
||||
});
|
||||
if (parsed.journeyProtocol !== "dynamic-choice-v2") {
|
||||
throw new DynamicPreviewInvariantError("Dynamic preview parsed as legacy journey");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function dynamicBirthTimePreview(
|
||||
state: "generating" | "question-retry" | "question" | "clarification" | "scoring" | "scoring-retry" | "low" | "medium" | "confirmation" | "ready" | "paused" = "question",
|
||||
): DynamicJourneyClientResponse {
|
||||
switch (state) {
|
||||
case "generating": return response({ nextAction: { kind: "generate_dynamic_question" }, phase: "question" });
|
||||
case "question-retry": return response({ nextAction: { kind: "retry_question_generation" }, phase: "question" });
|
||||
case "question": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[0] }, phase: "question" });
|
||||
case "clarification": return response({ nextAction: { kind: "clarify_unmatched_answer", questionId: questions[0].questionId }, phase: "clarification", answeredCount: 1, turnVersion: 2 });
|
||||
case "scoring": return response({ nextAction: { kind: "score_pending", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 });
|
||||
case "scoring-retry": return response({ nextAction: { kind: "retry_scoring", jobId }, phase: "scoring", answeredCount: 1, effectiveAnswerCount: 1, turnVersion: 2 });
|
||||
case "low": return response({ nextAction: { kind: "present_low_result", resultId: null }, phase: "result", answeredCount: 2, effectiveAnswerCount: 1, snapshotState: "candidate", turnVersion: 3 });
|
||||
case "medium": return response({ nextAction: { kind: "present_medium_result", resultId }, phase: "result", answeredCount: 3, effectiveAnswerCount: 2, candidateResult: candidate, snapshotState: "candidate", turnVersion: 4 });
|
||||
case "confirmation": return response({ nextAction: { kind: "request_candidate_confirmation", resultId }, phase: "result", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "confirming", turnVersion: 5 });
|
||||
case "ready": return response({ nextAction: { kind: "ready", activeTime: "05:43" }, phase: "ready", answeredCount: 4, effectiveAnswerCount: 4, candidateResult: highCandidate, snapshotState: "ready", activeTime: "05:43", turnVersion: 6 });
|
||||
case "paused": return response({ nextAction: { kind: "paused" }, phase: "paused", turnVersion: 2 });
|
||||
}
|
||||
}
|
||||
|
||||
export type DynamicPreviewCommand =
|
||||
| { readonly kind: "select"; readonly optionId: string }
|
||||
| { readonly kind: "reframe" }
|
||||
| { readonly kind: "finish" }
|
||||
| { readonly kind: "pause" }
|
||||
| { readonly kind: "resume" }
|
||||
| { readonly kind: "retry_scoring" };
|
||||
|
||||
export function advanceDynamicBirthTimePreview(
|
||||
turn: DynamicJourneyClientResponse,
|
||||
command: DynamicPreviewCommand,
|
||||
): DynamicJourneyClientResponse {
|
||||
switch (command.kind) {
|
||||
case "select": {
|
||||
const action = turn.nextAction;
|
||||
if (action.kind !== "ask_dynamic_choice") return turn;
|
||||
const option = action.question.options.find((item) => item.optionId === command.optionId);
|
||||
if (!option) return turn;
|
||||
switch (option.kind) {
|
||||
case "unmatched": return dynamicBirthTimePreview("clarification");
|
||||
case "primary": return dynamicBirthTimePreview("scoring");
|
||||
case "unknown": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 2 });
|
||||
}
|
||||
}
|
||||
case "reframe": return response({ nextAction: { kind: "ask_dynamic_choice", question: questions[1] }, phase: "question", answeredCount: 1, turnVersion: 3 });
|
||||
case "finish": return dynamicBirthTimePreview("low");
|
||||
case "pause": return dynamicBirthTimePreview("paused");
|
||||
case "resume": return dynamicBirthTimePreview("question");
|
||||
case "retry_scoring": return dynamicBirthTimePreview("medium");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { parseJourneyResponse } from "./birth-time-journey-client.ts";
|
||||
import type { JourneyClientResponse } from "./birth-time-journey-client.ts";
|
||||
import { dynamicBirthTimePreview } from "./birth-time-dynamic-preview.ts";
|
||||
|
||||
const caseId = "7299894c-10a8-4b45-91d1-339007282c50";
|
||||
const resultId = "345087cc-7e7f-4b37-90e5-f0c0a6e5b7a7";
|
||||
@@ -89,6 +90,7 @@ export function isGuidedBirthTimePreview(mode: string): boolean {
|
||||
}
|
||||
|
||||
export function guidedBirthTimePreview(mode: string): JourneyClientResponse {
|
||||
if (mode === "birth-time-rectification") return dynamicBirthTimePreview();
|
||||
if (mode === "birth-time-rectification-draft" || mode === "birth-time-rectification-events") {
|
||||
return response({
|
||||
snapshot: { ...response().snapshot, input: "life_events", assistantIntent: "collect_dated_life_events" },
|
||||
|
||||
Reference in New Issue
Block a user