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 에 없는 이름은 절대 넣지 마세요.