diff --git a/apps/operator-backend/src/deterministic/room_receptacle_analog.ts b/apps/operator-backend/src/deterministic/room_receptacle_analog.ts index 4b5afff..9750779 100644 --- a/apps/operator-backend/src/deterministic/room_receptacle_analog.ts +++ b/apps/operator-backend/src/deterministic/room_receptacle_analog.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { OPERATOR_BACKEND_CONTRACT_VERSION, type ChatRequest, type ChatResponse, type ToolResult } from "../contracts.js"; import type { AecTaskIntentV1 } from "../aec_task_intent.js"; -import { appendGoalProgress, getActiveGoalForSession, setAgentGoal } from "../goals/service.js"; +import { appendGoalProgress, getActiveGoalForSession, setAgentGoal, updateGoal } from "../goals/service.js"; const PLAN_PATH = "/revit/plan-room-receptacles-from-analog"; const APPLY_PATH = "/revit/apply-room-receptacles-from-analog"; @@ -41,6 +41,12 @@ function appendRoomDesignProgress(sessionId: string, entry: unknown): void { appendGoalProgress(sessionId, entry); } +function updateRoomDesignPhase(sessionId: string, currentPhase: string, currentStep: string): void { + const goal = getActiveGoalForSession(sessionId); + if (!goal) return; + updateGoal(goal.id, { current_phase: currentPhase, current_step: currentStep }); +} + function asRecord(value: unknown): Record | null { return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : null; } @@ -130,7 +136,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte if (planResult && !applyResult) { if (planResult.status !== "done") { - appendRoomDesignProgress(req.session_id, { summary: "Bounded project-precedent discovery failed; no model changes were made.", work_item: { id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "blocked", blocker: planResult.error || planResult.failure_hint || "Analog preview failed.", evidence_refs: [`action:${planResult.action_id}`] } }); + appendRoomDesignProgress(req.session_id, { summary: "Bounded project-precedent discovery failed; no model changes were made.", work_item: { id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "blocked", scope: null, depends_on: ["target.inspect"], planned_actions: ["score same-level furnished analogs", "record selected precedent and assumptions"], blocker: planResult.error || planResult.failure_hint || "Analog preview failed.", evidence_refs: [`action:${planResult.action_id}`] } }); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: failureMessage("preview", planResult), actions: [] }; } const plan = payloadFor(planResult); @@ -139,23 +145,25 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte const planHash = typeof plan?.planHash === "string" ? plan.planHash : ""; const ready = plan?.ready === true && plan?.status === "ready" && !!targetRoomNumber && !!sourceRoomNumber && !!planHash; if (!ready) { - appendRoomDesignProgress(req.session_id, { summary: "The analog preview did not establish a unique hash-bound project precedent.", work_items: [{ id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "blocked", blocker: "No complete unique analog plan was returned.", evidence_refs: [`action:${planResult.action_id}`] }, { id: "layout.preview", title: "Rollback-preview the exact mapped layout", status: "blocked", blocker: "Project precedent remains unresolved.", evidence_refs: [`action:${planResult.action_id}`] }] }); + appendRoomDesignProgress(req.session_id, { summary: "The analog preview did not establish a unique hash-bound project precedent.", work_items: [{ id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "blocked", scope: null, depends_on: ["target.inspect"], planned_actions: ["score same-level furnished analogs", "record selected precedent and assumptions"], blocker: "No complete unique analog plan was returned.", evidence_refs: [`action:${planResult.action_id}`] }, { id: "layout.preview", title: "Rollback-preview the exact mapped layout", status: "blocked", scope: null, depends_on: ["precedent.resolve"], planned_actions: ["hash-bound native preview"], blocker: "Project precedent remains unresolved.", evidence_refs: [`action:${planResult.action_id}`] }] }); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: "The rollback preview did not produce a complete, hash-bound analog plan, so I stopped without changing the model.", actions: [] }; } + const groundedScope = { target_room_number: targetRoomNumber, source_room_number: sourceRoomNumber }; appendRoomDesignProgress(req.session_id, { summary: `Selected Room ${sourceRoomNumber} as the unique current-project precedent and verified a hash-bound rollback preview for Room ${targetRoomNumber}.`, assumptions: [{ id: "precedent.room", statement: `Room ${sourceRoomNumber} is the selected current-project analog for target Room ${targetRoomNumber}.`, status: "accepted", basis: "native same-level geometry, anchor, adjacency, and exact device-inventory scoring", evidence_refs: [`action:${planResult.action_id}`] }], work_items: [ - { id: "target.inspect", title: `Inspect Room ${targetRoomNumber} geometry and existing receptacles`, status: "complete", evidence_refs: [`action:${planResult.action_id}`], result_summary: "Target room and anchor geometry were resolved natively." }, - { id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "complete", evidence_refs: [`action:${planResult.action_id}`], result_summary: `Unique project analog Room ${sourceRoomNumber} selected.` }, - { id: "layout.preview", title: "Rollback-preview the exact mapped layout", status: "complete", evidence_refs: [`action:${planResult.action_id}`], result_summary: `Hash-bound plan ${planHash} verified with no persistent model change.` }, - { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "ready", depends_on: ["layout.preview"], planned_actions: ["atomic apply", "exact created-id receipt"] } + { id: "target.inspect", title: `Inspect Room ${targetRoomNumber} geometry and existing receptacles`, status: "complete", scope: { room_number: targetRoomNumber }, planned_actions: ["native room and anchor inventory"], evidence_refs: [`action:${planResult.action_id}`], result_summary: "Target room and anchor geometry were resolved natively." }, + { id: "precedent.resolve", title: "Resolve the strongest applicable project precedent", status: "complete", scope: groundedScope, depends_on: ["target.inspect"], planned_actions: ["score same-level furnished analogs", "record selected precedent and assumptions"], evidence_refs: [`action:${planResult.action_id}`], result_summary: `Unique project analog Room ${sourceRoomNumber} selected.` }, + { id: "layout.preview", title: "Rollback-preview the exact mapped layout", status: "complete", scope: groundedScope, depends_on: ["precedent.resolve"], planned_actions: ["hash-bound native preview"], evidence_refs: [`action:${planResult.action_id}`], result_summary: `Hash-bound plan ${planHash} verified with no persistent model change.` }, + { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "ready", scope: groundedScope, depends_on: ["layout.preview"], planned_actions: ["atomic apply", "exact created-id receipt"] } ] }); + updateRoomDesignPhase(req.session_id, "layout_execution", `Apply the verified Room ${sourceRoomNumber} analog in Room ${targetRoomNumber}`); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `The rollback preview verified the Room ${targetRoomNumber} layout against analog Room ${sourceRoomNumber}. Applying the same hash-bound plan…`, @@ -170,7 +178,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte if (applyResult) { if (applyResult.status !== "done") { - appendRoomDesignProgress(req.session_id, { summary: "The atomic apply failed and no broad or conversational recovery was attempted.", work_item: { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "failed", blocker: applyResult.error || applyResult.failure_hint || "Apply failed.", evidence_refs: [`action:${applyResult.action_id}`] } }); + appendRoomDesignProgress(req.session_id, { summary: "The atomic apply failed and no broad or conversational recovery was attempted.", work_item: { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "failed", scope: null, depends_on: ["layout.preview"], planned_actions: ["atomic apply", "exact created-id receipt"], blocker: applyResult.error || applyResult.failure_hint || "Apply failed.", evidence_refs: [`action:${applyResult.action_id}`] } }); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: failureMessage("apply", applyResult), actions: [] }; } const applied = payloadFor(applyResult); @@ -181,7 +189,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte const typeCounts = receipt?.typeCounts ?? []; const warnings = Array.isArray(applied?.warnings) ? applied.warnings.filter(value => typeof value === "string") as string[] : []; if (!receipt) { - appendRoomDesignProgress(req.session_id, { summary: "Apply returned without complete exact persistent readback, so completion is not claimed.", work_items: [{ id: "layout.apply", title: "Apply the verified hash-bound layout", status: "blocked", blocker: "Created IDs or persistent readback were incomplete.", evidence_refs: [`action:${applyResult.action_id}`] }, { id: "layout.verify", title: "Verify device type, room, host, position, and orientation", status: "blocked", blocker: "Persistent readback is incomplete.", evidence_refs: [`action:${applyResult.action_id}`] }] }); + appendRoomDesignProgress(req.session_id, { summary: "Apply returned without complete exact persistent readback, so completion is not claimed.", work_items: [{ id: "layout.apply", title: "Apply the verified hash-bound layout", status: "blocked", scope: null, depends_on: ["layout.preview"], planned_actions: ["atomic apply", "exact created-id receipt"], blocker: "Created IDs or persistent readback were incomplete.", evidence_refs: [`action:${applyResult.action_id}`] }, { id: "layout.verify", title: "Verify device type, room, host, position, and orientation", status: "blocked", scope: null, depends_on: ["layout.apply"], planned_actions: ["native persistent readback"], blocker: "Persistent readback is incomplete.", evidence_refs: [`action:${applyResult.action_id}`] }] }); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: "The apply call did not return a complete persistent inventory/readback receipt, so I cannot claim the layout is complete.", @@ -201,11 +209,12 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte appendRoomDesignProgress(req.session_id, { summary: `Atomic Room ${targetRoomNumber} apply and native persistent readback passed for ${createdIds.length} receptacle(s) using Room ${sourceRoomNumber}.`, work_items: [ - { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "complete", evidence_refs: [`action:${applyResult.action_id}`], result_summary: `${createdIds.length} unique receptacle IDs created atomically.` }, - { id: "layout.verify", title: "Verify device type, room, host, position, and orientation", status: "complete", evidence_refs: [`action:${applyResult.action_id}`], result_summary: "Exact ID, type, room, physical host, point, and orientation readback passed for every created device." }, - { id: "verify.visual", title: `Perform focused visual QA in Room ${targetRoomNumber}`, status: "ready", depends_on: ["layout.verify"], evidence_refs: [`action:${applyResult.action_id}`], result_summary: previewNote ? "Native readback passed; focused visual follow-up remains required by the preview warning." : "Native readback passed; focused human-style Revit inspection is queued." } + { id: "layout.apply", title: "Apply the verified hash-bound layout", status: "complete", scope: { target_room_number: targetRoomNumber, source_room_number: sourceRoomNumber }, depends_on: ["layout.preview"], planned_actions: ["atomic apply", "exact created-id receipt"], evidence_refs: [`action:${applyResult.action_id}`], result_summary: `${createdIds.length} unique receptacle IDs created atomically.` }, + { id: "layout.verify", title: "Verify device type, room, host, position, and orientation", status: "complete", scope: { target_room_number: targetRoomNumber, source_room_number: sourceRoomNumber }, depends_on: ["layout.apply"], planned_actions: ["native persistent readback"], evidence_refs: [`action:${applyResult.action_id}`], result_summary: "Exact ID, type, room, physical host, point, and orientation readback passed for every created device." }, + { id: "verify.visual", title: `Perform focused visual QA in Room ${targetRoomNumber}`, status: "ready", scope: { room_number: targetRoomNumber }, depends_on: ["layout.verify"], planned_actions: ["focused Revit inspection", "bounded repair if needed"], evidence_refs: [`action:${applyResult.action_id}`], result_summary: previewNote ? "Native readback passed; focused visual follow-up remains required by the preview warning." : "Native readback passed; focused human-style Revit inspection is queued." } ] }); + updateRoomDesignPhase(req.session_id, "visual_verification", `Perform focused visual QA in Room ${targetRoomNumber}`); return { version: OPERATOR_BACKEND_CONTRACT_VERSION, assistant_message: `Room ${targetRoomNumber} is complete. I placed and natively verified ${createdIds.length} receptacles using Room ${sourceRoomNumber} as the project-standard analog${types ? ` (${types})` : ""}. The model write was atomic and the returned host, position, orientation, type, and room readback all passed.${previewNote}`, diff --git a/apps/operator-backend/test/room_receptacle_analog.test.ts b/apps/operator-backend/test/room_receptacle_analog.test.ts index fb29339..1e49b33 100644 --- a/apps/operator-backend/test/room_receptacle_analog.test.ts +++ b/apps/operator-backend/test/room_receptacle_analog.test.ts @@ -119,6 +119,9 @@ test("room design persists target, selected precedent, exact apply evidence, and assert.equal(previewed?.assumptions.find(item => item.id === "precedent.room")?.statement, "Room 409 is the selected current-project analog for target Room 407."); assert.equal(previewed?.work_items.find(item => item.id === "layout.preview")?.status, "complete"); assert.equal(previewed?.work_items.find(item => item.id === "layout.apply")?.status, "ready"); + assert.deepEqual(previewed?.work_items.find(item => item.id === "layout.apply")?.scope, { target_room_number: "407", source_room_number: "409" }); + assert.deepEqual(previewed?.work_items.find(item => item.id === "layout.apply")?.depends_on, ["layout.preview"]); + assert.equal(previewed?.current_phase, "layout_execution"); const receipt = appliedReceipt([1700407], [{ familyType: "Duplex Receptacle|Standard", count: 1 }]); receipt.target.number = "407"; @@ -129,6 +132,11 @@ test("room design persists target, selected precedent, exact apply evidence, and assert.equal(completed?.work_items.find(item => item.id === "layout.apply")?.status, "complete"); assert.equal(completed?.work_items.find(item => item.id === "layout.verify")?.status, "complete"); assert.equal(completed?.work_items.find(item => item.id === "verify.visual")?.status, "ready"); + assert.deepEqual(completed?.work_items.find(item => item.id === "layout.verify")?.scope, { target_room_number: "407", source_room_number: "409" }); + assert.deepEqual(completed?.work_items.find(item => item.id === "verify.visual")?.scope, { room_number: "407" }); + assert.deepEqual(completed?.work_items.find(item => item.id === "verify.visual")?.planned_actions, ["focused Revit inspection", "bounded repair if needed"]); + assert.equal(completed?.current_phase, "visual_verification"); + assert.equal(completed?.current_step, "Perform focused visual QA in Room 407"); assert.match(completed?.progress_summary ?? "", /Room 407 apply and native persistent readback passed/); });