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 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,22 @@ export class PlannerBot extends TeamsActivityHandler {
|
|||||||
...appliedExplicitNames,
|
...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 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 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 -------------------------
|
// --- Build ActionContext for preview / result -------------------------
|
||||||
const ctx: ActionContext = {
|
const ctx: ActionContext = {
|
||||||
@ -342,10 +359,9 @@ 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 (action.assigneeUserIds) {
|
||||||
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) {
|
||||||
@ -569,6 +585,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 -----------------------------------------------------------
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user