Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions apps/operator-backend/src/deterministic/room_receptacle_analog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ import { appendGoalProgress, getActiveGoalForSession, setAgentGoal, updateGoal }

const PLAN_PATH = "/revit/plan-room-receptacles-from-analog";
const APPLY_PATH = "/revit/apply-room-receptacles-from-analog";
const MATCH_SOURCE_CIRCUIT_MODE = "match_source_system";

function requestedCircuitMode(intent: AecTaskIntentV1 | null): "none" | "match_source_system" {
const text = intent?.evidence.user_text ?? "";
const explicitCircuitMatch = /\b(?:same|match|copy|mirror|include|assign)\b.{0,48}\b(?:circuits?|panels?)\b/i.test(text)
|| /\b(?:circuits?|panels?)\b.{0,48}\b(?:same|match|copy|mirror|include|assign)\b/i.test(text)
|| /\bwire(?:d|ing)?\b/i.test(text);
return explicitCircuitMatch ? MATCH_SOURCE_CIRCUIT_MODE : "none";
}

function payloadCircuitMode(payload: Record<string, unknown> | null): "none" | "match_source_system" {
const validation = asRecord(payload?.circuitValidation);
return validation?.mode === MATCH_SOURCE_CIRCUIT_MODE ? MATCH_SOURCE_CIRCUIT_MODE : "none";
}

function ensureRoomDesignGoal(req: ChatRequest, intent: AecTaskIntentV1, roomNumber: string): void {
const objective = intent.evidence.user_text.trim() || `Lay out receptacles in Room ${roomNumber}.`;
Expand Down Expand Up @@ -101,6 +115,11 @@ function verifiedApplyReceipt(payload: Record<string, unknown> | null): { create
const typeCounts = Array.isArray(payload.typeCounts) ? payload.typeCounts.map(asRecord) : [];
if (typeCounts.some(value => value === null) || typeCounts.reduce((sum, value) => sum + (Number.isSafeInteger(value?.count) ? value!.count as number : -createdIds.length), 0) !== createdIds.length) return null;
if (typeCounts.some(value => typeof value?.familyType !== "string" || !value.familyType.trim() || !Number.isSafeInteger(value.count) || (value.count as number) <= 0)) return null;
const circuitValidation = asRecord(payload.circuitValidation);
if (circuitValidation?.mode === MATCH_SOURCE_CIRCUIT_MODE) {
const assignments = Array.isArray(circuitValidation.assignments) ? circuitValidation.assignments.map(asRecord) : [];
if (circuitValidation.verified !== true || assignments.length !== createdIds.length || assignments.some(value => value?.exactMatch !== true)) return null;
}
return { createdIds, readback: records, typeCounts: typeCounts as Record<string, unknown>[] };
}

Expand All @@ -121,6 +140,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte

if (!planResult && !applyResult && initialRoom) {
ensureRoomDesignGoal(req, intent!, initialRoom);
const circuitMode = requestedCircuitMode(intent);
return {
version: OPERATOR_BACKEND_CONTRACT_VERSION,
assistant_message: `Preparing and validating the office-standard receptacle layout for Room ${initialRoom}…`,
Expand All @@ -131,6 +151,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte
body: {
targetRoomNumber: initialRoom,
...(intent?.reference.kind === "room" && intent.reference.room_number ? { sourceRoomNumber: intent.reference.room_number } : {}),
...(circuitMode === MATCH_SOURCE_CIRCUIT_MODE ? { circuitMode } : {}),
includePreviewImage: true
}
}]
Expand All @@ -146,6 +167,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte
const targetRoomNumber = nestedRoomNumber(plan, "target");
const sourceRoomNumber = nestedRoomNumber(plan, "source");
const planHash = typeof plan?.planHash === "string" ? plan.planHash : "";
const circuitMode = payloadCircuitMode(plan);
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", 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}`] }] });
Expand Down Expand Up @@ -174,7 +196,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte
action_id: randomUUID(),
method: "POST",
path: APPLY_PATH,
body: { targetRoomNumber, sourceRoomNumber, planHash, includePreviewImage: true }
body: { targetRoomNumber, sourceRoomNumber, planHash, ...(circuitMode === MATCH_SOURCE_CIRCUIT_MODE ? { circuitMode } : {}), includePreviewImage: true }
}]
};
}
Expand Down Expand Up @@ -209,6 +231,12 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte
: previewUnavailable
? " The model write and native element readback passed, but the optional post-apply preview image was unavailable; visual confirmation remains a follow-up."
: "";
const circuitValidation = asRecord(applied?.circuitValidation);
const circuitNote = circuitValidation?.mode === MATCH_SOURCE_CIRCUIT_MODE
? circuitValidation.engineeringReviewRequired === true
? ` Exact source circuit-state parity passed, including ${String(circuitValidation.assignedCount ?? "the assigned")} real system memberships; ${String(circuitValidation.unassignedCount ?? "one or more")} source-matched device(s) remain intentionally unassigned and require engineering review.`
: " Every created device was verified on its exact source power-system ID. This is factual membership/load readback, not a capacity or code-compliance determination."
: "";
appendRoomDesignProgress(req.session_id, {
summary: `Atomic Room ${targetRoomNumber} apply and native persistent readback passed for ${createdIds.length} receptacle(s) using Room ${sourceRoomNumber}.`,
work_items: [
Expand All @@ -220,7 +248,7 @@ export function maybeRunDeterministicRoomReceptacleAnalog(req: ChatRequest, inte
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}`,
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.${circuitNote}${previewNote}`,
actions: []
};
}
Expand Down
37 changes: 37 additions & 0 deletions apps/operator-backend/test/room_receptacle_analog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,37 @@ test("office-standard Room 403 intent enters the native preview path without mod
assert.deepEqual(explicit?.actions[0]?.body, { targetRoomNumber: "403", sourceRoomNumber: "405", includePreviewImage: true });
});

test("explicit circuit intent binds strict source-system matching through preview and apply", () => {
const intent = layoutIntent("403", "405");
intent.evidence.user_text = "Lay out Room 403 from Room 405 and match the same circuits.";
const initial = maybeRunDeterministicRoomReceptacleAnalog(request(intent.evidence.user_text), intent);
assert.deepEqual(initial?.actions[0]?.body, {
targetRoomNumber: "403",
sourceRoomNumber: "405",
circuitMode: "match_source_system",
includePreviewImage: true
});

const continuation = maybeRunDeterministicRoomReceptacleAnalog(request("", [{
action_id: "preview-circuits",
method: "POST",
path: "/revit/plan-room-receptacles-from-analog",
status: "done",
result_json: {
status: "ready", ready: true, planHash: "hash-circuit-405-403",
source: { number: "405" }, target: { number: "403" },
circuitValidation: { mode: "match_source_system", verified: true }
}
}]));
assert.deepEqual(continuation?.actions[0]?.body, {
targetRoomNumber: "403",
sourceRoomNumber: "405",
planHash: "hash-circuit-405-403",
circuitMode: "match_source_system",
includePreviewImage: true
});
});

test("verified rollback preview advances to the exact hash-bound analog apply", () => {
const response = maybeRunDeterministicRoomReceptacleAnalog(request("", [{
action_id: "preview",
Expand Down Expand Up @@ -177,6 +208,12 @@ test("completion receipt requires exact current-run ids plus type, room, host, p
mutate(copy => { delete copy.readback[0].semanticAnchor; }),
mutate(copy => { copy.typeCounts[0].count = 2; })
]) assert.equal(__testOnlyVerifiedAnalogApplyReceipt(invalid), null);

const circuitValid = appliedReceipt([1700001], [{ familyType: "Duplex Receptacle|Standard", count: 1 }]) as any;
circuitValid.circuitValidation = { mode: "match_source_system", verified: true, assignments: [{ exactMatch: true }] };
assert.ok(__testOnlyVerifiedAnalogApplyReceipt(circuitValid));
circuitValid.circuitValidation.assignments[0].exactMatch = false;
assert.equal(__testOnlyVerifiedAnalogApplyReceipt(circuitValid), null);
});

test("applied receipt keeps success truthful while surfacing post-commit preview warnings", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
using System;
using RevitBridge.Common.Electrical;
using Xunit;

namespace RevitBridge.Common.Tests
{
public sealed class CircuitMatchPolicyTests
{
[Theory]
[InlineData(null, "none")]
[InlineData("", "none")]
[InlineData("NONE", "none")]
[InlineData(" match_source_system ", "match_source_system")]
public void NormalizesSupportedModes(string? value, string expected)
{
Assert.Equal(expected, CircuitMatchPolicy.NormalizeMode(value));
}

[Fact]
public void RejectsUnsupportedMode()
{
Assert.Throws<ArgumentException>(() => CircuitMatchPolicy.NormalizeMode("copy_labels"));
}

[Theory]
[InlineData("PowerCircuit", true)]
[InlineData("PowerBalanced", true)]
[InlineData("PowerUnBalanced", true)]
[InlineData("Data", false)]
[InlineData(null, false)]
public void RecognizesOnlyPowerSystems(string? value, bool expected)
{
Assert.Equal(expected, CircuitMatchPolicy.IsPowerSystemType(value));
}

[Fact]
public void ExactMembershipRequiresOneMatchingSystem()
{
Assert.True(CircuitMatchPolicy.HasExactMembership(42, new long[] { 42 }));
Assert.False(CircuitMatchPolicy.HasExactMembership(42, Array.Empty<long>()));
Assert.False(CircuitMatchPolicy.HasExactMembership(42, new long[] { 41 }));
Assert.False(CircuitMatchPolicy.HasExactMembership(42, new long[] { 42, 43 }));
}

[Fact]
public void FactualDeltaRejectsMissingOrNonFiniteValues()
{
Assert.Equal(12.5, CircuitMatchPolicy.FactualDelta(100, 112.5));
Assert.Null(CircuitMatchPolicy.FactualDelta(null, 112.5));
Assert.Null(CircuitMatchPolicy.FactualDelta(100, double.NaN));
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using System.Linq;

namespace RevitBridge.Common.Electrical
{
public static class CircuitMatchPolicy
{
public const string None = "none";
public const string MatchSourceSystem = "match_source_system";

public static string NormalizeMode(string? value)
{
var mode = (value ?? string.Empty).Trim().ToLowerInvariant();
if (mode.Length == 0 || mode == None) return None;
if (mode == MatchSourceSystem) return MatchSourceSystem;
throw new ArgumentException("Unsupported circuitMode. Expected 'none' or 'match_source_system'.");
}

public static bool IsPowerSystemType(string? value)
{
var type = (value ?? string.Empty).Trim();
return type.Equals("PowerCircuit", StringComparison.OrdinalIgnoreCase) ||
type.Equals("PowerBalanced", StringComparison.OrdinalIgnoreCase) ||
type.Equals("PowerUnBalanced", StringComparison.OrdinalIgnoreCase);
}

public static bool HasExactMembership(long expectedSystemId, IEnumerable<long>? actualPowerSystemIds)
{
var ids = (actualPowerSystemIds ?? Enumerable.Empty<long>()).Distinct().ToList();
return expectedSystemId > 0 && ids.Count == 1 && ids[0] == expectedSystemId;
}

public static double? FactualDelta(double? before, double? after)
{
if (!before.HasValue || !after.HasValue ||
double.IsNaN(before.Value) || double.IsInfinity(before.Value) ||
double.IsNaN(after.Value) || double.IsInfinity(after.Value)) return null;
return after.Value - before.Value;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
using Autodesk.Revit.DB.Structure;
using Autodesk.Revit.UI;
using RevitBridge.Common;
using RevitBridge.Common.Electrical;
using RevitBridge.Common.Spatial;
using RevitBridge.Logic.Handlers.Core;

Expand Down Expand Up @@ -457,7 +458,7 @@ internal static string GetSpatialKind(SpatialElement? spatial)
};
}

internal static ResolvedSpatialContext? FindSpatialElement(Document doc, long? spatialId, string? roomNumber)
internal static ResolvedSpatialContext? FindSpatialElement(Document doc, long? spatialId, string? roomNumber, string? spatialKindPreference = "auto")
{
if (spatialId.HasValue && spatialId.Value > 0)
{
Expand All @@ -477,7 +478,7 @@ internal static string GetSpatialKind(SpatialElement? spatial)
}
}

var resolved = SpatialElementResolver.ResolveByNumber(doc, roomNumber ?? "", "auto");
var resolved = SpatialElementResolver.ResolveByNumber(doc, roomNumber ?? "", spatialKindPreference);
if (resolved?.Element == null) return null;
return new ResolvedSpatialContext
{
Expand Down Expand Up @@ -1811,6 +1812,7 @@ internal static bool TryReassignElectricalCircuitFromSource(
}

var sourceLabel = BuildElectricalCircuitLabel(sourceFi, sourceCircuit);
var sourceSystemId = TryGetElectricalSystemId(sourceCircuit);
var sourceNormalized = NormalizeElectricalCircuitLabel(sourceLabel);
var targetSystemsBefore = GetElectricalSystems(target);
var removedLabels = new List<string>();
Expand Down Expand Up @@ -1856,6 +1858,12 @@ internal static bool TryReassignElectricalCircuitFromSource(
var finalNormalized = NormalizeElectricalCircuitLabel(finalLabel);
if (requireMatch)
{
var finalPowerSystemIds = GetPowerElectricalSystemIds(target);
if (!sourceSystemId.HasValue || !CircuitMatchPolicy.HasExactMembership(sourceSystemId.Value, finalPowerSystemIds))
{
detail = $"Adjusted instance did not join exactly the source power system. Expected system {sourceSystemId?.ToString(CultureInfo.InvariantCulture) ?? "unknown"}; actual power systems: {string.Join(",", finalPowerSystemIds)}.";
return false;
}
if (sourceNormalized.Length > 0 && finalNormalized.Length > 0 && !string.Equals(sourceNormalized, finalNormalized, StringComparison.OrdinalIgnoreCase))
{
detail = $"Adjusted instance joined an unexpected electrical circuit. Expected {sourceLabel}, got {finalLabel}.";
Expand Down Expand Up @@ -1883,6 +1891,8 @@ internal static object BuildElectricalCircuitAuditPayload(FamilyInstance? instan
var labels = new List<string>();
var normalized = new List<string>();
var systems = instance == null ? new List<object>() : GetElectricalSystems(instance);
var systemIds = systems.Select(TryGetElectricalSystemId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().OrderBy(id => id).ToList();
var powerSystemIds = systems.Where(LooksLikePowerCircuit).Select(TryGetElectricalSystemId).Where(id => id.HasValue).Select(id => id!.Value).Distinct().OrderBy(id => id).ToList();
if (instance != null)
{
foreach (var system in systems)
Expand All @@ -1907,10 +1917,26 @@ internal static object BuildElectricalCircuitAuditPayload(FamilyInstance? instan
primaryLabel = primaryLabel.Length > 0 ? primaryLabel : null,
labels,
normalizedLabels = normalized,
systemCount = systems.Count
systemCount = systems.Count,
systemIds,
powerSystemIds,
exactPowerSystemCount = powerSystemIds.Count
};
}

internal static List<long> GetPowerElectricalSystemIds(FamilyInstance? instance)
{
if (instance == null) return new List<long>();
return GetElectricalSystems(instance)
.Where(LooksLikePowerCircuit)
.Select(TryGetElectricalSystemId)
.Where(id => id.HasValue)
.Select(id => id!.Value)
.Distinct()
.OrderBy(id => id)
.ToList();
}

private static object? ResolvePreferredElectricalSystem(FamilyInstance sourceFi)
{
var systems = GetElectricalSystems(sourceFi);
Expand Down Expand Up @@ -1994,6 +2020,18 @@ private static bool LooksLikePowerCircuit(object system)
return false;
}

private static long? TryGetElectricalSystemId(object? system)
{
if (system is Element element) return ElementIdCompat.GetValue(element.Id);
try
{
var value = system?.GetType().GetProperty("Id", BindingFlags.Instance | BindingFlags.Public)?.GetValue(system);
if (value is ElementId id) return ElementIdCompat.GetValue(id);
}
catch { }
return null;
}

private static bool TryAddFamilyInstanceToElectricalSystem(object system, FamilyInstance created, out string error)
{
error = "";
Expand Down
Loading
Loading