From 132d130ff139c3e6aced502ac3ce5bde18b267e1 Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 21 May 2026 19:11:00 +0900 Subject: [PATCH] =?UTF-8?q?feat(datacollect):=20/meet=20=ED=9A=8C=EC=9D=98?= =?UTF-8?q?=EB=A1=9D=20=EB=AA=85=EB=A0=B9=20+=20=EC=BA=98=EB=A6=B0?= =?UTF-8?q?=EB=8D=94=20=EC=9E=90=EB=8F=99=20=EB=93=B1=EB=A1=9D,=20?= =?UTF-8?q?=ED=95=9C=C2=B7=EC=98=81=20=EA=B9=A8=EC=A7=90=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /meet — 로컬 회의 녹취를 사실 기반 구조화 회의록으로 합성·저장 (v2.2.53) - /meet 회의록 액션 아이템을 task별 종일 일정으로 Google Calendar에 자동 등록 (v2.2.55) - 날짜 규칙: 명시 날짜·"차주"(+6일)·변환 불가(등록일+영업일 5일, "(미확정)" 꼬리표) - handleSlashCommand에 ExtensionContext 배선 (chatHandlers 경유) - callLmSynthesis: top_p/top_k/repeat_penalty 추가 + 한·영 깨짐 조건부 교정 패스 (v2.2.54) Co-Authored-By: Claude Opus 4.7 --- PATCHNOTES.md | 35 +++ package.json | 2 +- src/features/datacollect/slashRouter.ts | 338 +++++++++++++++++++++++- src/sidebar/chatHandlers.ts | 2 +- 4 files changed, 373 insertions(+), 4 deletions(-) diff --git a/PATCHNOTES.md b/PATCHNOTES.md index 1a4682e..490457b 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,40 @@ # Astra Patch Notes +## v2.2.55 (2026-05-21) +### 📅 /meet — 회의록 액션 아이템 → Google 캘린더 자동 등록 (Phase 1) +- `/meet`이 회의록 합성·저장 후, **액션 아이템 표를 파싱해 task별 종일 일정으로 Google Calendar에 자동 등록**한다. +- 날짜 규칙(사용자 정의): 명시 날짜(`YYYY-MM-DD`/`YYYY년 M월 D일`)→그대로 / "차주·다음 주"→회의일 +6일 / "즉시·당일"→등록일 / 변환 불가·빈 값→등록일 +영업일 5일(토·일 제외, 공휴일 무시) + 제목에 **"(미확정)"** 꼬리표. +- 캘린더 이벤트 설명에 회의 제목·담당·원래 기한 표기를 기록. Google Calendar OAuth(쓰기)가 연결돼 있어야 하며, 미연결 시 회의록 저장만 하고 안내한다. +- `handleSlashCommand`에 `ExtensionContext` 배선 추가(`/meet`만 사용). +- **신규 패키징:** `astra-2.2.55.vsix`. + +--- + + + +## v2.2.54 (2026-05-21) +### 🔧 한·영 토큰 깨짐 추가 개선 — 샘플링 조정 + 교정 패스 +- **샘플링 파라미터 조정.** 슬래시 합성 LLM 호출에 `top_p`(0.85)·`top_k`(20)·`repeat_penalty`(1.1) 추가 — 깨진 저확률 토큰("핵ess" 등)의 샘플링 자체를 억제. +- **조건부 교정 패스.** 합성 결과에 한·영 깨짐(`한글+영문 소문자 조각`)이 감지되면 LLM 교정 패스를 1회 돌려 깨진 표기만 자연스러운 한국어로 교정. 깨짐이 없으면 추가 호출 없음. 교정 결과가 원본보다 비정상적으로 짧으면(잘라먹음) 원본을 유지하는 안전장치 포함. +- 적용 범위: `/benchmark`·`/youtube`·`/wikify`·`/meet` 모든 슬래시 합성. +- **신규 패키징:** `astra-2.2.54.vsix`. + +--- + + + +## v2.2.53 (2026-05-20) +### 📝 신규 /meet — 회의 녹취 txt → 사실 기반 구조화 회의록 +- **신규 슬래시 명령 `/meet [메타데이터]`.** 로컬 회의 녹취 텍스트 파일을 ASTRA가 직접 읽어(로컬 파일이라 Bridge 불필요), 사실 기반 구조화 회의록(Actionable Minutes)으로 LLM 합성·저장한다. +- 처리 규칙: Deconstruction(잡담 제거) → Classification(Fact/Discussion/Decision/Risk/Action) → Decision Logic → Structuring. 메타데이터(참석자·날짜)가 녹취록과 충돌하면 메타데이터 우선. +- 출력 구조: 요약 보고 / 주요 논의 사항 / 리스크·이슈 / 결정 사항 / 오픈 이슈 / 액션 아이템(담당·작업·기한 표). +- 경로에 공백이 있으면 따옴표로 감쌀 수 있음. 결과물은 `.md`로 `WIKI_RAW_PATH`(`E:\Wiki\2nd\00_Raw`)에 저장. +- **신규 패키징:** `astra-2.2.53.vsix`. + +--- + + + ## v2.2.52 (2026-05-20) ### 📦 재패키징 (v2.2.51 동일 내용) - 기능 변경 없음 — v2.2.51 작업 트리를 버전만 올려 재패키징. 버전 정합성 정리를 위해 `package-lock.json` 버전도 함께 2.2.52로 동기화. diff --git a/package.json b/package.json index 54b5cc7..cda6d43 100644 --- a/package.json +++ b/package.json @@ -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.52", + "version": "2.2.55", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/datacollect/slashRouter.ts b/src/features/datacollect/slashRouter.ts index 3f20e07..49bd82e 100644 --- a/src/features/datacollect/slashRouter.ts +++ b/src/features/datacollect/slashRouter.ts @@ -1,6 +1,8 @@ import * as vscode from 'vscode'; +import { promises as fsp } from 'fs'; import { logInfo } from '../../utils'; import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient'; +import { createCalendarEvent, readCalendarConfig } from '../calendar'; /** * Datacollect "라디오" slash 명령 라우터. @@ -16,7 +18,7 @@ import { bridgeFetch, getBridgeBaseUrl } from './bridgeClient'; * 명령이 처리되면 true 반환 → chatHandlers가 일반 LLM 흐름으로 안 내려가게. */ -const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify'] as const; +const COMMANDS = ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'] as const; type SlashCommand = typeof COMMANDS[number]; export function isSlashCommand(input: string): boolean { @@ -48,6 +50,7 @@ function chunk(view: Webview | undefined, value: string) { export async function handleSlashCommand( input: string, view: Webview | undefined, + context?: vscode.ExtensionContext, ): Promise { const trimmed = input.trim(); const spaceIdx = trimmed.indexOf(' '); @@ -73,6 +76,7 @@ export async function handleSlashCommand( case '/youtube': return await runYoutube(arg, view); case '/blog': return await runBlog(arg, view); case '/wikify': return await runWikify(arg, view); + case '/meet': return await runMeet(arg, view, context); } return true; } catch (e: any) { @@ -436,6 +440,11 @@ async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise { + try { + const res = await bridgeFetch('/api/lm', { + method: 'POST', + body: JSON.stringify({ + url: `${lmUrl}/v1/chat/completions`, + payload: { + model, + messages: [ + { role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' }, + { role: 'user', content: text }, + ], + temperature: 0, + top_p: 0.7, + top_k: 20, + }, + }), + }, { timeoutMs: 120_000 }); + const fixed = String( + res?.choices?.[0]?.message?.content + ?? res?.choices?.[0]?.text + ?? res?.answer ?? res?.response ?? '', + ).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim(); + // 안전장치 — 교정 결과가 원본의 70% 미만이면 LLM이 내용을 잘라먹은 것이므로 원본 사용. + if (!fixed || fixed.length < text.length * 0.7) return text; + return fixed; + } catch { + return text; // 교정 실패는 비치명적 — 원본 그대로 반환. + } } async function runBenchmark(arg: string, view: Webview | undefined): Promise { @@ -1083,3 +1134,286 @@ async function runWikify(arg: string, view: Webview | undefined): Promise { + // 경로 파싱 — 따옴표로 감싸면 공백 포함 경로 허용, 아니면 첫 공백 전까지가 경로. + const trimmed = arg.trim(); + let filePath = ''; + let metadata = ''; + if (trimmed.startsWith('"')) { + const end = trimmed.indexOf('"', 1); + if (end > 0) { + filePath = trimmed.slice(1, end); + metadata = trimmed.slice(end + 1).trim(); + } + } + if (!filePath) { + const sp = trimmed.indexOf(' '); + if (sp === -1) { + filePath = trimmed; + } else { + filePath = trimmed.slice(0, sp); + metadata = trimmed.slice(sp + 1).trim(); + } + } + if (!filePath) { + chunk(view, `사용법: \`/meet [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`); + return true; + } + + chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`); + + // 1) 로컬 txt 파일 읽기 — ASTRA가 직접 (로컬 파일이라 Bridge 불필요). + let transcript: string; + try { + transcript = await fsp.readFile(filePath, 'utf-8'); + } catch (e: any) { + chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`); + return true; + } + if (!transcript || transcript.trim().length < 20) { + chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`); + return true; + } + // LLM 입력 폭주 방지 — 60000자 상한. + const MAX = 60000; + const truncated = transcript.length > MAX; + if (truncated) transcript = transcript.slice(0, MAX); + chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`); + + // 2) LLM 회의록 합성. + const cfg = vscode.workspace.getConfiguration('g1nation'); + const model = (cfg.get('defaultModel', '') || 'gemma4:e2b').trim(); + const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.'; + chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`); + let report: string; + try { + const t0 = Date.now(); + report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem); + if (!report) throw new Error('LLM 응답이 비어 있습니다.'); + chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`); + } catch (e: any) { + chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`); + return true; + } + chunk(view, report + '\n\n'); + + // 3) 저장 — /api/wiki/save (datacollectSavePath > WIKI_RAW_PATH). + // WIKI_RAW_PATH가 E:\Wiki\2nd\00_Raw 를 가리키므로 결과물이 그곳에 .md로 저장된다. + try { + const today = new Date().toISOString().slice(0, 10); + const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting'; + const title = `회의록 ${baseName} ${today}`; + const savePath = (cfg.get('datacollectSavePath', '') || '').trim(); + const body: Record = { title, content: report }; + if (savePath) body.saveDir = savePath; + const saved = await bridgeFetch<{ success: boolean; path?: string }>( + '/api/wiki/save', + { method: 'POST', body: JSON.stringify(body) }, + { timeoutMs: 30_000 }, + ); + chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`); + } catch (e: any) { + chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`); + } + + // 4) 캘린더 자동 등록 — 액션 아이템을 task별 종일 일정으로 Google Calendar에 등록. + if (context) { + try { + const calCfg = readCalendarConfig(context); + if (!calCfg.refreshToken) { + chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`); + } else { + const tasks = parseActionItems(report); + if (tasks.length === 0) { + chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`); + } else { + const today = new Date(); + const meetingDate = extractMeetingDate(report, today); + const titleMatch = report.match(/^#\s+(.+)$/m); + const meetTitle = titleMatch + ? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim() + : '회의'; + chunk(view, `\n📅 **캘린더 등록**: 액션 아이템 ${tasks.length}건…\n`); + let ok = 0; + let tentativeCount = 0; + for (const task of tasks) { + const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); + if (tentative) tentativeCount++; + const evTitle = tentative ? `${task.work} (미확정)` : task.work; + const result = await createCalendarEvent(context, { + title: evTitle, + start: date, + allDay: true, + description: `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`, + }); + if (result.ok) { + ok++; + chunk(view, ` · ${date} — ${evTitle}\n`); + } else { + chunk(view, ` · ⚠️ 등록 실패 (${task.work}): ${result.error}\n`); + } + } + chunk(view, `✅ 캘린더 ${ok}/${tasks.length}건 등록 완료${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`); + } + } + } catch (e: any) { + chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`); + } + } + return true; +} + +// ─── /meet 캘린더 등록 헬퍼 ─── + +/** 토·일을 제외하고 영업일 n일을 더한 날짜 (공휴일은 고려하지 않음). */ +function addBusinessDays(base: Date, n: number): Date { + const r = new Date(base); + let added = 0; + while (added < n) { + r.setDate(r.getDate() + 1); + const day = r.getDay(); + if (day !== 0 && day !== 6) added++; + } + return r; +} + +/** Date → 'YYYY-MM-DD' (로컬 기준). */ +function toYmd(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; +} + +/** 회의록 본문의 "**날짜**: 2026년 05월 08일"에서 회의 날짜 추출. 없으면 fallback. */ +function extractMeetingDate(report: string, fallback: Date): Date { + const m = report.match(/날짜\*{0,2}\s*[::]\s*\*{0,2}\s*(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/); + if (m) { + const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); + if (!isNaN(d.getTime())) return d; + } + return fallback; +} + +/** + * 액션 아이템 '기한' 텍스트 → 캘린더 등록 날짜. 사용자 정의 규칙: + * - 명시 날짜(YYYY-MM-DD / YYYY년 M월 D일) → 그 날짜 + * - "차주 / 다음 주 / 내주" → 회의일 +6일 + * - "즉시 / 당일 / 금일 / 바로 / 오늘" → 등록일(오늘) + * - 변환 불가 / 빈 값 → 등록일 +영업일 5일, tentative=true (제목에 "(미확정)") + */ +function resolveTaskDate(due: string, meetingDate: Date, today: Date): { date: string; tentative: boolean } { + const t = (due || '').trim(); + const iso = t.match(/(\d{4})-(\d{1,2})-(\d{1,2})/); + if (iso) { + return { date: `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`, tentative: false }; + } + const kor = t.match(/(\d{4})\s*년\s*(\d{1,2})\s*월\s*(\d{1,2})\s*일/); + if (kor) { + return { date: toYmd(new Date(Number(kor[1]), Number(kor[2]) - 1, Number(kor[3]))), tentative: false }; + } + if (/차주|다음\s*주|내주/.test(t)) { + const d = new Date(meetingDate); + d.setDate(d.getDate() + 6); + return { date: toYmd(d), tentative: false }; + } + if (/즉시|당일|금일|바로|오늘/.test(t)) { + return { date: toYmd(today), tentative: false }; + } + // 변환 불가 — 등록일 + 영업일 5일, "(미확정)" 꼬리표. + return { date: toYmd(addBusinessDays(today, 5)), tentative: true }; +} + +/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */ +function parseActionItems(report: string): { owner: string; work: string; due: string }[] { + const rows: { owner: string; work: string; due: string }[] = []; + let inSection = false; + for (const line of report.split('\n')) { + if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; } + if (!inSection) continue; + if (/^#{1,6}\s/.test(line)) break; // 다음 섹션 시작 → 종료 + if (!/^\s*\|/.test(line)) continue; + const cells = line.split('|').slice(1, -1).map((c) => c.trim()); + if (cells.length < 3) continue; + if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선 + if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더 + rows.push({ owner: cells[0], work: cells[1], due: cells[2] }); + } + return rows; +} diff --git a/src/sidebar/chatHandlers.ts b/src/sidebar/chatHandlers.ts index 482050b..45c51c4 100644 --- a/src/sidebar/chatHandlers.ts +++ b/src/sidebar/chatHandlers.ts @@ -35,7 +35,7 @@ export async function handleChatMessage(provider: SidebarChatProvider, data: any return true; } logInfo(`[SLASH] handleSlashCommand entering`); - await handleSlashCommand(data.value, provider._view.webview); + await handleSlashCommand(data.value, provider._view.webview, provider._context); logInfo(`[SLASH] handleSlashCommand returned`); return true; }