297 lines
14 KiB
TypeScript
297 lines
14 KiB
TypeScript
import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts";
|
|
import { createJourneyTurnActions } from "./birth-time-journey-actions.ts";
|
|
import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts";
|
|
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
|
|
import type { CandidateVargaSample } from "./birth-time-question-planner.ts";
|
|
import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
|
|
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
|
|
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
|
|
import type { DynamicScoringJobIdentity, DynamicScoringJobSpec, ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts";
|
|
import { createBirthTimeScoringService } from "./birth-time-scoring-service.ts";
|
|
import { scanAssessment } from "./birth-time-journey-assessment.ts";
|
|
import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts";
|
|
import { createGuidedCandidateActions } from "./birth-time-guided-candidate.ts";
|
|
import { createGuidedDraftRevisionActions } from "./birth-time-guided-draft-revision.ts";
|
|
import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts";
|
|
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
|
|
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
|
|
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
|
|
import { createDynamicJourneyMethods } from "./birth-time-dynamic-service-methods.ts";
|
|
import { createDynamicCandidateConfirmation } from "./birth-time-dynamic-candidate-confirmation.ts";
|
|
|
|
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
|
|
|
|
export type RectificationAnswer = "A" | "B" | "C" | "D";
|
|
|
|
export type RectificationQuestion = {
|
|
readonly id: string;
|
|
readonly prompt: string;
|
|
readonly round?: number;
|
|
readonly options?: readonly {
|
|
readonly key: RectificationAnswer;
|
|
readonly label: string;
|
|
}[];
|
|
};
|
|
|
|
export type RectificationQuestionnaire = {
|
|
readonly questions: readonly RectificationQuestion[];
|
|
readonly samples: readonly (CandidateVargaSample & {
|
|
readonly ascendantSign: string | null;
|
|
})[];
|
|
readonly raw: Readonly<Record<string, unknown>>;
|
|
};
|
|
|
|
export type RectificationScoringResult = RectificationScoring & {
|
|
readonly nextRound: number | null;
|
|
readonly nextRoundQuestions: readonly RectificationQuestion[];
|
|
readonly raw: Readonly<Record<string, unknown>>;
|
|
};
|
|
|
|
export type JourneyScanInput = {
|
|
readonly birthTime: string;
|
|
readonly uncertaintyMinutes: number;
|
|
readonly lat: number;
|
|
readonly lon: number;
|
|
readonly tz: number;
|
|
readonly ayanamsa: "lahiri";
|
|
};
|
|
|
|
export type JourneyScoreInput = {
|
|
readonly questionnaire: RectificationQuestionnaire;
|
|
readonly answers: Readonly<Record<string, RectificationAnswer>>;
|
|
};
|
|
|
|
export type JourneyEventScoreInput = {
|
|
readonly birthDate: string;
|
|
readonly startTime: string;
|
|
readonly endTime: string;
|
|
readonly lat: number;
|
|
readonly lon: number;
|
|
readonly tz: number;
|
|
readonly events: readonly LifeEvent[];
|
|
};
|
|
|
|
export type DifferencePacketInput = {
|
|
readonly caseId: string;
|
|
readonly asOfDate: string;
|
|
readonly birthDate: string;
|
|
readonly startTime: string;
|
|
readonly endTime: string;
|
|
readonly lat: number;
|
|
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[];
|
|
readonly recentRanges: readonly TimeRange[];
|
|
readonly candidateModel: Readonly<Record<string, unknown>> | null;
|
|
};
|
|
|
|
export type DynamicChoiceScoreInput = Pick<DifferencePacketInput, "birthDate" | "startTime" | "endTime" | "lat" | "lon" | "tz" | "evidence">;
|
|
|
|
export interface BirthTimeJourneyEngine {
|
|
scan(input: JourneyScanInput): Promise<{ readonly questionnaire: RectificationQuestionnaire }>;
|
|
score(input: JourneyScoreInput): Promise<RectificationScoringResult>;
|
|
scoreEvents(input: JourneyEventScoreInput): Promise<CandidateResult>;
|
|
buildDifferencePacket(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
|
|
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
|
|
}
|
|
|
|
export type LegacyBirthTimeJourneyEngine = Pick<BirthTimeJourneyEngine, "scan" | "score" | "scoreEvents">;
|
|
|
|
export type PersistedJourneyAssessment = {
|
|
readonly userId: string;
|
|
readonly assessment: BirthTimeAssessment;
|
|
readonly snapshot: JourneySnapshot;
|
|
readonly questionnaire: RectificationQuestionnaire | null;
|
|
readonly candidateScan: RectificationQuestionnaire | null;
|
|
};
|
|
|
|
type StoredRectificationCaseBase = {
|
|
readonly id: string;
|
|
readonly userId: string;
|
|
readonly snapshot: JourneySnapshot;
|
|
readonly questionnaire: RectificationQuestionnaire | null;
|
|
readonly answers: Readonly<Record<string, RectificationAnswer>>;
|
|
readonly scoring?: RectificationScoringResult;
|
|
readonly eventContext?: {
|
|
readonly birthDate: string;
|
|
readonly lat: number;
|
|
readonly lon: number;
|
|
readonly tz: number;
|
|
};
|
|
readonly lifeEvents?: readonly LifeEvent[];
|
|
readonly candidateResult?: CandidateResult | null;
|
|
};
|
|
|
|
export type LegacyStoredRectificationCase = StoredRectificationCaseBase & LegacyStoredFields;
|
|
|
|
export type DynamicStoredRectificationCase = StoredRectificationCaseBase & DynamicStoredFields;
|
|
|
|
export type StoredRectificationCase = LegacyStoredRectificationCase | DynamicStoredRectificationCase;
|
|
|
|
export type DynamicScoringJobCommand = {
|
|
readonly expectedVersion: number;
|
|
readonly jobId: string;
|
|
readonly evidenceFingerprint: string;
|
|
readonly algorithmVersion: string;
|
|
};
|
|
|
|
export type DynamicScoringJobFailureCommand = DynamicScoringJobCommand & { readonly failureCode: string };
|
|
|
|
export type DynamicCandidateConfirmationCommand = {
|
|
readonly userId: string;
|
|
readonly caseId: string;
|
|
readonly actionId: string;
|
|
readonly expectedVersion: number;
|
|
readonly resultId: string;
|
|
readonly time: string;
|
|
};
|
|
|
|
export interface BirthTimeJourneyStore {
|
|
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
|
|
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
|
|
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
|
|
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
|
|
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
|
|
confirmDynamicCandidate(value: DynamicStoredRectificationCase, command: DynamicCandidateConfirmationCommand): Promise<DynamicStoredRectificationCase>;
|
|
completeDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobCommand): Promise<DynamicStoredRectificationCase>;
|
|
failDynamicScoringJob(value: DynamicStoredRectificationCase, command: DynamicScoringJobFailureCommand): Promise<DynamicStoredRectificationCase>;
|
|
createDynamicScoringJob?(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string, questionId: string, job: DynamicScoringJobSpec): Promise<DynamicStoredRectificationCase>;
|
|
claimDynamicScoringJob?(identity: DynamicScoringJobIdentity): Promise<ScoringJobClaim>;
|
|
upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise<StoredRectificationCase>;
|
|
createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
|
|
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
|
|
completeScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
|
|
failScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise<StoredRectificationCase>;
|
|
saveCandidateResult(value: LegacyStoredRectificationCase): Promise<void>;
|
|
saveCandidate(value: LegacyStoredRectificationCase): Promise<void>;
|
|
confirmCandidate(value: LegacyStoredRectificationCase): Promise<void>;
|
|
commitGuidedCandidate(value: LegacyStoredRectificationCase, command: GuidedCandidateCommit): Promise<StoredRectificationCase>;
|
|
}
|
|
|
|
export type BirthTimeJourneyPorts = {
|
|
readonly store: BirthTimeJourneyStore;
|
|
readonly engine: BirthTimeJourneyEngine | LegacyBirthTimeJourneyEngine;
|
|
readonly now?: () => Date;
|
|
};
|
|
|
|
export type JourneyResponseBase = {
|
|
readonly caseId: string;
|
|
readonly snapshot: JourneySnapshot;
|
|
readonly questionnaire: RectificationQuestionnaire | null;
|
|
readonly scoring: RectificationScoringResult | null;
|
|
readonly answers: Readonly<Record<string, RectificationAnswer>>;
|
|
readonly lifeEvents: readonly LifeEvent[];
|
|
readonly candidateResult: CandidateResult | null;
|
|
};
|
|
|
|
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState
|
|
& { readonly journeyProtocol?: undefined };
|
|
|
|
export type DynamicVersionedJourneyResponse = JourneyResponseBase & DynamicJourneyTurnState
|
|
& { readonly evidenceDraft: null };
|
|
|
|
export type LegacyJourneyResponse = JourneyResponseBase & { readonly turnVersion?: undefined; readonly nextAction?: undefined; readonly progress?: undefined; readonly permissions?: undefined; readonly evidenceDraft?: undefined; };
|
|
|
|
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse | DynamicVersionedJourneyResponse;
|
|
|
|
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
|
|
const evidenceActions = createBirthTimeEvidenceActions(ports);
|
|
const turnActions = createJourneyTurnActions(ports);
|
|
const scoringActions = createBirthTimeScoringService(ports);
|
|
const guidedCandidates = createGuidedCandidateActions(ports);
|
|
const draftRevisions = createGuidedDraftRevisionActions(ports, turnActions.proposeEvidenceDraft);
|
|
const dynamicMethods = createDynamicJourneyMethods(ports);
|
|
const dynamicCandidates = createDynamicCandidateConfirmation(ports);
|
|
return {
|
|
async assess(userId: string, assessment: BirthTimeAssessment): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
|
|
const scan = await scanAssessment(ports.engine, assessment);
|
|
const snapshot = assessBirthTime(assessment, scan.stability);
|
|
const persisted = {
|
|
userId,
|
|
assessment,
|
|
snapshot,
|
|
questionnaire: scan.questionnaire,
|
|
candidateScan: scan.questionnaire,
|
|
} satisfies PersistedJourneyAssessment;
|
|
const caseId = await ports.store.saveAssessment(persisted);
|
|
const stored = await ports.store.loadCase(userId, caseId);
|
|
if (stored?.journeyProtocol === "dynamic-choice-v2") return storedDynamicJourneyResponse(stored);
|
|
return projectJourneyResponse({
|
|
caseId,
|
|
snapshot,
|
|
questionnaire: scan.questionnaire,
|
|
scoring: null,
|
|
answers: {},
|
|
lifeEvents: [],
|
|
candidateResult: null,
|
|
}, 0);
|
|
},
|
|
|
|
async resume(userId: string, caseId: string): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
|
|
const stored = await ports.store.loadCase(userId, caseId);
|
|
if (!stored) throw new RectificationCaseNotFoundError(caseId);
|
|
if (stored.journeyProtocol === "dynamic-choice-v2") {
|
|
return storedDynamicJourneyResponse(stored);
|
|
}
|
|
const upgraded = await ports.store.upgradeLegacyActiveCase(stored);
|
|
if (upgraded.journeyProtocol === "dynamic-choice-v2") {
|
|
return storedDynamicJourneyResponse(upgraded);
|
|
}
|
|
const completedLegacyQuestionnaire = stored.snapshot.input === "rectification_questions"
|
|
&& stored.scoring?.nextRound === null
|
|
&& stored.scoring.nextRoundQuestions.length === 0
|
|
&& stored.scoring.answeredCount > 0;
|
|
const snapshot = completedLegacyQuestionnaire
|
|
? withRectificationScoring(stored.snapshot, stored.scoring)
|
|
: stored.snapshot;
|
|
const normalized = snapshot === stored.snapshot ? stored : { ...stored, snapshot };
|
|
if (normalized !== stored) await ports.store.saveScoring(normalized);
|
|
return storedJourneyResponse(normalized);
|
|
},
|
|
|
|
async answerQuestion(
|
|
userId: string,
|
|
caseId: string,
|
|
questionId: string,
|
|
answer: RectificationAnswer,
|
|
): Promise<VersionedJourneyResponse> {
|
|
const stored = await ports.store.loadCase(userId, caseId);
|
|
if (!stored) throw new RectificationCaseNotFoundError(caseId);
|
|
assertLegacyJourneyMutation(stored);
|
|
if (!stored.questionnaire) throw new RectificationQuestionsUnavailableError();
|
|
const answers = { ...stored.answers, [questionId]: answer };
|
|
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
|
|
const snapshot = withRectificationScoring(stored.snapshot, scoring);
|
|
const updated = { ...stored, answers, scoring, snapshot } satisfies LegacyStoredRectificationCase;
|
|
await ports.store.saveScoring(updated);
|
|
return projectJourneyResponse({
|
|
caseId,
|
|
snapshot,
|
|
questionnaire: stored.questionnaire,
|
|
scoring,
|
|
answers,
|
|
lifeEvents: stored.lifeEvents ?? [],
|
|
candidateResult: stored.candidateResult ?? null,
|
|
}, stored.turnVersion ?? 0, stored.persistedProgress);
|
|
},
|
|
async submitLifeEvents(...args: Parameters<typeof evidenceActions.submitLifeEvents>) { return projectJourneyResponse(await evidenceActions.submitLifeEvents(...args), 0); },
|
|
async saveCandidate(...args: Parameters<typeof evidenceActions.saveCandidate>) { return projectJourneyResponse(await evidenceActions.saveCandidate(...args), 0); },
|
|
async confirmCandidate(...args: Parameters<typeof evidenceActions.confirmCandidate>) { return projectJourneyResponse(await evidenceActions.confirmCandidate(...args), 0); },
|
|
proposeEvidenceDraft: turnActions.proposeEvidenceDraft,
|
|
confirmEvidenceDraft: turnActions.confirmEvidenceDraft,
|
|
skipEvidenceQuestion: turnActions.skipEvidenceQuestion,
|
|
pause: turnActions.pause,
|
|
finishWithCurrentRange: turnActions.finishWithCurrentRange,
|
|
reviseEvidenceDraft: draftRevisions.revise,
|
|
saveGuidedCandidate: guidedCandidates.save,
|
|
confirmGuidedCandidate: guidedCandidates.confirm,
|
|
confirmDynamicCandidate: dynamicCandidates.confirm,
|
|
pollScoringJob: scoringActions.pollScoringJob,
|
|
...dynamicMethods,
|
|
};
|
|
}
|