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
+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 은 로컬