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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user