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>
This commit is contained in:
parent
d3271fa1e8
commit
c093480e2b
@ -91,6 +91,11 @@ export class PlannerBot extends TeamsActivityHandler {
|
||||
private readonly memoryAccessor: StatePropertyAccessor<WorkingMemory | undefined>;
|
||||
private readonly rawTurnsAccessor: StatePropertyAccessor<RawTurn[] | undefined>;
|
||||
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) {
|
||||
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<string>([
|
||||
...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<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 -----------------------------------------------------------
|
||||
|
||||
@ -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<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. */
|
||||
private async fetchPlanMembers(
|
||||
planId: string,
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user