feat: connect dynamic rectification engine
This commit is contained in:
@@ -93,7 +93,7 @@ export const candidateResultSchema = z.object({
|
||||
representativeTime: timeSchema,
|
||||
widthMinutes: z.number().int().min(1).max(1_440),
|
||||
}).strict().readonly().nullable(),
|
||||
eventCount: z.number().int().min(0).max(6),
|
||||
eventCount: z.number().int().min(0).max(10),
|
||||
domainCount: z.number().int().min(0).max(5),
|
||||
topScore: z.number(),
|
||||
secondScore: z.number(),
|
||||
@@ -107,7 +107,7 @@ export const candidateResultSchema = z.object({
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["eventCount"],
|
||||
message: "high candidates require at least four events",
|
||||
message: "high candidates require at least four effective evidence items",
|
||||
});
|
||||
}
|
||||
if (value.confidence === "high" && value.domainCount < 3) {
|
||||
|
||||
@@ -5,6 +5,14 @@ import {
|
||||
type BirthTimeAssessment,
|
||||
} from "./birth-time-journey.ts";
|
||||
import type { CandidateResult } from "./birth-time-evidence.ts";
|
||||
import {
|
||||
candidateDifferenceBuildSchema,
|
||||
dynamicChoiceScoringResultSchema,
|
||||
} from "./birth-time-dynamic-choice-internal.ts";
|
||||
import type {
|
||||
CandidateDifferenceBuild,
|
||||
DynamicChoiceScoringResult,
|
||||
} from "./birth-time-dynamic-choice-internal.ts";
|
||||
import type {
|
||||
RectificationAnswer,
|
||||
RectificationQuestion,
|
||||
@@ -83,7 +91,15 @@ const eventDomainSchema = z.enum([
|
||||
"career",
|
||||
"health_pressure",
|
||||
]);
|
||||
const candidateResultApiSchema = z.object({
|
||||
const candidateEvidenceApiSchema = z.object({
|
||||
event_id: z.string().uuid(),
|
||||
domain: eventDomainSchema,
|
||||
candidate_time: z.string(),
|
||||
rule_ids: z.array(z.string()),
|
||||
points: z.number(),
|
||||
}).strict();
|
||||
|
||||
const candidateResultApiFields = {
|
||||
result_id: z.string().uuid(),
|
||||
confidence: z.enum(["low", "medium", "high"]),
|
||||
can_apply: z.boolean(),
|
||||
@@ -99,15 +115,129 @@ const candidateResultApiSchema = z.object({
|
||||
second_score: z.number(),
|
||||
margin_percent: z.number(),
|
||||
reasons: z.array(z.string()),
|
||||
evidence: z.array(z.object({
|
||||
event_id: z.string().uuid(),
|
||||
domain: eventDomainSchema,
|
||||
candidate_time: z.string(),
|
||||
rule_ids: z.array(z.string()),
|
||||
points: z.number(),
|
||||
})),
|
||||
evidence: z.array(candidateEvidenceApiSchema),
|
||||
algorithm_version: z.string(),
|
||||
}).passthrough();
|
||||
} as const;
|
||||
const candidateResultApiSchema = z.object(candidateResultApiFields).passthrough();
|
||||
|
||||
const apiCandidateTimeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/);
|
||||
const apiTimeRangeSchema = z.object({
|
||||
start_time: apiCandidateTimeSchema,
|
||||
end_time: apiCandidateTimeSchema,
|
||||
}).strict();
|
||||
|
||||
const scoredPartitionApiSchema = z.object({
|
||||
partition_id: z.string().trim().min(1),
|
||||
descriptor: z.string().trim().min(1),
|
||||
fallback_label: z.string().trim().min(1).max(80),
|
||||
candidate_scores: z.record(apiCandidateTimeSchema, z.number().finite().nonnegative()),
|
||||
}).strict();
|
||||
|
||||
function candidateTimes(startTime: string, endTime: string): Set<string> {
|
||||
const toMinute = (value: string) => {
|
||||
const [hour, minute] = value.split(":").map(Number);
|
||||
return hour * 60 + minute;
|
||||
};
|
||||
const toTime = (value: number) => `${String(Math.floor(value / 60)).padStart(2, "0")}:${String(value % 60).padStart(2, "0")}`;
|
||||
const end = toMinute(endTime);
|
||||
let current = toMinute(startTime);
|
||||
const result = new Set<string>([toTime(current)]);
|
||||
while (current !== end) {
|
||||
current = (current + 1) % 1_440;
|
||||
result.add(toTime(current));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const opportunityApiSchema = z.object({
|
||||
opportunity_id: z.string().trim().min(1),
|
||||
dimension_code: z.string().trim().min(1),
|
||||
neutral_context: z.string().trim().min(1),
|
||||
estimated_information_gain: z.number().finite().nonnegative(),
|
||||
candidate_partition_fingerprint: z.string().trim().min(1),
|
||||
fallback_prompt: z.string().trim().min(1).max(240),
|
||||
partitions: z.array(scoredPartitionApiSchema).min(2).max(4),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (new Set(value.partitions.map((partition) => partition.partition_id)).size !== value.partitions.length) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["partitions"],
|
||||
message: "opportunity partition ids must be unique",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const candidateDifferenceApiSchema = z.object({
|
||||
success: z.literal(true),
|
||||
endpoint: z.literal("dynamic_rectification_opportunities"),
|
||||
case_id: z.string().trim().min(1),
|
||||
scoring_version: z.literal("birth-time-choice-scoring-v2"),
|
||||
current_range: apiTimeRangeSchema,
|
||||
opportunities: z.array(opportunityApiSchema),
|
||||
asked_question_fingerprints: z.array(z.string().trim().min(1)),
|
||||
candidate_partition_fingerprints: z.array(z.string().trim().min(1)),
|
||||
recent_range_history: z.array(apiTimeRangeSchema),
|
||||
candidate_model: z.record(z.unknown()),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (new Set(value.opportunities.map((opportunity) => opportunity.opportunity_id)).size !== value.opportunities.length) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["opportunities"],
|
||||
message: "opportunity ids must be unique",
|
||||
});
|
||||
}
|
||||
const expectedCandidates = candidateTimes(
|
||||
value.current_range.start_time,
|
||||
value.current_range.end_time,
|
||||
);
|
||||
value.opportunities.forEach((opportunity, opportunityIndex) => {
|
||||
opportunity.partitions.forEach((partition, partitionIndex) => {
|
||||
const actualCandidates = Object.keys(partition.candidate_scores);
|
||||
if (
|
||||
actualCandidates.length !== expectedCandidates.size
|
||||
|| actualCandidates.some((candidate) => !expectedCandidates.has(candidate))
|
||||
) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["opportunities", opportunityIndex, "partitions", partitionIndex, "candidate_scores"],
|
||||
message: "candidate scores must exactly match the current range",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const dynamicChoiceScoringApiSchema = z.object({
|
||||
success: z.literal(true),
|
||||
endpoint: z.literal("dynamic_rectification_score"),
|
||||
...candidateResultApiFields,
|
||||
algorithm_version: z.literal("birth-time-choice-scoring-v2"),
|
||||
evidence_mode: z.literal("dynamic_choice"),
|
||||
effective_answer_count: z.number().int().min(0).max(10),
|
||||
dimension_count: z.number().int().min(0).max(5),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (value.event_count !== value.effective_answer_count) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["event_count"],
|
||||
message: "event count must equal effective answer count",
|
||||
});
|
||||
}
|
||||
if (value.domain_count !== value.dimension_count) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["domain_count"],
|
||||
message: "domain count must equal dimension count",
|
||||
});
|
||||
}
|
||||
if (value.evidence.length !== 0) {
|
||||
context.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["evidence"],
|
||||
message: "dynamic choice results cannot contain public dated-event evidence",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
class UnexpectedProfileSourceError extends Error {
|
||||
readonly name = "UnexpectedProfileSourceError";
|
||||
@@ -195,8 +325,64 @@ export function parseRectificationAnswer(value: unknown): RectificationAnswer {
|
||||
return z.enum(["A", "B", "C", "D"]).parse(value);
|
||||
}
|
||||
|
||||
export function parseCandidateDifferenceBuild(value: unknown): CandidateDifferenceBuild {
|
||||
const parsed = candidateDifferenceApiSchema.parse(value);
|
||||
return candidateDifferenceBuildSchema.parse({
|
||||
packet: {
|
||||
caseId: parsed.case_id,
|
||||
scoringVersion: parsed.scoring_version,
|
||||
currentRange: {
|
||||
startTime: parsed.current_range.start_time,
|
||||
endTime: parsed.current_range.end_time,
|
||||
},
|
||||
opportunities: parsed.opportunities.map((opportunity) => ({
|
||||
opportunityId: opportunity.opportunity_id,
|
||||
dimensionCode: opportunity.dimension_code,
|
||||
neutralContext: opportunity.neutral_context,
|
||||
estimatedInformationGain: opportunity.estimated_information_gain,
|
||||
candidatePartitionFingerprint: opportunity.candidate_partition_fingerprint,
|
||||
fallbackPrompt: opportunity.fallback_prompt,
|
||||
partitions: opportunity.partitions.map((partition) => ({
|
||||
partitionId: partition.partition_id,
|
||||
descriptor: partition.descriptor,
|
||||
fallbackLabel: partition.fallback_label,
|
||||
})),
|
||||
})),
|
||||
askedQuestionFingerprints: parsed.asked_question_fingerprints,
|
||||
candidatePartitionFingerprints: parsed.candidate_partition_fingerprints,
|
||||
recentRangeHistory: parsed.recent_range_history.map((range) => ({
|
||||
startTime: range.start_time,
|
||||
endTime: range.end_time,
|
||||
})),
|
||||
},
|
||||
candidateModel: parsed.candidate_model,
|
||||
scoringPartitions: Object.fromEntries(parsed.opportunities.map((opportunity) => [
|
||||
opportunity.opportunity_id,
|
||||
opportunity.partitions.map((partition) => ({
|
||||
partitionId: partition.partition_id,
|
||||
descriptor: partition.descriptor,
|
||||
fallbackLabel: partition.fallback_label,
|
||||
candidateScores: partition.candidate_scores,
|
||||
})),
|
||||
])),
|
||||
});
|
||||
}
|
||||
|
||||
export function parseDynamicChoiceScoring(value: unknown): DynamicChoiceScoringResult {
|
||||
const parsed = dynamicChoiceScoringApiSchema.parse(value);
|
||||
return dynamicChoiceScoringResultSchema.parse({
|
||||
candidate: adaptCandidateResult(parsed),
|
||||
evidenceMode: parsed.evidence_mode,
|
||||
effectiveAnswerCount: parsed.effective_answer_count,
|
||||
dimensionCount: parsed.dimension_count,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseCandidateResult(value: unknown): CandidateResult {
|
||||
const parsed = candidateResultApiSchema.parse(value);
|
||||
return adaptCandidateResult(candidateResultApiSchema.parse(value));
|
||||
}
|
||||
|
||||
function adaptCandidateResult(parsed: z.infer<typeof candidateResultApiSchema>): CandidateResult {
|
||||
return candidateResultSchema.parse({
|
||||
resultId: parsed.result_id,
|
||||
confidence: parsed.confidence,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { JourneyEventScoreInput } from "./birth-time-journey-service.ts";
|
||||
import type {
|
||||
DifferencePacketInput,
|
||||
DynamicChoiceScoreInput,
|
||||
JourneyEventScoreInput,
|
||||
} from "./birth-time-journey-service.ts";
|
||||
|
||||
export function eventScorePayload(input: JourneyEventScoreInput) {
|
||||
return {
|
||||
@@ -16,3 +20,48 @@ export function eventScorePayload(input: JourneyEventScoreInput) {
|
||||
})),
|
||||
} as const;
|
||||
}
|
||||
|
||||
function choiceEvidencePayload(input: DifferencePacketInput["evidence"]) {
|
||||
return input.map((item) => ({
|
||||
question_id: item.questionId,
|
||||
opportunity_id: item.opportunityId,
|
||||
partition_id: item.partitionId,
|
||||
dimension_code: item.dimensionCode,
|
||||
candidate_scores: item.candidateScores,
|
||||
information_gain: item.informationGain,
|
||||
}));
|
||||
}
|
||||
|
||||
export function differencePacketPayload(input: DifferencePacketInput) {
|
||||
return {
|
||||
case_id: input.caseId,
|
||||
as_of_date: input.asOfDate,
|
||||
birth_date: input.birthDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
lat: input.lat,
|
||||
lon: input.lon,
|
||||
tz: input.tz,
|
||||
evidence: choiceEvidencePayload(input.evidence),
|
||||
dismissed_opportunity_ids: input.dismissedOpportunityIds,
|
||||
question_fingerprints: input.questionFingerprints,
|
||||
partition_fingerprints: input.partitionFingerprints,
|
||||
recent_ranges: input.recentRanges.map((range) => ({
|
||||
start_time: range.startTime,
|
||||
end_time: range.endTime,
|
||||
})),
|
||||
candidate_model: input.candidateModel,
|
||||
} as const;
|
||||
}
|
||||
|
||||
export function dynamicChoiceScorePayload(input: DynamicChoiceScoreInput) {
|
||||
return {
|
||||
birth_date: input.birthDate,
|
||||
start_time: input.startTime,
|
||||
end_time: input.endTime,
|
||||
lat: input.lat,
|
||||
lon: input.lon,
|
||||
tz: input.tz,
|
||||
choice_evidence: choiceEvidencePayload(input.evidence),
|
||||
} as const;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
parseCandidateDifferenceBuild,
|
||||
parseDynamicChoiceScoring,
|
||||
parseRectificationQuestionnaire,
|
||||
parseRectificationScoring,
|
||||
parseCandidateResult,
|
||||
} from "./birth-time-journey-adapters.ts";
|
||||
import { eventScorePayload } from "./birth-time-journey-engine-model.ts";
|
||||
import type { BirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
|
||||
import {
|
||||
differencePacketPayload,
|
||||
dynamicChoiceScorePayload,
|
||||
eventScorePayload,
|
||||
} from "./birth-time-journey-engine-model.ts";
|
||||
import type { DynamicBirthTimeJourneyEngine } from "./birth-time-journey-service.ts";
|
||||
|
||||
export class BirthTimeJourneyEngineError extends Error {
|
||||
readonly name = "BirthTimeJourneyEngineError";
|
||||
@@ -18,10 +24,26 @@ export class BirthTimeJourneyEngineError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(apiBase: string, path: string, body: unknown): Promise<unknown> {
|
||||
export class BirthTimeJourneyEngineConfigurationError extends Error {
|
||||
readonly name = "BirthTimeJourneyEngineConfigurationError";
|
||||
|
||||
constructor() {
|
||||
super("Dynamic Jyotish rectification is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(
|
||||
apiBase: string,
|
||||
path: string,
|
||||
body: unknown,
|
||||
authorization?: string,
|
||||
): Promise<unknown> {
|
||||
const response = await fetch(`${apiBase}${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
...(authorization ? { authorization } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(45_000),
|
||||
});
|
||||
@@ -30,9 +52,15 @@ async function postJson(apiBase: string, path: string, body: unknown): Promise<u
|
||||
return payload;
|
||||
}
|
||||
|
||||
function dynamicAuthorization(): string {
|
||||
const token = process.env.JYOTISH_DYNAMIC_RECTIFICATION_TOKEN?.trim();
|
||||
if (!token) throw new BirthTimeJourneyEngineConfigurationError();
|
||||
return `Bearer ${token}`;
|
||||
}
|
||||
|
||||
export function createJyotishBirthTimeJourneyEngine(
|
||||
apiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200",
|
||||
): BirthTimeJourneyEngine {
|
||||
): DynamicBirthTimeJourneyEngine {
|
||||
return {
|
||||
async scan(input) {
|
||||
const payload = await postJson(apiBase, "/api/active_rectification_questions", {
|
||||
@@ -63,5 +91,25 @@ export function createJyotishBirthTimeJourneyEngine(
|
||||
);
|
||||
return parseCandidateResult(payload);
|
||||
},
|
||||
|
||||
async buildDifferencePacket(input) {
|
||||
const payload = await postJson(
|
||||
apiBase,
|
||||
"/api/dynamic_rectification_opportunities",
|
||||
differencePacketPayload(input),
|
||||
dynamicAuthorization(),
|
||||
);
|
||||
return parseCandidateDifferenceBuild(payload);
|
||||
},
|
||||
|
||||
async scoreChoices(input) {
|
||||
const payload = await postJson(
|
||||
apiBase,
|
||||
"/api/dynamic_rectification_score",
|
||||
dynamicChoiceScorePayload(input),
|
||||
dynamicAuthorization(),
|
||||
);
|
||||
return parseDynamicChoiceScoring(payload);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,12 @@ 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";
|
||||
|
||||
export type RectificationAnswer = "A" | "B" | "C" | "D";
|
||||
|
||||
@@ -66,10 +72,38 @@ export type JourneyEventScoreInput = {
|
||||
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 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 interface DynamicBirthTimeJourneyEngine extends BirthTimeJourneyEngine {
|
||||
buildDifferencePacket(input: DifferencePacketInput): Promise<CandidateDifferenceBuild>;
|
||||
scoreChoices(input: DynamicChoiceScoreInput): Promise<DynamicChoiceScoringResult>;
|
||||
}
|
||||
|
||||
export type PersistedJourneyAssessment = {
|
||||
|
||||
Reference in New Issue
Block a user