diff --git a/packages/core/src/discourse-state.ts b/packages/core/src/discourse-state.ts index 63264c7..383c617 100644 --- a/packages/core/src/discourse-state.ts +++ b/packages/core/src/discourse-state.ts @@ -71,7 +71,7 @@ export const timeSpecSchema = z.discriminatedUnion("kind", [ export type TimeSpec = z.infer; -// Kept for entity slotSnapshot fields (proposal_option, task_draft) — not used in discourse state. +// Kept for slot-extractor schemas — not used in discourse state. export const resolvedSlotsSchema = z.object({ day: z.string().optional(), time: timeSpecSchema.optional(), diff --git a/packages/core/src/index.test.ts b/packages/core/src/index.test.ts index ac79d15..6f40ca9 100644 --- a/packages/core/src/index.test.ts +++ b/packages/core/src/index.test.ts @@ -1146,7 +1146,7 @@ describe("core package", () => { route: "conversation_then_mutation", replyText: "It sounds like you want to move the dentist reminder after lunch.", - slotSnapshot: {}, + fieldSnapshot: {}, }, }, ], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa1086f..ade7c1c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { conversationDiscourseStateSchema, pendingWriteOperationSchema, + resolvedFieldsSchema, resolvedSlotsSchema, } from "./discourse-state"; @@ -821,8 +822,8 @@ export const conversationProposalOptionEntitySchema = .optional(), confirmationRequired: z.boolean().optional(), originatingTurnText: z.string().min(1).nullable().optional(), - missingSlots: z.array(z.string().min(1)).optional(), - slotSnapshot: resolvedSlotsSchema, + missingFields: z.array(z.string().min(1)).optional(), + fieldSnapshot: resolvedFieldsSchema, }), }); diff --git a/packages/core/src/proposal-rules.test.ts b/packages/core/src/proposal-rules.test.ts index d616b76..02960ad 100644 --- a/packages/core/src/proposal-rules.test.ts +++ b/packages/core/src/proposal-rules.test.ts @@ -1,24 +1,23 @@ import { describe, expect, it } from "vitest"; -import type { - ConversationEntity, - ResolvedFields, - ResolvedSlots, - TimeSpec, -} from "./index"; +import type { ConversationEntity, ResolvedFields, TimeSpec } from "./index"; import { deriveProposalCompatibility } from "./proposal-rules"; function t(hour: number, minute: number): TimeSpec { return { kind: "absolute", hour, minute }; } -function sf(slots: ResolvedSlots): ResolvedFields { - return { scheduleFields: slots }; +type ScheduleFields = NonNullable; + +function sf(fields: ScheduleFields): ResolvedFields { + return { scheduleFields: fields }; } type ProposalOption = Extract; function makeProposal( - overrides: Partial & { slotSnapshot: ResolvedSlots }, + overrides: Partial & { + fieldSnapshot: ResolvedFields; + }, ): ProposalOption { return { id: "proposal-1", @@ -38,10 +37,10 @@ function makeProposal( } describe("deriveProposalCompatibility", () => { - describe("slot-based compatibility", () => { - it("is compatible when committed slots match the snapshot", () => { + describe("field-based compatibility", () => { + it("is compatible when committed fields match the snapshot", () => { const proposal = makeProposal({ - slotSnapshot: { time: t(15, 0), day: "friday" }, + fieldSnapshot: sf({ time: t(15, 0), day: "friday" }), }); const result = deriveProposalCompatibility( @@ -53,9 +52,9 @@ describe("deriveProposalCompatibility", () => { expect(result.compatible).toBe(true); }); - it("is incompatible when a committed slot differs from the snapshot", () => { + it("is incompatible when a committed field differs from the snapshot", () => { const proposal = makeProposal({ - slotSnapshot: { time: t(15, 0) }, + fieldSnapshot: sf({ time: t(15, 0) }), }); const result = deriveProposalCompatibility( @@ -68,9 +67,9 @@ describe("deriveProposalCompatibility", () => { expect(result.reason).toMatch(/differs from proposal snapshot/); }); - it("is compatible when committed slot is new (not in snapshot)", () => { + it("is compatible when committed field is new (not in snapshot)", () => { const proposal = makeProposal({ - slotSnapshot: { day: "friday" }, + fieldSnapshot: sf({ day: "friday" }), }); const result = deriveProposalCompatibility( @@ -82,9 +81,9 @@ describe("deriveProposalCompatibility", () => { expect(result.compatible).toBe(true); }); - it("is compatible when committed slots are empty", () => { + it("is compatible when committed fields are empty", () => { const proposal = makeProposal({ - slotSnapshot: { time: t(15, 0), day: "friday" }, + fieldSnapshot: sf({ time: t(15, 0), day: "friday" }), }); const result = deriveProposalCompatibility( @@ -98,7 +97,7 @@ describe("deriveProposalCompatibility", () => { it("detects duration change as incompatible", () => { const proposal = makeProposal({ - slotSnapshot: { duration: 30 }, + fieldSnapshot: sf({ duration: 30 }), }); const result = deriveProposalCompatibility( @@ -116,7 +115,7 @@ describe("deriveProposalCompatibility", () => { it("is incompatible when action kind changes from plan to edit", () => { const proposal = makeProposal({ originatingTurnText: "schedule a meeting", - slotSnapshot: { time: t(15, 0) }, + fieldSnapshot: sf({ time: t(15, 0) }), }); const result = deriveProposalCompatibility( @@ -132,7 +131,7 @@ describe("deriveProposalCompatibility", () => { it("skips action kind check for clarification answers", () => { const proposal = makeProposal({ originatingTurnText: "move the meeting", - slotSnapshot: { time: t(15, 0) }, + fieldSnapshot: sf({ time: t(15, 0) }), }); const result = deriveProposalCompatibility( diff --git a/packages/core/src/proposal-rules.ts b/packages/core/src/proposal-rules.ts index 0a67a12..45e097b 100644 --- a/packages/core/src/proposal-rules.ts +++ b/packages/core/src/proposal-rules.ts @@ -1,7 +1,6 @@ import type { ConversationEntity, ResolvedFields, - ResolvedSlots, TurnClassifierOutput, TurnInterpretationType, } from "./index"; @@ -101,15 +100,18 @@ export function matchesProposalTarget( return resolvedEntityIds.includes(targetEntityId); } +type ScheduleFields = NonNullable; + export function deriveProposalCompatibility( turnType: TurnInterpretationType, resolvedFields: ResolvedFields, proposal: ProposalOption, ) { - const committedSlots = flattenScheduleFields(resolvedFields); + const committedSchedule = resolvedFields.scheduleFields ?? {}; + const snapshotSchedule = proposal.data.fieldSnapshot.scheduleFields ?? {}; if (turnType === "clarification_answer") { - return deriveSlotsCompatibility(committedSlots, proposal.data.slotSnapshot); + return deriveFieldsCompatibility(committedSchedule, snapshotSchedule); } const currentActionKind = turnType === "edit_request" ? "edit" : "plan"; @@ -124,24 +126,18 @@ export function deriveProposalCompatibility( }; } - return deriveSlotsCompatibility(committedSlots, proposal.data.slotSnapshot); -} - -// Adapter: flatten grouped schedule fields back to ResolvedSlots for -// comparison against proposal.data.slotSnapshot (entity shape not yet migrated). -function flattenScheduleFields(fields: ResolvedFields): ResolvedSlots { - return fields.scheduleFields ?? {}; + return deriveFieldsCompatibility(committedSchedule, snapshotSchedule); } -function deriveSlotsCompatibility( - committedSlots: ResolvedSlots, - snapshotSlots: ResolvedSlots, +function deriveFieldsCompatibility( + committedFields: ScheduleFields, + snapshotFields: ScheduleFields, ) { - const scalarKeys = ["day", "duration", "target"] as const; + const scalarKeys = ["day", "duration"] as const; for (const key of scalarKeys) { - const committed = committedSlots[key]; - const snapshot = snapshotSlots[key]; + const committed = committedFields[key]; + const snapshot = snapshotFields[key]; if ( committed !== undefined && @@ -150,19 +146,19 @@ function deriveSlotsCompatibility( ) { return { compatible: false, - reason: `Committed slot "${key}" differs from proposal snapshot, so it needs fresh consent.`, + reason: `Committed field "${key}" differs from proposal snapshot, so it needs fresh consent.`, }; } } if ( - committedSlots.time !== undefined && - snapshotSlots.time !== undefined && - !timeSpecsEqual(committedSlots.time, snapshotSlots.time) + committedFields.time !== undefined && + snapshotFields.time !== undefined && + !timeSpecsEqual(committedFields.time, snapshotFields.time) ) { return { compatible: false, - reason: `Committed slot "time" differs from proposal snapshot, so it needs fresh consent.`, + reason: `Committed field "time" differs from proposal snapshot, so it needs fresh consent.`, }; } diff --git a/packages/core/src/synthesize-mutation-text.test.ts b/packages/core/src/synthesize-mutation-text.test.ts index f821ee0..ff0dcdd 100644 --- a/packages/core/src/synthesize-mutation-text.test.ts +++ b/packages/core/src/synthesize-mutation-text.test.ts @@ -15,7 +15,7 @@ function buildProposalEntity( overrides: Partial<{ id: string; originatingTurnText: string | null; - missingSlots: string[]; + missingFields: string[]; targetEntityId: string | null; replyText: string; }> = {}, @@ -36,8 +36,8 @@ function buildProposalEntity( ? overrides.originatingTurnText! : "schedule dentist tomorrow", targetEntityId: overrides.targetEntityId ?? null, - missingSlots: overrides.missingSlots ?? [], - slotSnapshot: {}, + missingFields: overrides.missingFields ?? [], + fieldSnapshot: {}, }, }; } @@ -65,14 +65,14 @@ function buildInput( overrides: Partial = {}, ): SynthesizeMutationTextInput { return { - resolvedSlots: {}, + resolvedFields: {}, entityRegistry: [], ...overrides, }; } describe("synthesizeMutationText", () => { - it("returns originatingTurnText unchanged when no missing slots", () => { + it("returns originatingTurnText unchanged when no missing fields", () => { const result = synthesizeMutationText( buildInput({ proposalEntity: buildProposalEntity({ @@ -87,13 +87,13 @@ describe("synthesizeMutationText", () => { }); }); - it("augments originatingTurnText with resolved time slot", () => { + it("augments originatingTurnText with resolved time field", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: { time: t(15, 0) }, + resolvedFields: { scheduleFields: { time: t(15, 0) } }, proposalEntity: buildProposalEntity({ originatingTurnText: "schedule dentist tomorrow", - missingSlots: ["time"], + missingFields: ["time"], }), }), ); @@ -104,13 +104,15 @@ describe("synthesizeMutationText", () => { }); }); - it("augments with multiple missing slots", () => { + it("augments with multiple missing fields", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: { day: "friday", time: t(9, 30), duration: 60 }, + resolvedFields: { + scheduleFields: { day: "friday", time: t(9, 30), duration: 60 }, + }, proposalEntity: buildProposalEntity({ originatingTurnText: "schedule dentist", - missingSlots: ["day", "time", "duration"], + missingFields: ["day", "time", "duration"], }), }), ); @@ -121,13 +123,15 @@ describe("synthesizeMutationText", () => { }); }); - it("does not augment slots that were not in missingSlots", () => { + it("does not augment fields that were not in missingFields", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: { day: "friday", time: t(15, 0) }, + resolvedFields: { + scheduleFields: { day: "friday", time: t(15, 0) }, + }, proposalEntity: buildProposalEntity({ originatingTurnText: "schedule dentist on friday", - missingSlots: ["time"], + missingFields: ["time"], }), }), ); @@ -143,7 +147,7 @@ describe("synthesizeMutationText", () => { buildInput({ proposalEntity: buildProposalEntity({ originatingTurnText: "mark journaling as done", - missingSlots: [], + missingFields: [], }), }), ); @@ -158,10 +162,10 @@ describe("synthesizeMutationText", () => { const taskEntity = buildTaskEntity("entity-1", "Team standup"); const result = synthesizeMutationText( buildInput({ - resolvedSlots: { target: "entity-1" }, + targetEntityId: "entity-1", proposalEntity: buildProposalEntity({ originatingTurnText: "schedule it tomorrow at 3pm", - missingSlots: ["target"], + missingFields: ["target"], }), entityRegistry: [taskEntity], }), @@ -173,10 +177,12 @@ describe("synthesizeMutationText", () => { }); }); - it("falls back to slot-only synthesis when no originatingTurnText", () => { + it("falls back to field-only synthesis when no originatingTurnText", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: { day: "tomorrow", time: t(15, 0) }, + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(15, 0) }, + }, proposalEntity: buildProposalEntity({ originatingTurnText: null, }), @@ -189,11 +195,14 @@ describe("synthesizeMutationText", () => { }); }); - it("falls back to slot-only synthesis when no proposal entity", () => { + it("falls back to field-only synthesis when no proposal entity", () => { const taskEntity = buildTaskEntity("entity-1", "Dentist"); const result = synthesizeMutationText( buildInput({ - resolvedSlots: { target: "entity-1", day: "friday", time: t(14, 0) }, + resolvedFields: { + scheduleFields: { day: "friday", time: t(14, 0) }, + }, + targetEntityId: "entity-1", entityRegistry: [taskEntity], }), ); @@ -204,39 +213,39 @@ describe("synthesizeMutationText", () => { }); }); - it("returns insufficient_data when no proposal and no useful slots", () => { + it("returns insufficient_data when no proposal and no useful fields", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: {}, + resolvedFields: {}, }), ); expect(result).toEqual({ outcome: "insufficient_data", reason: - "No originating turn text and insufficient resolved slots to synthesize a mutation request.", + "No originating turn text and insufficient resolved fields to synthesize a mutation request.", }); }); - it("returns insufficient_data when only target slot with unknown entity", () => { + it("returns insufficient_data when only target with unknown entity", () => { const result = synthesizeMutationText( buildInput({ - resolvedSlots: { target: "unknown-entity" }, + targetEntityId: "unknown-entity", }), ); expect(result).toEqual({ outcome: "insufficient_data", reason: - "No originating turn text and insufficient resolved slots to synthesize a mutation request.", + "No originating turn text and insufficient resolved fields to synthesize a mutation request.", }); }); - it("synthesizes from target-only slot when entity exists", () => { + it("synthesizes from target-only when entity exists", () => { const taskEntity = buildTaskEntity("entity-1", "Dentist"); const result = synthesizeMutationText( buildInput({ - resolvedSlots: { target: "entity-1" }, + targetEntityId: "entity-1", entityRegistry: [taskEntity], }), ); diff --git a/packages/core/src/synthesize-mutation-text.ts b/packages/core/src/synthesize-mutation-text.ts index 29d32a0..57a5585 100644 --- a/packages/core/src/synthesize-mutation-text.ts +++ b/packages/core/src/synthesize-mutation-text.ts @@ -1,19 +1,20 @@ import type { z } from "zod"; -import type { resolvedSlotsSchema } from "./discourse-state"; +import type { resolvedFieldsSchema } from "./discourse-state"; import type { conversationEntitySchema, conversationProposalOptionEntitySchema, } from "./index"; import { formatTimeSpec } from "./time-spec"; -type ResolvedSlots = z.infer; +type ResolvedFields = z.infer; type ConversationEntity = z.infer; type ProposalOptionEntity = z.infer< typeof conversationProposalOptionEntitySchema >; export type SynthesizeMutationTextInput = { - resolvedSlots: ResolvedSlots; + resolvedFields: ResolvedFields; + targetEntityId?: string | undefined; proposalEntity?: ProposalOptionEntity | undefined; entityRegistry: ConversationEntity[]; }; @@ -25,15 +26,19 @@ export type SynthesizeMutationTextResult = export function synthesizeMutationText( input: SynthesizeMutationTextInput, ): SynthesizeMutationTextResult { - const { resolvedSlots, proposalEntity, entityRegistry } = input; + const { resolvedFields, proposalEntity, entityRegistry } = input; + const scheduleFields = resolvedFields.scheduleFields ?? {}; + const targetEntityId = + input.targetEntityId ?? proposalEntity?.data.targetEntityId ?? undefined; const originatingText = proposalEntity?.data.originatingTurnText; - const missingSlots = new Set(proposalEntity?.data.missingSlots ?? []); + const missingFields = new Set(proposalEntity?.data.missingFields ?? []); if (originatingText) { - const augmentations = buildSlotAugmentations( - resolvedSlots, - missingSlots, + const augmentations = buildFieldAugmentations( + scheduleFields, + targetEntityId, + missingFields, entityRegistry, ); const text = @@ -43,7 +48,11 @@ export function synthesizeMutationText( return { outcome: "synthesized", text }; } - const synthesized = buildFromSlotsOnly(resolvedSlots, entityRegistry); + const synthesized = buildFromFieldsOnly( + scheduleFields, + targetEntityId, + entityRegistry, + ); if (synthesized) { return { outcome: "synthesized", text: synthesized }; } @@ -51,28 +60,31 @@ export function synthesizeMutationText( return { outcome: "insufficient_data", reason: - "No originating turn text and insufficient resolved slots to synthesize a mutation request.", + "No originating turn text and insufficient resolved fields to synthesize a mutation request.", }; } -function buildSlotAugmentations( - slots: ResolvedSlots, - missingSlots: Set, +type ScheduleFields = NonNullable; + +function buildFieldAugmentations( + fields: ScheduleFields, + targetEntityId: string | undefined, + missingFields: Set, entityRegistry: ConversationEntity[], ): string[] { const parts: string[] = []; - if (slots.day && missingSlots.has("day")) { - parts.push(`on ${slots.day}`); + if (fields.day && missingFields.has("day")) { + parts.push(`on ${fields.day}`); } - if (slots.time && missingSlots.has("time")) { - parts.push(`at ${formatTimeSpec(slots.time)}`); + if (fields.time && missingFields.has("time")) { + parts.push(`at ${formatTimeSpec(fields.time)}`); } - if (slots.duration != null && missingSlots.has("duration")) { - parts.push(formatDurationForPlanner(slots.duration)); + if (fields.duration != null && missingFields.has("duration")) { + parts.push(formatDurationForPlanner(fields.duration)); } - if (slots.target && missingSlots.has("target")) { - const name = resolveEntityName(slots.target, entityRegistry); + if (targetEntityId && missingFields.has("target")) { + const name = resolveEntityName(targetEntityId, entityRegistry); if (name) { parts.push(`for ${name}`); } @@ -81,32 +93,33 @@ function buildSlotAugmentations( return parts; } -function buildFromSlotsOnly( - slots: ResolvedSlots, +function buildFromFieldsOnly( + fields: ScheduleFields, + targetEntityId: string | undefined, entityRegistry: ConversationEntity[], ): string | null { const parts: string[] = []; - const targetName = slots.target - ? resolveEntityName(slots.target, entityRegistry) + const targetName = targetEntityId + ? resolveEntityName(targetEntityId, entityRegistry) : null; if (targetName) { parts.push(`Schedule ${targetName}`); - } else if (slots.day || slots.time) { + } else if (fields.day || fields.time) { parts.push("Schedule"); } else { return null; } - if (slots.day) { - parts.push(`on ${slots.day}`); + if (fields.day) { + parts.push(`on ${fields.day}`); } - if (slots.time) { - parts.push(`at ${formatTimeSpec(slots.time)}`); + if (fields.time) { + parts.push(`at ${formatTimeSpec(fields.time)}`); } - if (slots.duration != null) { - parts.push(formatDurationForPlanner(slots.duration)); + if (fields.duration != null) { + parts.push(formatDurationForPlanner(fields.duration)); } return parts.join(" ");