feat(meet): 확신 게이트 등록 + /meet confirm + 데일리 브리핑 (v2.2.216)

캘린더 등록 정책을 "확신 없으면 등록 대신 질문"으로 전환:
- 액션 표에 상태 컬럼(확정/진행미정/기한미정/조건부:선행/반복:주기) — 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 16:22:19 +09:00
parent 4eb8bf03f7
commit 70ea421827
10 changed files with 777 additions and 60 deletions
+2 -2
View File
@@ -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",
+11 -1
View File
@@ -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": "",
+5
View File
@@ -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.
+169
View File
@@ -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<string>('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<number[]>('telegram.allowedChatIds', []) || [];
return allowed.length > 0 ? allowed[0] : null;
}
/** 오늘의 브리핑 텍스트 구성. 일정·할일이 모두 없으면 null (발송 skip). */
export async function buildBriefingText(context: vscode.ExtensionContext, ymd: string): Promise<string | null> {
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<void> {
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<boolean>('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; }
});
}
+9 -6
View File
@@ -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 } : {}),
};
+200 -42
View File
@@ -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<boolea
async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<boolean> {
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<void> {
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<boolean>('meetUsesTasks', true);
const useCalendar = gCfg.get<boolean>('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<number>();
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 은 로컬
@@ -91,11 +91,18 @@ const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조
## 5. 액션 아이템
각 행은 반드시 녹취록 근거로 작성한다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것.
| 담당 | 작업 내용 | 작업 상세 | 기한 |
| --- | --- | --- | --- |
| 담당 | 작업 내용 | 작업 상세 | 기한 | 상태 |
| --- | --- | --- | --- | --- |
- **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지)
- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것.
- **상태**: 다음 중 정확히 하나로 분류한다 (캘린더 자동 등록 게이트가 이 값으로 분기하므로 형식 엄수):
- \`확정\` — 진행 합의가 명시적이고 기한도 언급됨.
- \`진행미정\` — 작업이 언급됐으나 실제로 진행할지 합의가 명확하지 않음.
- \`기한미정\` — 하기로 확정됐으나 완료일/D-Day 가 정해지지 않음.
- \`조건부: <선행작업>\` — 다른 작업·사건이 끝나야 진행 (선행작업을 짧게 명시. 예: \`조건부: 계약 체결 후\`).
- \`반복: <주기>\` — 정기 반복 업무 (예: \`반복: 매주 목요일\`).
확신이 없으면 \`확정\`이 아니라 \`진행미정\`/\`기한미정\` 쪽으로 보수적으로 분류한다.
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
@@ -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;
@@ -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<T>(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<string, { keys: string[]; meetTitle: string; at: string }>;
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<string> {
const reg = readJson<Registry>(REGISTRY_REL) || {};
return new Set(reg[hash]?.keys || []);
}
export function markRegistered(hash: string, meetTitle: string, keys: string[]): void {
const reg = readJson<Registry>(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<PendingFile>(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<string, unknown>): void {
logInfo(`/meet 등록 게이트: ${event}`, data);
}
+110
View File
@@ -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 라이선스 검토'));
});
});