Files
Jyotisha/frontend/src/lib/birth-time-guide-service.ts
T

251 lines
8.7 KiB
TypeScript

import {
draftEvidencePrompt,
fallbackQuestionCopy,
guideQuestionResponseSchema,
parseEvidenceDraftOutput,
parseGuideQuestionOutput,
parseJsonObject,
renderQuestionPrompt,
type BirthTimeGuideGenerator,
} from "./birth-time-guide-agent.ts";
import {
bindDynamicQuestion,
bindFallbackDynamicQuestion,
generateDynamicQuestionPrompt,
isRecoverableDynamicQuestionError,
parseDynamicQuestionText,
} from "./birth-time-dynamic-question-validator.ts";
import { toPublicDynamicChoiceQuestion } from "./birth-time-dynamic-choice-internal.ts";
import type {
CandidateDifferenceBuild,
PersistedDynamicChoiceQuestion,
} from "./birth-time-dynamic-choice-internal.ts";
import type { DynamicNextAction } from "./birth-time-journey-turn-protocol.ts";
import type { EvidenceDraftProposal } from "./birth-time-evidence.ts";
import { currentJourneyTurn, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type {
StoredRectificationCase,
VersionedJourneyResponse,
} from "./birth-time-journey-service.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-turn-persistence.ts";
import { questionFromTurn } from "./birth-time-journey-transitions.ts";
type DraftRequest = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly message: string;
};
export type DynamicQuestionGenerationCommand = {
readonly caseId: string;
readonly actionId: string;
readonly turnVersion: number;
readonly unmatchedNote: string | null;
};
export type DynamicQuestionGenerationCommit = {
readonly nextAction: Extract<DynamicNextAction,
{ readonly kind: "ask_dynamic_choice" | "present_low_result" }
>;
};
type GuideServicePorts = {
readonly generator: BirthTimeGuideGenerator | null;
readonly timeoutMs?: number;
readonly loadCase: (
userId: string,
caseId: string,
) => Promise<StoredRectificationCase | null>;
readonly proposeEvidenceDraft: (
userId: string,
caseId: string,
actionId: string,
turnVersion: number,
proposal: EvidenceDraftProposal,
) => Promise<VersionedJourneyResponse>;
readonly loadDynamicQuestionBuild?: (
userId: string,
command: DynamicQuestionGenerationCommand,
) => Promise<CandidateDifferenceBuild>;
readonly commitDynamicQuestion?: (
userId: string,
command: DynamicQuestionGenerationCommand,
question: PersistedDynamicChoiceQuestion | null,
commit: DynamicQuestionGenerationCommit,
) => Promise<DynamicQuestionGenerationCommit>;
readonly createDynamicId?: () => string;
};
export class BirthTimeGuideActionError extends Error {
readonly name = "BirthTimeGuideActionError";
readonly reason: "case_not_found" | "invalid_turn";
constructor(reason: "case_not_found" | "invalid_turn") {
super(`Birth-time guide action ${reason}`);
this.reason = reason;
}
}
class BirthTimeGuideTimeoutError extends Error {
readonly name = "BirthTimeGuideTimeoutError";
}
async function generatedText(
generator: BirthTimeGuideGenerator | null,
prompt: string,
timeoutMs: number,
): Promise<string | null> {
if (!generator) return null;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BirthTimeGuideTimeoutError()), timeoutMs);
});
const result = await Promise.race([generator.generate(prompt), timeout]);
return result.text;
} catch {
return null;
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
function currentQuestion(stored: StoredRectificationCase) {
const turn = currentJourneyTurn(stored);
const question = questionFromTurn(turn);
if (!question) throw new BirthTimeGuideActionError("invalid_turn");
return { turn, question };
}
async function generatedQuestion(
generator: BirthTimeGuideGenerator | null,
prompt: string,
question: Parameters<typeof parseGuideQuestionOutput>[1],
timeoutMs: number,
): Promise<string | null> {
const text = await generatedText(generator, prompt, timeoutMs);
if (text === null) return null;
try {
return parseGuideQuestionOutput(parseJsonObject(text), question);
} catch {
return null;
}
}
async function generatedProposal(
generator: BirthTimeGuideGenerator | null,
prompt: string,
requiredDomain: Parameters<typeof parseEvidenceDraftOutput>[1]["requiredDomain"],
sourceMessage: string,
timeoutMs: number,
): Promise<EvidenceDraftProposal> {
const text = await generatedText(generator, prompt, timeoutMs);
if (text !== null) {
try {
const parsed = parseEvidenceDraftOutput(parseJsonObject(text), {
requiredDomain,
sourceMessage,
});
return { domain: parsed.domain, precision: parsed.precision, date: parsed.date };
} catch {
return { domain: requiredDomain, precision: null, date: null };
}
}
return { domain: requiredDomain, precision: null, date: null };
}
export function createBirthTimeGuideService(ports: GuideServicePorts) {
const timeoutMs = ports.timeoutMs ?? 8_000;
return {
async generateQuestion(userId: string, command: DynamicQuestionGenerationCommand) {
if (!ports.loadDynamicQuestionBuild || !ports.commitDynamicQuestion) {
throw new BirthTimeGuideActionError("invalid_turn");
}
const build = await ports.loadDynamicQuestionBuild(userId, command);
if (build.packet.caseId !== command.caseId) {
throw new BirthTimeGuideActionError("invalid_turn");
}
const createId = ports.createDynamicId ?? (() => globalThis.crypto.randomUUID());
let question: PersistedDynamicChoiceQuestion | null = null;
if (build.packet.opportunities.length > 0) {
const prompt = generateDynamicQuestionPrompt(build.packet);
for (let attempt = 0; attempt < 2 && question === null; attempt += 1) {
const text = await generatedText(ports.generator, prompt, timeoutMs);
if (text === null) continue;
try {
const output = parseDynamicQuestionText(text, build.packet);
if (output.kind === "no_useful_question") break;
question = bindDynamicQuestion(output, build, createId);
} catch (error) {
if (!isRecoverableDynamicQuestionError(error)) throw error;
}
}
question ??= bindFallbackDynamicQuestion(build, createId);
}
const nextAction = question === null
? { kind: "present_low_result" as const, resultId: null }
: { kind: "ask_dynamic_choice" as const, question: toPublicDynamicChoiceQuestion(question) };
return ports.commitDynamicQuestion(userId, command, question, { nextAction });
},
async renderQuestion(userId: string, caseId: string) {
const stored = await ports.loadCase(userId, caseId);
if (!stored) throw new BirthTimeGuideActionError("case_not_found");
const { turn, question } = currentQuestion(stored);
const generated = await generatedQuestion(
ports.generator,
renderQuestionPrompt(),
question,
timeoutMs,
);
return guideQuestionResponseSchema.parse({
type: "question",
caseId,
turnVersion: turn.turnVersion,
questionId: question.questionId,
question: generated ?? fallbackQuestionCopy(question),
source: generated === null ? "fallback" : "agent",
});
},
async draftEvidence(userId: string, request: DraftRequest) {
const stored = await ports.loadCase(userId, request.caseId);
if (!stored) throw new BirthTimeGuideActionError("case_not_found");
const receipt = request.actionId.toLowerCase();
if (stored.processedActionIds?.includes(receipt)) {
return {
type: "evidence_draft" as const,
actionId: request.actionId,
requestedTurnVersion: request.turnVersion,
turn: storedJourneyResponse(stored),
};
}
const current = currentJourneyTurn(stored);
if (current.turnVersion !== request.turnVersion) {
throw new StaleJourneyTurnError(
request.caseId,
request.turnVersion,
current.turnVersion,
);
}
const question = questionFromTurn(current);
if (!question) throw new BirthTimeGuideActionError("invalid_turn");
const proposal = await generatedProposal(
ports.generator,
draftEvidencePrompt(question, request.message),
question.domain,
request.message,
timeoutMs,
);
const turn = await ports.proposeEvidenceDraft(
userId,
request.caseId,
request.actionId,
request.turnVersion,
proposal,
);
return {
type: "evidence_draft" as const,
actionId: request.actionId,
requestedTurnVersion: request.turnVersion,
turn,
};
},
};
}