From 70ea4218272d60d341c9699a8e3f7ef2cb30f7f3 Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 11 Jun 2026 16:22:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(meet):=20=ED=99=95=EC=8B=A0=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20=EB=93=B1=EB=A1=9D=20+=20/meet=20confirm?= =?UTF-8?q?=20+=20=EB=8D=B0=EC=9D=BC=EB=A6=AC=20=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=ED=95=91=20(v2.2.216)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 캘린더 등록 정책을 "확신 없으면 등록 대신 질문"으로 전환: - 액션 표에 상태 컬럼(확정/진행미정/기한미정/조건부:선행/반복:주기) — LLM 분류. - 확정+기한만 자동 등록. 진행미정·기한미정·조건부는 보류 목록으로 질문, `/meet confirm 1=6/20 2=ok 3=skip` 답변으로 등록 완결 (/meet pending 재확인). - 조건부 규칙: ok=날짜 없는 Tasks 로 [조건부] 등록(선행조건 노트 명시), 날짜=그날을 '조건 확인일'로 등록 — 의존 대상이 제목/노트에서 즉시 인지됨. - 반복 업무: 반복 등록 없이 첫 1회만(다음 해당 요일) — 까먹음 방지. - 기한 해석 불가 확정건: 구버전의 +5일 추측 등록 제거 → 보류 질문. - 과거 날짜(옛 녹취): 과거 날짜 그대로 등록 + "과거자료·완료확인 필요" 표기. - 중복 방지: 녹취 sha256 해시 레지스트리(.astra/meet_registered.json)로 같은 녹취 재실행 시 이중 등록 차단. - tasksApi: due 옵션화(날짜 없는 task 지원). 데일리 브리핑 (신규): - 평일 KST 09:30(설정 가능) 오늘의 캘린더 일정 + Tasks(오늘 마감/기한 경과/ 조건부 대기)를 텔레그램 발송. 텔레그램·캘린더 미연결 시 조용히 skip. - g1nation.dailyBriefing.enabled(기본 true) / .time("09:30"). 테스트: meetRegistration 15건 (분류 게이트·confirm 파싱·날짜 정규화·중복 키). Co-Authored-By: Claude Opus 4.8 --- package-lock.json | 4 +- package.json | 12 +- src/extension.ts | 5 + src/features/briefing/dailyBriefing.ts | 169 ++++++++++++ src/features/calendar/tasksApi.ts | 15 +- src/features/datacollect/handlers.ts | 242 ++++++++++++++--- .../datacollect/prompts/meetPrompt.ts | 11 +- .../datacollect/scheduling/calendarHelpers.ts | 16 +- .../scheduling/meetRegistration.ts | 253 ++++++++++++++++++ tests/meetRegistration.test.ts | 110 ++++++++ 10 files changed, 777 insertions(+), 60 deletions(-) create mode 100644 src/features/briefing/dailyBriefing.ts create mode 100644 src/features/datacollect/scheduling/meetRegistration.ts create mode 100644 tests/meetRegistration.test.ts diff --git a/package-lock.json b/package-lock.json index 4539ba7..fd31c37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "astra", - "version": "2.2.215", + "version": "2.2.216", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "astra", - "version": "2.2.215", + "version": "2.2.216", "license": "MIT", "dependencies": { "@lmstudio/sdk": "^1.5.0", diff --git a/package.json b/package.json index 10f981c..1ce7040 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "astra", "displayName": "Astra", "description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.", - "version": "2.2.215", + "version": "2.2.216", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", @@ -304,6 +304,16 @@ "default": false, "markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부. 결정 사항·액션 아이템을 녹취록(또는 추출 노트)과 LLM 으로 대조해, 근거를 못 찾는 항목을 `⚠️ 검증 결과` 섹션으로 표시한다 (날조 검출). LLM 호출이 1회 추가되어 그만큼 느려짐 — 중요한 회의에만 켜는 것을 권장." }, + "g1nation.dailyBriefing.enabled": { + "type": "boolean", + "default": true, + "markdownDescription": "**데일리 브리핑** — 평일(월~금) 지정 시각에 오늘의 캘린더 일정 + Google Tasks(오늘 마감·기한 경과·조건부 대기)를 텔레그램으로 발송. 텔레그램 봇 토큰·allowedChatIds·캘린더 연결이 돼 있어야 실제 발송되며, 미연결이면 조용히 건너뜀." + }, + "g1nation.dailyBriefing.time": { + "type": "string", + "default": "09:30", + "markdownDescription": "데일리 브리핑 발송 시각 (KST, `HH:MM`). 기본 `09:30`." + }, "g1nation.teamVoiceGuide": { "type": "string", "default": "", diff --git a/src/extension.ts b/src/extension.ts index 1506b3f..5562ab3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -39,6 +39,7 @@ import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitl import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup'; import { runInitialSetup } from './extension/initialSetup'; import { startStocksWatcher } from './features/stocks'; +import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing'; import { registerProviderCommands } from './extension/providerCommands'; import { registerScaffoldCommand } from './extension/scaffoldCommand'; import { registerLessonCommands } from './extension/lessonCommands'; @@ -307,6 +308,10 @@ export async function activate(context: vscode.ExtensionContext) { // disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup. context.subscriptions.push(startStocksWatcher(context)); + // Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송. + // 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만). + context.subscriptions.push(startDailyBriefingWatcher(context)); + // 7. Auto-open all three Astra webviews as tabs in editor column 3. // The sidebar/activity-bar entry point was removed in 2.81 — all three views // (Chat, Approvals, Settings) now stack as tabs in the third editor column. diff --git a/src/features/briefing/dailyBriefing.ts b/src/features/briefing/dailyBriefing.ts new file mode 100644 index 0000000..7adaf3f --- /dev/null +++ b/src/features/briefing/dailyBriefing.ts @@ -0,0 +1,169 @@ +/** + * 데일리 브리핑 워처 — 평일(월~금) KST 09:30 에 "오늘의 할 일"을 텔레그램으로 발송. + * + * 소스: + * 1. Google Calendar — iCal 캐시 새로고침 후 오늘 일정 (readCalendarEventsCache) + * 2. Google Tasks — 오늘 마감 + 기한 지난 미완료 + 날짜 없는 [조건부] task (listTasks) + * + * 발송 조건 (모두 충족 시에만): + * - g1nation.dailyBriefing.enabled (기본 true) + * - 텔레그램 봇 토큰 등록 + allowedChatIds 설정 — 미설정이면 조용히 skip (로그만) + * + * 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정). + */ +import * as vscode from 'vscode'; +import { logError, logInfo } from '../../utils'; +import { TelegramHttpClient } from '../../integrations/telegram/telegramClient'; +import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands'; +import { refreshCalendarCache, readCalendarEventsCache, readCalendarConfig } from '../calendar/calendarCache'; +import { listTasks } from '../calendar/tasksApi'; + +let _timer: NodeJS.Timeout | undefined; +let _disposed = false; +let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비) + +function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } { + const now = new Date(); + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Seoul', + year: 'numeric', month: '2-digit', day: '2-digit', + hour: '2-digit', minute: '2-digit', hour12: false, + }).formatToParts(now); + const get = (t: string) => parts.find(p => p.type === t)?.value || '00'; + const ymd = `${get('year')}-${get('month')}-${get('day')}`; + // 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일 (0=일). + const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay(); + return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday }; +} + +function briefingTime(): { hour: number; minute: number } { + const raw = (vscode.workspace.getConfiguration('g1nation').get('dailyBriefing.time', '09:30') || '09:30').trim(); + const m = raw.match(/^(\d{1,2}):(\d{2})$/); + if (!m) return { hour: 9, minute: 30 }; + return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) }; +} + +/** 다음 발송까지 ms — 평일 스킵 포함. */ +function msUntilNextFire(): number { + const { hour, minute, ymd, weekday } = nowInKst(); + const target = briefingTime(); + const nowMin = hour * 60 + minute; + const targetMin = target.hour * 60 + target.minute; + + // 오늘이 평일이고 아직 시간 전이면 오늘. + if (weekday >= 1 && weekday <= 5 && targetMin > nowMin && _lastFiredYmd !== ymd) { + return (targetMin - nowMin) * 60_000; + } + // 다음 평일 찾기 (내일부터 최대 7일). + let daysAhead = 1; + let wd = weekday; + for (; daysAhead <= 7; daysAhead++) { + wd = (weekday + daysAhead) % 7; + if (wd >= 1 && wd <= 5) break; + } + return ((24 * 60 - nowMin) + (daysAhead - 1) * 24 * 60 + targetMin) * 60_000; +} + +function pickChatId(): number | null { + const allowed = vscode.workspace.getConfiguration('g1nation').get('telegram.allowedChatIds', []) || []; + return allowed.length > 0 ? allowed[0] : null; +} + +/** 오늘의 브리핑 텍스트 구성. 일정·할일이 모두 없으면 null (발송 skip). */ +export async function buildBriefingText(context: vscode.ExtensionContext, ymd: string): Promise { + const lines: string[] = [`📋 *오늘의 할 일* — ${ymd}`]; + + // (1) 캘린더 일정 — 캐시 새로고침은 best-effort (실패해도 기존 캐시 사용). + try { await refreshCalendarCache(context); } catch { /* 기존 캐시로 진행 */ } + const events = readCalendarEventsCache(context) + .filter(e => (e.startIso || '').slice(0, 10) === ymd) + .sort((a, b) => a.startIso.localeCompare(b.startIso)); + if (events.length) { + lines.push('', `🗓 *일정* (${events.length})`); + for (const e of events.slice(0, 15)) { + const time = e.allDay ? '종일' : e.startIso.slice(11, 16); + lines.push(`· ${time} — ${e.summary}${e.location ? ` @${e.location}` : ''}`); + } + } + + // (2) Google Tasks — 미완료 중: 오늘 마감 / 기한 경과 / 날짜 없는 조건부. + const tr = await listTasks(context, { showCompleted: false, maxResults: 100 }); + if (tr.ok) { + const open = tr.tasks.filter(t => t.status === 'needsAction'); + const dueToday = open.filter(t => t.due === ymd); + const overdue = open.filter(t => t.due && t.due < ymd); + const conditional = open.filter(t => !t.due && /^\[조건부\]/.test(t.title)); + if (dueToday.length) { + lines.push('', `✅ *오늘 마감 할 일* (${dueToday.length})`); + for (const t of dueToday.slice(0, 15)) lines.push(`· ${t.title}`); + } + if (overdue.length) { + lines.push('', `⏰ *기한 지난 미완료* (${overdue.length})`); + for (const t of overdue.slice(0, 10)) lines.push(`· ${t.due} — ${t.title}`); + } + if (conditional.length) { + lines.push('', `🔗 *조건부 대기* (${conditional.length})`); + for (const t of conditional.slice(0, 10)) lines.push(`· ${t.title}`); + } + if (!dueToday.length && !overdue.length && !events.length && !conditional.length) { + return null; // 알릴 것이 전혀 없음 — 발송 skip + } + } else if (!events.length) { + // Tasks 실패 + 일정도 없으면 보낼 내용 없음. + logInfo('Daily briefing: Tasks 조회 실패 + 일정 없음 — skip.', { error: tr.error }); + return null; + } else { + lines.push('', `⚠️ Tasks 조회 실패: ${tr.error}`); + } + + lines.push('', '— Astra 데일리 브리핑'); + return lines.join('\n'); +} + +async function fireOnce(context: vscode.ExtensionContext): Promise { + const { ymd } = nowInKst(); + if (_lastFiredYmd === ymd) return; + _lastFiredYmd = ymd; + + const chatId = pickChatId(); + if (chatId === null) { logInfo('Daily briefing skip: telegram.allowedChatIds 미설정.'); return; } + const token = (await context.secrets.get(TELEGRAM_TOKEN_SECRET_KEY)) || ''; + if (!token.trim()) { logInfo('Daily briefing skip: 텔레그램 봇 토큰 없음.'); return; } + const calCfg = readCalendarConfig(context); + if (!calCfg.icalUrl && !calCfg.refreshToken) { logInfo('Daily briefing skip: 캘린더 미연결.'); return; } + + try { + const text = await buildBriefingText(context, ymd); + if (!text) { logInfo('Daily briefing: 오늘 일정·할일 없음 — 발송 skip.'); return; } + const client = new TelegramHttpClient({ getToken: () => token }); + await client.sendMessage({ chatId, text, parseMode: 'Markdown' }); + logInfo('Daily briefing 발송 완료.', { chatId, chars: text.length }); + } catch (e: any) { + logError('Daily briefing 발송 실패.', { error: e?.message ?? String(e) }); + } +} + +function scheduleNext(context: vscode.ExtensionContext): void { + if (_disposed) return; + const ms = msUntilNextFire(); + logInfo('Daily briefing 다음 발송 예약.', { inMinutes: Math.round(ms / 60_000) }); + _timer = setTimeout(async () => { + const enabled = vscode.workspace.getConfiguration('g1nation').get('dailyBriefing.enabled', true); + if (enabled) { + try { await fireOnce(context); } catch (e: any) { + logError('Daily briefing fire 실패.', { error: e?.message ?? String(e) }); + } + } + scheduleNext(context); + }, ms); +} + +/** VS Code 시작 시 호출 — disposable 반환 (subscriptions 에 push). */ +export function startDailyBriefingWatcher(context: vscode.ExtensionContext): vscode.Disposable { + _disposed = false; + scheduleNext(context); + return new vscode.Disposable(() => { + _disposed = true; + if (_timer) { clearTimeout(_timer); _timer = undefined; } + }); +} diff --git a/src/features/calendar/tasksApi.ts b/src/features/calendar/tasksApi.ts index 8c8e837..3508118 100644 --- a/src/features/calendar/tasksApi.ts +++ b/src/features/calendar/tasksApi.ts @@ -16,8 +16,11 @@ const API_BASE = 'https://tasks.googleapis.com/tasks/v1'; export interface TaskInput { /** 작업 제목 (필수). */ title: string; - /** 마감일 'YYYY-MM-DD' — Google Tasks 는 시간 무시, 날짜만 사용. */ - due: string; + /** + * 마감일 'YYYY-MM-DD' — Google Tasks 는 시간 무시, 날짜만 사용. + * 생략 시 *날짜 없는* task 로 등록 (조건부 task: 선행 작업 완료 시 진행, D-Day 없음). + */ + due?: string; /** 메모 (옵션) — Tasks UI 에서 작업 본문 아래 노트로 표시. */ notes?: string; /** @@ -31,7 +34,7 @@ export interface CreatedTask { /** Google 이 발급한 task id. */ id: string; title: string; - due: string; + due?: string; /** Google Tasks API 의 self link (API 용). 사용자용 deep link 는 별도로 없음. */ selfLink?: string; } @@ -51,7 +54,7 @@ export async function createTask( input: TaskInput, ): Promise<{ ok: true; task: CreatedTask } | { ok: false; error: string }> { if (!input.title?.trim()) return { ok: false, error: 'title 비어 있음' }; - if (!/^\d{4}-\d{2}-\d{2}$/.test(input.due)) { + if (input.due !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(input.due)) { return { ok: false, error: `due 는 'YYYY-MM-DD' 형식이어야 함 (받은 값: ${input.due})` }; } @@ -63,8 +66,8 @@ export async function createTask( const body = { title: input.title.trim(), // Tasks API 의 `due` 는 RFC3339 timestamp 인데 시간 부분은 서버에서 무시되고 - // 날짜만 사용. UTC midnight 으로 보내는 게 표준 패턴. - due: `${input.due}T00:00:00.000Z`, + // 날짜만 사용. UTC midnight 으로 보내는 게 표준 패턴. due 생략 = 날짜 없는 task. + ...(input.due ? { due: `${input.due}T00:00:00.000Z` } : {}), ...(input.notes ? { notes: input.notes } : {}), }; diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index e3707be..adeb45c 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -26,6 +26,12 @@ import { import { buildWikifyPrompt } from './prompts/wikifyPrompt'; import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt'; import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar'; +import { + transcriptHash, taskKey, loadRegisteredKeys, markRegistered, + savePending, loadPending, clearPending, classifyAction, + registerAction, buildNotes, parseConfirmArgs, renderPendingQuestion, + type PendingItem, type PendingFile, +} from './scheduling/meetRegistration'; import { addBusinessDays, toYmd, @@ -502,6 +508,18 @@ async function runWikify(arg: string, view: Webview | undefined): Promise { const trimmed = arg.trim(); + // ── 서브커맨드: 보류 항목 확인/답변 (등록 게이트의 후속 흐름) ────────────── + if (/^pending\b/i.test(trimmed)) { + const pend = loadPending(); + chunk(view, pend && pend.items.length + ? renderPendingQuestion(pend) + : '\nℹ️ 등록 보류 중인 액션 아이템이 없습니다.\n'); + return true; + } + if (/^confirm\b/i.test(trimmed)) { + await runMeetConfirm(trimmed.replace(/^confirm\b/i, '').trim(), view, context); + return true; + } let filePath = ''; let metadata = ''; if (trimmed.startsWith('"')) { @@ -534,6 +552,8 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`); return true; } + // 중복 방지 키 — 동일 녹취 재실행 시 이미 등록된 액션을 건너뛰기 위한 해시 (원본 전체 기준). + const tHash = transcriptHash(transcript); // v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce). // 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간 // 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다. @@ -652,56 +672,79 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. if (!meetUsesTasks && !meetUsesCalendar) { chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`); } else { + // ── 확신 게이트 등록 (v2.2.216) ───────────────────────── + // 확정만 자동 등록. 진행미정/기한미정/조건부는 보류→질문→ + // `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만. + // 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단. const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + '); - chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`); - let tasksOk = 0; - let calendarOk = 0; - let tentativeCount = 0; + const registeredKeys = loadRegisteredKeys(tHash); + const holds: PendingItem[] = []; + let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0; + const newKeys: string[] = []; + + chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`); for (const task of tasks) { - const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); - if (tentative) tentativeCount++; - const evTitle = tentative ? `${task.work} (미확정)` : task.work; - const detailLine = task.detail?.trim() - ? task.detail.trim() - : '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)'; - const notes = [ - `■ 작업 상세`, - detailLine, - ``, - `■ 맥락`, - `· 회의록: ${meetTitle}`, - `· 담당: ${task.owner || '(미지정)'}`, - `· 기한: ${task.due?.trim() || '(미표기)'} → ${date}${tentative ? ' (미확정·자동 산정)' : ''}`, - ``, - `— Astra /meet 자동 등록`, - ].join('\n'); - - const successes: string[] = []; - const failures: string[] = []; - if (meetUsesTasks) { - const r = await createTask(context, { title: evTitle, due: date, notes }); - if (r.ok) { tasksOk++; successes.push('Tasks'); } - else { failures.push(`Tasks: ${r.error}`); } + const key = taskKey(task.work); + if (registeredKeys.has(key)) { + dupSkipped++; + chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`); + continue; } - if (meetUsesCalendar) { - const r = await createCalendarEvent(context, { - title: evTitle, start: date, allDay: true, description: notes, + const cls = classifyAction(task, meetingDate, today); + if (cls.route === 'hold') { + holds.push({ + idx: holds.length + 1, + owner: task.owner, work: task.work, detail: task.detail, due: task.due, + kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate, }); - if (r.ok) { calendarOk++; successes.push('Calendar'); } - else { failures.push(`Calendar: ${r.error}`); } + continue; } - - if (failures.length === 0) { - chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`); + // auto — 과거 날짜 가드: 옛 녹취면 과거 날짜 그대로 + 완료확인 표기. + const past = cls.pastNote; + if (past) pastCount++; + const evTitle = past ? `${task.work} (과거자료·완료확인 필요)` : task.work; + const extra: string[] = []; + if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.'); + if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`); + const notes = buildNotes({ + detail: task.detail, meetTitle, owner: task.owner, + dueRaw: task.due, dateLabel: cls.date, extra, + }); + const r = await registerAction(context, { + title: evTitle, date: cls.date, notes, + useTasks: meetUsesTasks, useCalendar: meetUsesCalendar, + }); + if (r.failures.length === 0) { + autoOk++; + newKeys.push(key); + chunk(view, ` · ✅ ${cls.date} — ${evTitle} (${r.successes.join(' + ')})\n`); } else { - chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`); - for (const f of failures) chunk(view, ` ⚠️ ${f}\n`); + autoFail++; + if (r.successes.length) newKeys.push(key); + chunk(view, ` · ${cls.date} — ${evTitle}${r.successes.length ? ` (✅ ${r.successes.join(' + ')})` : ''}\n`); + for (const f of r.failures) chunk(view, ` ⚠️ ${f}\n`); } } - const summary: string[] = []; - if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`); - if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`); - chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); + if (newKeys.length) markRegistered(tHash, meetTitle, newKeys); + + const summary: string[] = [`자동 등록 ${autoOk}건`]; + if (autoFail) summary.push(`실패 ${autoFail}건`); + if (dupSkipped) summary.push(`중복 건너뜀 ${dupSkipped}건`); + if (pastCount) summary.push(`과거자료 ${pastCount}건(완료확인 필요)`); + if (holds.length) summary.push(`보류 ${holds.length}건(확인 필요)`); + chunk(view, `\n결과 — ${summary.join(' · ')}\n`); + + if (holds.length) { + const pend: PendingFile = { + createdAt: new Date().toISOString(), + meetTitle, + meetingDateYmd: meetingDate.toISOString().slice(0, 10), + transcriptHash: tHash, + items: holds, + }; + savePending(pend); + chunk(view, renderPendingQuestion(pend) + '\n'); + } } } } @@ -712,6 +755,121 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. return true; } +/** + * `/meet confirm 1=6/20 2=ok 3=skip` — 보류된 액션 아이템에 대한 사용자 답변을 + * 받아 등록을 *완결*한다 (확신 게이트의 후속 흐름). + * - `날짜` → 그 날짜로 등록 (조건부면 '조건 확인일'로 등록) + * - `ok` → 일반 항목: 제안 날짜로 등록 · 조건부: 날짜 없는 Tasks 로 [조건부] 등록 + * - `skip` → 등록하지 않고 목록에서 제거 + * 처리된 항목은 pending 에서 제거되고, 등록 성공분은 레지스트리에 기록되어 + * 같은 녹취 재실행 시 중복 등록되지 않는다. + */ +async function runMeetConfirm(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise { + const pend = loadPending(); + if (!pend || !pend.items.length) { + chunk(view, '\nℹ️ 등록 보류 중인 액션 아이템이 없습니다. (`/meet <녹취파일>` 실행 후 보류가 생기면 사용)\n'); + return; + } + if (!context) { + chunk(view, '\n⚠️ 확장 컨텍스트를 사용할 수 없어 등록을 진행할 수 없습니다.\n'); + return; + } + if (!arg.trim()) { + chunk(view, renderPendingQuestion(pend) + '\n'); + return; + } + const calCfg = readCalendarConfig(context); + if (!calCfg.refreshToken) { + chunk(view, '\n⚠️ Google OAuth(쓰기)가 연결되지 않아 등록할 수 없습니다. (Astra Settings → Google 섹션)\n'); + return; + } + const gCfg = vscode.workspace.getConfiguration('g1nation'); + const useTasks = gCfg.get('meetUsesTasks', true); + const useCalendar = gCfg.get('meetUsesCalendar', true); + + const { decisions, errors } = parseConfirmArgs(arg, new Date().getFullYear()); + for (const e of errors) chunk(view, ` ⚠️ ${e}\n`); + if (!decisions.length) { + chunk(view, '\n해석 가능한 답변이 없습니다. 예: `/meet confirm 1=6/20 2=ok 3=skip`\n'); + return; + } + + let registered = 0, skipped = 0, failed = 0; + const doneIdx = new Set(); + const newKeys: string[] = []; + + for (const d of decisions) { + const item = pend.items.find(i => i.idx === d.idx); + if (!item) { chunk(view, ` ⚠️ ${d.idx}번 항목이 보류 목록에 없습니다.\n`); continue; } + + if (d.action === 'skip') { + skipped++; + doneIdx.add(item.idx); + chunk(view, ` · ⏭️ ${item.idx}. ${item.work} — 등록 안 함\n`); + continue; + } + + const isConditional = item.kind === 'conditional'; + let title: string; + let date: string | undefined; + const extra: string[] = []; + + if (isConditional) { + extra.push(`■ 선행 조건: ${item.condition}`); + if (d.action === 'ok') { + // 날짜 없는 Tasks 등록 — 조건 충족 시 사용자가 날짜를 부여. + title = `[조건부] ${item.work}`; + date = undefined; + extra.push('· 선행 조건 충족 후 진행 — 날짜 없는 task 로 등록됨 (충족 시 날짜 부여)'); + if (!useTasks) { + failed++; + chunk(view, ` · ⚠️ ${item.idx}. ${item.work} — 날짜 없는 등록은 Tasks 가 필요합니다 (meetUsesTasks 꺼짐). 확인일 날짜를 지정해주세요: \`${item.idx}=날짜\`\n`); + continue; + } + } else { + title = `[조건부 확인] ${item.work}`; + date = d.date!; + extra.push(`· ${date} 는 선행 조건 충족 여부를 점검하는 확인일입니다.`); + } + } else { + title = item.work; + date = d.action === 'ok' ? item.suggestedDate : d.date!; + } + + const notes = buildNotes({ + detail: item.detail, meetTitle: pend.meetTitle, owner: item.owner, + dueRaw: item.due, dateLabel: date || '(날짜 없음 — 조건부)', extra, + }); + const r = await registerAction(context, { + title, date, notes, + useTasks, useCalendar: isConditional && !date ? false : useCalendar, + }); + if (r.failures.length === 0) { + registered++; + doneIdx.add(item.idx); + newKeys.push(taskKey(item.work)); + chunk(view, ` · ✅ ${date || '(날짜 없음)'} — ${title} (${r.successes.join(' + ')})\n`); + } else { + failed++; + if (r.successes.length) { doneIdx.add(item.idx); newKeys.push(taskKey(item.work)); } + chunk(view, ` · ${date || '(날짜 없음)'} — ${title}${r.successes.length ? ` (✅ ${r.successes.join(' + ')})` : ''}\n`); + for (const f of r.failures) chunk(view, ` ⚠️ ${f}\n`); + } + } + + if (newKeys.length) markRegistered(pend.transcriptHash, pend.meetTitle, newKeys); + + // 처리된 항목 제거 — 남은 보류는 유지 후 안내. + const remaining = pend.items.filter(i => !doneIdx.has(i.idx)); + if (remaining.length === 0) { + clearPending(); + chunk(view, `\n✅ 보류 항목 처리 완료 — 등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''}\n`); + } else { + savePending({ ...pend, items: remaining }); + chunk(view, `\n등록 ${registered}건 · 건너뜀 ${skipped}건${failed ? ` · 실패 ${failed}건` : ''} — 아직 ${remaining.length}건이 보류 중입니다 (\`/meet pending\` 으로 확인)\n`); + } +} + // ─── 등록 ───────────────────────────────────────────────────────────────── // /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬 diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index 0a2e5f2..f7f3c69 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -91,11 +91,18 @@ const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조 ## 5. 액션 아이템 각 행은 반드시 녹취록 근거로 작성한다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것. -| 담당 | 작업 내용 | 작업 상세 | 기한 | -| --- | --- | --- | --- | +| 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 | +| --- | --- | --- | --- | --- | - **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지) - **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것. +- **상태**: 다음 중 정확히 하나로 분류한다 (캘린더 자동 등록 게이트가 이 값으로 분기하므로 형식 엄수): + - \`확정\` — 진행 합의가 명시적이고 기한도 언급됨. + - \`진행미정\` — 작업이 언급됐으나 실제로 진행할지 합의가 명확하지 않음. + - \`기한미정\` — 하기로 확정됐으나 완료일/D-Day 가 정해지지 않음. + - \`조건부: <선행작업>\` — 다른 작업·사건이 끝나야 진행 (선행작업을 짧게 명시. 예: \`조건부: 계약 체결 후\`). + - \`반복: <주기>\` — 정기 반복 업무 (예: \`반복: 매주 목요일\`). + 확신이 없으면 \`확정\`이 아니라 \`진행미정\`/\`기한미정\` 쪽으로 보수적으로 분류한다. 위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`; diff --git a/src/features/datacollect/scheduling/calendarHelpers.ts b/src/features/datacollect/scheduling/calendarHelpers.ts index ad58d98..694aeb5 100644 --- a/src/features/datacollect/scheduling/calendarHelpers.ts +++ b/src/features/datacollect/scheduling/calendarHelpers.ts @@ -72,11 +72,11 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { /** * 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. - * 4열 표(담당 | 작업 내용 | 작업 상세 | 기한)와 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 - * 모두 지원한다. 3열일 때 detail 은 빈 문자열. + * 5열 신표(담당 | 작업 내용 | 작업 상세 | 기한 | 상태) · 4열(상태 없음) · + * 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 모두 지원한다. 누락 컬럼은 빈 문자열. */ -export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string }[] { - const rows: { owner: string; work: string; detail: string; due: string }[] = []; +export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string; status: string }[] { + const rows: { owner: string; work: string; detail: string; due: string; status: string }[] = []; let inSection = false; for (const line of report.split('\n')) { if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; } @@ -87,10 +87,12 @@ export function parseActionItems(report: string): { owner: string; work: string; if (cells.length < 3) continue; if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선 if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더 - if (cells.length >= 4) { - rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3] }); + if (cells.length >= 5) { + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: cells[4] }); + } else if (cells.length === 4) { + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3], status: '' }); } else { - rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2] }); + rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2], status: '' }); } } return rows; diff --git a/src/features/datacollect/scheduling/meetRegistration.ts b/src/features/datacollect/scheduling/meetRegistration.ts new file mode 100644 index 0000000..1869d5f --- /dev/null +++ b/src/features/datacollect/scheduling/meetRegistration.ts @@ -0,0 +1,253 @@ +/** + * /meet 액션 아이템의 "확신 게이트" 등록 시스템. + * + * 정책 (사용자 정의): + * - 확정(합의+기한) → 자동 등록. + * - 진행미정 / 기한미정 / 조건부 → 등록 보류 + 사용자에게 질문, `/meet confirm` 답변으로 등록 완결. + * - 반복(예: 매주 목요일) → 반복 등록하지 않고 *첫 1회만* 등록 (까먹음 방지). + * - 과거 날짜(옛 녹취) → 과거 날짜 그대로 등록 + "과거자료·완료확인 필요" 표기. + * - 같은 녹취록 재실행 → 녹취 해시 레지스트리로 이중 등록 차단. + * + * 저장 (워크스페이스): + * - `.astra/meet_pending.json` — 보류 항목 (confirm 대기) + * - `.astra/meet_registered.json` — 녹취해시 → 등록된 작업 키 (idempotency) + * + * 조건부 task 규칙 (Google Calendar 에 의존성 개념이 없어 설계한 자체 규칙): + * - `N=ok` → Google Tasks 에 *날짜 없이* 등록. 제목 `[조건부] …`, 노트에 `■ 선행 조건` 명시. + * (날짜 없는 task 는 Tasks 목록에 상시 노출 — 조건 충족 시 사용자가 날짜 부여) + * - `N=날짜` → 그 날짜를 "조건 확인일"로 등록. 제목 `[조건부 확인] …` — 그날 선행 조건 + * 충족 여부를 점검하라는 리마인더. + * → 어느 쪽이든 의존 대상(선행작업)이 제목/노트에 명시되어 한눈에 인지된다. + */ +import * as crypto from 'crypto'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as vscode from 'vscode'; +import { createCalendarEvent } from '../../calendar/calendarApi'; +import { createTask } from '../../calendar/tasksApi'; +import { resolveTaskDate, toYmd, addBusinessDays } from './calendarHelpers'; +import { logInfo } from '../../../utils'; + +// ── 타입 ──────────────────────────────────────────────────────────────────── +export type ActionRow = { owner: string; work: string; detail: string; due: string; status: string }; + +export type HoldKind = 'undecided' | 'nodate' | 'conditional'; +export interface PendingItem { + idx: number; // 사용자 답변용 번호 (1-base) + owner: string; + work: string; + detail: string; + due: string; + kind: HoldKind; + condition?: string; // kind=conditional 의 선행작업 + suggestedDate: string; // ok 답변 시 사용할 제안 날짜 (YYYY-MM-DD) +} +export interface PendingFile { + createdAt: string; + meetTitle: string; + meetingDateYmd: string; + transcriptHash: string; + items: PendingItem[]; +} + +// ── 워크스페이스 파일 경로 ────────────────────────────────────────────────── +function wsFile(rel: string): string | null { + const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!ws) return null; + return path.join(ws, '.astra', rel); +} +function readJson(rel: string): T | null { + const f = wsFile(rel); + if (!f || !fs.existsSync(f)) return null; + try { return JSON.parse(fs.readFileSync(f, 'utf8')) as T; } catch { return null; } +} +function writeJson(rel: string, data: unknown): boolean { + const f = wsFile(rel); + if (!f) return false; + try { + fs.mkdirSync(path.dirname(f), { recursive: true }); + fs.writeFileSync(f, JSON.stringify(data, null, 2), 'utf8'); + return true; + } catch { return false; } +} + +// ── 중복 방지 레지스트리 ──────────────────────────────────────────────────── +const REGISTRY_REL = 'meet_registered.json'; +const PENDING_REL = 'meet_pending.json'; +type Registry = Record; + +export function transcriptHash(raw: string): string { + return crypto.createHash('sha256').update(raw, 'utf8').digest('hex').slice(0, 16); +} +export function taskKey(work: string): string { + return (work || '').normalize('NFC').toLowerCase().replace(/[\s\-_.,:;'"()[\]]/g, ''); +} +export function loadRegisteredKeys(hash: string): Set { + const reg = readJson(REGISTRY_REL) || {}; + return new Set(reg[hash]?.keys || []); +} +export function markRegistered(hash: string, meetTitle: string, keys: string[]): void { + const reg = readJson(REGISTRY_REL) || {}; + const cur = reg[hash] || { keys: [], meetTitle, at: new Date().toISOString() }; + const set = new Set(cur.keys); + keys.forEach(k => set.add(k)); + reg[hash] = { keys: [...set], meetTitle, at: new Date().toISOString() }; + writeJson(REGISTRY_REL, reg); +} + +// ── Pending 저장 ──────────────────────────────────────────────────────────── +export function savePending(p: PendingFile): boolean { return writeJson(PENDING_REL, p); } +export function loadPending(): PendingFile | null { return readJson(PENDING_REL); } +export function clearPending(): void { const f = wsFile(PENDING_REL); if (f && fs.existsSync(f)) try { fs.rmSync(f); } catch { /* noop */ } } + +// ── 상태 분류 ─────────────────────────────────────────────────────────────── +export type Classified = + | { route: 'auto'; date: string; pastNote: boolean; recurNote?: string } + | { route: 'hold'; kind: HoldKind; condition?: string; suggestedDate: string }; + +const WEEKDAYS = ['일', '월', '화', '수', '목', '금', '토']; +export function nextWeekday(from: Date, korDay: string): Date | null { + const target = WEEKDAYS.indexOf(korDay); + if (target < 0) return null; + const d = new Date(from); + const diff = (target - d.getDay() + 7) % 7 || 7; // 오늘이 그 요일이면 다음 주 + d.setDate(d.getDate() + diff); + return d; +} + +/** 액션 한 행을 등록 경로로 분류한다. */ +export function classifyAction(row: ActionRow, meetingDate: Date, today: Date): Classified { + const status = (row.status || '').trim(); + const suggested = () => { + const r = resolveTaskDate(row.due, meetingDate, today); + return r.tentative ? toYmd(addBusinessDays(today, 5)) : r.date; + }; + + if (/^진행\s*미정/.test(status)) { + return { route: 'hold', kind: 'undecided', suggestedDate: suggested() }; + } + if (/^기한\s*미정/.test(status)) { + return { route: 'hold', kind: 'nodate', suggestedDate: suggested() }; + } + const cond = status.match(/^조건부\s*[::]?\s*(.*)$/); + if (cond) { + return { route: 'hold', kind: 'conditional', condition: cond[1].trim() || '(선행작업 미표기 — 회의록 참조)', suggestedDate: suggested() }; + } + const recur = status.match(/^반복\s*[::]?\s*(.*)$/); + if (recur) { + // 반복은 첫 1회만 등록. 주기 텍스트에서 요일 추출 → 다음 해당 요일. + const cycle = recur[1].trim(); + const dayM = (cycle + ' ' + row.due).match(/([월화수목금토일])요일?/); + const first = dayM ? nextWeekday(today, dayM[1]) : null; + if (first) { + return { route: 'auto', date: toYmd(first), pastNote: false, recurNote: cycle || row.due }; + } + // 요일을 못 찾으면 기한미정으로 보류 (추측 등록 금지) + return { route: 'hold', kind: 'nodate', suggestedDate: suggested() }; + } + + // 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 기한미정 보류. + const r = resolveTaskDate(row.due, meetingDate, today); + if (r.tentative) { + return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) }; + } + const past = r.date < toYmd(today); + return { route: 'auto', date: r.date, pastNote: past }; +} + +// ── 공용 등록기 ───────────────────────────────────────────────────────────── +export interface RegisterOpts { + title: string; + date?: string; // 없으면 Tasks 에 날짜 없는 task (조건부 ok) + notes: string; + useTasks: boolean; + useCalendar: boolean; +} +export async function registerAction( + context: vscode.ExtensionContext, + opts: RegisterOpts, +): Promise<{ successes: string[]; failures: string[] }> { + const successes: string[] = []; + const failures: string[] = []; + if (opts.useTasks) { + const r = await createTask(context, { title: opts.title, due: opts.date, notes: opts.notes }); + if (r.ok) successes.push('Tasks'); else failures.push(`Tasks: ${r.error}`); + } + if (opts.useCalendar) { + if (!opts.date) { + failures.push('Calendar: 날짜 없는 등록은 Tasks 만 가능 (조건부는 확인일을 지정하면 Calendar 에도 등록)'); + } else { + const r = await createCalendarEvent(context, { title: opts.title, start: opts.date, allDay: true, description: opts.notes }); + if (r.ok) successes.push('Calendar'); else failures.push(`Calendar: ${r.error}`); + } + } + return { successes, failures }; +} + +/** 등록 노트 공통 빌더. */ +export function buildNotes(p: { detail: string; meetTitle: string; owner: string; dueRaw: string; dateLabel: string; extra?: string[] }): string { + const detailLine = p.detail?.trim() || '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)'; + return [ + '■ 작업 상세', detailLine, '', + ...(p.extra && p.extra.length ? [...p.extra, ''] : []), + '■ 맥락', + `· 회의록: ${p.meetTitle}`, + `· 담당: ${p.owner || '(미지정)'}`, + `· 기한: ${p.dueRaw?.trim() || '(미표기)'} → ${p.dateLabel}`, + '', '— Astra /meet 등록', + ].join('\n'); +} + +// ── /meet confirm 답변 파싱 ───────────────────────────────────────────────── +export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string }; + +/** `1=6/20 2=ok 3=skip 4=2026-07-01` → 결정 목록. 잘못된 토큰은 errors 로. */ +export function parseConfirmArgs(arg: string, todayYear: number): { decisions: ConfirmDecision[]; errors: string[] } { + const decisions: ConfirmDecision[] = []; + const errors: string[] = []; + for (const tok of arg.split(/\s+/).map(t => t.trim()).filter(Boolean)) { + const m = tok.match(/^(\d+)\s*=\s*(.+)$/); + if (!m) { errors.push(`형식 오류: \`${tok}\` (예: 1=6/20, 2=ok, 3=skip)`); continue; } + const idx = Number(m[1]); + const val = m[2].trim().toLowerCase(); + if (val === 'ok' || val === '등록' || val === 'yes' || val === 'y') { decisions.push({ idx, action: 'ok' }); continue; } + if (val === 'skip' || val === '취소' || val === 'no' || val === 'n' || val === '제외') { decisions.push({ idx, action: 'skip' }); continue; } + const date = normalizeDate(val, todayYear); + if (date) { decisions.push({ idx, action: 'date', date }); continue; } + errors.push(`날짜 해석 불가: \`${tok}\` (YYYY-MM-DD, M/D, M월D일 지원)`); + } + return { decisions, errors }; +} + +export function normalizeDate(raw: string, todayYear: number): string | null { + const iso = raw.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/); + if (iso) return `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`; + const slash = raw.match(/^(\d{1,2})[\/.](\d{1,2})$/); + if (slash) return `${todayYear}-${slash[1].padStart(2, '0')}-${slash[2].padStart(2, '0')}`; + const kor = raw.match(/^(\d{1,2})월\s*(\d{1,2})일?$/); + if (kor) return `${todayYear}-${kor[1].padStart(2, '0')}-${kor[2].padStart(2, '0')}`; + return null; +} + +/** 보류 목록을 사용자 질문 텍스트로 렌더. */ +export function renderPendingQuestion(p: PendingFile): string { + const lines: string[] = []; + lines.push(`\n⏸️ **등록 보류 ${p.items.length}건 — 확신이 없어 등록 전에 확인이 필요합니다** (회의: ${p.meetTitle})\n`); + for (const it of p.items) { + const why = it.kind === 'undecided' ? '진행 여부 미확정' + : it.kind === 'nodate' ? '기한 미정' + : `조건부 — 선행: ${it.condition}`; + lines.push(` ${it.idx}. **${it.work}** — ${why}${it.owner ? ` (담당: ${it.owner})` : ''}`); + if (it.kind !== 'conditional') lines.push(` · ok 시 제안 날짜: ${it.suggestedDate}`); + } + lines.push(''); + lines.push('답변 방법: `/meet confirm 1=6/20 2=ok 3=skip` 처럼 한 줄로.'); + lines.push(' · `날짜`(YYYY-MM-DD, M/D, M월D일) = 그 날짜로 등록 · `ok` = 제안 날짜로 등록 · `skip` = 등록 안 함'); + lines.push(' · 조건부 항목: `ok` = 날짜 없는 Tasks 로 등록([조건부] 표시) · `날짜` = 그날을 조건 확인일로 등록'); + lines.push('보류 목록 다시 보기: `/meet pending`'); + return lines.join('\n'); +} + +export function logMeetRegistration(event: string, data: Record): void { + logInfo(`/meet 등록 게이트: ${event}`, data); +} diff --git a/tests/meetRegistration.test.ts b/tests/meetRegistration.test.ts new file mode 100644 index 0000000..4be115a --- /dev/null +++ b/tests/meetRegistration.test.ts @@ -0,0 +1,110 @@ +/** + * /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. + * 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, + * 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지). + */ +import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey } from '../src/features/datacollect/scheduling/meetRegistration'; + +const MEET = new Date('2026-06-10'); +const TODAY = new Date('2026-06-11'); +const row = (due: string, status: string) => ({ owner: '나', work: '테스트 작업', detail: '', due, status }); + +describe('classifyAction — 등록 게이트 분기', () => { + test('확정 + 명시 기한 → auto', () => { + const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY); + expect(c).toMatchObject({ route: 'auto', date: '2026-06-20', pastNote: false }); + }); + + test('진행미정 → hold(undecided)', () => { + const c = classifyAction(row('2026-06-20', '진행미정'), MEET, TODAY); + expect(c).toMatchObject({ route: 'hold', kind: 'undecided' }); + }); + + test('기한미정 → hold(nodate) + 제안 날짜 제공', () => { + const c = classifyAction(row('', '기한미정'), MEET, TODAY); + expect(c.route).toBe('hold'); + if (c.route === 'hold') { + expect(c.kind).toBe('nodate'); + expect(c.suggestedDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + } + }); + + test('조건부 → hold(conditional) + 선행작업 보존', () => { + const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY); + expect(c).toMatchObject({ route: 'hold', kind: 'conditional', condition: '계약 체결 후' }); + }); + + test('반복(매주 목요일) → 첫 1회만 auto, 다음 목요일', () => { + const c = classifyAction(row('', '반복: 매주 목요일'), MEET, TODAY); + // 2026-06-11(목) 기준 다음 목요일 = 06-18 (오늘이 그 요일이면 다음 주) + expect(c).toMatchObject({ route: 'auto', date: '2026-06-18', recurNote: '매주 목요일' }); + }); + + test('반복인데 요일 해석 불가 → 보류(추측 등록 금지)', () => { + const c = classifyAction(row('', '반복: 격주'), MEET, TODAY); + expect(c.route).toBe('hold'); + }); + + test('과거 날짜(옛 녹취) → auto + pastNote (완료확인 표기 대상)', () => { + const c = classifyAction(row('2026-05-01', '확정'), MEET, TODAY); + expect(c).toMatchObject({ route: 'auto', date: '2026-05-01', pastNote: true }); + }); + + test('확정이지만 기한 해석 불가 → 보류 (구버전의 +5일 추측 등록 제거)', () => { + const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY); + expect(c.route).toBe('hold'); + if (c.route === 'hold') expect(c.kind).toBe('nodate'); + }); + + test('상태 누락(구표 호환) — 기한 있으면 auto', () => { + const c = classifyAction(row('2026-07-01', ''), MEET, TODAY); + expect(c).toMatchObject({ route: 'auto', date: '2026-07-01' }); + }); +}); + +describe('parseConfirmArgs / normalizeDate', () => { + test('혼합 답변 파싱', () => { + const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026); + expect(errors).toEqual([]); + expect(decisions).toEqual([ + { idx: 1, action: 'date', date: '2026-06-20' }, + { idx: 2, action: 'ok' }, + { idx: 3, action: 'skip' }, + { idx: 4, action: 'date', date: '2026-07-01' }, + ]); + }); + + test('한글 별칭(등록/취소) + M월D일', () => { + const { decisions } = parseConfirmArgs('1=등록 2=취소 3=7월3일', 2026); + expect(decisions).toEqual([ + { idx: 1, action: 'ok' }, + { idx: 2, action: 'skip' }, + { idx: 3, action: 'date', date: '2026-07-03' }, + ]); + }); + + test('형식 오류는 errors 로 분리', () => { + const { decisions, errors } = parseConfirmArgs('1=언젠가 foo', 2026); + expect(decisions).toEqual([]); + expect(errors.length).toBe(2); + }); + + test('normalizeDate 변형들', () => { + expect(normalizeDate('2026-6-5', 2026)).toBe('2026-06-05'); + expect(normalizeDate('6/20', 2026)).toBe('2026-06-20'); + expect(normalizeDate('12월3일', 2026)).toBe('2026-12-03'); + expect(normalizeDate('내일', 2026)).toBeNull(); + }); +}); + +describe('보조 유틸', () => { + test('nextWeekday — 오늘이 해당 요일이면 다음 주', () => { + expect(nextWeekday(new Date('2026-06-11'), '목')!.toISOString().slice(0, 10)).toBe('2026-06-18'); + expect(nextWeekday(new Date('2026-06-11'), '금')!.toISOString().slice(0, 10)).toBe('2026-06-12'); + expect(nextWeekday(new Date('2026-06-11'), 'X')).toBeNull(); + }); + + test('taskKey — 표기 변형에도 같은 키 (중복 방지)', () => { + expect(taskKey('DRM 라이선스 검토')).toBe(taskKey('drm 라이선스 검토')); + }); +});