feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import type { CompanyState } from '../../features/company';
|
||||
|
||||
/**
|
||||
* Telegram 메시지 처리 파이프라인의 순수 헬퍼 5개.
|
||||
* 모두 stateless — 테스트 가능 + Telegram 라우팅 정책 바뀔 때 한 곳만 수정.
|
||||
*
|
||||
* - buildTelegramSystemPrompt(hasContext) — 시스템 프롬프트 (4가지 역할/규칙)
|
||||
* - looksLikeWorkOrder(text) — 회사 모드 라우팅용 휴리스틱
|
||||
* - buildTelegramCompanyContext(state, ctx) — 비서 역할용 [COMPANY CONTEXT] 블록
|
||||
* - latestCompanySessionDir(ctx) — 최근 세션 디렉토리 (위 블록의 데이터)
|
||||
* - chunkTelegramMessage(text, max) — 4096자 제한 대응 분할
|
||||
*/
|
||||
|
||||
/**
|
||||
* Build the Telegram-specific system prompt.
|
||||
*
|
||||
* Why this matters: small local models (gemma e2b/e4b) drift badly when
|
||||
* called as a single user message with no role grounding. The reported
|
||||
* symptom ("path 입력 → 시 못 써드려요" 같은 환각 거절) is exactly that
|
||||
* drift — the model invents an interpretation because it has no anchor.
|
||||
*
|
||||
* The prompt does four things:
|
||||
* 1. Names the role (Astra Telegram assistant) so the model has a
|
||||
* consistent persona across messages.
|
||||
* 2. States the language rule (mirror the user's language).
|
||||
* 3. Tells the model how to treat brain context (evidence when relevant,
|
||||
* ignore otherwise — never refuse the question because context
|
||||
* doesn't match).
|
||||
* 4. Specifies behavior for ambiguous inputs (paths, single words,
|
||||
* fragments) — ask a clarifying question instead of guessing.
|
||||
*/
|
||||
export function buildTelegramSystemPrompt(hasContext: boolean): string {
|
||||
const base = [
|
||||
'You are Astra, a Telegram assistant connected to the user\'s personal Second Brain knowledge base.',
|
||||
'Reply in the user\'s language (mirror Korean ↔ English exactly as the user wrote).',
|
||||
'Be concise but complete. Telegram messages should feel like a knowledgeable friend, not a formal report.',
|
||||
'',
|
||||
'Behavior rules:',
|
||||
'- Never refuse a question by claiming you can only do certain things. If you can answer, just answer.',
|
||||
'- If the user\'s message is ambiguous (a single word, a file path, a fragment with no question), ask one short clarifying question instead of guessing what they meant.',
|
||||
'- Do NOT invent that the user asked for poetry, songs, code, or any content type they did not request.',
|
||||
];
|
||||
if (hasContext) {
|
||||
base.push(
|
||||
'',
|
||||
'You will receive a [SECOND BRAIN CONTEXT] block before the user\'s message.',
|
||||
'- Use it as evidence only when it directly answers the question. Cite the file path (relative form, e.g. `10_Wiki/Topics/Foo.md`) inline when you do.',
|
||||
'- If the context is unrelated to the question, ignore it silently. Do NOT mention that the context exists, do NOT explain why it doesn\'t apply, do NOT refuse the question because of it.',
|
||||
);
|
||||
}
|
||||
return base.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap heuristic: does the message look like a *work order* the user
|
||||
* wants the company to execute? Triggers company-turn routing.
|
||||
*
|
||||
* Conservative matches only — we'd rather miss a borderline case
|
||||
* (user retries with clearer wording) than mis-route a question
|
||||
* into a company turn (which spends LLM calls + writes to disk).
|
||||
*
|
||||
* Positive signals:
|
||||
* • Explicit dispatch prefix: "CEO한테", "회사한테", "팀한테"
|
||||
* • Korean imperative verbs at sentence end: 만들어/해줘/작성해줘/
|
||||
* 짜줘/구현해/만들어줘/돌려줘/실행해줘/분석해줘/정리해줘
|
||||
* • English imperatives: "make X", "build X", "create X", "implement"
|
||||
*
|
||||
* Negative signals (override → treat as question, not order):
|
||||
* • Ends with "?" — pure question
|
||||
* • Contains "알려줘 / 어디 / 뭐야 / what / where" — informational
|
||||
*/
|
||||
export function looksLikeWorkOrder(text: string): boolean {
|
||||
const t = (text || '').trim();
|
||||
if (!t) return false;
|
||||
if (/(CEO|회사|팀)\s*(한테|에게|보고|에)/i.test(t)) return true;
|
||||
if (/[??]$/.test(t)) return false;
|
||||
if (/(어디(에|에서|야)|뭐야|얼마|언제|왜|^(누구|어떻게|뭐))/i.test(t)) return false;
|
||||
if (/(만들어|짜줘|작성해|구현해|돌려줘|실행해|분석해|정리해|보고해|해줘|짜봐|만들어줘)/i.test(t)) return true;
|
||||
if (/^\s*(make|build|create|implement|run|analyze|generate|write|fix|add|remove)\b/i.test(t)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Find the newest `<workspace>/.astra/company/sessions/<ts>/` directory, or '' if none. */
|
||||
export function latestCompanySessionDir(ctx: vscode.ExtensionContext): string {
|
||||
try {
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
const baseDir = ws
|
||||
? path.join(ws, '.astra', 'company', 'sessions')
|
||||
: path.join(ctx.globalStorageUri.fsPath, 'company', 'sessions');
|
||||
if (!fs.existsSync(baseDir)) return '';
|
||||
const dirs = fs.readdirSync(baseDir)
|
||||
.filter((n) => fs.statSync(path.join(baseDir, n)).isDirectory())
|
||||
.sort()
|
||||
.reverse();
|
||||
return dirs[0] ? path.join(baseDir, dirs[0]) : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a `[COMPANY CONTEXT]` block describing the workspace, the
|
||||
* current company state, and the most recent session directory. Lets
|
||||
* the bot answer questions like "어디에 저장했어?" by reading its own
|
||||
* mirror history *plus* the resolved absolute path on disk.
|
||||
*
|
||||
* Returns '' when company mode is off, so the prompt stays minimal
|
||||
* for users who only use the Telegram bot for RAG-chat.
|
||||
*/
|
||||
export function buildTelegramCompanyContext(state: CompanyState, ctx: vscode.ExtensionContext): string {
|
||||
if (!state.enabled) return '';
|
||||
const ws = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
const lines: string[] = [`[COMPANY CONTEXT]`];
|
||||
lines.push(`회사명: ${state.companyName || '1인 기업'}`);
|
||||
if (ws) lines.push(`작업 폴더 (워크스페이스 루트): ${ws}`);
|
||||
const latestSession = latestCompanySessionDir(ctx);
|
||||
if (latestSession) {
|
||||
lines.push(`최근 작업 세션 폴더: ${latestSession}`);
|
||||
lines.push(`(이 안에 _brief.md, _report.md, 각 에이전트별 산출물이 저장됨)`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('당신의 역할: 이 회사의 비서(Secretary). 사용자(사장님)의 질문에 답할 때 위 경로 정보를 *그대로* 활용하세요.');
|
||||
lines.push('"실제 파일 시스템에 접근할 수 없다" 같은 답변은 잘못된 것입니다 — 위 경로가 실제 시스템 경로입니다.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/** Telegram has a 4096-char per-message limit. Split on paragraph/sentence boundaries to keep replies readable. */
|
||||
export function chunkTelegramMessage(text: string, max = 4000): string[] {
|
||||
if (text.length <= max) return [text];
|
||||
const out: string[] = [];
|
||||
let remaining = text;
|
||||
while (remaining.length > max) {
|
||||
let cut = remaining.lastIndexOf('\n\n', max);
|
||||
if (cut < max * 0.5) cut = remaining.lastIndexOf('\n', max);
|
||||
if (cut < max * 0.5) cut = remaining.lastIndexOf('. ', max);
|
||||
if (cut < max * 0.5) cut = max;
|
||||
out.push(remaining.slice(0, cut).trim());
|
||||
remaining = remaining.slice(cut).trim();
|
||||
}
|
||||
if (remaining) out.push(remaining);
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { TelegramBot } from './telegramBot';
|
||||
import type { TelegramHttpClient } from './telegramClient';
|
||||
import { AIService } from '../../core/services';
|
||||
import { getActiveBrainProfile, logInfo, logError } from '../../utils';
|
||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||
import { retrieveScoped, buildContextBlock } from '../../skills/scopedBrainRetriever';
|
||||
import type { SidebarChatProvider } from '../../sidebarProvider';
|
||||
import {
|
||||
buildTelegramSystemPrompt,
|
||||
looksLikeWorkOrder,
|
||||
buildTelegramCompanyContext,
|
||||
chunkTelegramMessage,
|
||||
} from './promptBuilders';
|
||||
|
||||
export interface TelegramSetupDeps {
|
||||
telegramClient: TelegramHttpClient;
|
||||
/** activate() 안 `let provider` 의 *호출 시점* 값. 등록 시점엔 undefined 일 수 있음. */
|
||||
getProvider: () => SidebarChatProvider | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram bot 생성 + 메시지 처리 콜백.
|
||||
*
|
||||
* 흐름:
|
||||
* 1) allowlist 검사 (`telegram.allowedChatIds`) — 통과 못 하면 silent drop
|
||||
* 2) 회사 모드 + work-order 라우팅 → CEO 디스패처 (즉시 ack 반환)
|
||||
* 3) Per-chat agent 결정 + scoped RAG 검색
|
||||
* 4) 시스템 프롬프트 (company-aware) + 대화 히스토리 + 현재 메시지 결합
|
||||
* 5) AI 호출 → empty/error 시 친절한 fallback 메시지
|
||||
* 6) reply 저장 + 4096자 청크 분할
|
||||
*
|
||||
* 모든 단계에서 "조용히 실패하지 말 것" 이 가장 중요 — 사용자가 "응답 없음" 을
|
||||
* 가장 큰 통증으로 보고했음. 빈 응답이나 예외에도 항상 사람이 읽을 수 있는
|
||||
* 메시지를 반환.
|
||||
*/
|
||||
export function createTelegramBot(
|
||||
context: vscode.ExtensionContext,
|
||||
deps: TelegramSetupDeps,
|
||||
): TelegramBot {
|
||||
const { telegramClient, getProvider } = deps;
|
||||
const telegramAi = new AIService();
|
||||
|
||||
return new TelegramBot({
|
||||
client: telegramClient,
|
||||
handle: async (text, chatId) => {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const allowed = cfg.get<number[]>('telegram.allowedChatIds', []) || [];
|
||||
if (allowed.length > 0 && !allowed.includes(chatId)) {
|
||||
logInfo('Telegram message from unallowed chat ignored.', { chatId });
|
||||
return null;
|
||||
}
|
||||
|
||||
// 진입점 trace — silent failure 진단용. "응답 없음" 신고 시 이 로그가
|
||||
// 없으면 메시지가 여기까지도 못 옴 (allowlist 또는 polling drop).
|
||||
logInfo('Telegram message received.', {
|
||||
chatId,
|
||||
chars: text.length,
|
||||
preview: text.length > 80 ? text.slice(0, 80) + '…' : text,
|
||||
});
|
||||
|
||||
// ── 1인 기업 모드 라우팅 ────────────────────────────────────────
|
||||
// 회사 모드 ON + 메시지가 work *order* 처럼 보이면 (만들어줘/해줘 또는
|
||||
// "CEO한테 …" 접두) RAG-chat 대신 dispatcher 로. dispatcher 가 끝에
|
||||
// Telegram mirror 를 쏘므로 사용자는 제대로 된 보고서를 받음.
|
||||
const { readCompanyState } = await import('../../features/company');
|
||||
const companyState = readCompanyState(context);
|
||||
if (companyState.enabled && looksLikeWorkOrder(text)) {
|
||||
const { appendTelegramMessage } = await import('./conversationHistory');
|
||||
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
|
||||
logInfo('Telegram: routing to company turn.', { chatId, preview: text.slice(0, 60) });
|
||||
// Fire-and-forget — dispatcher 의 secretary mirror 가 최종 보고 전송.
|
||||
// 즉시 ack 를 반환해 사용자는 봇이 명령을 받았다는 신호를 봄.
|
||||
void (async () => {
|
||||
try {
|
||||
await getProvider()!._runCompanyTurn(text);
|
||||
} catch (e: any) {
|
||||
logError('Telegram → company turn failed.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
})();
|
||||
const ack = '🧭 CEO에게 전달했어요. 작업 끝나면 보고드릴게요.';
|
||||
appendTelegramMessage({ chatId, role: 'assistant', text: ack, kind: 'reply' });
|
||||
return ack;
|
||||
}
|
||||
|
||||
// Per-chat agent override → global default → mapping default.
|
||||
const perChatAgents = cfg.get<Record<string, string>>('telegram.agentByChatId', {}) || {};
|
||||
const perChatAgent = perChatAgents[String(chatId)];
|
||||
const defaultAgent = cfg.get<string>('telegram.defaultAgent', '') || '';
|
||||
const agentName = (perChatAgent || defaultAgent || '').trim();
|
||||
|
||||
const brain = getActiveBrainProfile();
|
||||
const brainRoot = brain?.localBrainPath || '';
|
||||
const scope = resolveScopeForAgent(agentName, brainRoot);
|
||||
|
||||
// RAG retrieval — agent 매치 없어도 전체 brain 검색해 봇이 계속 유용하게.
|
||||
// buildContextBlock 가 '' 반환하면 섹션 자체를 빼버림 (cleaner prompt).
|
||||
let contextBlock = '';
|
||||
if (brainRoot) {
|
||||
try {
|
||||
const result = retrieveScoped(text, brainRoot, scope.folders, {
|
||||
maxResults: cfg.get<number>('telegram.contextChunks', 6) ?? 6,
|
||||
});
|
||||
contextBlock = buildContextBlock(result);
|
||||
logInfo('Telegram RAG retrieval done.', {
|
||||
chatId,
|
||||
agent: scope.agent?.name ?? '(none)',
|
||||
scopedFolders: scope.folders.length,
|
||||
candidates: result.candidateCount,
|
||||
chunks: result.chunks.length,
|
||||
});
|
||||
} catch (e: any) {
|
||||
logError('Telegram RAG retrieval failed; falling back to plain prompt.', {
|
||||
chatId, error: e?.message ?? String(e),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Company-aware 시스템 프롬프트 — 회사 모드 ON 일 때 봇은 그 회사의 *비서*.
|
||||
// 모델에게 그렇게 말해줘야 "어디에 저장했어?" 같은 질문에 "파일 시스템 접근
|
||||
// 못 함" 으로 회피하지 않고 실제 경로를 그대로 답함.
|
||||
const companyContextBlock = buildTelegramCompanyContext(companyState, context);
|
||||
const systemPrompt = buildTelegramSystemPrompt(!!contextBlock)
|
||||
+ (companyContextBlock ? `\n\n${companyContextBlock}` : '');
|
||||
|
||||
// Per-chat 대화 히스토리 — 없으면 매 inbound 가 fresh turn 이라
|
||||
// "방금 한 말" 을 봇이 즉시 잊음. AI service 의 {system, user} 표면이
|
||||
// messages 배열을 안 받아서 user 메시지에 inline 으로 박음.
|
||||
const { appendTelegramMessage, getRecentMessages, formatHistoryForPrompt } =
|
||||
await import('./conversationHistory');
|
||||
const history = getRecentMessages(chatId, 10);
|
||||
const historyBlock = formatHistoryForPrompt(history);
|
||||
const pieces: string[] = [];
|
||||
if (contextBlock) pieces.push(`[SECOND BRAIN CONTEXT]\n${contextBlock}`);
|
||||
if (historyBlock) pieces.push(historyBlock);
|
||||
pieces.push(`[USER MESSAGE]\n${text}`);
|
||||
const userMessage = pieces.join('\n\n');
|
||||
|
||||
// AI 호출 *전에* user 메시지 persist — 실패해도 다음 inbound 가 발화를 봄.
|
||||
appendTelegramMessage({ chatId, role: 'user', text, kind: 'user' });
|
||||
|
||||
try {
|
||||
const result = await telegramAi.chat({ system: systemPrompt, user: userMessage });
|
||||
logInfo('Telegram AI reply generated.', {
|
||||
chatId, engine: result.engine, model: result.model,
|
||||
empty: result.empty, chars: result.content.length,
|
||||
});
|
||||
|
||||
if (result.empty) {
|
||||
// silent 가 아니라 사용자에게 도달. 모델 재시작/질문 단순화 안내.
|
||||
return [
|
||||
'⚠️ AI 모델이 빈 응답을 반환했습니다.',
|
||||
'',
|
||||
'다음을 시도해보세요:',
|
||||
'• LM Studio에서 모델이 실제로 로드되어 있는지 확인',
|
||||
'• 더 짧고 구체적인 질문으로 다시 보내기',
|
||||
'• `Astra: Test Telegram Connection` 으로 연결 상태 확인',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// assistant reply persist → 다음 inbound 가 우리가 한 말을 봄.
|
||||
appendTelegramMessage({ chatId, role: 'assistant', text: result.content, kind: 'reply' });
|
||||
// 4096자 hard limit 분할. 1청크면 그대로, 여러 청크는 "(이어서 i/n)"
|
||||
// 힌트와 함께 join — bot framework 가 한 메시지로 보내지만, drop 없이
|
||||
// 모든 청크를 시도하는 게 silent loss 보다 나음.
|
||||
const chunks = chunkTelegramMessage(result.content);
|
||||
if (chunks.length === 1) return chunks[0];
|
||||
return chunks.map((c, i) => i === 0 ? c : `(이어서 ${i + 1}/${chunks.length})\n\n${c}`).join('\n\n---\n\n').slice(0, 4000);
|
||||
} catch (e: any) {
|
||||
// 하드 실패에서도 ALWAYS 응답 — silent failure 가 두 번째로 보고된 통증.
|
||||
logError('Telegram handler threw.', { chatId, error: e?.message ?? String(e) });
|
||||
return `⚠️ Astra 처리 중 오류가 발생했습니다.\n${e?.message ?? e}\n\nLM Studio가 실행 중인지, 모델이 로드되어 있는지 확인해주세요.`;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user