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
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 30s
This commit is contained in:
parent
59c6814ccc
commit
59a983133f
@ -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" })],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user