feat: preview card with Confirm/Cancel buttons before Planner writes
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s

This commit is contained in:
윤정민 2026-05-15 17:44:54 +09:00
parent 59c6814ccc
commit 59a983133f
2 changed files with 195 additions and 84 deletions

View File

@ -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<void> {
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<ClassifiedAction, { type: "ask_clarification" }>;
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<PendingAction | undefined>;
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 | undefined>("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<void> {
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<void> {
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<void> {
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" })],
});
}
}

View File

@ -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<ClassifiedAction, { type: "ask_clarification" }>;
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" },
],
});
}