refactor: make birth time rectification conversational
This commit is contained in:
@@ -14,10 +14,15 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
|
||||
const now = options.now ?? (() => new Date());
|
||||
|
||||
async function response(userId: string, caseValue: RectificationV4Case, jobId?: string): Promise<RectificationV4ApiResponse> {
|
||||
const [events, turns] = await Promise.all([
|
||||
store.loadEvents(userId, caseValue.id),
|
||||
store.loadTurns(userId, caseValue.id),
|
||||
]);
|
||||
return {
|
||||
case: caseValue,
|
||||
job: jobId ? await store.loadJob(userId, jobId) : null,
|
||||
events: [...await store.loadEvents(userId, caseValue.id)],
|
||||
events: [...events],
|
||||
turns: [...turns],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -34,7 +39,7 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
|
||||
calculationSpec: input.calculationSpec,
|
||||
calculationSpecHash: calculationSpecHash(input.calculationSpec),
|
||||
evidenceSetHash: evidenceSetHash([]),
|
||||
currentQuestion: openingQuestion(),
|
||||
currentQuestion: openingQuestion(input.calculationSpec.candidateRange),
|
||||
latestSnapshot: null,
|
||||
acceptedRange: null,
|
||||
createdAt: timestamp,
|
||||
@@ -57,11 +62,12 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op
|
||||
return store.loadJob(userId, jobId);
|
||||
},
|
||||
|
||||
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string }) {
|
||||
async answer(input: { readonly userId: string; readonly caseId: string; readonly actionId: string; readonly expectedCaseVersion: number; readonly answer: string; readonly modelId?: string | null }) {
|
||||
const current = await store.loadCase(input.userId, input.caseId);
|
||||
if (!current?.currentQuestion) return null;
|
||||
const saved = await store.submitAnswer({
|
||||
...input,
|
||||
modelId: input.modelId ?? null,
|
||||
question: current.currentQuestion,
|
||||
jobId: randomUUID(),
|
||||
turnId: randomUUID(),
|
||||
|
||||
@@ -57,9 +57,9 @@ export async function loadRectificationV4(caseId: string): Promise<Rectification
|
||||
return json(await fetch(`/api/rectification/v4/cases/${caseId}`, { cache: "no-store" }), rectificationV4ApiResponseSchema);
|
||||
}
|
||||
|
||||
export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string) {
|
||||
export function answerRectificationV4(caseId: string, expectedCaseVersion: number, answer: string, modelId?: string | null) {
|
||||
return post(`/api/rectification/v4/cases/${caseId}/answers`, {
|
||||
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer,
|
||||
actionId: globalThis.crypto.randomUUID(), expectedCaseVersion, answer, modelId: modelId || null,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -150,6 +150,21 @@ export const rectificationV4QuestionSchema = z.object({
|
||||
}).strict();
|
||||
export type RectificationV4Question = z.infer<typeof rectificationV4QuestionSchema>;
|
||||
|
||||
export const rectificationV4TurnSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
caseId: z.string().uuid(),
|
||||
caseVersion: z.number().int().positive(),
|
||||
questionId: z.string().uuid().nullable(),
|
||||
questionDomain: evidenceDomainSchema.nullable(),
|
||||
questionTargetEventId: z.string().uuid().nullable(),
|
||||
question: z.string().trim().min(1).max(1_000),
|
||||
answer: z.string().max(4_000),
|
||||
modelId: z.string().trim().min(1).max(120).nullable(),
|
||||
actionId: z.string().uuid(),
|
||||
createdAt: z.string().datetime({ offset: true }),
|
||||
}).strict();
|
||||
export type RectificationV4Turn = z.infer<typeof rectificationV4TurnSchema>;
|
||||
|
||||
export const rectificationV4CaseSchema = z.object({
|
||||
id: z.string().uuid(),
|
||||
userId: z.string().uuid(),
|
||||
@@ -177,6 +192,7 @@ export const answerRequestSchema = z.object({
|
||||
actionId: z.string().uuid(),
|
||||
expectedCaseVersion: z.number().int().nonnegative(),
|
||||
answer: z.string().trim().min(1).max(4_000),
|
||||
modelId: z.string().trim().min(1).max(120).nullable().optional(),
|
||||
}).strict();
|
||||
|
||||
export const reviseEventRequestSchema = z.object({
|
||||
@@ -218,6 +234,7 @@ export const rectificationV4ApiResponseSchema = z.object({
|
||||
case: rectificationV4CaseSchema,
|
||||
job: rectificationV4JobSchema.nullable(),
|
||||
events: z.array(lifeEventRevisionSchema),
|
||||
turns: z.array(rectificationV4TurnSchema),
|
||||
}).strict();
|
||||
export type RectificationV4ApiResponse = z.infer<typeof rectificationV4ApiResponseSchema>;
|
||||
|
||||
|
||||
@@ -43,6 +43,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
owned(userId, caseId);
|
||||
return events.get(caseId) ?? [];
|
||||
},
|
||||
async loadTurns(userId, caseId) {
|
||||
owned(userId, caseId);
|
||||
return [...turns.values()]
|
||||
.filter((turn) => turn.caseId === caseId)
|
||||
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
|
||||
},
|
||||
async createCase(input) {
|
||||
const replay = actionResults.get(`${input.case.userId}:${input.actionId}`);
|
||||
if (replay) return owned(input.case.userId, replay.caseId);
|
||||
@@ -85,6 +91,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
questionTargetEventId: input.question.targetEventId,
|
||||
question: input.question.prompt,
|
||||
answer: input.answer,
|
||||
modelId: input.modelId,
|
||||
actionId: input.actionId,
|
||||
createdAt: input.now,
|
||||
};
|
||||
@@ -123,7 +130,7 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
};
|
||||
const turn: RectificationV4Turn = {
|
||||
id: input.revision.id, caseId: current.id, caseVersion: version, questionId: null, questionDomain: null,
|
||||
questionTargetEventId: null, question: "修订事件", answer: "", actionId: input.actionId, createdAt: input.now,
|
||||
questionTargetEventId: null, question: "修订事件", answer: "", modelId: null, actionId: input.actionId, createdAt: input.now,
|
||||
};
|
||||
const job = {
|
||||
id: input.jobId, caseId: current.id, status: "pending" as const, phase: "scoring_candidates" as const,
|
||||
@@ -174,10 +181,14 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & {
|
||||
const claimed = { ...job, status: "processing" as const, workerId, updatedAt: now };
|
||||
jobs.set(job.id, claimed);
|
||||
const caseValue = cases.get(job.caseId)!;
|
||||
const caseTurns = [...turns.values()]
|
||||
.filter((turn) => turn.caseId === job.caseId)
|
||||
.sort((left, right) => left.caseVersion - right.caseVersion || left.createdAt.localeCompare(right.createdAt));
|
||||
return {
|
||||
job: claimed,
|
||||
case: caseValue,
|
||||
turn: turns.get(job.turnId)!,
|
||||
turns: caseTurns,
|
||||
events: events.get(job.caseId) ?? [],
|
||||
attemptedRefinementEventIds: [...new Set(
|
||||
[...turns.values()]
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { Agent } from "@mastra/core/agent";
|
||||
import { z } from "zod";
|
||||
import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model";
|
||||
import {
|
||||
evidenceDomainSchema,
|
||||
type CandidateSnapshot,
|
||||
type LifeEventRevision,
|
||||
type RectificationV4Question,
|
||||
type RectificationV4Turn,
|
||||
} from "./contracts.ts";
|
||||
import { planNextQuestion } from "./question-planner.ts";
|
||||
|
||||
const outputSchema = z.object({
|
||||
domain: evidenceDomainSchema,
|
||||
targetEventId: z.string().uuid().nullable(),
|
||||
prompt: z.string().trim().min(1).max(1_000),
|
||||
recallCost: z.enum(["low", "medium", "high"]),
|
||||
reason: z.string().trim().min(1).max(240),
|
||||
}).strict();
|
||||
|
||||
const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim()
|
||||
|| path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology");
|
||||
const agents = new Map<string, Agent>();
|
||||
const internalCopyPattern = /(?:候选分数|内部(?:领域|路由|状态)|评分权重|\b(?:education|relocation|relationship|career|finance|health_pressure|family|other)\b)/iu;
|
||||
|
||||
function agentFor(modelId: string | null) {
|
||||
const model = modelId ? resolveLanguageModel(modelId) : defaultLanguageModel();
|
||||
const selected = model ?? defaultLanguageModel();
|
||||
if (!selected) return null;
|
||||
const cached = agents.get(selected.id);
|
||||
if (cached) return cached;
|
||||
const agent = new Agent({
|
||||
id: `rectification-v4-question-${selected.id}`,
|
||||
name: "Rectification V4 Conversational Question Author",
|
||||
model: selected.model,
|
||||
skills: [jyotishSkillPath],
|
||||
instructions: "Return only the requested JSON. Act as a birth-time rectification conversation partner, not a questionnaire. Respond to the user's latest concrete experience, then ask at most one natural open question that can materially improve evidence quality or distinguish the remaining candidate range. Choose the next evidence domain from context; never follow a fixed domain order. Never expose domain labels, event ids, scores, routing metadata, gate reasons, or implementation status in the visible prompt.",
|
||||
});
|
||||
agents.set(selected.id, agent);
|
||||
return agent;
|
||||
}
|
||||
|
||||
function latestByEvent(events: readonly LifeEventRevision[]) {
|
||||
const latest = new Map<string, LifeEventRevision>();
|
||||
for (const event of events) {
|
||||
const current = latest.get(event.eventId);
|
||||
if (!current || current.revision < event.revision) latest.set(event.eventId, event);
|
||||
}
|
||||
return [...latest.values()];
|
||||
}
|
||||
|
||||
export async function authorRectificationV4Question(input: Readonly<{
|
||||
modelId: string | null;
|
||||
candidateRange: Readonly<{ start: string; end: string }>;
|
||||
snapshot: CandidateSnapshot | null;
|
||||
turns: readonly RectificationV4Turn[];
|
||||
events: readonly LifeEventRevision[];
|
||||
attemptedRefinementEventIds: readonly string[];
|
||||
}>): Promise<RectificationV4Question> {
|
||||
const fallback = () => planNextQuestion({
|
||||
events: input.events,
|
||||
attemptedRefinementEventIds: input.attemptedRefinementEventIds,
|
||||
latestAnswer: input.turns.at(-1)?.answer,
|
||||
});
|
||||
const agent = agentFor(input.modelId);
|
||||
if (!agent) return fallback();
|
||||
|
||||
const events = latestByEvent(input.events);
|
||||
const allowedTargets = new Map(events.map((event) => [event.eventId, event]));
|
||||
const recentTurns = input.turns.slice(-6).flatMap((turn) => [
|
||||
{ role: "assistant", text: turn.question },
|
||||
...(turn.answer ? [{ role: "user", text: turn.answer }] : []),
|
||||
]);
|
||||
const prompt = JSON.stringify({
|
||||
task: "Write the next assistant message for an open-ended birth-time rectification conversation.",
|
||||
constraints: [
|
||||
"First acknowledge or connect to the latest user experience; do not say merely that an answer is complete or recorded.",
|
||||
"Ask zero or one question, never a checklist, form, domain menu, or fixed sequence.",
|
||||
"Prefer continuing the current event when a date/detail clarification can change scoring; otherwise choose the highest-information missing life dimension.",
|
||||
"The visible prompt must not mention internal domains, ids, scores, weights, gates, processing phases, or that a model selected a route.",
|
||||
"targetEventId must be null or one of allowedTargetEventIds.",
|
||||
],
|
||||
candidateRange: input.candidateRange,
|
||||
currentCandidateRange: input.snapshot?.clusters[0]
|
||||
? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime }
|
||||
: null,
|
||||
recentConversation: recentTurns,
|
||||
existingEvidence: events.map((event) => ({
|
||||
eventId: event.eventId,
|
||||
domain: event.domain,
|
||||
summary: event.summary,
|
||||
date: event.dateRange.label,
|
||||
precision: event.dateRange.precision,
|
||||
scoreability: event.scoreability,
|
||||
})),
|
||||
attemptedRefinementEventIds: input.attemptedRefinementEventIds,
|
||||
allowedTargetEventIds: [...allowedTargets.keys()],
|
||||
allowedDomains: evidenceDomainSchema.options,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await agent.generate(
|
||||
[{ role: "user", content: prompt }],
|
||||
{
|
||||
abortSignal: AbortSignal.timeout(35_000),
|
||||
structuredOutput: { schema: outputSchema, jsonPromptInjection: "inline" },
|
||||
},
|
||||
);
|
||||
const parsed = outputSchema.safeParse(result.object ?? (result.text ? JSON.parse(result.text) : null));
|
||||
if (!parsed.success || internalCopyPattern.test(parsed.data.prompt)) return fallback();
|
||||
const target = parsed.data.targetEventId ? allowedTargets.get(parsed.data.targetEventId) : null;
|
||||
return {
|
||||
id: randomUUID(),
|
||||
domain: target?.domain ?? parsed.data.domain,
|
||||
targetEventId: target?.eventId ?? null,
|
||||
prompt: parsed.data.prompt,
|
||||
recallCost: parsed.data.recallCost,
|
||||
reason: parsed.data.reason,
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("rectification_v4_question_author_failed", {
|
||||
modelId: input.modelId,
|
||||
errorName: error instanceof Error ? error.name : "UnknownError",
|
||||
});
|
||||
return fallback();
|
||||
}
|
||||
}
|
||||
@@ -1,82 +1,52 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { EvidenceDomain, LifeEventRevision, RectificationV4Question } from "./contracts.ts";
|
||||
import type { LifeEventRevision, RectificationV4Question } from "./contracts.ts";
|
||||
import { scoreableEvents } from "./evidence-ledger.ts";
|
||||
|
||||
const domainOrder: readonly EvidenceDomain[] = [
|
||||
"education", "relocation", "relationship", "career", "finance", "health_pressure", "family",
|
||||
];
|
||||
const recallCost: Readonly<Record<EvidenceDomain, number>> = {
|
||||
education: 1, relocation: 1, relationship: 1, career: 1, finance: 2, health_pressure: 2, family: 2, other: 3,
|
||||
};
|
||||
const prompts: Readonly<Record<EvidenceDomain, string>> = {
|
||||
education: "请说一件你记得最清楚的升学、复读、转学或毕业事件,并给出尽可能准确的年月。",
|
||||
relocation: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。",
|
||||
relationship: "请说一段重要关系明确开始或结束的时间;开始和结束请分开说。",
|
||||
career: "请说一次明确的入职、离职、转行或职责突变,并给出尽可能准确的年月。",
|
||||
finance: "请说一次明显的收入、负债或资产变化,并给出尽可能准确的年月。",
|
||||
health_pressure: "请说一次明确的疾病、手术、事故或长期压力起点,并给出尽可能准确的年月。",
|
||||
family: "请说一件对你影响很大的家庭事件和时间;这一类先作为背景,不直接参与评分。",
|
||||
other: "请再补充一件日期明确、对人生方向影响较大的事件;如果暂时想不到,也可以回复“暂停”。",
|
||||
};
|
||||
|
||||
function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question {
|
||||
return {
|
||||
id: id ?? randomUUID(),
|
||||
domain: event.domain,
|
||||
targetEventId: event.eventId,
|
||||
prompt: `你之前提到“${event.summary.slice(0, 120)}”,目前时间是${event.dateRange.label}。如果记得,请补充更具体的日期;不记得可以回复“跳过”。`,
|
||||
prompt: `你刚才提到的“${event.summary.slice(0, 120)}”很重要。你目前记得的时间是${event.dateRange.label};如果还能想起更具体的月份或日期,可以继续说,不确定也没关系。`,
|
||||
recallCost: "medium",
|
||||
reason: "缩小已有事件的日期范围,用于检验候选时间对日期误差是否稳定。",
|
||||
reason: "缩小已有事件的日期范围,用于检验候选范围对日期误差是否稳定。",
|
||||
};
|
||||
}
|
||||
|
||||
export function planNextQuestion(input: {
|
||||
readonly askedDomains: readonly EvidenceDomain[];
|
||||
readonly coveredDomains: readonly EvidenceDomain[];
|
||||
readonly candidateSplitByDomain?: Readonly<Partial<Record<EvidenceDomain, number>>>;
|
||||
readonly events?: readonly LifeEventRevision[];
|
||||
readonly attemptedRefinementEventIds?: readonly string[];
|
||||
readonly latestAnswer?: string;
|
||||
readonly id?: string;
|
||||
}): RectificationV4Question {
|
||||
const asked = new Set(input.askedDomains);
|
||||
const covered = new Set(input.coveredDomains);
|
||||
const candidates = domainOrder.filter((domain) => !asked.has(domain));
|
||||
if (candidates.length > 0) {
|
||||
candidates.sort((left, right) => {
|
||||
const leftValue = (input.candidateSplitByDomain?.[left] ?? 0) + (covered.has(left) ? 0 : 1) - recallCost[left] * 0.1;
|
||||
const rightValue = (input.candidateSplitByDomain?.[right] ?? 0) + (covered.has(right) ? 0 : 1) - recallCost[right] * 0.1;
|
||||
return rightValue - leftValue || domainOrder.indexOf(left) - domainOrder.indexOf(right);
|
||||
});
|
||||
const domain = candidates[0]!;
|
||||
const cost = recallCost[domain] === 1 ? "low" : recallCost[domain] === 2 ? "medium" : "high";
|
||||
return {
|
||||
id: input.id ?? randomUUID(),
|
||||
domain,
|
||||
targetEventId: null,
|
||||
prompt: prompts[domain],
|
||||
recallCost: cost,
|
||||
reason: input.candidateSplitByDomain?.[domain]
|
||||
? "该领域最能区分当前候选时间,同时回忆成本较低。"
|
||||
: "先收集高回忆率、可核对日期的人生事件。",
|
||||
};
|
||||
}
|
||||
|
||||
const attempted = new Set(input.attemptedRefinementEventIds ?? []);
|
||||
const target = scoreableEvents(input.events ?? [])
|
||||
.filter((event) => event.dateRange.precision !== "day" && !attempted.has(event.eventId))
|
||||
.sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.eventId.localeCompare(right.eventId))[0];
|
||||
.sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0];
|
||||
if (target) return refinementQuestion(target, input.id);
|
||||
|
||||
return {
|
||||
id: input.id ?? randomUUID(),
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
prompt: prompts.other,
|
||||
recallCost: "high",
|
||||
reason: "现有事件仍不足以通过稳定性门槛,需要新的明确日期证据,或由用户主动暂停。",
|
||||
prompt: input.latestAnswer
|
||||
? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。"
|
||||
: "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。",
|
||||
recallCost: "low",
|
||||
reason: "模型不可用时保持开放叙述,不退回固定领域问卷。",
|
||||
};
|
||||
}
|
||||
|
||||
export function openingQuestion(id?: string): RectificationV4Question {
|
||||
return planNextQuestion({ askedDomains: [], coveredDomains: [], id });
|
||||
export function openingQuestion(
|
||||
candidateRange: Readonly<{ start: string; end: string }>,
|
||||
id?: string,
|
||||
): RectificationV4Question {
|
||||
return {
|
||||
id: id ?? randomUUID(),
|
||||
domain: "other",
|
||||
targetEventId: null,
|
||||
prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`,
|
||||
recallCost: "low",
|
||||
reason: "首轮允许开放叙述,由后续模型根据真实经历选择高信息量问题。",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,25 +5,15 @@ import type {
|
||||
RectificationV4Job,
|
||||
RectificationV4Phase,
|
||||
RectificationV4Question,
|
||||
RectificationV4Turn,
|
||||
} from "./contracts.ts";
|
||||
|
||||
export type RectificationV4Turn = Readonly<{
|
||||
id: string;
|
||||
caseId: string;
|
||||
caseVersion: number;
|
||||
questionId: string | null;
|
||||
questionDomain: LifeEventRevision["domain"] | null;
|
||||
questionTargetEventId: string | null;
|
||||
question: string;
|
||||
answer: string;
|
||||
actionId: string;
|
||||
createdAt: string;
|
||||
}>;
|
||||
export type { RectificationV4Turn } from "./contracts.ts";
|
||||
|
||||
export type ClaimedRectificationV4Job = Readonly<{
|
||||
job: RectificationV4Job;
|
||||
case: RectificationV4Case;
|
||||
turn: RectificationV4Turn;
|
||||
turns: readonly RectificationV4Turn[];
|
||||
events: readonly LifeEventRevision[];
|
||||
attemptedRefinementEventIds: readonly string[];
|
||||
}>;
|
||||
@@ -46,6 +36,7 @@ export interface RectificationV4Store {
|
||||
findActiveCase(userId: string): Promise<RectificationV4Case | null>;
|
||||
loadCase(userId: string, caseId: string): Promise<RectificationV4Case | null>;
|
||||
loadEvents(userId: string, caseId: string): Promise<readonly LifeEventRevision[]>;
|
||||
loadTurns(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]>;
|
||||
createCase(input: { readonly case: RectificationV4Case; readonly actionId: string }): Promise<RectificationV4Case>;
|
||||
submitAnswer(input: {
|
||||
readonly userId: string;
|
||||
@@ -53,6 +44,7 @@ export interface RectificationV4Store {
|
||||
readonly actionId: string;
|
||||
readonly expectedCaseVersion: number;
|
||||
readonly answer: string;
|
||||
readonly modelId: string | null;
|
||||
readonly question: RectificationV4Question;
|
||||
readonly jobId: string;
|
||||
readonly turnId: string;
|
||||
|
||||
@@ -4,16 +4,17 @@ import {
|
||||
lifeEventRevisionSchema,
|
||||
rectificationV4CaseSchema,
|
||||
rectificationV4JobSchema,
|
||||
rectificationV4TurnSchema,
|
||||
type CandidateSnapshot,
|
||||
type LifeEventRevision,
|
||||
type RectificationV4Case,
|
||||
type RectificationV4Job,
|
||||
type RectificationV4Turn,
|
||||
} from "./contracts.ts";
|
||||
import type {
|
||||
ClaimedRectificationV4Job,
|
||||
CompleteRectificationV4JobInput,
|
||||
RectificationV4Store,
|
||||
RectificationV4Turn,
|
||||
} from "./store.ts";
|
||||
import { RectificationV4StoreError } from "./store.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
@@ -114,7 +115,7 @@ function jobValue(row: Row): RectificationV4Job {
|
||||
}
|
||||
|
||||
function turnValue(row: Row): RectificationV4Turn {
|
||||
return {
|
||||
return rectificationV4TurnSchema.parse({
|
||||
id: String(row.id),
|
||||
caseId: String(row.case_id),
|
||||
caseVersion: Number(row.case_version),
|
||||
@@ -123,9 +124,10 @@ function turnValue(row: Row): RectificationV4Turn {
|
||||
questionTargetEventId: row.question_target_event_id ? String(row.question_target_event_id) : null,
|
||||
question: String(row.question),
|
||||
answer: String(row.answer),
|
||||
modelId: row.model_id ? String(row.model_id) : null,
|
||||
actionId: String(row.action_id),
|
||||
createdAt: timestamp(row.created_at),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
|
||||
@@ -160,6 +162,15 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
return ((data ?? []) as Row[]).map(eventRevision);
|
||||
}
|
||||
|
||||
async function loadTurnsByCase(userId: string, caseId: string): Promise<readonly RectificationV4Turn[]> {
|
||||
if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_turns")
|
||||
.select("*").eq("case_id", caseId).eq("user_id", userId)
|
||||
.order("case_version", { ascending: true });
|
||||
if (error) throw storeError(error);
|
||||
return ((data ?? []) as Row[]).map(turnValue);
|
||||
}
|
||||
|
||||
async function rpc(name: string, args: Row): Promise<unknown> {
|
||||
const { data, error } = await supabase.rpc(name, args);
|
||||
if (error) throw storeError(error);
|
||||
@@ -176,6 +187,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
},
|
||||
loadCase: loadCaseById,
|
||||
loadEvents: loadEventsByCase,
|
||||
loadTurns: loadTurnsByCase,
|
||||
async createCase(input) {
|
||||
const id = String(await rpc("create_birth_time_rectification_v4_case", {
|
||||
p_user_id: input.case.userId,
|
||||
@@ -205,6 +217,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
p_question_target_event_id: input.question.targetEventId,
|
||||
p_question: input.question.prompt,
|
||||
p_answer: input.answer,
|
||||
p_model_id: input.modelId,
|
||||
p_job_id: input.jobId,
|
||||
p_now: input.now,
|
||||
}));
|
||||
@@ -266,23 +279,21 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re
|
||||
if (!jobRow) throw new RectificationV4StoreError("not_found");
|
||||
const userId = String(jobRow.user_id);
|
||||
const caseId = String(jobRow.case_id);
|
||||
const [caseResult, turnRow, events, turnRows] = await Promise.all([
|
||||
const [caseResult, turnRow, events, turns] = await Promise.all([
|
||||
loadCaseById(userId, caseId),
|
||||
rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
|
||||
loadEventsByCase(userId, caseId),
|
||||
supabase.from("birth_time_rectification_v4_turns")
|
||||
.select("question_target_event_id").eq("case_id", caseId),
|
||||
loadTurnsByCase(userId, caseId),
|
||||
]);
|
||||
if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
|
||||
if (turnRows.error) throw storeError(turnRows.error);
|
||||
return {
|
||||
job: jobValue(jobRow),
|
||||
case: caseResult,
|
||||
turn: turnValue(turnRow),
|
||||
turns,
|
||||
events,
|
||||
attemptedRefinementEventIds: [...new Set(
|
||||
((turnRows.data ?? []) as Row[])
|
||||
.flatMap((row) => row.question_target_event_id ? [String(row.question_target_event_id)] : []),
|
||||
turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : []),
|
||||
)],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { CandidateSnapshot, EvidenceDomain } from "./contracts.ts";
|
||||
import type {
|
||||
CandidateSnapshot,
|
||||
LifeEventRevision,
|
||||
RectificationV4Case,
|
||||
RectificationV4Question,
|
||||
} from "./contracts.ts";
|
||||
import { rectificationV4AlgorithmVersion } from "./contracts.ts";
|
||||
import type { RectificationV4CandidateEngine } from "./candidate-engine.ts";
|
||||
import { buildCandidateClusters } from "./candidate-clusters.ts";
|
||||
@@ -8,13 +13,21 @@ import { evidenceSetHash } from "./fingerprints.ts";
|
||||
import { extractV4EventRevisions } from "./extraction.ts";
|
||||
import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts";
|
||||
import { planNextQuestion } from "./question-planner.ts";
|
||||
import type { RectificationV4Store } from "./store.ts";
|
||||
import type { ClaimedRectificationV4Job, RectificationV4Store } from "./store.ts";
|
||||
|
||||
export function createRectificationV4Worker(input: {
|
||||
readonly store: RectificationV4Store;
|
||||
readonly engine: RectificationV4CandidateEngine;
|
||||
readonly workerId?: string;
|
||||
readonly now?: () => Date;
|
||||
readonly questionAuthor?: (context: Readonly<{
|
||||
modelId: string | null;
|
||||
candidateRange: RectificationV4Case["calculationSpec"]["candidateRange"];
|
||||
snapshot: CandidateSnapshot | null;
|
||||
turns: ClaimedRectificationV4Job["turns"];
|
||||
events: readonly LifeEventRevision[];
|
||||
attemptedRefinementEventIds: readonly string[];
|
||||
}>) => Promise<RectificationV4Question>;
|
||||
}) {
|
||||
const workerId = input.workerId ?? randomUUID();
|
||||
const now = input.now ?? (() => new Date());
|
||||
@@ -70,14 +83,20 @@ export function createRectificationV4Worker(input: {
|
||||
};
|
||||
}
|
||||
await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() });
|
||||
const covered = events.map((event) => event.domain);
|
||||
const asked = [...covered, ...(claimed.turn.questionDomain ? [claimed.turn.questionDomain] : [])];
|
||||
const nextQuestion = snapshot?.canAcceptRange ? null : planNextQuestion({
|
||||
askedDomains: [...new Set(asked)] as EvidenceDomain[],
|
||||
coveredDomains: [...new Set(covered)] as EvidenceDomain[],
|
||||
events,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
});
|
||||
const nextQuestion = snapshot?.canAcceptRange ? null : input.questionAuthor
|
||||
? await input.questionAuthor({
|
||||
modelId: claimed.turn.modelId,
|
||||
candidateRange: claimed.case.calculationSpec.candidateRange,
|
||||
snapshot,
|
||||
turns: claimed.turns,
|
||||
events,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
})
|
||||
: planNextQuestion({
|
||||
events,
|
||||
attemptedRefinementEventIds: claimed.attemptedRefinementEventIds,
|
||||
latestAnswer: claimed.turn.answer,
|
||||
});
|
||||
await input.store.completeJob({
|
||||
workerId,
|
||||
jobId: claimed.job.id,
|
||||
|
||||
Reference in New Issue
Block a user