initial: teams planner bot with gemini classifier and docker deploy
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 14s
All checks were successful
Build and Deploy Teams Planner Bot / build-and-run (push) Successful in 14s
This commit is contained in:
commit
fd504738eb
16
.dockerignore
Normal file
16
.dockerignore
Normal file
@ -0,0 +1,16 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.example
|
||||
.git
|
||||
.gitea
|
||||
.idea
|
||||
.vscode
|
||||
*.log
|
||||
README.md
|
||||
appPackage
|
||||
scripts
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.gitignore
|
||||
36
.env.example
Normal file
36
.env.example
Normal file
@ -0,0 +1,36 @@
|
||||
# ---- Bot Framework / Azure Bot ----
|
||||
# From "Azure Bot" resource → Configuration
|
||||
MICROSOFT_APP_ID=
|
||||
MICROSOFT_APP_PASSWORD=
|
||||
MICROSOFT_APP_TYPE=MultiTenant
|
||||
MICROSOFT_APP_TENANT_ID=
|
||||
|
||||
# Local server port
|
||||
PORT=3978
|
||||
|
||||
# OAuth Connection name configured on the Azure Bot resource → "Configuration → OAuth Connection Settings"
|
||||
OAUTH_CONNECTION_NAME=GraphConnection
|
||||
|
||||
# ---- Microsoft Graph (Planner access) ----
|
||||
# Azure AD app registration that has delegated Tasks.ReadWrite / Group.Read.All
|
||||
GRAPH_TENANT_ID=
|
||||
GRAPH_CLIENT_ID=
|
||||
GRAPH_CLIENT_SECRET=
|
||||
|
||||
# ---- LLM selection ----
|
||||
# "claude" | "azure-openai" | "gemini"
|
||||
LLM_PROVIDER=claude
|
||||
|
||||
# Google Gemini (if LLM_PROVIDER=gemini)
|
||||
GEMINI_API_KEY=
|
||||
GEMINI_MODEL=gemini-2.0-flash
|
||||
|
||||
# Anthropic Claude
|
||||
ANTHROPIC_API_KEY=
|
||||
CLAUDE_MODEL=claude-opus-4-7
|
||||
|
||||
# Azure OpenAI (only if LLM_PROVIDER=azure-openai)
|
||||
AZURE_OPENAI_ENDPOINT=
|
||||
AZURE_OPENAI_API_KEY=
|
||||
AZURE_OPENAI_DEPLOYMENT=
|
||||
AZURE_OPENAI_API_VERSION=2024-08-01-preview
|
||||
54
.gitea/workflows/build.yaml
Normal file
54
.gitea/workflows/build.yaml
Normal file
@ -0,0 +1,54 @@
|
||||
name: Build and Deploy Teams Planner Bot
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
build-and-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Extract project name
|
||||
shell: bash
|
||||
run: echo "PROJECT_NAME=$(echo '${{ github.repository }}' | awk -F '/' '{print $2}')" >> $GITHUB_ENV
|
||||
|
||||
- name: Create environment file
|
||||
run: |
|
||||
echo "MICROSOFT_APP_ID=${{ secrets.MICROSOFT_APP_ID }}" > .env
|
||||
echo "MICROSOFT_APP_PASSWORD=${{ secrets.MICROSOFT_APP_PASSWORD }}" >> .env
|
||||
echo "MICROSOFT_APP_TYPE=SingleTenant" >> .env
|
||||
echo "MICROSOFT_APP_TENANT_ID=${{ secrets.MICROSOFT_APP_TENANT_ID }}" >> .env
|
||||
echo "PORT=3978" >> .env
|
||||
echo "OAUTH_CONNECTION_NAME=GraphConnection" >> .env
|
||||
echo "GRAPH_TENANT_ID=${{ secrets.MICROSOFT_APP_TENANT_ID }}" >> .env
|
||||
echo "GRAPH_CLIENT_ID=${{ secrets.MICROSOFT_APP_ID }}" >> .env
|
||||
echo "GRAPH_CLIENT_SECRET=${{ secrets.MICROSOFT_APP_PASSWORD }}" >> .env
|
||||
echo "LLM_PROVIDER=gemini" >> .env
|
||||
echo "GEMINI_API_KEY=${{ secrets.GEMINI_API_KEY }}" >> .env
|
||||
echo "GEMINI_MODEL=gemini-3.1-flash-lite" >> .env
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Build Docker image
|
||||
run: docker build -t ${{ env.PROJECT_NAME }}:latest .
|
||||
|
||||
- name: Stop and remove existing container
|
||||
run: |
|
||||
docker stop ${{ env.PROJECT_NAME }} || true
|
||||
docker rm ${{ env.PROJECT_NAME }} || true
|
||||
|
||||
- name: Run Docker container
|
||||
run: |
|
||||
docker run -d -p 3978:3978 \
|
||||
--env-file .env \
|
||||
--name ${{ env.PROJECT_NAME }} \
|
||||
--network npm_default \
|
||||
--restart unless-stopped \
|
||||
${{ env.PROJECT_NAME }}:latest
|
||||
|
||||
- name: Clean up environment file
|
||||
run: rm -f .env
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@ -0,0 +1,16 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
COPY --from=build /app/dist ./dist
|
||||
EXPOSE 3978
|
||||
CMD ["node", "dist/index.js"]
|
||||
111
README.md
Normal file
111
README.md
Normal file
@ -0,0 +1,111 @@
|
||||
# Teams Planner Bot
|
||||
|
||||
MS Teams 봇. 사용자의 자연어 작업 보고를 LLM으로 분류해서 Microsoft Planner에 자동으로 task를 생성하거나 업데이트한다.
|
||||
|
||||
## 동작 개요
|
||||
|
||||
```
|
||||
사용자 (Teams) → 봇 → OAuthPrompt 로그인 (최초 1회)
|
||||
→ Graph로 본인 Plans/Buckets/최근 Tasks 조회
|
||||
→ LLM(Claude 또는 Azure OpenAI)에게 발화 + 컨텍스트 전달
|
||||
→ tool/function call로 {action, planId, ...} JSON 강제 추출
|
||||
→ Planner Graph API로 create / patch
|
||||
→ Adaptive Card로 결과 응답
|
||||
```
|
||||
|
||||
## 구성
|
||||
|
||||
```
|
||||
src/
|
||||
index.ts # restify 서버, CloudAdapter
|
||||
config/index.ts # 환경변수 로딩/검증
|
||||
bot/PlannerBot.ts # 메시지 핸들러 + OAuth 다이얼로그 + 오케스트레이션
|
||||
graph/
|
||||
graphClientFactory.ts # 사용자 토큰을 받아 Graph Client 생성
|
||||
plannerClient.ts # Plans/Buckets/Tasks 조회·생성·업데이트 (ETag 처리 포함)
|
||||
llm/
|
||||
types.ts # ClassifiedAction 등 공용 타입
|
||||
prompt.ts # 시스템 프롬프트 + tool/function 스키마
|
||||
coerce.ts # LLM JSON → ClassifiedAction 검증
|
||||
claudeClassifier.ts # Anthropic Claude 구현 (tool_use 강제)
|
||||
azureOpenAiClassifier.ts # Azure OpenAI 구현 (function calling 강제)
|
||||
factory.ts # LLM_PROVIDER 기반 분기
|
||||
cards/confirmationCard.ts # 결과 Adaptive Card
|
||||
appPackage/manifest.json # Teams 앱 매니페스트 (개발자 포털에 업로드)
|
||||
```
|
||||
|
||||
## 사전 준비 (Azure / Microsoft 365)
|
||||
|
||||
1. **Azure AD 앱 등록** (Graph 위임 권한)
|
||||
- Azure Portal → App registrations → New
|
||||
- API permissions → Microsoft Graph → Delegated:
|
||||
- `Tasks.ReadWrite`
|
||||
- `Group.Read.All`
|
||||
- `User.Read`
|
||||
- `offline_access`
|
||||
- Grant admin consent
|
||||
- Certificates & secrets → Client secret 발급
|
||||
- 노트: `tenantId`, `clientId`, `clientSecret`
|
||||
|
||||
2. **Azure Bot 리소스**
|
||||
- Azure Portal → Create resource → Azure Bot (Multi Tenant)
|
||||
- Microsoft App ID/Password 발급 → 노트
|
||||
- **Configuration → OAuth Connection Settings → Add**
|
||||
- Name: `GraphConnection` (이 이름이 `.env`의 `OAUTH_CONNECTION_NAME`)
|
||||
- Service Provider: `Azure Active Directory v2`
|
||||
- Client id / secret: 1번에서 만든 값
|
||||
- Tenant ID: 1번의 tenantId
|
||||
- Token Exchange URL: 비워두기 (SSO 안 쓸 경우)
|
||||
- Scopes: `Tasks.ReadWrite Group.Read.All User.Read offline_access`
|
||||
- "Test Connection" 으로 토큰이 떨어지는지 확인
|
||||
|
||||
3. **Teams 앱 매니페스트**
|
||||
- `appPackage/manifest.json` 의 `REPLACE_WITH_YOUR_BOT_APP_ID` 를 Azure Bot의 App ID로 치환
|
||||
- `color.png` (192×192), `outline.png` (32×32) 아이콘을 `appPackage/`에 추가
|
||||
- 폴더를 zip 으로 압축 → Teams 관리자 센터(또는 개발자 포털)에서 사이드로딩
|
||||
|
||||
## 로컬 실행
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env
|
||||
# .env 채우기
|
||||
npm run dev
|
||||
```
|
||||
|
||||
다른 터미널에서 ngrok 등으로 외부 노출:
|
||||
|
||||
```bash
|
||||
ngrok http 3978
|
||||
```
|
||||
|
||||
ngrok URL을 Azure Bot의 **Messaging endpoint** 에 `https://<ngrok>/api/messages` 형태로 설정.
|
||||
|
||||
## LLM 교체
|
||||
|
||||
`.env` 의 `LLM_PROVIDER` 만 바꾸면 됨:
|
||||
|
||||
- `claude` → `ANTHROPIC_API_KEY`, `CLAUDE_MODEL` 사용
|
||||
- `azure-openai` → `AZURE_OPENAI_*` 사용
|
||||
|
||||
두 구현 모두 동일한 `LlmClassifier` 인터페이스를 구현하므로 호출부 변경 없음.
|
||||
|
||||
## 알아두면 좋을 점 / 함정
|
||||
|
||||
- **Planner write는 ETag 필수.** `plannerClient.ts` 가 PATCH 직전 항상 GET으로 ETag를 받아 `If-Match`에 넣는다. 만약 동시 편집이 잦은 환경이면 412 에러를 retry 로 감싸야 한다.
|
||||
- **`/me/planner/plans` 는 사용자가 속한 그룹의 Plan 만 반환한다.** 봇이 "보이지 않는다" 면 Teams 채널에 그 사용자가 멤버인지부터 확인.
|
||||
- **OAuthPrompt 의 토큰은 Bot Framework 의 토큰 서비스(token.botframework.com)에 캐시된다.** `logout` 명령으로 강제 갱신 가능.
|
||||
- **MemoryStorage** 는 개발용. 프로덕션은 CosmosDB/Blob 백엔드로 교체.
|
||||
- **확장 아이디어**:
|
||||
- 다중 후보일 때 카드의 `Action.Submit` 으로 사용자가 직접 plan 선택
|
||||
- Plans/Buckets 캐시 (현재는 매 발화마다 다시 조회)
|
||||
- 일/주간 요약 발송 (proactive messaging)
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. `npm install` → 의존성 받기
|
||||
2. Azure AD 앱 등록 + Azure Bot 리소스 + OAuth Connection 설정
|
||||
3. `.env` 채우기
|
||||
4. `npm run dev` 로 띄우고 ngrok 으로 노출
|
||||
5. Teams 매니페스트 사이드로딩 → 개인 채팅으로 봇과 대화
|
||||
6. `"오늘 견적서 초안 작성 시작했어"` 같은 첫 발화로 테스트
|
||||
48
appPackage/manifest.json
Normal file
48
appPackage/manifest.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://developer.microsoft.com/en-us/json-schemas/teams/v1.16/MicrosoftTeams.schema.json",
|
||||
"manifestVersion": "1.16",
|
||||
"version": "0.1.0",
|
||||
"id": "REPLACE_WITH_YOUR_BOT_APP_ID",
|
||||
"packageName": "com.example.teamsplannerbot",
|
||||
"developer": {
|
||||
"name": "Internal",
|
||||
"websiteUrl": "https://example.com",
|
||||
"privacyUrl": "https://example.com/privacy",
|
||||
"termsOfUseUrl": "https://example.com/terms"
|
||||
},
|
||||
"icons": {
|
||||
"color": "color.png",
|
||||
"outline": "outline.png"
|
||||
},
|
||||
"name": {
|
||||
"short": "Planner Bot",
|
||||
"full": "Teams Planner Bot"
|
||||
},
|
||||
"description": {
|
||||
"short": "자연어로 Planner 작업을 만들고 업데이트합니다.",
|
||||
"full": "사용자가 평범한 말로 작업 진행 상황을 알려주면 적절한 Plan/Bucket에 작업을 생성하거나 업데이트하는 봇입니다."
|
||||
},
|
||||
"accentColor": "#4F6BED",
|
||||
"bots": [
|
||||
{
|
||||
"botId": "REPLACE_WITH_YOUR_BOT_APP_ID",
|
||||
"scopes": ["personal", "team", "groupchat"],
|
||||
"supportsFiles": false,
|
||||
"isNotificationOnly": false,
|
||||
"commandLists": [
|
||||
{
|
||||
"scopes": ["personal", "team", "groupchat"],
|
||||
"commands": [
|
||||
{ "title": "logout", "description": "Microsoft 계정 로그아웃" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"permissions": ["identity", "messageTeamMembers"],
|
||||
"validDomains": ["token.botframework.com"],
|
||||
"webApplicationInfo": {
|
||||
"id": "REPLACE_WITH_YOUR_BOT_APP_ID",
|
||||
"resource": "https://graph.microsoft.com/"
|
||||
}
|
||||
}
|
||||
4310
package-lock.json
generated
Normal file
4310
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
36
package.json
Normal file
36
package.json
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"name": "teams-planner-bot",
|
||||
"version": "0.1.0",
|
||||
"description": "MS Teams bot that classifies natural-language task updates and writes them to Microsoft Planner.",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc -p .",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint \"src/**/*.ts\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.40.1",
|
||||
"@azure/identity": "^4.5.0",
|
||||
"@azure/msal-node": "^2.16.2",
|
||||
"@google/genai": "^2.3.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"adaptivecards-templating": "^2.3.1",
|
||||
"botbuilder": "^4.23.1",
|
||||
"botbuilder-dialogs": "^4.23.1",
|
||||
"dotenv": "^16.4.5",
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"openai": "^4.77.0",
|
||||
"restify": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/restify": "^8.5.12",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
64
scripts/smokeTestClassifier.ts
Normal file
64
scripts/smokeTestClassifier.ts
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Run with: npx ts-node scripts/smokeTestClassifier.ts
|
||||
* Verifies the LLM classifier (selected by LLM_PROVIDER) returns valid
|
||||
* ClassifiedAction objects against canned plan/task fixtures.
|
||||
*/
|
||||
import { createClassifier } from "../src/llm/factory";
|
||||
import { ClassifierInput } from "../src/llm/types";
|
||||
|
||||
const fixtures: { name: string; utterance: string }[] = [
|
||||
{ name: "create — 새 작업 시작", utterance: "오늘 견적서 초안 작성 시작했어" },
|
||||
{ name: "update — 진행률 80%", utterance: "API 통합 작업 80%까지 진행했어" },
|
||||
{ name: "update — 완료", utterance: "디자인 검토 다 끝냈어" },
|
||||
{ name: "ambiguous — 되묻기 기대", utterance: "그거 했어" },
|
||||
];
|
||||
|
||||
const baseInput: Omit<ClassifierInput, "utterance"> = {
|
||||
nowIso: new Date().toISOString(),
|
||||
plans: [
|
||||
{
|
||||
planId: "plan-eng-001",
|
||||
planTitle: "엔지니어링",
|
||||
buckets: [
|
||||
{ bucketId: "bkt-eng-todo", bucketTitle: "할 일" },
|
||||
{ bucketId: "bkt-eng-doing", bucketTitle: "진행 중" },
|
||||
],
|
||||
},
|
||||
{
|
||||
planId: "plan-sales-001",
|
||||
planTitle: "영업",
|
||||
buckets: [{ bucketId: "bkt-sales-todo", bucketTitle: "견적/제안" }],
|
||||
},
|
||||
],
|
||||
recentTasks: [
|
||||
{
|
||||
taskId: "task-api-1",
|
||||
title: "API 통합 작업",
|
||||
planId: "plan-eng-001",
|
||||
bucketId: "bkt-eng-doing",
|
||||
percentComplete: 50,
|
||||
},
|
||||
{
|
||||
taskId: "task-design-1",
|
||||
title: "디자인 검토",
|
||||
planId: "plan-eng-001",
|
||||
bucketId: "bkt-eng-doing",
|
||||
percentComplete: 70,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const classifier = createClassifier();
|
||||
for (const f of fixtures) {
|
||||
process.stdout.write(`\n— ${f.name}\n 발화: "${f.utterance}"\n`);
|
||||
try {
|
||||
const result = await classifier.classify({ ...baseInput, utterance: f.utterance });
|
||||
console.log(" 결과:", JSON.stringify(result, null, 2).replace(/\n/g, "\n "));
|
||||
} catch (err) {
|
||||
console.log(" ❌ ERROR:", (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
193
src/bot/PlannerBot.ts
Normal file
193
src/bot/PlannerBot.ts
Normal file
@ -0,0 +1,193 @@
|
||||
import {
|
||||
ActivityHandler,
|
||||
CardFactory,
|
||||
ConversationState,
|
||||
StatePropertyAccessor,
|
||||
TurnContext,
|
||||
UserState,
|
||||
} from "botbuilder";
|
||||
import { OAuthPrompt, DialogSet, DialogTurnStatus, WaterfallDialog } from "botbuilder-dialogs";
|
||||
import { createGraphClient } from "../graph/graphClientFactory";
|
||||
import { PlannerClient } from "../graph/plannerClient";
|
||||
import { LlmClassifier } from "../llm/types";
|
||||
import { buildResultCard } from "../cards/confirmationCard";
|
||||
|
||||
const OAUTH_PROMPT = "graphOAuthPrompt";
|
||||
const MAIN_DIALOG = "mainDialog";
|
||||
|
||||
export interface PlannerBotDeps {
|
||||
conversationState: ConversationState;
|
||||
userState: UserState;
|
||||
classifier: LlmClassifier;
|
||||
connectionName: string; // Azure Bot OAuth connection name
|
||||
}
|
||||
|
||||
export class PlannerBot extends ActivityHandler {
|
||||
private readonly conversationState: ConversationState;
|
||||
private readonly userState: UserState;
|
||||
private readonly classifier: LlmClassifier;
|
||||
private readonly dialogs: DialogSet;
|
||||
private readonly dialogStateAccessor: StatePropertyAccessor;
|
||||
|
||||
constructor(deps: PlannerBotDeps) {
|
||||
super();
|
||||
this.conversationState = deps.conversationState;
|
||||
this.userState = deps.userState;
|
||||
this.classifier = deps.classifier;
|
||||
|
||||
this.dialogStateAccessor = this.conversationState.createProperty("DialogState");
|
||||
this.dialogs = new DialogSet(this.dialogStateAccessor);
|
||||
|
||||
this.dialogs.add(
|
||||
new OAuthPrompt(OAUTH_PROMPT, {
|
||||
connectionName: deps.connectionName,
|
||||
text: "Microsoft 계정으로 로그인해 주세요. (Planner 접근 권한)",
|
||||
title: "로그인",
|
||||
timeout: 300_000,
|
||||
}),
|
||||
);
|
||||
|
||||
this.dialogs.add(
|
||||
new WaterfallDialog(MAIN_DIALOG, [
|
||||
async (step) => step.beginDialog(OAUTH_PROMPT),
|
||||
async (step) => {
|
||||
const tokenResponse = step.result as { token?: string } | undefined;
|
||||
if (!tokenResponse?.token) {
|
||||
await step.context.sendActivity("로그인이 완료되지 않았어요. 다시 시도해 주세요.");
|
||||
return step.endDialog();
|
||||
}
|
||||
const utterance: string = (step.options as { utterance: string }).utterance;
|
||||
await this.handleUtterance(step.context, tokenResponse.token, utterance);
|
||||
return step.endDialog();
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
this.onMessage(async (context, next) => {
|
||||
const text = (context.activity.text ?? "").trim();
|
||||
|
||||
if (text.toLowerCase() === "logout") {
|
||||
const adapter = context.adapter as unknown as {
|
||||
signOutUser: (ctx: TurnContext, connName: string) => Promise<void>;
|
||||
};
|
||||
await adapter.signOutUser(context, deps.connectionName);
|
||||
await context.sendActivity("로그아웃 완료.");
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
const dialogContext = await this.dialogs.createContext(context);
|
||||
const result = await dialogContext.continueDialog();
|
||||
if (result.status === DialogTurnStatus.empty) {
|
||||
await dialogContext.beginDialog(MAIN_DIALOG, { utterance: text });
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
this.onMembersAdded(async (context, next) => {
|
||||
const greeting =
|
||||
"안녕하세요! 작업 진행 상황을 평소 말로 알려주시면 Planner에 자동으로 정리해 드릴게요.\n" +
|
||||
"예) “오늘 견적서 초안 작성 시작했어”, “API 통합 작업 80%까지 진행했어”";
|
||||
for (const m of context.activity.membersAdded ?? []) {
|
||||
if (m.id !== context.activity.recipient.id) {
|
||||
await context.sendActivity(greeting);
|
||||
}
|
||||
}
|
||||
await next();
|
||||
});
|
||||
}
|
||||
|
||||
async run(context: TurnContext): Promise<void> {
|
||||
await super.run(context);
|
||||
await this.conversationState.saveChanges(context, false);
|
||||
await this.userState.saveChanges(context, false);
|
||||
}
|
||||
|
||||
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;
|
||||
try {
|
||||
[plans, recentTasks] = await Promise.all([
|
||||
planner.listPlansWithBuckets(),
|
||||
planner.listRecentTasks(),
|
||||
]);
|
||||
} catch (err) {
|
||||
await context.sendActivity(
|
||||
`Planner 데이터를 불러오지 못했어요. 권한 동의가 끝났는지 확인해주세요. (${(err as Error).message})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (plans.length === 0) {
|
||||
await context.sendActivity("접근 가능한 Planner Plan이 없어요. Teams 채널에 Plan을 먼저 추가해 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
const action = await this.classifier.classify({
|
||||
utterance,
|
||||
plans,
|
||||
recentTasks,
|
||||
nowIso: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
if (action.type === "create_task") {
|
||||
const { taskId } = await planner.createTask(action);
|
||||
const plan = plans.find((p) => p.planId === action.planId);
|
||||
const bucket = plan?.buckets.find((b) => b.bucketId === action.bucketId);
|
||||
await context.sendActivity({
|
||||
attachments: [
|
||||
buildResultCard({
|
||||
action,
|
||||
planTitle: plan?.planTitle,
|
||||
bucketTitle: bucket?.bucketTitle,
|
||||
status: "done",
|
||||
}),
|
||||
],
|
||||
});
|
||||
void taskId;
|
||||
} else if (action.type === "update_task") {
|
||||
await planner.updateTask(action);
|
||||
if (action.appendNote) {
|
||||
await planner.appendTaskNote(action.taskId, action.appendNote);
|
||||
}
|
||||
const task = recentTasks.find((t) => t.taskId === action.taskId);
|
||||
await context.sendActivity({
|
||||
attachments: [
|
||||
buildResultCard({
|
||||
action,
|
||||
taskTitle: task?.title,
|
||||
status: "done",
|
||||
}),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
await context.sendActivity({
|
||||
attachments: [buildResultCard({ action, status: "ask" })],
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
await context.sendActivity({
|
||||
attachments: [
|
||||
buildResultCard({
|
||||
action,
|
||||
status: "error",
|
||||
errorMessage: (err as Error).message,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/cards/confirmationCard.ts
Normal file
58
src/cards/confirmationCard.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import { CardFactory, Attachment } from "botbuilder";
|
||||
import { ClassifiedAction } from "../llm/types";
|
||||
|
||||
export function buildResultCard(args: {
|
||||
action: ClassifiedAction;
|
||||
planTitle?: string;
|
||||
bucketTitle?: string;
|
||||
taskTitle?: string;
|
||||
status: "done" | "ask" | "error";
|
||||
errorMessage?: string;
|
||||
}): Attachment {
|
||||
const lines: string[] = [];
|
||||
const a = args.action;
|
||||
|
||||
if (args.status === "error") {
|
||||
lines.push(`❌ 처리 중 오류: ${args.errorMessage ?? "알 수 없음"}`);
|
||||
} else if (a.type === "create_task") {
|
||||
lines.push(`✅ 새 작업 생성`);
|
||||
lines.push(`Plan: ${args.planTitle ?? a.planId}`);
|
||||
lines.push(`Bucket: ${args.bucketTitle ?? a.bucketId}`);
|
||||
lines.push(`제목: ${a.title}`);
|
||||
if (a.dueDate) lines.push(`마감: ${a.dueDate}`);
|
||||
if (a.progress) lines.push(`진행: ${a.progress}`);
|
||||
} else if (a.type === "update_task") {
|
||||
lines.push(`🔄 작업 업데이트`);
|
||||
lines.push(`대상: ${args.taskTitle ?? a.taskId}`);
|
||||
if (a.progress) lines.push(`진행: ${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 {
|
||||
lines.push(`❓ 한 번 더 확인이 필요해요`);
|
||||
lines.push(a.question);
|
||||
}
|
||||
|
||||
const card = {
|
||||
type: "AdaptiveCard",
|
||||
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
|
||||
version: "1.4",
|
||||
body: [
|
||||
{
|
||||
type: "TextBlock",
|
||||
text: lines[0],
|
||||
weight: "Bolder",
|
||||
size: "Medium",
|
||||
wrap: true,
|
||||
},
|
||||
...lines.slice(1).map((t) => ({
|
||||
type: "TextBlock",
|
||||
text: t,
|
||||
wrap: true,
|
||||
spacing: "Small",
|
||||
})),
|
||||
],
|
||||
};
|
||||
|
||||
return CardFactory.adaptiveCard(card);
|
||||
}
|
||||
49
src/config/index.ts
Normal file
49
src/config/index.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import * as dotenv from "dotenv";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
function required(name: string): string {
|
||||
const v = process.env[name];
|
||||
if (!v) throw new Error(`Missing required env var: ${name}`);
|
||||
return v;
|
||||
}
|
||||
|
||||
function optional(name: string, fallback = ""): string {
|
||||
return process.env[name] ?? fallback;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT ?? 3978),
|
||||
|
||||
bot: {
|
||||
appId: required("MICROSOFT_APP_ID"),
|
||||
appPassword: required("MICROSOFT_APP_PASSWORD"),
|
||||
appType: optional("MICROSOFT_APP_TYPE", "MultiTenant"),
|
||||
tenantId: optional("MICROSOFT_APP_TENANT_ID"),
|
||||
oauthConnectionName: optional("OAUTH_CONNECTION_NAME", "GraphConnection"),
|
||||
},
|
||||
|
||||
graph: {
|
||||
tenantId: required("GRAPH_TENANT_ID"),
|
||||
clientId: required("GRAPH_CLIENT_ID"),
|
||||
clientSecret: required("GRAPH_CLIENT_SECRET"),
|
||||
},
|
||||
|
||||
llm: {
|
||||
provider: (optional("LLM_PROVIDER", "claude") as "claude" | "azure-openai" | "gemini"),
|
||||
anthropic: {
|
||||
apiKey: optional("ANTHROPIC_API_KEY"),
|
||||
model: optional("CLAUDE_MODEL", "claude-opus-4-7"),
|
||||
},
|
||||
azureOpenAI: {
|
||||
endpoint: optional("AZURE_OPENAI_ENDPOINT"),
|
||||
apiKey: optional("AZURE_OPENAI_API_KEY"),
|
||||
deployment: optional("AZURE_OPENAI_DEPLOYMENT"),
|
||||
apiVersion: optional("AZURE_OPENAI_API_VERSION", "2024-08-01-preview"),
|
||||
},
|
||||
gemini: {
|
||||
apiKey: optional("GEMINI_API_KEY"),
|
||||
model: optional("GEMINI_MODEL", "gemini-3.1-flash-lite"),
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
21
src/graph/graphClientFactory.ts
Normal file
21
src/graph/graphClientFactory.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import "isomorphic-fetch";
|
||||
import { Client, AuthenticationProvider } from "@microsoft/microsoft-graph-client";
|
||||
|
||||
/**
|
||||
* AuthenticationProvider that returns a fixed access token.
|
||||
* Token is acquired by the bot via OAuthPrompt (Bot Framework token service)
|
||||
* and passed in here per-request.
|
||||
*/
|
||||
class StaticTokenProvider implements AuthenticationProvider {
|
||||
constructor(private readonly token: string) {}
|
||||
async getAccessToken(): Promise<string> {
|
||||
return this.token;
|
||||
}
|
||||
}
|
||||
|
||||
export function createGraphClient(userAccessToken: string): Client {
|
||||
return Client.initWithMiddleware({
|
||||
authProvider: new StaticTokenProvider(userAccessToken),
|
||||
defaultVersion: "v1.0",
|
||||
});
|
||||
}
|
||||
149
src/graph/plannerClient.ts
Normal file
149
src/graph/plannerClient.ts
Normal file
@ -0,0 +1,149 @@
|
||||
import { Client } from "@microsoft/microsoft-graph-client";
|
||||
import { PlanContext, Progress, RecentTaskContext } from "../llm/types";
|
||||
|
||||
/**
|
||||
* Thin wrapper around the Graph Planner endpoints we actually use.
|
||||
*
|
||||
* IMPORTANT: Planner write endpoints require the resource's @odata.etag in
|
||||
* an If-Match header. We always re-fetch immediately before PATCH/DELETE
|
||||
* to grab a fresh ETag.
|
||||
*/
|
||||
export class PlannerClient {
|
||||
constructor(private readonly graph: Client) {}
|
||||
|
||||
/** All plans the signed-in user can see, with their buckets pre-fetched. */
|
||||
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 ?? [];
|
||||
|
||||
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 result;
|
||||
}
|
||||
|
||||
/** 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<{
|
||||
id: string;
|
||||
title: string;
|
||||
planId: string;
|
||||
bucketId: string;
|
||||
percentComplete: number;
|
||||
createdDateTime: string;
|
||||
}> = resp.value ?? [];
|
||||
|
||||
return tasks
|
||||
.filter((t) => t.percentComplete < 100)
|
||||
.sort((a, b) => (a.createdDateTime < b.createdDateTime ? 1 : -1))
|
||||
.slice(0, limit)
|
||||
.map((t) => ({
|
||||
taskId: t.id,
|
||||
title: t.title,
|
||||
planId: t.planId,
|
||||
bucketId: t.bucketId,
|
||||
percentComplete: t.percentComplete,
|
||||
}));
|
||||
}
|
||||
|
||||
async createTask(args: {
|
||||
planId: string;
|
||||
bucketId: string;
|
||||
title: string;
|
||||
progress?: Progress;
|
||||
dueDate?: string;
|
||||
description?: string;
|
||||
}): Promise<{ taskId: string }> {
|
||||
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);
|
||||
}
|
||||
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) {
|
||||
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 });
|
||||
}
|
||||
|
||||
return { taskId };
|
||||
}
|
||||
|
||||
async updateTask(args: {
|
||||
taskId: string;
|
||||
progress?: Progress;
|
||||
percentComplete?: number;
|
||||
newTitle?: string;
|
||||
dueDate?: string;
|
||||
}): 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);
|
||||
if (pct !== undefined) patch.percentComplete = pct;
|
||||
if (args.newTitle) patch.title = args.newTitle;
|
||||
if (args.dueDate) patch.dueDateTime = toIsoEod(args.dueDate);
|
||||
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
|
||||
await this.graph
|
||||
.api(`/planner/tasks/${args.taskId}`)
|
||||
.header("If-Match", etag)
|
||||
.patch(patch);
|
||||
}
|
||||
|
||||
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"];
|
||||
const previous: string = details.description ?? "";
|
||||
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
||||
const combined = previous
|
||||
? `${previous}\n[${stamp}] ${note}`
|
||||
: `[${stamp}] ${note}`;
|
||||
await this.graph
|
||||
.api(`/planner/tasks/${taskId}/details`)
|
||||
.header("If-Match", etag)
|
||||
.patch({ description: combined });
|
||||
}
|
||||
}
|
||||
|
||||
function progressToPercent(p?: Progress): number | undefined {
|
||||
switch (p) {
|
||||
case "notStarted":
|
||||
return 0;
|
||||
case "inProgress":
|
||||
return 50;
|
||||
case "completed":
|
||||
return 100;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function toIsoEod(yyyyMmDd: string): string {
|
||||
// Planner expects an ISO datetime with TZ. We default to end-of-day UTC.
|
||||
return `${yyyyMmDd}T23:59:59Z`;
|
||||
}
|
||||
69
src/index.ts
Normal file
69
src/index.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import * as restify from "restify";
|
||||
import {
|
||||
CloudAdapter,
|
||||
ConfigurationServiceClientCredentialFactory,
|
||||
ConfigurationBotFrameworkAuthentication,
|
||||
ConversationState,
|
||||
MemoryStorage,
|
||||
UserState,
|
||||
} from "botbuilder";
|
||||
|
||||
import { config } from "./config";
|
||||
import { PlannerBot } from "./bot/PlannerBot";
|
||||
import { createClassifier } from "./llm/factory";
|
||||
|
||||
// --- Bot Framework auth wiring ---------------------------------------------
|
||||
const credentialsFactory = new ConfigurationServiceClientCredentialFactory({
|
||||
MicrosoftAppId: config.bot.appId,
|
||||
MicrosoftAppPassword: config.bot.appPassword,
|
||||
MicrosoftAppType: config.bot.appType,
|
||||
MicrosoftAppTenantId: config.bot.tenantId || undefined,
|
||||
});
|
||||
|
||||
const botFrameworkAuthentication = new ConfigurationBotFrameworkAuthentication(
|
||||
{},
|
||||
credentialsFactory,
|
||||
);
|
||||
|
||||
const adapter = new CloudAdapter(botFrameworkAuthentication);
|
||||
|
||||
adapter.onTurnError = async (context, error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[onTurnError]", error);
|
||||
await context.sendActivity(
|
||||
`봇 처리 중 오류가 발생했어요: ${(error as Error).message}`,
|
||||
);
|
||||
};
|
||||
|
||||
// --- State + bot ----------------------------------------------------------
|
||||
// MemoryStorage is fine for local dev. For production switch to Cosmos/Blob.
|
||||
const storage = new MemoryStorage();
|
||||
const conversationState = new ConversationState(storage);
|
||||
const userState = new UserState(storage);
|
||||
|
||||
const bot = new PlannerBot({
|
||||
conversationState,
|
||||
userState,
|
||||
classifier: createClassifier(),
|
||||
connectionName: config.bot.oauthConnectionName,
|
||||
});
|
||||
|
||||
// --- HTTP server ----------------------------------------------------------
|
||||
const server = restify.createServer({ name: "teams-planner-bot" });
|
||||
server.use(restify.plugins.bodyParser());
|
||||
|
||||
server.post("/api/messages", async (req, res) => {
|
||||
await adapter.process(req, res, (context) => bot.run(context));
|
||||
});
|
||||
|
||||
server.get("/healthz", (_req, res, next) => {
|
||||
res.send(200, { ok: true });
|
||||
return next();
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ teams-planner-bot listening on http://localhost:${config.port}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(` POST /api/messages (Teams endpoint)`);
|
||||
});
|
||||
56
src/llm/azureOpenAiClassifier.ts
Normal file
56
src/llm/azureOpenAiClassifier.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { AzureOpenAI } from "openai";
|
||||
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
|
||||
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types";
|
||||
import { coerceAction } from "./coerce";
|
||||
|
||||
export class AzureOpenAIClassifier implements LlmClassifier {
|
||||
private readonly client: AzureOpenAI;
|
||||
private readonly deployment: string;
|
||||
|
||||
constructor(opts: {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
deployment: string;
|
||||
apiVersion: string;
|
||||
}) {
|
||||
this.client = new AzureOpenAI({
|
||||
endpoint: opts.endpoint,
|
||||
apiKey: opts.apiKey,
|
||||
apiVersion: opts.apiVersion,
|
||||
deployment: opts.deployment,
|
||||
});
|
||||
this.deployment = opts.deployment;
|
||||
}
|
||||
|
||||
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
|
||||
const response = await this.client.chat.completions.create({
|
||||
model: this.deployment,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: renderUserMessage(input) },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: ACTION_TOOL_SCHEMA.name,
|
||||
description: ACTION_TOOL_SCHEMA.description,
|
||||
parameters: ACTION_TOOL_SCHEMA.parameters,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: {
|
||||
type: "function",
|
||||
function: { name: ACTION_TOOL_SCHEMA.name },
|
||||
},
|
||||
});
|
||||
|
||||
const call = response.choices[0]?.message?.tool_calls?.[0];
|
||||
if (!call || call.type !== "function") {
|
||||
throw new Error("Azure OpenAI did not return a tool call");
|
||||
}
|
||||
|
||||
return coerceAction(JSON.parse(call.function.arguments));
|
||||
}
|
||||
}
|
||||
38
src/llm/claudeClassifier.ts
Normal file
38
src/llm/claudeClassifier.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { ACTION_TOOL_SCHEMA, SYSTEM_PROMPT, renderUserMessage } from "./prompt";
|
||||
import { ClassifiedAction, ClassifierInput, LlmClassifier } from "./types";
|
||||
import { coerceAction } from "./coerce";
|
||||
|
||||
export class ClaudeClassifier implements LlmClassifier {
|
||||
private readonly client: Anthropic;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(opts: { apiKey: string; model: string }) {
|
||||
this.client = new Anthropic({ apiKey: opts.apiKey });
|
||||
this.model = opts.model;
|
||||
}
|
||||
|
||||
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
|
||||
const response = await this.client.messages.create({
|
||||
model: this.model,
|
||||
max_tokens: 1024,
|
||||
system: SYSTEM_PROMPT,
|
||||
tools: [
|
||||
{
|
||||
name: ACTION_TOOL_SCHEMA.name,
|
||||
description: ACTION_TOOL_SCHEMA.description,
|
||||
input_schema: ACTION_TOOL_SCHEMA.parameters as Anthropic.Tool.InputSchema,
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: ACTION_TOOL_SCHEMA.name },
|
||||
messages: [{ role: "user", content: renderUserMessage(input) }],
|
||||
});
|
||||
|
||||
const toolUse = response.content.find((b) => b.type === "tool_use");
|
||||
if (!toolUse || toolUse.type !== "tool_use") {
|
||||
throw new Error("Claude did not return a tool_use block");
|
||||
}
|
||||
|
||||
return coerceAction(toolUse.input);
|
||||
}
|
||||
}
|
||||
71
src/llm/coerce.ts
Normal file
71
src/llm/coerce.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import { ClassifiedAction, Progress } from "./types";
|
||||
|
||||
/**
|
||||
* Validates and narrows the raw JSON returned by the LLM into a ClassifiedAction.
|
||||
* We treat the LLM's output as untrusted — the schema guards are belt-and-suspenders
|
||||
* on top of the tool/function-call constraint.
|
||||
*/
|
||||
export function coerceAction(raw: unknown): ClassifiedAction {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new Error("LLM action payload is not an object");
|
||||
}
|
||||
const o = raw as Record<string, unknown>;
|
||||
const type = o.type;
|
||||
|
||||
if (type === "create_task") {
|
||||
if (typeof o.planId !== "string" || typeof o.bucketId !== "string" || typeof o.title !== "string") {
|
||||
throw new Error("create_task requires planId, bucketId, title");
|
||||
}
|
||||
return {
|
||||
type: "create_task",
|
||||
planId: sanitizeId(o.planId),
|
||||
bucketId: sanitizeId(o.bucketId),
|
||||
title: o.title,
|
||||
description: stringOrUndef(o.description),
|
||||
progress: progressOrUndef(o.progress),
|
||||
dueDate: stringOrUndef(o.dueDate),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "update_task") {
|
||||
if (typeof o.taskId !== "string") {
|
||||
throw new Error("update_task requires taskId");
|
||||
}
|
||||
return {
|
||||
type: "update_task",
|
||||
taskId: sanitizeId(o.taskId),
|
||||
progress: progressOrUndef(o.progress),
|
||||
percentComplete: numberOrUndef(o.percentComplete),
|
||||
appendNote: stringOrUndef(o.appendNote),
|
||||
newTitle: stringOrUndef(o.newTitle),
|
||||
dueDate: stringOrUndef(o.dueDate),
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "ask_clarification") {
|
||||
if (typeof o.question !== "string") {
|
||||
throw new Error("ask_clarification requires question");
|
||||
}
|
||||
return { type: "ask_clarification", question: o.question };
|
||||
}
|
||||
|
||||
throw new Error(`Unknown action type: ${String(type)}`);
|
||||
}
|
||||
|
||||
function stringOrUndef(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.length > 0 ? v : undefined;
|
||||
}
|
||||
function numberOrUndef(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
||||
}
|
||||
function progressOrUndef(v: unknown): Progress | undefined {
|
||||
return v === "notStarted" || v === "inProgress" || v === "completed" ? v : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip surrounding whitespace, quotes, and trailing punctuation (commas/periods)
|
||||
* that LLMs occasionally add when copying IDs from context.
|
||||
*/
|
||||
function sanitizeId(s: string): string {
|
||||
return s.trim().replace(/^["'`]+|["'`]+$/g, "").replace(/[,.\s]+$/, "");
|
||||
}
|
||||
30
src/llm/factory.ts
Normal file
30
src/llm/factory.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { config } from "../config";
|
||||
import { AzureOpenAIClassifier } from "./azureOpenAiClassifier";
|
||||
import { ClaudeClassifier } from "./claudeClassifier";
|
||||
import { GeminiClassifier } from "./geminiClassifier";
|
||||
import { LlmClassifier } from "./types";
|
||||
|
||||
export function createClassifier(): LlmClassifier {
|
||||
if (config.llm.provider === "azure-openai") {
|
||||
const { endpoint, apiKey, deployment, apiVersion } = config.llm.azureOpenAI;
|
||||
if (!endpoint || !apiKey || !deployment) {
|
||||
throw new Error("Azure OpenAI 설정이 누락되었습니다. .env 확인하세요.");
|
||||
}
|
||||
return new AzureOpenAIClassifier({ endpoint, apiKey, deployment, apiVersion });
|
||||
}
|
||||
|
||||
if (config.llm.provider === "gemini") {
|
||||
const { apiKey, model } = config.llm.gemini;
|
||||
if (!apiKey) {
|
||||
throw new Error("GEMINI_API_KEY 가 설정되지 않았습니다.");
|
||||
}
|
||||
return new GeminiClassifier({ apiKey, model });
|
||||
}
|
||||
|
||||
// default: claude
|
||||
const { apiKey, model } = config.llm.anthropic;
|
||||
if (!apiKey) {
|
||||
throw new Error("ANTHROPIC_API_KEY 가 설정되지 않았습니다.");
|
||||
}
|
||||
return new ClaudeClassifier({ apiKey, model });
|
||||
}
|
||||
81
src/llm/geminiClassifier.ts
Normal file
81
src/llm/geminiClassifier.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import {FunctionCallingConfigMode, GoogleGenAI, ThinkingLevel, Type} from "@google/genai";
|
||||
import {ACTION_TOOL_SCHEMA, renderUserMessage, SYSTEM_PROMPT} from "./prompt";
|
||||
import {ClassifiedAction, ClassifierInput, LlmClassifier} from "./types";
|
||||
import {coerceAction} from "./coerce";
|
||||
|
||||
/**
|
||||
* Gemini's function-declaration schema uses Type.* enums instead of JSON-Schema strings.
|
||||
* We map our portable schema onto it here.
|
||||
*/
|
||||
const GEMINI_SCHEMA = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
type: {
|
||||
type: Type.STRING,
|
||||
enum: ["create_task", "update_task", "ask_clarification"],
|
||||
},
|
||||
planId: { type: Type.STRING, description: "create_task일 때 필수" },
|
||||
bucketId: { type: Type.STRING, description: "create_task일 때 필수" },
|
||||
title: { type: Type.STRING },
|
||||
description: { type: Type.STRING },
|
||||
progress: {
|
||||
type: Type.STRING,
|
||||
enum: ["notStarted", "inProgress", "completed"],
|
||||
},
|
||||
percentComplete: { type: Type.INTEGER },
|
||||
dueDate: { type: Type.STRING, description: "YYYY-MM-DD" },
|
||||
taskId: { type: Type.STRING, description: "update_task일 때 필수" },
|
||||
appendNote: { type: Type.STRING },
|
||||
newTitle: { type: Type.STRING },
|
||||
question: { type: Type.STRING, description: "ask_clarification일 때 필수" },
|
||||
},
|
||||
required: ["type"],
|
||||
};
|
||||
|
||||
export class GeminiClassifier implements LlmClassifier {
|
||||
private readonly client: GoogleGenAI;
|
||||
private readonly model: string;
|
||||
|
||||
constructor(opts: { apiKey: string; model: string }) {
|
||||
this.client = new GoogleGenAI({ apiKey: opts.apiKey });
|
||||
this.model = opts.model;
|
||||
}
|
||||
|
||||
async classify(input: ClassifierInput): Promise<ClassifiedAction> {
|
||||
const response = await this.client.models.generateContent({
|
||||
model: this.model,
|
||||
contents: [{ role: "user", parts: [{ text: renderUserMessage(input) }] }],
|
||||
config: {
|
||||
systemInstruction: SYSTEM_PROMPT,
|
||||
temperature: 0,
|
||||
tools: [
|
||||
{
|
||||
functionDeclarations: [
|
||||
{
|
||||
name: ACTION_TOOL_SCHEMA.name,
|
||||
description: ACTION_TOOL_SCHEMA.description,
|
||||
parameters: GEMINI_SCHEMA,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
thinkingConfig: {
|
||||
thinkingLevel: ThinkingLevel.LOW
|
||||
},
|
||||
toolConfig: {
|
||||
functionCallingConfig: {
|
||||
mode: FunctionCallingConfigMode.ANY,
|
||||
allowedFunctionNames: [ACTION_TOOL_SCHEMA.name],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const call = response.functionCalls?.[0];
|
||||
if (!call || call.name !== ACTION_TOOL_SCHEMA.name || !call.args) {
|
||||
throw new Error("Gemini did not return a function call with args");
|
||||
}
|
||||
|
||||
return coerceAction(call.args);
|
||||
}
|
||||
}
|
||||
81
src/llm/prompt.ts
Normal file
81
src/llm/prompt.ts
Normal file
@ -0,0 +1,81 @@
|
||||
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요일" 같은 표현을 처리하세요.
|
||||
- 액션의 핵심 한 가지만 출력합니다. 부가 설명, 코멘트, 마크다운은 출력하지 마세요. 반드시 도구 호출로만 응답하세요.`;
|
||||
|
||||
export function renderUserMessage(input: ClassifierInput): string {
|
||||
const plansBlock = input.plans
|
||||
.map(
|
||||
(p) =>
|
||||
`- planId=${p.planId} | "${p.planTitle}"\n` +
|
||||
p.buckets.map((b) => ` bucket: bucketId=${b.bucketId} | "${b.bucketTitle}"`).join("\n"),
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
const tasksBlock = input.recentTasks.length
|
||||
? input.recentTasks
|
||||
.map(
|
||||
(t) =>
|
||||
`- taskId=${t.taskId} | "${t.title}" | plan=${t.planId} | bucket=${t.bucketId} | ${t.percentComplete}%`,
|
||||
)
|
||||
.join("\n")
|
||||
: "(최근 작업 없음)";
|
||||
|
||||
return `현재 시각(ISO): ${input.nowIso}
|
||||
|
||||
[사용 가능한 Plans / Buckets]
|
||||
${plansBlock}
|
||||
|
||||
[최근 작업(업데이트 후보)]
|
||||
${tasksBlock}
|
||||
|
||||
[사용자 발화]
|
||||
${input.utterance}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON Schema describing the ClassifiedAction discriminated union.
|
||||
* Used by both Claude (tool_use input_schema) and Azure OpenAI (function tool).
|
||||
*/
|
||||
export const ACTION_TOOL_SCHEMA = {
|
||||
name: "submit_planner_action",
|
||||
description: "Plannner에 적용할 단일 액션을 제출합니다.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: ["create_task", "update_task", "ask_clarification"],
|
||||
},
|
||||
planId: { type: "string", description: "create_task일 때 필수" },
|
||||
bucketId: { type: "string", description: "create_task일 때 필수" },
|
||||
title: { type: "string" },
|
||||
description: { type: "string" },
|
||||
progress: {
|
||||
type: "string",
|
||||
enum: ["notStarted", "inProgress", "completed"],
|
||||
},
|
||||
percentComplete: { type: "integer", minimum: 0, maximum: 100 },
|
||||
dueDate: { type: "string", description: "YYYY-MM-DD" },
|
||||
taskId: { type: "string", description: "update_task일 때 필수" },
|
||||
appendNote: { type: "string", description: "기존 노트 뒤에 덧붙일 진행 메모" },
|
||||
newTitle: { type: "string" },
|
||||
question: { type: "string", description: "ask_clarification일 때 필수" },
|
||||
},
|
||||
required: ["type"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
} as const;
|
||||
59
src/llm/types.ts
Normal file
59
src/llm/types.ts
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Compact view of a Planner plan/bucket that we feed to the LLM as context.
|
||||
* Names matter most — the LLM matches user intent to these strings.
|
||||
*/
|
||||
export interface PlanContext {
|
||||
planId: string;
|
||||
planTitle: string;
|
||||
buckets: { bucketId: string; bucketTitle: 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.
|
||||
*/
|
||||
export interface RecentTaskContext {
|
||||
taskId: string;
|
||||
title: string;
|
||||
planId: string;
|
||||
bucketId: string;
|
||||
percentComplete: number;
|
||||
}
|
||||
|
||||
export type Progress = "notStarted" | "inProgress" | "completed";
|
||||
|
||||
export type ClassifiedAction =
|
||||
| {
|
||||
type: "create_task";
|
||||
planId: string;
|
||||
bucketId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
progress?: Progress;
|
||||
dueDate?: string; // ISO 8601 date
|
||||
}
|
||||
| {
|
||||
type: "update_task";
|
||||
taskId: string;
|
||||
progress?: Progress;
|
||||
percentComplete?: number; // 0–100
|
||||
appendNote?: string;
|
||||
newTitle?: string;
|
||||
dueDate?: string;
|
||||
}
|
||||
| {
|
||||
type: "ask_clarification";
|
||||
question: string;
|
||||
};
|
||||
|
||||
export interface ClassifierInput {
|
||||
utterance: string;
|
||||
plans: PlanContext[];
|
||||
recentTasks: RecentTaskContext[];
|
||||
/** ISO-8601 of "now" in the user's TZ, so the LLM can resolve "tomorrow" etc. */
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
export interface LlmClassifier {
|
||||
classify(input: ClassifierInput): Promise<ClassifiedAction>;
|
||||
}
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
"declaration": false
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user