Files
connectai/src/features/teamops/handlers/communication.ts
T
koriweb 7bec20620a refactor: v2.2.195-201 — slashRouter god-file 해체 (–95%) + 인프라 5개 추출
아키텍처 감사 결과 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>
2026-06-01 11:55:22 +09:00

256 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 });