From 01616c4526834082be4840c7ab1da521de2611df 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:10:33 +0900 Subject: [PATCH] feat: full Planner field coverage (priority/start/checklist/assignees/labels/bucket move) Confirmation preview enumerates every side-effect (plan-level label creation, assignee diff, bucket move, checklist truncation) so nothing happens that wasn't shown on the card. Explicit-but-missing labels trigger a 3-button choice (register / drop / cancel) since creating them mutates the Plan's categoryDescriptions. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bot/PlannerBot.ts | 301 ++++++++++++++++++++++++++----- src/cards/confirmationCard.ts | 275 ++++++++++++++++++++++++---- src/graph/plannerClient.ts | 326 ++++++++++++++++++++++++++++++---- src/llm/coerce.ts | 27 ++- src/llm/geminiClassifier.ts | 31 ++++ src/llm/prompt.ts | 137 +++++++++++--- src/llm/types.ts | 43 ++++- 7 files changed, 1003 insertions(+), 137 deletions(-) diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index 115ed53..9ade770 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -10,8 +10,13 @@ import { UserTokenClient } from "botframework-connector"; import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs"; import { createGraphClient } from "../graph/graphClientFactory"; import { PlannerClient } from "../graph/plannerClient"; -import { ClassifiedAction, LlmClassifier } from "../llm/types"; -import { buildClarificationCard, buildPreviewCard, buildResultCard } from "../cards/confirmationCard"; +import { ClassifiedAction, LlmClassifier, PlanContext, RecentTaskContext } from "../llm/types"; +import { + ActionContext, + buildClarificationCard, + buildPreviewCard, + buildResultCard, +} from "../cards/confirmationCard"; async function signOutUser(context: TurnContext, connectionName: string): Promise { const adapter = context.adapter as CloudAdapter; @@ -31,28 +36,31 @@ async function signOutUser(context: TurnContext, connectionName: string): Promis const OAUTH_PROMPT = "graphOAuthPrompt"; const MAIN_DIALOG = "mainDialog"; -interface ActionContextSnapshot { - planTitle?: string; - bucketTitle?: string; - taskTitle?: string; -} - interface PendingAction { action: Exclude; - ctx: ActionContextSnapshot; + ctx: ActionContext; + /** Pre-resolved execution data so handleConfirm doesn't need to re-fetch context. */ + exec: { + planId: string; + /** Slots that already exist on the plan (= inferred + explicit-existing). */ + existingSlots: number[]; + /** Explicit label names the user mentioned that don't yet exist on the plan. */ + missingExplicitLabels: string[]; + currentAssigneeIds?: string[]; + }; createdAt: number; } type MainDialogOpts = | { kind: "utterance"; text: string } - | { kind: "confirm" } + | { kind: "confirm"; createLabels?: boolean } | { kind: "cancel" }; export interface PlannerBotDeps { conversationState: ConversationState; userState: UserState; classifier: LlmClassifier; - connectionName: string; // Azure Bot OAuth connection name + connectionName: string; } export class PlannerBot extends TeamsActivityHandler { @@ -72,7 +80,9 @@ export class PlannerBot extends TeamsActivityHandler { this.connectionName = deps.connectionName; this.dialogStateAccessor = this.conversationState.createProperty("DialogState"); - this.pendingAccessor = this.conversationState.createProperty("PendingAction"); + this.pendingAccessor = this.conversationState.createProperty( + "PendingAction", + ); this.dialogs = new DialogSet(this.dialogStateAccessor); this.dialogs.add( @@ -97,7 +107,7 @@ export class PlannerBot extends TeamsActivityHandler { if (opts.kind === "utterance") { await this.handleUtterance(step.context, tokenResponse.token, opts.text); } else if (opts.kind === "confirm") { - await this.handleConfirm(step.context, tokenResponse.token); + await this.handleConfirm(step.context, tokenResponse.token, opts.createLabels === true); } else { await this.handleCancel(step.context); } @@ -107,15 +117,20 @@ export class PlannerBot extends TeamsActivityHandler { ); this.onMessage(async (context, next) => { - const value = context.activity.value as { kind?: string } | undefined; + const value = context.activity.value as + | { kind?: string; createLabels?: boolean } + | undefined; const text = (context.activity.text ?? "").trim(); - // Adaptive Card button click → dispatch by `kind` if (value && (value.kind === "confirm" || value.kind === "cancel")) { const dc = await this.dialogs.createContext(context); const r = await dc.continueDialog(); if (r.status === DialogTurnStatus.empty) { - await dc.beginDialog(MAIN_DIALOG, { kind: value.kind } as MainDialogOpts); + const opts: MainDialogOpts = + value.kind === "confirm" + ? { kind: "confirm", createLabels: value.createLabels === true } + : { kind: "cancel" }; + await dc.beginDialog(MAIN_DIALOG, opts); } await next(); return; @@ -142,7 +157,6 @@ export class PlannerBot extends TeamsActivityHandler { await next(); }); - // Token-response event (legacy bot-framework token delivery path) this.onTokenResponseEvent(async (context, next) => { const dialogContext = await this.dialogs.createContext(context); await dialogContext.continueDialog(); @@ -152,8 +166,8 @@ export class PlannerBot extends TeamsActivityHandler { this.onMembersAdded(async (context, next) => { const greeting = "안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" + - "예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”\n" + - "작업을 실제로 반영하기 전에 항상 확인 카드를 보여드려요."; + "예) “내일까지 견적서 초안 작성, 긴급으로 민수에게 할당, 체크리스트 시안A·시안B”\n" + + "작업을 실제로 반영하기 전에 항상 확인 카드로 모든 변경 사항을 보여드려요."; for (const m of context.activity.membersAdded ?? []) { if (m.id !== context.activity.recipient.id) { await context.sendActivity(greeting); @@ -169,7 +183,6 @@ export class PlannerBot extends TeamsActivityHandler { await this.userState.saveChanges(context, false); } - // Teams sign-in invoke activities — route back into the active OAuth dialog. protected async handleTeamsSigninVerifyState(context: TurnContext): Promise { const dialogContext = await this.dialogs.createContext(context); await dialogContext.continueDialog(); @@ -180,18 +193,18 @@ export class PlannerBot extends TeamsActivityHandler { await dialogContext.continueDialog(); } - /** Classify the utterance and present a Confirm/Cancel preview card. Nothing - * is written to Planner here — the user has to click ✅ on the card. */ + // -------- Utterance → classify → preview --------------------------------- + private async handleUtterance( context: TurnContext, token: string, utterance: string, ): Promise { await context.sendActivity({ type: "typing" }); - const planner = new PlannerClient(createGraphClient(token)); - let plans, recentTasks; + let plans: PlanContext[]; + let recentTasks: RecentTaskContext[]; try { [plans, recentTasks] = await Promise.all([ planner.listPlansWithBuckets(), @@ -205,7 +218,9 @@ export class PlannerBot extends TeamsActivityHandler { } if (plans.length === 0) { - await context.sendActivity("접근 가능한 Planner Plan이 없어요. Teams 채널에 Plan을 먼저 추가해 주세요."); + await context.sendActivity( + "접근 가능한 Planner Plan이 없어요. Teams 채널에 Plan을 먼저 추가해 주세요.", + ); return; } @@ -225,25 +240,117 @@ export class PlannerBot extends TeamsActivityHandler { return; } - // Build a human-readable context snapshot so the preview/result cards can - // show plan/bucket/task names instead of opaque IDs. - const ctx: ActionContextSnapshot = {}; + // Resolve the plan the action targets. + let plan: PlanContext | undefined; + let task: RecentTaskContext | undefined; if (action.type === "create_task") { - const plan = plans.find((p) => p.planId === action.planId); - ctx.planTitle = plan?.planTitle; - ctx.bucketTitle = plan?.buckets.find((b) => b.bucketId === action.bucketId)?.bucketTitle; + plan = plans.find((p) => p.planId === action.planId); } else { - const task = recentTasks.find((t) => t.taskId === action.taskId); - ctx.taskTitle = task?.title; + task = recentTasks.find((t) => t.taskId === action.taskId); + if (!task) { + await this.pendingAccessor.set(context, undefined); + await context.sendActivity({ + attachments: [ + buildClarificationCard( + "어떤 기존 작업을 가리키는지 찾지 못했어요. 작업 이름을 더 구체적으로 알려주세요.", + ), + ], + }); + return; + } + plan = plans.find((p) => p.planId === task!.planId); + } + if (!plan) { + await context.sendActivity( + "대상 Plan을 찾을 수 없었어요. 다시 한 번 말씀해 주시겠어요?", + ); + return; } - await this.pendingAccessor.set(context, { action, ctx, createdAt: Date.now() }); + // --- Label resolution ------------------------------------------------- + const { existingSlots: inferredSlots, applied: appliedInferredNames, dropped } = + resolveLabelsAgainstPlan(action.inferredLabels ?? [], plan); + const { existingSlots: explicitSlots, applied: appliedExplicitNames, missing } = + resolveLabelsAgainstPlan(action.explicitLabels ?? [], plan); - await context.sendActivity({ attachments: [buildPreviewCard({ action, ctx })] }); + const appliedLabelNames = dedupePreserveOrder([ + ...appliedInferredNames, + ...appliedExplicitNames, + ]); + + // --- Assignee name resolution + sanity-filter to plan members -------- + 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); + + // --- Build ActionContext for preview / result ------------------------- + const ctx: ActionContext = { + planTitle: plan.planTitle, + appliedLabelNames: appliedLabelNames.length ? appliedLabelNames : undefined, + droppedInferredLabels: dropped.length ? dropped : undefined, + missingExplicitLabels: missing.length ? missing : undefined, + }; + + if (action.type === "create_task") { + const bucket = plan.buckets.find((b) => b.bucketId === action.bucketId); + ctx.bucketTitle = bucket?.bucketTitle; + if (newAssigneeNames.length) ctx.newAssigneeNames = newAssigneeNames; + if (action.checklistItems && action.checklistItems.length) { + ctx.checklistItemsToAdd = action.checklistItems.slice(0, 20); + const overflow = action.checklistItems.length - 20; + if (overflow > 0) ctx.checklistOverflow = overflow; + } + } else if (task) { + ctx.taskTitle = task.title; + if (action.newBucketId) { + ctx.bucketTitleFrom = plan.buckets.find((b) => b.bucketId === task!.bucketId)?.bucketTitle; + ctx.bucketTitleTo = plan.buckets.find((b) => b.bucketId === action.newBucketId)?.bucketTitle; + } + ctx.currentPercent = task.percentComplete; + ctx.currentPriority = task.priority; + if (action.assigneeUserIds) { + const currentNames = (task.assigneeIds ?? []).map( + (id) => memberById.get(id)?.displayName ?? id, + ); + ctx.currentAssigneeNames = currentNames; + ctx.newAssigneeNames = newAssigneeNames; + } + if (action.addChecklistItems && action.addChecklistItems.length) { + ctx.checklistItemsToAdd = action.addChecklistItems; + } + } + + const pending: PendingAction = { + action: { + ...action, + ...(action.type === "create_task" ? { assigneeUserIds: validAssigneeIds } : {}), + ...(action.type === "update_task" && action.assigneeUserIds + ? { assigneeUserIds: validAssigneeIds } + : {}), + } as Exclude, + ctx, + exec: { + planId: plan.planId, + existingSlots: dedupePreserveOrder([...inferredSlots, ...explicitSlots]), + missingExplicitLabels: missing, + currentAssigneeIds: task?.assigneeIds, + }, + createdAt: Date.now(), + }; + + await this.pendingAccessor.set(context, pending); + await context.sendActivity({ + attachments: [buildPreviewCard({ action: pending.action, ctx })], + }); } - /** User clicked ✅ on a preview card — execute the stored action. */ - private async handleConfirm(context: TurnContext, token: string): Promise { + // -------- Confirm: actually apply ---------------------------------------- + + private async handleConfirm( + context: TurnContext, + token: string, + createLabels: boolean, + ): Promise { const pending = await this.pendingAccessor.get(context); if (!pending) { await context.sendActivity("확정할 작업이 없어요. 다시 말씀해 주시겠어요?"); @@ -252,19 +359,90 @@ export class PlannerBot extends TeamsActivityHandler { await context.sendActivity({ type: "typing" }); const planner = new PlannerClient(createGraphClient(token)); - const { action, ctx } = pending; + const { action, exec } = pending; + const ctx: ActionContext = { ...pending.ctx }; + + // --- (1) Optional: register missing labels first ----------------------- + const newlyCreatedLabels: string[] = []; + const failedLabelCreations: { name: string; reason: string }[] = []; + const finalSlots = [...exec.existingSlots]; + + if (createLabels && exec.missingExplicitLabels.length > 0) { + for (const name of exec.missingExplicitLabels) { + try { + const slot = await planner.ensurePlanLabel(exec.planId, name); + finalSlots.push(slot); + newlyCreatedLabels.push(name); + } catch (err) { + failedLabelCreations.push({ name, reason: (err as Error).message }); + } + } + } + if (newlyCreatedLabels.length) { + ctx.newlyCreatedLabels = newlyCreatedLabels; + ctx.appliedLabelNames = dedupePreserveOrder([ + ...(ctx.appliedLabelNames ?? []), + ...newlyCreatedLabels, + ]); + } + if (failedLabelCreations.length) { + ctx.failedLabelCreations = failedLabelCreations; + } + // Surfaced in result card: any explicit labels that ended up not applied + // (user declined, or creation failed). + const stillMissing = createLabels + ? failedLabelCreations.map((f) => f.name) + : exec.missingExplicitLabels; + ctx.missingExplicitLabels = stillMissing.length ? stillMissing : undefined; + + const dedupedSlots = dedupePreserveOrder(finalSlots); try { if (action.type === "create_task") { - await planner.createTask(action); + const { checklistOverflow } = await planner.createTask({ + planId: action.planId, + bucketId: action.bucketId, + title: action.title, + progress: action.progress, + priority: action.priority, + startDate: action.startDate, + dueDate: action.dueDate, + description: action.description, + assigneeUserIds: action.assigneeUserIds, + categorySlots: dedupedSlots.length ? dedupedSlots : undefined, + checklistItems: action.checklistItems, + }); + if (checklistOverflow > 0) ctx.checklistOverflow = checklistOverflow; } else { - await planner.updateTask(action); + await planner.updateTask({ + taskId: action.taskId, + newBucketId: action.newBucketId, + progress: action.progress, + percentComplete: action.percentComplete, + priority: action.priority, + startDate: action.startDate, + dueDate: action.dueDate, + newTitle: action.newTitle, + assigneeUserIds: action.assigneeUserIds, + currentAssigneeIds: exec.currentAssigneeIds, + categorySlots: dedupedSlots.length ? dedupedSlots : undefined, + }); if (action.appendNote) { await planner.appendTaskNote(action.taskId, action.appendNote); } + if (action.addChecklistItems && action.addChecklistItems.length) { + const { overflow } = await planner.appendChecklist( + action.taskId, + action.addChecklistItems, + ); + if (overflow > 0) ctx.checklistOverflow = overflow; + } } + await this.pendingAccessor.set(context, undefined); - await context.sendActivity({ attachments: [buildResultCard({ action, ctx, status: "done" })] }); + await context.sendActivity({ + attachments: [buildResultCard({ action, ctx, status: "done" })], + }); } catch (err) { await context.sendActivity({ attachments: [ @@ -274,7 +452,6 @@ export class PlannerBot extends TeamsActivityHandler { } } - /** User clicked ❌ on a preview card. */ private async handleCancel(context: TurnContext): Promise { const pending = await this.pendingAccessor.get(context); if (!pending) { @@ -283,7 +460,45 @@ export class PlannerBot extends TeamsActivityHandler { } await this.pendingAccessor.set(context, undefined); await context.sendActivity({ - attachments: [buildResultCard({ action: pending.action, ctx: pending.ctx, status: "canceled" })], + attachments: [ + buildResultCard({ action: pending.action, ctx: pending.ctx, status: "canceled" }), + ], }); } } + +// -------- Helpers ----------------------------------------------------------- + +function resolveLabelsAgainstPlan( + names: string[], + plan: PlanContext, +): { existingSlots: number[]; applied: string[]; dropped: string[]; missing: string[] } { + const existingSlots: number[] = []; + const applied: string[] = []; + const dropped: string[] = []; + const missing: string[] = []; + for (const name of names) { + const norm = name.trim().toLowerCase(); + const hit = plan.labels.find((l) => l.name.trim().toLowerCase() === norm); + if (hit) { + existingSlots.push(hit.slot); + applied.push(hit.name); + } else { + dropped.push(name); + missing.push(name); + } + } + return { existingSlots, applied, dropped, missing }; +} + +function dedupePreserveOrder(arr: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const x of arr) { + if (!seen.has(x)) { + seen.add(x); + out.push(x); + } + } + return out; +} diff --git a/src/cards/confirmationCard.ts b/src/cards/confirmationCard.ts index 93d573c..426f312 100644 --- a/src/cards/confirmationCard.ts +++ b/src/cards/confirmationCard.ts @@ -1,32 +1,49 @@ import { CardFactory, Attachment } from "botbuilder"; -import { ClassifiedAction } from "../llm/types"; +import { ClassifiedAction, Priority } from "../llm/types"; -interface ActionContext { +/** + * Everything the preview / result cards need to render. The bot fills this in + * after classifying the utterance — keeping the card pure means the renderer + * can be tested in isolation and the card itself never has to look up Plan/ + * task names. + */ +export interface ActionContext { planTitle?: string; - bucketTitle?: string; taskTitle?: string; + + // Bucket: create shows a single bucket; update may move buckets. + bucketTitle?: string; // for create_task display + bucketTitleFrom?: string; + bucketTitleTo?: string; + + // Assignment diff (update_task) + currentAssigneeNames?: string[]; + newAssigneeNames?: string[]; + + // Update-only "before" values (for diff display) + currentProgressLabel?: string; + currentPercent?: number; + currentPriority?: Priority; + currentDueDate?: string; + + // Labels + appliedLabelNames?: string[]; + droppedInferredLabels?: string[]; + missingExplicitLabels?: string[]; + newlyCreatedLabels?: string[]; + failedLabelCreations?: { name: string; reason: string }[]; + + // Checklist + checklistItemsToAdd?: string[]; + checklistOverflow?: number; } -function actionDetailLines(a: ClassifiedAction, ctx: ActionContext): string[] { - const lines: string[] = []; - if (a.type === "create_task") { - lines.push(`Plan: ${ctx.planTitle ?? a.planId}`); - lines.push(`Bucket: ${ctx.bucketTitle ?? a.bucketId}`); - lines.push(`제목: ${a.title}`); - if (a.dueDate) lines.push(`마감: ${a.dueDate}`); - if (a.progress) lines.push(`진행: ${progressLabel(a.progress)}`); - } else if (a.type === "update_task") { - lines.push(`대상: ${ctx.taskTitle ?? a.taskId}`); - if (a.newTitle) lines.push(`새 제목: ${a.newTitle}`); - if (a.progress) lines.push(`진행: ${progressLabel(a.progress)}`); - if (a.percentComplete !== undefined) lines.push(`진행률: ${a.percentComplete}%`); - if (a.appendNote) lines.push(`메모 추가: ${a.appendNote}`); - if (a.dueDate) lines.push(`마감: ${a.dueDate}`); - } else if (a.type === "ask_clarification") { - lines.push(a.question); - } - return lines; -} +const PRIORITY_LABEL: Record = { + urgent: "긴급", + important: "중요", + medium: "보통", + low: "낮음", +}; function progressLabel(p: string): string { switch (p) { @@ -37,7 +54,129 @@ function progressLabel(p: string): string { } } -/** Preview card with Confirm / Cancel buttons. Used before any Planner write. */ +/** + * Build the full list of "what will happen / what happened" lines used by both + * the preview card and the result card. Every side-effect must appear here so + * the user can verify before confirming. + */ +function actionDetailLines(a: ClassifiedAction, ctx: ActionContext): { text: string; emphasis?: "warning" | "attention" }[] { + const lines: { text: string; emphasis?: "warning" | "attention" }[] = []; + + if (a.type === "create_task") { + lines.push({ text: `📋 Plan: ${ctx.planTitle ?? "(알 수 없음)"}` }); + lines.push({ text: `🪣 Bucket: ${ctx.bucketTitle ?? "(알 수 없음)"}` }); + lines.push({ text: `📝 제목: ${a.title}` }); + if (a.progress) { + lines.push({ text: `📊 진행: ${progressLabel(a.progress)}` }); + } + if (a.priority) lines.push({ text: `⚡ 우선순위: ${PRIORITY_LABEL[a.priority]}` }); + if (a.startDate) lines.push({ text: `🟢 시작: ${a.startDate}` }); + if (a.dueDate) lines.push({ text: `🎯 마감: ${a.dueDate}` }); + if (a.description) lines.push({ text: `🗒️ 메모: ${a.description}` }); + + if (ctx.newAssigneeNames && ctx.newAssigneeNames.length) { + lines.push({ text: `👥 할당: ${ctx.newAssigneeNames.join(", ")}` }); + } + if (ctx.appliedLabelNames && ctx.appliedLabelNames.length) { + lines.push({ text: `🏷️ 라벨: ${ctx.appliedLabelNames.join(", ")}` }); + } + if (ctx.checklistItemsToAdd && ctx.checklistItemsToAdd.length) { + lines.push({ + text: `☐ 체크리스트(${ctx.checklistItemsToAdd.length}): ${ctx.checklistItemsToAdd.join(" / ")}`, + }); + } + } else if (a.type === "update_task") { + lines.push({ text: `🎯 대상: ${ctx.taskTitle ?? a.taskId}` }); + + if (a.newTitle) lines.push({ text: `📝 새 제목: ${a.newTitle}` }); + + // Bucket move diff + if (a.newBucketId) { + lines.push({ + text: `🪣 버킷 이동: ${ctx.bucketTitleFrom ?? "?"} → ${ctx.bucketTitleTo ?? a.newBucketId}`, + }); + } + + // Progress diff + if (a.progress || a.percentComplete !== undefined) { + const newPct = a.percentComplete ?? progressDefaultPct(a.progress); + const oldStr = + ctx.currentPercent !== undefined ? `${ctx.currentPercent}%` : "?"; + const newStr = newPct !== undefined ? `${newPct}%` : "?"; + lines.push({ text: `📊 진행률: ${oldStr} → ${newStr}` }); + } + + if (a.priority) { + const old = ctx.currentPriority ? PRIORITY_LABEL[ctx.currentPriority] : "?"; + lines.push({ text: `⚡ 우선순위: ${old} → ${PRIORITY_LABEL[a.priority]}` }); + } + + if (a.startDate) lines.push({ text: `🟢 시작: → ${a.startDate}` }); + if (a.dueDate) { + const old = ctx.currentDueDate ?? "?"; + lines.push({ text: `🎯 마감: ${old} → ${a.dueDate}` }); + } + + if (a.appendNote) { + lines.push({ + text: `📝 메모 추가 (기존 노트 끝에 timestamp+텍스트로 append): ${a.appendNote}`, + }); + } + + // Assignment diff + if (ctx.newAssigneeNames) { + const before = (ctx.currentAssigneeNames ?? []).join(", ") || "(없음)"; + const after = ctx.newAssigneeNames.join(", ") || "(없음)"; + lines.push({ text: `👥 할당: [기존] ${before} → [신규] ${after}` }); + } + + if (ctx.appliedLabelNames && ctx.appliedLabelNames.length) { + lines.push({ text: `🏷️ 라벨: ${ctx.appliedLabelNames.join(", ")}` }); + } + + if (a.addChecklistItems && a.addChecklistItems.length) { + const toAdd = ctx.checklistItemsToAdd ?? a.addChecklistItems; + lines.push({ + text: `☐ 체크리스트 추가(${toAdd.length}, 기존 항목은 유지): ${toAdd.join(" / ")}`, + }); + } + } else if (a.type === "ask_clarification") { + lines.push({ text: a.question }); + } + + // Side-effect / advisory lines — apply to both create_task & update_task. + if (ctx.droppedInferredLabels && ctx.droppedInferredLabels.length) { + lines.push({ + text: `ℹ️ 추론 라벨 중 이 Plan에 없어 무시됨: ${ctx.droppedInferredLabels.join(", ")}`, + emphasis: "warning", + }); + } + if (ctx.missingExplicitLabels && ctx.missingExplicitLabels.length) { + lines.push({ + text: `🆕 이 Plan에 없는 라벨이 발화에 있어요: ${ctx.missingExplicitLabels.join(", ")} — '확인 (라벨 새로 등록)' 을 누르면 **Plan 설정** 의 categoryDescriptions 에 새 라벨이 등록됩니다.`, + emphasis: "attention", + }); + } + if (ctx.checklistOverflow && ctx.checklistOverflow > 0) { + lines.push({ + text: `⚠️ 체크리스트는 항목당 최대 20개입니다. ${ctx.checklistOverflow}개는 잘립니다.`, + emphasis: "warning", + }); + } + + return lines; +} + +function progressDefaultPct(p?: string): number | undefined { + if (p === "notStarted") return 0; + if (p === "inProgress") return 50; + if (p === "completed") return 100; + return undefined; +} + +/** Preview card with Confirm / Cancel buttons. Used before any Planner write. + * If there are missing explicit labels, surfaces a 3-way decision so the user + * can choose whether to mutate the Plan's label set. */ export function buildPreviewCard(args: { action: Exclude; ctx: ActionContext; @@ -45,7 +184,36 @@ export function buildPreviewCard(args: { const a = args.action; const heading = a.type === "create_task" ? "📋 새 작업을 만들까요?" : "🔄 이 작업을 업데이트할까요?"; - const details = actionDetailLines(a, args.ctx); + const detailBlocks = actionDetailLines(a, args.ctx).map((d) => ({ + type: "TextBlock", + text: d.text, + wrap: true, + spacing: "Small", + ...(d.emphasis === "attention" ? { color: "Attention", weight: "Bolder" } : {}), + ...(d.emphasis === "warning" ? { color: "Warning" } : {}), + })); + + const hasMissingLabels = + (args.ctx.missingExplicitLabels?.length ?? 0) > 0; + + const actions = hasMissingLabels + ? [ + { + type: "Action.Submit", + title: "✅ 확인 (라벨 새로 등록)", + data: { kind: "confirm", createLabels: true }, + }, + { + type: "Action.Submit", + title: "🏷️ 라벨 빼고 진행", + data: { kind: "confirm", createLabels: false }, + }, + { type: "Action.Submit", title: "❌ 취소", data: { kind: "cancel" } }, + ] + : [ + { type: "Action.Submit", title: "✅ 확인", data: { kind: "confirm" } }, + { type: "Action.Submit", title: "❌ 취소", data: { kind: "cancel" } }, + ]; return CardFactory.adaptiveCard({ type: "AdaptiveCard", @@ -53,39 +221,65 @@ export function buildPreviewCard(args: { version: "1.4", body: [ { type: "TextBlock", text: heading, weight: "Bolder", size: "Medium", wrap: true }, - ...details.map((t) => ({ type: "TextBlock", text: t, wrap: true, spacing: "Small" })), - { type: "TextBlock", text: "아래 버튼으로 결정해 주세요.", isSubtle: true, wrap: true, spacing: "Medium" }, - ], - actions: [ - { type: "Action.Submit", title: "✅ 확인", data: { kind: "confirm" } }, - { type: "Action.Submit", title: "❌ 취소", data: { kind: "cancel" } }, + ...detailBlocks, + { + type: "TextBlock", + text: "위 변경 사항이 ✅ 확인 시 즉시 적용됩니다.", + isSubtle: true, + wrap: true, + spacing: "Medium", + }, ], + actions, }); } -/** Result card after a Planner write succeeds/fails. */ +/** Result card after a Planner write succeeds/fails. Replays every effect + * that was applied — including plan-level mutations like new labels. */ export function buildResultCard(args: { action: ClassifiedAction; ctx?: ActionContext; status: "done" | "error" | "canceled"; errorMessage?: string; }): Attachment { + const ctx = args.ctx ?? {}; let heading: string; - let details: string[] = []; + const lines: { text: string; emphasis?: "warning" | "attention" }[] = []; if (args.status === "error") { heading = `❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`; + if (args.action.type !== "ask_clarification") { + lines.push(...actionDetailLines(args.action, ctx)); + } } else if (args.status === "canceled") { heading = "🚫 취소했습니다."; + if (args.action.type !== "ask_clarification") { + lines.push(...actionDetailLines(args.action, ctx)); + } } else if (args.action.type === "create_task") { heading = "✅ 새 작업을 만들었어요"; - details = actionDetailLines(args.action, args.ctx ?? {}); + lines.push(...actionDetailLines(args.action, ctx)); } else if (args.action.type === "update_task") { heading = "✅ 작업을 업데이트했어요"; - details = actionDetailLines(args.action, args.ctx ?? {}); + lines.push(...actionDetailLines(args.action, ctx)); } else { heading = "❓ 한 번 더 확인이 필요해요"; - details = actionDetailLines(args.action, args.ctx ?? {}); + lines.push({ text: args.action.question }); + } + + if (ctx.newlyCreatedLabels && ctx.newlyCreatedLabels.length) { + lines.push({ + text: `🆕 이 Plan에 새 라벨 등록 완료: ${ctx.newlyCreatedLabels.join(", ")}`, + emphasis: "attention", + }); + } + if (ctx.failedLabelCreations && ctx.failedLabelCreations.length) { + for (const f of ctx.failedLabelCreations) { + lines.push({ + text: `❌ 라벨 '${f.name}' 등록 실패: ${f.reason}`, + emphasis: "warning", + }); + } } return CardFactory.adaptiveCard({ @@ -94,7 +288,14 @@ export function buildResultCard(args: { version: "1.4", body: [ { type: "TextBlock", text: heading, weight: "Bolder", size: "Medium", wrap: true }, - ...details.map((t) => ({ type: "TextBlock", text: t, wrap: true, spacing: "Small" })), + ...lines.map((d) => ({ + type: "TextBlock", + text: d.text, + wrap: true, + spacing: "Small", + ...(d.emphasis === "attention" ? { color: "Attention", weight: "Bolder" } : {}), + ...(d.emphasis === "warning" ? { color: "Warning" } : {}), + })), ], }); } diff --git a/src/graph/plannerClient.ts b/src/graph/plannerClient.ts index 2a629a9..1158ec1 100644 --- a/src/graph/plannerClient.ts +++ b/src/graph/plannerClient.ts @@ -1,5 +1,6 @@ +import { randomUUID } from "node:crypto"; import { Client } from "@microsoft/microsoft-graph-client"; -import { PlanContext, Progress, RecentTaskContext } from "../llm/types"; +import { PlanContext, Priority, Progress, RecentTaskContext } from "../llm/types"; /** * Thin wrapper around the Graph Planner endpoints we actually use. @@ -11,53 +12,115 @@ import { PlanContext, Progress, RecentTaskContext } from "../llm/types"; export class PlannerClient { constructor(private readonly graph: Client) {} - /** All plans the signed-in user can see, with their buckets pre-fetched. + /** All plans the signed-in user can see, with buckets/members/labels pre-fetched. * Plans whose title contains "deprecated" (case-insensitive) are filtered out * so old/archived plans don't pollute the LLM context. */ async listPlansWithBuckets(): Promise { - // Plans this user can access via the groups they belong to. - // Graph exposes /me/planner/plans which returns plans for groups the user is a member of. - const plansResp = await this.graph.api("/me/planner/plans").get(); - const plans: Array<{ id: string; title: string }> = (plansResp.value ?? []).filter( - (p: { title?: string }) => !/deprecated/i.test(p.title ?? ""), + const [plansResp, me] = await Promise.all([ + this.graph.api("/me/planner/plans").get(), + this.graph.api("/me").select(["id"]).get(), + ]); + + const meId: string = me.id; + const plans: Array<{ id: string; title: string; owner?: string }> = (plansResp.value ?? []) + .filter((p: { title?: string }) => !/deprecated/i.test(p.title ?? "")); + + const enriched = await Promise.all( + plans.map(async (p) => { + // Buckets + plan-details + group-members in parallel. + const [bucketsResp, planDetails, groupMembers] = await Promise.all([ + this.graph.api(`/planner/plans/${p.id}/buckets`).get(), + this.graph + .api(`/planner/plans/${p.id}/details`) + .get() + .catch(() => ({ categoryDescriptions: {} })), + this.fetchPlanMembers(p.id, p.owner), + ]); + + const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? []; + const cd = (planDetails.categoryDescriptions ?? {}) as Record; + const labels = Object.entries(cd) + .map(([key, name]) => ({ key, name })) + .filter((x): x is { key: string; name: string } => typeof x.name === "string" && x.name.length > 0) + .map((x) => ({ slot: slotFromKey(x.key), name: x.name })) + .filter((l) => l.slot > 0); + + return { + planId: p.id, + planTitle: p.title, + buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })), + members: groupMembers.map((m) => ({ + userId: m.id, + displayName: m.displayName, + ...(m.id === meId ? { isMe: true as const } : {}), + })), + labels, + } satisfies PlanContext; + }), ); - const result: PlanContext[] = []; - for (const p of plans) { - const bucketsResp = await this.graph.api(`/planner/plans/${p.id}/buckets`).get(); - const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? []; - result.push({ - planId: p.id, - planTitle: p.title, - buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })), - }); + return enriched; + } + + /** Resolve owning M365 group's members. Falls back gracefully if owner missing. */ + private async fetchPlanMembers( + planId: string, + ownerGroupId?: string, + ): Promise> { + let groupId = ownerGroupId; + if (!groupId) { + const plan = await this.graph + .api(`/planner/plans/${planId}`) + .select(["id", "owner"]) + .get() + .catch(() => undefined); + groupId = plan?.owner; } - return result; + if (!groupId) return []; + + const resp = await this.graph + .api(`/groups/${groupId}/members`) + .select(["id", "displayName"]) + .get() + .catch(() => ({ value: [] })); + + type DirObj = { id?: string; displayName?: string; "@odata.type"?: string }; + const users: Array<{ id: string; displayName: string }> = (resp.value ?? []) + .filter((m: DirObj) => !m["@odata.type"] || m["@odata.type"] === "#microsoft.graph.user") + .filter((m: DirObj): m is { id: string; displayName: string } => + typeof m.id === "string" && typeof m.displayName === "string" && m.displayName.length > 0, + ); + return users; } /** Recent (non-completed) tasks across the user's plans, capped. */ async listRecentTasks(limit = 20): Promise { const resp = await this.graph.api("/me/planner/tasks").get(); - const tasks: Array<{ + type RawTask = { id: string; title: string; planId: string; bucketId: string; percentComplete: number; + priority?: number; + assignments?: Record; createdDateTime: string; - }> = resp.value ?? []; + }; + const tasks: RawTask[] = resp.value ?? []; return tasks .filter((t) => t.percentComplete < 100) .sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1)) .slice(0, limit) - .map((t) => ({ + .map((t) => ({ taskId: t.id, title: t.title, planId: t.planId, bucketId: t.bucketId, percentComplete: t.percentComplete, + priority: priorityFromInt(t.priority), + assigneeIds: t.assignments ? Object.keys(t.assignments) : undefined, })); } @@ -66,51 +129,97 @@ export class PlannerClient { bucketId: string; title: string; progress?: Progress; + priority?: Priority; + startDate?: string; dueDate?: string; description?: string; - }): Promise<{ taskId: string }> { + assigneeUserIds?: string[]; + /** Plan-label slots (1..25) to apply. */ + categorySlots?: number[]; + checklistItems?: string[]; + }): Promise<{ taskId: string; checklistOverflow: number }> { const body: Record = { planId: args.planId, bucketId: args.bucketId, title: args.title, percentComplete: progressToPercent(args.progress) ?? 0, }; - if (args.dueDate) { - body.dueDateTime = toIsoEod(args.dueDate); + if (args.startDate) body.startDateTime = toIsoStartOfDay(args.startDate); + if (args.dueDate) body.dueDateTime = toIsoEod(args.dueDate); + const priorityInt = priorityToInt(args.priority); + if (priorityInt !== undefined) body.priority = priorityInt; + if (args.assigneeUserIds && args.assigneeUserIds.length) { + body.assignments = buildAssignmentsForCreate(args.assigneeUserIds); } + if (args.categorySlots && args.categorySlots.length) { + body.appliedCategories = buildAppliedCategories(args.categorySlots); + } + const created = await this.graph.api("/planner/tasks").post(body); const taskId: string = created.id; - // Optionally write description to the task's details resource. - if (args.description) { + // Combine description + checklist into a single details PATCH. + const checklistItems = (args.checklistItems ?? []).slice(0, 20); + const checklistOverflow = Math.max(0, (args.checklistItems?.length ?? 0) - 20); + if (args.description || checklistItems.length > 0) { + const detailsPatch: Record = {}; + if (args.description) detailsPatch.description = args.description; + if (checklistItems.length > 0) detailsPatch.checklist = buildChecklist(checklistItems); + const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); const etag: string = details["@odata.etag"]; await this.graph .api(`/planner/tasks/${taskId}/details`) .header("If-Match", etag) - .patch({ description: args.description }); + .header("Prefer", "return=representation") + .patch(detailsPatch); } - return { taskId }; + return { taskId, checklistOverflow }; } async updateTask(args: { taskId: string; + newBucketId?: string; progress?: Progress; percentComplete?: number; - newTitle?: string; + priority?: Priority; + startDate?: string; dueDate?: string; + newTitle?: string; + /** When provided, this is the full new set of assignees — old ones are removed. */ + assigneeUserIds?: string[]; + currentAssigneeIds?: string[]; + /** Plan-label slots (1..25) to apply (full set after this update). */ + categorySlots?: number[]; + currentCategorySlots?: number[]; }): Promise { const current = await this.graph.api(`/planner/tasks/${args.taskId}`).get(); const etag: string = current["@odata.etag"]; const patch: Record = {}; - const pct = - args.percentComplete ?? - progressToPercent(args.progress); + const pct = args.percentComplete ?? progressToPercent(args.progress); if (pct !== undefined) patch.percentComplete = pct; if (args.newTitle) patch.title = args.newTitle; + if (args.startDate) patch.startDateTime = toIsoStartOfDay(args.startDate); if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate); + if (args.newBucketId) patch.bucketId = args.newBucketId; + const priorityInt = priorityToInt(args.priority); + if (priorityInt !== undefined) patch.priority = priorityInt; + + if (args.assigneeUserIds) { + patch.assignments = buildAssignmentsForReplace( + args.currentAssigneeIds ?? [], + args.assigneeUserIds, + ); + } + + if (args.categorySlots) { + patch.appliedCategories = buildAppliedCategoriesDiff( + args.currentCategorySlots ?? [], + args.categorySlots, + ); + } if (Object.keys(patch).length === 0) return; @@ -120,6 +229,26 @@ export class PlannerClient { .patch(patch); } + /** Append items to a task's existing checklist on its /details resource. + * Returns how many items overflowed (Planner caps at 20). */ + async appendChecklist(taskId: string, items: string[]): Promise<{ overflow: number }> { + if (items.length === 0) return { overflow: 0 }; + const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); + const etag: string = details["@odata.etag"]; + const existing = (details.checklist ?? {}) as Record; + const remainingCapacity = Math.max(0, 20 - Object.keys(existing).length); + const toAdd = items.slice(0, remainingCapacity); + const overflow = items.length - toAdd.length; + if (toAdd.length === 0) return { overflow }; + + const additions = buildChecklist(toAdd); + await this.graph + .api(`/planner/tasks/${taskId}/details`) + .header("If-Match", etag) + .patch({ checklist: additions }); + return { overflow }; + } + async appendTaskNote(taskId: string, note: string): Promise { const details = await this.graph.api(`/planner/tasks/${taskId}/details`).get(); const etag: string = details["@odata.etag"]; @@ -133,6 +262,38 @@ export class PlannerClient { .header("If-Match", etag) .patch({ description: combined }); } + + /** Ensure a plan-level label exists with the given name. Returns its slot + * (1..25). Picks the lowest empty slot for new labels. Throws if 25 slots + * are all taken. */ + async ensurePlanLabel(planId: string, name: string): Promise { + const details = await this.graph.api(`/planner/plans/${planId}/details`).get(); + const etag: string = details["@odata.etag"]; + const cd = (details.categoryDescriptions ?? {}) as Record; + + for (let slot = 1; slot <= 25; slot++) { + const key = `category${slot}`; + if (cd[key] && cd[key].trim().toLowerCase() === name.trim().toLowerCase()) { + return slot; + } + } + let emptySlot: number | null = null; + for (let slot = 1; slot <= 25; slot++) { + const key = `category${slot}`; + if (!cd[key]) { + emptySlot = slot; + break; + } + } + if (emptySlot === null) { + throw new Error(`Plan 의 라벨 슬롯이 25개 모두 차있어 '${name}' 을 등록할 수 없어요.`); + } + await this.graph + .api(`/planner/plans/${planId}/details`) + .header("If-Match", etag) + .patch({ categoryDescriptions: { [`category${emptySlot}`]: name } }); + return emptySlot; + } } function progressToPercent(p?: Progress): number | undefined { @@ -148,7 +309,108 @@ function progressToPercent(p?: Progress): number | undefined { } } +function priorityToInt(p?: Priority): number | undefined { + switch (p) { + case "urgent": + return 1; + case "important": + return 3; + case "medium": + return 5; + case "low": + return 9; + default: + return undefined; + } +} + +function priorityFromInt(n?: number): Priority | undefined { + if (n === undefined) return undefined; + if (n <= 1) return "urgent"; + if (n <= 3) return "important"; + if (n <= 5) return "medium"; + return "low"; +} + +function buildAssignmentsForCreate(userIds: string[]): Record { + const out: Record = {}; + for (const id of userIds) { + out[id] = { + "@odata.type": "#microsoft.graph.plannerAssignment", + orderHint: " !", + }; + } + return out; +} + +/** Build an assignments PATCH that removes old assignees (set to null) and + * adds the new ones, preserving any user who is in both sets. */ +function buildAssignmentsForReplace( + currentIds: string[], + newIds: string[], +): Record { + const out: Record = {}; + const current = new Set(currentIds); + const target = new Set(newIds); + for (const id of current) { + if (!target.has(id)) out[id] = null; + } + for (const id of target) { + if (!current.has(id)) { + out[id] = { + "@odata.type": "#microsoft.graph.plannerAssignment", + orderHint: " !", + }; + } + } + return out; +} + +function buildAppliedCategories(slots: number[]): Record { + const out: Record = {}; + for (const s of slots) { + if (s >= 1 && s <= 25) out[`category${s}`] = true; + } + return out; +} + +/** Diff appliedCategories so we explicitly set removed slots to false. */ +function buildAppliedCategoriesDiff( + currentSlots: number[], + newSlots: number[], +): Record { + const out: Record = {}; + const target = new Set(newSlots); + for (const s of currentSlots) { + if (!target.has(s)) out[`category${s}`] = false; + } + for (const s of newSlots) { + if (s >= 1 && s <= 25) out[`category${s}`] = true; + } + return out; +} + +function buildChecklist(items: string[]): Record { + const out: Record = {}; + for (const title of items) { + out[randomUUID()] = { + "@odata.type": "#microsoft.graph.plannerChecklistItem", + title, + isChecked: false, + }; + } + return out; +} + +function slotFromKey(key: string): number { + const m = /^category(\d+)$/.exec(key); + return m ? Number(m[1]) : 0; +} + function toIsoEod(yyyyMmDd: string): string { - // Planner expects an ISO datetime with TZ. We default to end-of-day UTC. return `${yyyyMmDd}T23:59:59Z`; } + +function toIsoStartOfDay(yyyyMmDd: string): string { + return `${yyyyMmDd}T00:00:00Z`; +} diff --git a/src/llm/coerce.ts b/src/llm/coerce.ts index b4ee5ca..62b43c3 100644 --- a/src/llm/coerce.ts +++ b/src/llm/coerce.ts @@ -1,4 +1,4 @@ -import { ClassifiedAction, Progress } from "./types"; +import { ClassifiedAction, Priority, Progress } from "./types"; /** * Validates and narrows the raw JSON returned by the LLM into a ClassifiedAction. @@ -23,7 +23,13 @@ export function coerceAction(raw: unknown): ClassifiedAction { title: o.title, description: stringOrUndef(o.description), progress: progressOrUndef(o.progress), + priority: priorityOrUndef(o.priority), + startDate: stringOrUndef(o.startDate), dueDate: stringOrUndef(o.dueDate), + checklistItems: stringArrayOrUndef(o.checklistItems), + assigneeUserIds: idArrayOrUndef(o.assigneeUserIds), + inferredLabels: stringArrayOrUndef(o.inferredLabels), + explicitLabels: stringArrayOrUndef(o.explicitLabels), }; } @@ -34,11 +40,18 @@ export function coerceAction(raw: unknown): ClassifiedAction { return { type: "update_task", taskId: sanitizeId(o.taskId), + newBucketId: o.newBucketId !== undefined ? sanitizeId(String(o.newBucketId)) : undefined, progress: progressOrUndef(o.progress), percentComplete: numberOrUndef(o.percentComplete), appendNote: stringOrUndef(o.appendNote), newTitle: stringOrUndef(o.newTitle), dueDate: stringOrUndef(o.dueDate), + startDate: stringOrUndef(o.startDate), + priority: priorityOrUndef(o.priority), + addChecklistItems: stringArrayOrUndef(o.addChecklistItems), + assigneeUserIds: idArrayOrUndef(o.assigneeUserIds), + inferredLabels: stringArrayOrUndef(o.inferredLabels), + explicitLabels: stringArrayOrUndef(o.explicitLabels), }; } @@ -61,6 +74,18 @@ function numberOrUndef(v: unknown): number | undefined { function progressOrUndef(v: unknown): Progress | undefined { return v === "notStarted" || v === "inProgress" || v === "completed" ? v : undefined; } +function priorityOrUndef(v: unknown): Priority | undefined { + return v === "urgent" || v === "important" || v === "medium" || v === "low" ? v : undefined; +} +function stringArrayOrUndef(v: unknown): string[] | undefined { + if (!Array.isArray(v)) return undefined; + const out = v.filter((x): x is string => typeof x === "string" && x.length > 0); + return out.length ? out : undefined; +} +function idArrayOrUndef(v: unknown): string[] | undefined { + const arr = stringArrayOrUndef(v); + return arr ? arr.map(sanitizeId) : undefined; +} /** * Strip surrounding whitespace, quotes, and trailing punctuation (commas/periods) diff --git a/src/llm/geminiClassifier.ts b/src/llm/geminiClassifier.ts index 65b41cc..6274190 100644 --- a/src/llm/geminiClassifier.ts +++ b/src/llm/geminiClassifier.ts @@ -23,8 +23,39 @@ const GEMINI_SCHEMA = { enum: ["notStarted", "inProgress", "completed"], }, percentComplete: { type: Type.INTEGER }, + priority: { + type: Type.STRING, + enum: ["urgent", "important", "medium", "low"], + }, + startDate: { type: Type.STRING, description: "YYYY-MM-DD" }, dueDate: { type: Type.STRING, description: "YYYY-MM-DD" }, + checklistItems: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: "create_task 의 새 체크리스트 (최대 20개)", + }, + addChecklistItems: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: "update_task 에서 기존 체크리스트 뒤에 덧붙일 항목", + }, + assigneeUserIds: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: "plan members 의 userId 만 허용", + }, + inferredLabels: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: "plan 의 기존 라벨 이름만 (LLM 추론)", + }, + explicitLabels: { + type: Type.ARRAY, + items: { type: Type.STRING }, + description: "사용자가 명시한 라벨 이름 (plan 에 없을 수도 있음)", + }, taskId: { type: Type.STRING, description: "update_task일 때 필수" }, + newBucketId: { type: Type.STRING, description: "같은 plan 안의 bucketId" }, appendNote: { type: Type.STRING }, newTitle: { type: Type.STRING }, question: { type: Type.STRING, description: "ask_clarification일 때 필수" }, diff --git a/src/llm/prompt.ts b/src/llm/prompt.ts index 97b80f9..05f1fa0 100644 --- a/src/llm/prompt.ts +++ b/src/llm/prompt.ts @@ -2,41 +2,102 @@ import { ClassifierInput } from "./types"; export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다. -규칙: -- 사용자의 발화가 "새 작업"인지, "기존 작업 진행상황 업데이트"인지, "불명확해서 되물어야 하는지" 판단합니다. -- create_task 를 선택할 때는 반드시 제공된 plans 중 하나의 planId 와 그 plan 의 bucketId 를 선택해야 합니다. 임의 ID를 만들면 안 됩니다. -- update_task 를 선택할 때는 반드시 recentTasks 중 하나의 taskId 를 선택해야 합니다. -- **ID 값은 반드시 컨텍스트에 주어진 문자열을 한 글자도 빼거나 더하지 말고 그대로 복사해야 합니다.** 콤마, 공백, 따옴표 어떤 것도 추가하지 마세요. -- 발화에 어떤 작업을 가리키는지 명확한 단서가 없으면(예: "그거 했어", "다 끝났어"처럼 작업명을 특정하지 못함) 반드시 ask_clarification 으로 어떤 작업인지 되물어보세요. 임의로 가장 최근 작업을 가정하지 마세요. -- plan 또는 task 후보가 둘 이상이라 확신할 수 없으면 ask_clarification 으로 짧고 구체적인 질문을 돌려주세요. -- 진행률 표현은 다음으로 매핑: - - "시작", "할 거야", "착수" → progress: "notStarted", percentComplete: 0 - - "하는 중", "진행 중", "%" 언급 → progress: "inProgress", percentComplete: 그 값(없으면 50) - - "끝", "완료", "다 했어" → progress: "completed", percentComplete: 100 -- 날짜는 nowIso 를 기준으로 ISO-8601 (YYYY-MM-DD) 로 변환. "내일/모레/다음주 X요일" 같은 표현을 처리하세요. -- 액션의 핵심 한 가지만 출력합니다. 부가 설명, 코멘트, 마크다운은 출력하지 마세요. 반드시 도구 호출로만 응답하세요.`; +## 액션 종류 +- create_task: 새 작업을 만든다. +- update_task: 기존 작업을 수정한다 (진행률/상태/할당/라벨/버킷 이동 등). +- ask_clarification: 어떤 작업/계획/사람/라벨을 가리키는지 확신할 수 없을 때 짧고 구체적인 한국어 질문을 돌려준다. + +## 공통 규칙 +- 반드시 도구 호출(함수 호출)로만 응답하세요. 부가 설명, 코멘트, 마크다운은 출력하지 마세요. +- create_task 의 planId/bucketId, update_task 의 taskId, assigneeUserIds 의 각 항목 — **모든 ID 값은 컨텍스트에 주어진 문자열을 한 글자도 빼거나 더하지 말고 그대로 복사**하세요. 콤마/공백/따옴표를 덧붙이지 마세요. +- 컨텍스트에 없는 ID 를 만들지 마세요. 적절한 후보가 없으면 ask_clarification 으로 되묻습니다. +- 발화에서 작업을 가리키는 단서가 모호하면(예: "그거 했어", "다 끝났어") 반드시 ask_clarification 을 사용하세요. 임의로 가장 최근 작업을 가정하지 마세요. + +## 진행/상태 매핑 (progress, percentComplete) +- "시작/할 거야/착수" → progress: "notStarted", percentComplete: 0 +- "하는 중/진행 중/% 언급" → progress: "inProgress", percentComplete: 그 값(없으면 50) +- "끝/완료/다 했어" → progress: "completed", percentComplete: 100 + +## 우선순위 (priority) — Planner의 priority 필드 +- "긴급/ASAP/급해/지금 당장" → priority: "urgent" +- "중요/중요해/우선순위 높음" → priority: "important" +- "보통/normal" → priority: "medium" +- "낮음/나중에" → priority: "low" +- 발화에 단서가 없으면 priority 를 채우지 마세요(Planner 기본값 medium 유지). + +## 날짜 (startDate, dueDate) +- 둘 다 YYYY-MM-DD 형식. +- nowIso 를 기준으로 "내일/모레/다음주 X요일/이번 주 금요일/N일 후" 같은 표현을 절대 날짜로 변환. +- "내일부터 금요일까지" 처럼 시작과 마감이 모두 명시되면 startDate 와 dueDate 둘 다 채웁니다. + +## 체크리스트 (checklistItems / addChecklistItems) +- "체크리스트로 A, B, C", "할 일 1. X 2. Y 3. Z", "확인사항: ...", "준비물: ..." 같은 단서가 있으면 각 항목을 배열의 원소로 분리하세요. +- 새 작업이면 checklistItems, 기존 작업에 덧붙이는 거면 addChecklistItems 를 사용. +- 최대 20개. 그보다 많으면 앞 20개만. + +## 할당 (assigneeUserIds) +- 반드시 컨텍스트의 plan members 목록 안에서 displayName 부분 매칭으로 사용자를 찾고, 그 userId 를 채웁니다. +- "나/내가/제가" → members 중 isMe=true 항목의 userId. +- 발화에 이름이 있는데 members 에 일치하는 사람이 없으면 ask_clarification. +- update_task 에서는 assigneeUserIds 가 전체 교체로 동작합니다. "X 도 추가" 처럼 누적 의도가 분명하면 기존 assigneeIds + 새 사람을 모두 채워서 넣으세요. + +## 라벨 (inferredLabels vs explicitLabels) +- **inferredLabels**: 사용자가 직접 라벨을 언급하지 않았지만 발화 의미가 plan 의 기존 라벨 중 하나와 명확히 매칭될 때만 채웁니다. 자신 없으면 비워두세요. plan 에 없는 이름은 절대 넣지 마세요. +- **explicitLabels**: 사용자가 "#버그", "버그 라벨로", "label: 보안" 같이 명시적으로 라벨 이름을 말했을 때 그 문자열을 그대로 넣습니다. plan 에 그 라벨이 없어도 그대로 넣으세요(봇이 "새로 등록할까요" 흐름을 띄웁니다). + +## 버킷 이동 (update_task.newBucketId) +- 사용자가 "X 버킷으로 옮겨", "검토 중 버킷으로" 같이 명시적으로 말하면 해당 plan 의 buckets 중 매칭되는 bucketId 를 newBucketId 에 채웁니다. +- 또는 상태 변경 표현이 명확히 다른 버킷으로의 이동을 시사하면(예: "이제 완료 됐어" → "Done" 버킷이 있을 때) 같이 채워주세요. 단, 다른 plan 으로의 이동은 금지. + +## 출력 형식 +- 단 한 개의 액션만 출력. 부가 설명 없이 도구 호출만 사용.`; export function renderUserMessage(input: ClassifierInput): string { const plansBlock = input.plans - .map( - (p) => + .map((p) => { + const bucketLines = p.buckets + .map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`) + .join("\n"); + const memberLines = p.members.length + ? p.members + .map( + (m) => + ` member: userId=${m.userId} | "${m.displayName}"${m.isMe ? " (나)" : ""}`, + ) + .join("\n") + : ' member: (없음)'; + const labelLines = p.labels.length + ? p.labels + .map((l) => ` label: slot=${l.slot} | "${l.name}"`) + .join("\n") + : ' label: (등록된 라벨 없음)'; + return ( `- planId=${p.planId} | "${p.planTitle}"\n` + - p.buckets.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`).join("\n"), - ) + bucketLines + + "\n" + + memberLines + + "\n" + + labelLines + ); + }) .join("\n"); const tasksBlock = input.recentTasks.length ? input.recentTasks - .map( - (t) => - `- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%`, - ) + .map((t) => { + const extras: string[] = []; + if (t.priority) extras.push(`priority=${t.priority}`); + if (t.assigneeIds && t.assigneeIds.length) + extras.push(`assigneeIds=[${t.assigneeIds.join(",")}]`); + const tail = extras.length ? ` | ${extras.join(" | ")}` : ""; + return `- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%${tail}`; + }) .join("\n") : "(최근 작업 없음)"; return `현재 시각(ISO): ${input.nowIso} -[사용 가능한 Plans / Buckets] +[사용 가능한 Plans / Buckets / Members / Labels] ${plansBlock} [최근 작업(업데이트 후보)] @@ -49,6 +110,7 @@ ${input.utterance}`; /** * JSON Schema describing the ClassifiedAction discriminated union. * Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool). + * Gemini uses a parallel Type.*-based schema; keep them in sync. */ export const ACTION_TOOL_SCHEMA = { name: "submit_planner_action", @@ -69,8 +131,39 @@ export const ACTION_TOOL_SCHEMA = { enum: ["notStarted", "inProgress", "completed"], }, percentComplete: { type: "integer", minimum: 0, maximum: 100 }, + priority: { + type: "string", + enum: ["urgent", "important", "medium", "low"], + }, + startDate: { type: "string", description: "YYYY-MM-DD" }, dueDate: { type: "string", description: "YYYY-MM-DD" }, + checklistItems: { + type: "array", + items: { type: "string" }, + description: "create_task 의 새 체크리스트 (최대 20개)", + }, + addChecklistItems: { + type: "array", + items: { type: "string" }, + description: "update_task 에서 기존 체크리스트 뒤에 덧붙일 항목", + }, + assigneeUserIds: { + type: "array", + items: { type: "string" }, + description: "plan members 의 userId 만 허용", + }, + inferredLabels: { + type: "array", + items: { type: "string" }, + description: "plan 의 기존 라벨 이름만 (LLM 추론)", + }, + explicitLabels: { + type: "array", + items: { type: "string" }, + description: "사용자가 명시한 라벨 이름 (plan 에 없을 수도 있음)", + }, taskId: { type: "string", description: "update_task일 때 필수" }, + newBucketId: { type: "string", description: "같은 plan 안의 bucketId" }, appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" }, newTitle: { type: "string" }, question: { type: "string", description: "ask_clarification일 때 필수" }, diff --git a/src/llm/types.ts b/src/llm/types.ts index 183bb9e..0bbe940 100644 --- a/src/llm/types.ts +++ b/src/llm/types.ts @@ -6,11 +6,30 @@ export interface PlanContext { planId: string; planTitle: string; buckets: { bucketId: string; bucketTitle: string }[]; + /** M365 group members backing this plan — the only valid assignee pool. */ + members: PlanMember[]; + /** Plan-level label slots already in use (categoryDescriptions non-null). */ + labels: PlanLabel[]; +} + +export interface PlanMember { + userId: string; // AAD object id + displayName: string; + /** true if this entry is the signed-in user — lets LLM resolve "나"/"내가". */ + isMe?: true; +} + +export interface PlanLabel { + /** 1–25. Maps to `category${slot}` on plannerTask.appliedCategories. */ + slot: number; + name: string; } /** * A recent task we surface to the LLM so it can resolve an "update X" intent - * to a specific taskId without us having to fuzzy-match later. + * to a specific taskId without us having to fuzzy-match later. Includes + * current-state fields so the LLM (and the preview card) can diff intent + * against the current task. */ export interface RecentTaskContext { taskId: string; @@ -18,9 +37,12 @@ export interface RecentTaskContext { planId: string; bucketId: string; percentComplete: number; + priority?: Priority; + assigneeIds?: string[]; } export type Progress = "notStarted" | "inProgress" | "completed"; +export type Priority = "urgent" | "important" | "medium" | "low"; export type ClassifiedAction = | { @@ -30,16 +52,33 @@ export type ClassifiedAction = title: string; description?: string; progress?: Progress; - dueDate?: string; // ISO 8601 date + priority?: Priority; + startDate?: string; // YYYY-MM-DD + dueDate?: string; // YYYY-MM-DD + checklistItems?: string[]; + assigneeUserIds?: string[]; + /** Labels the LLM inferred from the utterance — silently dropped if not on the plan. */ + inferredLabels?: string[]; + /** Labels the user explicitly mentioned — may not exist on the plan; + * if missing, the bot asks whether to create them. */ + explicitLabels?: string[]; } | { type: "update_task"; taskId: string; + newBucketId?: string; progress?: Progress; percentComplete?: number; // 0–100 appendNote?: string; newTitle?: string; dueDate?: string; + startDate?: string; + priority?: Priority; + addChecklistItems?: string[]; + /** Full replacement of assignees. */ + assigneeUserIds?: string[]; + inferredLabels?: string[]; + explicitLabels?: string[]; } | { type: "ask_clarification";