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
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:
parent
59a983133f
commit
01616c4526
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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 })],
|
||||
});
|
||||
}
|
||||
|
||||
/** User clicked ✅ on a preview card — execute the stored action. */
|
||||
private async handleConfirm(context: TurnContext, token: string): Promise<void> {
|
||||
// -------- 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;
|
||||
}
|
||||
|
||||
@ -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" } : {}),
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? [];
|
||||
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;
|
||||
}),
|
||||
);
|
||||
|
||||
const result: PlanContext[] = [];
|
||||
for (const p of plans) {
|
||||
const bucketsResp = await this.graph.api(`/planner/plans/${p.id}/buckets`).get();
|
||||
const buckets: Array<{ id: string; name: string }> = bucketsResp.value ?? [];
|
||||
result.push({
|
||||
planId: p.id,
|
||||
planTitle: p.title,
|
||||
buckets: buckets.map((b) => ({ bucketId: b.id, bucketTitle: b.name })),
|
||||
});
|
||||
return enriched;
|
||||
}
|
||||
|
||||
/** Resolve owning M365 group's members. Falls back gracefully if owner missing. */
|
||||
private async fetchPlanMembers(
|
||||
planId: string,
|
||||
ownerGroupId?: string,
|
||||
): Promise<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;
|
||||
}
|
||||
return result;
|
||||
if (!groupId) return [];
|
||||
|
||||
const resp = await this.graph
|
||||
.api(`/groups/${groupId}/members`)
|
||||
.select(["id", "displayName"])
|
||||
.get()
|
||||
.catch(() => ({ value: [] }));
|
||||
|
||||
type DirObj = { id?: string; displayName?: string; "@odata.type"?: string };
|
||||
const users: Array<{ id: string; displayName: string }> = (resp.value ?? [])
|
||||
.filter((m: DirObj) => !m["@odata.type"] || m["@odata.type"] === "#microsoft.graph.user")
|
||||
.filter((m: DirObj): m is { id: string; displayName: string } =>
|
||||
typeof m.id === "string" && typeof m.displayName === "string" && m.displayName.length > 0,
|
||||
);
|
||||
return users;
|
||||
}
|
||||
|
||||
/** Recent (non-completed) tasks across the user's plans, capped. */
|
||||
async listRecentTasks(limit = 20): Promise<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`;
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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일 때 필수" },
|
||||
|
||||
@ -2,41 +2,102 @@ import { ClassifierInput } from "./types";
|
||||
|
||||
export const SYSTEM_PROMPT = `당신은 사용자의 한국어 자연어 작업 보고를 Microsoft Planner 액션 하나로 변환하는 분류기입니다.
|
||||
|
||||
규칙:
|
||||
- 사용자의 발화가 "새 작업"인지, "기존 작업 진행상황 업데이트"인지, "불명확해서 되물어야 하는지" 판단합니다.
|
||||
- create_task 를 선택할 때는 반드시 제공된 plans 중 하나의 planId 와 그 plan 의 bucketId 를 선택해야 합니다. 임의 ID를 만들면 안 됩니다.
|
||||
- update_task 를 선택할 때는 반드시 recentTasks 중 하나의 taskId 를 선택해야 합니다.
|
||||
- **ID 값은 반드시 컨텍스트에 주어진 문자열을 한 글자도 빼거나 더하지 말고 그대로 복사해야 합니다.** 콤마, 공백, 따옴표 어떤 것도 추가하지 마세요.
|
||||
- 발화에 어떤 작업을 가리키는지 명확한 단서가 없으면(예: "그거 했어", "다 끝났어"처럼 작업명을 특정하지 못함) 반드시 ask_clarification 으로 어떤 작업인지 되물어보세요. 임의로 가장 최근 작업을 가정하지 마세요.
|
||||
- plan 또는 task 후보가 둘 이상이라 확신할 수 없으면 ask_clarification 으로 짧고 구체적인 질문을 돌려주세요.
|
||||
- 진행률 표현은 다음으로 매핑:
|
||||
- "시작", "할 거야", "착수" → progress: "notStarted", percentComplete: 0
|
||||
- "하는 중", "진행 중", "%" 언급 → progress: "inProgress", percentComplete: 그 값(없으면 50)
|
||||
- "끝", "완료", "다 했어" → progress: "completed", percentComplete: 100
|
||||
- 날짜는 nowIso 를 기준으로 ISO-8601 (YYYY-MM-DD) 로 변환. "내일/모레/다음주 X요일" 같은 표현을 처리하세요.
|
||||
- 액션의 핵심 한 가지만 출력합니다. 부가 설명, 코멘트, 마크다운은 출력하지 마세요. 반드시 도구 호출로만 응답하세요.`;
|
||||
## 액션 종류
|
||||
- create_task: 새 작업을 만든다.
|
||||
- update_task: 기존 작업을 수정한다 (진행률/상태/할당/라벨/버킷 이동 등).
|
||||
- ask_clarification: 어떤 작업/계획/사람/라벨을 가리키는지 확신할 수 없을 때 짧고 구체적인 한국어 질문을 돌려준다.
|
||||
|
||||
## 공통 규칙
|
||||
- 반드시 도구 호출(함수 호출)로만 응답하세요. 부가 설명, 코멘트, 마크다운은 출력하지 마세요.
|
||||
- create_task 의 planId/bucketId, update_task 의 taskId, assigneeUserIds 의 각 항목 — **모든 ID 값은 컨텍스트에 주어진 문자열을 한 글자도 빼거나 더하지 말고 그대로 복사**하세요. 콤마/공백/따옴표를 덧붙이지 마세요.
|
||||
- 컨텍스트에 없는 ID 를 만들지 마세요. 적절한 후보가 없으면 ask_clarification 으로 되묻습니다.
|
||||
- 발화에서 작업을 가리키는 단서가 모호하면(예: "그거 했어", "다 끝났어") 반드시 ask_clarification 을 사용하세요. 임의로 가장 최근 작업을 가정하지 마세요.
|
||||
|
||||
## 진행/상태 매핑 (progress, percentComplete)
|
||||
- "시작/할 거야/착수" → progress: "notStarted", percentComplete: 0
|
||||
- "하는 중/진행 중/% 언급" → progress: "inProgress", percentComplete: 그 값(없으면 50)
|
||||
- "끝/완료/다 했어" → progress: "completed", percentComplete: 100
|
||||
|
||||
## 우선순위 (priority) — Planner의 priority 필드
|
||||
- "긴급/ASAP/급해/지금 당장" → priority: "urgent"
|
||||
- "중요/중요해/우선순위 높음" → priority: "important"
|
||||
- "보통/normal" → priority: "medium"
|
||||
- "낮음/나중에" → priority: "low"
|
||||
- 발화에 단서가 없으면 priority 를 채우지 마세요(Planner 기본값 medium 유지).
|
||||
|
||||
## 날짜 (startDate, dueDate)
|
||||
- 둘 다 YYYY-MM-DD 형식.
|
||||
- nowIso 를 기준으로 "내일/모레/다음주 X요일/이번 주 금요일/N일 후" 같은 표현을 절대 날짜로 변환.
|
||||
- "내일부터 금요일까지" 처럼 시작과 마감이 모두 명시되면 startDate 와 dueDate 둘 다 채웁니다.
|
||||
|
||||
## 체크리스트 (checklistItems / addChecklistItems)
|
||||
- "체크리스트로 A, B, C", "할 일 1. X 2. Y 3. Z", "확인사항: ...", "준비물: ..." 같은 단서가 있으면 각 항목을 배열의 원소로 분리하세요.
|
||||
- 새 작업이면 checklistItems, 기존 작업에 덧붙이는 거면 addChecklistItems 를 사용.
|
||||
- 최대 20개. 그보다 많으면 앞 20개만.
|
||||
|
||||
## 할당 (assigneeUserIds)
|
||||
- 반드시 컨텍스트의 plan members 목록 안에서 displayName 부분 매칭으로 사용자를 찾고, 그 userId 를 채웁니다.
|
||||
- "나/내가/제가" → members 중 isMe=true 항목의 userId.
|
||||
- 발화에 이름이 있는데 members 에 일치하는 사람이 없으면 ask_clarification.
|
||||
- update_task 에서는 assigneeUserIds 가 전체 교체로 동작합니다. "X 도 추가" 처럼 누적 의도가 분명하면 기존 assigneeIds + 새 사람을 모두 채워서 넣으세요.
|
||||
|
||||
## 라벨 (inferredLabels vs explicitLabels)
|
||||
- **inferredLabels**: 사용자가 직접 라벨을 언급하지 않았지만 발화 의미가 plan 의 기존 라벨 중 하나와 명확히 매칭될 때만 채웁니다. 자신 없으면 비워두세요. plan 에 없는 이름은 절대 넣지 마세요.
|
||||
- **explicitLabels**: 사용자가 "#버그", "버그 라벨로", "label: 보안" 같이 명시적으로 라벨 이름을 말했을 때 그 문자열을 그대로 넣습니다. plan 에 그 라벨이 없어도 그대로 넣으세요(봇이 "새로 등록할까요" 흐름을 띄웁니다).
|
||||
|
||||
## 버킷 이동 (update_task.newBucketId)
|
||||
- 사용자가 "X 버킷으로 옮겨", "검토 중 버킷으로" 같이 명시적으로 말하면 해당 plan 의 buckets 중 매칭되는 bucketId 를 newBucketId 에 채웁니다.
|
||||
- 또는 상태 변경 표현이 명확히 다른 버킷으로의 이동을 시사하면(예: "이제 완료 됐어" → "Done" 버킷이 있을 때) 같이 채워주세요. 단, 다른 plan 으로의 이동은 금지.
|
||||
|
||||
## 출력 형식
|
||||
- 단 한 개의 액션만 출력. 부가 설명 없이 도구 호출만 사용.`;
|
||||
|
||||
export function renderUserMessage(input: ClassifierInput): string {
|
||||
const plansBlock = input.plans
|
||||
.map(
|
||||
(p) =>
|
||||
.map((p) => {
|
||||
const bucketLines = p.buckets
|
||||
.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`)
|
||||
.join("\n");
|
||||
const memberLines = p.members.length
|
||||
? p.members
|
||||
.map(
|
||||
(m) =>
|
||||
` member: userId=${m.userId} | "${m.displayName}"${m.isMe ? " (나)" : ""}`,
|
||||
)
|
||||
.join("\n")
|
||||
: ' member: (없음)';
|
||||
const labelLines = p.labels.length
|
||||
? p.labels
|
||||
.map((l) => ` label: slot=${l.slot} | "${l.name}"`)
|
||||
.join("\n")
|
||||
: ' label: (등록된 라벨 없음)';
|
||||
return (
|
||||
`- planId=${p.planId} | "${p.planTitle}"\n` +
|
||||
p.buckets.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`).join("\n"),
|
||||
)
|
||||
bucketLines +
|
||||
"\n" +
|
||||
memberLines +
|
||||
"\n" +
|
||||
labelLines
|
||||
);
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const tasksBlock = input.recentTasks.length
|
||||
? input.recentTasks
|
||||
.map(
|
||||
(t) =>
|
||||
`- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%`,
|
||||
)
|
||||
.map((t) => {
|
||||
const extras: string[] = [];
|
||||
if (t.priority) extras.push(`priority=${t.priority}`);
|
||||
if (t.assigneeIds && t.assigneeIds.length)
|
||||
extras.push(`assigneeIds=[${t.assigneeIds.join(",")}]`);
|
||||
const tail = extras.length ? ` | ${extras.join(" | ")}` : "";
|
||||
return `- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%${tail}`;
|
||||
})
|
||||
.join("\n")
|
||||
: "(최근 작업 없음)";
|
||||
|
||||
return `현재 시각(ISO): ${input.nowIso}
|
||||
|
||||
[사용 가능한 Plans / Buckets]
|
||||
[사용 가능한 Plans / Buckets / Members / Labels]
|
||||
${plansBlock}
|
||||
|
||||
[최근 작업(업데이트 후보)]
|
||||
@ -49,6 +110,7 @@ ${input.utterance}`;
|
||||
/**
|
||||
* JSON Schema describing the ClassifiedAction discriminated union.
|
||||
* Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool).
|
||||
* Gemini uses a parallel Type.*-based schema; keep them in sync.
|
||||
*/
|
||||
export const ACTION_TOOL_SCHEMA = {
|
||||
name: "submit_planner_action",
|
||||
@ -69,8 +131,39 @@ export const ACTION_TOOL_SCHEMA = {
|
||||
enum: ["notStarted", "inProgress", "completed"],
|
||||
},
|
||||
percentComplete: { type: "integer", minimum: 0, maximum: 100 },
|
||||
priority: {
|
||||
type: "string",
|
||||
enum: ["urgent", "important", "medium", "low"],
|
||||
},
|
||||
startDate: { type: "string", description: "YYYY-MM-DD" },
|
||||
dueDate: { type: "string", description: "YYYY-MM-DD" },
|
||||
checklistItems: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "create_task 의 새 체크리스트 (최대 20개)",
|
||||
},
|
||||
addChecklistItems: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "update_task 에서 기존 체크리스트 뒤에 덧붙일 항목",
|
||||
},
|
||||
assigneeUserIds: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "plan members 의 userId 만 허용",
|
||||
},
|
||||
inferredLabels: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "plan 의 기존 라벨 이름만 (LLM 추론)",
|
||||
},
|
||||
explicitLabels: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "사용자가 명시한 라벨 이름 (plan 에 없을 수도 있음)",
|
||||
},
|
||||
taskId: { type: "string", description: "update_task일 때 필수" },
|
||||
newBucketId: { type: "string", description: "같은 plan 안의 bucketId" },
|
||||
appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" },
|
||||
newTitle: { type: "string" },
|
||||
question: { type: "string", description: "ask_clarification일 때 필수" },
|
||||
|
||||
@ -6,11 +6,30 @@ export interface PlanContext {
|
||||
planId: string;
|
||||
planTitle: string;
|
||||
buckets: { bucketId: string; bucketTitle: string }[];
|
||||
/** M365 group members backing this plan — the only valid assignee pool. */
|
||||
members: PlanMember[];
|
||||
/** Plan-level label slots already in use (categoryDescriptions non-null). */
|
||||
labels: PlanLabel[];
|
||||
}
|
||||
|
||||
export interface PlanMember {
|
||||
userId: string; // AAD object id
|
||||
displayName: string;
|
||||
/** true if this entry is the signed-in user — lets LLM resolve "나"/"내가". */
|
||||
isMe?: true;
|
||||
}
|
||||
|
||||
export interface PlanLabel {
|
||||
/** 1–25. Maps to `category${slot}` on plannerTask.appliedCategories. */
|
||||
slot: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A recent task we surface to the LLM so it can resolve an "update X" intent
|
||||
* to a specific taskId without us having to fuzzy-match later.
|
||||
* to a specific taskId without us having to fuzzy-match later. Includes
|
||||
* current-state fields so the LLM (and the preview card) can diff intent
|
||||
* against the current task.
|
||||
*/
|
||||
export interface RecentTaskContext {
|
||||
taskId: string;
|
||||
@ -18,9 +37,12 @@ export interface RecentTaskContext {
|
||||
planId: string;
|
||||
bucketId: string;
|
||||
percentComplete: number;
|
||||
priority?: Priority;
|
||||
assigneeIds?: string[];
|
||||
}
|
||||
|
||||
export type Progress = "notStarted" | "inProgress" | "completed";
|
||||
export type Priority = "urgent" | "important" | "medium" | "low";
|
||||
|
||||
export type ClassifiedAction =
|
||||
| {
|
||||
@ -30,16 +52,33 @@ export type ClassifiedAction =
|
||||
title: string;
|
||||
description?: string;
|
||||
progress?: Progress;
|
||||
dueDate?: string; // ISO 8601 date
|
||||
priority?: Priority;
|
||||
startDate?: string; // YYYY-MM-DD
|
||||
dueDate?: string; // YYYY-MM-DD
|
||||
checklistItems?: string[];
|
||||
assigneeUserIds?: string[];
|
||||
/** Labels the LLM inferred from the utterance — silently dropped if not on the plan. */
|
||||
inferredLabels?: string[];
|
||||
/** Labels the user explicitly mentioned — may not exist on the plan;
|
||||
* if missing, the bot asks whether to create them. */
|
||||
explicitLabels?: string[];
|
||||
}
|
||||
| {
|
||||
type: "update_task";
|
||||
taskId: string;
|
||||
newBucketId?: string;
|
||||
progress?: Progress;
|
||||
percentComplete?: number; // 0–100
|
||||
appendNote?: string;
|
||||
newTitle?: string;
|
||||
dueDate?: string;
|
||||
startDate?: string;
|
||||
priority?: Priority;
|
||||
addChecklistItems?: string[];
|
||||
/** Full replacement of assignees. */
|
||||
assigneeUserIds?: string[];
|
||||
inferredLabels?: string[];
|
||||
explicitLabels?: string[];
|
||||
}
|
||||
| {
|
||||
type: "ask_clarification";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user