From c093480e2bf1d43e798fa0994fe11c2ece01d8b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Sat, 16 May 2026 15:42:25 +0900 Subject: [PATCH] fix: resolve assignee userIds to displayName via Graph fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/bot/PlannerBot.ts | 50 +++++++++++++++++++++++++++++++++----- src/graph/plannerClient.ts | 19 +++++++++++++++ src/llm/prompt.ts | 1 + 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index 6052180..c9883f1 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -91,6 +91,11 @@ export class PlannerBot extends TeamsActivityHandler { private readonly memoryAccessor: StatePropertyAccessor; private readonly rawTurnsAccessor: StatePropertyAccessor; 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(); constructor(deps: PlannerBotDeps) { super(); @@ -311,10 +316,22 @@ export class PlannerBot extends TeamsActivityHandler { ...appliedExplicitNames, ]); - // --- Assignee name resolution + sanity-filter to plan members -------- + // --- 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. const memberById = new Map(plan.members.map((m) => [m.userId, m])); - const validAssigneeIds = (action.assigneeUserIds ?? []).filter((id) => memberById.has(id)); - const newAssigneeNames = validAssigneeIds.map((id) => memberById.get(id)!.displayName); + 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)); // --- Build ActionContext for preview / result ------------------------- const ctx: ActionContext = { @@ -342,10 +359,9 @@ export class PlannerBot extends TeamsActivityHandler { ctx.currentPercent = task.percentComplete; ctx.currentPriority = task.priority; if (action.assigneeUserIds) { - const currentNames = (task.assigneeIds ?? []).map( - (id) => memberById.get(id)?.displayName ?? id, + ctx.currentAssigneeNames = await Promise.all( + (task.assigneeIds ?? []).map(resolveName), ); - ctx.currentAssigneeNames = currentNames; ctx.newAssigneeNames = newAssigneeNames; } if (action.addChecklistItems && action.addChecklistItems.length) { @@ -569,6 +585,28 @@ export class PlannerBot extends TeamsActivityHandler { await this.memoryAccessor.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, + userId: string, + ): Promise { + 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 ----------------------------------------------------------- diff --git a/src/graph/plannerClient.ts b/src/graph/plannerClient.ts index 1158ec1..1aa1a17 100644 --- a/src/graph/plannerClient.ts +++ b/src/graph/plannerClient.ts @@ -63,6 +63,25 @@ export class PlannerClient { 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 { + 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. */ private async fetchPlanMembers( planId: string, diff --git a/src/llm/prompt.ts b/src/llm/prompt.ts index 73ea104..42f8e6d 100644 --- a/src/llm/prompt.ts +++ b/src/llm/prompt.ts @@ -11,6 +11,7 @@ export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 - 반드시 도구 호출(함수 호출)로만 응답하세요. 부가 설명, 코멘트, 마크다운은 출력하지 마세요. - create_task 의 planId/bucketId, update_task 의 taskId, assigneeUserIds 의 각 항목 — **모든 ID 값은 컨텍스트에 주어진 문자열을 한 글자도 빼거나 더하지 말고 그대로 복사**하세요. 콤마/공백/따옴표를 덧붙이지 마세요. - 컨텍스트에 없는 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 을 사용하세요. 임의로 가장 최근 작업을 가정하지 마세요. ## 진행/상태 매핑 (progress, percentComplete)