/** * 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 = { 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 { 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('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 { 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 { 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 { 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건+ 누적 시)', '', '저장 위치: `/.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 });