v2.2.265: /meet 검수 루프·편집·액션 상세·논의 매듭
/meet 파이프라인 대폭 개선: - 검수 루프: 회의록 검증 후 지적분만 수정, 통과까지 반복 (전체 재작성 X) - 편집(Editor): 검수 통과 후 보고서 형태로 구조 정리 (환각 방지 재검증) - 액션 상세 확장: task 노트에 2~3문장 자기완결 설명 자동 생성 - 주요 논의/쟁점: 각 쟁점을 논점→논의→결론으로 그 자리에서 매듭 - 자동 등록 0건 fix: 기한 없는 확정·기한미정을 작성일(오늘)로 등록 (폴백) 신규 설정: - g1nation.meetVerifyMaxRounds (기본 2) - g1nation.meetEditorPass (기본 ON) - g1nation.meetTaskDateFallback (기본 today) - g1nation.meetTaskDetailExpand (기본 ON) 신규 프롬프트: - buildMeetRevisePrompt (지적분만 수정) - buildMeetEditorPrompt (근거 유지 편집) - buildTaskDetailPrompt (액션 상세 확장) 신규 파서: - parseTaskDetails (task 상세 맵핑) 신규 테스트: - classifyAction dateFallback 동작 5건 - parseTaskDetails 파싱 4건 tsc ✓ · jest 715/715 ✓ Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
+31
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "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.",
|
"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.261",
|
"version": "2.2.265",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -314,13 +314,42 @@
|
|||||||
"g1nation.meetVerifyPass": {
|
"g1nation.meetVerifyPass": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부(기본 ON). 완성된 회의록을 녹취록(또는 추출 노트)과 LLM 으로 대조해 ①근거 미확인 ②\"참석자 N\" 화자번호 잔존 ③결정 과확정(테스트후결정·검토필요) ④폐기 가설 격상 ⑤액션 중복 을 점검하고 `⚠️ 검증 결과` 섹션으로 표시한다. LLM 호출이 1회 추가되어 그만큼 느려짐 — 끄려면 false."
|
"markdownDescription": "`/meet` 회의록 생성 후 **검수 루프** 실행 여부(기본 ON). 검수자가 완성된 회의록을 녹취록(또는 추출 노트)과 LLM 으로 대조해 ①근거 미확인 ②\"참석자 N\" 화자번호 잔존 ③결정 과확정(테스트후결정·검토필요) ④폐기 가설 격상 ⑤액션 중복 을 지적하면, **지적된 부분만** 수정하고 `검증 통과` 또는 최대 라운드(`meetVerifyMaxRounds`)까지 재검수한다(전체 재작성 X). 통과 후 편집자(`meetEditorPass`)가 보고서 형태로 정리. LLM 호출이 여러 번 추가되어 느려짐 — 끄려면 false."
|
||||||
|
},
|
||||||
|
"g1nation.meetVerifyMaxRounds": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 2,
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 4,
|
||||||
|
"markdownDescription": "`/meet` 검수 루프의 **최대 반복 횟수**(기본 `2`). 검수자가 지적 → 지적분만 수정 → 재검수를 이 횟수까지 반복하며, 도중에 `검증 통과`가 나오면 즉시 종료한다. 끝까지 지적이 남으면 회의록 끝에 `⚠️ 검수 미해결 지적` 섹션으로 남긴다. 라운드마다 LLM 호출 2회(검수+수정)가 추가되므로 로컬 모델에서는 2~3 권장."
|
||||||
|
},
|
||||||
|
"g1nation.meetEditorPass": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"markdownDescription": "`/meet` 검수 통과 후 **편집(Editor) 패스** 실행 여부(기본 ON). 편집자가 회의록 전체를 **보고서 형태**로 구조·가독성만 다듬는다(섹션 정돈·표 정렬·개조식 통일). 근거 소스에 없는 사실을 새로 만들지 않고 기존 항목을 삭제·축약하지 않으며, 편집 직후 1회 재검증으로 환각이 없는지 확인해 문제가 있으면 편집 전 회의록을 유지한다. LLM 호출 2회 추가 — 끄려면 false."
|
||||||
|
},
|
||||||
|
"g1nation.meetTaskDateFallback": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "today",
|
||||||
|
"enum": ["today", "hold"],
|
||||||
|
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
||||||
|
},
|
||||||
|
"g1nation.meetTaskDetailExpand": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"markdownDescription": "`/meet` 등록 시 각 액션의 **작업 상세를 자동으로 풀어 씀**(기본 ON). 슬림 액션 표에는 상세 칸이 없어 Google Task 노트가 액션 한 줄만 담겨 앙상했던 문제를 해소한다 — 등록 직전 배치 LLM 1회로 각 액션을 '회의에 참석하지 않은 담당자도 무엇을·왜·어떤 결과물을 목표로 하는지 이해'할 2~3문장 설명으로 풀어 task 노트의 `■ 작업 상세`에 넣는다(근거 소스에 있는 내용만, 환각 금지). LLM 호출 1회 추가 — 끄려면 false(그러면 액션 한 줄만 등록)."
|
||||||
},
|
},
|
||||||
"g1nation.meetTeamRoster": {
|
"g1nation.meetTeamRoster": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "`/meet` 화자 정규화용 **회사 표준 팀/역할 분류**(쉼표 구분). STT 화자번호(\"참석자 N\")를 실명 추측 대신 이 팀들 중 하나로 귀속해 오귀속을 막는다. 비워두면 기본값(`PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM`) 사용. 회사 구조에 맞게 수정하세요."
|
"markdownDescription": "`/meet` 화자 정규화용 **회사 표준 팀/역할 분류**(쉼표 구분). STT 화자번호(\"참석자 N\")를 실명 추측 대신 이 팀들 중 하나로 귀속해 오귀속을 막는다. 비워두면 기본값(`PD, 기획, 사업, 클라이언트, 넥서스개발팀(서버), UI, 배경팀, 캐릭터팀, QA, 사운드, 개발PM`) 사용. 회사 구조에 맞게 수정하세요."
|
||||||
},
|
},
|
||||||
|
"g1nation.meetMaxOutputTokens": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 8000,
|
||||||
|
"minimum": 2048,
|
||||||
|
"markdownDescription": "`/meet` 최종 회의록(단일샷·병합) 생성 시 **출력 토큰 상한**(max_tokens). 기본 `8000`. 이 값을 명시하지 않으면 LM 서버 기본 상한에 잘려 **회의록 후반부·긴 액션표가 통째로 누락**되던 문제를 막는다. 잘림이 감지되면(finish=length) 끊긴 지점부터 자동 이어쓰기까지 수행하므로 이 값은 '한 번에 뽑는 양'이고 최종 길이는 이보다 길어질 수 있다. 아주 긴 회의(2시간+)에서 여전히 뒷부분이 짧다면 12000~16000 으로 올려보세요. (모델 컨텍스트 창을 넘지 않는 선에서)"
|
||||||
|
},
|
||||||
"g1nation.dailyBriefing.enabled": {
|
"g1nation.dailyBriefing.enabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
|
|||||||
@@ -27,12 +27,13 @@ import {
|
|||||||
build4LensPrompt,
|
build4LensPrompt,
|
||||||
} from './prompts/youtubePrompts';
|
} from './prompts/youtubePrompts';
|
||||||
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
||||||
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
|
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt, buildMeetRevisePrompt, buildMeetEditorPrompt, buildTaskDetailPrompt } from './prompts/meetPrompt';
|
||||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||||
import {
|
import {
|
||||||
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
|
transcriptHash, taskKey, loadRegisteredKeys, markRegistered,
|
||||||
savePending, loadPending, classifyAction,
|
savePending, loadPending, classifyAction,
|
||||||
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
|
registerAction, buildNotes, renderPendingQuestion, processConfirmDecisions,
|
||||||
|
parseTaskDetails,
|
||||||
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
|
loadGlossaryTerms, updateGlossary, extractGlossaryCandidates, loadTeamRoster,
|
||||||
type PendingItem, type PendingFile,
|
type PendingItem, type PendingFile,
|
||||||
} from './scheduling/meetRegistration';
|
} from './scheduling/meetRegistration';
|
||||||
@@ -79,7 +80,7 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
|
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
|
||||||
BRIDGE_API.web.benchmarkScan,
|
BRIDGE_API.web.benchmarkScan,
|
||||||
{
|
{
|
||||||
@@ -112,7 +113,15 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
|||||||
``,
|
``,
|
||||||
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
|
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
|
||||||
'```',
|
'```',
|
||||||
s?.sitemap?.ascii || '(없음)',
|
// 큰 사이트맵 트리는 채팅을 벽처럼 밀어내므로 상위 25줄만 보이고 나머지는 요약(전체는 저장 파일에).
|
||||||
|
(() => {
|
||||||
|
const tree = String(s?.sitemap?.ascii || '').trimEnd();
|
||||||
|
if (!tree) return '(없음)';
|
||||||
|
const lines = tree.split('\n');
|
||||||
|
return lines.length <= 25
|
||||||
|
? tree
|
||||||
|
: `${lines.slice(0, 25).join('\n')}\n… (외 ${lines.length - 25}줄 · 총 ${s?.sitemap?.totalPages ?? lines.length}페이지 — 전체 트리는 저장된 보고서 파일 참조)`;
|
||||||
|
})(),
|
||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
@@ -264,7 +273,7 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
|
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
|
||||||
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
|
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
|
||||||
BRIDGE_API.youtube.extract,
|
BRIDGE_API.youtube.extract,
|
||||||
@@ -319,7 +328,9 @@ async function runYoutube(arg: string, view: Webview | undefined): Promise<boole
|
|||||||
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
|
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
|
||||||
|
|
||||||
const script = fullScriptFromSegments(video?.segments);
|
const script = fullScriptFromSegments(video?.segments);
|
||||||
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
|
// 전체 스크립트는 아래 저장 파일에 그대로 들어가므로, 채팅엔 길이 + 미리보기만 보여 벽 스크롤을 막는다.
|
||||||
|
const scriptPreview = script.length > 600 ? `${script.slice(0, 600).trimEnd()}…` : script;
|
||||||
|
chunk(view, `## 📜 전체 스크립트 (${script.length.toLocaleString()}자)\n\n${scriptPreview}\n\n_(전체 스크립트는 저장되는 분석 파일에 포함됩니다.)_\n\n---\n\n`);
|
||||||
|
|
||||||
const sections: Section[] = [];
|
const sections: Section[] = [];
|
||||||
if (mode === 'info' || mode === 'both') {
|
if (mode === 'info' || mode === 'both') {
|
||||||
@@ -417,7 +428,7 @@ async function wikifyOne(url: string, userContent: string, view: Webview | undef
|
|||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
const heartbeat = setInterval(() => {
|
const heartbeat = setInterval(() => {
|
||||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||||
}, 4000);
|
}, 10000);
|
||||||
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
|
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
|
||||||
BRIDGE_API.web.extract,
|
BRIDGE_API.web.extract,
|
||||||
{ method: 'POST', body: JSON.stringify({ url }) },
|
{ method: 'POST', body: JSON.stringify({ url }) },
|
||||||
@@ -612,6 +623,10 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
|
|
||||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||||
|
// 출력 토큰 상한 — 미지정 시 서버 기본값에 잘려 후반부·긴 표가 통째로 사라지던 "누락"의 주원인.
|
||||||
|
// 넉넉히 주고(잘리면 callLmSynthesis 가 이어쓰기까지 수행) 완결성을 확보한다. 긴 회의는 설정으로 상향.
|
||||||
|
const MEET_REPORT_TOKENS = Math.max(2048, cfg.get<number>('meetMaxOutputTokens', 8000) || 8000); // 최종 회의록(단일샷/병합)
|
||||||
|
const MEET_EXTRACT_TOKENS = 5000; // 세그먼트 추출 노트
|
||||||
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
||||||
let report: string;
|
let report: string;
|
||||||
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
|
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
|
||||||
@@ -619,7 +634,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
if (transcript.length <= SINGLE_SHOT_MAX) {
|
if (transcript.length <= SINGLE_SHOT_MAX) {
|
||||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||||
const t0 = Date.now();
|
const t0 = Date.now();
|
||||||
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
|
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem, { maxTokens: MEET_REPORT_TOKENS });
|
||||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
||||||
} else {
|
} else {
|
||||||
@@ -642,7 +657,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
|
const extractSeg = async (seg: string, label: string, idx: number, total: number): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다.
|
// callLmSynthesis 가 내부적으로 재시도(반복 억제 강화)까지 수행한다.
|
||||||
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem);
|
const note = await callLmSynthesis(buildMeetExtractPrompt(seg, metadata, idx, total), extractSystem, { maxTokens: MEET_EXTRACT_TOKENS });
|
||||||
if (!note) throw new Error('추출 결과가 비어 있습니다.');
|
if (!note) throw new Error('추출 결과가 비어 있습니다.');
|
||||||
return note.trim();
|
return note.trim();
|
||||||
} catch (segErr: any) {
|
} catch (segErr: any) {
|
||||||
@@ -689,7 +704,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
// ── Reduce: 노트 병합 → 최종 회의록 ──
|
// ── Reduce: 노트 병합 → 최종 회의록 ──
|
||||||
chunk(view, ` 🧪 최종 회의록 병합 중…`);
|
chunk(view, ` 🧪 최종 회의록 병합 중…`);
|
||||||
const t1 = Date.now();
|
const t1 = Date.now();
|
||||||
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem);
|
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem, { maxTokens: MEET_REPORT_TOKENS });
|
||||||
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
|
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
|
||||||
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
|
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
|
||||||
}
|
}
|
||||||
@@ -708,22 +723,80 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 검증 패스 (g1nation.meetVerifyPass, 기본 ON) — 결정·액션·화자번호·과확정·중복 점검 ──
|
// ── 검수 루프 (g1nation.meetVerifyPass, 기본 ON) ─────────────────────────
|
||||||
|
// 검수자: 녹취록(근거 소스) vs 회의록 대조 → 지적. 지적된 부분만 수정하고 재검수를
|
||||||
|
// '검증 통과' 또는 최대 라운드까지 반복(전체 재작성 X). 통과 후 편집자가 보고서 형태로 정리.
|
||||||
if (cfg.get<boolean>('meetVerifyPass', true)) {
|
if (cfg.get<boolean>('meetVerifyPass', true)) {
|
||||||
try {
|
const verifySystem = '당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.';
|
||||||
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
|
|
||||||
const t2 = Date.now();
|
|
||||||
const source = groundingNotes || transcript.slice(0, 28000);
|
const source = groundingNotes || transcript.slice(0, 28000);
|
||||||
const flagged = await callLmSynthesis(
|
const maxRounds = Math.max(1, Math.min(4, cfg.get<number>('meetVerifyMaxRounds', 2)));
|
||||||
buildMeetVerifyPrompt(report, source),
|
let passed = false;
|
||||||
'당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.',
|
let lastFlags = '';
|
||||||
|
for (let round = 1; round <= maxRounds; round++) {
|
||||||
|
try {
|
||||||
|
chunk(view, `🔍 **검수 ${round}/${maxRounds}** — 녹취록과 회의록 대조 중…`);
|
||||||
|
const t2 = Date.now();
|
||||||
|
const flagged = await callLmSynthesis(buildMeetVerifyPrompt(report, source), verifySystem);
|
||||||
|
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n`);
|
||||||
|
if (!flagged || /검증\s*통과/.test(flagged)) {
|
||||||
|
passed = true;
|
||||||
|
chunk(view, ` ✅ 검수 통과\n\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lastFlags = flagged.trim();
|
||||||
|
const issueCount = (lastFlags.match(/❗/g) || []).length;
|
||||||
|
if (round === maxRounds) {
|
||||||
|
// 마지막 라운드에서도 지적이 남으면 더는 수정하지 않고 미해결로 기록.
|
||||||
|
chunk(view, ` ⚠️ 지적 ${issueCount || ''}건 미해결 (최대 라운드 도달)\n\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
chunk(view, ` ✎ 지적 ${issueCount || ''}건 — 해당 부분만 수정 중…`);
|
||||||
|
const t3 = Date.now();
|
||||||
|
const revised = await callLmSynthesis(
|
||||||
|
buildMeetRevisePrompt(report, lastFlags, source), meetSystem, { maxTokens: MEET_REPORT_TOKENS },
|
||||||
);
|
);
|
||||||
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n\n`);
|
// 수정본이 비정상적으로 짧으면(모델 붕괴) 이전 회의록을 지킨다.
|
||||||
if (flagged && !/검증\s*통과/.test(flagged)) {
|
if (revised && revised.trim().length > report.length * 0.5) {
|
||||||
report += `\n\n---\n## ⚠️ 검증 결과 (자동)\n${flagged.trim()}\n`;
|
report = revised.trim();
|
||||||
|
chunk(view, ` ✓ (${Math.round((Date.now() - t3) / 1000)}s)\n\n`);
|
||||||
|
} else {
|
||||||
|
chunk(view, ` ⚠️ 수정본이 비정상적으로 짧아 이전 회의록 유지 → 검수 중단\n\n`);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
chunk(view, `\n⚠️ 검수 ${round} 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!passed && lastFlags) {
|
||||||
|
report += `\n\n---\n## ⚠️ 검수 미해결 지적 (자동 — 확인 필요)\n${lastFlags}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 편집(Editor): 보고서 형태로 구조 정리 (근거 유지·환각 금지) ──
|
||||||
|
if (cfg.get<boolean>('meetEditorPass', true)) {
|
||||||
|
try {
|
||||||
|
chunk(view, `📐 **편집(Editor)** — 보고서 형태로 구조 정리 중…`);
|
||||||
|
const t4 = Date.now();
|
||||||
|
const edited = await callLmSynthesis(
|
||||||
|
buildMeetEditorPrompt(report, source),
|
||||||
|
'당신은 회의록 편집자입니다. 형식과 구조만 다듬고, 근거 소스에 없는 내용을 새로 만들지 않습니다. 한국어로 출력합니다.',
|
||||||
|
{ maxTokens: MEET_REPORT_TOKENS },
|
||||||
|
);
|
||||||
|
if (edited && edited.trim().length > report.length * 0.6) {
|
||||||
|
// 편집자가 없던 사실을 넣지 않았는지 1회 재검증(환각 방지 안전망).
|
||||||
|
const editFlags = await callLmSynthesis(buildMeetVerifyPrompt(edited.trim(), source), verifySystem);
|
||||||
|
if (!editFlags || /검증\s*통과/.test(editFlags)) {
|
||||||
|
report = edited.trim();
|
||||||
|
chunk(view, ` ✓ (${Math.round((Date.now() - t4) / 1000)}s)\n\n`);
|
||||||
|
} else {
|
||||||
|
chunk(view, ` ⚠️ 편집본에서 근거 미확인 항목 발견 → 편집 전 회의록 유지\n\n`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chunk(view, ` ⚠️ 편집본이 비정상적으로 짧아 편집 전 회의록 유지\n\n`);
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
chunk(view, `\n⚠️ 편집 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
chunk(view, report + '\n\n');
|
chunk(view, report + '\n\n');
|
||||||
@@ -777,11 +850,33 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
// `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만.
|
// `/meet confirm` 답변으로 등록 완결. 반복은 첫 1회만.
|
||||||
// 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단.
|
// 같은 녹취 재실행은 해시 레지스트리로 이중 등록 차단.
|
||||||
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
|
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
|
||||||
|
// 기한 없는 확정·기한미정 액션을 작성일(오늘)로 자동 등록 (기본 ON).
|
||||||
|
const dateFallbackToday = (gCfg.get<string>('meetTaskDateFallback', 'today') || 'today') === 'today';
|
||||||
const registeredKeys = loadRegisteredKeys(tHash);
|
const registeredKeys = loadRegisteredKeys(tHash);
|
||||||
const holds: PendingItem[] = [];
|
const holds: PendingItem[] = [];
|
||||||
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
|
let autoOk = 0, autoFail = 0, dupSkipped = 0, pastCount = 0;
|
||||||
const newKeys: string[] = [];
|
const newKeys: string[] = [];
|
||||||
|
|
||||||
|
// ── 액션 상세 자동 확장 (task 노트용) — v2.2.264 ──
|
||||||
|
// 슬림 표엔 '작업 상세'가 없어 task 노트가 앙상하던 문제. 배치 LLM 1회로
|
||||||
|
// 각 액션을 회의 불참자도 이해할 자기완결 설명으로 풀어 씀(근거 기반·환각 금지).
|
||||||
|
let detailByKey = new Map<string, string>();
|
||||||
|
if (gCfg.get<boolean>('meetTaskDetailExpand', true)) {
|
||||||
|
try {
|
||||||
|
chunk(view, `\n🧾 **액션 상세 정리** — task 설명 풀어쓰는 중…`);
|
||||||
|
const dsrc = groundingNotes || transcript.slice(0, 28000);
|
||||||
|
const expanded = await callLmSynthesis(
|
||||||
|
buildTaskDetailPrompt(tasks, meetTitle, dsrc),
|
||||||
|
'당신은 회의록에서 각 액션의 실행 설명을 작성합니다. 근거에 없는 내용을 지어내지 않습니다. 한국어로 출력합니다.',
|
||||||
|
{ maxTokens: MEET_EXTRACT_TOKENS },
|
||||||
|
);
|
||||||
|
detailByKey = parseTaskDetails(expanded, tasks);
|
||||||
|
chunk(view, ` ✓ (${detailByKey.size}/${tasks.length}건)\n`);
|
||||||
|
} catch (e: any) {
|
||||||
|
chunk(view, ` ⚠️ 생략(${e?.message || String(e)})\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`);
|
chunk(view, `\n📝 **Google ${destLabel} 등록 (확신 게이트)**: 액션 아이템 ${tasks.length}건 분류 중…\n`);
|
||||||
for (const task of tasks) {
|
for (const task of tasks) {
|
||||||
const key = taskKey(task.work);
|
const key = taskKey(task.work);
|
||||||
@@ -790,11 +885,11 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`);
|
chunk(view, ` · ⏭️ 이미 등록됨(같은 녹취) — ${task.work}\n`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cls = classifyAction(task, meetingDate, today);
|
const cls = classifyAction(task, meetingDate, today, dateFallbackToday);
|
||||||
if (cls.route === 'hold') {
|
if (cls.route === 'hold') {
|
||||||
holds.push({
|
holds.push({
|
||||||
idx: holds.length + 1,
|
idx: holds.length + 1,
|
||||||
owner: task.owner, work: task.work, detail: task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
|
owner: task.owner, work: task.work, detail: detailByKey.get(key) || task.detail, deliverable: task.deliverable, due: task.due, source: task.source,
|
||||||
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
|
kind: cls.kind, condition: cls.condition, suggestedDate: cls.suggestedDate,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -807,7 +902,7 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
|||||||
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
|
if (past) extra.push('⚠️ 과거 자료 기반 등록 — 이미 완료되었는지 확인이 필요합니다.');
|
||||||
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
|
if (cls.recurNote) extra.push(`↻ 반복 업무 언급(${cls.recurNote}) — 정책상 첫 1회만 등록합니다.`);
|
||||||
const notes = buildNotes({
|
const notes = buildNotes({
|
||||||
detail: task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
|
detail: detailByKey.get(key) || task.detail, meetTitle, owner: task.owner, deliverable: task.deliverable,
|
||||||
dueRaw: task.due, dateLabel: cls.date, source: task.source, extra,
|
dueRaw: task.due, dateLabel: cls.date, source: task.source, extra,
|
||||||
});
|
});
|
||||||
const r = await registerAction(context, {
|
const r = await registerAction(context, {
|
||||||
|
|||||||
@@ -64,14 +64,26 @@ interface LmOpts {
|
|||||||
retries?: number;
|
retries?: number;
|
||||||
/** 기본 샘플링 온도 override. */
|
/** 기본 샘플링 온도 override. */
|
||||||
temperature?: number;
|
temperature?: number;
|
||||||
|
/** 출력 토큰 상한(max_tokens). 미지정이면 payload 에 넣지 않아 서버 기본값을 따른다.
|
||||||
|
* 회의록처럼 긴 출력은 크게 지정해 서버 기본 상한에 잘리는 것을 막는다. */
|
||||||
|
maxTokens?: number;
|
||||||
|
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
||||||
|
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
||||||
|
maxContinuations?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** bridge `/api/lm` 단발 호출 — 샘플링 파라미터까지 받는 내부 코어. */
|
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
||||||
|
interface LmResult { text: string; finish: string; }
|
||||||
|
|
||||||
|
const SELF_REFLECT_RE = /\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i;
|
||||||
|
|
||||||
|
/** LM 단발 호출 — 샘플링/토큰상한을 받고 (정제 텍스트 + finish_reason)을 돌려주는 내부 코어. */
|
||||||
async function callLmOnce(
|
async function callLmOnce(
|
||||||
lmUrl: string, model: string, sys: string, prompt: string,
|
lmUrl: string, model: string, sys: string, prompt: string,
|
||||||
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
||||||
): Promise<string> {
|
maxTokens?: number,
|
||||||
const res = await lmChat(lmUrl, {
|
): Promise<LmResult> {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: sys },
|
{ role: 'system', content: sys },
|
||||||
@@ -81,15 +93,25 @@ async function callLmOnce(
|
|||||||
top_p: 0.85,
|
top_p: 0.85,
|
||||||
top_k: sampling.top_k,
|
top_k: sampling.top_k,
|
||||||
repeat_penalty: sampling.repeat_penalty,
|
repeat_penalty: sampling.repeat_penalty,
|
||||||
}, 120_000);
|
};
|
||||||
const content = res?.choices?.[0]?.message?.content
|
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
||||||
?? res?.choices?.[0]?.text
|
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||||
?? res?.answer
|
const res = await lmChat(lmUrl, payload, 120_000);
|
||||||
?? res?.response
|
const choice = res?.choices?.[0];
|
||||||
?? '';
|
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
||||||
return String(content)
|
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
|
||||||
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
|
const text = String(content).replace(SELF_REFLECT_RE, '').trim();
|
||||||
.trim();
|
return { text, finish };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 이어쓰기 프롬프트 — 이미 쓴 뒷부분을 주고, 끊긴 지점부터 중복 없이 이어붙일 텍스트만 요청. */
|
||||||
|
function buildContinuePrompt(soFarTail: string): string {
|
||||||
|
return '아래는 지금 작성 중이던 문서(회의록/보고서)의 **마지막 부분**이다. 토큰 한도로 중간에 끊겼다.\n'
|
||||||
|
+ '끊긴 바로 그 지점부터 **이어서** 계속 작성하라. 규칙:\n'
|
||||||
|
+ '- 이미 쓴 내용을 반복하거나 요약하지 말 것. 새 서두·인사·머리말 금지.\n'
|
||||||
|
+ '- 끊긴 문장/표 행/섹션을 자연스럽게 이어붙일 **뒷부분만** 출력한다(중복 헤더 재출력 금지).\n'
|
||||||
|
+ '- 원문서와 동일한 형식·문체(개조식·명사형 종결)를 유지한다.\n\n'
|
||||||
|
+ '[지금까지 출력의 마지막 부분]\n' + soFarTail;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -114,6 +136,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
|||||||
+ '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
|
+ '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
|
||||||
|
|
||||||
const maxRetries = Math.max(0, opts?.retries ?? 2);
|
const maxRetries = Math.max(0, opts?.retries ?? 2);
|
||||||
|
const maxCont = Math.max(0, opts?.maxContinuations ?? 3);
|
||||||
let lastErr: unknown = null;
|
let lastErr: unknown = null;
|
||||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||||
// 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓.
|
// 재시도할수록 반복을 더 강하게 억제: repeat_penalty ↑, top_k ↓.
|
||||||
@@ -123,12 +146,25 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
|||||||
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
let out = await callLmOnce(lmUrl, model, sys, prompt, sampling);
|
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
|
||||||
|
let out = first.text;
|
||||||
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
||||||
if (looksDegenerate(out)) {
|
if (looksDegenerate(out)) {
|
||||||
lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.');
|
lastErr = new Error('모델 출력이 붕괴(반복/깨짐)했습니다.');
|
||||||
continue; // 다음 시도에서 반복 억제 강화
|
continue; // 다음 시도에서 반복 억제 강화
|
||||||
}
|
}
|
||||||
|
// ── 이어쓰기: 출력이 토큰 상한(finish=length)으로 잘렸으면 끊긴 지점부터 이어붙인다.
|
||||||
|
// 회의록 후반부·긴 표가 통째로 사라지던 "누락"을 완결성으로 보완한다.
|
||||||
|
let finish = first.finish;
|
||||||
|
for (let c = 0; c < maxCont && finish === 'length'; c++) {
|
||||||
|
let next: LmResult;
|
||||||
|
try {
|
||||||
|
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
|
||||||
|
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
||||||
|
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
||||||
|
out = `${out}\n${next.text}`.trim();
|
||||||
|
finish = next.finish;
|
||||||
|
}
|
||||||
if (/[가-힣][a-z]{2,}/.test(out)) {
|
if (/[가-힣][a-z]{2,}/.test(out)) {
|
||||||
out = await repairKoreanGlitches(out, lmUrl, model);
|
out = await repairKoreanGlitches(out, lmUrl, model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,11 +92,12 @@ ${OUTPUT_FORMAT}`;
|
|||||||
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
|
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
|
||||||
const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화)
|
const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드시 내재화)
|
||||||
회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다.
|
회의록은 *대화 요약 문서*가 아니라 **프로젝트 관리 문서**다. "무엇이 결정됐는가 / 누가(어느 팀이) 무엇을 해야 하는가 / 무엇이 아직 미결인가"를 우선한다.
|
||||||
|
- **완전성 (누락 금지 — 최우선)**: 실제로 논의·결정·요청·질문된 항목은 **하나도 빠뜨리지 않는다.** '핵심 요약'만 3~5줄 다이제스트로 압축하고, **'주요 논의/결정/액션/오픈이슈' 본문 섹션은 다뤄진 모든 안건을 담아 상세히** 쓴다. 개조식·군더더기 최소화는 *문체*일 뿐 *내용 삭제가 아니다* — 각 항목은 수치·조건·대상·기한 등 핵심 디테일을 넣어 **그 한 줄만 봐도 이해되게(자기완결)** 적는다(맥락 없는 앙상한 뼈대 금지). 섹션이 길어지는 것은 정상이다.
|
||||||
- **개조식·명사형 종결 (필수 문체)**: 모든 항목을 개조식으로 — 명사형 또는 '~함·됨·필요·예정·합의·완료·확인' 류로 끝맺는다. **서술형 종결어미('~하였다/했다/합니다/입니다/됩니다/할 예정이다/것으로 보인다') 금지** — 회의록은 일기·기사·서간문이 아니다. 한 항목 = 한 줄, 군더더기·접속사("따라서/그리고/하였으며") 최소화.
|
- **개조식·명사형 종결 (필수 문체)**: 모든 항목을 개조식으로 — 명사형 또는 '~함·됨·필요·예정·합의·완료·확인' 류로 끝맺는다. **서술형 종결어미('~하였다/했다/합니다/입니다/됩니다/할 예정이다/것으로 보인다') 금지** — 회의록은 일기·기사·서간문이 아니다. 한 항목 = 한 줄, 군더더기·접속사("따라서/그리고/하였으며") 최소화.
|
||||||
- 좋은 예: \`- CDN 경로는 위버스 운영툴로 관리 — 합의\` / \`- 보안 솔루션 2개사 PoC·비용 검토 완료, 최종 도입안 미정\`
|
- 좋은 예: \`- CDN 경로는 위버스 운영툴로 관리 — 합의\` / \`- 보안 솔루션 2개사 PoC·비용 검토 완료, 최종 도입안 미정\`
|
||||||
- 나쁜 예: \`- CDN 경로 정보를 위버스 운영툴을 통해 관리하는 방식으로 합의하였습니다.\` / \`- …검토를 완료하였으며, 향후 …결정할 예정이다.\`
|
- 나쁜 예: \`- CDN 경로 정보를 위버스 운영툴을 통해 관리하는 방식으로 합의하였습니다.\` / \`- …검토를 완료하였으며, 향후 …결정할 예정이다.\`
|
||||||
- **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떤 결정이 났는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **쟁점(질문·대립·선택지)은 '주요 논의'에, 결론(답)은 '결정 사항'에** 나눠 적되 — 같은 *결론*을 양쪽에 반복하지 말 것(쟁점 ≠ 결론, 중복 아님).
|
- **이슈 → 결정 흐름 (구조 순서)**: 독자가 "무슨 쟁점이 있었고 → 어떻게 논의됐고 → 어떻게 됐는지"를 따라가도록, '주요 논의 / 쟁점'을 '결정 사항' **앞**에 둔다. **'주요 논의'는 각 주제를 논점→논의→결론으로 그 자리에서 매듭짓고**(결론은 *상태 한 줄* — 미결/조건부/보류거나, 확정이면 짧게), **'결정 사항'은 그중 확정 결정만** 정본으로 다시 크고 명확하게 모은다. 즉 주요 논의의 '결론'은 *상태 요약*, 결정 사항은 *확정 결정의 정본* — 성격이 달라 상태 수준 겹침은 허용하되, **같은 문장을 통째로 복붙하지는 말 것**.
|
||||||
- **한 사실은 한 섹션에만**: 같은 항목을 요약·결정·액션에 중복 기재하지 말 것. 핵심 요약은 미리보기가 아니라 다이제스트다.
|
- **한 사실은 한 섹션에만**: 같은 항목을 통째로 여러 섹션에 반복 기재하지 말 것(예외: 위처럼 주요 논의 '결론' 상태 한 줄 ↔ 결정 사항 정본은 허용). 핵심 요약은 미리보기가 아니라 다이제스트다.
|
||||||
- **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다.
|
- **결정 ↔ 액션 경계**: 담당+행동이 있는 *일감*은 '액션 아이템' 표로 보낸다. '결정 사항'에는 **담당자 없는 순수 방향/정책 결정만** 남긴다(예: "제품 리스트 클릭 이벤트 제거하기로 함"). "구현하기로 함"처럼 누가 할 일이면 액션이다.
|
||||||
- **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정.
|
- **결정 ≠ 검토요청**: "테스트해보고 결정 / 다시 보고 얘기 / 고민해보자"는 결정이 아니다 → 오픈 이슈. 명시적 합의만 결정.
|
||||||
- **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지.
|
- **담당은 팀/역할 우선**: STT 화자번호 매핑이 불확실하므로 개인명보다 **팀/역할**(예: 넥서스개발팀, 기획, UI)로 적는다. 개인이 명단+문맥으로 확실할 때만 병기. "참석자 N" 금지.
|
||||||
@@ -124,9 +125,11 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드
|
|||||||
- 예) 보안 솔루션 2개사(라인컴퍼니·스틸리언) PoC·비용 검토 완료, 최종 도입안·일정 미정
|
- 예) 보안 솔루션 2개사(라인컴퍼니·스틸리언) PoC·비용 검토 완료, 최종 도입안·일정 미정
|
||||||
|
|
||||||
## 주요 논의 / 쟁점
|
## 주요 논의 / 쟁점
|
||||||
**결정에 앞서**, 회의에서 다뤄진 안건·쟁점을 **주제별**로 먼저 제시한다(독자가 "무슨 이슈가 있었고 → 어떤 결정이 났는지" 흐름을 따라가게). 발언 나열이 아니라 **쟁점·대립·선택지 중심**의 개조식. 결론 자체는 여기 쓰지 말고 '결정 사항'으로 — 여기엔 *질문/논거/미해결*만. 각 논점 끝 [mm:ss].
|
**결정에 앞서**, 회의에서 다뤄진 안건을 **주제별**로, 각 주제가 **그 자리에서 매듭지어 보이게 "논점 → 논의 → 결론" 3단**으로 적는다(독자가 이 섹션만 읽어도 "무슨 이슈였고 → 어떻게 오갔고 → 그래서 어떻게 됐는지"를 따라가게). 발언을 그대로 옮기지 말고 **요지로 압축**, 개조식·명사형 종결, 각 줄 끝 [mm:ss].
|
||||||
**[주제명]**
|
**[주제명]**
|
||||||
- 핵심 쟁점/논거 한 줄 (명사형 종결) — [mm:ss]
|
- **논점**: 핵심 쟁점/질문 한 줄 (명사형 종결) — [mm:ss]
|
||||||
|
- **논의**: 실제 오간 주장·근거·선택지·대립의 요지 (녹취/노트에 있는 내용만 — 없으면 이 줄 생략, 지어내지 말 것) — [mm:ss]
|
||||||
|
- **결론**: *그래서 어떻게 됐는지* 한 줄로 매듭 — 다음 중 하나로 표기: \`합의·결정됨\`(핵심 결정이면 아래 '결정 사항'에도 정본으로 크게 표기) / \`미결 — 오픈 이슈로\` / \`조건부: <선행>\` / \`보류·다음 회의로\`. **결정 안 났어도 반드시 이 줄로 현재 상태를 남긴다**(붕 뜨는 쟁점 금지) — [mm:ss]
|
||||||
|
|
||||||
## 결정 사항
|
## 결정 사항
|
||||||
위 쟁점에 대해 **명시적으로 합의·확정된 순수 방향/정책 결정만** 적는다(담당 있는 일감은 액션으로). 개조식·명사형 종결, 각 줄 끝 타임스탬프.
|
위 쟁점에 대해 **명시적으로 합의·확정된 순수 방향/정책 결정만** 적는다(담당 있는 일감은 액션으로). 개조식·명사형 종결, 각 줄 끝 타임스탬프.
|
||||||
@@ -159,7 +162,7 @@ const OUTPUT_FORMAT = `# 작성 원칙 (회의록 품질 — 출력 전 반드
|
|||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
|
||||||
# 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것)
|
# 최종 점검 (출력 전 내부 확인 — 체크 로그는 출력하지 말 것)
|
||||||
□ 모든 문장이 개조식·명사형 종결(서술형 '~하였다/했습니다/할 예정이다' 0개) □ '주요 논의/쟁점'이 '결정 사항'보다 앞에 위치 □ 참석자 명단에 "(참석자 N)" 병기 0개 □ "참석자 N" 토큰 0개 □ 화자가 팀/역할로 귀속(개인 추측 없음) □ 결정사항에 순수 방향/정책만(일감은 액션으로) □ "테스트후결정·검토필요"가 결정에 섞이지 않음 □ 즉시 반박된 가설을 이슈로 격상 안 함 □ 화자간 충돌은 단일값 확정 말고 그대로 표기 □ 같은 사실이 여러 섹션에 중복 안 됨 □ 빈 칸은 "—"(추측 채움 없음) □ 통째 미정 항목은 오픈이슈로 □ 액션 상태가 5종 taxonomy 형식 □ 리스크는 실제 논의됐을 때만 표 띄움 □ 모든 결정·액션·논점에 [mm:ss]
|
□ 모든 문장이 개조식·명사형 종결(서술형 '~하였다/했습니다/할 예정이다' 0개) □ '주요 논의/쟁점'이 '결정 사항'보다 앞에 위치 □ 주요 논의 각 주제가 논점→논의→결론으로 매듭(결론 한 줄 없이 붕 뜬 쟁점 0개) □ 참석자 명단에 "(참석자 N)" 병기 0개 □ "참석자 N" 토큰 0개 □ 화자가 팀/역할로 귀속(개인 추측 없음) □ 결정사항에 순수 방향/정책만(일감은 액션으로) □ "테스트후결정·검토필요"가 결정에 섞이지 않음 □ 즉시 반박된 가설을 이슈로 격상 안 함 □ 화자간 충돌은 단일값 확정 말고 그대로 표기 □ 같은 문장을 통째로 여러 섹션에 복붙 안 함(주요논의 결론↔결정 정본의 상태 수준 겹침은 예외) □ 빈 칸은 "—"(추측 채움 없음) □ 통째 미정 항목은 오픈이슈로 □ 액션 상태가 5종 taxonomy 형식 □ 리스크는 실제 논의됐을 때만 표 띄움 □ 모든 결정·액션·논점에 [mm:ss]
|
||||||
|
|
||||||
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
||||||
|
|
||||||
@@ -227,11 +230,12 @@ export function buildMeetReducePrompt(notes: string, metadata: string): string {
|
|||||||
- **화자 역할 통일**: 각 조각의 '화자 역할 매핑'을 종합해 같은 팀/역할 라벨로 통일한다. 최종 문서에 "참석자 N"이 단 하나도 남으면 안 된다(0개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술.
|
- **화자 역할 통일**: 각 조각의 '화자 역할 매핑'을 종합해 같은 팀/역할 라벨로 통일한다. 최종 문서에 "참석자 N"이 단 하나도 남으면 안 된다(0개). 조각마다 매핑이 엇갈리면 다수·문맥으로 결정하고, 끝내 불명이면 주체 없이 중립 서술.
|
||||||
- **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다.
|
- **전역 헤드라인 추출**: 조각 전체(특히 마지막 조각)를 관통하는 **결론·핵심 요구**를 찾아 '핵심 요약' 맨 앞에 세운다. 한 청크에만 있던 핵심 요구가 묻히지 않게 한다.
|
||||||
- **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보.
|
- **담화 상태 반영**: \`(반박됨)\`·\`(철회됨)\`으로 표시된 가설은 오픈 이슈·결정으로 격상하지 말 것. \`(미합의)\`는 오픈 이슈/논의로. \`(합의)\`만 결정 후보.
|
||||||
|
- **주요 논의 = 논점→논의→결론**: 각 주제에 대해 노트의 '논의(Discussion)'·'리스크/이슈'를 *논의* 줄로, '결정'·담화상태(합의/미합의/보류)를 *결론* 줄로 엮어 논점→논의→결론을 완성한다. **결정이 없던 쟁점도 '미결·보류' 결론을 반드시 달아** 붕 뜨지 않게 한다(결론 줄 없는 쟁점 금지).
|
||||||
- **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만.
|
- **결정/액션 경계**: 담당+행동이 있는 일감은 액션 표로, 결정엔 순수 방향/정책만.
|
||||||
- **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만.
|
- **의미 dedup**: 표현이 달라도 같은 작업·논점은 **하나로 병합**한다(여러 조각에서 중복 추출된 것). 한 사실은 한 섹션에만.
|
||||||
- **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다.
|
- **충돌 보존**: 수치·의견이 화자간 갈리면 단일값으로 확정하지 말고 "A vs B, 미정"으로 오픈 이슈에 적는다.
|
||||||
- **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다.
|
- **빈 칸은 "—"**, 모든 칸이 미정인 액션은 오픈 이슈로. 리스크는 실제 논의된 경우만 표를 만든다.
|
||||||
- **안건 누락 점검**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제가 결과 문서에서 누락되지 않았는지 확인한다(특히 회의 도입부 첫 안건).
|
- **안건 누락 점검 (필수)**: 노트의 '안건/주제' 목록을 체크리스트 삼아, 다뤄진 의제·사실·결정·액션을 **하나도 빠뜨리지 말고** 결과 문서에 반영한다(중복만 병합, 특히 회의 도입부 첫 안건). 출력이 길어지더라도 **항목을 잘라내 분량을 맞추지 말 것** — 완전성이 간결성보다 우선.
|
||||||
- 메타데이터와 노트가 충돌하면 메타데이터 우선.
|
- 메타데이터와 노트가 충돌하면 메타데이터 우선.
|
||||||
|
|
||||||
[메타데이터]
|
[메타데이터]
|
||||||
@@ -255,7 +259,7 @@ export function buildMeetVerifyPrompt(report: string, source: string): string {
|
|||||||
# 점검 항목
|
# 점검 항목
|
||||||
1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.)
|
1. **근거 미확인**: '결정 사항'·'액션 아이템'의 내용(및 수치·날짜)이 근거 소스에서 확인되는가? 못 찾으면 FLAG. (표기·철자 차이는 무시, 의미로 대조.)
|
||||||
2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG.
|
2. **화자번호 잔존**: 회의록 본문에 "참석자 N"/"화자 N"/"발언자 A" 같은 STT 화자번호가 남아 있는가? 있으면 FLAG.
|
||||||
3. **결정 과확정**: '결정 사항'에 든 항목이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(오픈 이슈로 내려야 함).
|
3. **결정 과확정**: '결정 사항'(또는 '주요 논의'의 **결론** 줄에 \`합의·결정됨\`으로 적힌 것)이 소스에서는 "테스트 후 결정 / 다시 보고 얘기 / 고민해보자"처럼 *조건부·미합의*인가? 그렇다면 FLAG(결론을 '미결'로 내려야 함).
|
||||||
4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG.
|
4. **폐기 가설 격상**: 소스에서 즉시 반박·철회된 발언이 회의록의 오픈이슈/리스크/결정으로 격상돼 있는가? 있으면 FLAG.
|
||||||
5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요).
|
5. **액션 중복**: 액션 아이템 중 의미가 사실상 동일한데 별개 행으로 중복된 것이 있는가? 있으면 FLAG(병합 필요).
|
||||||
|
|
||||||
@@ -275,3 +279,97 @@ ${report}
|
|||||||
${source}
|
${source}
|
||||||
\`\`\``;
|
\`\`\``;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [검수 수정] 검증자가 지적한 부분만 고쳐 '수정된 전체 회의록'을 반환한다.
|
||||||
|
* 핵심: 지적되지 않은 내용은 그대로 보존(재작성 금지), 소스에 없는 사실 추가 금지.
|
||||||
|
*/
|
||||||
|
export function buildMeetRevisePrompt(report: string, flagged: string, source: string): string {
|
||||||
|
return `# 임무
|
||||||
|
아래 [회의록]에서 [검수 지적사항]에 해당하는 부분**만** 고쳐, 수정된 **전체 회의록**을 출력하라.
|
||||||
|
|
||||||
|
# 절대 규칙
|
||||||
|
- **지적된 부분만** 수정한다. 지적되지 않은 문장·표·섹션·표현은 **한 글자도 바꾸지 말고 그대로** 둔다(전체 재작성·재요약 금지).
|
||||||
|
- 수정은 [근거 소스]에 맞춘다. 소스에 **없는 새 사실·수치·날짜·항목을 만들어 넣지 말 것**.
|
||||||
|
- 지적 유형별 처리:
|
||||||
|
- **[근거]** 소스에서 확인되면 근거 표기만 보강, 확인 안 되면 그 항목을 삭제하거나 '오픈 이슈'로 강등.
|
||||||
|
- **[화자번호]** "참석자 N"/"화자 N"/"발언자 A"를 팀/역할로 치환(불명이면 주체 없이 중립 서술).
|
||||||
|
- **[과확정]** '결정 사항'에서 빼서 '오픈 이슈'로 이동.
|
||||||
|
- **[폐기가설]** 반박·철회된 항목을 결정/오픈이슈/리스크에서 제거.
|
||||||
|
- **[중복]** 의미가 같은 액션 행을 하나로 병합.
|
||||||
|
- 출력은 수정된 **회의록 본문 하나**뿐. 머리말·설명·코드펜스(\`\`\`) 금지.
|
||||||
|
|
||||||
|
[검수 지적사항]
|
||||||
|
${flagged}
|
||||||
|
|
||||||
|
[근거 소스]
|
||||||
|
\`\`\`
|
||||||
|
${source}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
[회의록]
|
||||||
|
${report}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [편집(Editor)] 검수를 통과한 회의록을 보고서 형태로 구조·가독성만 다듬는다.
|
||||||
|
* 편집자는 작성자가 아니다 — 소스에 없는 사실을 새로 만들지 않고, 기존 항목을 삭제·축약하지 않는다.
|
||||||
|
*/
|
||||||
|
export function buildMeetEditorPrompt(report: string, source: string): string {
|
||||||
|
return `# 임무
|
||||||
|
아래 [회의록]을 최종 **보고서** 형태로 다듬어 출력하라. 너는 편집자이며 작성자가 아니다.
|
||||||
|
|
||||||
|
# 절대 규칙 (환각·누락 금지)
|
||||||
|
- **형식·구조·가독성만** 개선한다: 섹션 순서 정돈, 표 정렬, 개조식·명사형 종결 통일, 어색한 문장 다듬기.
|
||||||
|
- [근거 소스]에 **없는 사실·수치·날짜·이름·항목을 절대 새로 만들지 말 것**(편집 ≠ 창작).
|
||||||
|
- 기존 결정·액션·오픈이슈·논의 항목을 **임의로 삭제·병합·축약하지 말 것** — 모든 정보를 보존한다(완전성 우선).
|
||||||
|
- 화자번호("참석자 N" 등)가 남아 있으면 팀/역할로 정리한다.
|
||||||
|
- 기존 섹션 구조(회의 개요 / 핵심 요약 / 주요 논의·쟁점 / 결정 사항 / 액션 아이템(표) / 오픈 이슈 / 리스크)를 **유지**한다. 없는 섹션을 억지로 만들지 않는다.
|
||||||
|
- 출력은 다듬어진 **보고서 본문 하나**뿐. 머리말·설명·코드펜스(\`\`\`) 금지.
|
||||||
|
|
||||||
|
[근거 소스]
|
||||||
|
\`\`\`
|
||||||
|
${source}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
[회의록]
|
||||||
|
${report}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [액션 상세 확장 — task 노트용] 슬림 액션 표에는 '작업 상세'가 없어 Google Task 노트가
|
||||||
|
* 앙상해진다. 이 프롬프트는 각 액션을 '회의 불참자도 무엇을 해야 하는지 이해할' 자기완결
|
||||||
|
* 설명으로 풀어 쓴다(근거 소스 기반, 환각 금지). 번호는 입력과 1:1로 매핑해 파싱한다.
|
||||||
|
*/
|
||||||
|
export function buildTaskDetailPrompt(
|
||||||
|
actions: { owner: string; work: string; due: string; source: string }[],
|
||||||
|
meetTitle: string,
|
||||||
|
source: string,
|
||||||
|
): string {
|
||||||
|
const list = actions
|
||||||
|
.map((a, i) => `[${i + 1}] (담당: ${a.owner || '미지정'} · 기한: ${a.due || '미정'} · 근거: ${a.source || '—'}) ${a.work}`)
|
||||||
|
.join('\n');
|
||||||
|
return `# 임무
|
||||||
|
아래 [액션 목록]의 각 항목을, 이 회의에 **참석하지 않은 담당자도 무엇을 해야 하는지 이해**할 수 있게 풀어서 설명하라. 각 항목마다 2~3문장.
|
||||||
|
|
||||||
|
# 규칙
|
||||||
|
- 각 설명에 담을 것(가능한 범위에서): **무엇을** 하는 작업인지 / **왜**(배경·목적) / **기대 결과물·완료 기준**.
|
||||||
|
- **[근거 소스]에서 확인되는 내용만** 쓴다. 소스에 없는 수치·인물·마감·결과를 **지어내지 말 것**. 정보가 부족하면 액션 문장을 더 명확히 풀어 쓰는 선에서 멈춘다(억지 창작 금지).
|
||||||
|
- 액션 제목을 그대로 반복하지 말고 실질 설명을 준다. 회의 화자번호("참석자 N")를 쓰지 말고 팀/역할로.
|
||||||
|
- 각 설명은 **한 문단(줄바꿈 없이)**.
|
||||||
|
|
||||||
|
# 출력 형식 (정확히 이 형식, 번호는 입력과 1:1 대응)
|
||||||
|
[1] <설명 한 문단>
|
||||||
|
[2] <설명 한 문단>
|
||||||
|
…
|
||||||
|
|
||||||
|
[회의] ${meetTitle}
|
||||||
|
|
||||||
|
[액션 목록]
|
||||||
|
${list}
|
||||||
|
|
||||||
|
[근거 소스]
|
||||||
|
\`\`\`
|
||||||
|
${source}
|
||||||
|
\`\`\``;
|
||||||
|
}
|
||||||
|
|||||||
@@ -118,17 +118,25 @@ export function nextWeekday(from: Date, korDay: string): Date | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 액션 한 행을 등록 경로로 분류한다. */
|
/** 액션 한 행을 등록 경로로 분류한다. */
|
||||||
export function classifyAction(row: ActionRow, meetingDate: Date, today: Date): Classified {
|
export function classifyAction(row: ActionRow, meetingDate: Date, today: Date, dateFallbackToday = false): Classified {
|
||||||
const status = (row.status || '').trim();
|
const status = (row.status || '').trim();
|
||||||
const suggested = () => {
|
const suggested = () => {
|
||||||
const r = resolveTaskDate(row.due, meetingDate, today);
|
const r = resolveTaskDate(row.due, meetingDate, today);
|
||||||
return r.tentative ? toYmd(addBusinessDays(today, 5)) : r.date;
|
return r.tentative ? toYmd(addBusinessDays(today, 5)) : r.date;
|
||||||
};
|
};
|
||||||
|
// 기한이 해석되면 그 날짜, 해석 불가면 '작성일(오늘)'로 자동 등록.
|
||||||
|
// (g1nation.meetTaskDateFallback='today' — 확정·기한미정이 매번 0건 보류되는 문제 해소)
|
||||||
|
const autoWithDue = (): Classified => {
|
||||||
|
const r = resolveTaskDate(row.due, meetingDate, today);
|
||||||
|
if (!r.tentative) return { route: 'auto', date: r.date, pastNote: r.date < toYmd(today) };
|
||||||
|
return { route: 'auto', date: toYmd(today), pastNote: false };
|
||||||
|
};
|
||||||
|
|
||||||
if (/^진행\s*미정/.test(status)) {
|
if (/^진행\s*미정/.test(status)) {
|
||||||
return { route: 'hold', kind: 'undecided', suggestedDate: suggested() };
|
return { route: 'hold', kind: 'undecided', suggestedDate: suggested() };
|
||||||
}
|
}
|
||||||
if (/^기한\s*미정/.test(status)) {
|
if (/^기한\s*미정/.test(status)) {
|
||||||
|
if (dateFallbackToday) return autoWithDue();
|
||||||
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
|
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
|
||||||
}
|
}
|
||||||
const cond = status.match(/^조건부\s*[::]?\s*(.*)$/);
|
const cond = status.match(/^조건부\s*[::]?\s*(.*)$/);
|
||||||
@@ -148,9 +156,10 @@ export function classifyAction(row: ActionRow, meetingDate: Date, today: Date):
|
|||||||
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
|
return { route: 'hold', kind: 'nodate', suggestedDate: suggested() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 기한미정 보류.
|
// 확정(또는 상태 누락 구표): 기한이 해석되면 자동, 해석 불가면 작성일 폴백 or 보류.
|
||||||
const r = resolveTaskDate(row.due, meetingDate, today);
|
const r = resolveTaskDate(row.due, meetingDate, today);
|
||||||
if (r.tentative) {
|
if (r.tentative) {
|
||||||
|
if (dateFallbackToday) return { route: 'auto', date: toYmd(today), pastNote: false };
|
||||||
return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) };
|
return { route: 'hold', kind: 'nodate', suggestedDate: toYmd(addBusinessDays(today, 5)) };
|
||||||
}
|
}
|
||||||
const past = r.date < toYmd(today);
|
const past = r.date < toYmd(today);
|
||||||
@@ -204,6 +213,26 @@ export function buildNotes(p: { detail: string; meetTitle: string; owner: string
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [액션 상세 확장 파싱] buildTaskDetailPrompt 응답("[n] 설명…")을 액션별 설명 맵으로.
|
||||||
|
* 번호(1-based)를 tasks 인덱스에 대응시키고, 설명 텍스트를 taskKey(work)로 키잉한다.
|
||||||
|
* 형식이 깨진 응답은 매핑 안 된 항목을 그냥 비워 둔다(폴백: buildNotes 가 placeholder).
|
||||||
|
*/
|
||||||
|
export function parseTaskDetails(raw: string, tasks: { work: string }[]): Map<string, string> {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (!raw || !raw.trim()) return map;
|
||||||
|
const re = /\[(\d+)\]\s*([\s\S]*?)(?=\n\s*\[\d+\]\s|$)/g;
|
||||||
|
let m: RegExpExecArray | null;
|
||||||
|
while ((m = re.exec(raw)) !== null) {
|
||||||
|
const idx = Number(m[1]) - 1;
|
||||||
|
const desc = m[2].trim().replace(/\s*\n\s*/g, ' ').trim();
|
||||||
|
if (idx >= 0 && idx < tasks.length && desc) {
|
||||||
|
map.set(taskKey(tasks[idx].work), desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
// ── /meet confirm 답변 파싱 ─────────────────────────────────────────────────
|
// ── /meet confirm 답변 파싱 ─────────────────────────────────────────────────
|
||||||
export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string };
|
export type ConfirmDecision = { idx: number; action: 'ok' | 'skip' | 'date'; date?: string };
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
* 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회,
|
* 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회,
|
||||||
* 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
* 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
||||||
*/
|
*/
|
||||||
import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey } from '../src/features/datacollect/scheduling/meetRegistration';
|
import { classifyAction, parseConfirmArgs, normalizeDate, nextWeekday, taskKey, parseTaskDetails } from '../src/features/datacollect/scheduling/meetRegistration';
|
||||||
import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers';
|
import { parseActionItems } from '../src/features/datacollect/scheduling/calendarHelpers';
|
||||||
import { looksDegenerate } from '../src/features/datacollect/llm';
|
import { looksDegenerate } from '../src/features/datacollect/llm';
|
||||||
|
|
||||||
@@ -64,6 +64,60 @@ describe('classifyAction — 등록 게이트 분기', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('classifyAction — dateFallbackToday (작성일 폴백)', () => {
|
||||||
|
test('확정 + 기한 해석 불가 → 작성일(오늘)로 auto', () => {
|
||||||
|
const c = classifyAction(row('추후 논의', '확정'), MEET, TODAY, true);
|
||||||
|
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기한미정 → 작성일(오늘)로 auto', () => {
|
||||||
|
const c = classifyAction(row('', '기한미정'), MEET, TODAY, true);
|
||||||
|
expect(c).toMatchObject({ route: 'auto', date: '2026-06-11', pastNote: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('폴백 켜져도 명시 기한이 있으면 그 날짜 유지', () => {
|
||||||
|
const c = classifyAction(row('2026-06-20', '확정'), MEET, TODAY, true);
|
||||||
|
expect(c).toMatchObject({ route: 'auto', date: '2026-06-20' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('폴백 켜져도 진행미정은 여전히 보류', () => {
|
||||||
|
const c = classifyAction(row('', '진행미정'), MEET, TODAY, true);
|
||||||
|
expect(c).toMatchObject({ route: 'hold', kind: 'undecided' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('폴백 켜져도 조건부는 여전히 보류', () => {
|
||||||
|
const c = classifyAction(row('', '조건부: 계약 체결 후'), MEET, TODAY, true);
|
||||||
|
expect(c).toMatchObject({ route: 'hold', kind: 'conditional' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseTaskDetails — 액션 상세 확장 파싱', () => {
|
||||||
|
const tasks = [{ work: '국내외 서비스 사례 분석' }, { work: 'API 연동 방식 검토' }];
|
||||||
|
|
||||||
|
test('[n] 블록을 taskKey(work)로 매핑', () => {
|
||||||
|
const raw = '[1] 무신사·트루핏 등 국내외 사례를 조사해 벤치마킹 표로 정리한다.\n[2] 외부 API 연동 가능 여부와 인증 방식을 확인한다.';
|
||||||
|
const m = parseTaskDetails(raw, tasks);
|
||||||
|
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toMatch(/무신사/);
|
||||||
|
expect(m.get(taskKey('API 연동 방식 검토'))).toMatch(/인증 방식/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('여러 줄 설명은 한 문단으로 합쳐짐', () => {
|
||||||
|
const raw = '[1] 첫째 줄.\n 둘째 줄 이어짐.\n[2] 두 번째.';
|
||||||
|
const m = parseTaskDetails(raw, tasks);
|
||||||
|
expect(m.get(taskKey('국내외 서비스 사례 분석'))).toBe('첫째 줄. 둘째 줄 이어짐.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빈/깨진 응답 → 빈 맵(폴백은 buildNotes placeholder)', () => {
|
||||||
|
expect(parseTaskDetails('', tasks).size).toBe(0);
|
||||||
|
expect(parseTaskDetails('설명만 있고 번호 없음', tasks).size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('입력 범위 밖 번호는 무시', () => {
|
||||||
|
const m = parseTaskDetails('[1] ok\n[9] 범위밖', tasks);
|
||||||
|
expect(m.size).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('parseConfirmArgs / normalizeDate', () => {
|
describe('parseConfirmArgs / normalizeDate', () => {
|
||||||
test('혼합 답변 파싱', () => {
|
test('혼합 답변 파싱', () => {
|
||||||
const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026);
|
const { decisions, errors } = parseConfirmArgs('1=6/20 2=ok 3=skip 4=2026-07-01', 2026);
|
||||||
|
|||||||
Reference in New Issue
Block a user