From 2a05de85d2b5f01bafe39a43286e04bb22f45c2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 18 May 2026 08:36:58 +0900 Subject: [PATCH] feat: enforce self-only assignment (mandatory on create, coerced silently) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project rule: every create_task must be assigned, and only the signed-in user can ever be the assignee. PlannerBot coerces non-self ids to self and the preview card surfaces the coercion with a ๐Ÿ”’ advisory line so the user can see exactly what was substituted before confirm. - prompt.ts: rewrites the ํ• ๋‹น section โ€” LLM must always fill assigneeUserIds with the isMe user on create_task, never assign others, never ask_clarify about non-self assignees - PlannerBot.ts: derives selfId from plan.members, computes coerced state and attempted non-self names for the card, forces finalAssigneeIds to [selfId] on create_task and on any update_task that signals an assignment change - confirmationCard.ts: ActionContext gains assignmentCoercedToSelf + attemptedNonSelfNames; renders the advisory as a warning line Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bot/PlannerBot.ts | 60 +++++++++++++++++++++++++++-------- src/cards/confirmationCard.ts | 14 ++++++++ src/llm/prompt.ts | 11 ++++--- 3 files changed, 66 insertions(+), 19 deletions(-) diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index c9883f1..58aae74 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -316,22 +316,50 @@ export class PlannerBot extends TeamsActivityHandler { ...appliedExplicitNames, ]); - // --- Assignee name resolution ---------------------------------------- - // We accept any id that's either in the plan's group members OR already - // assigned on the task โ€” Planner sometimes carries assignees that aren't - // direct group members (guests, ex-members, cross-tenant). Names get - // resolved via Graph /users/{id} when the group listing didn't surface - // them, and cached on the bot instance so we don't re-hit per turn. + // --- Assignment (self-only project rule) ----------------------------- + // Project rule: every create_task MUST be assigned, and only the signed-in + // user can be assigned. If the LLM tried to assign anyone else, we silently + // coerce to self and surface that on the preview card. update_task only + // touches assigneeUserIds if the LLM intended a change at all. const memberById = new Map(plan.members.map((m) => [m.userId, m])); const knownAssigneeIds = new Set([ ...plan.members.map((m) => m.userId), ...(task?.assigneeIds ?? []), ]); - const validAssigneeIds = (action.assigneeUserIds ?? []).filter((id) => - knownAssigneeIds.has(id), - ); const resolveName = (id: string) => this.resolveAssigneeName(planner, memberById, id); - const newAssigneeNames = await Promise.all(validAssigneeIds.map(resolveName)); + + const selfId = plan.members.find((m) => m.isMe)?.userId; + const llmRequestedAssignees = action.assigneeUserIds; + const llmRequestedNonSelf = (llmRequestedAssignees ?? []).filter( + (id) => id !== selfId, + ); + + let assignmentCoercedToSelf = false; + let attemptedNonSelfNames: string[] | undefined; + if (llmRequestedNonSelf.length > 0) { + assignmentCoercedToSelf = true; + const validNonSelf = llmRequestedNonSelf.filter((id) => + knownAssigneeIds.has(id), + ); + if (validNonSelf.length) { + attemptedNonSelfNames = await Promise.all(validNonSelf.map(resolveName)); + } + } + + // What we'll actually write to Planner. + // - create_task: always [selfId] โ€” assignment is mandatory + // - update_task: leave undefined unless the LLM signaled an assignment + // change, in which case force [selfId] + let finalAssigneeIds: string[] | undefined; + if (action.type === "create_task") { + finalAssigneeIds = selfId ? [selfId] : []; + } else if (llmRequestedAssignees !== undefined) { + finalAssigneeIds = selfId ? [selfId] : []; + } + const newAssigneeNames = + finalAssigneeIds && finalAssigneeIds.length + ? await Promise.all(finalAssigneeIds.map(resolveName)) + : []; // --- Build ActionContext for preview / result ------------------------- const ctx: ActionContext = { @@ -339,6 +367,8 @@ export class PlannerBot extends TeamsActivityHandler { appliedLabelNames: appliedLabelNames.length ? appliedLabelNames : undefined, droppedInferredLabels: dropped.length ? dropped : undefined, missingExplicitLabels: missing.length ? missing : undefined, + assignmentCoercedToSelf: assignmentCoercedToSelf || undefined, + attemptedNonSelfNames, }; if (action.type === "create_task") { @@ -358,7 +388,7 @@ export class PlannerBot extends TeamsActivityHandler { } ctx.currentPercent = task.percentComplete; ctx.currentPriority = task.priority; - if (action.assigneeUserIds) { + if (llmRequestedAssignees !== undefined) { ctx.currentAssigneeNames = await Promise.all( (task.assigneeIds ?? []).map(resolveName), ); @@ -372,9 +402,11 @@ export class PlannerBot extends TeamsActivityHandler { const pending: PendingAction = { action: { ...action, - ...(action.type === "create_task" ? { assigneeUserIds: validAssigneeIds } : {}), - ...(action.type === "update_task" && action.assigneeUserIds - ? { assigneeUserIds: validAssigneeIds } + ...(action.type === "create_task" + ? { assigneeUserIds: finalAssigneeIds } + : {}), + ...(action.type === "update_task" && llmRequestedAssignees !== undefined + ? { assigneeUserIds: finalAssigneeIds } : {}), } as Exclude, ctx, diff --git a/src/cards/confirmationCard.ts b/src/cards/confirmationCard.ts index 426f312..d5d1aa5 100644 --- a/src/cards/confirmationCard.ts +++ b/src/cards/confirmationCard.ts @@ -19,6 +19,11 @@ export interface ActionContext { // Assignment diff (update_task) currentAssigneeNames?: string[]; newAssigneeNames?: string[]; + /** Project rule: only self can be assigned. True when the LLM tried to assign + * someone other than the signed-in user and the bot silently coerced it. */ + assignmentCoercedToSelf?: boolean; + /** displayName ๋ชฉ๋ก โ€” coerce ๋œ ๋น„-๋ณธ์ธ ํ›„๋ณด๋“ค. preview ์•ˆ๋‚ด์— ์‚ฌ์šฉ. */ + attemptedNonSelfNames?: string[]; // Update-only "before" values (for diff display) currentProgressLabel?: string; @@ -145,6 +150,15 @@ function actionDetailLines(a: ClassifiedAction, ctx: ActionContext): { text: str } // Side-effect / advisory lines โ€” apply to both create_task & update_task. + if (ctx.assignmentCoercedToSelf) { + const tried = ctx.attemptedNonSelfNames && ctx.attemptedNonSelfNames.length + ? ` (์š”์ฒญํ•œ ${ctx.attemptedNonSelfNames.join(", ")} ์€(๋Š”) ๋ฌด์‹œ)` + : ""; + lines.push({ + text: `๐Ÿ”’ ์ด ๋ด‡์€ ๋ณธ์ธ์—๊ฒŒ๋งŒ ํ• ๋‹นํ•  ์ˆ˜ ์žˆ์–ด์š” โ€” ๋ณธ์ธ์œผ๋กœ ์ž๋™ ํ• ๋‹นํ–ˆ์Šต๋‹ˆ๋‹ค${tried}.`, + emphasis: "warning", + }); + } if (ctx.droppedInferredLabels && ctx.droppedInferredLabels.length) { lines.push({ text: `โ„น๏ธ ์ถ”๋ก  ๋ผ๋ฒจ ์ค‘ ์ด Plan์— ์—†์–ด ๋ฌด์‹œ๋จ: ${ctx.droppedInferredLabels.join(", ")}`, diff --git a/src/llm/prompt.ts b/src/llm/prompt.ts index 42f8e6d..36e3648 100644 --- a/src/llm/prompt.ts +++ b/src/llm/prompt.ts @@ -36,11 +36,12 @@ export const SYSTEM_PROMPT = `๋‹น์‹ ์€ ์‚ฌ์šฉ์ž์˜ ํ•œ๊ตญ์–ด ์ž์—ฐ์–ด ์ž‘์—… - ์ƒˆ ์ž‘์—…์ด๋ฉด checklistItems, ๊ธฐ์กด ์ž‘์—…์— ๋ง๋ถ™์ด๋Š” ๊ฑฐ๋ฉด addChecklistItems ๋ฅผ ์‚ฌ์šฉ. - ์ตœ๋Œ€ 20๊ฐœ. ๊ทธ๋ณด๋‹ค ๋งŽ์œผ๋ฉด ์•ž 20๊ฐœ๋งŒ. -## ํ• ๋‹น (assigneeUserIds) -- ๋ฐ˜๋“œ์‹œ ์ปจํ…์ŠคํŠธ์˜ plan members ๋ชฉ๋ก ์•ˆ์—์„œ displayName ๋ถ€๋ถ„ ๋งค์นญ์œผ๋กœ ์‚ฌ์šฉ์ž๋ฅผ ์ฐพ๊ณ , ๊ทธ userId ๋ฅผ ์ฑ„์›๋‹ˆ๋‹ค. -- "๋‚˜/๋‚ด๊ฐ€/์ œ๊ฐ€" โ†’ members ์ค‘ isMe=true ํ•ญ๋ชฉ์˜ userId. -- ๋ฐœํ™”์— ์ด๋ฆ„์ด ์žˆ๋Š”๋ฐ members ์— ์ผ์น˜ํ•˜๋Š” ์‚ฌ๋žŒ์ด ์—†์œผ๋ฉด ask_clarification. -- update_task ์—์„œ๋Š” assigneeUserIds ๊ฐ€ ์ „์ฒด ๊ต์ฒด๋กœ ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค. "X ๋„ ์ถ”๊ฐ€" ์ฒ˜๋Ÿผ ๋ˆ„์  ์˜๋„๊ฐ€ ๋ถ„๋ช…ํ•˜๋ฉด ๊ธฐ์กด assigneeIds + ์ƒˆ ์‚ฌ๋žŒ์„ ๋ชจ๋‘ ์ฑ„์›Œ์„œ ๋„ฃ์œผ์„ธ์š”. +## ํ• ๋‹น (assigneeUserIds) โ€” ๋ณธ์ธ๋งŒ ํ• ๋‹น ๊ฐ€๋Šฅ, ๊ทธ๋ฆฌ๊ณ  ํ•„์ˆ˜ +- **์ด ๋ด‡์€ ๋ณธ์ธ(members ์ค‘ isMe=true) ์™ธ์—๋Š” ๋ˆ„๊ตฌ๋„ ํ• ๋‹นํ•˜์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค. ๋ฌด์กฐ๊ฑด ๋ณธ์ธ userId ํ•œ ๊ฐœ๋งŒ ์‚ฌ์šฉํ•˜์„ธ์š”.** +- **๋ชจ๋“  create_task ์— ๋ฐ˜๋“œ์‹œ isMe=true ์ธ ๋ณธ์ธ userId ํ•œ ๊ฐœ๋ฅผ assigneeUserIds ์— ๋„ฃ์œผ์„ธ์š”.** ๋ˆ„๋ฝ ๊ธˆ์ง€(๋นˆ ๋ฐฐ์—ดยทundefined ๊ธˆ์ง€). +- update_task ์—์„œ ํ• ๋‹น์„ ๋ฐ”๊พธ๋ ค๋Š” ์˜๋„๊ฐ€ ๋ช…ํ™•ํ•  ๋•Œ(์˜ˆ: "์ด๊ฑฐ ๋‚˜ํ•œํ…Œ ์ค˜", "๋‚ด๊ฐ€ ํ• ๊ฒŒ")๋งŒ assigneeUserIds ์— ๋ณธ์ธ userId ํ•œ ๊ฐœ๋ฅผ ๋„ฃ์œผ์„ธ์š”. ๋ณ€๊ฒฝ ์˜๋„๊ฐ€ ์—†์œผ๋ฉด assigneeUserIds ๋ฅผ ํ†ต์งธ๋กœ ์ƒ๋žตํ•ด์„œ ๊ธฐ์กด ํ• ๋‹น์„ ์œ ์ง€ํ•ฉ๋‹ˆ๋‹ค. +- ๋ฐœํ™”์— ๋‹ค๋ฅธ ์‚ฌ๋žŒ ์ด๋ฆ„์ด ๋‚˜์™€๋„ ๋ฌด์‹œํ•˜๊ณ  ๋ณธ์ธ userId ๋งŒ ๋„ฃ์œผ์„ธ์š”. ask_clarification ์œผ๋กœ ๋˜๋ฌป์ง€ ๋งˆ์„ธ์š” โ€” ๋ด‡์ด preview ์นด๋“œ์—์„œ "๋ณธ์ธ์œผ๋กœ ์ž๋™ ํ• ๋‹น" ์•ˆ๋‚ด๋ฅผ ํ•ด ์ค๋‹ˆ๋‹ค. +- members ์˜ ๋‹ค๋ฅธ ์‚ฌ๋žŒ userId ๋ฅผ ์ ˆ๋Œ€ assigneeUserIds ์— ๋„ฃ์ง€ ๋งˆ์„ธ์š”. ## ๋ผ๋ฒจ (inferredLabels vs explicitLabels) - **inferredLabels**: ์‚ฌ์šฉ์ž๊ฐ€ ์ง์ ‘ ๋ผ๋ฒจ์„ ์–ธ๊ธ‰ํ•˜์ง€ ์•Š์•˜์ง€๋งŒ ๋ฐœํ™” ์˜๋ฏธ๊ฐ€ plan ์˜ ๊ธฐ์กด ๋ผ๋ฒจ ์ค‘ ํ•˜๋‚˜์™€ ๋ช…ํ™•ํžˆ ๋งค์นญ๋  ๋•Œ๋งŒ ์ฑ„์›๋‹ˆ๋‹ค. ์ž์‹  ์—†์œผ๋ฉด ๋น„์›Œ๋‘์„ธ์š”. plan ์— ์—†๋Š” ์ด๋ฆ„์€ ์ ˆ๋Œ€ ๋„ฃ์ง€ ๋งˆ์„ธ์š”.