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:
@@ -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 은 로컬
|
||||
|
||||
Reference in New Issue
Block a user