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
@@ -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);
}