Compare commits

...

2 Commits

Author SHA1 Message Date
윤정민
2a05de85d2 feat: enforce self-only assignment (mandatory on create, coerced silently)
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 32s
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) <noreply@anthropic.com>
2026-05-18 08:36:58 +09:00
윤정민
c093480e2b fix: resolve assignee userIds to displayName via Graph fallback
When an assignee on a task isn't in the plan's M365 group member listing
(guests, ex-members, cross-tenant collaborators), we previously leaked the
raw GUID into preview/result cards. Now we fall back to /users/{id} via the
delegated User.ReadBasic.All scope, cache the resolved name on the bot
instance, and show "사용자 {short-guid}…" only if every lookup fails.

Also: allow assigneeUserIds to retain ids that are already on the task even
if not in the current member list, so additive assignments don't strip
existing assignees. Prompt rule added: never put GUIDs into user-facing
text fields.

NOTE: requires User.ReadBasic.All delegated permission on the AAD app
(add + grant admin consent; users must re-login to pick up the new scope).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 15:42:25 +09:00
4 changed files with 120 additions and 15 deletions

View File

@ -91,6 +91,11 @@ export class PlannerBot extends TeamsActivityHandler {
private readonly memoryAccessor: StatePropertyAccessor<WorkingMemory | undefined>; private readonly memoryAccessor: StatePropertyAccessor<WorkingMemory | undefined>;
private readonly rawTurnsAccessor: StatePropertyAccessor<RawTurn[] | undefined>; private readonly rawTurnsAccessor: StatePropertyAccessor<RawTurn[] | undefined>;
private readonly connectionName: string; private readonly connectionName: string;
/** In-process cache for Graph `/users/{id}` displayName lookups so we don't
* re-hit Graph for the same assignee on every preview. Lives until the
* container restarts perfectly fine since the cache is purely a perf
* optimization and recovers itself on first miss. */
private readonly nameCache = new Map<string, string>();
constructor(deps: PlannerBotDeps) { constructor(deps: PlannerBotDeps) {
super(); super();
@ -311,10 +316,50 @@ export class PlannerBot extends TeamsActivityHandler {
...appliedExplicitNames, ...appliedExplicitNames,
]); ]);
// --- Assignee name resolution + sanity-filter to plan members -------- // --- 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 memberById = new Map(plan.members.map((m) => [m.userId, m]));
const validAssigneeIds = (action.assigneeUserIds ?? []).filter((id) => memberById.has(id)); const knownAssigneeIds = new Set<string>([
const newAssigneeNames = validAssigneeIds.map((id) => memberById.get(id)!.displayName); ...plan.members.map((m) => m.userId),
...(task?.assigneeIds ?? []),
]);
const resolveName = (id: string) => this.resolveAssigneeName(planner, memberById, id);
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 ------------------------- // --- Build ActionContext for preview / result -------------------------
const ctx: ActionContext = { const ctx: ActionContext = {
@ -322,6 +367,8 @@ export class PlannerBot extends TeamsActivityHandler {
appliedLabelNames: appliedLabelNames.length ? appliedLabelNames : undefined, appliedLabelNames: appliedLabelNames.length ? appliedLabelNames : undefined,
droppedInferredLabels: dropped.length ? dropped : undefined, droppedInferredLabels: dropped.length ? dropped : undefined,
missingExplicitLabels: missing.length ? missing : undefined, missingExplicitLabels: missing.length ? missing : undefined,
assignmentCoercedToSelf: assignmentCoercedToSelf || undefined,
attemptedNonSelfNames,
}; };
if (action.type === "create_task") { if (action.type === "create_task") {
@ -341,11 +388,10 @@ export class PlannerBot extends TeamsActivityHandler {
} }
ctx.currentPercent = task.percentComplete; ctx.currentPercent = task.percentComplete;
ctx.currentPriority = task.priority; ctx.currentPriority = task.priority;
if (action.assigneeUserIds) { if (llmRequestedAssignees !== undefined) {
const currentNames = (task.assigneeIds ?? []).map( ctx.currentAssigneeNames = await Promise.all(
(id) => memberById.get(id)?.displayName ?? id, (task.assigneeIds ?? []).map(resolveName),
); );
ctx.currentAssigneeNames = currentNames;
ctx.newAssigneeNames = newAssigneeNames; ctx.newAssigneeNames = newAssigneeNames;
} }
if (action.addChecklistItems && action.addChecklistItems.length) { if (action.addChecklistItems && action.addChecklistItems.length) {
@ -356,9 +402,11 @@ export class PlannerBot extends TeamsActivityHandler {
const pending: PendingAction = { const pending: PendingAction = {
action: { action: {
...action, ...action,
...(action.type === "create_task" ? { assigneeUserIds: validAssigneeIds } : {}), ...(action.type === "create_task"
...(action.type === "update_task" && action.assigneeUserIds ? { assigneeUserIds: finalAssigneeIds }
? { assigneeUserIds: validAssigneeIds } : {}),
...(action.type === "update_task" && llmRequestedAssignees !== undefined
? { assigneeUserIds: finalAssigneeIds }
: {}), : {}),
} as Exclude<ClassifiedAction, { type: "ask_clarification" }>, } as Exclude<ClassifiedAction, { type: "ask_clarification" }>,
ctx, ctx,
@ -569,6 +617,28 @@ export class PlannerBot extends TeamsActivityHandler {
await this.memoryAccessor.set(context, undefined); await this.memoryAccessor.set(context, undefined);
await this.rawTurnsAccessor.set(context, undefined); await this.rawTurnsAccessor.set(context, undefined);
} }
/** Resolve a user id to a displayName. Lookups in priority order:
* 1) plan's group member list (already in context, free)
* 2) instance-level nameCache (free after first miss)
* 3) Graph `/users/{id}` (needs delegated `User.ReadBasic.All`)
* 4) friendly short placeholder if all of the above fail */
private async resolveAssigneeName(
planner: PlannerClient,
memberById: Map<string, { displayName: string }>,
userId: string,
): Promise<string> {
const member = memberById.get(userId);
if (member) return member.displayName;
const cached = this.nameCache.get(userId);
if (cached) return cached;
const looked = await planner.resolveUserDisplayName(userId);
if (looked) {
this.nameCache.set(userId, looked);
return looked;
}
return `사용자 ${userId.slice(0, 8)}`;
}
} }
// -------- Helpers ----------------------------------------------------------- // -------- Helpers -----------------------------------------------------------

View File

@ -19,6 +19,11 @@ export interface ActionContext {
// Assignment diff (update_task) // Assignment diff (update_task)
currentAssigneeNames?: string[]; currentAssigneeNames?: string[];
newAssigneeNames?: 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) // Update-only "before" values (for diff display)
currentProgressLabel?: string; 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. // 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) { if (ctx.droppedInferredLabels && ctx.droppedInferredLabels.length) {
lines.push({ lines.push({
text: ` 추론 라벨 중 이 Plan에 없어 무시됨: ${ctx.droppedInferredLabels.join(", ")}`, text: ` 추론 라벨 중 이 Plan에 없어 무시됨: ${ctx.droppedInferredLabels.join(", ")}`,

View File

@ -63,6 +63,25 @@ export class PlannerClient {
return enriched; return enriched;
} }
/** Best-effort displayName lookup for a single AAD user id. Used when an
* assignee on a task isn't (or no longer is) in their plan's group member
* list guests, alumni, cross-tenant collaborators, etc. Requires the
* delegated `User.ReadBasic.All` Graph scope; if missing or the lookup
* fails we return undefined so the caller can show a friendly placeholder. */
async resolveUserDisplayName(userId: string): Promise<string | undefined> {
try {
const u = await this.graph
.api(`/users/${encodeURIComponent(userId)}`)
.select(["id", "displayName"])
.get();
return typeof u?.displayName === "string" && u.displayName.length > 0
? u.displayName
: undefined;
} catch {
return undefined;
}
}
/** Resolve owning M365 group's members. Falls back gracefully if owner missing. */ /** Resolve owning M365 group's members. Falls back gracefully if owner missing. */
private async fetchPlanMembers( private async fetchPlanMembers(
planId: string, planId: string,

View File

@ -11,6 +11,7 @@ export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업
- ( ) . , , . - ( ) . , , .
- create_task planId/bucketId, update_task taskId, assigneeUserIds ** ID **. // . - create_task planId/bucketId, update_task taskId, assigneeUserIds ** ID **. // .
- ID . ask_clarification . - ID . ask_clarification .
- ** (question, description, appendNote, newTitle, title, memoryUpdate.setTopic/setNotes/addOpenLoop ) GUID userId/planId/bucketId/taskId . displayName , plan/bucket/task .** GUID planId/bucketId/taskId/newBucketId/assigneeUserIds ID .
- (: "그거 했어", "다 끝났어") ask_clarification . . - (: "그거 했어", "다 끝났어") ask_clarification . .
## / (progress, percentComplete) ## / (progress, percentComplete)
@ -35,11 +36,12 @@ export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업
- checklistItems, addChecklistItems . - checklistItems, addChecklistItems .
- 20. 20. - 20. 20.
## (assigneeUserIds) ## (assigneeUserIds) ,
- plan members displayName , userId . - ** (members isMe=true) . userId .**
- "나/내가/제가" members isMe=true userId. - ** create_task isMe=true userId assigneeUserIds .** ( ·undefined ).
- members ask_clarification. - update_task (: "이거 나한테 줘", "내가 할게") assigneeUserIds userId . assigneeUserIds .
- update_task assigneeUserIds . "X 도 추가" assigneeIds + . - userId . ask_clarification preview "본인으로 자동 할당" .
- members userId assigneeUserIds .
## (inferredLabels vs explicitLabels) ## (inferredLabels vs explicitLabels)
- **inferredLabels**: plan . . plan . - **inferredLabels**: plan . . plan .