Skip to content

Commit d0fc3d3

Browse files
lweberruCopilot
andauthored
fix(configurator): preserve broad Firewall setup decisions (#122)
## Root cause Protected Dev release [39412434669](https://github.com/stackitcloud/stackit-landing-zone/actions/runs/39412434669), merged source `0c5188bf60c332adcbecf5372e913021b298e126`, passed 113/114 model cases and 13/13 journey steps, with no transport failures. `firewall-metro-setup-operations`, repetition 1, omitted the still-unanswered interruption/operations question. Build and deployment were skipped; the original failed receipt remains retained. The general intent classifier could label a broad “continue the central Firewall setup with HA” request with `firewallFields=[ha]`. That field hint bypassed the dedicated setup-scope/evidence classifier, making a broad continuation behave like a narrow HA-only update. Native proposal validation did not detect the omitted conversational decision. ## Changes - Always consult the existing dedicated scope/evidence classifier for active Firewall drafts and multi-turn Firewall conversations, independently of general field hints. - Keep the existing semantic distinction: broad setup asks only the next unanswered business decision; explicit narrow native changes do not acquire unrelated setup questions, including after earlier broad setup. - Use null-only evidence fields when no bounded quote fragments exist, rather than emitting empty enumerations for a newly classified long native request. - Add six regression cases covering every remaining/complete setup stage despite `ha` field hints, explicit narrow changes after broad history, and empty quote inventories. - Adapt mock classifier responses and positional schema assertions to the additional classification call, preserving all semantic expectations and the no-extra-answer-model contract for missing native IPs. - Update complete EN/DE guidance and regenerate source bindings. No curated topic facts, model defaults, native infrastructure, or factual oracle changed. ## Validation - Red reproduction: four valid Metro setup cases demonstrated the bypass before the runtime correction. - Pinned Node 24.21.0/npm 11.19.0 canonical `npm run check`: **977 passed**, 69 unchanged default-gated cases; lint, types and build passed. - Real configured-model Firewall cases: **28/28 passed**, two independent repetitions; continuous dialogue **13/13 passed**, both exit 0. Both answer and intent models remain `Qwen/Qwen3-VL-235B-A22B-Instruct-FP8`; execution was disabled and only the dedicated inference token was used. - Knowledge SHA: `0fd2c183ebdf903b2e7d10e50dac5e4b3abd08e9f665fcc15bbab01b95669299`; unchanged factual suite SHA: `8c60fd19e7e5a6e8903ccea7987f834112c948b4de8cb60487a1694bc64fe2ee`. - Exact qualified source: `81acc9da14b7665b5733373773f7e8c608a51b2b`; committed content manifest retained with the local receipts. No transport failures occurred during this qualification. ## Release boundary Refs #95. Dev has not been changed by the failed release. After normal reviewed integration, a new manual exact-source release must pass its complete repeated model gate before building and deploying with `runner_pool_size=1`, then verify hosted warm handoff and replacement readiness. Previous failures remain failures. No failed iteration was retried as a replacement receipt, no oracle was weakened, and no customer Apply or production operation was performed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0c5188b commit d0fc3d3

6 files changed

Lines changed: 211 additions & 16 deletions

File tree

‎landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1512,7 +1512,7 @@ export const assistantKnowledgeTopics = {
15121512
{
15131513
path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts",
15141514
sha256:
1515-
"2bd270d1afd3937822417d790452180c9323d13b779862198a724948056f7186",
1515+
"afd22388e2326e4cf45f6fdc970e7725448a8ac11d2be38333cd02ea19642f07",
15161516
},
15171517
{
15181518
path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts",

‎landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1551,9 +1551,7 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge })
15511551
let setupDecisions: z.infer<typeof requirementQuotesSchema> | null = null;
15521552
if (
15531553
["firewall-infrastructure", "firewall-policy"].includes(intent.topic) &&
1554-
(intent.mode === "draft" || requirementSources.length > 1) &&
1555-
(intent.firewallFields.length === 0 ||
1556-
intent.networkScope === "sna-and-firewall")
1554+
(intent.mode === "draft" || requirementSources.length > 1)
15571555
) {
15581556
const quoteSources = [
15591557
...new Set(
@@ -1574,9 +1572,9 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge })
15741572
.filter((quote) => quote.length > 0 && quote.length <= 600),
15751573
),
15761574
].map((text, index) => ({ id: `user-evidence-${index}`, text }));
1577-
const evidence = z
1578-
.enum(quoteSources.map((source) => source.id))
1579-
.nullable();
1575+
const evidence = quoteSources.length
1576+
? z.enum(quoteSources.map((source) => source.id)).nullable()
1577+
: z.null();
15801578
const selection = (
15811579
field:
15821580
| "setupRequest"

‎landing-zone-configurator/app/tests/assistant-firewall.test.ts‎

Lines changed: 187 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ function model(
118118
entry.text.includes(quote),
119119
)?.id ??
120120
quote);
121+
const setupScope =
122+
targets.setupScope ??
123+
(mode === "advice"
124+
? "advice"
125+
: targets.fields?.length
126+
? "field-update"
127+
: "setup");
121128
return Response.json({
122129
choices: [
123130
{
@@ -127,13 +134,13 @@ function model(
127134
content: JSON.stringify(
128135
schemaName === "firewall_setup_decisions"
129136
? {
130-
scope:
131-
targets.setupScope ??
132-
(mode === "advice" ? "advice" : "setup"),
137+
scope: setupScope,
133138
setupRequest: source(
134139
targets.setupRequest ??
135-
JSON.parse(body.messages.at(-1).content)
136-
.latestUserMessage,
140+
(setupScope === "setup"
141+
? JSON.parse(body.messages.at(-1).content)
142+
.latestUserMessage
143+
: null),
137144
),
138145
capacityDeferred: source(
139146
targets.capacityDeferred
@@ -859,6 +866,173 @@ it("keeps a narrow native patch free of setup questions even when the general cl
859866
expect(result.questions).toEqual([]);
860867
});
861868

869+
it.each([
870+
{
871+
traffic: false,
872+
capacity: false,
873+
operations: false,
874+
path: "connectivity.firewall.lan_network_range",
875+
},
876+
{
877+
traffic: true,
878+
capacity: false,
879+
operations: false,
880+
path: "connectivity.firewall.flavor",
881+
},
882+
{
883+
traffic: true,
884+
capacity: true,
885+
operations: false,
886+
path: "connectivity.firewall.ha",
887+
},
888+
{ traffic: true, capacity: true, operations: true, path: null },
889+
])(
890+
"classifies broad HA setup independently of general field hints: $path",
891+
async (answered) => {
892+
const f = fixture();
893+
f.values["connectivity.firewall"] = {
894+
...firewall,
895+
zone: "eu01-m",
896+
flavor: "c1.2",
897+
lan_network_range: "10.0.2.0/28",
898+
wan_network_range: "10.0.2.16/28",
899+
};
900+
const message =
901+
"Bitte die restliche zentrale Firewall-Einrichtung mit HA fortsetzen. Vorhandene Netz- und VM-Werte behalten, keine Ausführung.";
902+
const quotes = {
903+
traffic: answered.traffic
904+
? "Über die zentrale Firewall sollen Internet-Verkehr und Verkehr zwischen Projekten laufen."
905+
: null,
906+
capacity: answered.capacity
907+
? "Wir erwarten 500 Mbit/s Spitzendurchsatz und 10000 parallele Verbindungen."
908+
: null,
909+
operations: answered.operations
910+
? "Maximal zwei Minuten Unterbrechung sind tolerierbar."
911+
: null,
912+
};
913+
const history = Object.values(quotes).flatMap((content) =>
914+
content ? [{ role: "user" as const, content }] : [],
915+
);
916+
const { engine, request } = model(
917+
proposal,
918+
"other",
919+
"draft",
920+
"firewall-infrastructure",
921+
"ha",
922+
{
923+
fields: ["ha"],
924+
setupScope: "setup",
925+
setupRequest: message,
926+
haRequest: message,
927+
setupQuotes: quotes,
928+
},
929+
);
930+
const result = await engine.converse(
931+
f.selection,
932+
f.values,
933+
history,
934+
message,
935+
{
936+
regions: [
937+
{
938+
region: "eu01",
939+
fetchedAt: null,
940+
products: {
941+
availabilityZones: {
942+
status: "available",
943+
totalOptions: 3,
944+
truncated: false,
945+
options: ["eu01-m", "eu01-1", "eu01-2"].map((value) => ({
946+
value,
947+
label: value,
948+
})),
949+
},
950+
},
951+
},
952+
],
953+
},
954+
);
955+
expect(result.changes).toEqual(proposal.changes);
956+
expect(result.questions.map((question) => question.path)).toEqual(
957+
answered.path ? [answered.path] : [],
958+
);
959+
expect(
960+
request.mock.calls.filter(
961+
([, options]) =>
962+
JSON.parse(String(options?.body)).response_format.json_schema.name ===
963+
"firewall_setup_decisions",
964+
),
965+
).toHaveLength(1);
966+
},
967+
);
968+
969+
it("retains an explicit narrow HA update after broad setup despite general field hints", async () => {
970+
const f = fixture();
971+
const { engine, request } = model(
972+
proposal,
973+
"other",
974+
"draft",
975+
"firewall-infrastructure",
976+
"ha",
977+
{ fields: ["ha"], setupScope: "field-update" },
978+
);
979+
const result = await engine.converse(
980+
f.selection,
981+
f.values,
982+
[
983+
{
984+
role: "user",
985+
content: "Bitte die zentrale Firewall vollständig einrichten.",
986+
},
987+
],
988+
"Nur HA mit Backup-Zone eu01-2 und vhid 23 ergänzen, keine weiteren Einrichtungsfragen.",
989+
);
990+
expect(result.changes).toEqual(proposal.changes);
991+
expect(result.questions).toEqual([]);
992+
expect(
993+
request.mock.calls.filter(
994+
([, options]) =>
995+
JSON.parse(String(options?.body)).response_format.json_schema.name ===
996+
"firewall_setup_decisions",
997+
),
998+
).toHaveLength(1);
999+
});
1000+
1001+
it("uses null-only evidence for a long narrow request without bounded quote fragments", async () => {
1002+
const f = fixture();
1003+
const { engine, request } = model(
1004+
proposal,
1005+
"other",
1006+
"draft",
1007+
"firewall-infrastructure",
1008+
"ha",
1009+
{ fields: ["ha"], setupScope: "field-update" },
1010+
);
1011+
const result = await engine.converse(
1012+
f.selection,
1013+
f.values,
1014+
[],
1015+
`Nur HA mit Backup-Zone eu01-2 und vhid 23 ergänzen; alle anderen Werte erhalten ${"Zusatz ".repeat(100)}`,
1016+
);
1017+
expect(result.changes).toEqual(proposal.changes);
1018+
expect(result.questions).toEqual([]);
1019+
const classification = request.mock.calls
1020+
.map(([, options]) => JSON.parse(String(options?.body)))
1021+
.find(
1022+
(body) =>
1023+
body.response_format.json_schema.name === "firewall_setup_decisions",
1024+
);
1025+
expect(
1026+
JSON.parse(classification.messages.at(-1).content).userEvidence,
1027+
).toEqual([]);
1028+
expect(
1029+
classification.response_format.json_schema.schema.properties.traffic,
1030+
).toMatchObject({ type: "null" });
1031+
expect(
1032+
JSON.stringify(classification.response_format.json_schema.schema),
1033+
).not.toContain('"enum":[]');
1034+
});
1035+
8621036
it("rejects invented confirmations from the bounded setup decision classifier", async () => {
8631037
const f = fixture();
8641038
const { request } = model(
@@ -922,7 +1096,7 @@ it("restricts explicitly requested IP response values to saved or user-supplied
9221096
[],
9231097
"LAN-IP 10.0.252.8 und HA-VIP 10.0.252.9, Backup-Zone eu01-2, vhid 23 vorschlagen.",
9241098
);
925-
const schema = JSON.parse(String(request.mock.calls[1]?.[1]?.body))
1099+
const schema = JSON.parse(String(request.mock.calls.at(-1)?.[1]?.body))
9261100
.response_format.json_schema.schema;
9271101
const value = schema.properties.changes.items.properties.value;
9281102
expect(value.required).toContain("lan_ip");
@@ -957,7 +1131,12 @@ it("asks for an explicitly requested missing node IP instead of proposing a comp
9571131
expect(response.questions.map((entry) => entry.path)).toEqual([
9581132
"connectivity.firewall.lan_ip",
9591133
]);
960-
expect(request).toHaveBeenCalledTimes(1);
1134+
expect(
1135+
request.mock.calls.map(
1136+
([, options]) =>
1137+
JSON.parse(String(options?.body)).response_format.json_schema.name,
1138+
),
1139+
).toEqual(["conversation_intent", "firewall_setup_decisions"]);
9611140
});
9621141

9631142
it("still rejects invented computed primary and HA IP defaults after schema narrowing", async () => {
@@ -1365,7 +1544,7 @@ it("requires explicitly requested existing native fields in the answer contract"
13651544
"Name reviewed-fw und Flavor g1.8 vorschlagen.",
13661545
);
13671546
expect(response.changes).toEqual(patch.changes);
1368-
const schema = JSON.parse(String(request.mock.calls[1]?.[1]?.body))
1547+
const schema = JSON.parse(String(request.mock.calls.at(-1)?.[1]?.body))
13691548
.response_format.json_schema.schema;
13701549
expect(schema.properties.changes.items.properties.value.required).toEqual([
13711550
"flavor",

‎landing-zone-configurator/docs/assistant-knowledge/README.de.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,16 @@ Effektive Modell-IDs stehen in jedem Nachweis.
138138
Qualifikation eines alternativen Modells qualifiziert nicht das
139139
konfigurierte Anwendungsmodell.
140140

141+
Aktive Firewall-Entwuerfe und mehrstufige Firewall-Dialoge verwenden immer
142+
den spezialisierten Klassifikator fuer Einrichtungsumfang und Belege.
143+
Ein allgemeiner Feldhinweis wie `ha` darf ihn nicht umgehen: Eine breite
144+
Einrichtungsfortsetzung mit HA behaelt die noch offenen Entscheidungen zu
145+
Verkehr, Kapazitaet und Unterbrechung bei. Eine ausdrueckliche gezielte native
146+
Aenderung bleibt auch nach einem frueheren breiten Einrichtungsdialog eine
147+
Feldaenderung und erzeugt keine fremden Einrichtungsfragen.
148+
Gibt es keine begrenzten Nutzerbeleg-Fragmente, erlauben Belegfelder nur null
149+
statt einer leeren Aufzaehlung.
150+
141151
Beschreibungen von Intent- und Evidenzschemas werden auch im Systemkontext
142152
des Klassifikators gesendet, nicht nur als Structured-Decoding-Beschraenkung.
143153
Antwortgenerierung erhaelt geprueftes Feld-/Themenwissen ohne zweiten

‎landing-zone-configurator/docs/assistant-knowledge/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,14 @@ without a CI-only classifier override. The effective model IDs are recorded in
100100
every receipt. Qualification of an alternative model does not qualify the
101101
application's configured model.
102102

103+
Active Firewall drafts and multi-turn Firewall conversations always use the
104+
dedicated setup-scope/evidence classifier. A general field hint such as `ha`
105+
must not bypass it: continuing broad setup with HA retains the remaining
106+
traffic, capacity and interruption decisions. An explicit narrow native update
107+
remains a field update, even after an earlier broad setup conversation, and does
108+
not add unrelated setup questions. If no bounded user-evidence fragments exist,
109+
evidence fields permit only null rather than an empty enumeration.
110+
103111
Intent and evidence schema descriptions are also sent in the classifier's system
104112
context, not only as a structured-decoding constraint. Answer generation receives
105113
the reviewed field and topic knowledge, without a second full schema dump.

‎landing-zone-configurator/docs/assistant-test-catalogue.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"schemaVersion": 1,
33
"status": "field-inventory-and-smoke-corpus-not-ai-qualification",
44
"engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2",
5-
"knowledgeSha256": "6179c3ad16fe011412c165ed1a0d2d71bff0678b9ba3dc2642464fbc3a41db69",
5+
"knowledgeSha256": "0fd2c183ebdf903b2e7d10e50dac5e4b3abd08e9f665fcc15bbab01b95669299",
66
"sourceDigests": {
77
"scripts/assistant-scenarios.ts": "441273d9af541a14185062728a9f9a7044df0e8a7c51645a53eb970a341867d9",
88
"scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d"

0 commit comments

Comments
 (0)