Compare commits
2 Commits
d3271fa1e8
...
2a05de85d2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a05de85d2 | ||
|
|
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,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 -----------------------------------------------------------
|
||||||
|
|||||||
@ -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(", ")}`,
|
||||||
|
|||||||
@ -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)
|
||||||
@ -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 에 없는 이름은 절대 넣지 마세요.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user