From 5469dcfd76cd803298a7f42225504a2edd453a73 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Sat, 28 Mar 2026 14:45:58 -0700 Subject: [PATCH] Implement phase 2 write interpretation --- .../app/api/telegram/webhook/route.test.ts | 75 ++-- .../src/lib/server/decide-turn-policy.test.ts | 1 + .../src/lib/server/interpret-write-turn.ts | 55 +++ apps/web/src/lib/server/turn-router.test.ts | 168 +++++++-- apps/web/src/lib/server/turn-router.ts | 102 ++---- docs/architecture/planning-request-flow.md | 97 +++--- docs/current-work.md | 5 + packages/core/src/commit-policy.test.ts | 325 +++++++----------- packages/core/src/commit-policy.ts | 178 +--------- packages/core/src/discourse-state.ts | 1 + packages/core/src/index.ts | 70 ++++ packages/core/src/write-commit.ts | 303 ++++++++++++++++ packages/core/src/write-interpretation.ts | 80 +++++ packages/integrations/src/index.test.ts | 70 ++++ packages/integrations/src/openai.ts | 56 +++ .../src/prompts/interpret-write-turn.ts | 55 +++ 16 files changed, 1094 insertions(+), 547 deletions(-) create mode 100644 apps/web/src/lib/server/interpret-write-turn.ts create mode 100644 packages/core/src/write-commit.ts create mode 100644 packages/core/src/write-interpretation.ts create mode 100644 packages/integrations/src/prompts/interpret-write-turn.ts diff --git a/apps/web/src/app/api/telegram/webhook/route.test.ts b/apps/web/src/app/api/telegram/webhook/route.test.ts index 7ebccd0..1344a81 100644 --- a/apps/web/src/app/api/telegram/webhook/route.test.ts +++ b/apps/web/src/app/api/telegram/webhook/route.test.ts @@ -44,7 +44,7 @@ const { summarizeConversationMemoryWithResponsesMock, respondToConversationTurnWithResponsesMock, recoverConfirmedMutationWithResponsesMock, - extractSlotsWithResponsesMock, + interpretWriteTurnWithResponsesMock, } = vi.hoisted(() => ({ editTelegramMessageMock: vi.fn(), sendTelegramMessageMock: vi.fn(), @@ -54,7 +54,7 @@ const { summarizeConversationMemoryWithResponsesMock: vi.fn(), respondToConversationTurnWithResponsesMock: vi.fn(), recoverConfirmedMutationWithResponsesMock: vi.fn(), - extractSlotsWithResponsesMock: vi.fn(), + interpretWriteTurnWithResponsesMock: vi.fn(), })); vi.mock("@atlas/integrations", async () => { @@ -102,7 +102,7 @@ vi.mock("@atlas/integrations", async () => { recoverConfirmedMutationWithResponsesMock, classifyTurnWithResponses: classifyTurnWithResponsesMock, routeTurnWithResponses: routeTurnWithResponsesMock, - extractSlotsWithResponses: extractSlotsWithResponsesMock, + interpretWriteTurnWithResponses: interpretWriteTurnWithResponsesMock, sendTelegramChatAction: sendTelegramChatActionMock, sendTelegramMessage: sendTelegramMessageMock, summarizeConversationMemoryWithResponses: @@ -367,14 +367,25 @@ beforeEach(async () => { summarizeConversationMemoryWithResponsesMock.mockReset(); respondToConversationTurnWithResponsesMock.mockReset(); recoverConfirmedMutationWithResponsesMock.mockReset(); - extractSlotsWithResponsesMock.mockReset(); - extractSlotsWithResponsesMock.mockResolvedValue({ - time: { kind: "absolute", hour: 9, minute: 0 }, - day: { kind: "relative", value: "tomorrow" }, - duration: null, - target: null, - confidence: { day: 0.95, time: 0.95 }, - unresolvable: [], + interpretWriteTurnWithResponsesMock.mockReset(); + interpretWriteTurnWithResponsesMock.mockResolvedValue({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { + scheduleFields: { + time: { kind: "absolute", hour: 9, minute: 0 }, + day: { kind: "relative", value: "tomorrow" }, + duration: null, + }, + taskFields: null, + }, + confidence: { + "scheduleFields.day": 0.95, + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], }); routeTurnWithResponsesMock.mockResolvedValue({ route: "mutation", @@ -842,13 +853,17 @@ describe("telegram webhook route", () => { it("normalizes a Telegram text message and routes to clarification when slots are missing", async () => { process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret"; - extractSlotsWithResponsesMock.mockResolvedValueOnce({ - time: null, - day: null, - duration: null, - target: null, + interpretWriteTurnWithResponsesMock.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, confidence: {}, - unresolvable: [], + unresolvedFields: [], }); const response = await handleTelegramWebhook( @@ -1058,13 +1073,25 @@ describe("telegram webhook route", () => { it("does not keep clear scheduling requests in discuss-first mode", async () => { process.env.TELEGRAM_WEBHOOK_SECRET = "test-webhook-secret"; - extractSlotsWithResponsesMock.mockResolvedValueOnce({ - time: { kind: "absolute", hour: 18, minute: 0 }, - day: { kind: "relative", value: "tomorrow" }, - duration: { minutes: 60 }, - target: null, - confidence: { day: 0.95, time: 0.95, duration: 0.9 }, - unresolvable: [], + interpretWriteTurnWithResponsesMock.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { + scheduleFields: { + time: { kind: "absolute", hour: 18, minute: 0 }, + day: { kind: "relative", value: "tomorrow" }, + duration: { minutes: 60 }, + }, + taskFields: null, + }, + confidence: { + "scheduleFields.day": 0.95, + "scheduleFields.time": 0.95, + "scheduleFields.duration": 0.9, + }, + unresolvedFields: [], }); const response = await handleTelegramWebhook( diff --git a/apps/web/src/lib/server/decide-turn-policy.test.ts b/apps/web/src/lib/server/decide-turn-policy.test.ts index c46ace2..a513136 100644 --- a/apps/web/src/lib/server/decide-turn-policy.test.ts +++ b/apps/web/src/lib/server/decide-turn-policy.test.ts @@ -20,6 +20,7 @@ const emptyCommit: CommitPolicyOutput = { needsClarification: [], missingFields: [], workflowChanged: false, + committedFieldPaths: [], }; function input( diff --git a/apps/web/src/lib/server/interpret-write-turn.ts b/apps/web/src/lib/server/interpret-write-turn.ts new file mode 100644 index 0000000..d350713 --- /dev/null +++ b/apps/web/src/lib/server/interpret-write-turn.ts @@ -0,0 +1,55 @@ +import { + normalizeRawWriteInterpretation, + rawWriteInterpretationSchema, + type WriteInterpretation, + type WriteInterpretationInput, +} from "@atlas/core"; +import { + interpretWriteTurnWithResponses, + type OpenAIResponsesClient, +} from "@atlas/integrations"; + +export async function interpretWriteTurn( + input: WriteInterpretationInput, + client?: OpenAIResponsesClient, +): Promise { + try { + const raw = await interpretWriteTurnWithResponses(input, client); + const parsed = rawWriteInterpretationSchema.safeParse(raw); + + if (!parsed.success) { + return fallbackInterpretation(input); + } + + return normalizeRawWriteInterpretation(parsed.data, input.currentTurnText); + } catch { + return fallbackInterpretation(input); + } +} + +function fallbackInterpretation( + input: WriteInterpretationInput, +): WriteInterpretation { + return { + operationKind: + input.priorPendingWriteOperation?.operationKind ?? inferFallbackOperation(input.turnType), + actionDomain: "task", + targetRef: input.priorPendingWriteOperation?.targetRef ?? null, + taskName: null, + fields: {}, + sourceText: input.currentTurnText, + confidence: {}, + unresolvedFields: [], + }; +} + +function inferFallbackOperation(turnType: WriteInterpretationInput["turnType"]) { + switch (turnType) { + case "edit_request": + return "edit" as const; + case "clarification_answer": + case "planning_request": + default: + return "plan" as const; + } +} diff --git a/apps/web/src/lib/server/turn-router.test.ts b/apps/web/src/lib/server/turn-router.test.ts index f3655f3..6386e48 100644 --- a/apps/web/src/lib/server/turn-router.test.ts +++ b/apps/web/src/lib/server/turn-router.test.ts @@ -3,7 +3,7 @@ import type { TimeSpec, TurnClassifierOutput, } from "@atlas/core"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; function t(hour: number, minute: number): TimeSpec { return { kind: "absolute", hour, minute }; @@ -15,19 +15,38 @@ vi.mock("./llm-classifier", () => ({ classifyTurn: vi.fn(), })); -vi.mock("./slot-extractor", () => ({ - extractSlots: vi.fn().mockResolvedValue({ - extractedValues: {}, +vi.mock("./interpret-write-turn", () => ({ + interpretWriteTurn: vi.fn().mockResolvedValue({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: {}, + sourceText: "default", confidence: {}, - unresolvable: [], + unresolvedFields: [], }), })); import { classifyTurn } from "./llm-classifier"; -import { extractSlots } from "./slot-extractor"; +import { interpretWriteTurn } from "./interpret-write-turn"; const mockClassifyTurn = vi.mocked(classifyTurn); -const mockExtractSlots = vi.mocked(extractSlots); +const mockInterpretWriteTurn = vi.mocked(interpretWriteTurn); + +beforeEach(() => { + vi.clearAllMocks(); + mockInterpretWriteTurn.mockResolvedValue({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: {}, + sourceText: "default", + confidence: {}, + unresolvedFields: [], + }); +}); function mockClassification(output: Partial) { const full: TurnClassifierOutput = { @@ -45,10 +64,18 @@ describe("turn router", () => { turnType: "planning_request", confidence: 0.95, }); - mockExtractSlots.mockResolvedValueOnce({ - extractedValues: { day: "tomorrow", time: t(18, 0) }, - confidence: { day: 0.95, time: 0.95 }, - unresolvable: [], + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: "gym", + fields: { scheduleFields: { day: "tomorrow", time: t(18, 0) } }, + sourceText: "Schedule gym tomorrow at 6pm for 1 hour", + confidence: { + "scheduleFields.day": 0.95, + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], }); const result = await routeMessageTurn({ @@ -193,10 +220,18 @@ describe("turn router", () => { turnType: "planning_request", confidence: 0.95, }); - mockExtractSlots.mockResolvedValueOnce({ - extractedValues: { day: "tomorrow", time: t(18, 0) }, - confidence: { day: 0.95, time: 0.95 }, - unresolvable: [], + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: "gym", + fields: { scheduleFields: { day: "tomorrow", time: t(18, 0) } }, + sourceText: "Schedule gym tomorrow at 6pm", + confidence: { + "scheduleFields.day": 0.95, + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], }); const result = await routeMessageTurn({ @@ -228,10 +263,15 @@ describe("turn router", () => { turnType: "clarification_answer", confidence: 0.9, }); - mockExtractSlots.mockResolvedValueOnce({ - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.92 }, - unresolvable: [], + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(17, 0) } }, + sourceText: "5pm", + confidence: { "scheduleFields.time": 0.92 }, + unresolvedFields: [], }); const result = await routeMessageTurn({ @@ -260,10 +300,15 @@ describe("turn router", () => { turnType: "clarification_answer", confidence: 0.9, }); - mockExtractSlots.mockResolvedValueOnce({ - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.92 }, - unresolvable: [], + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(17, 0) } }, + sourceText: "5pm", + confidence: { "scheduleFields.time": 0.92 }, + unresolvedFields: [], }); const result = await routeMessageTurn({ @@ -284,10 +329,15 @@ describe("turn router", () => { confidence: 0.95, resolvedProposalId: "proposal-1", }); - mockExtractSlots.mockResolvedValueOnce({ - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.92 }, - unresolvable: [], + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(17, 0) } }, + sourceText: "ok but make it 5pm", + confidence: { "scheduleFields.time": 0.92 }, + unresolvedFields: [], }); const result = await routeMessageTurn({ @@ -371,6 +421,70 @@ describe("turn router", () => { expect(result.interpretation.turnType).toBe("confirmation"); }); + + it("does not run write interpretation for informational turns", async () => { + mockClassification({ + turnType: "informational", + confidence: 0.9, + }); + + await routeMessageTurn({ + rawText: "What's later today?", + normalizedText: "What's later today?", + recentTurns: [], + }); + + expect(mockInterpretWriteTurn).not.toHaveBeenCalled(); + }); + + it("clears prior committed fields when the interpreted workflow changes", async () => { + mockClassification({ + turnType: "planning_request", + confidence: 0.94, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(11, 0) } }, + sourceText: "Move it to 11", + confidence: { "scheduleFields.time": 0.94 }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "Move it to 11", + normalizedText: "Move it to 11", + recentTurns: [], + discourseState: { + focus_entity_id: null, + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + pending_write_operation: { + operationKind: "plan", + targetRef: null, + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(18, 0) }, + }, + missingFields: [], + originatingText: "Schedule gym tomorrow at 6", + startedAt: new Date().toISOString(), + }, + mode: "planning", + }, + }); + + expect(result.policy.resolvedOperation).toMatchObject({ + operationKind: "edit", + resolvedFields: { scheduleFields: { time: t(11, 0) } }, + }); + expect( + result.policy.resolvedOperation?.resolvedFields.scheduleFields?.day, + ).toBeUndefined(); + }); }); describe("containsModificationPayload", () => { diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index 85957d8..3c9aabc 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -1,47 +1,29 @@ import { - applyCommitPolicy, - type CommitPolicyOutput, + applyWriteCommit, type ConversationEntity, type ConversationTurn, createEmptyDiscourseState, deriveAmbiguity, - type OperationKind, type PendingWriteOperation, type RoutedTurn, - resolveOperationKind, routedTurnSchema, - FIELD_COMMITTING_TURN_TYPES, type TurnAmbiguity, type TurnClassifierOutput, type TurnInterpretation, type TurnPolicyAction, type TurnRoute, type TurnRoutingInput, + type WriteCommitOutput, + WRITE_INTERPRETING_TURN_TYPES, } from "@atlas/core"; import { decideTurnPolicy } from "./decide-turn-policy"; import { classifyTurn } from "./llm-classifier"; -import { extractSlots } from "./slot-extractor"; +import { interpretWriteTurn } from "./interpret-write-turn"; export type TurnRouterInput = TurnRoutingInput; export type TurnRouterResult = RoutedTurn; -// Required schedule fields per operation kind — mirrors commit-policy internals. -function requiredScheduleFieldsForOperation( - operationKind: OperationKind, -): ("day" | "time" | "duration")[] { - switch (operationKind) { - case "plan": - return ["day", "time"]; - case "edit": - case "reschedule": - return ["time"]; - case "complete": - case "archive": - return []; - } -} - export async function routeMessageTurn( input: TurnRouterInput, ): Promise { @@ -77,42 +59,32 @@ export async function routeMessageTurn( } } - // Pipeline B: extract fields via the slot extractor (conditional) + // Pipeline B: interpret write intent for write-capable turns only. const priorOperation = discourseState.pending_write_operation; - const operationKind = - resolveOperationKind({ - turnType: classification.turnType, - priorOperationKind: priorOperation?.operationKind, - }) ?? "plan"; - - let fieldExtraction = null; - - if (FIELD_COMMITTING_TURN_TYPES.has(classification.turnType)) { - const priorScheduleFields = - priorOperation?.resolvedFields.scheduleFields ?? {}; - const pendingScheduleFields = requiredScheduleFieldsForOperation( - operationKind, - ).filter( - (fieldKey) => - priorScheduleFields[fieldKey as keyof typeof priorScheduleFields] === - undefined, - ); - - fieldExtraction = await extractSlots({ - currentTurnText: input.normalizedText, - pendingSlots: pendingScheduleFields, - priorResolvedSlots: priorScheduleFields, - conversationContext: deriveConversationContext(input.recentTurns), - }); - } + const writeInterpretation = WRITE_INTERPRETING_TURN_TYPES.has( + classification.turnType, + ) + ? await interpretWriteTurn({ + currentTurnText: input.normalizedText, + turnType: classification.turnType, + priorPendingWriteOperation: priorOperation, + conversationContext: deriveConversationContext(input.recentTurns), + }) + : { + operationKind: priorOperation?.operationKind ?? "plan", + actionDomain: "task", + targetRef: priorOperation?.targetRef ?? null, + taskName: null, + fields: {}, + sourceText: input.normalizedText, + confidence: {}, + unresolvedFields: [], + }; // Policy layer: commit + route - const commitResult = applyCommitPolicy({ + const commitResult = applyWriteCommit({ turnType: classification.turnType, - extractedValues: fieldExtraction?.extractedValues ?? {}, - confidence: compactConfidence(fieldExtraction?.confidence ?? {}), - unresolvable: fieldExtraction?.unresolvable ?? [], - operationKind, + interpretation: writeInterpretation, priorPendingWriteOperation: priorOperation, ...(classification.resolvedEntityIds[0] !== undefined ? { currentTargetEntityId: classification.resolvedEntityIds[0] } @@ -131,10 +103,10 @@ export async function routeMessageTurn( const resolvedOperation = policy.action !== "reply_only" ? buildResolvedOperation( - operationKind, + writeInterpretation.operationKind, commitResult, priorOperation, - input.normalizedText, + writeInterpretation.sourceText, ) : undefined; @@ -148,8 +120,8 @@ export async function routeMessageTurn( } function buildResolvedOperation( - operationKind: OperationKind, - commitResult: CommitPolicyOutput, + operationKind: PendingWriteOperation["operationKind"], + commitResult: WriteCommitOutput, priorOperation: PendingWriteOperation | undefined, currentTurnText: string, ): PendingWriteOperation { @@ -177,7 +149,7 @@ function deriveConversationContext(recentTurns: ConversationTurn[]): string { function buildInterpretation( classification: TurnClassifierOutput, - commitResult: CommitPolicyOutput, + commitResult: WriteCommitOutput, ): TurnInterpretation { const allMissingFields = unique([ ...commitResult.missingFields, @@ -231,18 +203,6 @@ function unique(values: string[]) { return Array.from(new Set(values)); } -function compactConfidence( - confidence: Record, -): Partial> { - const result: Partial> = {}; - for (const [key, value] of Object.entries(confidence)) { - if (typeof value === "number") { - result[key as "day" | "time" | "duration"] = value; - } - } - return result; -} - export function doesPolicyAllowWrites(action: TurnPolicyAction) { return action === "execute_mutation" || action === "recover_and_execute"; } diff --git a/docs/architecture/planning-request-flow.md b/docs/architecture/planning-request-flow.md index c4270c0..50a777a 100644 --- a/docs/architecture/planning-request-flow.md +++ b/docs/architecture/planning-request-flow.md @@ -143,27 +143,33 @@ signals (time, day, duration, or words like "but"/"instead"/"actually"), `clarification_answer`. This prevents edits from being silently dropped when a user says something like "yes but make it 3pm". -### Step B — Slot extraction +### Step B — Write interpretation -**File:** `apps/web/src/lib/server/slot-extractor.ts` → `extractSlots()` -**Downstream:** `packages/integrations/src/prompts/slot-extractor.ts` +**File:** `apps/web/src/lib/server/interpret-write-turn.ts` → `interpretWriteTurn()` +**Downstream:** `packages/integrations/src/prompts/interpret-write-turn.ts` -Slot extraction runs **only** for `SLOT_COMMITTING_TURN_TYPES`: +Write interpretation runs only for write-capable turns: `planning_request`, `edit_request`, `clarification_answer`. -Before calling the LLM, `requiredSlotsForOperation()` computes which schedule -fields are still missing given the current `operationKind`: +Unlike the old slot-extractor seam, this stage does not precompute pending +fields before the LLM call. It interprets the whole turn in one pass and +returns a turn-scoped `WriteInterpretation` object. -- **`plan`**: requires `["day", "time"]` -- **`edit` / `reschedule`**: requires `["time"]` -- **`complete` / `archive`**: requires `[]` - -`resolveOperationKind()` (`packages/core`) derives the active operation: -- `planning_request` → `plan` -- `edit_request` → `edit` -- everything else → inherits `priorOperationKind` from `pending_write_operation` +**Output:** `WriteInterpretation` +```ts +{ + operationKind: "plan" | "edit" | "reschedule" | "complete" | "archive"; + actionDomain: string; + targetRef: TargetRef; + taskName: string | null; + fields: ResolvedFields; + sourceText: string; + confidence: Record; + unresolvedFields: string[]; +} +``` -**Schedule slot types** (grouped under `scheduleFields` in `ResolvedFields`): +**Schedule field types** (grouped under `fields.scheduleFields`): ```ts { day?: string; // e.g. "monday", "tomorrow" @@ -172,36 +178,36 @@ fields are still missing given the current `operationKind`: } ``` -Target entity resolution happens via `classification.resolvedEntityIds`, not -slot extraction. The slot normalizer (`packages/core/src/slot-normalizer.ts`) -validates ranges and converts raw LLM output into typed schedule values. - -**Output:** `{ extractedValues, confidence, unresolvable }` -- `confidence` is a per-slot score (0–1) -- `unresolvable` lists slots the LLM flagged as impossible to extract +Target entity resolution still stays on the classifier side in Phase 2 via +`classification.resolvedEntityIds`; `targetRef` is available for richer later +phases and descriptive continuity. The slot normalizer still validates ranges +and converts raw schedule values into typed fields. ### Step C — Commit policy -**File:** `packages/core/src/commit-policy.ts` → `applyCommitPolicy()` +**File:** `packages/core/src/write-commit.ts` → `applyWriteCommit()` -The commit policy is the gate between "LLM extracted something" and "the -system will act on it". Each extracted schedule field is individually evaluated: +The commit step is the gate between "LLM interpreted something" and "the +system will act on it". Each interpreted field is individually evaluated: | Condition | Result | |-----------|--------| -| Field is in `unresolvable` | → `needsClarification` | -| `confidence[field] < 0.75` | → `needsClarification` | +| Field is in `unresolvedFields` | → `needsClarification` | +| `confidence[fieldPath] < 0.75` | → `needsClarification` | | Field corrects a prior value AND `confidence < 0.90` | → `needsClarification` | | Otherwise | → `resolvedFields.scheduleFields` | The correction threshold (0.90 vs 0.75) is higher because overwriting a field the user previously confirmed carries more risk. -**Workflow change detection:** if `operationKind` changed from the prior -operation, or if `currentTargetEntityId` differs from the prior -`targetRef.entityId`, all prior resolved schedule fields are discarded so stale -fields don't bleed into a new workflow. `workflowChanged: true` is surfaced in -the output so `buildResolvedOperation` can reset `originatingText`/`startedAt`. +**Required-field derivation:** required fields are derived inside commit from +`interpretation.operationKind`, not before interpretation. + +**Workflow change detection:** if the interpreted `operationKind` changed from +the prior operation, or if the effective target changed, all prior resolved +workflow fields are discarded so stale data does not bleed into a new workflow. +`workflowChanged: true` is surfaced so `buildResolvedOperation` can reset +`originatingText` and `startedAt`. **Target resolution:** `resolvedTargetRef` is the canonical next target — `currentTargetEntityId` when the classifier resolved one, otherwise carried @@ -210,11 +216,12 @@ forward from `priorPendingWriteOperation.targetRef`. **Output:** `CommitPolicyOutput` ```ts { - resolvedFields: ResolvedFields; // schedule fields safe to act on + resolvedFields: ResolvedFields; // grouped fields safe to act on resolvedTargetRef: TargetRef; // canonical target for this workflow step needsClarification: string[]; // dot-path fields not confident enough (e.g. "scheduleFields.time") missingFields: string[]; // required by operationKind, not yet resolved workflowChanged: boolean; // true when operation or target switched + committedFieldPaths: string[]; } ``` @@ -561,7 +568,7 @@ routing dispatch, execution branch selection, reply delivery, and state save. All dependencies are injected for testability. ### `apps/web/src/lib/server/turn-router.ts` -Pure pipeline. Composes classify → extract → commit → decide into a single +Pure pipeline. Composes classify → interpret → commit → decide into a single `RoutedTurn`. Contains `containsModificationPayload()` (compound confirmation guard) and `doesPolicyAllowWrites()` / `getConversationRouteForPolicy()` helpers used by the webhook orchestrator. @@ -570,8 +577,9 @@ helpers used by the webhook orchestrator. Intent classification. Fast-path for pure confirmations with an active proposal. Falls back to LLM via `@atlas/integrations`. -### `apps/web/src/lib/server/slot-extractor.ts` -Slot extraction. Calls LLM, normalizes output via `packages/core/src/slot-normalizer.ts`. +### `apps/web/src/lib/server/interpret-write-turn.ts` +Write interpretation boundary. Calls the unified write-interpretation prompt +and normalizes the raw response into `WriteInterpretation`. ### `apps/web/src/lib/server/decide-turn-policy.ts` Turn policy decision. Pure function. Maps intent + commit result + entity @@ -596,16 +604,13 @@ dispatches actions, writes to calendar and DB. The only file that calls User-facing reply renderer for mutation outcomes. Converts `ProcessedInboxResult` to a human-readable message in the user's timezone. -### `packages/core/src/commit-policy.ts` -Schedule field gating logic. Thresholds: 0.75 (normal), 0.90 (correction). -Operation/target change detection clears prior schedule fields and sets -`workflowChanged`. Outputs `resolvedTargetRef` as the canonical next target. +### `packages/core/src/write-commit.ts` +Grouped field gating logic. Thresholds: 0.75 (normal), 0.90 (correction). +Required fields are derived from `operationKind` inside commit. Operation or +target changes clear prior workflow fields and set `workflowChanged`. +Outputs `resolvedTargetRef` as the canonical next target. Pure function, no LLM calls. -### `packages/core/src/write-contract.ts` -`resolveOperationKind()` — derives the active `OperationKind` from turn type -and prior operation. Required fields per operation are owned by commit-policy. - ### `packages/core/src/discourse-state.ts` All discourse state schema types, helpers, and mode derivation. Contains `resolveReference()` (reference resolution fallback chain), clarification @@ -630,8 +635,8 @@ entity + entity registry + committed slots. ### `packages/integrations/src/prompts/turn-classifier.ts` LLM prompt for intent classification. -### `packages/integrations/src/prompts/slot-extractor.ts` -LLM prompt for slot extraction. +### `packages/integrations/src/prompts/interpret-write-turn.ts` +LLM prompt for unified write interpretation. ### `packages/integrations/src/prompts/planner.ts` LLM prompt for inbox item planning. Receives full planning context; outputs diff --git a/docs/current-work.md b/docs/current-work.md index d26f6be..c984f42 100644 --- a/docs/current-work.md +++ b/docs/current-work.md @@ -4,6 +4,7 @@ REMOVE: pr smoke test Atlas is a schedule-forward, Google-calendar-gated product with a working mutation pipeline. Current implementation focus: replace transcript-heavy conversation memory with an explicit conversation-state layer. Atlas now persists a user-scoped conversation snapshot with transcript, summary, entity registry, and discourse state so reference resolution can anchor on known objects instead of reconstructing intent from recent turns alone. Recent work still includes the prompt-asset cleanup, expanded live eval coverage around ambiguous routing and confirmed-mutation recovery, a consolidated `pnpm eval:all` loop plus suite-specific eval reports and prompt-improvement briefs for prompt iteration, chat-first prompt/docs framing, and consistent `referenceTime` threading through scheduling. +The turn-routing pipeline now uses a unified write-interpretation stage for write-capable turns: `classifyTurn -> interpretWriteTurn -> writeCommit -> decideTurnPolicy`. The old router-owned pre-gating around slot extraction is retired from the active path. DB rollout hardening is now part of the active release path: `packages/db` owns all Drizzle commands and config, root-level DB command aliases are removed, and production migrations are expected to run from GitHub Actions before Vercel production release. @@ -85,6 +86,10 @@ DB rollout hardening is now part of the active release path: `packages/db` owns - live scheduled commitment is now stored directly on `tasks` - `schedule_blocks` are no longer the active persisted runtime schedule record - planner-facing `schedule_block_*` aliases are reconstructed from task state for compatibility +- Phase 2 of the turn-routing refactor is now landed: + - write-capable turns use one interpretation call instead of a separate slot-extractor pass + - commit derives required fields from `operationKind` and commits grouped field paths + - classifier-owned entity resolution is intentionally still in place for now; moving that to the interpretation stage remains Phase 3 work - Webhook ingress is idempotent and persists canonical `inbox_items` before any planner mutation for linked users. - Unlinked allowlisted Telegram users are now short-circuited before ingress persistence with a signed Google connect reply. Atlas does not keep stale pre-link messages in v1. - Telegram webhook ingress is now protected by a required `TELEGRAM_ALLOWED_USER_IDS` allowlist; blocked users are rejected before inbox persistence, planning, or outbound delivery, and app config fails fast when that env var is missing. diff --git a/packages/core/src/commit-policy.test.ts b/packages/core/src/commit-policy.test.ts index 9cc1345..ee4c0f8 100644 --- a/packages/core/src/commit-policy.test.ts +++ b/packages/core/src/commit-policy.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { CommitPolicyInput } from "./commit-policy"; -import { applyCommitPolicy } from "./commit-policy"; -import type { PendingWriteOperation, TimeSpec } from "./index"; +import type { PendingWriteOperation, TimeSpec, WriteInterpretation } from "./index"; +import type { WriteCommitInput } from "./write-commit"; +import { applyWriteCommit } from "./write-commit"; function t(hour: number, minute: number): TimeSpec { return { kind: "absolute", hour, minute }; @@ -23,62 +23,75 @@ function priorOp( }; } -function buildInput(overrides: Partial): CommitPolicyInput { +function interpretation( + overrides: Partial, +): WriteInterpretation { return { - turnType: "planning_request", - extractedValues: {}, - confidence: {}, - unresolvable: [], operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: {}, + sourceText: "source turn", + confidence: {}, + unresolvedFields: [], + ...overrides, + }; +} + +function buildInput(overrides: Partial): WriteCommitInput { + return { + turnType: "planning_request", + interpretation: interpretation({}), ...overrides, }; } -describe("applyCommitPolicy", () => { +describe("applyWriteCommit", () => { it("does not commit fields for informational turns", () => { - const result = applyCommitPolicy( + const result = applyWriteCommit( buildInput({ turnType: "informational", - extractedValues: { time: t(15, 0) }, - confidence: { time: 0.95 }, - }), - ); - - expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); - }); - - it("does not commit fields for confirmation turns", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "confirmation", - extractedValues: { time: t(15, 0) }, - confidence: { time: 0.95 }, + interpretation: interpretation({ + fields: { scheduleFields: { time: t(15, 0) } }, + confidence: { "scheduleFields.time": 0.95 }, + }), }), ); expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); }); - it("commits fields above confidence threshold for planning_request", () => { - const result = applyCommitPolicy( + it("commits grouped schedule fields above threshold", () => { + const result = applyWriteCommit( buildInput({ - turnType: "planning_request", - extractedValues: { time: t(17, 0), day: "tomorrow" }, - confidence: { time: 0.9, day: 0.85 }, + interpretation: interpretation({ + fields: { + scheduleFields: { time: t(17, 0), day: "tomorrow" }, + }, + confidence: { + "scheduleFields.time": 0.9, + "scheduleFields.day": 0.85, + }, + }), }), ); expect(result.resolvedFields.scheduleFields?.time).toEqual(t(17, 0)); expect(result.resolvedFields.scheduleFields?.day).toBe("tomorrow"); - expect(result.needsClarification).toEqual([]); + expect(result.committedFieldPaths).toEqual([ + "scheduleFields.time", + "scheduleFields.day", + ]); }); - it("routes low confidence extraction to needsClarification", () => { - const result = applyCommitPolicy( + it("routes low-confidence grouped fields to clarification", () => { + const result = applyWriteCommit( buildInput({ - turnType: "planning_request", - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.6 }, + interpretation: interpretation({ + fields: { scheduleFields: { time: t(17, 0) } }, + confidence: { "scheduleFields.time": 0.6 }, + }), }), ); @@ -86,12 +99,14 @@ describe("applyCommitPolicy", () => { expect(result.needsClarification).toContain("scheduleFields.time"); }); - it("does not commit correction below correction threshold", () => { - const result = applyCommitPolicy( + it("does not commit corrections below the correction threshold", () => { + const result = applyWriteCommit( buildInput({ turnType: "clarification_answer", - extractedValues: { time: t(15, 0) }, - confidence: { time: 0.8 }, + interpretation: interpretation({ + fields: { scheduleFields: { time: t(15, 0) } }, + confidence: { "scheduleFields.time": 0.8 }, + }), priorPendingWriteOperation: priorOp("plan", { time: t(14, 0) }), }), ); @@ -100,12 +115,14 @@ describe("applyCommitPolicy", () => { expect(result.needsClarification).toContain("scheduleFields.time"); }); - it("commits correction at or above correction threshold", () => { - const result = applyCommitPolicy( + it("commits corrections at or above the correction threshold", () => { + const result = applyWriteCommit( buildInput({ turnType: "clarification_answer", - extractedValues: { time: t(15, 0) }, - confidence: { time: 0.92 }, + interpretation: interpretation({ + fields: { scheduleFields: { time: t(15, 0) } }, + confidence: { "scheduleFields.time": 0.92 }, + }), priorPendingWriteOperation: priorOp("plan", { time: t(14, 0) }), }), ); @@ -114,184 +131,75 @@ describe("applyCommitPolicy", () => { expect(result.needsClarification).not.toContain("scheduleFields.time"); }); - it("routes unresolvable fields to needsClarification", () => { - const result = applyCommitPolicy( + it("routes unresolved field paths to clarification", () => { + const result = applyWriteCommit( buildInput({ turnType: "clarification_answer", - extractedValues: {}, - unresolvable: ["time"], - }), - ); - - expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); - expect(result.needsClarification).toContain("scheduleFields.time"); - }); - - it("resets prior fields on operation kind change", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - extractedValues: { day: "friday" }, - confidence: { day: 0.9 }, - operationKind: "edit", - priorPendingWriteOperation: priorOp("plan", { - time: t(14, 0), - day: "tomorrow", + interpretation: interpretation({ + unresolvedFields: ["scheduleFields.time"], }), }), ); - expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); - expect(result.resolvedFields.scheduleFields?.day).toBe("friday"); - }); - - it("does not reset fields when operation kind is unchanged", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - extractedValues: { day: "friday" }, - confidence: { day: 0.9 }, - operationKind: "plan", - priorPendingWriteOperation: priorOp("plan", { time: t(14, 0) }), - }), - ); - - expect(result.resolvedFields.scheduleFields?.time).toEqual(t(14, 0)); - expect(result.resolvedFields.scheduleFields?.day).toBe("friday"); - }); - - it("derives missingFields from post-commit state for plan", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - extractedValues: { day: "tomorrow" }, - confidence: { day: 0.9 }, - operationKind: "plan", - }), - ); - - expect(result.missingFields).toContain("scheduleFields.time"); - expect(result.missingFields).not.toContain("scheduleFields.day"); - }); - - it("reports all required fields as missing when nothing is committed", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - extractedValues: {}, - operationKind: "plan", - }), - ); - - expect(result.missingFields).toContain("scheduleFields.day"); - expect(result.missingFields).toContain("scheduleFields.time"); + expect(result.needsClarification).toContain("scheduleFields.time"); }); - it("preserves prior resolved fields when new turn adds more", () => { - const result = applyCommitPolicy( + it("derives missingFields from operationKind after merge", () => { + const result = applyWriteCommit( buildInput({ turnType: "clarification_answer", - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.9 }, + interpretation: interpretation({ + operationKind: "plan", + fields: { scheduleFields: { time: t(17, 0) } }, + confidence: { "scheduleFields.time": 0.9 }, + }), priorPendingWriteOperation: priorOp("plan", { day: "tomorrow" }), }), ); - expect(result.resolvedFields.scheduleFields?.day).toBe("tomorrow"); - expect(result.resolvedFields.scheduleFields?.time).toEqual(t(17, 0)); - }); - - it("commits fields for edit_request turn type", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "edit_request", - extractedValues: { time: t(10, 0) }, - confidence: { time: 0.88 }, - operationKind: "edit", - }), - ); - - expect(result.resolvedFields.scheduleFields?.time).toEqual(t(10, 0)); expect(result.missingFields).toEqual([]); + expect(result.resolvedFields.scheduleFields).toEqual({ + day: "tomorrow", + time: t(17, 0), + }); }); - it("treats missing confidence as zero", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - extractedValues: { time: t(17, 0) }, - confidence: {}, - }), - ); - - expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); - expect(result.needsClarification).toContain("scheduleFields.time"); - }); - - it("does not flag an unresolvable field that is already resolved from prior turn", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "clarification_answer", - extractedValues: { day: "friday" }, - confidence: { day: 0.9 }, - unresolvable: ["time"], - priorPendingWriteOperation: priorOp("plan", { time: t(14, 0) }), - }), - ); + it("reports all required fields as missing when nothing is committed", () => { + const result = applyWriteCommit(buildInput({})); - expect(result.resolvedFields.scheduleFields?.time).toEqual(t(14, 0)); - expect(result.needsClarification).not.toContain("scheduleFields.time"); - expect(result.resolvedFields.scheduleFields?.day).toBe("friday"); + expect(result.missingFields).toEqual([ + "scheduleFields.day", + "scheduleFields.time", + ]); }); - it("handles unresolvable fields not in extractedValues", () => { - const result = applyCommitPolicy( + it("resets prior state when operation kind changes", () => { + const result = applyWriteCommit( buildInput({ - turnType: "clarification_answer", - extractedValues: { day: "friday" }, - confidence: { day: 0.9 }, - unresolvable: ["time"], + interpretation: interpretation({ + operationKind: "edit", + fields: { scheduleFields: { day: "friday" } }, + confidence: { "scheduleFields.day": 0.9 }, + }), + priorPendingWriteOperation: priorOp("plan", { + time: t(14, 0), + day: "tomorrow", + }), }), ); + expect(result.workflowChanged).toBe(true); + expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); expect(result.resolvedFields.scheduleFields?.day).toBe("friday"); - expect(result.needsClarification).toContain("scheduleFields.time"); - }); - - it("sets resolvedTargetRef from currentTargetEntityId when no prior operation", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "planning_request", - currentTargetEntityId: "task-abc", - }), - ); - - expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" }); - expect(result.workflowChanged).toBe(false); - }); - - it("carries forward prior targetRef when no new entity is resolved", () => { - const result = applyCommitPolicy( - buildInput({ - turnType: "clarification_answer", - priorPendingWriteOperation: priorOp( - "plan", - { day: "tomorrow" }, - "task-abc", - ), - }), - ); - - expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" }); - expect(result.workflowChanged).toBe(false); }); - it("clears prior schedule fields and sets workflowChanged when target changes", () => { - const result = applyCommitPolicy( + it("resets prior state when target changes", () => { + const result = applyWriteCommit( buildInput({ - turnType: "planning_request", - extractedValues: { day: "friday" }, - confidence: { day: 0.9 }, + interpretation: interpretation({ + fields: { scheduleFields: { day: "friday" } }, + confidence: { "scheduleFields.day": 0.9 }, + }), currentTargetEntityId: "task-xyz", priorPendingWriteOperation: priorOp( "plan", @@ -301,30 +209,27 @@ describe("applyCommitPolicy", () => { }), ); - expect(result.resolvedTargetRef).toEqual({ entityId: "task-xyz" }); expect(result.workflowChanged).toBe(true); + expect(result.resolvedTargetRef).toEqual({ entityId: "task-xyz" }); expect(result.resolvedFields.scheduleFields?.time).toBeUndefined(); - expect(result.resolvedFields.scheduleFields?.day).toBe("friday"); }); - it("does not set workflowChanged when target is the same entity", () => { - const result = applyCommitPolicy( + it("commits task fields using grouped field paths", () => { + const result = applyWriteCommit( buildInput({ - turnType: "clarification_answer", - extractedValues: { time: t(17, 0) }, - confidence: { time: 0.9 }, - currentTargetEntityId: "task-abc", - priorPendingWriteOperation: priorOp( - "plan", - { day: "tomorrow" }, - "task-abc", - ), + interpretation: interpretation({ + fields: { taskFields: { label: "Deep work", priority: "high" } }, + confidence: { + "taskFields.label": 0.95, + "taskFields.priority": 0.85, + }, + }), }), ); - expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" }); - expect(result.workflowChanged).toBe(false); - expect(result.resolvedFields.scheduleFields?.day).toBe("tomorrow"); - expect(result.resolvedFields.scheduleFields?.time).toEqual(t(17, 0)); + expect(result.resolvedFields.taskFields).toEqual({ + label: "Deep work", + priority: "high", + }); }); }); diff --git a/packages/core/src/commit-policy.ts b/packages/core/src/commit-policy.ts index 9dc886e..a7cee9e 100644 --- a/packages/core/src/commit-policy.ts +++ b/packages/core/src/commit-policy.ts @@ -1,169 +1,9 @@ -import type { - OperationKind, - PendingWriteOperation, - ResolvedFields, - TargetRef, - TimeSpec, - TurnInterpretation, -} from "./index"; -import { timeSpecsEqual } from "./time-spec"; - -type ScheduleFieldKey = "day" | "time" | "duration"; - -export type CommitPolicyInput = { - turnType: TurnInterpretation["turnType"]; - extractedValues: Partial>; - confidence: Partial>; - unresolvable: ScheduleFieldKey[]; - operationKind: OperationKind; - priorPendingWriteOperation?: PendingWriteOperation | undefined; - currentTargetEntityId?: string; -}; - -export type CommitPolicyOutput = { - resolvedFields: ResolvedFields; - resolvedTargetRef: TargetRef; - needsClarification: string[]; - missingFields: string[]; - workflowChanged: boolean; -}; - -export const FIELD_COMMITTING_TURN_TYPES = new Set< - TurnInterpretation["turnType"] ->(["clarification_answer", "planning_request", "edit_request"]); - -const CONFIDENCE_THRESHOLD = 0.75; -const CORRECTION_THRESHOLD = 0.9; - -// Required schedule fields per operation kind. -// Contract derivation lives here rather than as a pre-extraction gate. -function requiredFieldsForOperation( - operationKind: OperationKind, -): ScheduleFieldKey[] { - switch (operationKind) { - case "plan": - return ["day", "time"]; - case "edit": - case "reschedule": - return ["time"]; - case "complete": - case "archive": - return []; - } -} - -export function applyCommitPolicy( - input: CommitPolicyInput, -): CommitPolicyOutput { - const { - turnType, - extractedValues, - confidence, - unresolvable, - operationKind, - priorPendingWriteOperation, - currentTargetEntityId, - } = input; - - // Target change: a new entity ID that differs from the prior workflow's target - // means the user switched subjects. Treat it the same as an operation change — - // prior committed schedule fields belong to a different task and must be cleared. - const targetChanged = - currentTargetEntityId !== undefined && - currentTargetEntityId !== priorPendingWriteOperation?.targetRef?.entityId; - - const operationChanged = - priorPendingWriteOperation != null && - (operationKind !== priorPendingWriteOperation.operationKind || - targetChanged); - - const priorScheduleFields: Partial> = - operationChanged - ? {} - : { - ...(priorPendingWriteOperation?.resolvedFields.scheduleFields ?? {}), - }; - - const needsClarification: string[] = []; - const committedScheduleFields: Partial> = { - ...priorScheduleFields, - }; - - if (FIELD_COMMITTING_TURN_TYPES.has(turnType)) { - const extractedFieldKeys = Object.keys(extractedValues) as ScheduleFieldKey[]; - - for (const fieldKey of extractedFieldKeys) { - const value = extractedValues[fieldKey]; - if (value === undefined) continue; - - if (unresolvable.includes(fieldKey)) { - needsClarification.push(`scheduleFields.${fieldKey}`); - continue; - } - - const fieldConfidence = confidence[fieldKey] ?? 0; - if (fieldConfidence < CONFIDENCE_THRESHOLD) { - needsClarification.push(`scheduleFields.${fieldKey}`); - continue; - } - - const priorValue = priorScheduleFields[fieldKey]; - const isCorrection = - priorValue !== undefined && - !scheduleFieldValuesEqual(fieldKey, priorValue, value); - if (isCorrection && fieldConfidence < CORRECTION_THRESHOLD) { - needsClarification.push(`scheduleFields.${fieldKey}`); - continue; - } - - committedScheduleFields[fieldKey] = value; - } - - for (const fieldKey of unresolvable) { - const dotPath = `scheduleFields.${fieldKey}`; - if ( - !extractedFieldKeys.includes(fieldKey) && - !needsClarification.includes(dotPath) && - committedScheduleFields[fieldKey] === undefined - ) { - needsClarification.push(dotPath); - } - } - } - - const resolvedFields: ResolvedFields = { - scheduleFields: - Object.keys(committedScheduleFields).length > 0 - ? (committedScheduleFields as ResolvedFields["scheduleFields"]) - : undefined, - }; - - const requiredFieldKeys = requiredFieldsForOperation(operationKind); - const missingFields = requiredFieldKeys - .filter((fieldKey) => committedScheduleFields[fieldKey] === undefined) - .map((fieldKey) => `scheduleFields.${fieldKey}`); - - // Carry forward the prior target unless this turn introduced a new one. - const resolvedTargetRef: TargetRef = currentTargetEntityId - ? { entityId: currentTargetEntityId } - : (priorPendingWriteOperation?.targetRef ?? null); - - return { - resolvedFields, - resolvedTargetRef, - needsClarification, - missingFields, - workflowChanged: operationChanged, - }; -} - -function scheduleFieldValuesEqual( - fieldKey: ScheduleFieldKey, - a: unknown, - b: unknown, -): boolean { - if (fieldKey === "time" && a && b) { - return timeSpecsEqual(a as TimeSpec, b as TimeSpec); - } - return a === b; -} +export { + applyWriteCommit as applyCommitPolicy, + WRITE_INTERPRETING_TURN_TYPES as FIELD_COMMITTING_TURN_TYPES, +} from "./write-commit"; + +export type { + WriteCommitInput as CommitPolicyInput, + WriteCommitOutput as CommitPolicyOutput, +} from "./write-commit"; diff --git a/packages/core/src/discourse-state.ts b/packages/core/src/discourse-state.ts index 63264c7..5defa80 100644 --- a/packages/core/src/discourse-state.ts +++ b/packages/core/src/discourse-state.ts @@ -95,6 +95,7 @@ export const targetRefSchema = z .object({ entityId: z.string().optional(), description: z.string().optional(), + entityKind: z.string().optional(), }) .nullable(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fa1086f..08caec8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,8 +3,11 @@ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto"; import { z } from "zod"; import { conversationDiscourseStateSchema, + operationKindSchema, pendingWriteOperationSchema, + resolvedFieldsSchema, resolvedSlotsSchema, + targetRefSchema, } from "./discourse-state"; export * from "./ambiguity"; @@ -15,6 +18,8 @@ export * from "./slot-normalizer"; export * from "./synthesize-mutation-text"; export * from "./telegram"; export * from "./time-spec"; +export * from "./write-commit"; +export * from "./write-interpretation"; export * from "./write-contract"; const postgresConnectionStringSchema = z.string().refine((value) => { @@ -986,6 +991,33 @@ const rawTimeWindowSchema = z.object({ window: z.enum(["morning", "afternoon", "evening"]), }); +const rawScheduleFieldsSchema = z.object({ + time: z + .union([rawTimeAbsoluteSchema, rawTimeRelativeSchema, rawTimeWindowSchema]) + .nullable(), + day: z + .object({ + kind: z.enum(["relative", "weekday", "absolute"]), + value: z.string(), + }) + .nullable(), + duration: z.object({ minutes: z.number().int().min(0) }).nullable(), +}); + +const rawTaskFieldsSchema = z.object({ + priority: z.string().nullable(), + label: z.string().nullable(), + sourceText: z.string().nullable(), +}); + +const rawWriteTargetRefSchema = z + .object({ + entityId: z.string().nullable(), + description: z.string().nullable(), + entityKind: z.string().nullable(), + }) + .nullable(); + export const rawSlotExtractionSchema = z.object({ time: z .union([rawTimeAbsoluteSchema, rawTimeRelativeSchema, rawTimeWindowSchema]) @@ -1015,6 +1047,37 @@ export const slotExtractorOutputSchema = z.object({ unresolvable: z.array(slotKeySchema), }); +export const writeInterpretationInputSchema = z.object({ + currentTurnText: z.string().min(1), + turnType: turnInterpretationTypeSchema, + priorPendingWriteOperation: pendingWriteOperationSchema.optional(), + conversationContext: z.string().optional(), +}); + +export const rawWriteInterpretationSchema = z.object({ + operationKind: operationKindSchema, + actionDomain: z.string().min(1), + targetRef: rawWriteTargetRefSchema, + taskName: z.string().nullable(), + fields: z.object({ + scheduleFields: rawScheduleFieldsSchema.nullable(), + taskFields: rawTaskFieldsSchema.nullable(), + }), + confidence: z.record(z.string(), z.number().min(0).max(1)), + unresolvedFields: z.array(z.string().min(1)), +}); + +export const writeInterpretationSchema = z.object({ + operationKind: operationKindSchema, + actionDomain: z.string().min(1), + targetRef: targetRefSchema, + taskName: z.string().nullable(), + fields: resolvedFieldsSchema, + sourceText: z.string().min(1), + confidence: z.record(z.string(), z.number().min(0).max(1)), + unresolvedFields: z.array(z.string().min(1)), +}); + export const turnClassifierInputSchema = z.object({ normalizedText: z.string().min(1), discourseState: conversationDiscourseStateSchema.nullable(), @@ -1134,6 +1197,13 @@ export type RoutedTurn = z.infer; export type RawSlotExtraction = z.infer; export type SlotExtractorInput = z.infer; export type SlotExtractorOutput = z.infer; +export type WriteInterpretationInput = z.infer< + typeof writeInterpretationInputSchema +>; +export type RawWriteInterpretation = z.infer< + typeof rawWriteInterpretationSchema +>; +export type WriteInterpretation = z.infer; export type TurnClassifierInput = z.input; export type TurnClassifierResponse = z.infer< typeof turnClassifierResponseSchema diff --git a/packages/core/src/write-commit.ts b/packages/core/src/write-commit.ts new file mode 100644 index 0000000..9ae16db --- /dev/null +++ b/packages/core/src/write-commit.ts @@ -0,0 +1,303 @@ +import type { + PendingWriteOperation, + ResolvedFields, + TargetRef, + TimeSpec, + TurnInterpretation, + WriteInterpretation, +} from "./index"; +import { timeSpecsEqual } from "./time-spec"; + +type ScheduleFieldKey = "day" | "time" | "duration"; + +export type WriteCommitInput = { + turnType: TurnInterpretation["turnType"]; + interpretation: WriteInterpretation; + priorPendingWriteOperation?: PendingWriteOperation | undefined; + currentTargetEntityId?: string; +}; + +export type WriteCommitOutput = { + resolvedFields: ResolvedFields; + resolvedTargetRef: TargetRef; + needsClarification: string[]; + missingFields: string[]; + workflowChanged: boolean; + committedFieldPaths: string[]; +}; + +export const WRITE_INTERPRETING_TURN_TYPES = new Set< + TurnInterpretation["turnType"] +>(["clarification_answer", "planning_request", "edit_request"]); + +const CONFIDENCE_THRESHOLD = 0.75; +const CORRECTION_THRESHOLD = 0.9; + +function requiredFieldsForOperation(operationKind: WriteInterpretation["operationKind"]) { + switch (operationKind) { + case "plan": + return ["day", "time"] as const; + case "edit": + case "reschedule": + return ["time"] as const; + case "complete": + case "archive": + return [] as const; + } +} + +export function applyWriteCommit(input: WriteCommitInput): WriteCommitOutput { + const { + turnType, + interpretation, + priorPendingWriteOperation, + currentTargetEntityId, + } = input; + + const interpretedTargetRef = mergeTargetRef( + interpretation.targetRef, + currentTargetEntityId, + ); + const targetChanged = + targetRefsEqual( + interpretedTargetRef, + priorPendingWriteOperation?.targetRef ?? null, + ) === false; + const operationChanged = + priorPendingWriteOperation != null && + (interpretation.operationKind !== + priorPendingWriteOperation.operationKind || + targetChanged); + + const priorScheduleFields = operationChanged + ? {} + : { ...(priorPendingWriteOperation?.resolvedFields.scheduleFields ?? {}) }; + + const priorTaskFields = operationChanged + ? {} + : { ...(priorPendingWriteOperation?.resolvedFields.taskFields ?? {}) }; + + const committedScheduleFields: NonNullable = + { ...priorScheduleFields }; + const committedTaskFields: NonNullable = { + ...priorTaskFields, + }; + const needsClarification: string[] = []; + const committedFieldPaths: string[] = []; + + if (WRITE_INTERPRETING_TURN_TYPES.has(turnType)) { + commitScheduleFields({ + interpretation, + priorScheduleFields, + committedScheduleFields, + needsClarification, + committedFieldPaths, + }); + commitTaskFields({ + interpretation, + priorTaskFields, + committedTaskFields, + needsClarification, + committedFieldPaths, + }); + commitUnresolvedFieldPaths({ + interpretation, + needsClarification, + committedFieldPaths, + committedScheduleFields, + committedTaskFields, + }); + } + + const resolvedFields: ResolvedFields = { + ...(Object.keys(committedScheduleFields).length > 0 + ? { scheduleFields: committedScheduleFields } + : {}), + ...(Object.keys(committedTaskFields).length > 0 + ? { taskFields: committedTaskFields } + : {}), + }; + + const missingFields = requiredFieldsForOperation(interpretation.operationKind) + .filter((fieldKey) => committedScheduleFields[fieldKey] === undefined) + .map((fieldKey) => `scheduleFields.${fieldKey}`); + + return { + resolvedFields, + resolvedTargetRef: + interpretedTargetRef ?? (priorPendingWriteOperation?.targetRef ?? null), + needsClarification, + missingFields, + workflowChanged: operationChanged, + committedFieldPaths, + }; +} + +function commitScheduleFields(input: { + interpretation: WriteInterpretation; + priorScheduleFields: NonNullable; + committedScheduleFields: NonNullable; + needsClarification: string[]; + committedFieldPaths: string[]; +}) { + const { + interpretation, + priorScheduleFields, + committedScheduleFields, + needsClarification, + committedFieldPaths, + } = input; + const scheduleFields = interpretation.fields.scheduleFields; + if (!scheduleFields) return; + + const fieldKeys = Object.keys(scheduleFields) as ScheduleFieldKey[]; + for (const fieldKey of fieldKeys) { + const fieldPath = `scheduleFields.${fieldKey}`; + const value = scheduleFields[fieldKey]; + if (value === undefined) continue; + + if (interpretation.unresolvedFields.includes(fieldPath)) { + needsClarification.push(fieldPath); + continue; + } + + const fieldConfidence = interpretation.confidence[fieldPath] ?? 0; + if (fieldConfidence < CONFIDENCE_THRESHOLD) { + needsClarification.push(fieldPath); + continue; + } + + const priorValue = priorScheduleFields[fieldKey]; + const isCorrection = + priorValue !== undefined && + !scheduleFieldValuesEqual(fieldKey, priorValue, value); + if (isCorrection && fieldConfidence < CORRECTION_THRESHOLD) { + needsClarification.push(fieldPath); + continue; + } + + if (fieldKey === "day" && typeof value === "string") { + committedScheduleFields.day = value; + } else if (fieldKey === "duration" && typeof value === "number") { + committedScheduleFields.duration = value; + } else if (fieldKey === "time" && value !== undefined) { + committedScheduleFields.time = value as TimeSpec; + } + committedFieldPaths.push(fieldPath); + } +} + +function commitTaskFields(input: { + interpretation: WriteInterpretation; + priorTaskFields: NonNullable; + committedTaskFields: NonNullable; + needsClarification: string[]; + committedFieldPaths: string[]; +}) { + const { + interpretation, + priorTaskFields, + committedTaskFields, + needsClarification, + committedFieldPaths, + } = input; + const taskFields = interpretation.fields.taskFields; + if (!taskFields) return; + + const fieldKeys = Object.keys(taskFields) as Array< + keyof NonNullable + >; + for (const fieldKey of fieldKeys) { + const fieldPath = `taskFields.${fieldKey}`; + const value = taskFields[fieldKey]; + if (value === undefined) continue; + + if (interpretation.unresolvedFields.includes(fieldPath)) { + needsClarification.push(fieldPath); + continue; + } + + const fieldConfidence = interpretation.confidence[fieldPath] ?? 0; + if (fieldConfidence < CONFIDENCE_THRESHOLD) { + needsClarification.push(fieldPath); + continue; + } + + const priorValue = priorTaskFields[fieldKey]; + const isCorrection = priorValue !== undefined && priorValue !== value; + if (isCorrection && fieldConfidence < CORRECTION_THRESHOLD) { + needsClarification.push(fieldPath); + continue; + } + + committedTaskFields[fieldKey] = value; + committedFieldPaths.push(fieldPath); + } +} + +function commitUnresolvedFieldPaths(input: { + interpretation: WriteInterpretation; + needsClarification: string[]; + committedFieldPaths: string[]; + committedScheduleFields: NonNullable; + committedTaskFields: NonNullable; +}) { + const { + interpretation, + needsClarification, + committedScheduleFields, + committedTaskFields, + } = input; + + for (const fieldPath of interpretation.unresolvedFields) { + if (needsClarification.includes(fieldPath)) continue; + if (fieldPath.startsWith("scheduleFields.")) { + const fieldKey = fieldPath.replace( + "scheduleFields.", + "", + ) as ScheduleFieldKey; + if (committedScheduleFields[fieldKey] !== undefined) continue; + } + if (fieldPath.startsWith("taskFields.")) { + const fieldKey = fieldPath.replace( + "taskFields.", + "", + ) as keyof NonNullable; + if (committedTaskFields[fieldKey] !== undefined) continue; + } + needsClarification.push(fieldPath); + } +} + +function mergeTargetRef( + interpretedTargetRef: TargetRef, + currentTargetEntityId?: string, +): TargetRef { + if (!interpretedTargetRef && !currentTargetEntityId) return null; + return { + ...(interpretedTargetRef ?? {}), + ...(currentTargetEntityId ? { entityId: currentTargetEntityId } : {}), + }; +} + +function targetRefsEqual(a: TargetRef, b: TargetRef): boolean { + if (a === null && b === null) return true; + if (a === null || b === null) return false; + + return ( + a.entityId === b.entityId && + a.description === b.description && + a.entityKind === b.entityKind + ); +} + +function scheduleFieldValuesEqual( + fieldKey: ScheduleFieldKey, + a: unknown, + b: unknown, +): boolean { + if (fieldKey === "time" && a && b) { + return timeSpecsEqual(a as TimeSpec, b as TimeSpec); + } + return a === b; +} diff --git a/packages/core/src/write-interpretation.ts b/packages/core/src/write-interpretation.ts new file mode 100644 index 0000000..2d5786f --- /dev/null +++ b/packages/core/src/write-interpretation.ts @@ -0,0 +1,80 @@ +import type { + RawWriteInterpretation, + ResolvedFields, + ResolvedSlots, + TargetRef, + WriteInterpretation, +} from "./index"; +import { normalizeRawExtraction } from "./slot-normalizer"; + +export function normalizeRawWriteInterpretation( + raw: RawWriteInterpretation, + sourceText: string, +): WriteInterpretation { + const scheduleSlots = normalizeRawExtraction({ + time: raw.fields.scheduleFields?.time ?? null, + day: raw.fields.scheduleFields?.day ?? null, + duration: raw.fields.scheduleFields?.duration ?? null, + target: null, + confidence: {}, + unresolvable: [], + }); + + const resolvedFields: ResolvedFields = { + ...(Object.keys(scheduleSlots).length > 0 + ? { + scheduleFields: { + ...(scheduleSlots.day !== undefined + ? { day: scheduleSlots.day } + : {}), + ...(scheduleSlots.time !== undefined + ? { time: scheduleSlots.time } + : {}), + ...(scheduleSlots.duration !== undefined + ? { duration: scheduleSlots.duration } + : {}), + } satisfies ResolvedSlots, + } + : {}), + ...(raw.fields.taskFields + ? { + taskFields: { + ...(raw.fields.taskFields.priority + ? { priority: raw.fields.taskFields.priority } + : {}), + ...(raw.fields.taskFields.label + ? { label: raw.fields.taskFields.label } + : {}), + ...(raw.fields.taskFields.sourceText + ? { sourceText: raw.fields.taskFields.sourceText } + : {}), + }, + } + : {}), + }; + + const normalizedTargetRef: TargetRef = + raw.targetRef && + Object.values(raw.targetRef).some((value) => value !== null) + ? { + ...(raw.targetRef.entityId ? { entityId: raw.targetRef.entityId } : {}), + ...(raw.targetRef.description + ? { description: raw.targetRef.description } + : {}), + ...(raw.targetRef.entityKind + ? { entityKind: raw.targetRef.entityKind } + : {}), + } + : null; + + return { + operationKind: raw.operationKind, + actionDomain: raw.actionDomain, + targetRef: normalizedTargetRef, + taskName: raw.taskName, + fields: resolvedFields, + sourceText, + confidence: raw.confidence, + unresolvedFields: raw.unresolvedFields, + }; +} diff --git a/packages/integrations/src/index.test.ts b/packages/integrations/src/index.test.ts index 3eb2699..fcd3c76 100644 --- a/packages/integrations/src/index.test.ts +++ b/packages/integrations/src/index.test.ts @@ -11,6 +11,7 @@ import { exchangeGoogleOAuthCode, fetchGoogleCalendarIdentity, getDefaultCalendarAdapter, + interpretWriteTurnWithResponses, planInboxItemWithResponses, recoverConfirmedMutationWithResponses, refreshGoogleOAuthToken, @@ -599,6 +600,75 @@ describe("integrations", () => { ).rejects.toThrow(); }); + it("parses structured write interpretation output from the Responses API client", async () => { + const result = await interpretWriteTurnWithResponses( + { + currentTurnText: "Schedule gym tomorrow at 6pm", + turnType: "planning_request", + }, + { + responses: { + parse: async () => ({ + output_parsed: { + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: "gym", + fields: { + scheduleFields: { + day: { kind: "relative", value: "tomorrow" }, + time: { kind: "absolute", hour: 18, minute: 0 }, + duration: null, + }, + taskFields: null, + }, + confidence: { + "scheduleFields.day": 0.94, + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], + }, + }), + }, + }, + ); + + expect(result).toMatchObject({ + operationKind: "plan", + actionDomain: "task", + taskName: "gym", + }); + }); + + it("rejects malformed structured write interpretation output", async () => { + await expect( + interpretWriteTurnWithResponses( + { + currentTurnText: "Schedule gym tomorrow at 6pm", + turnType: "planning_request", + }, + { + responses: { + parse: async () => ({ + output_parsed: { + operationKind: "invent", + actionDomain: "", + targetRef: null, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: {}, + unresolvedFields: [], + }, + }), + }, + }, + ), + ).rejects.toThrow(); + }); + it("accepts confirmed mutation as a valid structured turn route", async () => { const result = await routeTurnWithResponses( { diff --git a/packages/integrations/src/openai.ts b/packages/integrations/src/openai.ts index 4b83ba1..3821f96 100644 --- a/packages/integrations/src/openai.ts +++ b/packages/integrations/src/openai.ts @@ -1,6 +1,7 @@ import { type ConfirmedMutationRecoveryInput, type ConfirmedMutationRecoveryOutput, + type RawWriteInterpretation, confirmedMutationRecoveryInputSchema, confirmedMutationRecoveryOutputSchema, confirmedMutationRecoveryResponseFormatSchema, @@ -13,6 +14,7 @@ import { inboxPlanningResponseFormatSchema, type RawSlotExtraction, rawSlotExtractionSchema, + rawWriteInterpretationSchema, type SlotExtractorInput, slotExtractorInputSchema, type TurnClassifierInput, @@ -23,6 +25,8 @@ import { turnClassifierResponseSchema, turnRoutingInputSchema, turnRoutingOutputSchema, + type WriteInterpretationInput, + writeInterpretationInputSchema, } from "@atlas/core"; import OpenAI from "openai"; import { zodTextFormat } from "openai/helpers/zod"; @@ -30,6 +34,7 @@ import { z } from "zod"; import { confirmedMutationRecoverySystemPrompt } from "./prompts/confirmed-mutation-recovery"; import { conversationMemorySummarySystemPrompt } from "./prompts/conversation-memory-summary"; import { conversationResponseSystemPrompt } from "./prompts/conversation-response"; +import { interpretWriteTurnSystemPrompt } from "./prompts/interpret-write-turn"; import { inboxPlannerSystemPrompt } from "./prompts/planner"; import { slotExtractorSystemPrompt } from "./prompts/slot-extractor"; import { turnClassifierSystemPrompt } from "./prompts/turn-classifier"; @@ -42,6 +47,7 @@ export const DEFAULT_CONVERSATION_MEMORY_SUMMARY_MODEL = "gpt-4o-mini"; export const DEFAULT_CONFIRMED_MUTATION_RECOVERY_MODEL = "gpt-4o-mini"; export const DEFAULT_SLOT_EXTRACTOR_MODEL = "gpt-4o-mini"; export const DEFAULT_TURN_CLASSIFIER_MODEL = "gpt-4o-mini"; +export const DEFAULT_WRITE_INTERPRETATION_MODEL = "gpt-4o-mini"; export const conversationMemorySummaryInputSchema = z.object({ recentTurns: z.array(conversationTurnSchema), @@ -301,6 +307,45 @@ export async function extractSlotsWithResponses( return rawSlotExtractionSchema.parse(response.output_parsed); } +export async function interpretWriteTurnWithResponses( + input: unknown, + client: OpenAIResponsesClient = createOpenAIClient(), +): Promise { + const context = writeInterpretationInputSchema.parse(input); + + const response = await client.responses.parse({ + model: DEFAULT_WRITE_INTERPRETATION_MODEL, + input: [ + { + role: "system", + content: [ + { + type: "input_text", + text: interpretWriteTurnSystemPrompt, + }, + ], + }, + { + role: "user", + content: [ + { + type: "input_text", + text: JSON.stringify(buildWriteInterpretationPromptContext(context)), + }, + ], + }, + ], + text: { + format: zodTextFormat( + rawWriteInterpretationSchema, + "atlas_write_interpretation_output", + ), + }, + }); + + return rawWriteInterpretationSchema.parse(response.output_parsed); +} + export async function classifyTurnWithResponses( input: unknown, client: OpenAIResponsesClient = createOpenAIClient(), @@ -365,6 +410,17 @@ function buildSlotExtractorPromptContext(context: SlotExtractorInput) { }; } +function buildWriteInterpretationPromptContext( + context: WriteInterpretationInput, +) { + return { + currentTurnText: context.currentTurnText, + turnType: context.turnType, + priorPendingWriteOperation: context.priorPendingWriteOperation ?? null, + conversationContext: context.conversationContext ?? null, + }; +} + function buildTurnRoutingPromptContext(context: TurnRoutingInput) { return { rawText: context.rawText, diff --git a/packages/integrations/src/prompts/interpret-write-turn.ts b/packages/integrations/src/prompts/interpret-write-turn.ts new file mode 100644 index 0000000..5dce5fb --- /dev/null +++ b/packages/integrations/src/prompts/interpret-write-turn.ts @@ -0,0 +1,55 @@ +import { buildPromptSpec } from "./shared"; + +export const interpretWriteTurnSystemPrompt = buildPromptSpec([ + { + title: "Role", + lines: [ + "You are Atlas's write-turn interpreter for planning conversations.", + "Your job is to interpret a single user turn for the write path without making policy decisions.", + ], + }, + { + title: "Task", + lines: [ + "Infer the operation kind expressed by the user, extract any concrete write fields, and report uncertainty per field path.", + "Describe what the user said in this turn. Do not decide whether Atlas should execute, clarify, or ask for consent.", + "Use priorPendingWriteOperation only for continuity when the turn is a follow-up clarification or continuation.", + ], + }, + { + title: "Output Format", + lines: [ + "Return structured JSON with these fields:", + "- operationKind: one of plan, edit, reschedule, complete, archive.", + "- actionDomain: a short string like task or schedule_block.", + "- targetRef: null or { entityId?: string, description?: string, entityKind?: string }.", + "- taskName: null or a short task label if the user names new work.", + "- fields.scheduleFields: optional object with day, time, duration.", + "- fields.taskFields: optional object with priority, label, sourceText.", + "- confidence: object keyed by dot-path, for example scheduleFields.time.", + "- unresolvedFields: array of dot-paths that could not be determined confidently.", + ], + }, + { + title: "Schedule Field Rules", + lines: [ + "For scheduleFields.time use one of:", + "- absolute: { kind: 'absolute', hour: number, minute: number }", + "- relative: { kind: 'relative', minutes: number }", + "- window: { kind: 'window', window: 'morning' | 'afternoon' | 'evening' }", + "For scheduleFields.day use { kind: 'relative' | 'weekday' | 'absolute', value: string }.", + "For scheduleFields.duration use { minutes: number }.", + "When a bare number like '5' appears in a scheduling context, prefer 17:00 with lower confidence.", + "When the user is vague like 'whenever' or 'you pick', do not guess. Put the field path in unresolvedFields.", + ], + }, + { + title: "Important Constraints", + lines: [ + "Do not emit readiness, shouldAsk, or any policy-like field.", + "Do not copy priorPendingWriteOperation into the output verbatim.", + "Use confidence only for fields you are actually asserting.", + "If the turn simply fills one missing detail, still return the continuing operationKind.", + ], + }, +]);