diff --git a/src/bot/PlannerBot.ts b/src/bot/PlannerBot.ts index 4c97ae7..115ed53 100644 --- a/src/bot/PlannerBot.ts +++ b/src/bot/PlannerBot.ts @@ -1,5 +1,4 @@ import { - CardFactory, CloudAdapter, ConversationState, StatePropertyAccessor, @@ -11,8 +10,8 @@ import { UserTokenClient } from "botframework-connector"; import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs"; import { createGraphClient } from "../graph/graphClientFactory"; import { PlannerClient } from "../graph/plannerClient"; -import { LlmClassifier } from "../llm/types"; -import { buildResultCard } from "../cards/confirmationCard"; +import { ClassifiedAction, LlmClassifier } from "../llm/types"; +import { buildClarificationCard, buildPreviewCard, buildResultCard } from "../cards/confirmationCard"; async function signOutUser(context: TurnContext, connectionName: string): Promise { const adapter = context.adapter as CloudAdapter; @@ -32,6 +31,23 @@ 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; + createdAt: number; +} + +type MainDialogOpts = + | { kind: "utterance"; text: string } + | { kind: "confirm" } + | { kind: "cancel" }; + export interface PlannerBotDeps { conversationState: ConversationState; userState: UserState; @@ -45,6 +61,7 @@ export class PlannerBot extends TeamsActivityHandler { private readonly classifier: LlmClassifier; private readonly dialogs: DialogSet; private readonly dialogStateAccessor: StatePropertyAccessor; + private readonly pendingAccessor: StatePropertyAccessor; private readonly connectionName: string; constructor(deps: PlannerBotDeps) { @@ -52,9 +69,10 @@ export class PlannerBot extends TeamsActivityHandler { this.conversationState = deps.conversationState; this.userState = deps.userState; this.classifier = deps.classifier; - this.connectionName = deps.connectionName; + this.dialogStateAccessor = this.conversationState.createProperty("DialogState"); + this.pendingAccessor = this.conversationState.createProperty("PendingAction"); this.dialogs = new DialogSet(this.dialogStateAccessor); this.dialogs.add( @@ -75,18 +93,37 @@ export class PlannerBot extends TeamsActivityHandler { await step.context.sendActivity("로그인이 완료되지 않았어요. 다시 시도해 주세요."); return step.endDialog(); } - const utterance: string = (step.options as { utterance: string }).utterance; - await this.handleUtterance(step.context, tokenResponse.token, utterance); + const opts = step.options as MainDialogOpts; + 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); + } else { + await this.handleCancel(step.context); + } return step.endDialog(); }, ]), ); this.onMessage(async (context, next) => { + const value = context.activity.value as { kind?: string } | 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); + } + await next(); + return; + } + if (text.toLowerCase() === "logout") { await signOutUser(context, this.connectionName); + await this.pendingAccessor.set(context, undefined); await context.sendActivity("로그아웃 완료."); await next(); return; @@ -100,14 +137,12 @@ export class PlannerBot extends TeamsActivityHandler { const dialogContext = await this.dialogs.createContext(context); const result = await dialogContext.continueDialog(); if (result.status === DialogTurnStatus.empty) { - await dialogContext.beginDialog(MAIN_DIALOG, { utterance: text }); + await dialogContext.beginDialog(MAIN_DIALOG, { kind: "utterance", text } as MainDialogOpts); } await next(); }); - // Teams sends an invoke activity ("signin/verifyState" or "signin/tokenExchange") - // after the user completes the OAuth flow. We must route it back into the - // active dialog so OAuthPrompt can finish. + // Token-response event (legacy bot-framework token delivery path) this.onTokenResponseEvent(async (context, next) => { const dialogContext = await this.dialogs.createContext(context); await dialogContext.continueDialog(); @@ -117,7 +152,8 @@ export class PlannerBot extends TeamsActivityHandler { this.onMembersAdded(async (context, next) => { const greeting = "안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" + - "예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”"; + "예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”\n" + + "작업을 실제로 반영하기 전에 항상 확인 카드를 보여드려요."; for (const m of context.activity.membersAdded ?? []) { if (m.id !== context.activity.recipient.id) { await context.sendActivity(greeting); @@ -133,6 +169,7 @@ 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(); @@ -143,6 +180,8 @@ 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. */ private async handleUtterance( context: TurnContext, token: string, @@ -170,8 +209,6 @@ export class PlannerBot extends TeamsActivityHandler { return; } - // listPlansWithBuckets already drops "Deprecated" plans; mirror that for tasks - // so the LLM can't try to update a task in a hidden plan. const validPlanIds = new Set(plans.map((p) => p.planId)); recentTasks = recentTasks.filter((t) => validPlanIds.has(t.planId)); @@ -182,52 +219,71 @@ export class PlannerBot extends TeamsActivityHandler { nowIso: new Date().toISOString(), }); + if (action.type === "ask_clarification") { + await this.pendingAccessor.set(context, undefined); + await context.sendActivity({ attachments: [buildClarificationCard(action.question)] }); + 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 = {}; + 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; + } else { + const task = recentTasks.find((t) => t.taskId === action.taskId); + ctx.taskTitle = task?.title; + } + + await this.pendingAccessor.set(context, { action, ctx, createdAt: Date.now() }); + + await context.sendActivity({ attachments: [buildPreviewCard({ action, ctx })] }); + } + + /** User clicked ✅ on a preview card — execute the stored action. */ + private async handleConfirm(context: TurnContext, token: string): Promise { + const pending = await this.pendingAccessor.get(context); + if (!pending) { + await context.sendActivity("확정할 작업이 없어요. 다시 말씀해 주시겠어요?"); + return; + } + + await context.sendActivity({ type: "typing" }); + const planner = new PlannerClient(createGraphClient(token)); + const { action, ctx } = pending; + try { if (action.type === "create_task") { - const { taskId } = await planner.createTask(action); - const plan = plans.find((p) => p.planId === action.planId); - const bucket = plan?.buckets.find((b) => b.bucketId === action.bucketId); - await context.sendActivity({ - attachments: [ - buildResultCard({ - action, - planTitle: plan?.planTitle, - bucketTitle: bucket?.bucketTitle, - status: "done", - }), - ], - }); - void taskId; - } else if (action.type === "update_task") { + await planner.createTask(action); + } else { await planner.updateTask(action); if (action.appendNote) { await planner.appendTaskNote(action.taskId, action.appendNote); } - const task = recentTasks.find((t) => t.taskId === action.taskId); - await context.sendActivity({ - attachments: [ - buildResultCard({ - action, - taskTitle: task?.title, - status: "done", - }), - ], - }); - } else { - await context.sendActivity({ - attachments: [buildResultCard({ action, status: "ask" })], - }); } + await this.pendingAccessor.set(context, undefined); + await context.sendActivity({ attachments: [buildResultCard({ action, ctx, status: "done" })] }); } catch (err) { await context.sendActivity({ attachments: [ - buildResultCard({ - action, - status: "error", - errorMessage: (err as Error).message, - }), + buildResultCard({ action, ctx, status: "error", errorMessage: (err as Error).message }), ], }); } } + + /** User clicked ❌ on a preview card. */ + private async handleCancel(context: TurnContext): Promise { + const pending = await this.pendingAccessor.get(context); + if (!pending) { + await context.sendActivity("취소할 작업이 없어요."); + return; + } + await this.pendingAccessor.set(context, undefined); + await context.sendActivity({ + attachments: [buildResultCard({ action: pending.action, ctx: pending.ctx, status: "canceled" })], + }); + } } diff --git a/src/cards/confirmationCard.ts b/src/cards/confirmationCard.ts index ddea575..93d573c 100644 --- a/src/cards/confirmationCard.ts +++ b/src/cards/confirmationCard.ts @@ -1,58 +1,113 @@ import { CardFactory, Attachment } from "botbuilder"; import { ClassifiedAction } from "../llm/types"; -export function buildResultCard(args: { - action: ClassifiedAction; +interface ActionContext { planTitle?: string; bucketTitle?: string; taskTitle?: string; - status: "done" | "ask" | "error"; - errorMessage?: string; -}): Attachment { - const lines: string[] = []; - const a = args.action; +} - if (args.status === "error") { - lines.push(`❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`); - } else if (a.type === "create_task") { - lines.push(`✅ 새 작업 생성`); - lines.push(`Plan: ${args.planTitle ?? a.planId}`); - lines.push(`Bucket: ${args.bucketTitle ?? a.bucketId}`); +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(`진행: ${a.progress}`); + if (a.progress) lines.push(`진행: ${progressLabel(a.progress)}`); } else if (a.type === "update_task") { - lines.push(`🔄 작업 업데이트`); - lines.push(`대상: ${args.taskTitle ?? a.taskId}`); - if (a.progress) lines.push(`진행: ${a.progress}`); + 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.appendNote) lines.push(`메모 추가: ${a.appendNote}`); if (a.dueDate) lines.push(`마감: ${a.dueDate}`); - } else { - lines.push(`❓ 한 번 더 확인이 필요해요`); + } else if (a.type === "ask_clarification") { lines.push(a.question); } + return lines; +} - const card = { +function progressLabel(p: string): string { + switch (p) { + case "notStarted": return "시작 전"; + case "inProgress": return "진행 중"; + case "completed": return "완료"; + default: return p; + } +} + +/** Preview card with Confirm / Cancel buttons. Used before any Planner write. */ +export function buildPreviewCard(args: { + action: Exclude; + ctx: ActionContext; +}): Attachment { + const a = args.action; + const heading = + a.type === "create_task" ? "📋 새 작업을 만들까요?" : "🔄 이 작업을 업데이트할까요?"; + const details = actionDetailLines(a, args.ctx); + + return CardFactory.adaptiveCard({ type: "AdaptiveCard", $schema: "http://adaptivecards.io/schemas/adaptive-card.json", version: "1.4", body: [ - { - type: "TextBlock", - text: lines[0], - weight: "Bolder", - size: "Medium", - wrap: true, - }, - ...lines.slice(1).map((t) => ({ - type: "TextBlock", - text: t, - wrap: true, - spacing: "Small", - })), + { 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" }, ], - }; - - return CardFactory.adaptiveCard(card); + actions: [ + { type: "Action.Submit", title: "✅ 확인", data: { kind: "confirm" } }, + { type: "Action.Submit", title: "❌ 취소", data: { kind: "cancel" } }, + ], + }); +} + +/** Result card after a Planner write succeeds/fails. */ +export function buildResultCard(args: { + action: ClassifiedAction; + ctx?: ActionContext; + status: "done" | "error" | "canceled"; + errorMessage?: string; +}): Attachment { + let heading: string; + let details: string[] = []; + + if (args.status === "error") { + heading = `❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`; + } else if (args.status === "canceled") { + heading = "🚫 취소했습니다."; + } else if (args.action.type === "create_task") { + heading = "✅ 새 작업을 만들었어요"; + details = actionDetailLines(args.action, args.ctx ?? {}); + } else if (args.action.type === "update_task") { + heading = "✅ 작업을 업데이트했어요"; + details = actionDetailLines(args.action, args.ctx ?? {}); + } else { + heading = "❓ 한 번 더 확인이 필요해요"; + details = actionDetailLines(args.action, args.ctx ?? {}); + } + + return CardFactory.adaptiveCard({ + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + 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" })), + ], + }); +} + +/** Pure-text clarification card (no buttons; user replies in chat). */ +export function buildClarificationCard(question: string): Attachment { + return CardFactory.adaptiveCard({ + type: "AdaptiveCard", + $schema: "http://adaptivecards.io/schemas/adaptive-card.json", + version: "1.4", + body: [ + { type: "TextBlock", text: "❓ 한 번 더 알려주세요", weight: "Bolder", size: "Medium", wrap: true }, + { type: "TextBlock", text: question, wrap: true, spacing: "Small" }, + ], + }); }