7bec20620a
아키텍처 감사 결과 HIGH 2건 + MED 2건 + LOW 1건 — 7 라운드 정리 시리즈.
기능 변경 없음, 순수 구조 정리.
**slashRouter.ts: 4,174 → 201줄 (–3,973, –95%)**
**agent.ts: 1,617 → 1,551줄 (–66, –4%)**
v2.2.195: eventSourcedStore + SystemPromptBlock registry
- createEventStore<E>(opts) — 4 store (customers/hire/runway/feedback) I/O 240줄 중복 제거
- _turnCtx 5 named string field → 1 Map<string, string> (새 verification block 추가 25곳→1곳)
- buildAstraModeSystemPrompt: 5 ternary gate + 5 위치 → 1 for-loop join
v2.2.196: trackers cluster split
- src/features/teamops/handlers/_shared.ts (fmtKrw/parseAmount/daysUntil/parseTaskOwner/stageEmoji/STAGE_ORDER/TERMINAL_STAGES)
- src/features/teamops/handlers/trackers.ts (runway/customers/hire)
- src/features/teamops/handlers/index.ts (barrel)
- extension.ts 에 side-effect import (순환 import 회피)
v2.2.197: mtimeFileCache + PostAnswerHook registry
- src/lib/mtimeFileCache.ts — createMtimeFileCache<T>(name, parse) (terminologyBlock + termValidator 2-cache invariant 자동화)
- src/agent/postAnswerHooks/{types,index}.ts — Devil/SelfCheck/TermValidator 3 _maybeX method → 1 runPostAnswerHooks(ctx) loop
- agent.ts –66줄
v2.2.198: dashboards cluster split
- src/features/teamops/handlers/dashboards.ts (morning/evening/cohort/weekly)
v2.2.199: coordination + communication clusters split
- src/features/teamops/handlers/coordination.ts (task/decisions/onesie/blocked/standup)
- src/features/teamops/handlers/communication.ts (draft/feedback)
- callLmSynthesis export 노출 (communication 이 사용)
- 옛 parseTaskOwner local 정의 삭제 (_shared.ts 사용)
v2.2.200: system cluster split
- src/features/system/handlers.ts (memory/glossary/help)
v2.2.201: datacollect cluster split + LLM 인프라 추출
- src/features/datacollect/handlers.ts (research/benchmark/youtube/blog/wikify/meet)
- src/features/datacollect/llm.ts (callLmSynthesis + repairKoreanGlitches + bridgeErrorRemedy)
- slashRouter import 4개로 축소: vscode/logInfo/getBridgeBaseUrl/bridgeErrorRemedy
**최종 slashRouter (201줄):**
- REGISTRY Map + registerSlashCommand/listSlashCommands/isSlashCommand
- handleSlashCommand (dispatcher + 에러 처리)
- Webview interface + chunk helper
- getRecentSlashCommands ring buffer (actionability scoring 용)
**미래 부담 감소 metrics:**
- 새 슬래시 명령: god-file 끝에 함수 + register → 1 파일 + 1 register call
- 새 verification block: 5곳 편집 → 1 set call
- 새 event store: 60줄 boilerplate → createEventStore 한 줄
- 새 post-answer hook: 3 step → 1 push
- 새 mtime cache: Map + invariant 관리 → createMtimeFileCache 한 줄
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
14 KiB
TypeScript
256 lines
14 KiB
TypeScript
/**
|
||
* TeamOps Communication — /draft · /feedback (외부 출력·기록).
|
||
*
|
||
* v2.2.199 에서 slashRouter.ts 에서 분리. (원래는 v2.2.200 예정이었으나 coordination
|
||
* 추출 시 register 라인이 인접해 묶여 함께 진행.)
|
||
*/
|
||
|
||
import * as fs from 'fs';
|
||
import * as vscode from 'vscode';
|
||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||
import { callLmSynthesis } from '../../datacollect/llm';
|
||
import {
|
||
appendFeedback, readFeedback, getFeedbackFilePath, countFeedback,
|
||
type FeedbackEntry,
|
||
} from '../../feedback/feedbackStore';
|
||
|
||
// ─── /draft — 외부 커뮤니케이션 초안 ─────────────────────────────────────
|
||
|
||
const DRAFT_TYPES: Record<string, { label: string; systemPrompt: string }> = {
|
||
email: {
|
||
label: '이메일',
|
||
systemPrompt: '한국어 비즈니스 이메일 초안. 격식 있되 과도하게 딱딱하지 않게. 인사 / 본문(목적·요청·맥락) / 맺음말 구조. 100~300자.',
|
||
},
|
||
slack: {
|
||
label: '슬랙/메신저 메시지',
|
||
systemPrompt: '슬랙·메신저용 짧고 명확한 한국어 메시지. 캐주얼하지만 프로페셔널. 50~150자 내. 필요하면 불릿 사용.',
|
||
},
|
||
blog: {
|
||
label: '블로그 포스트',
|
||
systemPrompt: '블로그 포스트 초안 한국어. 후크가 있는 도입부 + 본문 3~5개 섹션 + 결론. 800~2000자. 마크다운 헤더(##) 사용.',
|
||
},
|
||
newsletter: {
|
||
label: '뉴스레터',
|
||
systemPrompt: '뉴스레터용 한국어. 친근하면서 정보성. 헤드라인 + 본문 + 다음 액션 권유. 300~600자.',
|
||
},
|
||
'investor-update': {
|
||
label: '투자자 월간 업데이트',
|
||
systemPrompt: '투자자/이해관계자용 월간 업데이트 한국어. 구조: ① 핵심 지표 ② 이번 달 성과 ③ 과제·이슈 ④ 다음 우선순위 ⑤ ask (필요한 도움). 격식, 정량 지표 우선.',
|
||
},
|
||
proposal: {
|
||
label: '비즈니스 제안서',
|
||
systemPrompt: '비즈니스 제안서 초안 한국어. 구조: 배경 / 제안 내용 / 기대 효과 / 일정 / (가능하면) 비용. 격식, 명확.',
|
||
},
|
||
};
|
||
|
||
async function runDraft(arg: string, view: any): Promise<boolean> {
|
||
const tokens = arg.trim().split(/\s+/);
|
||
if (!arg.trim() || tokens.length < 2) {
|
||
const typeList = Object.entries(DRAFT_TYPES).map(([k, v]) => ` \`${k}\` — ${v.label}`).join('\n');
|
||
chunk(view, [
|
||
'\n📋 **/draft [유형] [요청] — 외부 커뮤니케이션 초안**',
|
||
'',
|
||
'사용법: `/draft <유형> <요청 내용>`',
|
||
'',
|
||
'유형 목록:',
|
||
typeList,
|
||
'',
|
||
'예시:',
|
||
' `/draft email 김 대표님께 미팅 일정 조율 요청, 다음 주 가능 시간 3개 제안`',
|
||
' `/draft slack 디자이너에게 메인 시안 1차 컨펌 요청, 금요일까지 회신 부탁`',
|
||
' `/draft blog v2.2 릴리즈 노트 — Tasks 통합 및 4인 팀 운영 기능 소개`',
|
||
' `/draft investor-update 5월 월간 — MAU 30% 성장, 결제 흐름 개선 완료, 다음 달 신규 출시`',
|
||
'',
|
||
'※ Settings 의 `g1nation.teamVoiceGuide` 에 팀 보이스 가이드(말투/금기어/자주 쓰는 표현)를 저장하면 모든 초안에 자동 반영.',
|
||
'',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
const typeKey = tokens[0].toLowerCase();
|
||
const typeDef = DRAFT_TYPES[typeKey];
|
||
if (!typeDef) {
|
||
chunk(view, `\n❌ 알 수 없는 유형: \`${typeKey}\`. 사용 가능: ${Object.keys(DRAFT_TYPES).join(' · ')}\n`);
|
||
return true;
|
||
}
|
||
const request = tokens.slice(1).join(' ').trim();
|
||
if (!request) { chunk(view, '\n❌ 요청 내용 없음.\n'); return true; }
|
||
|
||
const voiceGuide = (vscode.workspace.getConfiguration('g1nation').get<string>('teamVoiceGuide', '') || '').trim();
|
||
chunk(view, `\n📝 **${typeDef.label} 초안 작성 중**\n · 요청: ${request}\n · ${voiceGuide ? '팀 보이스 가이드 적용 (' + voiceGuide.length + '자)' : '팀 보이스 가이드 없음 (g1nation.teamVoiceGuide 설정 시 자동 반영)'}\n`);
|
||
|
||
const systemPrompt = [
|
||
typeDef.systemPrompt,
|
||
'',
|
||
voiceGuide ? `[팀 보이스 가이드 — 반드시 준수]\n${voiceGuide}` : '',
|
||
'',
|
||
'출력 형식: 초안 본문만. "네, 알겠습니다" 같은 인사·메타 설명 금지. 사용자가 그대로 복사해 보낼 수 있는 형태.',
|
||
].filter(Boolean).join('\n');
|
||
|
||
try {
|
||
const draft = await callLmSynthesis(request, systemPrompt);
|
||
if (!draft || !draft.trim()) { chunk(view, '\n❌ 초안 생성 실패 (LLM 빈 응답).\n'); return true; }
|
||
chunk(view, `\n---\n${draft.trim()}\n---\n`);
|
||
} catch (e: any) {
|
||
chunk(view, `\n❌ 초안 생성 실패: ${e?.message ?? String(e)}\n`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// ─── /feedback — 고객 피드백 누적 + 패턴 분석 ───────────────────────────
|
||
|
||
const FEEDBACK_CATEGORIZE_PROMPT = [
|
||
'당신은 고객 피드백 분류기.',
|
||
'',
|
||
'[입력] 사용자가 제공하는 고객 피드백 텍스트 한 건.',
|
||
'',
|
||
'[출력 형식 — 정확히 이 JSON 한 줄, 다른 텍스트/설명 절대 금지]',
|
||
'{"categories":["..."],"sentiment":"positive|neutral|negative"}',
|
||
'',
|
||
'[규칙]',
|
||
'- categories: 1~3개. 짧은 한국어 단어. 일관된 분류 (예: UX, 결제, 성능, 안정성, 가격, 신뢰, 기능 요청, 버그, 사용성, 디자인, 고객지원). 명확하지 않으면 "기타".',
|
||
'- sentiment: 긍정 호평 = positive, 단순 질문/중립 = neutral, 불만/버그/요청 = negative.',
|
||
'- JSON 외 어떤 문자도 출력하지 마시오. 마크다운 코드블록도 금지.',
|
||
].join('\n');
|
||
|
||
const FEEDBACK_SUMMARY_PROMPT = [
|
||
'당신은 고객 피드백 분석가. 사용자가 제공한 누적 피드백 데이터(JSON Lines)를 보고',
|
||
'*패턴 분석 리포트* 를 한국어 마크다운으로 작성한다. 외부 정보 추측 금지 — 주어진 데이터에서만.',
|
||
'',
|
||
'[출력 형식 — 정확히 이 구조]',
|
||
'',
|
||
'## 카테고리 분포',
|
||
'- 카테고리명 (N건, X%): 핵심 패턴 한 줄',
|
||
'- ...',
|
||
'',
|
||
'## 감정 분포',
|
||
'- 부정: N건 (X%)',
|
||
'- 중립: N건 (X%)',
|
||
'- 긍정: N건 (X%)',
|
||
'',
|
||
'## 반복 패턴 Top 3',
|
||
'구체적 인용 1-2개씩 포함. "여러 명이 X 에 대해 Y 하다고 언급" 형태.',
|
||
'1. ...',
|
||
'2. ...',
|
||
'3. ...',
|
||
'',
|
||
'## 추천 액션 (대표 의사결정 참고용)',
|
||
'데이터에서 *명확하게* 보이는 신호만. 단정적 단어("반드시" 등) 금지, "검토 권장" 톤.',
|
||
'- ...',
|
||
].join('\n');
|
||
|
||
async function feedbackSave(text: string, view: any): Promise<void> {
|
||
if (!text.trim()) { chunk(view, '\n❌ 피드백 텍스트가 비어 있습니다.\n'); return; }
|
||
|
||
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||
const initialEntry: FeedbackEntry = { id, timestamp: new Date().toISOString(), text: text.trim() };
|
||
const saveResult = appendFeedback(initialEntry);
|
||
if (!saveResult.ok) { chunk(view, `\n❌ 저장 실패: ${saveResult.error}\n`); return; }
|
||
chunk(view, `\n📥 **피드백 저장됨** (id: \`${id.slice(0, 13)}\`)\n · 누적 ${countFeedback()}건\n`);
|
||
|
||
chunk(view, '\n🤖 카테고리 자동 분류 중...\n');
|
||
try {
|
||
const llmOut = await callLmSynthesis(text.trim(), FEEDBACK_CATEGORIZE_PROMPT);
|
||
const jsonMatch = llmOut.match(/\{[\s\S]*\}/);
|
||
if (!jsonMatch) { chunk(view, ' ⚠️ 분류 실패 (LLM 응답에 JSON 없음). 원본은 저장됨, 수동으로 분류 추가 가능.\n'); return; }
|
||
const parsed = JSON.parse(jsonMatch[0]);
|
||
const categories: string[] = Array.isArray(parsed.categories) ? parsed.categories.map(String).slice(0, 3) : [];
|
||
const sentiment = ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : undefined;
|
||
const enriched: FeedbackEntry = { ...initialEntry, categories, sentiment };
|
||
const all = readFeedback().map((e) => (e.id === id ? enriched : e));
|
||
const filePath = getFeedbackFilePath();
|
||
if (filePath) fs.writeFileSync(filePath, all.map((e) => JSON.stringify(e)).join('\n') + '\n', 'utf-8');
|
||
chunk(view, ` · 카테고리: ${categories.length > 0 ? categories.join(', ') : '(없음)'}\n · 감정: ${sentiment ?? '(미분류)'}\n`);
|
||
} catch (e: any) {
|
||
chunk(view, ` ⚠️ 분류 실패: ${e?.message || String(e)} (원본은 저장됨)\n`);
|
||
}
|
||
}
|
||
|
||
function feedbackList(filterCategory: string | undefined, view: any): void {
|
||
const all = readFeedback();
|
||
const filtered = filterCategory
|
||
? all.filter((e) => (e.categories || []).some((c) => c === filterCategory || c.toLowerCase() === filterCategory.toLowerCase()))
|
||
: all;
|
||
if (filtered.length === 0) {
|
||
chunk(view, filterCategory ? `\nℹ️ 카테고리 "${filterCategory}" 매치 0건.\n` : '\nℹ️ 누적 피드백 없음. `/feedback <텍스트>` 로 첫 항목 추가.\n');
|
||
return;
|
||
}
|
||
chunk(view, `\n📋 **피드백 목록 (${filtered.length}건${filterCategory ? `, 카테고리 "${filterCategory}"` : ''})**\n\n`);
|
||
const sorted = filtered.slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, 20);
|
||
for (const e of sorted) {
|
||
const date = (e.timestamp || '').slice(0, 10);
|
||
const cats = (e.categories || []).join(', ') || '미분류';
|
||
const sentEmoji = e.sentiment === 'positive' ? '🟢' : e.sentiment === 'negative' ? '🔴' : e.sentiment === 'neutral' ? '⚪' : '❓';
|
||
chunk(view, `- ${sentEmoji} \`${date}\` [${cats}] ${e.text.slice(0, 120)}${e.text.length > 120 ? '…' : ''}\n`);
|
||
}
|
||
if (filtered.length > 20) chunk(view, `\n_…+${filtered.length - 20}건 더 (필터링하거나 \`/feedback path\` 로 직접 파일 열기)_\n`);
|
||
}
|
||
|
||
async function feedbackSummary(view: any): Promise<void> {
|
||
const all = readFeedback();
|
||
if (all.length < 3) {
|
||
chunk(view, `\nℹ️ 누적 ${all.length}건 — 패턴 분석엔 최소 3건 필요. \`/feedback <텍스트>\` 로 더 모아 주세요.\n`);
|
||
return;
|
||
}
|
||
chunk(view, `\n📊 **패턴 분석 시작** (누적 ${all.length}건)\n · LLM 호출 중...\n`);
|
||
const summaryInput = all.map((e) => JSON.stringify({
|
||
timestamp: (e.timestamp || '').slice(0, 10),
|
||
categories: e.categories || [],
|
||
sentiment: e.sentiment || 'unknown',
|
||
text: e.text.slice(0, 300),
|
||
})).join('\n');
|
||
try {
|
||
const report = await callLmSynthesis(`[누적 피드백 ${all.length}건]\n\n${summaryInput}`, FEEDBACK_SUMMARY_PROMPT);
|
||
if (!report || !report.trim()) { chunk(view, '\n❌ LLM 빈 응답.\n'); return; }
|
||
chunk(view, `\n${report.trim()}\n`);
|
||
} catch (e: any) {
|
||
chunk(view, `\n❌ 분석 실패: ${e?.message || String(e)}\n`);
|
||
}
|
||
}
|
||
|
||
function feedbackPath(view: any): void {
|
||
const p = getFeedbackFilePath();
|
||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return; }
|
||
chunk(view, `\n📂 \`${p}\`\n · 누적 ${countFeedback()}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능 — 카테고리 수정·삭제 등.\n`);
|
||
}
|
||
|
||
async function runFeedback(arg: string, view: any): Promise<boolean> {
|
||
const trimmed = arg.trim();
|
||
if (!trimmed) {
|
||
chunk(view, [
|
||
'\n📋 **/feedback — 고객 피드백 누적 + 패턴 분석**',
|
||
'',
|
||
'사용법:',
|
||
' `/feedback <텍스트>` — 피드백 저장 (LLM 자동 카테고리 분류)',
|
||
' `/feedback list [카테고리]` — 누적 목록 (최신순, 최대 20건)',
|
||
' `/feedback summary` — 누적 데이터 패턴 분석 리포트 (LLM)',
|
||
' `/feedback path` — 저장 파일 경로 표시',
|
||
'',
|
||
'예시:',
|
||
' `/feedback 결제 흐름이 너무 복잡해서 중간에 포기했어요 - 김XX`',
|
||
' `/feedback list 결제`',
|
||
' `/feedback summary` (3건+ 누적 시)',
|
||
'',
|
||
'저장 위치: `<workspace>/.astra/customer-feedback.jsonl` — 로컬 only, 외부 전송 없음.',
|
||
'',
|
||
].join('\n'));
|
||
return true;
|
||
}
|
||
|
||
const firstSpace = trimmed.search(/\s/);
|
||
const head = (firstSpace < 0 ? trimmed : trimmed.slice(0, firstSpace)).toLowerCase();
|
||
const rest = firstSpace < 0 ? '' : trimmed.slice(firstSpace + 1).trim();
|
||
|
||
switch (head) {
|
||
case 'list': feedbackList(rest || undefined, view); return true;
|
||
case 'summary': case 'analyze': case 'report': await feedbackSummary(view); return true;
|
||
case 'path': feedbackPath(view); return true;
|
||
default: await feedbackSave(trimmed, view); return true;
|
||
}
|
||
}
|
||
|
||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||
|
||
registerSlashCommand({ name: '/draft', description: '외부 커뮤니케이션 초안 — email/slack/blog/newsletter/investor-update/proposal', handler: runDraft });
|
||
registerSlashCommand({ name: '/feedback', description: '고객 피드백 누적 + 자동 카테고리 분류 + 패턴 분석 (로컬 .jsonl)', handler: runFeedback });
|