From 2ea5185cd6f873dedaabd18b7dc723aee46bade7 Mon Sep 17 00:00:00 2001 From: g1nation Date: Thu, 4 Jun 2026 16:12:33 +0900 Subject: [PATCH] =?UTF-8?q?feat(weekly):=20/weekly=20=EC=A3=BC=EA=B0=84=20?= =?UTF-8?q?=EB=B3=B4=EA=B3=A0=EC=84=9C(=EA=B8=88=EC=A3=BC/=EC=B0=A8?= =?UTF-8?q?=EC=A3=BC)=20=EC=B6=94=EA=B0=80=20+=20/meet=20=EC=A0=95?= =?UTF-8?q?=ED=99=95=EB=8F=84=20=EA=B0=9C=EC=84=A0=20(v2.2.204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /weekly: 차주 날짜 입력→금주 자동 역산, Google Tasks 기반 금주/차주 보고서. 버킷팅은 코드(예측 가능), 포맷팅만 LLM. 신규 weeklyPrompt.ts + coordination.ts runWeekly. - 기존 CEO /weekly 리뷰 카드(dashboards.ts) 제거 — 이름 충돌 해소, /weekly 일원화. - /meet: 액션아이템에 '작업 상세' 열 추가, 캘린더 notes 가 실제 작업 내용을 담도록 재구성. - /meet: 발언자 추적 복원 + 비선형 회의 재조립 + 근거/할루시네이션 억제 규칙으로 오귀속 감소. Co-Authored-By: Claude Opus 4.8 --- PATCHNOTES.md | 11 + package.json | 2 +- src/features/datacollect/handlers.ts | 15 +- .../datacollect/prompts/meetPrompt.ts | 34 +- .../datacollect/prompts/weeklyPrompt.ts | 96 ++++++ .../datacollect/scheduling/calendarHelpers.ts | 16 +- src/features/teamops/handlers/coordination.ts | 93 +++++ src/features/teamops/handlers/dashboards.ts | 325 +----------------- 8 files changed, 257 insertions(+), 335 deletions(-) create mode 100644 src/features/datacollect/prompts/weeklyPrompt.ts diff --git a/PATCHNOTES.md b/PATCHNOTES.md index d6f6783..8299d89 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -1,5 +1,16 @@ # Astra Patch Notes +## v2.2.204 (2026-06-04) +### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주) +- **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼) +- `/weekly <차주 시작일> <차주 종료일>` — 입력 날짜는 **차주** 기준, **금주**(차주 시작 직전 7일)는 자동 역산해 함께 검색. +- Google Tasks 의 마감/완료일로 금주·차주를 **코드가 버킷팅**(예측 가능), 각 task 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 LLM 이 서술. `[태그]`(예: [이머시브]/[3D App]/[기타])별로 정렬된 `금주/차주` 포맷 출력. +- 신규 [weeklyPrompt.ts](src/features/datacollect/prompts/weeklyPrompt.ts) + [coordination.ts](src/features/teamops/handlers/coordination.ts) `runWeekly`. + +### 🎯 `/meet` 정확도·실용성 개선 +- **캘린더 task 상세 강화** — 액션 아이템 표에 "작업 상세" 열 추가, 캘린더 notes 가 *무슨 작업이고 무엇을 해야 하는지*를 담도록 재구성 ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts) · [handlers.ts](src/features/datacollect/handlers.ts) · [calendarHelpers.ts](src/features/datacollect/scheduling/calendarHelpers.ts)). +- **할루시네이션·발언 오귀속 억제** — "발언자 무시" 규칙을 "발언자 추적"으로 반전, 비선형 회의 재조립("인접 ≠ 연결") + 근거·정확성 규칙(추론 금지, 불명확 시 "확인 필요", 없는 정보 날조 금지) 추가. + ## v2.2.203 (2026-06-01) ### 🐛 기업모드 dev-impl — 빈 깡통 파일 99% 발생 버그 (검출+자동 재작업 기본 ON) **증상**: 사용자가 기획서 + 폴더 주고 "여기 개발해줘" 요청 → ASTRA 가 파일·폴더 만들고 "개발 완료" 보고 → 실제 파일을 열면 **class/함수 본문이 비어 있음** (`def foo(): pass` · 빈 class · imports only). 99% 확률 재발. diff --git a/package.json b/package.json index a847e76..f3b6691 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.203", + "version": "2.2.204", "publisher": "g1nation", "license": "MIT", "icon": "assets/icon.png", diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index c50dba5..60ad61c 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -696,7 +696,20 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode. const { date, tentative } = resolveTaskDate(task.due, meetingDate, today); if (tentative) tentativeCount++; const evTitle = tentative ? `${task.work} (미확정)` : task.work; - const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`; + const detailLine = task.detail?.trim() + ? task.detail.trim() + : '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)'; + const notes = [ + `■ 작업 상세`, + detailLine, + ``, + `■ 맥락`, + `· 회의록: ${meetTitle}`, + `· 담당: ${task.owner || '(미지정)'}`, + `· 기한: ${task.due?.trim() || '(미표기)'} → ${date}${tentative ? ' (미확정·자동 산정)' : ''}`, + ``, + `— Astra /meet 자동 등록`, + ].join('\n'); const successes: string[] = []; const failures: string[] = []; diff --git a/src/features/datacollect/prompts/meetPrompt.ts b/src/features/datacollect/prompts/meetPrompt.ts index e2b4cfe..0bc280f 100644 --- a/src/features/datacollect/prompts/meetPrompt.ts +++ b/src/features/datacollect/prompts/meetPrompt.ts @@ -10,7 +10,8 @@ export function buildMeetPrompt(transcript: string, metadata: string): string { 구조화된 회의록(Actionable Minutes)을 생성한다. # 역할 (Role) -- Fact Extractor: 사실만 추출 +- Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출 +- Attribution Tracker: 누가 무엇을 말했는지 발언 주체를 끝까지 추적해 오귀속을 방지 - Decision Tracker: 결정 여부 구분 - Action Organizer: 실행 항목 구조화 - Context Filter: 불필요한 발언(잡담) 제거 @@ -19,17 +20,26 @@ export function buildMeetPrompt(transcript: string, metadata: string): string { 1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다. # 처리 절차 (Processing Flow) -1. Deconstruction — 잡담 제거, 의미 단위로 분해, 발언자 ID는 무시한다. -2. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다. -3. Decision Logic +1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.** +2. Topic Reclustering — 이 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**). +3. Deconstruction — 잡담을 제거하고 의미 단위로 분해하되, 각 단위에 발언 주체를 보존한다. +4. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다. +5. Decision Logic - 명확한 합의 표현 → Decision - 실행 주체 + 행동 → Action - 제안/의견 → Discussion - - 조건 부족 → Open Issue -4. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다. + - 조건 부족 / 합의 불명확 → Open Issue +6. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다. + +# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지) +- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것. +- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다. +- **녹취록에 없는 숫자·날짜·금액·고유명사·제품명을 만들어내지 말 것.** 불확실하면 "확인 필요"로 둔다. +- 어떤 항목의 근거가 녹취록에서 약하거나 모호하면, 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. +- Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다. # 출력 검증 (Validation) -출력 전 내부적으로 점검한다: Decision은 실제 합의인가 / Action은 실행 가능한가. +출력 전 내부적으로 점검한다: ① 각 발언이 올바른 주체에게 귀속됐는가 ② 인접 발언을 임의로 연결하지 않았는가 ③ Decision은 실제 합의인가 ④ 녹취록에 없는 정보를 추가하지 않았는가 ⑤ Action은 실행 가능한가. 단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다. [메타데이터] @@ -55,7 +65,7 @@ ${transcript} 각 안건마다 아래 구조로: ### [안건 제목] - **현황**: -- **핵심 논의**: +- **핵심 논의**: 쟁점이 되거나 주체가 중요한 발언은 "OOO: ~" 형태로 발언자를 밝힌다. 주체가 불명확하면 이름을 붙이지 말 것. - **결론**: [결정됨 / 논의 중 / 보류] ## 2. 리스크 및 이슈 @@ -65,8 +75,12 @@ ${transcript} ## 4. 오픈 이슈 ## 5. 액션 아이템 -| 담당 | 작업 내용 | 기한 | -| --- | --- | --- | +각 행은 반드시 녹취록 근거로 작성한다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것. +| 담당 | 작업 내용 | 작업 상세 | 기한 | +| --- | --- | --- | --- | + +- **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지) +- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용한다. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것. 위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`; } diff --git a/src/features/datacollect/prompts/weeklyPrompt.ts b/src/features/datacollect/prompts/weeklyPrompt.ts new file mode 100644 index 0000000..e4ca721 --- /dev/null +++ b/src/features/datacollect/prompts/weeklyPrompt.ts @@ -0,0 +1,96 @@ +/** + * `/weekly` 주간 보고서 LLM 프롬프트. + * + * 입력: 캘린더(Google Tasks)에서 버킷팅된 금주/차주 task 목록 + 각 task 의 + * 제목·마감·상태·메모(= /meet·/task 가 넣은 "작업 상세 / 맥락"). 금주/차주 분류는 + * **호출부(coordination.ts)에서 due/completed 날짜로 이미 끝낸 상태**로 들어온다. + * 이 프롬프트는 *재분류하지 않고* 정해진 포맷으로 **서술만** 한다. + * + * 하위 bullet 은 오직 각 task 의 메모에서만 끌어온다 — 없는 사실을 만들지 않는다. + */ + +export interface WeeklyTask { + title: string; + /** 'YYYY-MM-DD' 또는 '' (마감 미정). */ + due: string; + status: 'needsAction' | 'completed'; + /** 완료 시각 'YYYY-MM-DD' (status 'completed' 일 때만). */ + completedYmd?: string; + notes?: string; +} + +export interface WeeklyPromptInput { + thisWeek: WeeklyTask[]; + nextWeek: WeeklyTask[]; + thisRange: [string, string]; + nextRange: [string, string]; +} + +/** /weekly 전용 시스템 프롬프트 — 기본(UX 분석가) 대신 주간 보고 작성자로 역할 고정. */ +export const WEEKLY_SYSTEM = + '당신은 팀 주간 업무 보고서를 작성하는 PM 보조다. ' + + '제공된 task 데이터(제목·마감·상태·메모)에 있는 사실만으로 보고서를 쓴다. ' + + '메모에 없는 일정·숫자·고유명사·결정을 절대 지어내지 않으며, 모든 출력은 한국어로 작성한다.'; + +function serializeTasks(tasks: WeeklyTask[]): string { + if (tasks.length === 0) return '(해당 범위에 task 없음)'; + return tasks.map((t, i) => { + const statusLabel = t.status === 'completed' + ? `완료(${t.completedYmd || '시점 미상'})` + : '진행/예정'; + const notes = (t.notes || '').trim(); + const notesBlock = notes + ? notes.split('\n').map((l) => ` ${l}`).join('\n') + : ' (메모 없음)'; + return [ + `${i + 1}. 제목: ${t.title}`, + ` 마감: ${t.due || '미정'} | 상태: ${statusLabel}`, + ` 메모:`, + notesBlock, + ].join('\n'); + }).join('\n\n'); +} + +export function buildWeeklyPrompt(input: WeeklyPromptInput): string { + const { thisWeek, nextWeek, thisRange, nextRange } = input; + return `# 임무 (Objective) +캘린더(Google Tasks)에서 추출한 금주·차주 작업 목록을 기반으로, **아래 정확한 포맷**의 주간 업무 보고서를 작성한다. 외부 지식 없이 제공된 데이터만 사용한다. + +# 분류 규칙 (이미 끝남 — 재분류 금지) +- 금주/차주 분류는 호출부에서 날짜로 이미 끝냈다. [금주 작업]에 들어온 항목은 [금주] 섹션에, [차주 작업]은 [차주] 섹션에 그대로 배치한다. **임의로 옮기지 말 것.** +- 금주 기간: ${thisRange[0]} ~ ${thisRange[1]} / 차주 기간: ${nextRange[0]} ~ ${nextRange[1]} + +# 작성 규칙 (Rules) +1. 각 task 는 한 줄로: \`: [태그] 작업명 (M/D)\` 형식. 제목 앞의 \`[태그]\`(예: [이머시브], [3D App], [기타])는 그대로 유지한다. + - 날짜는 M/D 형식(예: 6/12). 완료된 작업은 \`(6/4 완료)\`, 예정은 \`(6/8)\`, 마감이 차주인데 금주에 진행 중이면 \`(6/12 완료 예상)\`처럼 메모 근거가 있을 때만 표기. + - 제목 끝의 \`(미확정)\` 같은 자동 꼬리표는 빼고 작업명만 쓴다. +2. 같은 \`[태그]\`끼리 인접하도록 정렬한다. +3. 각 task 아래에 하위 항목(\` - \`)으로 세부 내용을 2~4개 적는다. **이 세부 내용은 오직 해당 task 의 '메모'에서만** 끌어온다(작업 상세·맥락·기한 표기). 메모를 짧은 서술형으로 다듬되, 날짜·대상·수치는 메모에 적힌 그대로 인용한다. +4. **메모에 없는 내용을 추가하지 말 것.** 세부 정보가 부족하면 지어내지 말고 "추가 확인 필요" 또는 메모에 적힌 "(확인 필요)"를 그대로 옮긴다. +5. 해당 섹션에 task 가 하나도 없으면 그 섹션 본문에 \`: (해당 작업 없음)\` 한 줄만 적는다. + +# 출력 포맷 (정확히 이 구조 — 헤더 문구·대괄호 그대로) + +[금주] +[주요 일정] +: [태그] 작업명 (M/D) + - 세부 내용 + - 세부 내용 + +: [태그] 작업명 (M/D) + - 세부 내용 + +[차주] +[예상 일정] +: [태그] 작업명 (M/D) + - 세부 내용 + +--- +[금주 작업] (${thisWeek.length}건) +${serializeTasks(thisWeek)} + +[차주 작업] (${nextWeek.length}건) +${serializeTasks(nextWeek)} + +위 포맷을 정확히 따르고, 보고서 본문만 출력한다(설명·머리말·코드펜스 금지).`; +} diff --git a/src/features/datacollect/scheduling/calendarHelpers.ts b/src/features/datacollect/scheduling/calendarHelpers.ts index 6c061e1..ad58d98 100644 --- a/src/features/datacollect/scheduling/calendarHelpers.ts +++ b/src/features/datacollect/scheduling/calendarHelpers.ts @@ -70,9 +70,13 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): { return { date: toYmd(addBusinessDays(today, 5)), tentative: true }; } -/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */ -export function parseActionItems(report: string): { owner: string; work: string; due: string }[] { - const rows: { owner: string; work: string; due: string }[] = []; +/** + * 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. + * 4열 표(담당 | 작업 내용 | 작업 상세 | 기한)와 구(舊) 3열 표(담당 | 작업 내용 | 기한)를 + * 모두 지원한다. 3열일 때 detail 은 빈 문자열. + */ +export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string }[] { + const rows: { owner: string; work: string; detail: 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; } @@ -83,7 +87,11 @@ 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; // 헤더 - rows.push({ owner: cells[0], work: cells[1], due: cells[2] }); + if (cells.length >= 4) { + rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3] }); + } else { + rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2] }); + } } return rows; } diff --git a/src/features/teamops/handlers/coordination.ts b/src/features/teamops/handlers/coordination.ts index eff61ff..0af5144 100644 --- a/src/features/teamops/handlers/coordination.ts +++ b/src/features/teamops/handlers/coordination.ts @@ -15,6 +15,8 @@ import { registerSlashCommand, chunk } from '../../datacollect/slashRouter'; import { parseTaskOwner } from './_shared'; import { createCalendarEvent, createTask, listTasks, _addDaysDate } from '../../calendar'; import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore'; +import { callLmSynthesis } from '../../datacollect/llm'; +import { buildWeeklyPrompt, WEEKLY_SYSTEM, type WeeklyTask } from '../../datacollect/prompts/weeklyPrompt'; // ─── 공통 헬퍼 — /task 전용 ────────────────────────────────────────────── @@ -563,9 +565,100 @@ async function runStandup(arg: string, view: any, context?: vscode.ExtensionCont return true; } +// ─── /weekly — 캘린더 task 기반 주간 보고서 (금주/차주) ────────────────── +// `/weekly <차주시작일> <차주종료일>` — 입력한 날짜는 **차주** 기준. +// 금주(차주 시작 직전 7일)는 자동 역산해 함께 검색한다. 버킷팅은 due/completed +// 날짜로 코드가 처리(예측 가능), 서술 포맷팅만 LLM(meet 메모 → narrative bullet). + +async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise { + if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /weekly 실행 불가.\n'); return true; } + + const tokens = arg.trim().split(/\s+/).filter(Boolean); + if (tokens.length < 2) { + chunk(view, [ + '\n📋 **/weekly — 주간 업무 보고서 (금주/차주)**', + '', + '사용법: `/weekly <차주 시작일> <차주 종료일>`', + ' · 입력한 두 날짜는 **차주** 기준입니다.', + ' · 금주(차주 시작 직전 7일)는 자동으로 역산해 함께 검색합니다.', + '', + '날짜 형식: `YYYY-MM-DD` · `YYYY/MM/DD` · `YY/MM/DD`', + '', + '예시: `/weekly 2026-06-08 2026-06-12` → 차주 6/8~6/12, 금주 6/1~6/7', + '', + '캘린더(Google Tasks)에 등록된 작업의 마감·완료일로 금주/차주를 나누고,', + '각 작업의 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 채웁니다.', + '※ `/meet`·`/task` 로 등록한 작업이 소스입니다 — 메모가 충실할수록 보고서가 정확합니다.', + '', + ].join('\n')); + return true; + } + + let nextStart = parseFlexibleDate(tokens[0]); + let nextEnd = parseFlexibleDate(tokens[1]); + if (!nextStart || !nextEnd) { + chunk(view, `\n❌ 날짜 형식 오류 — "${tokens[0]}" / "${tokens[1]}". 사용 가능: YYYY-MM-DD · YYYY/MM/DD · YY/MM/DD.\n`); + return true; + } + if (nextStart > nextEnd) { [nextStart, nextEnd] = [nextEnd, nextStart]; } + + // 금주 = 차주 시작 직전 7일 (차주 시작 -7 ~ 차주 시작 -1). + const thisStart = _addDaysDate(nextStart, -7); + const thisEnd = _addDaysDate(nextStart, -1); + + chunk(view, `\n📊 **주간 보고서**\n · 금주: ${thisStart} ~ ${thisEnd}\n · 차주: ${nextStart} ~ ${nextEnd}\n`); + chunk(view, '\n📥 Tasks 가져오는 중...\n'); + + const result = await listTasks(context, { showCompleted: true, maxResults: 300 }); + if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; } + + const inRange = (d: string, a: string, b: string) => !!d && d >= a && d <= b; + const toWeekly = (t: typeof result.tasks[number]): WeeklyTask => ({ + title: t.title, + due: t.due || '', + status: t.status, + completedYmd: t.completed ? t.completed.slice(0, 10) : undefined, + notes: t.notes, + }); + + const thisWeek: WeeklyTask[] = []; + const nextWeek: WeeklyTask[] = []; + for (const t of result.tasks) { + const completedYmd = (t.completed || '').slice(0, 10); + if (inRange(t.due || '', nextStart, nextEnd)) { + nextWeek.push(toWeekly(t)); + } else if (inRange(t.due || '', thisStart, thisEnd) || inRange(completedYmd, thisStart, thisEnd)) { + thisWeek.push(toWeekly(t)); + } + } + + if (thisWeek.length === 0 && nextWeek.length === 0) { + chunk(view, `\nℹ️ 금주(${thisStart}~${thisEnd})·차주(${nextStart}~${nextEnd}) 범위에 등록된 task 가 없습니다.\n (\`/meet\` 또는 \`/task\` 로 등록하면 잡힙니다.)\n`); + return true; + } + + chunk(view, `\n· 금주 ${thisWeek.length}건 · 차주 ${nextWeek.length}건 → 보고서 합성 중…\n`); + + let report = ''; + try { + report = await callLmSynthesis( + buildWeeklyPrompt({ thisWeek, nextWeek, thisRange: [thisStart, thisEnd], nextRange: [nextStart, nextEnd] }), + WEEKLY_SYSTEM, + ); + if (!report) throw new Error('LLM 응답이 비어 있습니다.'); + } catch (e: any) { + chunk(view, `\n⚠️ 보고서 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`); + return true; + } + + chunk(view, '\n' + report.trim() + '\n'); + return true; +} + // ─── 등록 ───────────────────────────────────────────────────────────────── registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask }); +registerSlashCommand({ name: '/weekly', description: '캘린더 task 기반 주간 보고서 — 차주 날짜 입력, 금주 자동 역산 (금주/차주 포맷)', handler: runWeekly }); registerSlashCommand({ name: '/decisions', description: 'Chronicle 결정 기록(ADR) 검색 — 키워드/담당자로 필터', handler: runDecisions }); registerSlashCommand({ name: '/onesie', description: '멤버별 1:1 미팅 준비 카드 — Tasks 진행 + 최근 결정 + 대화 토픽 제안', handler: runOnesie }); registerSlashCommand({ name: '/blocked', description: '전사 across 지연·블로커 한 화면 — 일일 아침 ritual', handler: runBlocked }); diff --git a/src/features/teamops/handlers/dashboards.ts b/src/features/teamops/handlers/dashboards.ts index c2fbc11..5b82b0b 100644 --- a/src/features/teamops/handlers/dashboards.ts +++ b/src/features/teamops/handlers/dashboards.ts @@ -1,11 +1,13 @@ /** - * TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬). + * TeamOps Dashboards — /morning · /evening · /cohort (CEO 일·월 리듬). * * v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + - * hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공. + * hire + runway + Chronicle ADR) 으로 일/월 단위 시야 제공. + * (구 /weekly CEO 리뷰 카드는 v2.2.204 에서 제거 — /weekly 는 task 기반 금주/차주 + * 보고서로 일원화, coordination.ts 참조.) * - * 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek, - * _morningActions 등) 는 이 파일 안에. + * 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_morningActions 등) 는 + * 이 파일 안에. */ import * as vscode from 'vscode'; @@ -484,323 +486,8 @@ async function runCohort(arg: string, view: any): Promise { return true; } -// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ──────────────────────────── - -function _isoWeek(d: Date): { year: number; week: number; label: string } { - const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); - const dayNum = (target.getUTCDay() + 6) % 7; - target.setUTCDate(target.getUTCDate() - dayNum + 3); - const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4)); - const firstDayNum = (firstThursday.getUTCDay() + 6) % 7; - firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3); - const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000)); - return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` }; -} - -interface WeeklyWindow { - startIso: string; - endIso: string; - startMs: number; - endMs: number; - label: string; -} - -function _thisWeekWindow(now: Date = new Date()): WeeklyWindow { - const dayOfWeek = now.getDay(); - const daysFromMonday = (dayOfWeek + 6) % 7; - const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday); - const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59); - const { label: yw } = _isoWeek(monday); - const startIso = monday.toISOString().slice(0, 10); - const endIso = sunday.toISOString().slice(0, 10); - const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`; - const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`; - return { startIso, endIso, startMs: monday.getTime(), endMs: sunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` }; -} - -function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow { - const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000); - const priorSunday = new Date(thisWeek.startMs - 1000); - const { label: yw } = _isoWeek(priorMonday); - const startIso = priorMonday.toISOString().slice(0, 10); - const endIso = priorSunday.toISOString().slice(0, 10); - const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`; - const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`; - return { startIso, endIso, startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` }; -} - -interface WeeklyAggregate { - taskCompleted: number; - taskByOwner: Map; - customerEvents: number; - customerNewCount: number; - customerRenewCount: number; - customerRiskCount: number; - customerChurnCount: number; - customerNewMrr: number; - hireEvents: number; - hireMoved: number; - hireAdded: number; - hireHired: number; - runwayExpense: number; - runwayRevenue: number; - runwayLastCash: number | null; - runwayFirstCash: number | null; - adrCount: number; -} - -function _aggregateWeek( - win: WeeklyWindow, - completedTasks: any[], - cevs: CustomerEvent[], - hevs: HireEvent[], - rs: RunwayEntry[], - adrs: { date: string; title: string }[], -): WeeklyAggregate { - const agg: WeeklyAggregate = { - taskCompleted: 0, taskByOwner: new Map(), - customerEvents: 0, customerNewCount: 0, customerRenewCount: 0, - customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0, - hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0, - runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null, - adrCount: 0, - }; - for (const t of completedTasks) { - if (!t.completed) continue; - const ms = Date.parse(t.completed); - if (ms < win.startMs || ms > win.endMs) continue; - agg.taskCompleted++; - const { owner } = parseTaskOwner(t.title, t.notes); - const k = owner || '(미지정)'; - agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1); - } - for (const e of cevs) { - const ms = Date.parse(e.timestamp); - if (ms < win.startMs || ms > win.endMs) continue; - agg.customerEvents++; - if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; } - else if (e.type === 'renew') agg.customerRenewCount++; - else if (e.type === 'risk') agg.customerRiskCount++; - else if (e.type === 'churn') agg.customerChurnCount++; - } - for (const e of hevs) { - const ms = Date.parse(e.timestamp); - if (ms < win.startMs || ms > win.endMs) continue; - agg.hireEvents++; - if (e.type === 'add') agg.hireAdded++; - else if (e.type === 'stage') agg.hireMoved++; - else if (e.type === 'hire') agg.hireHired++; - } - const cashInWin = rs - .filter((r) => r.type === 'snapshot') - .filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; }) - .sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || '')); - if (cashInWin.length > 0) { - agg.runwayFirstCash = cashInWin[0].amount; - agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount; - } - for (const e of rs) { - const ms = Date.parse(e.timestamp); - if (ms < win.startMs || ms > win.endMs) continue; - if (e.type === 'expense') agg.runwayExpense += e.amount; - else if (e.type === 'revenue') agg.runwayRevenue += e.amount; - } - for (const a of adrs) { - if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++; - } - return agg; -} - -function _deltaSymbol(now: number, prev: number): string { - if (prev === 0 && now === 0) return '→'; - if (prev === 0) return `↑${now}`; - const diff = now - prev; - if (diff > 0) return `↑${diff}`; - if (diff < 0) return `↓${Math.abs(diff)}`; - return '→'; -} - -async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise { - const trimmed = arg.trim().toLowerCase(); - if (trimmed === 'help' || trimmed === '?') { - chunk(view, [ - '\n📅 **/weekly — 주간 리뷰 카드 (대표용)**', - '', - '사용법:', - ' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비', - '', - '`/standup weekly` 와 차이:', - '- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)', - '- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)', - '', - '데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n', - ].join('\n')); - return true; - } - - const thisWeek = _thisWeekWindow(); - const priorWeek = _priorWeekWindow(thisWeek); - chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`); - - let completedTasks: any[] = []; - let tasksError: string | undefined; - if (context) { - try { - const res = await listTasks(context, { showCompleted: true, maxResults: 500 }); - if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed'); - else tasksError = res.error; - } catch (e: any) { tasksError = e?.message || String(e); } - } - - const cevs = readCustomerEvents(); - const hevs = readHireEvents(); - const rs = readRunway(); - - const adrs: { date: string; title: string }[] = []; - try { - const folders = vscode.workspace.workspaceFolders; - if (folders && folders.length > 0 && context) { - const cs = new ChronicleProjectStore(context); - const projects = cs.getAll(); - for (const p of projects) { - const decisionsDir = path.join(p.recordRoot, 'decisions'); - if (!fs.existsSync(decisionsDir)) continue; - const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md')); - for (const f of files) { - try { - const full = path.join(decisionsDir, f); - const stat = fs.statSync(full); - const d = new Date(stat.mtimeMs).toISOString().slice(0, 10); - const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' '); - adrs.push({ date: d, title }); - } catch { /* skip */ } - } - } - } - } catch { /* ignore */ } - - const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs); - const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs); - - chunk(view, '\n## ✅ 이번 주 진척\n'); - if (tasksError) chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`); - else if (aggNow.taskCompleted === 0) chunk(view, '- _완료된 작업 없음._\n'); - else { - chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`); - const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]); - for (const [owner, n] of ranked) { - const prev = aggPrev.taskByOwner.get(owner) || 0; - chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`); - } - } - - if (aggNow.customerEvents > 0) { - chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`); - if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`); - if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`); - if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`); - if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`); - } - - if (aggNow.hireEvents > 0) { - chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`); - if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`); - if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`); - if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`); - } - - if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) { - chunk(view, '\n### 💰 재무\n'); - if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) { - const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue; - chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`); - } - if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) { - const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash; - chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`); - } else if (aggNow.runwayLastCash !== null) { - chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`); - } - } - - if (aggNow.adrCount > 0) { - chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`); - const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5); - for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`); - } - - chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`); - chunk(view, `- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`); - chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`); - chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`); - const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue; - const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue; - if (burnNow !== 0 || burnPrev !== 0) { - const diff = burnNow - burnPrev; - const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→'; - chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`); - } - chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`); - - chunk(view, '\n## 🌅 다음 주 준비\n'); - const customerStates = computeCustomerStates(); - const upcoming = Array.from(customerStates.values()) - .filter((c) => c.status !== 'churned') - .map((c) => ({ c, days: _daysUntil(c.renewalAt) })) - .filter((x) => x.days !== null && x.days >= 0 && x.days <= 14) - .sort((a, b) => (a.days as number) - (b.days as number)); - if (upcoming.length > 0) { - chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`); - for (const { c, days } of upcoming.slice(0, 5)) { - const emoji = (days as number) <= 7 ? '🔴' : '🟡'; - chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`); - } - } - - let nextWeekDue = 0; - if (!tasksError && context) { - try { - const res = await listTasks(context, { showCompleted: false, maxResults: 300 }); - if (res.ok) { - const startNext = new Date(thisWeek.endMs + 1000); - const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000); - const startIso = startNext.toISOString().slice(0, 10); - const endIso = endNext.toISOString().slice(0, 10); - nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length; - } - } catch { /* ignore */ } - } - if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`); - - const stalled = Array.from(computeCandidateStates().values()) - .filter((c) => !TERMINAL_STAGES.has(c.stage)) - .filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000); - if (stalled.length > 0) { - chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`); - for (const c of stalled.slice(0, 3)) { - const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000)); - chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`); - } - } - - const reflections = [ - '이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?', - '이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?', - '이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.', - '이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?', - '이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?', - '이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?', - ]; - const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7)); - const idx = weekKey % reflections.length; - chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`); - chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n'); - return true; -} - // ─── 등록 ───────────────────────────────────────────────────────────────── registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning }); registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening }); registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort }); -registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });