feat: full Planner field coverage (priority/start/checklist/assignees/labels/bucket move)
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 31s

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) <noreply@anthropic.com>
This commit is contained in:
윤정민 2026-05-16 15:10:33 +09:00
parent 59a983133f
commit 01616c4526
7 changed files with 1003 additions and 137 deletions

View File

@ -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<void> {
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<ClassifiedAction, { type: "ask_clarification" }>;
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 | undefined>("PendingAction");
this.pendingAccessor = this.conversationState.createProperty<PendingAction | undefined>(
"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<void> {
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<void> {
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;
}
}
/** User clicked ✅ on a preview card — execute the stored action. */
private async handleConfirm(context: TurnContext, token: string): Promise<void> {
const pending: PendingAction = {
action: {
...action,
...(action.type === "create_task" ? { assigneeUserIds: validAssigneeIds } : {}),
...(action.type === "update_task" && action.assigneeUserIds
? { assigneeUserIds: validAssigneeIds }
: {}),
} as Exclude<ClassifiedAction, { type: "ask_clarification" }>,
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 })],
});
}
// -------- Confirm: actually apply ----------------------------------------
private async handleConfirm(
context: TurnContext,
token: string,
createLabels: boolean,
): Promise<void> {
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<void> {
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<T>(arr: T[]): T[] {
const seen = new Set<T>();
const out: T[] = [];
for (const x of arr) {
if (!seen.has(x)) {
seen.add(x);
out.push(x);
}
}
return out;
}

View File

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

View File

@ -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<PlanContext[]> {
// 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 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({
const cd = (planDetails.categoryDescriptions ?? {}) as Record<string, string | null>;
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;
}),
);
return enriched;
}
return result;
/** Resolve owning M365 group's members. Falls back gracefully if owner missing. */
private async fetchPlanMembers(
planId: string,
ownerGroupId?: string,
): Promise<Array<{ id: string; displayName: string }>> {
let groupId = ownerGroupId;
if (!groupId) {
const plan = await this.graph
.api(`/planner/plans/${planId}`)
.select(["id", "owner"])
.get()
.catch(() => undefined);
groupId = plan?.owner;
}
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<RecentTaskContext[]> {
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<string, unknown>;
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<RecentTaskContext>((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<string, unknown> = {
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<string, unknown> = {};
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<void> {
const current = await this.graph.api(`/planner/tasks/${args.taskId}`).get();
const etag: string = current["@odata.etag"];
const patch: Record<string, unknown> = {};
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<string, unknown>;
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<void> {
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<number> {
const details = await this.graph.api(`/planner/plans/${planId}/details`).get();
const etag: string = details["@odata.etag"];
const cd = (details.categoryDescriptions ?? {}) as Record<string, string | null>;
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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown> {
const out: Record<string, unknown> = {};
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<string, boolean> {
const out: Record<string, boolean> = {};
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<string, boolean> {
const out: Record<string, boolean> = {};
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<string, unknown> {
const out: Record<string, unknown> = {};
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`;
}

View File

@ -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)

View File

@ -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일 때 필수" },

View File

@ -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) => {
const bucketLines = p.buckets
.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`)
.join("\n");
const memberLines = p.members.length
? p.members
.map(
(p) =>
`- planId=${p.planId} | "${p.planTitle}"\n` +
p.buckets.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`).join("\n"),
(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` +
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일 때 필수" },

View File

@ -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 {
/** 125. 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; // 0100
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";