diff --git a/landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts b/landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts index c6bc390..28be5d9 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts @@ -436,7 +436,10 @@ export function proposeAssistantAccess( if (questions.length) return { explanation: - "Für den Berechtigungsvorschlag fehlt noch die Zielangabe. Ich erfinde keine E-Mail und wähle keine zusätzlichen Ordner oder Rollen. Noch nichts gespeichert oder in STACKIT berechtigt.", + "Für den Berechtigungsvorschlag fehlt noch die Zielangabe. Ich erfinde keine E-Mail und wähle keine zusätzlichen Ordner oder Rollen. Noch nichts gespeichert oder in STACKIT berechtigt." + + (folderPaths.length + ? `\nAusstehende Ordnerrollen: ${[...(folderPaths.includes("rm_folders[*].owner_emails") ? ["Folder-Owner"] : []), ...(folderPaths.includes("rm_folders[*].reader_emails") ? ["Folder-Reader"] : [])].join(", ")}.` + : ""), questions, changes: [], references: ["governance-folders"], diff --git a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts index 53f91a4..920abec 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/knowledge-topics.generated.ts @@ -407,7 +407,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "1a374ed816dfcaa67a43bd27ca6e36ed2baceabf4bc904fede3e8b373d32680b", + "c52a6b232e658d0d4224d4595baca70c8974284d67448addbb98390846af0e6e", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/editor.ts", @@ -1565,7 +1565,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/dialogue.ts", sha256: - "00de48da38c1f902639fd2d24f5e36345fc38903e129bb87293e07d06f8486f0", + "b142c3ac1fd70349c502df9f7f4b8dc197438aaf28918c09304a215235b5400a", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/firewall.ts", @@ -1580,7 +1580,7 @@ export const assistantKnowledgeTopics = { { path: "landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts", sha256: - "1a374ed816dfcaa67a43bd27ca6e36ed2baceabf4bc904fede3e8b373d32680b", + "c52a6b232e658d0d4224d4595baca70c8974284d67448addbb98390846af0e6e", }, { path: "landing-zone-configurator/app/apps/api/src/assistant/knowledge.ts", diff --git a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts index 3281473..45dc44b 100644 --- a/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts +++ b/landing-zone-configurator/app/apps/api/src/assistant/model-serving.ts @@ -106,6 +106,184 @@ function assistantFolderNames(values: unknown) { ); } +// Folder scope is a user decision, never authority supplied by the classifier. +// Only literal folder references or an exact answer to the immediately open +// server question can select targets. Historical topics cannot revive scope. +const folderScopeQuestion = + "Für welche vorhandenen Ordner soll ich die Berechtigung vorschlagen?"; +function groundAssistantFolderScope( + values: unknown, + request: { + paths: (typeof assistantAccessPaths)[number][]; + source: string; + emailSource: "saved-owner" | "verified-user" | "explicit"; + folderKeys: string[]; + allFolders: boolean; + }, + source: string, + message: string, + history: { role: "user" | "assistant"; content: string }[], + contacts: { saved: string[]; verified: string[] }, +) { + const folders = assistantFolderNames(values); + if (request.folderKeys.some((key) => !Object.hasOwn(folders, key))) + throw new AssistantInferenceError("intent_evidence"); + const empty = { ...request, folderKeys: [] as string[], allFolders: false }; + const previous = history.at(-2), + reply = history.at(-1); + const previousSource = `history-${history.length - 2}`; + if ( + previous?.role === "user" && + reply?.role === "assistant" && + reply.content.endsWith(`\n${folderScopeQuestion}`) && + reply.content.includes( + "Für den Berechtigungsvorschlag fehlt noch die Zielangabe.", + ) + ) { + const answer = message.startsWith(`${folderScopeQuestion}\nMeine Antwort: `) + ? message.slice(`${folderScopeQuestion}\nMeine Antwort: `.length).trim() + : message.trim(); + const choices = Object.entries(folders).some(([key, value]) => + [key, String(value.name ?? "")].includes(answer), + ) + ? [answer] + : answer.split("; ").map((choice) => choice.trim()); + const selected = choices.map((choice) => + Object.entries(folders).filter(([key, value]) => + [key, String(value.name ?? "")].includes(choice), + ), + ); + const selectedKeys = selected.flatMap((matches) => + matches.length === 1 ? [matches[0]![0]] : [], + ); + if ( + choices.length > 0 && + selectedKeys.length === choices.length && + new Set(selectedKeys).size === selectedKeys.length && + (request.source === "current" || request.source === previousSource) + ) { + // Recover only an exact server-shaped pending question. The stored role + // and displayed email identity bind the continuation, not broad keywords + // in the original user request or the model's newly guessed role/source. + const roles: (typeof assistantAccessPaths)[number][][] = [ + ["rm_folders[*].reader_emails"], + ["rm_folders[*].owner_emails"], + ["rm_folders[*].owner_emails", "rm_folders[*].reader_emails"], + ]; + const explicit = [ + ...new Set( + ( + previous.content.match( + /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g, + ) ?? [] + ).filter((email) => z.email().safeParse(email).success), + ), + ]; + for (const [emailSource, emails, description] of [ + [ + "saved-owner", + contacts.saved, + "den gespeicherten Grundlagen (owner_email)", + ], + [ + "verified-user", + contacts.verified, + "der aktuell verifizierten STACKIT-Benutzeridentität", + ], + ["explicit", explicit, "deiner ausdrücklichen E-Mail-Angabe"], + ] as const) { + if (!emails.length) continue; + for (const paths of roles) { + const pending = proposeAssistantAccess( + values as Record, + { paths, folderKeys: [], allFolders: false }, + [...emails], + ); + const canonical = `E-Mail-Quelle: ${description} (${emails.join(", ")}).\n${pending.explanation}\n${pending.questions.map((question) => question.text).join("\n")}`; + if (reply.content === canonical) + return { + ...request, + paths, + source: previousSource, + emailSource, + folderKeys: selectedKeys, + allFolders: false, + }; + } + } + } + } + // Older user requests are not the current authority. A genuine pending reply + // is admitted only by the exact server-shaped continuation above. + if (request.source !== "current") { + if (!request.paths.some((path) => path.startsWith("rm_folders"))) + throw new AssistantInferenceError("intent_evidence"); + return empty; + } + if (!request.paths.some((path) => path.startsWith("rm_folders"))) + return request; + const escapePattern = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const folderWord = String.raw`(?:Ordner(?:s|n)?|folders?)`; + const allWords = String.raw`(?:alle[nr]?|sämtliche[nr]?|all|every)\s+(?:(?:vorhandenen?|bestehenden?|existing)\s+)?${folderWord}`; + const negation = String.raw`(?:nicht|not|kein(?:e|en)?|no|ohne|without)`; + const negativeTargetPrefix = String.raw`${negation}\s+(?:(?:Folder[- ]?)?(?:Lese(?:rechte|berechtigung(?:en)?)|Reader|Owner|rights|access|Berechtigung(?:en)?)\s+)?(?:(?:für|fuer|for|in|im|den|dem|the)\s+){0,3}`; + const excluded = /(?:\baußer\b|\bausser\b|\bexcept\b|\bexcluding\b)/iu.test( + source, + ); + const allMentioned = new RegExp( + String.raw`(?:^|[^\p{L}\p{N}_])${allWords}(?:$|[^\p{L}\p{N}_])`, + "iu", + ).test(source); + const allNegated = new RegExp( + String.raw`${negativeTargetPrefix}${allWords}`, + "iu", + ).test(source); + const allTrailingNegated = new RegExp( + String.raw`${allWords}\s+(?:(?:bitte|please)\s+)?${negation}(?:$|[^\p{L}\p{N}_])`, + "iu", + ).test(source); + if (request.allFolders) + return allMentioned && !allNegated && !allTrailingNegated && !excluded + ? request + : empty; + if (excluded) return empty; + const referenced = request.folderKeys.filter((key) => { + const labels = [...new Set([key, String(folders[key]?.name ?? "")])].filter( + Boolean, + ); + return labels.some((label) => { + const name = String.raw`["'\x60]?${escapePattern(label)}["'\x60]?`; + const boundary = String.raw`(?:^|[^\p{L}\p{N}_])`; + const end = String.raw`(?:$|[^\p{L}\p{N}_])`; + const reference = String.raw`(?:${name}\s*[-–—]?\s*${folderWord}|${folderWord}\s*(?:(?:namens|called|mit\s+dem\s+Namen|mit\s+dem\s+Schlüssel|with\s+key)\s+|:\s*)?${name})`; + const pathReference = String.raw`rm_folders(?:\.${escapePattern(key)}|\[["']${escapePattern(key)}["']\])`; + const negated = new RegExp( + String.raw`${negativeTargetPrefix}(?:${reference}|${pathReference})`, + "iu", + ).test(source); + const explicitPath = new RegExp( + String.raw`${boundary}rm_folders(?:\.${escapePattern(key)}|\[["']${escapePattern(key)}["']\])${end}`, + "u", + ).test(source); + const trailingNegation = new RegExp( + String.raw`(?:${reference}|${pathReference})\s+(?:(?:bitte|please)\s+)?(?:nicht|not|keine?)(?:$|[^\p{L}\p{N}_])`, + "iu", + ).test(source); + return ( + !negated && + !trailingNegation && + (explicitPath || + new RegExp(`${boundary}${reference}${end}`, "iu").test(source)) + ); + }); + }); + // Never apply only a guessed subset of the classifier's requested scope. + return referenced.length === request.folderKeys.length + ? { ...request, folderKeys: referenced, allFolders: false } + : empty; +} + export type AssistantCatalogueContext = { regions: { region: "eu01" | "eu02"; @@ -2641,19 +2819,59 @@ Reviewed knowledge: ${JSON.stringify({ fields: draftFields, ...topicKnowledge }) ) ? [String(values["assistant.user_email"])] : []; + const groundedAssignment = groundAssistantFolderScope( + publicValues, + accessAssignment, + namingSources.get(accessAssignment.source) ?? "", + message, + history, + { + saved: foundationContacts.includes(String(configuredOwner)) + ? [String(configuredOwner)] + : [], + verified: foundationContacts.includes( + String(values["assistant.user_email"]), + ) + ? [String(values["assistant.user_email"])] + : [], + }, + ); + const effectiveEmails = + groundedAssignment.emailSource === accessAssignment.emailSource && + groundedAssignment.source === accessAssignment.source + ? emails + : groundedAssignment.emailSource === "saved-owner" + ? foundationContacts.includes(String(configuredOwner)) + ? [String(configuredOwner)] + : [] + : groundedAssignment.emailSource === "verified-user" + ? foundationContacts.includes( + String(values["assistant.user_email"]), + ) + ? [String(values["assistant.user_email"])] + : [] + : [ + ...new Set( + ( + namingSources + .get(groundedAssignment.source) + ?.match(emailPattern) ?? [] + ).filter((email) => z.email().safeParse(email).success), + ), + ]; const proposal = proposeAssistantAccess( values, - accessAssignment, - emails, + groundedAssignment, + effectiveEmails, ); - if (emails.length) { + if (effectiveEmails.length) { const source = - accessAssignment.emailSource === "saved-owner" + groundedAssignment.emailSource === "saved-owner" ? "den gespeicherten Grundlagen (owner_email)" - : accessAssignment.emailSource === "verified-user" + : groundedAssignment.emailSource === "verified-user" ? "der aktuell verifizierten STACKIT-Benutzeridentität" : "deiner ausdrücklichen E-Mail-Angabe"; - proposal.explanation = `E-Mail-Quelle: ${source}.\n${proposal.explanation}`; + proposal.explanation = `E-Mail-Quelle: ${source} (${effectiveEmails.join(", ")}).\n${proposal.explanation}`; } stream?.onText( "Berechtigungsvorschlag wird geprüft. Noch nichts gespeichert oder in STACKIT berechtigt.", diff --git a/landing-zone-configurator/app/scripts/assistant-scenarios.ts b/landing-zone-configurator/app/scripts/assistant-scenarios.ts index 5347eb2..2fa337e 100644 --- a/landing-zone-configurator/app/scripts/assistant-scenarios.ts +++ b/landing-zone-configurator/app/scripts/assistant-scenarios.ts @@ -17,6 +17,7 @@ import { } from "@lzc/domain"; import { applyAssistantChanges, + proposeAssistantAccess, validateDialogue, } from "../apps/api/src/assistant/dialogue.js"; import { buildAssistantKnowledge } from "../apps/api/src/assistant/knowledge.js"; @@ -679,6 +680,12 @@ export const assistantScenarios: readonly AssistantScenario[] = [ fixture: "governance", message: "Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung.", }, + { + id: "governance-folder-scope-followup", + topic: "governance-folders", + fixture: "governance", + message: "Platform", + }, { id: "governance-missing-email", topic: "governance-folders", @@ -1021,6 +1028,28 @@ export function assistantScenarioFixture(scenario: AssistantScenario) { ] : [], }; + if (scenario.id === "governance-folder-scope-followup") { + const question = proposeAssistantAccess( + values, + { + paths: ["rm_folders[*].reader_emails"], + folderKeys: [], + allFolders: false, + }, + ["owner@example.test"], + ); + history.push( + { + role: "user", + content: + "Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung.", + }, + { + role: "assistant", + content: `E-Mail-Quelle: den gespeicherten Grundlagen (owner_email) (owner@example.test).\n${question.explanation}\n${question.questions.map((q) => q.text).join("\n")}`, + }, + ); + } return { draft, values, history, catalogues }; } @@ -1486,13 +1515,19 @@ export function validateAssistantScenario( ]), ), }; - if (scenario.id === "governance-folder-reader") + if ( + ["governance-folder-reader", "governance-folder-scope-followup"].includes( + scenario.id, + ) + ) expected = { rm_folders: { platform: { reader_emails: [ "folder-reader@example.test", - "reader@example.test", + scenario.id === "governance-folder-scope-followup" + ? "owner@example.test" + : "reader@example.test", ], }, }, diff --git a/landing-zone-configurator/app/tests/assistant-editor.test.ts b/landing-zone-configurator/app/tests/assistant-editor.test.ts index 2e113cc..5c4ddb8 100644 --- a/landing-zone-configurator/app/tests/assistant-editor.test.ts +++ b/landing-zone-configurator/app/tests/assistant-editor.test.ts @@ -2944,3 +2944,327 @@ it("rejects a missing literal-list operation instead of defaulting an addition t ).rejects.toMatchObject({ reason: "intent_schema" }); expect(original).toEqual(before); }); + +const folderScopeValues = () => ({ + owner_email: "saved-owner@example.test", + rm_folders: { + platform: { name: "Platform", reader_emails: ["old-reader@example.test"] }, + research: { + name: "Data Science", + reader_emails: ["research-reader@example.test"], + }, + }, +}); +function folderScopeModel( + options: { + folderKeys?: string[]; + allFolders?: boolean; + source?: string; + paths?: string[]; + emailSource?: string; + } = {}, +) { + return collectionModel( + { + topic: "governance-folders", + mode: "draft", + requirements: [], + accessAssignment: { + paths: options.paths ?? ["rm_folders[*].reader_emails"], + folderKeys: options.folderKeys ?? ["platform"], + allFolders: options.allFolders ?? false, + source: options.source ?? "current", + emailSource: options.emailSource ?? "saved-owner", + }, + }, + null, + ); +} + +it.each([ + ["Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung.", false], + [ + "Als Platform Owner möchte ich mit der hinterlegten Email eine Folder-Leseberechtigung.", + false, + ], + ["Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung.", true], + ["Nutze die hinterlegte Email nicht für alle Ordner als Leser.", true], + ["Nutze die hinterlegte Email NICHT fuer alle Ordner als Leser.", true], + [ + "Nutze die hinterlegte Email nicht fuer den Platform-Ordner als Leser.", + false, + ], + ["Keine Leseberechtigung für alle Ordner mit der hinterlegten Email.", true], + ["Keine Leserechte im Platform-Ordner mit der hinterlegten Email.", false], + [ + "Für alle Ordner keine Leseberechtigung mit der hinterlegten Email vorschlagen.", + true, + ], + [ + "Nicht rm_folders.platform, sondern rm_folders.research als Leser mit der hinterlegten Email.", + false, + ], + [ + "rm_folders.platform nicht ändern; Leserechte mit der hinterlegten Email.", + false, + ], + ["Nutze die hinterlegte Email nicht im Platform-Ordner als Leser.", false], + [ + "Nutze die hinterlegte Email für alle Ordner außer Platform als Leser.", + true, + ], +] as const)( + "asks for a folder instead of trusting guessed scope in %s (all=%s)", + async (message, allFolders) => { + const values = folderScopeValues(), + before = structuredClone(values); + const response = await folderScopeModel({ + allFolders, + folderKeys: allFolders ? [] : ["platform"], + }).converse(knowledgeSelection(), values, [], message); + expect(response.changes).toEqual([]); + expect(response.questions).toMatchObject([ + { + path: "rm_folders[*].reader_emails", + text: "Für welche vorhandenen Ordner soll ich die Berechtigung vorschlagen?", + multiple: true, + }, + ]); + expect(values).toEqual(before); + }, +); + +it.each([ + [ + "platform", + "Trage die hinterlegte Email als Leser des Platform-Ordners ein.", + ], + [ + "research", + "Nutze die hinterlegte Email als Leser für den Ordner namens Data Science.", + ], + [ + "platform", + "Nutze die hinterlegte Email als Leser für rm_folders.platform.", + ], +] as const)("grounds the explicit current folder %s", async (key, message) => { + const values = folderScopeValues(); + const response = await folderScopeModel({ folderKeys: [key] }).converse( + knowledgeSelection(), + values, + [], + message, + ); + expect(response.questions).toEqual([]); + expect(response.changes).toEqual([ + { + path: "rm_folders", + value: { + [key]: { + reader_emails: [ + ...values.rm_folders[key].reader_emails, + "saved-owner@example.test", + ], + }, + }, + }, + ]); +}); + +it("honors an explicitly requested all-folder scope", async () => { + const response = await folderScopeModel({ + allFolders: true, + folderKeys: [], + }).converse( + knowledgeSelection(), + folderScopeValues(), + [], + "Nutze die hinterlegte Email als Leser für alle vorhandenen Ordner.", + ); + expect(response.questions).toEqual([]); + expect(response.changes).toEqual([ + { + path: "rm_folders", + value: { + platform: { + reader_emails: [ + "old-reader@example.test", + "saved-owner@example.test", + ], + }, + research: { + reader_emails: [ + "research-reader@example.test", + "saved-owner@example.test", + ], + }, + }, + }, + ]); +}); + +it("never revives a historical folder target for a new unspecific request", async () => { + const response = await folderScopeModel({ source: "history-0" }).converse( + knowledgeSelection(), + folderScopeValues(), + [ + { + role: "user", + content: + "Nutze die hinterlegte Email für den Platform-Ordner als Leser.", + }, + { + role: "assistant", + content: "Auftrag abgeschlossen. Noch nichts ausgeführt.", + }, + ], + "Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung.", + ); + expect(response.changes).toEqual([]); + expect(response.questions).toHaveLength(1); +}); + +it.each([false, true])( + "continues only an immediately open folder question (discarded=%s)", + async (discarded) => { + const values = folderScopeValues(); + const request = + "Als Platform Owner möchte ich die hinterlegte Email für eine Folder-Leseberechtigung nutzen."; + const first = await folderScopeModel().converse( + knowledgeSelection(), + values, + [], + request, + ); + expect(first.questions).toHaveLength(1); + const history: { role: "user" | "assistant"; content: string }[] = [ + { role: "user", content: request }, + { + role: "assistant", + content: discarded + ? "Der Vorschlag wurde verworfen; dieser Auftrag ist abgeschlossen." + : `${first.explanation}\n${first.questions[0]?.text}`, + }, + ]; + const response = await folderScopeModel({ + source: "history-0", + paths: ["rm_folders[*].owner_emails"], + emailSource: "explicit", + }).converse(knowledgeSelection(), values, history, "Platform"); + if (discarded) { + expect(response.changes).toEqual([]); + expect(response.questions).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: "rm_folders[*].owner_emails" }), + ]), + ); + } else { + expect(response.questions).toEqual([]); + expect(response.changes).toEqual([ + { + path: "rm_folders", + value: { + platform: { + reader_emails: [ + "old-reader@example.test", + "saved-owner@example.test", + ], + }, + }, + }, + ]); + } + }, +); + +it.each(["saved-owner", "explicit"])( + "preserves the canonical verified subject despite negated saved-email context and a model %s guess", + async (emailSource) => { + const values = { + ...folderScopeValues(), + "assistant.user_email": "verified-reader@example.test", + }; + const request = + "Nutze NICHT die gespeicherte Email, sondern die aktuell verifizierte Email für eine Folder-Leseberechtigung."; + const first = await folderScopeModel({ + emailSource: "verified-user", + }).converse(knowledgeSelection(), values, [], request); + const response = await folderScopeModel({ + source: "history-0", + paths: ["organization_owners"], + emailSource, + }).converse( + knowledgeSelection(), + values, + [ + { role: "user", content: request }, + { + role: "assistant", + content: `${first.explanation}\n${first.questions[0]?.text}`, + }, + ], + "Platform", + ); + expect(response.questions).toEqual([]); + expect(response.changes).toEqual([ + { + path: "rm_folders", + value: { + platform: { + reader_emails: [ + "old-reader@example.test", + "verified-reader@example.test", + ], + }, + }, + }, + ]); + }, +); + +it("preserves the exact multiple-folder UI answer without organization-role promotion", async () => { + const values = folderScopeValues(); + const request = + "Nutze die hinterlegte Email fuer eine Folder-Leseberechtigung."; + const first = await folderScopeModel().converse( + knowledgeSelection(), + values, + [], + request, + ); + const response = await folderScopeModel({ + source: "current", + paths: ["organization_owners", "rm_folders[*].owner_emails"], + }).converse( + knowledgeSelection(), + values, + [ + { role: "user", content: request }, + { + role: "assistant", + content: `${first.explanation}\n${first.questions[0]?.text}`, + }, + ], + `${first.questions[0]?.text}\nMeine Antwort: Platform; Data Science`, + ); + expect(response.questions).toEqual([]); + expect(response.changes).toEqual([ + { + path: "rm_folders", + value: { + platform: { + reader_emails: [ + "old-reader@example.test", + "saved-owner@example.test", + ], + }, + research: { + reader_emails: [ + "research-reader@example.test", + "saved-owner@example.test", + ], + }, + }, + }, + ]); +}); diff --git a/landing-zone-configurator/docs/assistant-test-catalogue.json b/landing-zone-configurator/docs/assistant-test-catalogue.json index 2727fb9..00dce99 100644 --- a/landing-zone-configurator/docs/assistant-test-catalogue.json +++ b/landing-zone-configurator/docs/assistant-test-catalogue.json @@ -2,12 +2,12 @@ "schemaVersion": 1, "status": "field-inventory-and-smoke-corpus-not-ai-qualification", "engineRevision": "a256f6896d11134fdc351786f1be5eba4e56b2e2", - "knowledgeSha256": "97dbf413ea6025c7ad3a6b14f8c6e6742ecb536d236fc775293c00b3071fe137", + "knowledgeSha256": "07f55aadc052c9f71c6193b0ff3a7c4d4eb8e4318d826b1ad2afcec0c6ba2ba1", "sourceDigests": { - "scripts/assistant-scenarios.ts": "ce04f4319f089ae9269e2482aaa8bfd66bcf2a1a4617d504146edb757bd7266d", + "scripts/assistant-scenarios.ts": "c3ea7ac63ab6213134058af25cdf1227ba727135c9ee03336bf5d65ea872e859", "scripts/assistant-tests.ts": "5e19b1c74da1ffa996f2d76876cc8dc639da698d0b6f9b7e1fc6637863833a8d" }, - "suiteSha256": "a42cbfa041f8382c8bb4a4b25bae09f6d90269960194cdd97ac7edd69be82c6d", + "suiteSha256": "24385cceda47a61f9b6e02579eccb1aa4917e8810c7c288e6411357f8065ddec", "topics": [ { "id": "credential-foundations", @@ -773,6 +773,19 @@ "no_premature_saved_claim" ] }, + { + "id": "governance-folder-scope-followup", + "topic": "governance-folders", + "fixture": "governance", + "message": "Platform", + "assertions": [ + "valid_dialogue", + "reviewed_topic_reference", + "compiler_accepts_proposal", + "explicit_unsaved_notice", + "no_premature_saved_claim" + ] + }, { "id": "governance-missing-email", "topic": "governance-folders", diff --git a/landing-zone-configurator/docs/user-guide.de.md b/landing-zone-configurator/docs/user-guide.de.md index 2874ef5..c38be45 100644 --- a/landing-zone-configurator/docs/user-guide.de.md +++ b/landing-zone-configurator/docs/user-guide.de.md @@ -56,6 +56,11 @@ Eine verworfene Auswahl ist keine offene Rückfrage mehr; fordere bei Bedarf aus einen neuen Vorschlag an. **Gespräch löschen** bleibt eine eigene Aktion. Dies gilt im Konfigurations- und im Template-Entwurfschat. +Vorschläge für Ordnerrollen benötigen einen ausdrücklich ausgewählten vorhandenen +Ordner oder die ausdrückliche Auswahl aller Ordner. Fehlt das Ziel, fragt der Assistant +nach deiner Auswahl. Vorhandene Ordnernamen und frühere abgeschlossene Anfragen +berechtigen ihn nicht, selbst einen Ordner auszuwählen. + Vorschläge speichern oder veröffentlichen keine Vorlagen, vergeben keine Rollen und stellen keine Infrastruktur bereit. Der Server prüft bei der Übernahme erneut deine Berechtigung und die Entwurfsrevision. Aktualisiere und prüfe einen neuen Vorschlag, wenn diff --git a/landing-zone-configurator/docs/user-guide.md b/landing-zone-configurator/docs/user-guide.md index 299299a..aa6760a 100644 --- a/landing-zone-configurator/docs/user-guide.md +++ b/landing-zone-configurator/docs/user-guide.md @@ -50,6 +50,10 @@ request, saving or cloud execution is involved. A discarded selection is no long open follow-up; ask explicitly if you want a new proposal. **Delete conversation** remains a separate action. This applies in the configuration and template-draft chat. +Folder-role proposals require an explicitly selected existing folder or an explicit +all-folders scope. If the target is missing, the Assistant asks you to select it. +Existing folder names and closed earlier requests do not authorize a folder choice. + Proposals do not save, publish templates, grant roles or provision infrastructure. The server checks your authorization and the draft revision again on acceptance. Refresh and review a new proposal if the underlying configuration has changed.