diff --git a/apps/operator-backend/src/brain.ts b/apps/operator-backend/src/brain.ts index b7cf0dd..ff7efc3 100644 --- a/apps/operator-backend/src/brain.ts +++ b/apps/operator-backend/src/brain.ts @@ -87,14 +87,26 @@ async function maybeRunTopLevelMepRouteRedline(req: ChatRequest, resolver = mayb return isExplicitReadOnlyRedlineAnalysisRequest(req) ? null : resolver(req); } +async function maybeRunTopLevelSemanticAecWorkflow(req: ChatRequest, resolver = maybeRunSemanticAecWorkflow): Promise { + return isExplicitReadOnlyRedlineAnalysisRequest(req) ? null : resolver(req); +} + export async function __testOnlyMaybeRunTopLevelMepRouteRedline(req: ChatRequest, resolver: typeof maybeRunDeterministicMepRouteRedline): Promise { return maybeRunTopLevelMepRouteRedline(req, resolver); } +export async function __testOnlyMaybeRunTopLevelSemanticAecWorkflow(req: ChatRequest, resolver: typeof maybeRunSemanticAecWorkflow): Promise { return maybeRunTopLevelSemanticAecWorkflow(req, resolver); } export async function __testOnlyMaybeRunSemanticAecWorkflow(req: ChatRequest, interpreter: AecTaskIntentInterpreter): Promise { return maybeRunSemanticAecWorkflow(req, interpreter); } export function __testOnlyFinalizeDecision(req: ChatRequest, decision: ChatResponse): ChatResponse { return finalizeDecision(req, decision); } -export async function decide(req: ChatRequest): Promise { +export type BrainDecisionDependencies = { + mepRouteRedline?: typeof maybeRunDeterministicMepRouteRedline; + semanticAecWorkflow?: typeof maybeRunSemanticAecWorkflow; + openAiBrain?: typeof decideOpenAi; + openAiStreamingBrain?: typeof decideOpenAiStreaming; +}; + +export async function decide(req: ChatRequest, dependencies: BrainDecisionDependencies = {}): Promise { const roomReceptacleDecision = maybeRunDeterministicRoomReceptacleAnalog(req); if (roomReceptacleDecision) { return finalizeDecision(req, roomReceptacleDecision); @@ -119,12 +131,12 @@ export async function decide(req: ChatRequest): Promise { return finalizeDecision(req, enlargedPlanDecision); } - const mepRouteRedlineDecision = await maybeRunTopLevelMepRouteRedline(req); + const mepRouteRedlineDecision = await maybeRunTopLevelMepRouteRedline(req, dependencies.mepRouteRedline); if (mepRouteRedlineDecision) { return finalizeDecision(req, mepRouteRedlineDecision); } - const semanticAecDecision = await maybeRunSemanticAecWorkflow(req); + const semanticAecDecision = await maybeRunTopLevelSemanticAecWorkflow(req, dependencies.semanticAecWorkflow); if (semanticAecDecision) { return finalizeDecision(req, semanticAecDecision); } @@ -134,7 +146,7 @@ export async function decide(req: ChatRequest): Promise { let decision: ChatResponse; if (forced === "rule") decision = await decideRule(req); - else if (forced === "openai") decision = await decideOpenAi(req); + else if (forced === "openai") decision = await (dependencies.openAiBrain ?? decideOpenAi)(req); else if (forced === "codex") decision = await decideCodex(req); else if (hasOpenAiKey) decision = await decideOpenAi(req); else decision = await decideRule(req); @@ -142,7 +154,7 @@ export async function decide(req: ChatRequest): Promise { return finalizeDecision(req, decision); } -export async function decideStreaming(req: ChatRequest, cb: StreamCallbacks): Promise { +export async function decideStreaming(req: ChatRequest, cb: StreamCallbacks, dependencies: BrainDecisionDependencies = {}): Promise { const roomReceptacleDecision = maybeRunDeterministicRoomReceptacleAnalog(req); if (roomReceptacleDecision) { const text = roomReceptacleDecision.assistant_message || ""; @@ -197,7 +209,7 @@ export async function decideStreaming(req: ChatRequest, cb: StreamCallbacks): Pr return finalizeDecision(req, enlargedPlanDecision); } - const mepRouteRedlineDecision = await maybeRunTopLevelMepRouteRedline(req); + const mepRouteRedlineDecision = await maybeRunTopLevelMepRouteRedline(req, dependencies.mepRouteRedline); if (mepRouteRedlineDecision) { const text = mepRouteRedlineDecision.assistant_message || ""; const chunkSize = 60; @@ -210,7 +222,7 @@ export async function decideStreaming(req: ChatRequest, cb: StreamCallbacks): Pr return finalizeDecision(req, mepRouteRedlineDecision); } - const semanticAecDecision = await maybeRunSemanticAecWorkflow(req); + const semanticAecDecision = await maybeRunTopLevelSemanticAecWorkflow(req, dependencies.semanticAecWorkflow); if (semanticAecDecision) { const text = semanticAecDecision.assistant_message || ""; cb.onDelta?.(text); @@ -222,10 +234,10 @@ export async function decideStreaming(req: ChatRequest, cb: StreamCallbacks): Pr const hasOpenAiKey = !!resolveOpenAiApiKey(); if (forced === "codex") return decideCodexStreaming(req, cb); if (forced === "openai" || (forced !== "rule" && hasOpenAiKey)) { - return finalizeDecision(req, await decideOpenAiStreaming(req, cb)); + return finalizeDecision(req, await (dependencies.openAiStreamingBrain ?? decideOpenAiStreaming)(req, cb)); } - const decision = await decide(req); + const decision = await decide(req, dependencies); const text = decision.assistant_message || ""; const chunkSize = 60; diff --git a/apps/operator-backend/test/redline_live_gap.test.ts b/apps/operator-backend/test/redline_live_gap.test.ts index f8c296d..f7e28e6 100644 --- a/apps/operator-backend/test/redline_live_gap.test.ts +++ b/apps/operator-backend/test/redline_live_gap.test.ts @@ -5,7 +5,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { __testOnlyBuildInitialRedlinePreflightAction, __testOnlyIsFastElectricalPlacementRedline, __testOnlyRunInitialRedlineDecisionLane, __testOnlyShouldPrioritizeInitialRedlinePreflight } from "../src/brains/openai_brain.js"; -import { __testOnlyMaybeRunTopLevelMepRouteRedline } from "../src/brain.js"; +import { __testOnlyMaybeRunTopLevelMepRouteRedline, decide, decideStreaming } from "../src/brain.js"; import { adaptRedlineAnalyzeToAecIntentEvidence } from "../src/redline/redline_analyze_evidence.js"; import { AEC_INTENT_EVIDENCE_MAX_STRING_CHARS } from "../src/aec_intent_evidence.js"; import { executeWorkbenchActions } from "../src/workbench/workbench_runner.js"; @@ -58,7 +58,21 @@ test("exact failed-live request uses the production read-only decision lane once test("authoritative step 1968 safety list keeps both dispatches on one analysis-only lane", async () => { const req = { version: "operator.backend.v1", session_id: "ff088c6a-359c-4d15-97ff-7894e1d3415c", message_id: "f053cd9f-2b13-4389-b455-434f65f0f000", user_text: step1968Prompt, user_attachments: step1968Attachment } as any; const action = __testOnlyBuildInitialRedlinePreflightAction({ userText: step1968Prompt, userAttachments: step1968Attachment }); assert.equal(action?.type, "analyze_redline"); if (action?.type === "analyze_redline") assert.equal(action.file_path, step1968Attachment[0].relative_path); - for (const dispatch of ["non-streaming", "streaming"]) { let resolverCalls = 0; const response = await __testOnlyMaybeRunTopLevelMepRouteRedline(req, async () => { resolverCalls += 1; return { version: "operator.backend.v1", assistant_message: "resolver", actions: [] }; }); assert.equal(response, null, dispatch); assert.equal(resolverCalls, 0, dispatch); } + const previousBrain = process.env.OPERATOR_BRAIN; process.env.OPERATOR_BRAIN = "openai"; + try { + let mepResolverCalls = 0, semanticResolverCalls = 0, nonStreamingAnalysisCalls = 0, streamingAnalysisCalls = 0; + const dependencies = { + mepRouteRedline: async () => { mepResolverCalls += 1; return { version: "operator.backend.v1", assistant_message: "mep resolver", actions: [] }; }, + semanticAecWorkflow: async () => { semanticResolverCalls += 1; return { version: "operator.backend.v1", assistant_message: "semantic resolver", actions: [] }; }, + openAiBrain: async () => { nonStreamingAnalysisCalls += 1; return { version: "operator.backend.v1", assistant_message: "non-streaming analysis lane", actions: [] }; }, + openAiStreamingBrain: async () => { streamingAnalysisCalls += 1; return { version: "operator.backend.v1", assistant_message: "streaming analysis lane", actions: [] }; } + } as any; + const nonStreaming = await decide(req, dependencies); + const streaming = await decideStreaming(req, {}, dependencies); + assert.equal(mepResolverCalls, 0); assert.equal(semanticResolverCalls, 0); + assert.equal(nonStreamingAnalysisCalls, 1); assert.equal(streamingAnalysisCalls, 1); + assert.equal(nonStreaming.assistant_message, "non-streaming analysis lane"); assert.equal(streaming.assistant_message, "streaming analysis lane"); + } finally { if (previousBrain === undefined) delete process.env.OPERATOR_BRAIN; else process.env.OPERATOR_BRAIN = previousBrain; } const order: string[] = []; const lane = await __testOnlyRunInitialRedlineDecisionLane({ userText: step1968Prompt, userAttachments: step1968Attachment, runInitialPreflight: async (entry) => { order.push(entry.type); }, runFastPreflight: async () => { order.push("fast"); return null; }, summarize: async () => { order.push("summary"); return { assistant_message: "Evidence summary", actions: [{ action_id: "unexpected", method: "POST", path: "/revit/delete", body_json: "{}" }] } as any; } }); assert.deepEqual(order, ["analyze_redline", "summary"]); assert.equal(lane.fastPreflight, null); assert.deepEqual(lane.response, { version: "operator.backend.v1", assistant_message: "Evidence summary", actions: [] }); });