feat(datacollect): /meet 회의록 명령 + 캘린더 자동 등록, 한·영 깨짐 개선
- /meet <txt> — 로컬 회의 녹취를 사실 기반 구조화 회의록으로 합성·저장 (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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <txt 파일 경로> [메타데이터]`.** 로컬 회의 녹취 텍스트 파일을 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로 동기화.
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<boolean> {
|
||||
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<s
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature,
|
||||
// 깨진 저확률 토큰(한·영 혼합 등) 샘플링 억제 — top_p/top_k로 후보를
|
||||
// 좁히고 repeat_penalty로 반복 깨짐을 누른다.
|
||||
top_p: 0.85,
|
||||
top_k: 20,
|
||||
repeat_penalty: 1.1,
|
||||
},
|
||||
}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
@@ -446,9 +455,51 @@ async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise<s
|
||||
?? '';
|
||||
// 안전망 — 모델이 그래도 내부 검증 로그를 붙이면 잘라낸다. [Self-Reflector Check]
|
||||
// 블록은 항상 답변 맨 끝에 오므로 그 지점부터 끝까지 제거한다.
|
||||
return String(content)
|
||||
let out = String(content)
|
||||
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
|
||||
.trim();
|
||||
// 한·영 깨짐(예: "핵ess" — 한글 음절 + 영문 소문자 조각)이 감지되면 교정 패스를
|
||||
// 1회 돈다. 깨짐이 없으면(대부분) 추가 호출 없이 그대로 반환한다.
|
||||
if (/[가-힣][a-z]{2,}/.test(out)) {
|
||||
out = await repairKoreanGlitches(out, lmUrl, model);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
|
||||
* 깨진 토큰만 고치고 나머지 내용·구조는 보존한다. 실패하거나 결과가 비정상적으로
|
||||
* 짧으면(LLM이 내용을 잘라먹은 경우) 원본을 그대로 반환한다.
|
||||
*/
|
||||
async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise<string> {
|
||||
try {
|
||||
const res = await bridgeFetch<any>('/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<boolean> {
|
||||
@@ -1083,3 +1134,286 @@ async function runWikify(arg: string, view: Webview | undefined): Promise<boolea
|
||||
chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /meet ─────────────────────────────
|
||||
|
||||
/**
|
||||
* 회의 녹취 텍스트 → 사실 기반 구조화 회의록(Actionable Minutes) LLM 프롬프트.
|
||||
* 사용자 정의 규칙: Fact/Discussion/Decision/Risk/Action 분류, 메타데이터 우선.
|
||||
*/
|
||||
function buildMeetPrompt(transcript: string, metadata: string): string {
|
||||
const metaBlock = metadata.trim()
|
||||
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
|
||||
return `# 임무 (Objective)
|
||||
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의
|
||||
구조화된 회의록(Actionable Minutes)을 생성한다.
|
||||
|
||||
# 역할 (Role)
|
||||
- Fact Extractor: 사실만 추출
|
||||
- Decision Tracker: 결정 여부 구분
|
||||
- Action Organizer: 실행 항목 구조화
|
||||
- Context Filter: 불필요한 발언(잡담) 제거
|
||||
|
||||
# 데이터 우선순위 (Data Priority)
|
||||
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
|
||||
|
||||
# 처리 절차 (Processing Flow)
|
||||
1. Deconstruction — 잡담 제거, 의미 단위로 분해, 발언자 ID는 무시한다.
|
||||
2. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
|
||||
3. Decision Logic
|
||||
- 명확한 합의 표현 → Decision
|
||||
- 실행 주체 + 행동 → Action
|
||||
- 제안/의견 → Discussion
|
||||
- 조건 부족 → Open Issue
|
||||
4. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
|
||||
|
||||
# 출력 검증 (Validation)
|
||||
출력 전 내부적으로 점검한다: Decision은 실제 합의인가 / Action은 실행 가능한가.
|
||||
단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[회의 녹취록]
|
||||
\`\`\`
|
||||
${transcript}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
|
||||
# [회의 제목]
|
||||
|
||||
- **날짜**: [YYYY년 MM월 DD일 | 확인 불가]
|
||||
- **참석자**: [메타데이터 기준 | 없을 경우: 논의 참여 주체]
|
||||
- **주제 요약**: [한 문장 요약]
|
||||
|
||||
## 🔹 요약 보고
|
||||
핵심 논의 요약 3~5개를 글머리표로 작성.
|
||||
|
||||
## 1. 주요 논의 사항
|
||||
각 안건마다 아래 구조로:
|
||||
### [안건 제목]
|
||||
- **현황**:
|
||||
- **핵심 논의**:
|
||||
- **결론**: [결정됨 / 논의 중 / 보류]
|
||||
|
||||
## 2. 리스크 및 이슈
|
||||
|
||||
## 3. 결정 사항
|
||||
|
||||
## 4. 오픈 이슈
|
||||
|
||||
## 5. 액션 아이템
|
||||
| 담당 | 작업 내용 | 기한 |
|
||||
| --- | --- | --- |
|
||||
|
||||
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
||||
}
|
||||
|
||||
async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
// 경로 파싱 — 따옴표로 감싸면 공백 포함 경로 허용, 아니면 첫 공백 전까지가 경로.
|
||||
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 <txt 파일 경로> [참석자·날짜 등 메타데이터]\`\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<string>('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<string>('datacollectSavePath', '') || '').trim();
|
||||
const body: Record<string, unknown> = { 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user