Compare commits
10 Commits
0a97324f1b
...
ebfce17b03
| Author | SHA1 | Date | |
|---|---|---|---|
| ebfce17b03 | |||
| c27cd823a9 | |||
| 7bec20620a | |||
| 15a34e0889 | |||
| 990ea0ae5f | |||
| f3439ddad5 | |||
| 2174504b59 | |||
| d206293a19 | |||
| 323537e12b | |||
| fc2bf0d74e |
@@ -3,27 +3,27 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `ConnectAI` `v2.2.158` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Workspace**: `connectai` `v2.2.200` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.
|
||||
- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
|
||||
- **Stats**: 395 source files, ~63,423 lines across 5 top-level modules.
|
||||
- **Stats**: 431 source files, ~70,417 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-05-25T00:59:03.313Z
|
||||
- **Files newly analysed**: 1
|
||||
- **Files reused from cache**: 394
|
||||
- **Time**: 2026-06-01T02:30:44.120Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 428
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
mindmap
|
||||
root((ConnectAI))
|
||||
root((connectai))
|
||||
src/
|
||||
features/
|
||||
sidebar/
|
||||
lib/
|
||||
agent/
|
||||
lib/
|
||||
retrieval/
|
||||
core/
|
||||
extension/
|
||||
media/
|
||||
tests/
|
||||
helpers/
|
||||
@@ -33,17 +33,18 @@ mindmap
|
||||
docs/
|
||||
records/
|
||||
docs/
|
||||
Meeting/
|
||||
```
|
||||
|
||||
## Module Dependencies
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>247 files"]
|
||||
src["src/<br/>274 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>37 files"]
|
||||
core_py["core_py/<br/>6 files"]
|
||||
docs["docs/<br/>99 files"]
|
||||
docs["docs/<br/>108 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -56,9 +57,9 @@ flowchart LR
|
||||
## Hub Files
|
||||
> Imported by many other files — touching these has wide blast radius.
|
||||
- `src/utils.ts` — referenced by **87** files
|
||||
- `src/config.ts` — referenced by **35** files
|
||||
- `src/agent.ts` — referenced by **34** files
|
||||
- `src/config.ts` — referenced by **32** files
|
||||
- `src/core/services.ts` — referenced by **14** files
|
||||
- `src/core/services.ts` — referenced by **15** files
|
||||
- `src/features/company/index.ts` — referenced by **14** files · Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site.
|
||||
- `src/features/company/types.ts` — referenced by **14** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
||||
- `src/sidebarProvider.ts` — referenced by **11** files
|
||||
@@ -66,54 +67,54 @@ flowchart LR
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 247 files, ~45,859 lines
|
||||
### `src/` — 274 files, ~52,627 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (87) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/features/` (100) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||
- `src/lib/` (28) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/agent/` (25) — 25 files (.ts)
|
||||
- `src/agent/` (29) — Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.1
|
||||
- `src/lib/` (29) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/retrieval/` (16) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
- `src/extension/` (8) — 8 files (.ts)
|
||||
- `src/memory/` (8) — Episodic Memory (일화 기억) 과거 대화/회의/결정의 맥락 흐름을 저장합니다. 세션 종료 시 자동으로 에피소드를 요약하여 저장합니다. "왜 이렇게 결정했는지", "어떤 흐름으로 진행했는지" 기록. 저장
|
||||
- `src/retrieval/` (8) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-I
|
||||
- `src/docs/` (6) — src Chronicle Records
|
||||
- `src/docs/` (6) — Bug: Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts ...
|
||||
- `src/integrations/` (6) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
|
||||
- `src/lmstudio/` (4) — 4 files (.ts)
|
||||
- `src/skills/` (4) — 4 files (.ts)
|
||||
|
||||
**Key files**
|
||||
- `src/utils.ts` (471 lines)
|
||||
- `src/agent.ts` (1487 lines)
|
||||
- `src/config.ts` (418 lines)
|
||||
- `src/config.ts` (557 lines)
|
||||
- `src/agent.ts` (1503 lines)
|
||||
- `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
||||
- `src/sidebarProvider.ts` (3194 lines)
|
||||
- `src/core/services.ts` (176 lines)
|
||||
- `src/sidebarProvider.ts` (3186 lines)
|
||||
- `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
||||
- `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so
|
||||
- `src/features/datacollect/slashRouter.ts` (1240 lines)
|
||||
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
||||
- `src/lib/paths.ts` (151 lines)
|
||||
- `src/agent/actions/types.ts` (41 lines)
|
||||
- `src/skills/agentKnowledgeMap.ts` (374 lines)
|
||||
- `src/retrieval/types.ts` (66 lines) — Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다.
|
||||
- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
- `src/features/stocks/types.ts` (53 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
|
||||
- `src/lib/contextBuilders/promptDetection.ts` (85 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
|
||||
- `src/retrieval/lessonHelpers.ts` (325 lines) — Lesson / Experience Memory — pure helpers (no vscode dependency) "Lesson" = a markdown file in the active brain that captures a past mistake/risk and how to avoid repeating it. Identified by a lessons
|
||||
- `src/memory/types.ts` (126 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
- `src/security.ts` (159 lines)
|
||||
- `src/features/secondBrainTrace.ts` (792 lines)
|
||||
- `src/features/providers/types.ts` (63 lines) — Cloud LLM provider routing — model id prefix → provider id 매핑. Prefix 규칙: openrouter:anthropic/claude-3.5-sonnet → { provider: 'openrouter', model: 'anthropic/claude-3.5-sonnet' } anthropic:claude-3-5
|
||||
- `src/integrations/telegram/telegramBot.ts` (270 lines)
|
||||
- `src/lib/contextBuilders/localProjectIntent.ts` (233 lines)
|
||||
- `src/lib/engine.ts` (1114 lines)
|
||||
- `src/lmstudio/streamer.ts` (252 lines)
|
||||
- `src/core/responseRecovery.ts` (310 lines) — Response Recovery — Thought Quarantine + Final-only Retry + Auto-Continuation The user already asked their question; they're waiting for an answer, not for a chance to babysit the generation engine. S
|
||||
|
||||
### `media/` — 6 files, ~7,649 lines
|
||||
### `media/` — 6 files, ~7,671 lines
|
||||
|
||||
**Key files**
|
||||
- `media/sidebar.css` (2104 lines) — Stylesheet
|
||||
- `media/sidebar.js` (3921 lines)
|
||||
- `media/sidebar.css` (2114 lines) — Stylesheet
|
||||
- `media/sidebar.js` (3933 lines)
|
||||
- `media/sidebar.html` (539 lines) — Astra
|
||||
- `media/settings-panel.html` (406 lines) — Astra Settings
|
||||
- `media/settings-panel.css` (210 lines) — Stylesheet
|
||||
@@ -164,38 +165,39 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 99 files, ~3,631 lines
|
||||
### `docs/` — 108 files, ~3,835 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (86) — Astra Project Chronicle Records
|
||||
- `docs/docs/` (5) — docs Chronicle Records
|
||||
- `docs/records/` (95) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/Meeting/` (0)
|
||||
|
||||
**Key files**
|
||||
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
||||
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
|
||||
- `docs/records/ConnectAI/timeline.md` (209 lines) — Project Timeline
|
||||
- `docs/records/ConnectAI/timeline.md` (236 lines) — Project Timeline
|
||||
- `docs/ASTRA_OFFICE_REFACTOR.md` (198 lines) — Astra Office Refactor — Design Doc
|
||||
- `docs/EXPERIENCE_MEMORY_PLAN.md` (122 lines) — Experience Memory (Mistake / Lesson Loop) — Implementation Plan
|
||||
- `docs/records/ConnectAI/development/2026-05-02_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
|
||||
- `docs/records/ConnectAI/development/2026-05-03_connectai_project_knowledge_overview.md` (121 lines) — Astra Project Knowledge Overview
|
||||
- `docs/Advanced_Features_Implementation_Guide.md` (40 lines) — Advanced Features Implementation Guide
|
||||
- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap
|
||||
- `docs/UX_UI_Consistency_Guidelines.md` (44 lines) — UX/UI Consistency Guidelines
|
||||
- `docs/docs/records/docs/README.md` (18 lines) — docs Chronicle Records
|
||||
- `docs/docs/records/docs/bugs/BUG-0001-viewed-integration-retrieval-test-ts-1-59-integration-retrie.md` (16 lines) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/docs/records/docs/chronicle.config.json` (11 lines) — JSON configuration
|
||||
- `docs/docs/records/docs/project-profile.md` (31 lines) — Project Profile
|
||||
- `docs/docs/records/docs/README.md` (18 lines) — docs Chronicle Records
|
||||
- `docs/docs/records/docs/timeline.md` (7 lines) — Project Timeline
|
||||
- `docs/records/ConnectAI/README.md` (18 lines) — Astra Project Chronicle Records
|
||||
- `docs/records/ConnectAI/bugs/BUG-0001-volumes-data-project-antigravity-connectai-프로젝트-코드-리뷰-해줄-수-있.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0002-지금-내가-분석-요청하고-너가-답을-줄때-아래-템플릿에-맞춰-답을-써주고-있는데-개선-포인트가-있는지-확인해.md` (16 lines) — Bug: 지금 내가 분석 요청하고 너가 답을 줄때 아래 템플릿에 맞춰 답을 써주고 있는데, 개선 포인트가 있는지 확인해줘. ## 내가 보는 위험 가장 큰...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0003-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0004-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0005-다시한번-답줘-volumes-data-project-antigravity-connectai-내-질문에-대한-.md` (16 lines) — Bug: 다시한번 답줘. /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0006-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0007-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0008-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
- `docs/PROJECT_CHRONICLE_GUARD_ROADMAP.md` (43 lines) — Project Chronicle Guard: Search Engine Roadmap
|
||||
- `docs/records/ConnectAI/bugs/BUG-0001-volumes-data-project-antigravity-connectai-프로젝트-코드-리뷰-해줄-수-있.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0002-지금-내가-분석-요청하고-너가-답을-줄때-아래-템플릿에-맞춰-답을-써주고-있는데-개선-포인트가-있는지-확인해.md` (16 lines) — Bug: 지금 내가 분석 요청하고 너가 답을 줄때 아래 템플릿에 맞춰 답을 써주고 있는데, 개선 포인트가 있는지 확인해줘. ## 내가 보는 위험 가장 큰...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0003-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0004-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0005-다시한번-답줘-volumes-data-project-antigravity-connectai-내-질문에-대한-.md` (16 lines) — Bug: 다시한번 답줘. /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0006-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0007-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0008-volumes-data-project-antigravity-connectai-내-질문에-대한-답변이-잘-정리.md` (16 lines) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 내 질문에 대한 답변이 잘 정리되서 알려주긴 하는데 focused...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0009-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0010-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
- `docs/records/ConnectAI/bugs/BUG-0011-문제점을-읽고-어떻게-개선하는게-최선인지-분석해주면-좋겠어-알겠습니다-지금부터-connectai-프로젝트-에.md` (16 lines) — Bug: 문제점을 읽고 어떻게 개선하는게 최선인지 분석해주면 좋겠어. 알겠습니다. 지금부터 ConnectAI 프로젝트에만 완전히 집중하겠습니다. ...
|
||||
|
||||
## VS Code Extension Surface
|
||||
- **Extension ID**: `g1nation.astra`
|
||||
@@ -230,7 +232,7 @@ flowchart LR
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||
- **Configuration** (93 settings):
|
||||
- **Configuration** (122 settings):
|
||||
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
||||
- `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — Wiki/Datacollect MCP Bridge URL. /research, /benchmark, /youtube chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project).
|
||||
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
|
||||
@@ -238,6 +240,9 @@ flowchart LR
|
||||
- `g1nation.datacollectMaxPages` *(number)* _(default: `8`)_
|
||||
- `g1nation.datacollectSynthesisTemperature` *(number)* _(default: `0.1`)_
|
||||
- `g1nation.chatTemperature` *(number)* _(default: `0.3`)_
|
||||
- `g1nation.meetUsesTasks` *(boolean)* _(default: `true`)_
|
||||
- `g1nation.meetUsesCalendar` *(boolean)* _(default: `false`)_
|
||||
- `g1nation.teamVoiceGuide` *(string)* _(default: `""`)_
|
||||
- `g1nation.memoryEnabled` *(boolean)* _(default: `true`)_ — Enable layered memory injection before each model response.
|
||||
- `g1nation.memoryShortTermMessages` *(number)* _(default: `8`)_ — Number of recent conversation messages included as short-term memory.
|
||||
- `g1nation.memoryMediumTermSessions` *(number)* _(default: `5`)_ — Number of recent saved chat sessions included as medium-term memory.
|
||||
@@ -286,12 +291,9 @@ flowchart LR
|
||||
- `g1nation.agentSkillsPath` *(string)* _(default: `""`)_ — Absolute path to the agent skills folder (`.agent/skills/*.md`). When empty, defaults to '<workspace>/.agent/skills'. Use this on Windows or when your skills live outside the workspace.
|
||||
- `g1nation.embeddingModel` *(string)* _(default: `""`)_ — Embedding model registered in LM Studio / Ollama (e.g. 'text-embedding-bge-small-en-v1.5', 'nomic-embed-text', 'multilingual-e5-small'). When empty, Astra uses TF-IDF only. When set, the brain is embe
|
||||
- `g1nation.embeddingBlendAlpha` *(number)* _(default: `0.5`)_ — Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5.
|
||||
- `g1nation.knowledgeMix.secondBrainWeight` *(number)* _(default: `50`)_ — Knowledge Mix (0–100): how heavily the assistant should lean on Second Brain evidence vs. its own general knowledge. 0 = Second Brain disabled (model knowledge only). 50 = balanced (legacy default). 1
|
||||
- `g1nation.workflow.multiAgentMode` *(string)* _(default: `"auto"`)_
|
||||
- `g1nation.workflow.autoCtxFractionThreshold` *(number)* _(default: `0.3`)_
|
||||
- `g1nation.chunkedSwitchTokens` *(number)* _(default: `50000`)_
|
||||
- `g1nation.chunkedMaxSections` *(number)* _(default: `3`)_
|
||||
- _…and 33 more_
|
||||
- `g1nation.conflictHighlightingEnabled` *(boolean)* _(default: `true`)_ — Conflict Surface — 검색된 출처에서 충돌/논란 신호 감지 시 [CONFLICT WARNINGS] 블록을 시스템 프롬프트에 주입. LLM 이 상충되는 관점을 명시하고 사용자 판단에 위임하도록. 기본 켜짐.
|
||||
- `g1nation.conflictSeverityThreshold` *(string)* _(default: `"medium"`)_ — Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만.
|
||||
- _…and 62 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -339,8 +341,85 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-05-25T00:59:03.313Z · signature `fca24b52`_
|
||||
_Last auto-scan: 2026-06-01T02:30:44.120Z · signature `a95021db`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
_TODO_
|
||||
|
||||
Astra (g1nation) — **로컬 LLM 기반의 개인 지능형 운영 레이어**. VS Code 확장으로 LM Studio / Ollama 와 채팅하면서 *프로젝트 컨텍스트 / 메모리 / 작업 자동화* 를 단일 UI 에서 처리. 100% 로컬 (cloud LLM 은 opt-in 라우팅).
|
||||
|
||||
### Top-level 동작 흐름
|
||||
|
||||
```
|
||||
User ↘ ↗ webview (chat UI)
|
||||
sidebar.html / sidebar.js ←—— chatHandlers
|
||||
│ │
|
||||
│ ├─ slash router (/research /youtube /stocks 등)
|
||||
│ │
|
||||
↓ ↓
|
||||
SidebarChatProvider ──→ AgentExecutor (agent.ts, 1,529 lines)
|
||||
│
|
||||
┌──────────────────────┴──────────────────────┐
|
||||
↓ ↓ ↓
|
||||
handlePrompt phases executeActions (8 그룹) LLM I/O (agent/llm)
|
||||
(agent/handlePrompt) (agent/actions) createStreamingRequest
|
||||
streamChatOnce
|
||||
callNonStreaming
|
||||
│
|
||||
↓
|
||||
[PRIOR TURN CONCLUSION] ← 대화 연속성 anchor
|
||||
+ Mode Bridge + Context Blocks + System Prompt + Budget
|
||||
```
|
||||
|
||||
### 핵심 디자인 패턴
|
||||
|
||||
**1. God-file 분해 (R50–R59, 2026-05).** `agent.ts` 2731→1529 lines, `extension.ts` 1145→349 lines. 25개 신규 모듈 (`src/agent/**`, `src/extension/**`). 패턴:
|
||||
- *Pure free functions + deps object* — `this.X` 의존성을 callback dep 으로 명시 (e.g. `callNonStreaming(deps, params)`)
|
||||
- *Thin wrappers on the class* — AgentExecutor 의 메서드는 deps 묶어서 free function 으로 위임. 외부 caller signature 보존.
|
||||
- Action handlers (15+ tags) 는 `HandlerContext` 공유 ctx 객체로 통일 — 8개 그룹 (file/run/list/brain/calendar/sheets/tasks).
|
||||
- handlePrompt 1,100줄 → 7 phase 모듈 (buildTurnContextBlocks / buildModeBridgeContext / build{Agent,Astra}ModeSystemPrompt / computeBudgetedRequest / processFinalAnswer / applyAutoContinuation).
|
||||
|
||||
**2. 대화 연속성 (v2.2.150~157).** 작은 로컬 모델 (gemma 4B 등) 이 follow-up 정정/보강 turn 에 echo/parrot 하는 문제 해결:
|
||||
- `[PRIOR TURN CONCLUSION]` block — 직전 assistant 답변의 첫 문장을 system prompt 에 anchor
|
||||
- `[CONVERSATION CONTINUITY & REVISION]` rule — echo 금지, 최소 3-5 문장 구조 강제
|
||||
- *Thin follow-up classifier* (`isThinFollowUp`) — 짧은 follow-up turn 에서 Guard 의 4-section boilerplate (`## 요청 요약` 등) suppress
|
||||
- *Slash 결과 chatHistory mirror* — slash 명령 결과를 webview *와 함께* agent.chatHistory 에 push (capture wrapper). 다음 turn LLM 이 직전 명령 출력 인식. `getHistory()` 가 filtered copy 반환하는 silent bug fix 포함.
|
||||
|
||||
**3. Slash 명령 시스템.** `src/features/datacollect/slashRouter.ts` 의 registry. 등록된 명령: `/research /benchmark /youtube /blog /wikify /meet /stocks`. webview 의 `/` 자동완성 dropdown 으로 노출. 각 명령은 `(arg, view, context) => Promise<boolean>` 시그니처 통일.
|
||||
|
||||
**4. Stocks feature (v2.2.152~158).** 새 도메인 — 한국 주식 모니터링/발굴:
|
||||
- `.astra/stocks.json` 저장소 (workspace root)
|
||||
- Yahoo Finance 현재가 갱신, `g1nation.stocks.spreadsheetId` 로 Google Sheets 동기화 (calendar OAuth scope 재사용)
|
||||
- KST 09:00 / 15:00 watcher 자동 트리거 → Telegram 보고서
|
||||
- **8 키워드 자동 필터** (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) — `llmJudge.ts` 와 `stockDiscovery.ts` 가 동일 임계값 공유
|
||||
- `/stocks discover` — Naver 비공식 JSON API (`m.stock.naver.com/api/...`) 로 시가총액 페이지 + 펀더멘털 (ROE/영업이익률/유보율) 크롤 후 8 키워드 필터 → 통과한 종목 → LLM Top 5 매력도 분석 → Telegram 자동 발송
|
||||
- `/stocks judge <symbol>` — LLM 으로 4-criteria 필터 자동 판정 + JSON 업데이트
|
||||
|
||||
**5. 5-Layer Cognitive Memory.** `src/memory/`:
|
||||
- Short-Term (대화 단기) / Long-Term (Second Brain) / Project (프로젝트 기록) / Procedural (스킬) / Episodic (세션 요약)
|
||||
- TF-IDF + 한/영 이중 토크나이저 (`src/retrieval/scoring.ts`)
|
||||
- 옵션: embedding hybrid (`g1nation.embeddingModel` 설정 시 dense+sparse blend)
|
||||
- Knowledge Mix policy — Second Brain 의존도 (0-100) 사용자 조절 가능
|
||||
|
||||
**6. 1인 기업 모드 (Company Mode).** `src/features/company/`:
|
||||
- 가상 회사 — 사용자 = CEO, 에이전트 = 직원. 명령 분류기 → dispatcher → 직군별 specialist → CEO 검수 → 산출물
|
||||
- 각 turn 은 session directory 에 `_brief.md / _report.md / 에이전트별 산출물` 영구 기록
|
||||
- LLM 호출 인터페이스: `IAIService.chat({system, user})` (mock 가능 → `tests/helpers/mockLLMClient.ts`)
|
||||
|
||||
**7. Context budget (`src/lib/contextManager.ts`).** 매 turn 마다 system + history + input 의 토큰 예산 계산, output 상한 동적 조절. `smallModelContextCap` 옵션으로 ≤3B 모델의 EOS-first 환각 회피.
|
||||
|
||||
**8. Cloud LLM routing (`src/features/providers/`).** `openrouter:` / `anthropic:` / `gemini:` prefix 가 model id 에 있으면 자동 cloud SDK 라우팅. SSE 응답은 OpenAI 호환 형식으로 normalize → 로컬 path 와 동일 consumer.
|
||||
|
||||
### 진입 파일 (학습 순서)
|
||||
|
||||
1. **`src/extension.ts`** — activation, 모든 wiring (사이드바 / approval / telegram / stocks watcher / slash router)
|
||||
2. **`src/sidebarProvider.ts`** — webview lifecycle, message dispatch (chatHandlers / settings)
|
||||
3. **`src/agent.ts`** — AgentExecutor (thin wrappers + chatHistory 상태)
|
||||
4. **`src/agent/handlePrompt/`** — 한 turn 의 7-phase pipeline
|
||||
5. **`src/features/stocks/`** — 최근 새 도메인 사례 (slash + watcher + LLM + Telegram 통합)
|
||||
|
||||
### 활성 작업 (2026-05)
|
||||
|
||||
- *완료*: god-file 분해 R50–R59, Stocks feature 전체 파이프라인, 대화 연속성 fix
|
||||
- *진행 가능 (paused)*: `/stocks discover` 의 시가총액 범위 자동 유도 (사용자 드롭 요청)
|
||||
- *남은 큰 영역*: `slashRouter.ts` (1,127 lines), `dispatcher.ts` (1,364 lines), `sidebarProvider.ts` (3,194 lines) — 별도 god-file 분해 대상이나 ROI 검토 필요
|
||||
|
||||
+1285
-940
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1779670266607,
|
||||
"createdAt": 1780367973993,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-05-25T00:51:06.608Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (13ms)\n",
|
||||
"createdAt": 1779670266608,
|
||||
"result": "---\nid: wiki_on\ndate: 2026-06-02T02:39:33.994Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (25ms)\n",
|
||||
"createdAt": 1780367973995,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-25T00:51:06.593Z",
|
||||
"totalElapsedMs": 15,
|
||||
"startTime": "2026-06-02T02:39:33.967Z",
|
||||
"totalElapsedMs": 28,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 13,
|
||||
"durationMs": 25,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-05-25T00:51:06.606Z"
|
||||
"ts": "2026-06-02T02:39:33.992Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 2,
|
||||
"durationMs": 3,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-25T00:51:06.608Z"
|
||||
"ts": "2026-06-02T02:39:33.995Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779670273525,
|
||||
"createdAt": 1780367980792,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779670273525,
|
||||
"createdAt": 1780367980791,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1779670273513,
|
||||
"createdAt": 1780367980787,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1779670273519,
|
||||
"createdAt": 1780367980789,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1779670273495",
|
||||
"missionId": "stress_conflict_1780367980767",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-25T00:51:13.495Z",
|
||||
"totalElapsedMs": 30,
|
||||
"startTime": "2026-06-02T02:39:40.767Z",
|
||||
"totalElapsedMs": 26,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 12,
|
||||
"durationMs": 19,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-05-25T00:51:13.507Z"
|
||||
"ts": "2026-06-02T02:39:40.786Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 6,
|
||||
"durationMs": 3,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-05-25T00:51:13.513Z"
|
||||
"ts": "2026-06-02T02:39:40.789Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 6,
|
||||
"durationMs": 1,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-05-25T00:51:13.519Z"
|
||||
"ts": "2026-06-02T02:39:40.790Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 6,
|
||||
"durationMs": 2,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-25T00:51:13.525Z"
|
||||
"ts": "2026-06-02T02:39:40.792Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
+1587
File diff suppressed because it is too large
Load Diff
+16
@@ -0,0 +1,16 @@
|
||||
# Bug: 짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorfl...
|
||||
|
||||
## Date
|
||||
2026-06-01
|
||||
|
||||
## Symptom
|
||||
짚어둘 관찰 사항 (참고용) face-api.js 환경: face-api.js는 원래 브라우저용입니다. Node 단독 실행 시 @tensorflow/tfjs-node + canvas 네이티브 바인딩이 필요해 빌드가 까다롭습니다. Electron 렌더러(브라우저 환경)에서 돌리면 가장 자연스럽습니다 → 아래 1번 질문과 연결됩니다. 정확도 KPI ≥98%: 유사도 임계값(threshold)과 얼굴당 reference 사진 수에 크게 좌우됩니다. reference를 인물당 여러 장 등록받는 걸 권장합니다(평균 descriptor). 데이터 무결성 0 Error: '이동' 중 실패 대비 — 이동은 사실상 "복사 후 검증 후 원본 삭제" 순서로 구현해야 안전합니다. 이 부분은 제가 기본값으로 안전하게 설계하겠습니다. Move vs Copy 3인 케이스: PRD는 2인까지만 예시. 3명 모두 포함된 사진이면 "1번=이동, 2·3번=복사"로 일반화하면 됩니다(아래 4번에서 확정). 아래 4가지...
|
||||
|
||||
## Cause
|
||||
Captured automatically from the current conversation. Confirm root cause during follow-up review if needed.
|
||||
|
||||
## Fix
|
||||
결론: 제공해주신 관찰 사항은 기술적 구현의 난이도와 데이터 안정성을 결정짓는 매우 핵심적인 가이드라인입니다. 특히 `face-api.js`의 실행 환경(Electron vs Node)에 대한 판단과, 데이터 무결성을 위해 '복사 후 검증 후 삭제'라는 안전장치를 설계 원칙으로 세우신 점은 아키텍처의 신뢰도를 높이는 매우 탁월한 결정입니다. 제시해주신 4가지 사항을 바탕으로, 제가 설계를 진행하기 위해 확정해야 할 최종 의사결정 항목들을 정리하였습니다. 이 중 결정된 내용을 바탕으로 즉시 상세 설계에 착수하겠습니다. 1. 실행 환경 (Runtime Environment) - 현재: Electron 렌더러(브라우저 환경)를 권장하며, Node 단독 실행 시의 복잡성을 인지함. - 결정 필요: 타겟 플랫폼을 'Electron 기반 데스크톱 앱'으로 한정할 것인지, 아니면 서버 사이드(Node.js) 로직이 반드시 포함된 구조로 가져갈 것인지 확정해야 합니다. 2. 인식 정확도 전략 (A...
|
||||
|
||||
## Prevention
|
||||
Keep automatic records tied to the active project and verify the relevant test or reproduction path.
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"projectId": "connectai",
|
||||
"projectName": "ConnectAI",
|
||||
"projectRoot": "/Volumes/Data/project/Antigravity/ConnectAI",
|
||||
"recordRoot": "/Volumes/Data/project/Antigravity/ConnectAI/docs/records/ConnectAI",
|
||||
"projectName": "connectai",
|
||||
"projectRoot": "E:\\Wiki\\connectai",
|
||||
"recordRoot": "E:\\Wiki\\connectai\\docs\\records\\connectai",
|
||||
"description": "Auto-created by Project Architecture activation.",
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-23T03:51:11.620Z",
|
||||
"updatedAt": "2026-05-25T00:53:45.126Z"
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-06-01T02:30:37.893Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR: E:\Wiki\connectai 프로젝트에 대한 너의 평가 해줘.
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
E:\Wiki\connectai 프로젝트에 대한 너의 평가 해줘.
|
||||
|
||||
## Decision
|
||||
한 줄 판단 — 현재 정보만으로는 기술 구조를 판단할 수 없습니다. 잘된 점 - 확장성 있는 기능 설계: `g1nation.company.toggle`과 같은 명령어를 통해 '1인 기업 모드'라는 독특한 사용자 경험(UX)을 제공하며, 에이전트 기반의 워크플로우 확장을 고려한 구조를 갖추고 있습니다. - 체계적인 컨텍스트 관리: `src/lib/contextManager.ts`를 통해 LLM의 토큰 한계를 동적으로 관리하고 입력 토큰을 추적하는 로직은 대규모 언어 모델(LLM) 활용 시 필수적인 안정성을 제공합니다. - 풍부한 사용자 인터페이스(UI) 커맨드: 29개의 다양한 명령어를 통해 VS Code 사이드바와 에디터 영역에서 사용자가 복잡한 설정 없이도 기능을 즉시 제어할 수 있도록 설계되어 있습니다. 부족한 점 - 높은 의존성 위험: `src/utils.ts`가 87개의 파일에서 참조되고 있어, 유틸리티 함수 하나를 수정했을 때 전체 프로젝트에 미치는 영향(Blast Radi...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, 어제 설치했는데 하드가 사망한것 같아. 인지가 안되더라고. 그래서 일단 당근 마켓에 분쟁 신청을 했고, 판매자에게 채팅 메시지를 보냈어. 당근 내용을 보니 담당자가 나와 판매자간의 대화 내용을 데이터로 사용하기 위해 모니터링 한다고 하더라고. 그러곤 오늘 판매자가 통화 요청을 했어. 일단 통화는 어렵고, 당근 채팅 창에서만 대화를 해야 기록에 남을 것 같다고 함. 그러곤 당근에서 연락옴. 일단 배송업체에서 어떤 답변을 받는지 기다리고 답변을 받으면 다시 논의 하자고 답변을 줌. 내가 뭘해야해?
|
||||
|
||||
## Decision
|
||||
결론: 현재 상황에서 가장 중요한 것은 '추가적인 증거 확보'와 '당근 마켓 가이드라인에 따른 기록 유지'이며, 판매자에게는 '물건의 결함'을 입증할 객관적 자료를 제시해야 합니다. 지금 사용자가 처한 상황은 '물품 하자(사망)'로 인한 분쟁 중이며, 당근 측에서 모니터으로를 통해 대화 내용을 검토하고 있으므로, 감정적인 대응보다는 객상적인 데이터로 승부해야 하는 시점입니다. 다음의 단계별 행동 지침을 따르시기 바랍니다. 1. 객관적 결함 증거 확보 (가장 중요) 판매자가 '통화'를 요청했다는 것은 채팅 기록 외에 구두로 상황을 무마하거나, 사용자의 논리를 흐리려는 의도가 있을 수 있습니다. 따라서 반드시 '시각적/기술적 증거'를 먼저 만드세요. - 사진/영상 촬영: 하드가 컴퓨터에 연결된 상태에서 '인식되지 않는 화면(디스크 관리 창)'을 사진이나 영상으로 찍으세요. 8TB 용량이 표시되지 않거나, '알 수 없는 영역'으로 뜨는 것을 보여줘야 합니다. - S.M.A.T/로그 확인:...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: connectAI 프로젝트의 아스트라 대부분 답을 잘 줘. 근대 간혹 오타가 발생하거나 상황에 맞지 않는 단어를 간혹 사용해서 혼선을 줄때가 있...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
connectAI 프로젝트의 아스트라 대부분 답을 잘 줘. 근대 간혹 오타가 발생하거나 상황에 맞지 않는 단어를 간혹 사용해서 혼선을 줄때가 있거든. 이 부분을 개선하려면 어떠한 부분을 개선하는게 좋을까?
|
||||
|
||||
## Decision
|
||||
결론: 아스트라의 답변에서 발생하는 오타나 부적절한 단어 선택(Hallucination/Typo)을 개선하기 위해서는, 모델 자체의 성능에 의존하기보다 '출력 전 검증 로직(Post-processing)'과 '프롬프트 내 제약 조건(Constraint)의 정밀화'를 강화해야 합니다. 현재 아스트라는 지식(Second Brain)을 활용하는 능력은 뛰어나지만, 생성된 텍스트의 '품질 관리(QA)' 단계가 부족한 상태입니다. 이를 해결하기 위한 3가지 기술적 개선 방안을 제안합니다. 1. 출력 검증 레이어 도입 (Self-Correction Loop) 모델이 답변을 생성한 직후, 사용자에게 전달하기 전에 스스로 오류를 찾아내는 단계를 추가해야 합니다. - 강화 방안: `[Post-generation Check]` 단계를 시스템 프롬프트에 명시합니다. - "작성된 답변에 프로젝트의 핵심 용어(예: `connectai`, `p-rein동`)가 올바른 철자로 작성되었는지 확인하라." - "문...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: 질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, ...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
질문이 있어, 내가 당근이라는 중고 거래 사이트에서 8TB HDD를 구매했어. 안전거래 (Escrow)를 사용했어. 일단 물건을 어제 받았고, 어제 설치했는데 하드가 사망한것 같아. 인지가 안되더라고. 그래서 일단 당근 마켓에 분쟁 신청을 했고, 판매자에게 채팅 메시지를 보냈어. 당근 내용을 보니 담당자가 나와 판매자간의 대화 내용을 데이터로 사용하기 위해 모니터링 한다고 하더라고. 그러곤 오늘 판매자가 통화 요청을 했어. 일단 통화는 어렵고, 당근 채팅 창에서만 대화를 해야 기록에 남을 것 같다고 함. 그러곤 당근에서 연락옴. 일단 배송업체에서 어떤 답변을 받는지 기다리고 답변을 받으면 다시 논의 하자고 답변을 줌. 내가 뭘해야해?
|
||||
|
||||
## Decision
|
||||
결론: 현재 상황에서 가장 중요한 것은 '추가적인 증거 확보'와 '당근 마켓 가이드라인에 따른 기록 유지'이며, 판매자에게는 '물건의 결함'을 입증할 객관적 자료를 제시해야 합니다. 지금 사용자가 처한 상황은 '물품 하자(사망)'로 인한 분쟁 중이며, 당근 측에서 모니터링을 통해 대화 내용을 검토하고 있으므로, 감정적인 대응보다는 객관적인 데이터로 승부해야 하는 시점입니다. 다음의 단계별 행동 지침을 따르시기 바랍니다. 1. 객관적 결함 증거 확보 (가장 중요) 판매자가 '통화'를 요청했다는 것은 채팅 기록 외에 구두로 상황을 무마하거나, 사용자의 논리를 흐리려는 의도가 있을 수 있습니다. 따라서 반드시 '시각적/기술적 증거'를 먼저 만드세요. - 사진/영상 촬영: 하드가 컴퓨터에 연결된 상태에서 '인식되지 않는 화면(디스크 관리 창)'을 사진이나 영상으로 찍으세요. 8TB 용량이 표시되지 않거나, '알 수 없는 영역'으로 뜨는 것을 보여줘야 합니다. - S.M.A.T/로그 확인: ...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: 여전히 오타가 있어. 그러고 어색한 답변도 있네. 오타 및 문맥 수정 사항 S.M.A.T -> S.M.A.R.T: 하드디스크의 자가 진단 기능을...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
여전히 오타가 있어. 그러고 어색한 답변도 있네. 오타 및 문맥 수정 사항 S.M.A.T -> S.M.A.R.T: 하드디스크의 자가 진단 기능을 뜻하는 명칭은 'S.M.A.R.T(Smart)'가 올바른 표기입니다. 'R'이 빠져 있으니 수정이 필요합니다. 증거로 채점되기 -> 증거로 인정되기: 법적·절차적 분쟁에서는 '채점'보다는 '인정'이나 '채택'이라는 표현이 훨씬 자연스럽고 전문적으로 보입니다. 메시지 전한 상태 유지 -> 메시지 전송 후 대기: 체크리스트 항목으로서 행동의 완결성을 명확히 주기 위해 '전송 후 대기'나 '메시지 발송 완료'로 바꾸는 것이 더 깔끔합니다. 우리 connectai 시스템/로직적으로 어느 부분을 개선해야할까?
|
||||
|
||||
## Decision
|
||||
결론: 아스트라의 답변에서 발생하는 오타와 문맥적 어색함을 근본적으로 해결하려면, 단순히 '프롬프트 수정'에 그치지 말고 '출력물 검증 자동화(Automated Output Verification)'를 시스템 로직의 핵심 레이어로 통합해야 합니다. 사용자가 지적한 오류들은 모두 모델이 생성한 결과물이 '최종 확정'되기 전에 검토되지 않았기 때문에 발생한 문제입니다. 이를 개선하기 위해 `connectai` 시스템에 도입해야 할 3가지 기술적 강화 방안을 제안합니다. 1. **Post-Generation Validator (출력 후 검증 레이어) 구축 가장 시급한 부분입니다. 모델이 답변을 생성(Generation)한 직후, 사용자에게 전달하기 전 단계에서 '검증 로직'을 실행하는 프로세스를 추가해야 합니다. - 로직 개선: `LLM Response` $\rightarrow$ `Validator (Regex/Dictionary Check)` $\rightarrow$ `Final Out...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Development Log: E:\Wiki\connectai 프로젝트에 대해서 이야기 하는거야.
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
결론: `E:\Wiki\connectai` 프로젝트의 맥락에서, 방대한 지식 중 의미 있는 것을 발굴하는 것은 결국 '검색된 정보(Retrieved)와 현재 작업(Active Task) 사이의 논리적 간극을 어떻게 메울 것인가'의 문제입니다. 현재 아스트라는 `connectai` 프로젝트의 구조를 파악하고 있으며, 이 프로젝트의 핵심은 단순한 지식 저장이 아니라 '지능형 에이전트의 실행과 관리'에 있습니다. 따라서 지식을 발굴할 때 다음 세 가지 관점을 적용하면 훨씬 강력한 추론이 가능해집니다. 1. 프로젝트 의존성 기반의 확장 검색 (Dependency-aware Retrieval) - `connectai`는 VS Code Extension이며, `package.json`, `src/agent.ts`, `src/config.ts` 등 상호 연결된 모듈로 구성되어 있습니다. - 지식을 가져올 때 단순히 텍mplate 파일만 보는 것이 아니라, "이 설정(`config.ts`)이 변경...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- package.json
|
||||
- src/agent.ts
|
||||
- src/config.ts
|
||||
- config.ts
|
||||
- agent-knowledge-map.json
|
||||
- architecture.md
|
||||
- skills/*.md
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Development Log: 아스트라를 지금 보다 더 날카롭고 신뢰감이 높은 아이 그리고 나의 의도를 잘 파악하게 하기 위해서는 어떤부분을 강화하는게 좋을까?
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
결론: 아스트라가 사용자의 의도를 정확히 꿰뚫고(Sharpness) 신뢰할 수 있는 답변(Reliability)을 내놓기 위해서는, '추론 전 단계의 맥락 정밀화(Pre-inference Contextualization)'와 '추론 후 검증 루프(Post-inference Verification)'를 강화해야 합니다. 단순히 모델의 성능에 의존하는 것이 아니라, 아스트라가 스스로 '생각의 구조'를 설계할 수 있는 장치를 만드는 것이 핵심입니다. 이를 위한 3가지 강화 전략을 제안합니다. 1. 사용자 의도 파악의 정밀화: Intent-to-Context Mapping 현재 아스트라는 사용자의 질문을 받으면 즉시 답변을 생성하려 합니다. 하지만 '날카로운' 파악을 위해서는 질문 뒤에 숨겨진 '실행 목적'을 먼저 정의해야 합니다. - 강화 방안: 질문이 들어오면 바로 추론하지 않고, [의도 분류 $\rightarrow$ 필요 컨텍스트 식별 $\rightarrow$ 누락된 정보 확인] 단계...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- src/agent.ts
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Discussion: 진행해
|
||||
|
||||
## User Request Summary
|
||||
진행해
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- 결론: `E:\Wiki\connectai\docs\records\ConnectAI` 경로의 파일들을 순차적으로 읽어, 각 기록의 맥락을 보존하면서도 유기적인 연결이 가능한 구조로 Wiki화 작업을 진행하겠습니다. 방금 확인한 파일 목록 중 분석이 필요한 파일부터 차례대로 내용을 파악하여 지식 베이스에 통합하겠습니다. 먼저 `project-profile.md` 파일을 읽어 이 기록의 근간이 되는 프로젝트의 기본 프로필과 목적을 파악하겠습니다. <read_file path="E:\Wiki\connectint\docs\records\ConnectAI\project-profile.md"/>
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
@@ -207,3 +207,30 @@
|
||||
|
||||
## 2026-05-23
|
||||
- Auto decision record created: decisions/ADR-0024-volumes-data-project-antigravity-connectai-코드-리뷰하고-사용자-입장에서-.md
|
||||
|
||||
## 2026-05-26
|
||||
- Auto decision record created: decisions\ADR-0025-e-wiki-connectai-프로젝트에-대한-너의-평가-해줘.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto development record created: development\2026-05-29_e-wiki-connectai-프로젝트에-대해서-이야기-하는거야_implementation.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto development record created: development\2026-05-29_아스트라를-지금-보다-더-날카롭고-신뢰감이-높은-아이-그리고-나의-의도를-잘-파악하게-하기-위해서는-어떤부분_implementation.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto decision record created: decisions\ADR-0026-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto decision record created: decisions\ADR-0027-connectai-프로젝트의-아스트라-대부분-답을-잘-줘-근대-간혹-오타가-발생하거나-상황에-맞지-않는-단어.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto decision record created: decisions\ADR-0028-질문이-있어-내가-당근이라는-중고-거래-사이트에서-8tb-hdd를-구매했어-안전거래-escrow-를-사용했어.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto decision record created: decisions\ADR-0029-여전히-오타가-있어-그러고-어색한-답변도-있네-오타-및-문맥-수정-사항-s-m-a-t-s-m-a-r-t-하드.md
|
||||
|
||||
## 2026-05-29
|
||||
- Auto discussion record created: discussions\2026-05-29_진행해.md
|
||||
|
||||
## 2026-06-01
|
||||
- Auto bug record created: bugs\BUG-0015-짚어둘-관찰-사항-참고용-face-api-js-환경-face-api-js는-원래-브라우저용입니다-node-단.md
|
||||
|
||||
Generated
+1
-1
@@ -6,7 +6,7 @@
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.154",
|
||||
"version": "2.2.203",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+174
-1
@@ -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.158",
|
||||
"version": "2.2.203",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -242,6 +242,22 @@
|
||||
"maximum": 2,
|
||||
"markdownDescription": "채팅 응답 생성의 temperature. 낮을수록(0.2~0.3) 한국어 오타·깨진 토큰·환각이 줄고 결과가 안정적이며, 높을수록 표현이 다양해집니다. 분석·업무용은 0.3 권장."
|
||||
},
|
||||
"g1nation.meetUsesTasks": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "`/meet` 액션 아이템을 **Google Tasks** 에도 등록할지 여부. 시간 없이 마감일만 있는 \"할 일\" 모델. `meetUsesCalendar` 와 독립적으로 토글 가능 — 둘 다 true 면 양쪽 모두 등록. true 로 두려면 OAuth 재연결로 Tasks 스코프 동의 + Google Cloud Console 에서 Tasks API 활성화 필요(처음 1회)."
|
||||
},
|
||||
"g1nation.meetUsesCalendar": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "`/meet` 액션 아이템을 **Google Calendar** 일정(all-day)으로도 등록할지 여부. **기본 `false`** — Tasks 단독 등록으로 중복 방지 (Tasks 도 캘린더 사이드바에 같이 보이므로 둘 다 켜면 중복). true 로 켜면 Tasks + Calendar 양쪽 모두 등록."
|
||||
},
|
||||
"g1nation.teamVoiceGuide": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"editPresentation": "multilineText",
|
||||
"markdownDescription": "`/draft` 외부 커뮤니케이션 초안 작성 시 모든 생성에 적용되는 **팀 보이스 가이드**. 말투/금기 표현/자주 쓰는 표현/회사 약어 정의 등을 자유 형식으로. 예: '회사명은 항상 \"Astra\" 로 표기. 존댓말 기본. 이모지는 슬랙에서만. 약어 ASAP/FYI 사용 금지...' 비워두면 가이드 없이 일반 초안 생성."
|
||||
},
|
||||
"g1nation.memoryEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
@@ -540,6 +556,153 @@
|
||||
"maximum": 1,
|
||||
"description": "Hybrid score blend: 0 = pure TF-IDF (sparse / keyword), 1 = pure embedding cosine (dense / semantic), 0.5 = balanced. Only used when g1nation.embeddingModel is set. Default 0.5."
|
||||
},
|
||||
"g1nation.conflictHighlightingEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Conflict Surface — 검색된 출처에서 충돌/논란 신호 감지 시 [CONFLICT WARNINGS] 블록을 시스템 프롬프트에 주입. LLM 이 상충되는 관점을 명시하고 사용자 판단에 위임하도록. 기본 켜짐."
|
||||
},
|
||||
"g1nation.conflictSeverityThreshold": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"default": "medium",
|
||||
"description": "Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만."
|
||||
},
|
||||
"g1nation.conflictCrossDocEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "교차-문서 발산 감지 — 같은 주제 ≥2 chunks 의 본문 Jaccard < 0.30 인 잠재 모순 쌍을 [CONFLICT WARNINGS] 에 함께 표시. 기본 켜짐."
|
||||
},
|
||||
"g1nation.coveEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Chain-of-Verification (CoVe) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에 주입해 모델이 self-verify 하도록. 할루시네이션 방지 + 출처 명확화. 기본 켜짐."
|
||||
},
|
||||
"g1nation.coveTopSourcesCount": {
|
||||
"type": "number",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 15,
|
||||
"description": "CoVe 체크리스트에 나열할 상위 출처 개수. 너무 많으면 프롬프트 비대, 너무 적으면 그라운딩 부족. 기본 5."
|
||||
},
|
||||
"g1nation.coveStrictMode": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "CoVe Strict 모드 — 모든 사실 주장 뒤에 출처 ID [S1] 형식으로 inline 인용 강제. 답변이 학술적·verbose 해질 수 있어 기본 off."
|
||||
},
|
||||
"g1nation.actionabilityEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Actionability Scoring — '현재 작업 상태' 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과를 재가중. 지금 작업 중인 컨텍스트와 직접 연결된 문서를 우선. 기본 켜짐."
|
||||
},
|
||||
"g1nation.distillationEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Distillation Loop — stale Episodic Memory 를 LongTerm 'episode-digest' 로 승급해 검색 노이즈 방지. /memory distill 수동 + 세션 종료 시 자동 트리거. 기본 켜짐."
|
||||
},
|
||||
"g1nation.distillationAgeThresholdDays": {
|
||||
"type": "number",
|
||||
"default": 30,
|
||||
"minimum": 1,
|
||||
"maximum": 365,
|
||||
"description": "며칠 이상 지난 episode 를 distill 대상으로 할지. 기본 30일."
|
||||
},
|
||||
"g1nation.distillationIntervalDays": {
|
||||
"type": "number",
|
||||
"default": 7,
|
||||
"minimum": 1,
|
||||
"maximum": 90,
|
||||
"description": "자동 distillation 의 최소 간격 (일). 마지막 실행 후 이 일수가 지나야 재실행. 기본 7일."
|
||||
},
|
||||
"g1nation.distillationArchiveMode": {
|
||||
"type": "string",
|
||||
"enum": ["mark-promoted", "archive-file"],
|
||||
"default": "mark-promoted",
|
||||
"description": "Distillation 후 원본 episode 처리: 'mark-promoted'=플래그만 (파일 보존, 기본), 'archive-file'=memory/episodes/archive/ 로 파일 이동."
|
||||
},
|
||||
"g1nation.hierarchicalReweightEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Hierarchical Context Window — 질의·문서 추상도(concrete/operational/strategic) 매칭으로 검색 결과 재가중. 같은 레벨 +15%, 양 끝 mismatch -30%. LLM 호출 없음, 결정적. 기본 켜짐."
|
||||
},
|
||||
"g1nation.semanticRerankEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Semantic Re-ranking — 검색된 selectedChunks 의 순서를 LLM 한 번 호출로 의도-부합도 순 재정렬. 매 turn 1회 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 on 해야 함."
|
||||
},
|
||||
"g1nation.semanticRerankModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Semantic Re-ranking 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델(예: gemma2:2b) 권장 — latency 줄임."
|
||||
},
|
||||
"g1nation.semanticRerankCandidateK": {
|
||||
"type": "number",
|
||||
"default": 15,
|
||||
"minimum": 2,
|
||||
"maximum": 30,
|
||||
"description": "Re-rank 대상 상위 후보 개수. 많을수록 quality↑ latency↑ token↑. 기본 15."
|
||||
},
|
||||
"g1nation.semanticRerankTimeoutSec": {
|
||||
"type": "number",
|
||||
"default": 8,
|
||||
"minimum": 1,
|
||||
"maximum": 60,
|
||||
"description": "Re-rank LLM 호출 타임아웃 (초). 초과 시 원순서 그대로 — 검색 실패 안 됨. 기본 8."
|
||||
},
|
||||
"g1nation.intentClarificationEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Intent Clarification — 모호 질의(환경/대상/범위/포맷/마감 누락) 감지 시 LLM 에게 추측 답변보다 *역질문 우선* 지시. 기본 켜짐."
|
||||
},
|
||||
"g1nation.intentClarificationStrictness": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"default": "medium",
|
||||
"description": "모호 판정 임계. low=가장 덜 묻기(2개+ missing), medium=균형(1개+), high=가장 자주 묻기 (1개+ OR 짧은 질의+trigger)."
|
||||
},
|
||||
"g1nation.citationTraceEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Citation Trace — 답변 끝에 사용된 출처를 *출처:* 한 줄로 정리 지시. CoVe Strict 의 가벼운 형제, 항상 ON 권장. 기본 켜짐."
|
||||
},
|
||||
"g1nation.selfCheckEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Post-hoc Self-Check — 답변 완료 후 별도 LLM 호출 1회로 검증 (답변 직접도/그라운딩/논리 모순). 결과를 답변 아래 footer 한 줄로 표시. 매 turn 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 opt-in."
|
||||
},
|
||||
"g1nation.selfCheckModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"description": "Self-check 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델 (예: gemma2:2b) 권장."
|
||||
},
|
||||
"g1nation.selfCheckTimeoutSec": {
|
||||
"type": "number",
|
||||
"default": 6,
|
||||
"minimum": 1,
|
||||
"maximum": 60,
|
||||
"description": "Self-check LLM 호출 타임아웃 (초). 초과 시 흐릿한 한 줄 footer 로 fallback. 기본 6."
|
||||
},
|
||||
"g1nation.glossaryEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Terminology Dictionary — 사용자 편집 글로서리(.astra/glossary.md) 를 시스템 프롬프트에 주입. 표준 표기 강제 + 답변 직전 Term Check. 파일 없으면 자동 no-op. 기본 켜짐."
|
||||
},
|
||||
"g1nation.glossaryPath": {
|
||||
"type": "string",
|
||||
"default": ".astra/glossary.md",
|
||||
"description": "Glossary 파일 상대 경로 (workspace root 기준). 기본 '.astra/glossary.md'."
|
||||
},
|
||||
"g1nation.glossaryMaxBodyLength": {
|
||||
"type": "number",
|
||||
"default": 4000,
|
||||
"minimum": 500,
|
||||
"maximum": 20000,
|
||||
"description": "Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 초과분은 잘림. 기본 4000."
|
||||
},
|
||||
"g1nation.termValidatorEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Post-gen Term Validator — 답변 완료 후 글로서리 forbidden 단어 결정론적 정규식 스캔. Terminology Dictionary 의 instructional 지시를 deterministic 검증으로 보완. LLM 호출 없음, 매 turn 안전. footer 한 줄 표시. 기본 켜짐."
|
||||
},
|
||||
"g1nation.knowledgeMix.secondBrainWeight": {
|
||||
"type": "number",
|
||||
"default": 50,
|
||||
@@ -639,6 +802,16 @@
|
||||
"default": false,
|
||||
"description": "Self-Reflector Phase A — append a [Self-Reflector Check] block at the end of every substantive LLM answer (Consistency / Completeness / Accuracy, plus References / Paths for code answers). Zero extra LLM calls — the rule lives in the system prompt and the model self-imposes the checklist. OFF by default: the check block is an internal verification log that leaks into the user-facing answer and reads as unpolished. Enable only if you want that transparency block visible."
|
||||
},
|
||||
"g1nation.hollowCheck.enabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Hollow Code Check — `<create_file>` 등 action-tag 로 만든 파일이 *빈 깡통* (empty class, stub-only function, imports-only) 인지 정규식 스캔. LLM 호출 0 — 휴리스틱만. v2.2.203 부터 Phase A 와 분리, 기본 ON. 검출 시 actionReport 에 ⚠️ 라인 추가 + (autoRetry ON 이면) 자동 재작업."
|
||||
},
|
||||
"g1nation.hollowCheck.autoRetry": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Hollow 감지 시 1회 자동 재작업 — Phase B (externalVerification) 와 분리. dev-impl 같은 코드 작성 stage 가 빈 깡통을 뱉으면 같은 모델·context 에 빈 깡통 지적을 prepend 해서 1회 재호출. 재작업 결과도 hollow 재검사. 기본 ON. OFF 시 hollow 검출 후 경고만 표시."
|
||||
},
|
||||
"g1nation.selfReflector.externalVerification": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
|
||||
+37
-23
@@ -164,6 +164,7 @@ import { buildAgentModeSystemPrompt } from './agent/handlePrompt/buildAgentModeS
|
||||
import { buildAstraModeSystemPrompt } from './agent/handlePrompt/buildAstraModeSystemPrompt';
|
||||
import { computeBudgetedRequest } from './agent/handlePrompt/computeBudgetedRequest';
|
||||
import { processFinalAnswer } from './agent/handlePrompt/processFinalAnswer';
|
||||
import { runPostAnswerHooks } from './agent/postAnswerHooks';
|
||||
import { applyAutoContinuation } from './agent/handlePrompt/applyAutoContinuation';
|
||||
|
||||
export interface ChatMessage {
|
||||
@@ -285,10 +286,26 @@ export class AgentExecutor {
|
||||
lessons: string[];
|
||||
/** 이번 turn 에 결정된 Knowledge Mix — scope footer 표시용. */
|
||||
knowledgeMix: ResolvedKnowledgeMix | null;
|
||||
/**
|
||||
* 동적 시스템 프롬프트 블록 레지스트리 — turn 마다 memoryContext 가 채우고
|
||||
* buildAstraModeSystemPrompt 가 iterate 해서 prompt 에 주입.
|
||||
*
|
||||
* 옛 구조: conflictWarnings/coveChecklist/intentClarification/citationTrace/terminology
|
||||
* 5개 named field + 5개 reset + 5개 named param + 5개 ternary gate (총 25곳 edit).
|
||||
* 새 구조: 1 Map. 새 블록 추가 = 1 set call.
|
||||
*
|
||||
* Key 는 디버그·재정의용 id (예: 'conflict-warnings'). Value 는 이미 빌드된
|
||||
* 블록 본문 — 빈 문자열이면 주입 안 함. casual mode 게이팅은 호출자가 처리.
|
||||
*/
|
||||
dynamicBlocks: Map<string, string>;
|
||||
/** Self-check 용 — selected chunks 의 (title, content) 요약. memoryContext 가 채움. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
} = {
|
||||
retrieval: null,
|
||||
lessons: [],
|
||||
knowledgeMix: null,
|
||||
dynamicBlocks: new Map(),
|
||||
selfCheckSources: [],
|
||||
};
|
||||
|
||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||
@@ -296,6 +313,8 @@ export class AgentExecutor {
|
||||
this._turnCtx.retrieval = null;
|
||||
this._turnCtx.lessons = [];
|
||||
this._turnCtx.knowledgeMix = null;
|
||||
this._turnCtx.dynamicBlocks.clear();
|
||||
this._turnCtx.selfCheckSources = [];
|
||||
}
|
||||
|
||||
private readonly options: AgentExecutorOptions;
|
||||
@@ -647,6 +666,7 @@ export class AgentExecutor {
|
||||
isCasualConversation,
|
||||
localPathContext,
|
||||
knowledgeMix: this._turnCtx.knowledgeMix,
|
||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||
});
|
||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||
const imageCount = (reqMessages as any[])
|
||||
@@ -1190,15 +1210,20 @@ export class AgentExecutor {
|
||||
memoryLayers: this._turnCtx.retrieval?.usedMemoryLayers ?? [],
|
||||
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
|
||||
});
|
||||
// ── Devil Agent (도현) — 비활성 시 silent skip. 활성 시 별도 LLM 호출로 반박 카드 emit. ──
|
||||
// 비동기 — main turn 완료에 영향 없음. 실패해도 main 답변은 보존됨.
|
||||
void this._maybeEmitDevilRebuttal({
|
||||
// ── Post-answer hooks (v2.2.197) — Devil + SelfCheck + TermValidator 통합 레지스트리. ──
|
||||
// 새 hook 추가 = `src/agent/postAnswerHooks/index.ts` 에 한 객체 push.
|
||||
// 안전 fallback 내장 — 한 hook 실패가 다른 hook / main turn 영향 없음.
|
||||
runPostAnswerHooks({
|
||||
userPrompt: prompt || '',
|
||||
assistantAnswer: finalAssistantContent,
|
||||
baseUrl: ollamaUrl,
|
||||
modelName: actualModel,
|
||||
contextLength: ctxLimits.contextLength,
|
||||
engine,
|
||||
selfCheckSources: this._turnCtx.selfCheckSources,
|
||||
callNonStreaming: (p) => this.callNonStreaming(p),
|
||||
getAbortSignal: () => this.abortController?.signal,
|
||||
getWebview: () => this.webview,
|
||||
});
|
||||
} else {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
@@ -1282,10 +1307,18 @@ export class AgentExecutor {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
|
||||
|
||||
const cfgNow = getConfig();
|
||||
this.memoryManager.onSessionEnd(
|
||||
this.currentTaskId,
|
||||
this.chatHistory.filter((m) => !m.internal),
|
||||
workspacePath
|
||||
workspacePath,
|
||||
cfgNow.localBrainPath ? {
|
||||
enabled: cfgNow.distillationEnabled !== false,
|
||||
ageThresholdDays: cfgNow.distillationAgeThresholdDays ?? 30,
|
||||
intervalDays: cfgNow.distillationIntervalDays ?? 7,
|
||||
archiveMode: (cfgNow.distillationArchiveMode || 'mark-promoted') as any,
|
||||
brainPath: cfgNow.localBrainPath,
|
||||
} : undefined,
|
||||
);
|
||||
logInfo('Memory extraction completed for session end.', { taskId: this.currentTaskId });
|
||||
recordTelemetry({
|
||||
@@ -1346,25 +1379,6 @@ export class AgentExecutor {
|
||||
* "lock() request could not be registered" error this method is helping
|
||||
* to avoid.
|
||||
*/
|
||||
/**
|
||||
* Devil Agent 반박 emit — main turn 완료 직후 호출 (fire-and-forget).
|
||||
* 비활성 시 즉시 return. 활성 시 별도 LLM 호출 (callNonStreaming 재사용) 로 짧은 비판 생성.
|
||||
* 성공 시 webview 에 'devilRebuttal' 메시지 전송 → UI 가 카드로 렌더.
|
||||
*/
|
||||
private async _maybeEmitDevilRebuttal(opts: {
|
||||
userPrompt: string;
|
||||
assistantAnswer: string;
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
contextLength: number;
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
}): Promise<void> {
|
||||
return maybeEmitDevilRebuttalFn({
|
||||
getAbortSignal: () => this.abortController?.signal,
|
||||
callNonStreaming: (p) => this.callNonStreaming(p),
|
||||
getWebview: () => this.webview,
|
||||
}, opts);
|
||||
}
|
||||
|
||||
private async callNonStreaming(params: {
|
||||
baseUrl: string;
|
||||
|
||||
@@ -22,6 +22,13 @@ export interface BuildAstraModeSystemPromptInput {
|
||||
localPathContext: string;
|
||||
/** From this._turnCtx.knowledgeMix — pass null when absent. */
|
||||
knowledgeMix: any;
|
||||
/**
|
||||
* 동적 시스템 프롬프트 블록 Map (id → 본문). memoryContext 가 채움.
|
||||
* 옛 named param 5개 (conflictWarningsCtx/coveChecklistCtx/intentClarificationCtx/
|
||||
* citationTraceCtx/terminologyCtx) 를 통합. casual 모드는 자동 skip.
|
||||
* 등록 순서대로 [CONTEXT] *밖* 에 join 되어 주입.
|
||||
*/
|
||||
dynamicBlocks?: Map<string, string>;
|
||||
}
|
||||
|
||||
export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInput): string {
|
||||
@@ -40,6 +47,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
isCasualConversation,
|
||||
localPathContext,
|
||||
knowledgeMix,
|
||||
dynamicBlocks,
|
||||
} = input;
|
||||
|
||||
// 기존 Astra 모드 (에이전트 미선택)
|
||||
@@ -78,5 +86,15 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
// priorConclusionCtx 는 modeBridgeCtx 와 같은 위치 (base systemPrompt 직후) — 모델이
|
||||
// 자기 직전 결론을 anchor 로 잡고 사용자의 follow-up 을 그 결론에 대한 정정으로 해석하게.
|
||||
const priorConclusionBlock = priorConclusionCtx ? '\n\n' + priorConclusionCtx : '';
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
// 동적 블록 join — [CONTEXT] *밖* 에 주입돼 token-truncation 시 보호. Casual 모드면
|
||||
// RAG context 자체를 안 쓰므로 동적 블록도 의미 없음 → 일괄 skip.
|
||||
// 등록 순서대로 join (memoryContext 가 메모리 호출 순으로 set — 현재: intent →
|
||||
// terminology → conflict → cove → citation). 빈 본문 entry 는 자동 제외.
|
||||
let dynamicBlocksJoined = '';
|
||||
if (!isCasualConversation && dynamicBlocks && dynamicBlocks.size > 0) {
|
||||
for (const body of dynamicBlocks.values()) {
|
||||
if (body && body.trim()) dynamicBlocksJoined += '\n\n' + body;
|
||||
}
|
||||
}
|
||||
return `${systemPrompt}${modeBridgeCtx ? '\n\n' + modeBridgeCtx : ''}${priorConclusionBlock}${designerCtx}${projectArchitectureCtx}${localProjectKnowledgeCtx}${thinkingPartnerCtx}${astraStanceCtx}${secondBrainTraceCtx}${v4PolicyCtx}${knowledgeMixCtx}${casualCtx}${dynamicBlocksJoined}\n\n[CONTEXT]\n${memoryCtx}\n${knowledgeContextForPrompt}\n${contextBlock}\n[/CONTEXT]\n${negativeCtx}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음.
|
||||
*
|
||||
* 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함.
|
||||
*
|
||||
* 현재 등록 순서 (v2.2.197):
|
||||
* 1. devilRebuttal — Devil Agent 반박 카드 (비활성 시 silent skip)
|
||||
* 2. postHocSelfCheck — 답변 검증 LLM 호출 (opt-in, 기본 OFF)
|
||||
* 3. termValidator — 결정론적 글로서리 forbidden 검사 (기본 ON)
|
||||
*/
|
||||
|
||||
import type { PostAnswerHook, PostAnswerHookContext } from './types';
|
||||
import { maybeEmitDevilRebuttal as maybeEmitDevilRebuttalFn } from '../llm/devilRebuttal';
|
||||
import { postHocSelfCheck, formatSelfCheckFooter, DEFAULT_SELF_CHECK_OPTIONS } from '../postHocSelfCheck';
|
||||
import { validateTermUsage, formatTermValidatorFooter } from '../termValidator';
|
||||
import { getConfig } from '../../config';
|
||||
|
||||
const devilRebuttalHook: PostAnswerHook = {
|
||||
id: 'devil-rebuttal',
|
||||
runAsync: true,
|
||||
async run(ctx: PostAnswerHookContext): Promise<void> {
|
||||
await maybeEmitDevilRebuttalFn(
|
||||
{
|
||||
getAbortSignal: ctx.getAbortSignal,
|
||||
callNonStreaming: ctx.callNonStreaming,
|
||||
// agent.ts 에서 vscode.Webview 를 통과시키므로 실런타임 호환. 타입 cast 로 hook 일반화.
|
||||
getWebview: ctx.getWebview as any,
|
||||
},
|
||||
{
|
||||
userPrompt: ctx.userPrompt,
|
||||
assistantAnswer: ctx.assistantAnswer,
|
||||
baseUrl: ctx.baseUrl,
|
||||
modelName: ctx.modelName,
|
||||
contextLength: ctx.contextLength,
|
||||
engine: ctx.engine,
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const postHocSelfCheckHook: PostAnswerHook = {
|
||||
id: 'self-check',
|
||||
runAsync: true,
|
||||
async run(ctx: PostAnswerHookContext): Promise<void> {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.selfCheckEnabled) return;
|
||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||
const model = (cfg.selfCheckModel || '').trim() || cfg.defaultModel;
|
||||
if (!model || !cfg.ollamaUrl) return;
|
||||
|
||||
const result = await postHocSelfCheck(ctx.userPrompt, ctx.assistantAnswer, ctx.selfCheckSources, {
|
||||
ollamaUrl: cfg.ollamaUrl,
|
||||
model,
|
||||
timeoutMs: (cfg.selfCheckTimeoutSec ?? 6) * 1000,
|
||||
excerptLength: DEFAULT_SELF_CHECK_OPTIONS.excerptLength,
|
||||
maxSources: DEFAULT_SELF_CHECK_OPTIONS.maxSources,
|
||||
});
|
||||
const footer = formatSelfCheckFooter(result, model);
|
||||
ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
const termValidatorHook: PostAnswerHook = {
|
||||
id: 'term-validator',
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
const cfg = getConfig();
|
||||
if (cfg.termValidatorEnabled === false) return;
|
||||
if (!ctx.assistantAnswer || !ctx.assistantAnswer.trim()) return;
|
||||
const result = validateTermUsage(ctx.assistantAnswer, cfg.glossaryPath || '.astra/glossary.md');
|
||||
if (!result.ran || result.dictionarySize === 0) return;
|
||||
const footer = formatTermValidatorFooter(result);
|
||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||
},
|
||||
};
|
||||
|
||||
export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
||||
devilRebuttalHook,
|
||||
postHocSelfCheckHook,
|
||||
termValidatorHook,
|
||||
];
|
||||
|
||||
/** 모든 hook 을 안전하게 실행 — 한 hook 의 throw 가 다른 hook 막지 않음. */
|
||||
export function runPostAnswerHooks(ctx: PostAnswerHookContext): void {
|
||||
for (const hook of POST_ANSWER_HOOKS) {
|
||||
try {
|
||||
if (hook.runAsync) {
|
||||
void Promise.resolve(hook.run(ctx)).catch(() => { /* swallow */ });
|
||||
} else {
|
||||
hook.run(ctx);
|
||||
}
|
||||
} catch { /* hook never breaks the turn */ }
|
||||
}
|
||||
}
|
||||
|
||||
export type { PostAnswerHook, PostAnswerHookContext } from './types';
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Post-Answer Hook 인터페이스 — 답변 streaming 완료 후 실행되는 부가 작업.
|
||||
*
|
||||
* 옛 구조: `agent.ts` 의 `_maybeEmitDevilRebuttal`, `_maybePostHocSelfCheck`,
|
||||
* `_maybeRunTermValidator` 3개 private method. 새 hook 추가 시 (1) method 정의
|
||||
* (2) import (3) call site `void this._maybeX(...)` — 3곳 편집.
|
||||
*
|
||||
* 새 구조: 각 hook 이 `PostAnswerHook` 객체로 자기 module 에. agent.ts 는 1 loop.
|
||||
* 새 hook = 1 파일 + index.ts 에 1 push.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Hook 이 webview 에 postMessage 만 하면 되므로 vscode.Webview 또는 slashRouter 의
|
||||
* 간이 Webview 둘 다 만족하는 최소 인터페이스로 정의.
|
||||
*/
|
||||
interface PostMessageWebview {
|
||||
postMessage(msg: any): Thenable<boolean> | boolean;
|
||||
}
|
||||
|
||||
export interface PostAnswerHookContext {
|
||||
userPrompt: string;
|
||||
assistantAnswer: string;
|
||||
/** LLM 호출용 — Devil/SelfCheck 가 사용. */
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
contextLength: number;
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
/** Self-check 용 출처 미리보기. memoryContext 가 turnCtx 에 채움. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
/** Devil Agent 가 호출 — non-streaming LLM. */
|
||||
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
||||
/** Abort signal accessor. */
|
||||
getAbortSignal: () => AbortSignal | undefined;
|
||||
/** Webview accessor — hook 결과 streamChunk 송출. vscode.Webview / 간이 Webview 호환. */
|
||||
getWebview: () => PostMessageWebview | undefined;
|
||||
}
|
||||
|
||||
export interface PostAnswerHook {
|
||||
/** 디버그·중복 방지용. */
|
||||
id: string;
|
||||
/**
|
||||
* true → fire-and-forget (async, main turn 영향 없음).
|
||||
* false → 동기 실행 (LLM 호출 없는 결정론적 hook, 예: termValidator).
|
||||
*/
|
||||
runAsync: boolean;
|
||||
/** 실행 본문. throw 해도 다른 hook 영향 없음 (caller 가 try/catch 로 감쌈). */
|
||||
run(ctx: PostAnswerHookContext): void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
/**
|
||||
* Post-hoc Self-Check — 답변 *완료 후* LLM 한 번 호출로 3가지 평가.
|
||||
*
|
||||
* 사용자 제안: "[Self-Check] 단계 — 이 답변이 사용자 질문에 직접 답하는가 / 규칙
|
||||
* 준수 / 논리 모순 없는가".
|
||||
*
|
||||
* 기존 CoVe (v2.2.184) 와 차이:
|
||||
* - CoVe = *답변 작성 전* 모델에게 self-verify 지시 (instructional, 1 pass)
|
||||
* - Self-Check = *답변 완료 후* 별도 LLM 호출로 검증 (post-hoc, 2 pass)
|
||||
*
|
||||
* 비용·위험:
|
||||
* - 매 turn 추가 LLM 호출 1회 (latency 비용)
|
||||
* - 기본 OFF — semantic re-rank 와 같은 opt-in 패턴
|
||||
* - 짧은 timeout (기본 6초). 실패해도 답변 자체엔 영향 없음 — 그냥 평가 못 함.
|
||||
* - 빠른 작은 모델 권장 (예: gemma2:2b)
|
||||
*
|
||||
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
||||
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
||||
*/
|
||||
|
||||
export interface SelfCheckOptions {
|
||||
ollamaUrl: string;
|
||||
model: string;
|
||||
timeoutMs: number;
|
||||
/** 출처 컨텍스트 미리보기 길이. 기본 180 chars. */
|
||||
excerptLength: number;
|
||||
/** 컨텍스트로 넘길 최대 출처 개수. 기본 5. */
|
||||
maxSources: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SELF_CHECK_OPTIONS: Omit<SelfCheckOptions, 'ollamaUrl' | 'model'> = {
|
||||
timeoutMs: 6000,
|
||||
excerptLength: 180,
|
||||
maxSources: 5,
|
||||
};
|
||||
|
||||
export type SelfCheckVerdict = 'yes' | 'partial' | 'no' | 'unknown';
|
||||
export type ContradictionLevel = 'none' | 'minor' | 'major' | 'unknown';
|
||||
|
||||
export interface SelfCheckResult {
|
||||
success: boolean;
|
||||
answersQuestion: SelfCheckVerdict;
|
||||
grounded: SelfCheckVerdict;
|
||||
contradiction: ContradictionLevel;
|
||||
note: string;
|
||||
durationMs: number;
|
||||
/** 디버그·footer 표시용. */
|
||||
rawResponse?: string;
|
||||
}
|
||||
|
||||
const FAILURE_RESULT: Omit<SelfCheckResult, 'durationMs' | 'note'> = {
|
||||
success: false,
|
||||
answersQuestion: 'unknown',
|
||||
grounded: 'unknown',
|
||||
contradiction: 'unknown',
|
||||
};
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
function buildPrompt(
|
||||
userPrompt: string,
|
||||
answer: string,
|
||||
sources: { title: string; excerpt: string }[],
|
||||
excerptLength: number,
|
||||
): { system: string; user: string } {
|
||||
const system = [
|
||||
'당신은 답변 검증기 (judge). 사용자 질문, 답변, 출처를 받아 3가지 평가:',
|
||||
'',
|
||||
'1. answersQuestion: 답변이 질문에 *직접* 답하는가? (yes/partial/no)',
|
||||
'2. grounded: 답변이 *제공된 출처에 근거* 하는가? (출처 없으면 unknown 가능) (yes/partial/no/unknown)',
|
||||
'3. contradiction: 답변에 *논리적 모순* 이 있나? (none/minor/major)',
|
||||
'',
|
||||
'[출력 형식 — 정확히 한 줄 JSON, 다른 텍스트 절대 금지]',
|
||||
'{"answersQuestion":"yes","grounded":"partial","contradiction":"none","note":"답변은 직접적이나 일부 주장이 모델 일반 지식 기반"}',
|
||||
'',
|
||||
'[규칙]',
|
||||
'- partial/minor 는 *진짜* 애매한 경우에만. 둘 중 하나로 단정 가능하면 단정.',
|
||||
'- note 는 1문장, 80자 이내, 핵심 평가 근거.',
|
||||
'- JSON 한 줄 외 텍스트 (서론·설명·코드블록) 절대 출력 금지.',
|
||||
].join('\n');
|
||||
|
||||
const srcLines = sources.length > 0
|
||||
? sources.map((s, i) => `[S${i + 1}] ${s.title}\n ${shortExcerpt(s.excerpt, excerptLength)}`).join('\n')
|
||||
: '(검색된 출처 없음 — grounded 는 unknown 또는 no 평가)';
|
||||
|
||||
const user = [
|
||||
'[사용자 질문]',
|
||||
userPrompt,
|
||||
'',
|
||||
'[답변]',
|
||||
answer,
|
||||
'',
|
||||
'[제공된 출처]',
|
||||
srcLines,
|
||||
'',
|
||||
'위 평가 기준에 따라 JSON 한 줄 출력.',
|
||||
].join('\n');
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
function parseResult(raw: string): Omit<SelfCheckResult, 'durationMs' | 'rawResponse'> | null {
|
||||
if (!raw) return null;
|
||||
const match = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
const aq = String(parsed?.answersQuestion || '').toLowerCase();
|
||||
const gr = String(parsed?.grounded || '').toLowerCase();
|
||||
const co = String(parsed?.contradiction || '').toLowerCase();
|
||||
const validVerdict = (v: string): v is SelfCheckVerdict => ['yes', 'partial', 'no', 'unknown'].includes(v);
|
||||
const validCo = (v: string): v is ContradictionLevel => ['none', 'minor', 'major', 'unknown'].includes(v);
|
||||
if (!validVerdict(aq) || !validVerdict(gr) || !validCo(co)) return null;
|
||||
const note = typeof parsed?.note === 'string' ? parsed.note.slice(0, 120) : '';
|
||||
return {
|
||||
success: true,
|
||||
answersQuestion: aq,
|
||||
grounded: gr,
|
||||
contradiction: co,
|
||||
note: note || '평가 노트 없음',
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function postHocSelfCheck(
|
||||
userPrompt: string,
|
||||
answer: string,
|
||||
sources: { title: string; excerpt: string }[],
|
||||
options: SelfCheckOptions,
|
||||
): Promise<SelfCheckResult> {
|
||||
const start = Date.now();
|
||||
if (!userPrompt.trim() || !answer.trim()) {
|
||||
return { ...FAILURE_RESULT, note: 'empty input', durationMs: Date.now() - start };
|
||||
}
|
||||
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
||||
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
||||
|
||||
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
let raw = '';
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model: options.model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 200 },
|
||||
}
|
||||
: {
|
||||
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
raw = String(
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'',
|
||||
);
|
||||
} catch (e: any) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: '',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const parsed = parseResult(raw);
|
||||
if (!parsed) {
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: 'unparseable response',
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: raw.slice(0, 200),
|
||||
};
|
||||
}
|
||||
return {
|
||||
...parsed,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: raw.slice(0, 200),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 결과를 markdown 한 줄 footer 로 포맷 — 사용자가 답변 아래에서 바로 봄.
|
||||
*
|
||||
* 형식: `\n\n---\n_🔍 Self-check_: 답함=✓ · 근거=○ · 모순=없음 _(2.4s · 모델: gemma2:2b)_`
|
||||
*
|
||||
* 실패면 흐릿한 한 줄.
|
||||
*/
|
||||
export function formatSelfCheckFooter(result: SelfCheckResult, model: string): string {
|
||||
if (!result.success) {
|
||||
return `\n\n---\n_🔍 Self-check: ⊘ ${result.note} (${(result.durationMs / 1000).toFixed(1)}s)_`;
|
||||
}
|
||||
const aq = result.answersQuestion === 'yes' ? '✓'
|
||||
: result.answersQuestion === 'partial' ? '◐'
|
||||
: result.answersQuestion === 'no' ? '✗' : '?';
|
||||
const gr = result.grounded === 'yes' ? '✓'
|
||||
: result.grounded === 'partial' ? '◐'
|
||||
: result.grounded === 'no' ? '✗' : '?';
|
||||
const co = result.contradiction === 'none' ? '없음'
|
||||
: result.contradiction === 'minor' ? '경미'
|
||||
: result.contradiction === 'major' ? '⚠️ 중대' : '?';
|
||||
return `\n\n---\n_🔍 **Self-check**: 답함=${aq} · 근거=${gr} · 모순=${co} — ${result.note} _(${(result.durationMs / 1000).toFixed(1)}s · ${model})__`;
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* Post-generation Term Validator — 답변 *완료 후* 정규식/사전 기반 *결정론적* 스캔.
|
||||
*
|
||||
* v2.2.192 의 Terminology Dictionary 가 *instructional* (LLM 에게 표준 표기 사용 지시) 이면,
|
||||
* 이건 *deterministic* — LLM 이 지시를 안 따랐을 때 catch.
|
||||
*
|
||||
* Glossary 파싱 — 두 패턴 인식:
|
||||
* 1. **표준 표기**: `- **Canonical** (X: typo1, typo2, ...)`
|
||||
* → typo1/typo2 가 답변에 등장하면 "→ Canonical 권장" flag
|
||||
* 2. **금지 표현**: H2/H3 제목에 "금지"/"비추" 포함된 섹션의 `- ❌ "phrase"` 또는 `- ❌ phrase`
|
||||
* → phrase 가 답변에 등장하면 "삭제/재작성 권장" flag
|
||||
*
|
||||
* Forbidden 후보 필터:
|
||||
* - 1~30 chars
|
||||
* - 공백/괄호/이모지 등 description 토큰 제외
|
||||
* - 빈 문자열, 한 자 (한글 1음절 제외) 등 false-positive 위험 토큰 제외
|
||||
*
|
||||
* 비용: LLM 호출 없음, 정규식 1회. 매 turn 안전하게 실행 가능.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { createMtimeFileCache } from '../lib/mtimeFileCache';
|
||||
|
||||
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
|
||||
|
||||
interface ForbiddenEntry {
|
||||
forbidden: string;
|
||||
suggested?: string; // canonical 권장 표기 (X-marker 케이스만)
|
||||
source: 'x-marker' | 'banned-section';
|
||||
}
|
||||
|
||||
/** Parsed forbidden entries 캐시 — mtime 기반, 파일 편집 시 자동 재read+parse. */
|
||||
const _parsedCache = createMtimeFileCache<ForbiddenEntry[]>('term-validator', (raw) => parseGlossaryRaw(raw));
|
||||
|
||||
export function clearTermValidatorCache(): void {
|
||||
_parsedCache.clear();
|
||||
}
|
||||
|
||||
function getGlossaryFilePath(relPath: string): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, relPath);
|
||||
}
|
||||
|
||||
/** 토큰이 forbidden 후보로 적합한가 — false-positive 줄임. */
|
||||
function isValidForbiddenToken(token: string): boolean {
|
||||
if (!token) return false;
|
||||
const t = token.trim();
|
||||
if (t.length === 0 || t.length > 30) return false;
|
||||
// 빈 괄호·이모지·문장 등 제외 — 영문/한글/숫자/하이픈/언더스코어만 허용 (+ 슬래시 — 명령어 지원)
|
||||
if (!/^[\w가-힣\-./]+$/u.test(t)) return false;
|
||||
// 한글 1음절은 false-positive 위험 (조사 등) — 제외
|
||||
if (/^[가-힣]$/.test(t)) return false;
|
||||
// 영문 1글자는 거의 항상 false-positive — 제외
|
||||
if (/^[a-zA-Z]$/.test(t)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Glossary markdown raw → forbidden entries. mtime 캐시는 mtimeFileCache 가 담당.
|
||||
*/
|
||||
function parseGlossaryRaw(content: string): ForbiddenEntry[] {
|
||||
const entries: ForbiddenEntry[] = [];
|
||||
|
||||
// ─── Pattern 1: **Canonical** (X: typo1, typo2, ...) ───
|
||||
// `**ASTRA** (X: astra, Astra 외)` — Astra 외 같은 description 은 후보 필터로 제거.
|
||||
const xPattern = /\*\*([^*]+)\*\*\s*\(X:\s*([^)]+)\)/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = xPattern.exec(content)) !== null) {
|
||||
const canonical = m[1].trim();
|
||||
const variants = m[2].split(',').map((v) => v.trim());
|
||||
for (const v of variants) {
|
||||
if (isValidForbiddenToken(v)) {
|
||||
entries.push({ forbidden: v, suggested: canonical, source: 'x-marker' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pattern 2: 금지 섹션 내 `- ❌ "..."` 또는 `- ❌ ...` ───
|
||||
// H2/H3 제목에 "금지" 또는 "비추" 포함된 섹션 본문만 스캔.
|
||||
const sectionRegex = /^(#{2,3})\s+(.+)$/gm;
|
||||
const sections: { headerEnd: number; nextHeaderStart: number; title: string }[] = [];
|
||||
const matches: { idx: number; level: number; title: string }[] = [];
|
||||
let sm: RegExpExecArray | null;
|
||||
while ((sm = sectionRegex.exec(content)) !== null) {
|
||||
matches.push({ idx: sm.index + sm[0].length, level: sm[1].length, title: sm[2].trim() });
|
||||
}
|
||||
for (let i = 0; i < matches.length; i++) {
|
||||
const cur = matches[i];
|
||||
const next = matches[i + 1];
|
||||
sections.push({
|
||||
headerEnd: cur.idx,
|
||||
nextHeaderStart: next ? next.idx : content.length,
|
||||
title: cur.title,
|
||||
});
|
||||
}
|
||||
for (const sec of sections) {
|
||||
if (!/금지|비추|forbidden|avoid|don'?t/i.test(sec.title)) continue;
|
||||
const body = content.slice(sec.headerEnd, sec.nextHeaderStart);
|
||||
const itemRe = /^-\s*❌\s*(?:"([^"]+)"|'([^']+)'|([^\n—]+?))(?:\s*[—–-].*)?$/gm;
|
||||
let im: RegExpExecArray | null;
|
||||
while ((im = itemRe.exec(body)) !== null) {
|
||||
const phrase = (im[1] || im[2] || im[3] || '').trim();
|
||||
if (isValidForbiddenToken(phrase)) {
|
||||
entries.push({ forbidden: phrase, source: 'banned-section' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dedup
|
||||
const seen = new Set<string>();
|
||||
return entries.filter((e) => {
|
||||
const k = `${e.source}::${e.forbidden.toLowerCase()}`;
|
||||
if (seen.has(k)) return false;
|
||||
seen.add(k);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export interface TermViolation {
|
||||
forbidden: string;
|
||||
suggested?: string;
|
||||
source: 'x-marker' | 'banned-section';
|
||||
/** 답변 내 발견된 횟수. */
|
||||
occurrences: number;
|
||||
}
|
||||
|
||||
export interface TermValidationResult {
|
||||
/** validator 실행 여부 — 글로서리 없거나 disabled 면 false. */
|
||||
ran: boolean;
|
||||
/** 사전 entry 수 — 0 이면 글로서리 비어 있음. */
|
||||
dictionarySize: number;
|
||||
violations: TermViolation[];
|
||||
/** 0 이면 위반 없음. */
|
||||
totalViolations: number;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* 답변 텍스트에서 forbidden 단어 발견 — 대소문자 무시, 단어 경계 매치 (영문),
|
||||
* 한글은 substring (한글은 word boundary 가 의미 없음).
|
||||
*/
|
||||
function countMatches(text: string, forbidden: string): number {
|
||||
const isAscii = /^[\w\-./]+$/.test(forbidden);
|
||||
const re = isAscii
|
||||
? new RegExp(`\\b${escapeRegex(forbidden)}\\b`, 'gi')
|
||||
: new RegExp(escapeRegex(forbidden), 'g');
|
||||
const matches = text.match(re);
|
||||
return matches ? matches.length : 0;
|
||||
}
|
||||
|
||||
export function validateTermUsage(
|
||||
answer: string,
|
||||
glossaryRelPath: string = DEFAULT_GLOSSARY_REL_PATH,
|
||||
): TermValidationResult {
|
||||
const fp = getGlossaryFilePath(glossaryRelPath);
|
||||
if (!fp || !fs.existsSync(fp)) {
|
||||
return { ran: false, dictionarySize: 0, violations: [], totalViolations: 0 };
|
||||
}
|
||||
const entries = _parsedCache.read(fp) ?? [];
|
||||
if (entries.length === 0) {
|
||||
return { ran: true, dictionarySize: 0, violations: [], totalViolations: 0 };
|
||||
}
|
||||
if (!answer || !answer.trim()) {
|
||||
return { ran: true, dictionarySize: entries.length, violations: [], totalViolations: 0 };
|
||||
}
|
||||
|
||||
const violations: TermViolation[] = [];
|
||||
let total = 0;
|
||||
for (const e of entries) {
|
||||
const n = countMatches(answer, e.forbidden);
|
||||
if (n > 0) {
|
||||
violations.push({ ...e, occurrences: n });
|
||||
total += n;
|
||||
}
|
||||
}
|
||||
violations.sort((a, b) => b.occurrences - a.occurrences);
|
||||
return { ran: true, dictionarySize: entries.length, violations, totalViolations: total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Footer 마크다운 한 줄. 위반 없으면 깔끔한 ✓, 있으면 상세.
|
||||
* Self-Check footer 와 시각적으로 통일.
|
||||
*/
|
||||
export function formatTermValidatorFooter(result: TermValidationResult): string {
|
||||
if (!result.ran) return '';
|
||||
if (result.dictionarySize === 0) return '';
|
||||
if (result.totalViolations === 0) {
|
||||
return `\n_🔤 **Term validator**: ✓ 사전 ${result.dictionarySize}개 항목, 위반 없음_`;
|
||||
}
|
||||
const top = result.violations.slice(0, 4).map((v) => {
|
||||
if (v.suggested) return `"${v.forbidden}" → "${v.suggested}"`;
|
||||
return `"${v.forbidden}" (금지)`;
|
||||
}).join(' · ');
|
||||
const more = result.violations.length > 4 ? ` _+${result.violations.length - 4}건_` : '';
|
||||
return `\n_🔤 **Term validator**: ⚠️ 위반 ${result.totalViolations}건 — ${top}${more}_`;
|
||||
}
|
||||
+157
@@ -60,6 +60,119 @@ export interface IAgentConfig {
|
||||
* Default 0.5 = equal weight, a reasonable starting point.
|
||||
*/
|
||||
embeddingBlendAlpha: number;
|
||||
/**
|
||||
* Conflict Surface — 검색된 출처의 conflictSeverity 신호를 [CONFLICT WARNINGS] 블록
|
||||
* 으로 시스템 프롬프트에 노출. v4 정책 텍스트(buildAstraModeSystemPrompt) 가 이미
|
||||
* "[CONFLICT WARNING] 플래그" 를 참조하지만, 데이터를 LLM 에 전달하지 않아 무용했음.
|
||||
* true(기본) → 충돌 감지 시 블록 주입, false → 비활성.
|
||||
*/
|
||||
conflictHighlightingEnabled: boolean;
|
||||
/**
|
||||
* Conflict 자기-신호 surface 시 최소 severity 임계.
|
||||
* 'low' → LOW 이상 (가장 민감, 노이즈 가능)
|
||||
* 'medium' → MEDIUM 이상 (기본, 균형)
|
||||
* 'high' → HIGH 만 (가장 보수적, 강한 충돌만)
|
||||
*/
|
||||
conflictSeverityThreshold: 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* 교차-문서 발산 감지 (같은 주제 ≥2 chunks, 본문 Jaccard < 0.30 인 잠재 모순 쌍).
|
||||
* 자기-신호와 합쳐 [CONFLICT WARNINGS] 블록에 표시.
|
||||
*/
|
||||
conflictCrossDocEnabled: boolean;
|
||||
/**
|
||||
* CoVe (Chain-of-Verification) — 답변 *작성 전* 그라운딩 체크리스트를 시스템 프롬프트에
|
||||
* 주입해 모델이 self-verify 하도록. 할루시네이션 방지 + 그라운딩 명확화.
|
||||
* true(기본) → 검색된 출처 있을 때 [VERIFICATION CHECKLIST] 블록 주입.
|
||||
*/
|
||||
coveEnabled: boolean;
|
||||
/** CoVe 체크리스트에 나열할 상위 출처 개수. 기본 5. */
|
||||
coveTopSourcesCount: number;
|
||||
/**
|
||||
* CoVe Strict 모드 — 모든 사실 주장 뒤에 출처 ID([S1] 등) inline 인용 강제.
|
||||
* 답변이 좀 더 학술적·verbose 해질 수 있어 기본 off.
|
||||
*/
|
||||
coveStrictMode: boolean;
|
||||
/**
|
||||
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과
|
||||
* 재가중. TF-IDF 매치 점수에 actionability boost 추가해 "지금 작업 중인 컨텍스트" 와
|
||||
* 직접 연결된 문서를 우선. 기본 true.
|
||||
*/
|
||||
actionabilityEnabled: boolean;
|
||||
/**
|
||||
* Memory Lifecycle — Distillation Loop: stale Episodic Memory 를 LongTerm 'episode-digest'
|
||||
* 로 승급. 누적 epimemory 가 검색 노이즈가 되는 것 방지.
|
||||
* true(기본) → /memory distill + 세션 종료 시 자동 트리거 (interval 기준).
|
||||
*/
|
||||
distillationEnabled: boolean;
|
||||
/** 며칠 이상 지난 episode 를 distill 대상으로. 기본 30. */
|
||||
distillationAgeThresholdDays: number;
|
||||
/** 자동 distillation 의 최소 간격 (일). 너무 자주 안 돌도록. 기본 7. */
|
||||
distillationIntervalDays: number;
|
||||
/**
|
||||
* Archive 모드:
|
||||
* - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존
|
||||
* - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동
|
||||
*/
|
||||
distillationArchiveMode: 'mark-promoted' | 'archive-file';
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서 추상도(concrete/operational/strategic) 매칭으로
|
||||
* 검색 결과 재가중. 같은 레벨 boost (× 1.15), 양 끝 mismatch penalty (× 0.7). LLM 호출 없음.
|
||||
*/
|
||||
hierarchicalReweightEnabled: boolean;
|
||||
/**
|
||||
* Semantic Re-ranking — 토큰 예산 통과한 selectedChunks 의 *순서* 를 LLM 한 번 호출로
|
||||
* 재정렬. 의도 매치를 키워드 매치보다 우선. 매 turn 1회 추가 LLM 호출 (latency 비용).
|
||||
* 기본 OFF — 명시적으로 on 해야 함.
|
||||
*/
|
||||
semanticRerankEnabled: boolean;
|
||||
/** 재정렬 전용 모델 ID. 비면 defaultModel 사용. 빠른 작은 모델 권장. */
|
||||
semanticRerankModel: string;
|
||||
/** Re-rank 대상 상위 후보 개수. 기본 15. */
|
||||
semanticRerankCandidateK: number;
|
||||
/** Re-rank LLM 호출 타임아웃 (초). 기본 8. */
|
||||
semanticRerankTimeoutSec: number;
|
||||
/**
|
||||
* Intent Clarification — 모호 질의에서 *추측 답변 대신 역질문* 지시.
|
||||
* 휴리스틱 차원(환경/대상/범위/포맷/마감) 별 trigger + specifier 매치. 기본 true.
|
||||
*/
|
||||
intentClarificationEnabled: boolean;
|
||||
/**
|
||||
* 모호 판정 임계:
|
||||
* - 'low': 2개 이상 missing dimension 일 때만 ambiguous (가장 덜 묻기)
|
||||
* - 'medium' (기본): 1개 이상 missing → ambiguous
|
||||
* - 'high': 1개 이상 missing OR 짧은 prompt(<20자)+trigger 있으면 ambiguous (가장 자주 묻기)
|
||||
*/
|
||||
intentClarificationStrictness: 'low' | 'medium' | 'high';
|
||||
/**
|
||||
* Citation Trace — 답변 끝에 "출처:" 한 줄 정리 지시. CoVe Strict 의 가벼운 형제.
|
||||
* 검색 결과 있을 때만 동작. 기본 true.
|
||||
*/
|
||||
citationTraceEnabled: boolean;
|
||||
/**
|
||||
* Post-hoc Self-Check — 답변 *완료 후* LLM 1회 호출로 검증 (답변 직접도 / 그라운딩 /
|
||||
* 논리 모순). 매 turn 추가 LLM 호출 (latency 비용). 기본 OFF — 명시적 opt-in.
|
||||
* 결과는 답변 아래 footer 한 줄로 표시.
|
||||
*/
|
||||
selfCheckEnabled: boolean;
|
||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||
selfCheckModel: string;
|
||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||
selfCheckTimeoutSec: number;
|
||||
/**
|
||||
* Terminology Dictionary — 사용자 편집 글로서리 파일을 시스템 프롬프트에 주입,
|
||||
* 표준 표기 강제 + 답변 직전 자기 점검(Term Check). 기본 true. 파일 없으면 자동 no-op.
|
||||
*/
|
||||
glossaryEnabled: boolean;
|
||||
/** Glossary 파일 상대 경로 (workspace root 기준). 기본 '.astra/glossary.md'. */
|
||||
glossaryPath: string;
|
||||
/** Glossary 본문 시스템 프롬프트 cap (chars). 너무 크면 토큰 비용↑. 기본 4000. */
|
||||
glossaryMaxBodyLength: number;
|
||||
/**
|
||||
* Post-gen Term Validator — 답변 완료 후 글로서리 forbidden 단어 결정론적 스캔.
|
||||
* Terminology Dictionary (v2.2.192) 의 *instructional* 지시를 *deterministic* 검증으로 보완.
|
||||
* LLM 호출 없음 (정규식), 매 turn 안전 실행. footer 한 줄 표시. 기본 true.
|
||||
*/
|
||||
termValidatorEnabled: boolean;
|
||||
/**
|
||||
* Global Knowledge Mix weight (0–100). Controls how much the assistant leans on
|
||||
* Second Brain evidence vs. model general knowledge when answering.
|
||||
@@ -116,6 +229,22 @@ export interface IAgentConfig {
|
||||
* 느껴진다면 꺼둘 수 있다.
|
||||
*/
|
||||
selfReflectorEnabled: boolean;
|
||||
/**
|
||||
* Hollow Code Check — `<create_file>` 등 action-tag 로 생성된 파일이 *빈 깡통*
|
||||
* (empty class, stub-only function body, imports-only file 등) 인지 정규식으로
|
||||
* 스캔. LLM 호출 0 — 휴리스틱 only.
|
||||
*
|
||||
* 옛 구조: selfReflector Phase A 가 켜져 있어야 동작 → 다수 사용자가 안전망 부재.
|
||||
* 새 구조: 독립 toggle, *기본 ON*. action-tag 가 있는 응답에 무조건 실행. 검출 시
|
||||
* actionReport 에 "⚠️ 빈 깡통" 라인 추가 (LLM 콜 없음 — 비용 0).
|
||||
*/
|
||||
hollowCheckEnabled: boolean;
|
||||
/**
|
||||
* Hollow 감지 시 1회 자동 재작업. Phase B (selfReflectorExternalEnabled) 와 분리 —
|
||||
* dev-impl 같은 stage 당 정해진 단발 추가 호출 (예측 가능). 기본 ON.
|
||||
* 재작업 결과도 hollow 한 번 더 검사 → 재차 hollow 면 사용자 경고 (무한 루프 방지).
|
||||
*/
|
||||
hollowCheckAutoRetry: boolean;
|
||||
/**
|
||||
* Self-Reflector Phase B — 회사 모드 specialist 응답 직후 *분리된 콘텍스트*
|
||||
* 에서 LLM 한 번 더 호출해 외부 시각으로 검증. 실패 시 1회 retry. 비용
|
||||
@@ -307,6 +436,32 @@ export function getConfig(): IAgentConfig {
|
||||
finalOnlyRetryOnThoughtLeak: cfg.get<boolean>('finalOnlyRetryOnThoughtLeak', true),
|
||||
embeddingModel: (cfg.get<string>('embeddingModel', '') || '').trim(),
|
||||
embeddingBlendAlpha: Math.max(0, Math.min(1, cfg.get<number>('embeddingBlendAlpha', 0.5))),
|
||||
conflictHighlightingEnabled: cfg.get<boolean>('conflictHighlightingEnabled', true),
|
||||
conflictSeverityThreshold: (cfg.get<string>('conflictSeverityThreshold', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
conflictCrossDocEnabled: cfg.get<boolean>('conflictCrossDocEnabled', true),
|
||||
coveEnabled: cfg.get<boolean>('coveEnabled', true),
|
||||
coveTopSourcesCount: Math.max(1, Math.min(15, cfg.get<number>('coveTopSourcesCount', 5))),
|
||||
coveStrictMode: cfg.get<boolean>('coveStrictMode', false),
|
||||
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
||||
distillationEnabled: cfg.get<boolean>('distillationEnabled', true),
|
||||
distillationAgeThresholdDays: Math.max(1, Math.min(365, cfg.get<number>('distillationAgeThresholdDays', 30))),
|
||||
distillationIntervalDays: Math.max(1, Math.min(90, cfg.get<number>('distillationIntervalDays', 7))),
|
||||
distillationArchiveMode: (cfg.get<string>('distillationArchiveMode', 'mark-promoted') as 'mark-promoted' | 'archive-file') || 'mark-promoted',
|
||||
hierarchicalReweightEnabled: cfg.get<boolean>('hierarchicalReweightEnabled', true),
|
||||
semanticRerankEnabled: cfg.get<boolean>('semanticRerankEnabled', false),
|
||||
semanticRerankModel: cfg.get<string>('semanticRerankModel', '') || '',
|
||||
semanticRerankCandidateK: Math.max(2, Math.min(30, cfg.get<number>('semanticRerankCandidateK', 15))),
|
||||
semanticRerankTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('semanticRerankTimeoutSec', 8))),
|
||||
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||
glossaryEnabled: cfg.get<boolean>('glossaryEnabled', true),
|
||||
glossaryPath: cfg.get<string>('glossaryPath', '.astra/glossary.md') || '.astra/glossary.md',
|
||||
glossaryMaxBodyLength: Math.max(500, Math.min(20000, cfg.get<number>('glossaryMaxBodyLength', 4000))),
|
||||
termValidatorEnabled: cfg.get<boolean>('termValidatorEnabled', true),
|
||||
knowledgeMixSecondBrainWeight: Math.max(0, Math.min(100, Math.round(
|
||||
cfg.get<number>('knowledgeMix.secondBrainWeight', 50)
|
||||
))),
|
||||
@@ -322,6 +477,8 @@ export function getConfig(): IAgentConfig {
|
||||
// 둘이 어긋나면 안 되므로 변경 시 양쪽 같이 갱신.
|
||||
companyIntentAlignmentMaxRounds: Math.max(1, Math.min(5, cfg.get<number>('company.intentAlignmentMaxRounds', 3))),
|
||||
selfReflectorEnabled: cfg.get<boolean>('selfReflector.enabled', false),
|
||||
hollowCheckEnabled: cfg.get<boolean>('hollowCheck.enabled', true),
|
||||
hollowCheckAutoRetry: cfg.get<boolean>('hollowCheck.autoRetry', true),
|
||||
selfReflectorExternalEnabled: cfg.get<boolean>('selfReflector.externalVerification', false),
|
||||
selfReflectorExecutionEnabled: cfg.get<boolean>('selfReflector.executionVerification', false),
|
||||
companyPixelOfficeEnabled: cfg.get<boolean>('company.pixelOffice.enabled', true),
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
// TeamOps + System + Datacollect handler 자기 등록 — side-effect import. slashRouter
|
||||
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
|
||||
import './features/teamops/handlers';
|
||||
import './features/system/handlers';
|
||||
import './features/datacollect/handlers';
|
||||
// axios removed in favor of native fetch
|
||||
import {
|
||||
_getBrainDir,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Generic event-sourced store — append-only `.jsonl` 파일 1개를 읽고/쓰는 공통 기반.
|
||||
*
|
||||
* 배경: customers, hire, runway, feedback 4개 store 가 같은 패턴 4번 반복
|
||||
* (getXFilePath / readX / appendX / countX) — byte-for-byte 중복 ~240줄.
|
||||
* 한 곳에서 잡으면 BOM/인코딩 edge case 등 fix 도 한 번에 전파.
|
||||
*
|
||||
* 사용:
|
||||
* const store = createEventStore<CustomerEvent>({
|
||||
* relPath: '.astra/customers.jsonl',
|
||||
* validate: (e) => typeof e.id === 'string' && typeof e.customerId === 'string',
|
||||
* });
|
||||
* store.read(); store.append(event); store.count(); store.getFilePath();
|
||||
*
|
||||
* 도메인별 로직 (computeStates 등) 은 그대로 도메인 파일에 남음 — 본 모듈은
|
||||
* I/O 만 추상화.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
export interface EventStoreOptions<E> {
|
||||
/** workspace root 기준 상대 경로. 예: '.astra/customers.jsonl'. */
|
||||
relPath: string;
|
||||
/** 파싱된 객체가 유효한 event 인지 판정. false 면 malformed 로 skip. */
|
||||
validate: (e: unknown) => e is E;
|
||||
}
|
||||
|
||||
export interface EventStore<E> {
|
||||
getFilePath(): string | null;
|
||||
read(): E[];
|
||||
append(event: E): { ok: true; filePath: string } | { ok: false; error: string };
|
||||
count(): number;
|
||||
}
|
||||
|
||||
export function createEventStore<E>(opts: EventStoreOptions<E>): EventStore<E> {
|
||||
function getFilePath(): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, opts.relPath);
|
||||
}
|
||||
|
||||
function read(): E[] {
|
||||
const fp = getFilePath();
|
||||
if (!fp || !fs.existsSync(fp)) return [];
|
||||
const out: E[] = [];
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(fp, 'utf-8'); } catch { return []; }
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (opts.validate(parsed)) out.push(parsed);
|
||||
} catch { /* skip malformed — append-only 라 손상 1줄이 전체 무력화하면 안 됨 */ }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function append(event: E): { ok: true; filePath: string } | { ok: false; error: string } {
|
||||
const fp = getFilePath();
|
||||
if (!fp) return { ok: false, error: '워크스페이스 폴더가 없어 저장 불가. VS Code 에서 폴더를 열어 주세요.' };
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||
fs.appendFileSync(fp, JSON.stringify(event) + '\n', 'utf-8');
|
||||
return { ok: true, filePath: fp };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
function count(): number {
|
||||
const fp = getFilePath();
|
||||
if (!fp || !fs.existsSync(fp)) return 0;
|
||||
try {
|
||||
const content = fs.readFileSync(fp, 'utf-8');
|
||||
return content.split('\n').filter((l) => l.trim()).length;
|
||||
} catch { return 0; }
|
||||
}
|
||||
|
||||
return { getFilePath, read, append, count };
|
||||
}
|
||||
@@ -30,3 +30,11 @@ export {
|
||||
_addMinutesIso,
|
||||
_addDaysDate,
|
||||
} from './calendarApi';
|
||||
|
||||
export {
|
||||
createTask,
|
||||
listTasks,
|
||||
TaskInput,
|
||||
CreatedTask,
|
||||
ListedTask,
|
||||
} from './tasksApi';
|
||||
|
||||
@@ -24,6 +24,7 @@ import * as vscode from 'vscode';
|
||||
const SCOPE = [
|
||||
'https://www.googleapis.com/auth/calendar.events',
|
||||
'https://www.googleapis.com/auth/spreadsheets',
|
||||
'https://www.googleapis.com/auth/tasks',
|
||||
'openid',
|
||||
'email',
|
||||
].join(' ');
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Google Tasks API v1 — task create 호출.
|
||||
*
|
||||
* Calendar / Sheets 와 같은 OAuth 토큰을 공유한다 (scope 에 `tasks` 포함).
|
||||
* Tasks 는 date-only 모델(시간 없음)이라 /meet 의 액션 아이템처럼 "시간 없이
|
||||
* 마감일만 있는 할 일" 에 자연스럽게 맞는다.
|
||||
*
|
||||
* 외부 라이브러리 안 씀 — Tasks API 도 REST 라 native fetch 면 충분.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { getFreshAccessToken } from './calendarApi';
|
||||
|
||||
const API_BASE = 'https://tasks.googleapis.com/tasks/v1';
|
||||
|
||||
export interface TaskInput {
|
||||
/** 작업 제목 (필수). */
|
||||
title: string;
|
||||
/** 마감일 'YYYY-MM-DD' — Google Tasks 는 시간 무시, 날짜만 사용. */
|
||||
due: string;
|
||||
/** 메모 (옵션) — Tasks UI 에서 작업 본문 아래 노트로 표시. */
|
||||
notes?: string;
|
||||
/**
|
||||
* 작업이 들어갈 task list ID. default `@default` — 기본 list ("내 할 일").
|
||||
* 사용자가 별도 list 를 만들었으면 그 ID 를 넣으면 됨.
|
||||
*/
|
||||
taskListId?: string;
|
||||
}
|
||||
|
||||
export interface CreatedTask {
|
||||
/** Google 이 발급한 task id. */
|
||||
id: string;
|
||||
title: string;
|
||||
due: string;
|
||||
/** Google Tasks API 의 self link (API 용). 사용자용 deep link 는 별도로 없음. */
|
||||
selfLink?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Tasks 에 작업 생성. config 에 refresh token 있어야 함. access token 자동 갱신.
|
||||
*
|
||||
* 사용자가 Tasks 스코프 미동의(예전 OAuth 만 한 사용자) 면 Google 이 401/403 으로
|
||||
* 거부 → `error` 에 그 메시지가 친화적 형태로 전달되고, 사용자는 OAuth 재연결 안내를 본다.
|
||||
*
|
||||
* 반환값:
|
||||
* ok: true → CreatedTask
|
||||
* ok: false → 에러 메시지 (UI 표시용)
|
||||
*/
|
||||
export async function createTask(
|
||||
context: vscode.ExtensionContext,
|
||||
input: TaskInput,
|
||||
): Promise<{ ok: true; task: CreatedTask } | { ok: false; error: string }> {
|
||||
if (!input.title?.trim()) return { ok: false, error: 'title 비어 있음' };
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.due)) {
|
||||
return { ok: false, error: `due 는 'YYYY-MM-DD' 형식이어야 함 (받은 값: ${input.due})` };
|
||||
}
|
||||
|
||||
const tokenResult = await getFreshAccessToken(context);
|
||||
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
|
||||
|
||||
const taskListId = (input.taskListId || '@default').trim() || '@default';
|
||||
const url = `${API_BASE}/lists/${encodeURIComponent(taskListId)}/tasks`;
|
||||
const body = {
|
||||
title: input.title.trim(),
|
||||
// Tasks API 의 `due` 는 RFC3339 timestamp 인데 시간 부분은 서버에서 무시되고
|
||||
// 날짜만 사용. UTC midnight 으로 보내는 게 표준 패턴.
|
||||
due: `${input.due}T00:00:00.000Z`,
|
||||
...(input.notes ? { notes: input.notes } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResult.accessToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg: string = json?.error?.message || `HTTP ${res.status}`;
|
||||
// 스코프/권한 부족이면 사용자 친화 안내로 변환 — 어떤 명령을 다시 실행해야 하는지 명시.
|
||||
if (res.status === 401 || res.status === 403 || /insufficient|scope|disabled|enable/i.test(msg)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Tasks API 권한 부족 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령을 다시 실행해 Tasks 스코프 동의가 필요합니다. (Google Cloud Console 에서 Tasks API 활성화도 함께 확인) 원인: ${msg}`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
task: {
|
||||
id: json.id,
|
||||
title: json.title,
|
||||
due: input.due,
|
||||
selfLink: json.selfLink,
|
||||
},
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
|
||||
export interface ListedTask {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'needsAction' | 'completed';
|
||||
/** 'YYYY-MM-DD' 형식. due 가 없는 task 는 undefined. */
|
||||
due?: string;
|
||||
/** 완료 시각 ISO timestamp. status 'completed' 일 때만 있음. */
|
||||
completed?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Tasks 목록 조회 — /onesie 1:1 카드 등에서 멤버별 필터링용.
|
||||
*
|
||||
* 기본 default list 의 task 들을 가져온다 (완료 포함). 호출자가 클라이언트 측에서
|
||||
* 제목 prefix `[멤버]` 나 notes 의 `@멤버` / `담당: 멤버` 패턴으로 필터하면 됨.
|
||||
*/
|
||||
export async function listTasks(
|
||||
context: vscode.ExtensionContext,
|
||||
options: { taskListId?: string; showCompleted?: boolean; maxResults?: number } = {},
|
||||
): Promise<{ ok: true; tasks: ListedTask[] } | { ok: false; error: string }> {
|
||||
const tokenResult = await getFreshAccessToken(context);
|
||||
if (!tokenResult.ok) return { ok: false, error: tokenResult.error };
|
||||
|
||||
const taskListId = (options.taskListId || '@default').trim() || '@default';
|
||||
const params = new URLSearchParams({
|
||||
maxResults: String(options.maxResults ?? 100),
|
||||
showCompleted: options.showCompleted !== false ? 'true' : 'false',
|
||||
showHidden: 'true', // 완료 후 숨김 처리된 것도 포함 — 1:1 회고에 최근 완료가 중요.
|
||||
});
|
||||
const url = `${API_BASE}/lists/${encodeURIComponent(taskListId)}/tasks?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${tokenResult.accessToken}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
});
|
||||
const json: any = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const msg: string = json?.error?.message || `HTTP ${res.status}`;
|
||||
if (res.status === 401 || res.status === 403 || /insufficient|scope/i.test(msg)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Tasks API 권한 부족 — "Astra: Google Calendar OAuth 연결 (쓰기)" 명령 재실행 + Tasks 스코프 동의 필요. (원인: ${msg})`,
|
||||
};
|
||||
}
|
||||
return { ok: false, error: msg };
|
||||
}
|
||||
const items: any[] = Array.isArray(json.items) ? json.items : [];
|
||||
const tasks: ListedTask[] = items.map((item: any) => ({
|
||||
id: String(item.id || ''),
|
||||
title: String(item.title || ''),
|
||||
status: item.status === 'completed' ? 'completed' : 'needsAction',
|
||||
due: typeof item.due === 'string' ? item.due.slice(0, 10) : undefined,
|
||||
completed: typeof item.completed === 'string' ? item.completed : undefined,
|
||||
notes: typeof item.notes === 'string' ? item.notes : undefined,
|
||||
}));
|
||||
return { ok: true, tasks };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message || String(e) };
|
||||
}
|
||||
}
|
||||
@@ -769,24 +769,23 @@ async function _dispatchOne(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Self-Reflector Hollow Code Check (휴리스틱, LLM 콜 0) ──
|
||||
// ── Hollow Code Check (휴리스틱, LLM 콜 0) ──
|
||||
// Phase C(syntax)가 잡지 못하는 *빈 깡통* 패턴을 정규식으로 잡는다.
|
||||
// hollow 발견 → 1) actionReport에 ❌ 라인 추가 2) verifierIssues에
|
||||
// 합류시켜 Phase B retry 트리거 (혹은 Phase B OFF면 사용자에게
|
||||
// 경고만 표시). 작은 LLM이 가장 자주 만드는 실패 패턴이라
|
||||
// selfReflectorEnabled가 켜져 있으면 *조건부 자동 활성화*.
|
||||
// v2.2.203: Phase A(selfReflectorEnabled) 와 분리 — 독립 toggle
|
||||
// (hollowCheck.enabled, 기본 ON). action-tag 있는 모든 응답에 무조건 실행.
|
||||
// Retry 도 Phase B 와 분리 (hollowCheck.autoRetry, 기본 ON) — 다수 사용자가
|
||||
// 자동 안전망 받도록.
|
||||
try {
|
||||
const cfgRuntime = getDispatcherConfig();
|
||||
if (cfgRuntime.selfReflectorEnabled && actionReport.length > 0) {
|
||||
if (cfgRuntime.hollowCheckEnabled && actionReport.length > 0) {
|
||||
const projectRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
|
||||
if (projectRoot) {
|
||||
const hollowRes = verifyHollow(actionReport, projectRoot);
|
||||
if (hollowRes.hasHollow) {
|
||||
actionReport = [...actionReport, ...hollowRes.extraLines];
|
||||
// verifier가 켜져 있고 아직 retry 안 했다면 hollow를 issue로
|
||||
// 격상해서 자동 재작업 트리거. 켜져 있지 않으면 사용자에게
|
||||
// 경고만 노출(이미 actionReport에 들어감).
|
||||
if (cfgRuntime.selfReflectorExternalEnabled && verifierIssues.length === 0) {
|
||||
// hollowCheck.autoRetry 가 켜져 있고 아직 retry 안 했다면 hollow를
|
||||
// 자동 재작업 트리거. (Phase B 와 무관 — 빈 깡통은 늘 잡아야)
|
||||
if (cfgRuntime.hollowCheckAutoRetry && verifierIssues.length === 0) {
|
||||
verifierIssues = hollowRes.hollowReasons.map((r) => `빈 깡통: ${r}`);
|
||||
verifierSummary = `Hollow code 감지 — 자동 재시도 트리거`;
|
||||
// 같은 specialist 1회 retry: 빈 깡통 지적을 task 앞에 prepend.
|
||||
@@ -815,9 +814,9 @@ async function _dispatchOne(
|
||||
agentId, error: e?.message ?? String(e),
|
||||
});
|
||||
}
|
||||
} else if (!cfgRuntime.selfReflectorExternalEnabled) {
|
||||
// verifier OFF — 사용자에게 경고만.
|
||||
verifierSummary = `⚠️ Hollow code 감지 — externalVerification 켜면 자동 재시도`;
|
||||
} else if (!cfgRuntime.hollowCheckAutoRetry) {
|
||||
// auto-retry OFF — 사용자에게 경고만.
|
||||
verifierSummary = `⚠️ Hollow code 감지 — hollowCheck.autoRetry 켜면 자동 재시도`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ export interface IntentAnalysisInput {
|
||||
* goal/format을 그쪽 능력에 맞춰 추출할 수 있다.
|
||||
*/
|
||||
availableRoleCategories?: string[];
|
||||
/**
|
||||
* 모드 전환 *직전* 의 일반 채팅 히스토리 요약. 사용자가 일반 채팅에서
|
||||
* 프로젝트·맥락·요구를 충분히 논의한 뒤 기업모드로 전환해 *후속 작업* 을
|
||||
* 요청한 경우, 분석기가 이를 보면 context/goal/criteria 를 이미 도출
|
||||
* 가능 — 중복 질문(맥락/목표/기준/형식) 을 안 던진다.
|
||||
*
|
||||
* 형식: 최근 N(기본 10) 턴의 `role: content` 한 줄씩, 각 content 200자 cap.
|
||||
* 없으면 undefined (첫 진입 / 모드 토글 없는 케이스).
|
||||
*/
|
||||
priorChatSummary?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입니다. 사용자의 자연어 요청을 받아 그것을 실행 가능한 작업 조건 5가지(C-G-C-F-Q)로 정리합니다.
|
||||
@@ -73,6 +83,8 @@ const SYSTEM_PROMPT = `당신은 "1인 기업 모드"의 *요청 분석가*입
|
||||
|
||||
⚠️ 추측 금지. 사용자의 한 줄 + 컨텍스트에서 *직접 추론*되지 않는 정보는 채우지 마세요. 빈 칸은 그대로 두고 그 자리에 대응하는 질문을 openQuestions에 넣으세요.
|
||||
|
||||
⚠️ **[모드 전환 시 context 우선 추출]**: 입력에 \`[모드 전환 직전 일반 채팅 요약]\` 블록이 있으면, 그것을 **사용자의 한 줄과 같은 권위로** 취급하세요. 거기서 context/goal/criteria/format 을 *직접 추출* 한 뒤, 그래도 빠진 항목만 openQuestions 에 넣으세요. 사용자가 이미 일반 채팅에서 충분히 설명한 내용을 다시 물어보면 안 됩니다 — 일반 채팅에서 *명시적으로 언급* 된 항목은 추측이 아니라 **명시된 사실** 입니다.
|
||||
|
||||
confidence는 다음 기준으로 자체 판정:
|
||||
- "high" : C·G·C·F 4개 모두 prompt에서 직접 추론 가능. openQuestions = [] 가능.
|
||||
- "medium" : 대체로 명확하지만 1~2개 항목에서 합리적 가정 필요. 추가 질문 1~2개.
|
||||
@@ -95,6 +107,18 @@ function _buildUserMessage(input: IntentAnalysisInput): string {
|
||||
const lines: string[] = [];
|
||||
lines.push('[사용자 원본 요청]');
|
||||
lines.push(input.userOriginalPrompt);
|
||||
// 모드 전환 직전 일반 채팅 요약 — 분석기가 context/goal/criteria 를 *여기서 먼저 추출*.
|
||||
// 사용자가 일반 채팅에서 이미 설명한 항목을 openQuestions 에 다시 넣지 못하게 막음.
|
||||
if (input.priorChatSummary && input.priorChatSummary.trim()) {
|
||||
lines.push('');
|
||||
lines.push('[모드 전환 직전 일반 채팅 요약]');
|
||||
lines.push('아래는 사용자가 *기업모드 전환 전* 일반 채팅에서 같은 주제로 나눈 대화입니다.');
|
||||
lines.push('여기에 명시된 context/goal/criteria/format 은 *사용자가 이미 말한 사실* 로 취급하여');
|
||||
lines.push('contract 의 해당 슬롯을 채우고, 다시 묻지 마세요.');
|
||||
lines.push('---');
|
||||
lines.push(input.priorChatSummary);
|
||||
lines.push('---');
|
||||
}
|
||||
if (input.activePipelineName) {
|
||||
lines.push('');
|
||||
lines.push(`(활성 파이프라인) "${input.activePipelineName}"`);
|
||||
|
||||
@@ -166,7 +166,14 @@ const FULL_PRODUCT_DEV: PipelineTemplate = {
|
||||
instructionTemplate:
|
||||
'설계: {{stage.dev-design}}\n기획서: {{stage.plan-final}}\n\n' +
|
||||
'설계대로 *실제 코드를 작성*하세요. 반드시 ConnectAI 액션 태그(`<create_file>`, `<edit_file>`, `<run_command>`)를 사용해 디스크에 떨어지도록.\n' +
|
||||
'코드 블록만 보여주고 "생성 완료"라고 말하면 디스크엔 아무것도 안 만들어집니다. 작성 후 자가 검증 한 줄.',
|
||||
'코드 블록만 보여주고 "생성 완료"라고 말하면 디스크엔 아무것도 안 만들어집니다.\n\n' +
|
||||
'[빈 깡통 금지 — 가장 자주 발생하는 실패 패턴]\n' +
|
||||
'1. 파일은 **하나씩** 생성. 한 파일 안의 **모든 함수·메서드·클래스 본문을 완전히 구현** 후 다음 파일로.\n' +
|
||||
'2. **금지 패턴**: `def foo(): pass` · `def foo(): ...` · `def foo(): raise NotImplementedError` · `def foo(): # TODO` · 빈 class 본문 (`class Foo: pass`) · imports 만 있는 파일.\n' +
|
||||
'3. 함수 본문이 정말 비어도 되는 경우는 *인터페이스/추상 메서드* 만. 나머지는 **무조건 동작하는 코드** 작성. 단순화는 OK, 빈 깡통은 NO.\n' +
|
||||
'4. 각 파일 생성 직후 한 줄 자가 검증: "이 파일이 정말 동작하나? 빈 본문 없나?". 없으면 보강 후 `<edit_file>` 로 재기록.\n' +
|
||||
'5. 모든 파일 생성 끝나면 *최종 요약* 에 (a) 생성된 파일 수 (b) 각 파일의 핵심 함수 동작 한 줄씩 명시.\n\n' +
|
||||
'⚠️ "구조만 잡고 추후 구현" 패턴은 즉시 검출돼 자동 재작업이 트리거됩니다. 처음부터 본문까지 완전 구현하세요.',
|
||||
},
|
||||
{
|
||||
id: 'qa',
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 고객사 / MRR / 갱신 트래커.
|
||||
*
|
||||
* 4인 기업의 수입 쪽 — `/runway` 가 통장과 burn 을 본다면, 여기는 *어디서 돈이 들어오나*.
|
||||
* Salesforce / HubSpot 같은 CRM 아닌 가벼운 event-sourced 로그.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — append-only event log. 같은 customer 의 여러 이벤트를
|
||||
* 시간 순으로 재생하면 현재 상태 (MRR, 갱신일, 위험 등급) 가 나온다.
|
||||
*
|
||||
* 위치: `<workspace>/.astra/customers.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화.
|
||||
* 민감 정보(고객사 이름, 매출) 포함되므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import { createEventStore } from '../_shared/eventSourcedStore';
|
||||
|
||||
const STORE_REL_PATH = '.astra/customers.jsonl';
|
||||
|
||||
export type CustomerEventType = 'add' | 'note' | 'risk' | 'churn' | 'renew' | 'update';
|
||||
|
||||
export interface CustomerEvent {
|
||||
/** unique id — timestamp 기반. */
|
||||
id: string;
|
||||
/** ISO timestamp. */
|
||||
timestamp: string;
|
||||
/** 고객 식별자 — 소문자·trim 한 name 의 slug. 같은 customer 의 이벤트끼리 그룹. */
|
||||
customerId: string;
|
||||
/** 표시용 원본 이름 (가장 최근 이벤트의 이름 우선). */
|
||||
customerName: string;
|
||||
/** 이벤트 종류. */
|
||||
type: CustomerEventType;
|
||||
/** 월 매출 — add/renew/update 에서 사용. */
|
||||
mrr?: number;
|
||||
/** 갱신일 (YYYY-MM-DD) — add/renew/update. */
|
||||
renewalAt?: string;
|
||||
/** 요금제명 — 'pro' / 'enterprise' / 'starter' 등. */
|
||||
plan?: string;
|
||||
/** 자유 텍스트 — note / risk / churn 의 사유. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export type CustomerStatus = 'active' | 'at-risk' | 'churned';
|
||||
|
||||
export interface CustomerState {
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
mrr: number;
|
||||
plan?: string;
|
||||
renewalAt?: string;
|
||||
status: CustomerStatus;
|
||||
startedAt: string;
|
||||
lastEventAt: string;
|
||||
eventCount: number;
|
||||
notes: { timestamp: string; type: CustomerEventType; memo: string }[];
|
||||
}
|
||||
|
||||
const _store = createEventStore<CustomerEvent>({
|
||||
relPath: STORE_REL_PATH,
|
||||
validate: (e): e is CustomerEvent => !!e
|
||||
&& typeof (e as any).id === 'string'
|
||||
&& typeof (e as any).customerId === 'string'
|
||||
&& typeof (e as any).type === 'string',
|
||||
});
|
||||
|
||||
export const getCustomersFilePath = _store.getFilePath;
|
||||
export const readEvents = _store.read;
|
||||
export const appendEvent = _store.append;
|
||||
|
||||
export function customerIdFromName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 로그를 재생해 customerId 별 현재 상태 도출.
|
||||
*
|
||||
* - add: 신규 생성 (status=active, startedAt 설정)
|
||||
* - update / renew: mrr / renewalAt / plan 갱신, status=active 로 복귀 (renew 시)
|
||||
* - risk: status=at-risk, memo 누적
|
||||
* - churn: status=churned, mrr=0
|
||||
* - note: 노트 누적만, 상태 무변경
|
||||
*/
|
||||
export function computeCustomerStates(): Map<string, CustomerState> {
|
||||
const events = readEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
const states = new Map<string, CustomerState>();
|
||||
for (const e of events) {
|
||||
let s = states.get(e.customerId);
|
||||
if (!s) {
|
||||
if (e.type !== 'add') {
|
||||
// add 이벤트 없이 다른 이벤트가 먼저 와도 묵시적 생성 — 데이터 손상 방어.
|
||||
s = {
|
||||
customerId: e.customerId,
|
||||
customerName: e.customerName,
|
||||
mrr: 0,
|
||||
status: 'active',
|
||||
startedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.customerId, s);
|
||||
} else {
|
||||
s = {
|
||||
customerId: e.customerId,
|
||||
customerName: e.customerName,
|
||||
mrr: e.mrr ?? 0,
|
||||
plan: e.plan,
|
||||
renewalAt: e.renewalAt,
|
||||
status: 'active',
|
||||
startedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.customerId, s);
|
||||
}
|
||||
}
|
||||
s.customerName = e.customerName || s.customerName;
|
||||
s.lastEventAt = e.timestamp;
|
||||
s.eventCount += 1;
|
||||
|
||||
switch (e.type) {
|
||||
case 'add':
|
||||
// 위에서 이미 처리 (첫 진입 분기) — 중복 add 면 update 처럼.
|
||||
if (e.mrr !== undefined) s.mrr = e.mrr;
|
||||
if (e.renewalAt) s.renewalAt = e.renewalAt;
|
||||
if (e.plan) s.plan = e.plan;
|
||||
break;
|
||||
case 'update':
|
||||
case 'renew':
|
||||
if (e.mrr !== undefined) s.mrr = e.mrr;
|
||||
if (e.renewalAt) s.renewalAt = e.renewalAt;
|
||||
if (e.plan) s.plan = e.plan;
|
||||
if (e.type === 'renew' && s.status !== 'churned') s.status = 'active';
|
||||
break;
|
||||
case 'risk':
|
||||
if (s.status !== 'churned') s.status = 'at-risk';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'risk', memo: e.memo });
|
||||
break;
|
||||
case 'churn':
|
||||
s.status = 'churned';
|
||||
s.mrr = 0;
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'churn', memo: e.memo });
|
||||
break;
|
||||
case 'note':
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
@@ -0,0 +1,744 @@
|
||||
/**
|
||||
* Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet.
|
||||
*
|
||||
* v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 3002) 통합
|
||||
* 슬래시 명령 클러스터 — NotebookLM Deep Research · Playwright 웹 스캔 ·
|
||||
* yt-dlp YouTube 자막 추출 · 본문 wikify · 회의록 합성.
|
||||
*
|
||||
* callLmSynthesis / repairKoreanGlitches 는 slashRouter 에 남음 (communication
|
||||
* 핸들러도 사용하는 일반 LLM 호출 인프라). 본 파일은 *bridge 호출 시퀀스* 만 담당.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { promises as fsp } from 'fs';
|
||||
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
||||
import { callLmSynthesis } from './llm';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||||
import {
|
||||
type YoutubeAnalysisMode,
|
||||
formatHms,
|
||||
fullScriptFromSegments,
|
||||
buildInfoExtractionPrompt,
|
||||
build4LensPrompt,
|
||||
} from './prompts/youtubePrompts';
|
||||
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
||||
import { buildMeetPrompt } from './prompts/meetPrompt';
|
||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||
import {
|
||||
addBusinessDays,
|
||||
toYmd,
|
||||
extractMeetingDate,
|
||||
resolveTaskDate,
|
||||
parseActionItems,
|
||||
} from './scheduling/calendarHelpers';
|
||||
|
||||
// ───────────────────────────── /research ─────────────────────────────
|
||||
|
||||
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!topic) {
|
||||
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
|
||||
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
|
||||
BRIDGE_API.research.start,
|
||||
{ method: 'POST', body: JSON.stringify({ topic }) },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
|
||||
|
||||
const deadline = Date.now() + 10 * 60_000;
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
const MAX_CONSECUTIVE_FAILS = 5;
|
||||
const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
|
||||
const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
|
||||
|
||||
let lastStatus = '';
|
||||
let lastChangeAt = Date.now();
|
||||
let consecutiveFails = 0;
|
||||
let pollCount = 0;
|
||||
let researchOk = false;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 5_000));
|
||||
pollCount++;
|
||||
let st: { success: boolean; result: any } | undefined;
|
||||
try {
|
||||
st = await bridgeFetch<{ success: boolean; result: any }>(
|
||||
`${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
consecutiveFails = 0;
|
||||
} catch (e: any) {
|
||||
consecutiveFails++;
|
||||
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
|
||||
chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
|
||||
return true;
|
||||
}
|
||||
chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
|
||||
continue;
|
||||
}
|
||||
const status = String(st.result?.status || st.result || '').trim().toLowerCase();
|
||||
if (status && status !== lastStatus) {
|
||||
chunk(view, ` · ${status}\n`);
|
||||
lastStatus = status;
|
||||
lastChangeAt = Date.now();
|
||||
} else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
|
||||
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
|
||||
lastChangeAt = Date.now();
|
||||
}
|
||||
if (COMPLETED_SET.has(status)) { researchOk = true; break; }
|
||||
if (FAILED_SET.has(status)) {
|
||||
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!researchOk) {
|
||||
chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📥 import…\n`);
|
||||
await bridgeFetch(BRIDGE_API.research.import, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
|
||||
}, {
|
||||
timeoutMs: 300_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
});
|
||||
|
||||
chunk(view, `🧪 synthesize…\n\n`);
|
||||
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
|
||||
BRIDGE_API.research.synthesize,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
|
||||
},
|
||||
{
|
||||
timeoutMs: 600_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
},
|
||||
);
|
||||
const md = synth.markdown || synth.result || '(빈 응답)';
|
||||
chunk(view, `---\n\n${md}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
let url = '';
|
||||
let depthArg: number | undefined;
|
||||
let pagesArg: number | undefined;
|
||||
const restParts: string[] = [];
|
||||
for (const t of tokens) {
|
||||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||||
if (m) {
|
||||
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
|
||||
else pagesArg = Number(m[2]);
|
||||
} else if (!url) {
|
||||
url = t;
|
||||
} else {
|
||||
restParts.push(t);
|
||||
}
|
||||
}
|
||||
if (!url) {
|
||||
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
|
||||
return true;
|
||||
}
|
||||
const userContent = restParts.join(' ');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
|
||||
const maxPages = pagesArg ?? (cfg.get<number>('datacollectMaxPages', 8) ?? 8);
|
||||
|
||||
chunk(view, `🔍 **Web Benchmark 스캔**: ${url}\n(crawlDepth ${crawlDepth} · 최대 ${maxPages}페이지 · Playwright)\n\n⏳ 브라우저 기동 · 페이지 크롤링 · 디자인 토큰 추출 중…`);
|
||||
|
||||
const t0 = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||
}, 4000);
|
||||
const scan = await bridgeFetch<{ success: boolean; scan: any; slug?: string }>(
|
||||
BRIDGE_API.web.benchmarkScan,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url, captureScreenshots: false, maxPages, crawlDepth }),
|
||||
},
|
||||
{ timeoutMs: 6 * 60_000 },
|
||||
).finally(() => clearInterval(heartbeat));
|
||||
const s = scan.scan;
|
||||
chunk(view, `\n✅ **스캔 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${s?.sitemap?.totalPages ?? 1}페이지)\n\n`);
|
||||
|
||||
const looksEmpty = !s?.meta?.title
|
||||
&& !(s?.design?.colors?.palette?.length)
|
||||
&& !s?.microcopy?.headline;
|
||||
if (looksEmpty) {
|
||||
chunk(view, `⚠️ **스캔 결과가 비어 있습니다.** URL이 정확한지, 사이트가 접근 가능한지(봇 차단·JS 전용 렌더링 포함) 확인하세요. 스캔한 URL: \`${url}\`\n\n`);
|
||||
}
|
||||
|
||||
const palette = s?.design?.colors?.palette?.slice(0, 5) || [];
|
||||
const rawReport = [
|
||||
`### 메타`,
|
||||
`- **title**: ${s?.meta?.title || '(없음)'}`,
|
||||
`- **description**: ${s?.meta?.description || '(없음)'}`,
|
||||
`- **lang**: ${s?.meta?.lang || '(없음)'}`,
|
||||
``,
|
||||
`### 디자인 토큰 (상위)`,
|
||||
`- **컬러 팔레트 Top 5**: ${palette.map((p: any) => `\`${p.value}\` (×${p.count})`).join(', ') || '(없음)'}`,
|
||||
`- **컬러 비율**: ${s?.design?.colors?.composition?.ratioLabel || '(없음)'}`,
|
||||
`- **Primary Font**: \`${s?.design?.typography?.primaryFont || '(없음)'}\``,
|
||||
``,
|
||||
`### 사이트맵 (${s?.sitemap?.totalPages ?? 1}페이지, depth ${s?.sitemap?.crawlDepth ?? 0})`,
|
||||
'```',
|
||||
s?.sitemap?.ascii || '(없음)',
|
||||
'```',
|
||||
].join('\n');
|
||||
|
||||
let finalReport: string;
|
||||
if (looksEmpty) {
|
||||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||||
finalReport = rawReport;
|
||||
} else {
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
try {
|
||||
const parts: string[] = [];
|
||||
for (const part of [1, 2, 3] as const) {
|
||||
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
|
||||
const partT0 = Date.now();
|
||||
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
|
||||
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
|
||||
parts.push(out);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||||
}
|
||||
finalReport = parts.join('\n\n---\n\n');
|
||||
chunk(view, `\n\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ LLM 합성 실패: ${e?.message || String(e)}\n원시 스캔 요약만 표시·저장합니다. (LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
finalReport = rawReport;
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, finalReport + '\n\n');
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let host = url;
|
||||
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
|
||||
const title = `웹벤치마크 ${host} ${today}`;
|
||||
const fileMarkdown = [
|
||||
`# ${title}`,
|
||||
``,
|
||||
`- **원본 URL**: ${url}`,
|
||||
`- **스캔 시각**: ${new Date().toISOString()}`,
|
||||
`- **크롤 옵션**: depth ${crawlDepth} · 최대 ${maxPages}페이지`,
|
||||
`- **생성**: Astra /benchmark · Datacollect web-benchmark`,
|
||||
``,
|
||||
finalReport,
|
||||
``,
|
||||
].join('\n');
|
||||
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
|
||||
const body: Record<string, unknown> = { title, content: fileMarkdown };
|
||||
if (savePath) body.saveDir = savePath;
|
||||
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
|
||||
BRIDGE_API.wiki.save,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
chunk(view, `💾 **결과물 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `⚠️ 결과물 저장 실패: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /youtube ─────────────────────────────
|
||||
|
||||
function _looksLikeYoutubeChannelUrl(url: string): boolean {
|
||||
return /youtube\.com\/(channel\/|@|c\/|user\/|playlist\?list=|playlist\/)/i.test(url)
|
||||
|| /youtube\.com\/[^/?#]+\/(videos|shorts|streams)\b/i.test(url);
|
||||
}
|
||||
|
||||
function _normalizeYoutubeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
if (!/youtube\.com$|youtube\.com\.|youtu\.be$/i.test(u.hostname)) return url;
|
||||
const p = u.pathname;
|
||||
if (/\/(watch|shorts|playlist|videos|streams|featured|community|about)\b/i.test(p)) return url;
|
||||
if (u.hostname.includes('youtu.be')) return url;
|
||||
if (/^\/(@[^/]+|channel\/[^/]+|c\/[^/]+|user\/[^/]+)\/?$/i.test(p)) {
|
||||
u.pathname = p.replace(/\/?$/, '/videos');
|
||||
return u.toString();
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
const YOUTUBE_BATCH_MAX = 50;
|
||||
|
||||
async function runYoutube(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const BARE_MODE_KEYWORDS = new Set(['info', 'benchmark', 'both']);
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
const url = tokens[0] || '';
|
||||
let limitOverride: number | null = null;
|
||||
let mode: YoutubeAnalysisMode = 'both';
|
||||
const contextTokens: string[] = [];
|
||||
for (const tok of tokens.slice(1)) {
|
||||
const nMatch = tok.match(/^n[:=](\d+)$/i);
|
||||
if (nMatch) {
|
||||
const n = parseInt(nMatch[1], 10);
|
||||
if (Number.isFinite(n) && n > 0) limitOverride = Math.min(YOUTUBE_BATCH_MAX, n);
|
||||
continue;
|
||||
}
|
||||
const modeMatch = tok.match(/^mode[:=](info|benchmark|both)$/i);
|
||||
if (modeMatch) { mode = modeMatch[1].toLowerCase() as YoutubeAnalysisMode; continue; }
|
||||
const lower = tok.toLowerCase();
|
||||
if (BARE_MODE_KEYWORDS.has(lower)) { mode = lower as YoutubeAnalysisMode; continue; }
|
||||
contextTokens.push(tok);
|
||||
}
|
||||
const userContent = contextTokens.join(' ');
|
||||
|
||||
if (!url) {
|
||||
chunk(view, [
|
||||
`사용법:\n`,
|
||||
`- 단일 영상: \`/youtube <영상URL> [info|benchmark|both] [컨텍스트]\`\n`,
|
||||
`- 채널/플레이리스트: \`/youtube <채널URL> [n:30] [info|benchmark|both] [컨텍스트]\`\n`,
|
||||
`\n**분석 모드** (생략 시 \`both\`):\n`,
|
||||
`- \`info\` — 영상의 *내용*을 지식 카드로 추출 (튜토리얼·강의·뉴스·인터뷰)\n`,
|
||||
`- \`benchmark\` — 대본 역기획서 4-렌즈 분석 (콘텐츠 제작 벤치마크용)\n`,
|
||||
`- \`both\` — 둘 다 생성 (영상당 LLM 호출 2회)\n`,
|
||||
`\n예시:\n`,
|
||||
`- \`/youtube https://youtu.be/abc info\`\n`,
|
||||
`- \`/youtube https://youtube.com/@somechannel n:20 info AI 학습 자료\`\n`,
|
||||
`\n💡 \`mode:info\` / \`mode=info\` 같은 명시형도 그대로 동작 (백워드 호환).\n`,
|
||||
].join(''));
|
||||
return true;
|
||||
}
|
||||
|
||||
const isChannel = _looksLikeYoutubeChannelUrl(url);
|
||||
const limit = limitOverride ?? (isChannel ? 10 : 1);
|
||||
const normalizedUrl = isChannel ? _normalizeYoutubeUrl(url) : url;
|
||||
if (normalizedUrl !== url) {
|
||||
chunk(view, `🔧 채널 URL 정규화: \`${url}\` → \`${normalizedUrl}\` (yt-dlp 영상 enumeration 을 위한 \`/videos\` 탭 명시)\n\n`);
|
||||
}
|
||||
|
||||
const modeLabel = mode === 'info' ? '📋 정보 추출 (지식 카드)'
|
||||
: mode === 'benchmark' ? '🎬 벤치마킹 (4-렌즈 역기획서)'
|
||||
: '📋 정보 추출 + 🎬 벤치마킹 (둘 다)';
|
||||
if (isChannel) {
|
||||
const callsPerVideo = mode === 'both' ? 2 : 1;
|
||||
chunk(view, `📺 **채널/플레이리스트 감지** → 최신 ${limit}개 영상을 1개씩 순차 분석·wiki화 합니다.\n` +
|
||||
`분석 모드: **${modeLabel}** (영상당 LLM ${callsPerVideo}회 호출)\n` +
|
||||
`각 영상은 자막추출 → LLM 분석 → wiki 저장 순으로 처리되며, 영상당 보통 30~${120 * callsPerVideo}초.\n` +
|
||||
`중간에 멈추려면 Astra 사이드바의 ⏹ Stop 을 누르세요.\n\n`);
|
||||
} else {
|
||||
chunk(view, `📊 **분석 모드**: ${modeLabel}\n\n`);
|
||||
}
|
||||
|
||||
chunk(view, `🎬 **YouTube 추출**: ${normalizedUrl}\n(자막 + 메타데이터${limit > 1 ? `, ${limit}개 영상` : ''})\n\n⏳ Python 추출기 기동 · 자막/메타 추출 중…`);
|
||||
const t0 = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||
}, 4000);
|
||||
const extractTimeoutMs = Math.max(5 * 60_000, limit * 60_000);
|
||||
const data = await bridgeFetch<{ success: boolean; videos?: any[]; totalVideos?: number }>(
|
||||
BRIDGE_API.youtube.extract,
|
||||
{ method: 'POST', body: JSON.stringify({ source: normalizedUrl, withMetadata: true, limit }) },
|
||||
{
|
||||
timeoutMs: extractTimeoutMs,
|
||||
onHeartbeat: limit > 1
|
||||
? (elapsedMs) => chunk(view, `\n · 추출 진행 중 (${Math.round(elapsedMs / 1000)}s, ${limit}개 영상)\n`)
|
||||
: undefined,
|
||||
},
|
||||
).finally(() => clearInterval(heartbeat));
|
||||
|
||||
const okVideos = (data.videos || []).filter((v: any) => v?.status === 'ok');
|
||||
chunk(view, `\n✅ **추출 완료** (${Math.round((Date.now() - t0) / 1000)}s · ${okVideos.length}/${data.totalVideos ?? (data.videos || []).length}개 영상)\n\n`);
|
||||
if (okVideos.length === 0) {
|
||||
chunk(view, `⚠️ 자막 추출에 성공한 영상이 없습니다. 자막이 없거나 비공개 영상일 수 있습니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const sysInfo = '당신은 영상 콘텐츠를 지식 카드로 변환하는 정보 큐레이터입니다. 자막에 명시된 사실만 인용하세요.';
|
||||
const sysBench = '당신은 유튜브 콘텐츠 시니어 PD입니다. 데이터에 근거한 제작 가이드만 제공하세요.';
|
||||
|
||||
type Section = { label: string; body: string };
|
||||
async function runOneAnalysis(video: any, prompt: string, system: string, sectionLabel: string, progressTag: string): Promise<Section | null> {
|
||||
chunk(view, `🧪 **${sectionLabel}**${progressTag} (모델 \`${model}\`)…`);
|
||||
try {
|
||||
const t = Date.now();
|
||||
const body = await callLmSynthesis(prompt, system);
|
||||
if (!body) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t) / 1000)}s)\n\n`);
|
||||
chunk(view, body + '\n\n');
|
||||
return { label: sectionLabel, body };
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ ${sectionLabel} 실패${progressTag}: ${e?.message || String(e)}\n`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const total = okVideos.length;
|
||||
let analyzedOk = 0;
|
||||
let analyzedFail = 0;
|
||||
let savedOk = 0;
|
||||
let savedFail = 0;
|
||||
const batchT0 = Date.now();
|
||||
for (let i = 0; i < okVideos.length; i++) {
|
||||
const video = okVideos[i];
|
||||
const vTitle = video?.metadata?.title || video?.title || video?.video_id || '(제목 없음)';
|
||||
const progressTag = total > 1 ? ` [${i + 1}/${total}]` : '';
|
||||
|
||||
if (total > 1) chunk(view, `\n━━━ **${progressTag.trim()} ${vTitle}** ━━━\n\n`);
|
||||
|
||||
const script = fullScriptFromSegments(video?.segments);
|
||||
chunk(view, `## 📜 전체 스크립트 (Full Script)\n\n${script}\n\n---\n\n`);
|
||||
|
||||
const sections: Section[] = [];
|
||||
if (mode === 'info' || mode === 'both') {
|
||||
const sec = await runOneAnalysis(video, buildInfoExtractionPrompt(video, userContent), sysInfo, '📋 정보 추출 (지식 카드)', progressTag);
|
||||
if (sec) sections.push(sec);
|
||||
}
|
||||
if (mode === 'benchmark' || mode === 'both') {
|
||||
const sec = await runOneAnalysis(video, build4LensPrompt(video, userContent), sysBench, '🎬 벤치마킹 (4-렌즈 역기획서)', progressTag);
|
||||
if (sec) sections.push(sec);
|
||||
}
|
||||
|
||||
if (sections.length === 0) {
|
||||
analyzedFail++;
|
||||
chunk(view, `(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
continue;
|
||||
}
|
||||
analyzedOk++;
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const videoUrl = video?.metadata?.webpage_url || `https://www.youtube.com/watch?v=${video?.video_id}`;
|
||||
const modeSuffix = mode === 'info' ? ' (정보)' : mode === 'benchmark' ? ' (벤치마크)' : '';
|
||||
const title = `유튜브분석 ${vTitle}${modeSuffix} ${today}`;
|
||||
const sectionDivider = sections.length > 1 ? `\n\n---\n\n` : '';
|
||||
const fileMarkdown = [
|
||||
`# ${title}`,
|
||||
``,
|
||||
`- **영상 URL**: ${videoUrl}`,
|
||||
`- **분석 시각**: ${new Date().toISOString()}`,
|
||||
`- **분석 모드**: ${mode}`,
|
||||
`- **생성**: Astra /youtube · Datacollect youtube insight`,
|
||||
``,
|
||||
`## 📜 전체 스크립트 (Full Script)`,
|
||||
``,
|
||||
script,
|
||||
``,
|
||||
`---`,
|
||||
``,
|
||||
sections.map((s) => s.body).join(sectionDivider),
|
||||
``,
|
||||
].join('\n');
|
||||
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
|
||||
const body: Record<string, unknown> = { title, content: fileMarkdown };
|
||||
if (savePath) body.saveDir = savePath;
|
||||
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
|
||||
BRIDGE_API.wiki.save,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
savedOk++;
|
||||
chunk(view, `💾 **결과물 저장 완료**${progressTag}: \`${saved?.path || '(경로 미확인)'}\`\n\n`);
|
||||
} catch (e: any) {
|
||||
savedFail++;
|
||||
chunk(view, `⚠️ 결과물 저장 실패${progressTag}: ${e?.message || String(e)}\n\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (total > 1) {
|
||||
const batchSec = Math.round((Date.now() - batchT0) / 1000);
|
||||
chunk(view, `\n━━━━━━━━━━━━━━━━━━━━\n`
|
||||
+ `🏁 **배치 완료** (총 ${batchSec}s · ${total}개 영상)\n`
|
||||
+ `- 분석: ✅ ${analyzedOk} / ❌ ${analyzedFail}\n`
|
||||
+ `- 저장: 💾 ${savedOk} / ⚠️ ${savedFail}\n`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /blog ─────────────────────────────
|
||||
|
||||
async function runBlog(keyword: string, view: Webview | undefined): Promise<boolean> {
|
||||
const target = 'http://127.0.0.1:8787/blog/';
|
||||
chunk(view, `🖋️ **Blog Pipeline**\n\n`);
|
||||
if (keyword) chunk(view, `요청 키워드: \`${keyword}\`\n\n`);
|
||||
chunk(view, [
|
||||
`현재 Blog Pipeline은 Datacollect의 별도 정적 페이지(`,
|
||||
`[${target}](${target})`,
|
||||
`)에서 단계별로 실행됩니다. Bridge(3002)에 대응 API가 없어 채팅에서 직접`,
|
||||
` 트리거할 수 없어, 다음 두 가지 중 하나를 선택해 주세요:\n\n`,
|
||||
].join(''));
|
||||
chunk(view, `1. 위 링크에서 키워드/참고 자료/경험담을 입력해 직접 파이프라인 실행\n`);
|
||||
chunk(view, `2. Bridge에 \`/api/blog/run\` 같은 endpoint를 노출하면 \`/blog\`도 end-to-end 실행 가능 — 원하시면 추가 작업으로 진행\n`);
|
||||
|
||||
try { await vscode.env.openExternal(vscode.Uri.parse(target)); } catch { /* best-effort */ }
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /wikify ─────────────────────────────
|
||||
|
||||
type WikifyResult = { ok: true } | { ok: false; reason: string };
|
||||
async function wikifyOne(url: string, userContent: string, view: Webview | undefined): Promise<WikifyResult> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
|
||||
chunk(view, `⏳ 본문 추출 중…`);
|
||||
const t0 = Date.now();
|
||||
const heartbeat = setInterval(() => {
|
||||
chunk(view, ` ·${Math.round((Date.now() - t0) / 1000)}s`);
|
||||
}, 4000);
|
||||
const data = await bridgeFetch<{ success: boolean; url: string; title?: string; description?: string; lang?: string; headings?: string[]; text?: string; textLength?: number; truncated?: boolean }>(
|
||||
BRIDGE_API.web.extract,
|
||||
{ method: 'POST', body: JSON.stringify({ url }) },
|
||||
{ timeoutMs: 3 * 60_000 },
|
||||
).finally(() => clearInterval(heartbeat));
|
||||
chunk(view, `\n✅ 본문 추출 (${Math.round((Date.now() - t0) / 1000)}s · ${(data.textLength ?? 0).toLocaleString()}자${data.truncated ? ', 일부 잘림' : ''})\n\n`);
|
||||
|
||||
if (!data.text || data.text.trim().length < 50) {
|
||||
const reason = `본문 빈약 (${data.textLength ?? 0}자 — JS 전용 렌더링 또는 콘텐츠 없음)`;
|
||||
chunk(view, `⚠️ 추출된 본문이 거의 없어 건너뜁니다. (${reason})\n`);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const wikiSystem = '당신은 지식 큐레이터입니다. 제공된 웹사이트 본문을 P-Reinforce v3.0 규격의 고밀도 위키 문서로 정리합니다. 본문에 없는 내용은 절대 지어내지 않으며, 모든 문서는 한국어로 작성합니다.';
|
||||
chunk(view, `🧪 P-Reinforce 위키 합성 (모델 \`${model}\`)…`);
|
||||
let report: string;
|
||||
try {
|
||||
const synthT0 = Date.now();
|
||||
report = await callLmSynthesis(buildWikifyPrompt(data, userContent), wikiSystem);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
report = report.replace(/\[\[([^\[\]]+?)\](?!\])/g, '[[$1]]');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - synthT0) / 1000)}s)\n\n`);
|
||||
} catch (e: any) {
|
||||
const reason = `LLM 합성 실패: ${e?.message || String(e)}`;
|
||||
chunk(view, `\n⚠️ 위키 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
return { ok: false, reason };
|
||||
}
|
||||
chunk(view, report + '\n\n');
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let host = url;
|
||||
try { host = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw url */ }
|
||||
const title = `위키 ${(userContent.trim() || data.title || host)} ${today}`;
|
||||
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
|
||||
const body: Record<string, unknown> = { title, content: report };
|
||||
if (savePath) body.saveDir = savePath;
|
||||
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
|
||||
BRIDGE_API.wiki.save,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
chunk(view, `💾 위키 문서 저장 완료: \`${saved?.path || '(경로 미확인)'}\`\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `⚠️ 위키 문서 저장 실패: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function runWikify(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
const isUrl = (t: string) => /^https?:\/\//i.test(t) || /^[a-z0-9-]+(\.[a-z0-9-]+)+/i.test(t);
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
const urls = tokens.filter(isUrl);
|
||||
const userContent = tokens.filter((t) => !isUrl(t)).join(' ');
|
||||
if (urls.length === 0) {
|
||||
chunk(view, `사용법: \`/wikify <url> [url2 url3 …] [주제명]\`\n예: \`/wikify https://example.com\`\n여러 링크를 공백으로 구분해 한 번에 넣으면 1개씩 순차 위키화합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (urls.length === 1) {
|
||||
chunk(view, `📚 **위키화**: ${urls[0]}\n(본문 추출 → P-Reinforce v3.0 위키 문서 합성)\n\n`);
|
||||
const result = await wikifyOne(urls[0], userContent, view);
|
||||
if (!result.ok) chunk(view, `\n실패 사유: ${result.reason}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `📚 **위키화 배치**: 총 ${urls.length}개 링크를 순차 처리합니다.\n`);
|
||||
const batchT0 = Date.now();
|
||||
let okCount = 0;
|
||||
const failures: Array<{ url: string; reason: string }> = [];
|
||||
for (let i = 0; i < urls.length; i++) {
|
||||
chunk(view, `\n---\n\n### [${i + 1}/${urls.length}] ${urls[i]}\n\n`);
|
||||
try {
|
||||
const result = await wikifyOne(urls[i], userContent, view);
|
||||
if (result.ok) okCount++;
|
||||
else failures.push({ url: urls[i], reason: result.reason });
|
||||
} catch (e: any) {
|
||||
const reason = `처리 오류: ${e?.message || String(e)}`;
|
||||
chunk(view, `❌ [${i + 1}/${urls.length}] ${reason}\n`);
|
||||
failures.push({ url: urls[i], reason });
|
||||
}
|
||||
}
|
||||
chunk(view, `\n---\n\n🏁 **배치 완료**: ${okCount}/${urls.length}개 성공 (${Math.round((Date.now() - batchT0) / 1000)}s 소요)\n`);
|
||||
if (failures.length > 0) {
|
||||
chunk(view, `\n**실패 ${failures.length}건 사유**:\n`);
|
||||
for (const f of failures) chunk(view, `- ${f.url} — ${f.reason}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /meet ─────────────────────────────
|
||||
|
||||
async function runMeet(arg: string, view: Webview | undefined, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const trimmed = arg.trim();
|
||||
let filePath = '';
|
||||
let metadata = '';
|
||||
if (trimmed.startsWith('"')) {
|
||||
const end = trimmed.indexOf('"', 1);
|
||||
if (end > 0) {
|
||||
filePath = trimmed.slice(1, end);
|
||||
metadata = trimmed.slice(end + 1).trim();
|
||||
}
|
||||
}
|
||||
if (!filePath) {
|
||||
const sp = trimmed.indexOf(' ');
|
||||
if (sp === -1) filePath = trimmed;
|
||||
else { filePath = trimmed.slice(0, sp); metadata = trimmed.slice(sp + 1).trim(); }
|
||||
}
|
||||
if (!filePath) {
|
||||
chunk(view, `사용법: \`/meet <txt 파일 경로> [참석자·날짜 등 메타데이터]\`\n예: \`/meet c:\\doc\\0101.txt\`\n경로에 공백이 있으면 따옴표로 감싸세요: \`/meet "c:\\my docs\\0101.txt"\`\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `📝 **회의록 작성**: ${filePath}\n\n⏳ 녹취 파일 읽는 중…`);
|
||||
|
||||
let transcript: string;
|
||||
try {
|
||||
transcript = await fsp.readFile(filePath, 'utf-8');
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n❌ 파일을 읽을 수 없습니다: ${e?.message || String(e)}\n경로가 정확한지 확인하세요.\n`);
|
||||
return true;
|
||||
}
|
||||
if (!transcript || transcript.trim().length < 20) {
|
||||
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
|
||||
return true;
|
||||
}
|
||||
const MAX = 60000;
|
||||
const truncated = transcript.length > MAX;
|
||||
if (truncated) transcript = transcript.slice(0, MAX);
|
||||
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||
let report: string;
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
return true;
|
||||
}
|
||||
chunk(view, report + '\n\n');
|
||||
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const baseName = filePath.replace(/^.*[\\/]/, '').replace(/\.[^.]+$/, '') || 'meeting';
|
||||
const title = `회의록 ${baseName} ${today}`;
|
||||
const savePath = (cfg.get<string>('datacollectSavePath', '') || '').trim();
|
||||
const body: Record<string, unknown> = { title, content: report };
|
||||
if (savePath) body.saveDir = savePath;
|
||||
const saved = await bridgeFetch<{ success: boolean; path?: string }>(
|
||||
BRIDGE_API.wiki.save,
|
||||
{ method: 'POST', body: JSON.stringify(body) },
|
||||
{ timeoutMs: 30_000 },
|
||||
);
|
||||
chunk(view, `💾 **회의록 저장 완료**: \`${saved?.path || '(경로 미확인)'}\`\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `⚠️ 회의록 저장 실패: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
|
||||
if (context) {
|
||||
try {
|
||||
const calCfg = readCalendarConfig(context);
|
||||
if (!calCfg.refreshToken) {
|
||||
chunk(view, `\nℹ️ 캘린더 자동 등록을 건너뜁니다 — Google Calendar OAuth(쓰기)가 연결되지 않았습니다. (Astra Settings → Google 섹션에서 연결)\n`);
|
||||
} else {
|
||||
const tasks = parseActionItems(report);
|
||||
if (tasks.length === 0) {
|
||||
chunk(view, `\nℹ️ 액션 아이템이 없어 캘린더에 등록할 항목이 없습니다.\n`);
|
||||
} else {
|
||||
const today = new Date();
|
||||
const meetingDate = extractMeetingDate(report, today);
|
||||
const titleMatch = report.match(/^#\s+(.+)$/m);
|
||||
const meetTitle = titleMatch
|
||||
? titleMatch[1].replace(/^\[회의 제목\]\s*/, '').trim()
|
||||
: '회의';
|
||||
const gCfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const meetUsesTasks = gCfg.get<boolean>('meetUsesTasks', true);
|
||||
const meetUsesCalendar = gCfg.get<boolean>('meetUsesCalendar', true);
|
||||
if (!meetUsesTasks && !meetUsesCalendar) {
|
||||
chunk(view, `\nℹ️ Google Tasks·Calendar 등록이 모두 꺼져 있어 액션 아이템 자동 등록을 건너뜁니다. (Settings 의 \`g1nation.meetUsesTasks\` / \`g1nation.meetUsesCalendar\` 확인)\n`);
|
||||
} else {
|
||||
const destLabel = [meetUsesTasks && 'Tasks', meetUsesCalendar && 'Calendar'].filter(Boolean).join(' + ');
|
||||
chunk(view, `\n📝 **Google ${destLabel} 등록**: 액션 아이템 ${tasks.length}건…\n`);
|
||||
let tasksOk = 0;
|
||||
let calendarOk = 0;
|
||||
let tentativeCount = 0;
|
||||
for (const task of tasks) {
|
||||
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 successes: string[] = [];
|
||||
const failures: string[] = [];
|
||||
if (meetUsesTasks) {
|
||||
const r = await createTask(context, { title: evTitle, due: date, notes });
|
||||
if (r.ok) { tasksOk++; successes.push('Tasks'); }
|
||||
else { failures.push(`Tasks: ${r.error}`); }
|
||||
}
|
||||
if (meetUsesCalendar) {
|
||||
const r = await createCalendarEvent(context, {
|
||||
title: evTitle, start: date, allDay: true, description: notes,
|
||||
});
|
||||
if (r.ok) { calendarOk++; successes.push('Calendar'); }
|
||||
else { failures.push(`Calendar: ${r.error}`); }
|
||||
}
|
||||
|
||||
if (failures.length === 0) {
|
||||
chunk(view, ` · ${date} — ${evTitle} (${successes.join(' + ')})\n`);
|
||||
} else {
|
||||
chunk(view, ` · ${date} — ${evTitle}${successes.length ? ` (✅ ${successes.join(' + ')})` : ''}\n`);
|
||||
for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
|
||||
}
|
||||
}
|
||||
const summary: string[] = [];
|
||||
if (meetUsesTasks) summary.push(`Tasks ${tasksOk}/${tasks.length}`);
|
||||
if (meetUsesCalendar) summary.push(`Calendar ${calendarOk}/${tasks.length}`);
|
||||
chunk(view, `✅ 등록 완료 — ${summary.join(' · ')}${tentativeCount > 0 ? ` · 미확정 ${tentativeCount}건` : ''}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n⚠️ 캘린더 등록 중 오류: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
|
||||
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
||||
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
||||
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
||||
registerSlashCommand({ name: '/meet', description: '회의 transcript → 회의록 합성 + 캘린더·task 등록', handler: runMeet });
|
||||
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Datacollect LLM 호출 인프라 — bridge `/api/lm` 프록시 통해 OpenAI 호환 chat
|
||||
* completion 단발 호출.
|
||||
*
|
||||
* v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis
|
||||
* 였으나 datacollect handlers + teamops/communication 양쪽이 import 하므로
|
||||
* 별도 인프라 모듈로.
|
||||
*
|
||||
* `repairKoreanGlitches` 는 callLmSynthesis 출력 위생용 — 모델이 한·영 혼합
|
||||
* 토큰 깨짐 (예: "핵ess") 을 뱉으면 LLM 1회 추가 호출로 교정.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
|
||||
/**
|
||||
* Bridge `/api/lm` 프록시로 OpenAI 호환 chat completion 1회 호출.
|
||||
* LLM 서버/모델은 Astra 설정(g1nation.ollamaUrl / defaultModel)을 사용한다.
|
||||
*/
|
||||
export async function callLmSynthesis(prompt: string, systemPrompt?: string): Promise<string> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const lmUrl = (cfg.get<string>('ollamaUrl', 'http://127.0.0.1:11434') || 'http://127.0.0.1:11434').replace(/\/$/, '');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const temperature = Math.max(0, Math.min(2, cfg.get<number>('datacollectSynthesisTemperature', 0.1) ?? 0.1));
|
||||
const baseSys = systemPrompt
|
||||
|| '당신은 시니어 UX/UI 분석가이자 프론트엔드 아키텍트다. 모든 보고서는 한국어로, 제공된 JSON 스캔 데이터의 구체적 수치를 인용해 작성한다. 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 명세를 작성하는 것이 미션이며, 다른 서비스로 재해석·확장하지 않는다.';
|
||||
const sys = baseSys + '\n\n[출력 위생 규칙 — 반드시 준수]\n'
|
||||
+ '- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n'
|
||||
+ '- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n'
|
||||
+ '- [Self-Reflector Check], Consistency/Completeness/Accuracy 같은 내부 검증·체크 로그 블록을 출력에 절대 포함하지 마시오. 최종 사용자 결과물만 출력하시오.';
|
||||
const res = await bridgeFetch<any>(BRIDGE_API.lm.proxy, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: `${lmUrl}/v1/chat/completions`,
|
||||
payload: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: sys },
|
||||
{ role: 'user', content: prompt },
|
||||
],
|
||||
temperature,
|
||||
top_p: 0.85,
|
||||
top_k: 20,
|
||||
repeat_penalty: 1.1,
|
||||
},
|
||||
}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
const content = res?.choices?.[0]?.message?.content
|
||||
?? res?.choices?.[0]?.text
|
||||
?? res?.answer
|
||||
?? res?.response
|
||||
?? '';
|
||||
let out = String(content)
|
||||
.replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '')
|
||||
.trim();
|
||||
if (/[가-힣][a-z]{2,}/.test(out)) {
|
||||
out = await repairKoreanGlitches(out, lmUrl, model);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 한글+영문이 한 단어로 깨진 표기(LLM 토큰 꼬임)를 LLM 1회 호출로 교정.
|
||||
*/
|
||||
async function repairKoreanGlitches(text: string, lmUrl: string, model: string): Promise<string> {
|
||||
try {
|
||||
const res = await bridgeFetch<any>(BRIDGE_API.lm.proxy, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
url: `${lmUrl}/v1/chat/completions`,
|
||||
payload: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: '당신은 한국어 교정기입니다. 입력 텍스트에서 한글과 영문 알파벳이 한 단어로 잘못 합쳐진 깨진 표기(예: "핵ess"→"핵심", "결ently"→"결국")만 자연스러운 한국어로 교정합니다. 그 외 내용·문장·마크다운 구조·표·정상적인 영문 용어(API, JSON 등)는 한 글자도 바꾸지 않으며, 교정된 전체 텍스트만 그대로 출력합니다(설명·주석 금지).' },
|
||||
{ role: 'user', content: text },
|
||||
],
|
||||
temperature: 0,
|
||||
top_p: 0.7,
|
||||
top_k: 20,
|
||||
},
|
||||
}),
|
||||
}, { timeoutMs: 120_000 });
|
||||
const fixed = String(
|
||||
res?.choices?.[0]?.message?.content
|
||||
?? res?.choices?.[0]?.text
|
||||
?? res?.answer ?? res?.response ?? '',
|
||||
).replace(/\n*(?:```[\w]*\s*)?\[Self-Reflector Check\][\s\S]*$/i, '').trim();
|
||||
if (!fixed || fixed.length < text.length * 0.7) return text;
|
||||
return fixed;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Datacollect bridge 가 자주 뱉는 환경 의존성 에러(Python 패키지 미설치, Python
|
||||
* 자체 부재 등) 를 패턴 매칭해서 사용자에게 *해결 명령까지* 알려주는 가이드 텍스트.
|
||||
* 없으면 빈 문자열 반환. slashRouter 의 catch 블록에서 일반 에러 메시지 뒤에 append.
|
||||
*/
|
||||
export function bridgeErrorRemedy(rawMsg: string): string {
|
||||
const msg = String(rawMsg || '');
|
||||
const pkgMatch = msg.match(/필수 패키지가 없습니다?[:\s]+([\w\-,\s.]+)/i)
|
||||
|| msg.match(/missing (?:python )?packages?[:\s]+([\w\-,\s.]+)/i);
|
||||
if (pkgMatch) {
|
||||
const pkgs = pkgMatch[1].split(/[,\s]+/).map((s) => s.trim()).filter(Boolean).join(' ');
|
||||
return `\n\n💡 **해결**: Datacollect bridge 가 도는 환경에서 아래 명령으로 누락된 Python 패키지를 설치하세요.\n\n`
|
||||
+ '```bash\n'
|
||||
+ `# macOS (homebrew Python — PEP 668 보호 우회):\n`
|
||||
+ `python3 -m pip install --user --break-system-packages ${pkgs}\n\n`
|
||||
+ `# 또는 가상환경(venv) 사용 시 그 venv 활성화 후:\n`
|
||||
+ `pip install ${pkgs}\n`
|
||||
+ '```\n\n'
|
||||
+ `설치 후 **bridge 재시작은 보통 불필요** — bridge 는 Python 을 child process 로 spawn 하므로 다음 호출이 바로 새 패키지를 인식합니다. 그래도 안 되면 \`npm run bridge\` 재시작.\n`;
|
||||
}
|
||||
if (/Python 3이 설치돼 있지 않거나 PATH/i.test(msg) || /command not found.*python/i.test(msg)) {
|
||||
return `\n\n💡 **해결**: Python 3 이 설치돼 있어야 합니다. https://www.python.org 에서 설치 후 터미널에서 \`python3 --version\` 으로 확인하세요. 이미 설치돼 있으면 PATH 설정 확인 필요.`;
|
||||
}
|
||||
if (/ECONNREFUSED|fetch failed/i.test(msg) || /연결할 수 없습니다/i.test(msg)) {
|
||||
return `\n\n💡 **해결**: Datacollect bridge 가 떠 있지 않습니다. \`Datacollector_MAC\` 프로젝트에서 \`npm run bridge\` 실행 후 다시 시도하세요.`;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -127,7 +127,9 @@ export function buildInfoExtractionPrompt(video: any, userContent: string): stri
|
||||
대응 관계도 마찬가지 — 본문 그대로.
|
||||
6. **순서·단계 발명 금지** — 화자가 "A → B → C 순서로" 라고 명시하지 *않았으면* "단계적
|
||||
학습 순서" 같은 흐름을 정리자가 만들지 말 것. 굳이 필요하면 정리자 노트로.
|
||||
7. 한국어 마크다운. 표·불릿 자유롭게.
|
||||
7. 한국어 마크다운. **헤더에 이모지 금지** — 스캔할 때 시선이 분산되어 내용이 가려진다.
|
||||
8. **표 사용 신중** — 모든 셀이 명확한 값으로 채워질 확신이 없으면 표 만들지 말고 bullet로. 깨진 셀(\`way\` 같은 LLM artifact)·빈 셀·"…" placeholder 절대 노출 금지.
|
||||
9. **중복 금지** — 같은 내용은 한 곳에만. "한 줄 요약"이 여러 섹션에 반복되면 안 됨. 30초 요약에 한 번, 끝.
|
||||
|
||||
[영상 메타데이터]
|
||||
\`\`\`json
|
||||
@@ -137,66 +139,80 @@ ${JSON.stringify(slim, null, 2)}
|
||||
[자막 본문]
|
||||
${trimmed}${userBlock}
|
||||
|
||||
[필수 출력 형식 — 정확히 이 구조. 아래 8개 섹션 외 추가 금지]
|
||||
[필수 출력 형식 — 정확히 이 구조. 아래 4개 ## 섹션 외 추가 금지. 3-tier 깊이: ① 30초 요약(skim) → ② 핵심 개념(이해) → ③ 깊이 분석(deep dive). 읽는 사람이 목적에 따라 멈출 수 있게.]
|
||||
|
||||
# ${slim.title || video.title} — 정보 추출 카드
|
||||
|
||||
> **영상 URL**: ${slim.url} · **분석 일자**: ${today} · **길이**: ${slim.durationHms || (slim.durationSec ? formatHms(slim.durationSec) : '?')} · **채널**: ${slim.channel || '?'}
|
||||
|
||||
## 🎯 한 줄 요약 (TL;DR)
|
||||
(영상의 핵심 메시지 한 문장. "무엇이 누구에게 왜 중요한가" 를 압축. 제목 그대로 베끼지
|
||||
말고 본문 기준으로 다시 쓸 것. 정리자의 해석은 금지 — 화자의 말 그대로 압축)
|
||||
> 신뢰도 라벨: \`[근거 명시]\` 본문 출처/수치 · \`[화자 주장]\` 출처 없는 단정 · \`[가정]\` 조건부 · \`[정리자 추론]\` 정리자 노트 전용
|
||||
|
||||
## 💡 화자 한 줄 비유 (Anchor Metaphor)
|
||||
영상에서 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 가 있으면 그대로 따옴표로
|
||||
보존. 영상 마무리부에 자주 등장. 예: "Hugging Face = 자료실, Reddit = 공부방,
|
||||
유튜브 = 복습실" 같은 식. 없으면 "본문에 명시된 한 줄 비유 없음".
|
||||
---
|
||||
|
||||
## 📌 핵심 주장 3~5개
|
||||
영상이 *명시한* 주요 결론·주장만. 정리자 추론은 여기 들어오면 안 됨 (그건 🧩 섹션).
|
||||
각 항목 한 줄 + 신뢰도 라벨 + 본문 인용 (mm:ss).
|
||||
- **[근거 명시]** "주장 한 줄" — 본문 인용 (mm:ss)
|
||||
- **[화자 주장]** "주장 한 줄" — 본문 인용 (mm:ss)
|
||||
- …
|
||||
## 30초 요약
|
||||
|
||||
## 📊 사실·데이터·인용
|
||||
영상에 등장한 *구체적 수치·날짜·출처·고유명사·전문 용어 정의*. 가공 없이 그대로.
|
||||
표로 정리:
|
||||
*바쁜 사람은 여기서 멈춰도 영상을 안 본 것보다 낫게.*
|
||||
|
||||
| 항목 | 값 / 정의 | 출처 (영상 내) | 타임스탬프 |
|
||||
| --- | --- | --- | --- |
|
||||
| … | … | 화자/자료 화면/외부 출처 | mm:ss |
|
||||
- **한 줄**: 영상 전체를 한 문장으로 — "무엇이 누구에게 왜 중요한가". 화자 표현 기준, 정리자 의역 금지.
|
||||
- **핵심 포인트 3개**: 영상이 *명시한* 결론·주장만 (정리자 추론 금지). 각 항목 신뢰도 라벨 + 한 줄 + 타임스탬프.
|
||||
1. **[근거 명시]** ... — (mm:ss)
|
||||
2. **[화자 주장]** ... — (mm:ss)
|
||||
3. ... — (mm:ss)
|
||||
- **화자 한 줄 비유** (있는 경우만): 화자가 *전체 메시지를 한 줄로 압축한 비유·은유* 그대로 따옴표.
|
||||
⚠️ 방향 보존 필수 — "Hugging Face = 자료실, Reddit = 공부방" 같으면 짝과 순서를 본문 그대로. 정리자가 단어 위치를 바꾸거나 의역하면 안 됨. 고유명사·수치·대응관계도 본문 그대로.
|
||||
예: "..." — (mm:ss). 본문에 비유 없으면 이 bullet 통째로 생략(line drop).
|
||||
|
||||
데이터가 없는 영상이면 "본문에 명시된 구체 수치·출처 없음" 한 줄.
|
||||
---
|
||||
|
||||
## 🧭 구조 요약 (Sectioned Summary)
|
||||
영상을 chapters (메타데이터에 있으면 그것 사용) 또는 30초 버킷으로 구간 나눠 각 구간의
|
||||
*내용 요약*. 1~2문장씩. 각 항목 끝에 타임스탬프 범위 필수.
|
||||
- **[00:00–02:30]** 도입부에서 다룬 내용 한 문장 요약 (mm:ss–mm:ss)
|
||||
- **[02:30–05:00]** 본론 첫 부분… (mm:ss–mm:ss)
|
||||
- …
|
||||
## 핵심 개념 설명
|
||||
|
||||
## 🔗 인용용 한 줄 카드 (Citation Snippets)
|
||||
영상의 *결정적 발언* 을 그대로 따옴표로 보존. 사장님이 글·발표·메모에 인용할 때 복붙용.
|
||||
3~5개. 길이는 한 문장. 타임스탬프 필수.
|
||||
- "직접 인용 한 문장" — ${slim.title || video.title}, ${slim.channel || '?'} (mm:ss)
|
||||
- …
|
||||
*개념을 이해하고 싶은 사람을 위해.*
|
||||
|
||||
## ❓ 더 파고들 질문 (Open Questions)
|
||||
영상이 답하지 않았거나 추가 검증 필요한 사항 2~4개. 사장님이 다음 자료를 찾을 때
|
||||
바로 검색어로 쓸 수 있게 구체적으로.
|
||||
영상이 다룬 *주요 개념·용어* 2-5개. 각각 정의 + 영상에서의 등장 맥락. 영상이 개념 위주가 아니라 사례·잡담·일화 위주면 이 ## 섹션 통째로 한 줄로 갈음: "이 영상은 개념보다 사례·주장 위주 — 핵심은 30초 요약 참조."
|
||||
|
||||
### {개념 이름 1}
|
||||
- **정의**: 화자가 어떻게 정의했는지 (또는 일반 정의 + 화자 사용 방식).
|
||||
- **영상에서**: "직접 인용" — (mm:ss). 등장 맥락 1-2문장.
|
||||
|
||||
### {개념 이름 2}
|
||||
- **정의**: ...
|
||||
- **영상에서**: ...
|
||||
|
||||
---
|
||||
|
||||
## 깊이 분석
|
||||
|
||||
*깊게 보고 싶은 사람을 위한 추가 자료.*
|
||||
|
||||
### 타임라인
|
||||
영상 30분 이상이면 chapters(메타데이터에 있으면 사용) 또는 흐름 단위로 4-7개 구간 압축. 30분 미만이면 이 sub-section 통째로 한 줄: "영상이 짧아 생략."
|
||||
- **[mm:ss–mm:ss]** 구간 핵심 한 문장
|
||||
- ...
|
||||
|
||||
### 결정적 발언 (인용용)
|
||||
글·발표·메모에 그대로 복붙 가능한 *한 문장 인용* 3-5개. 타임스탬프 필수. 위 30초 요약의 핵심 포인트와 중복되는 인용은 빼고, 보조 발언 위주로.
|
||||
- "직접 인용 한 문장" — ${slim.channel || '?'} (mm:ss)
|
||||
- ...
|
||||
|
||||
### 더 파고들 질문
|
||||
영상이 답하지 않았거나 추가 검증 필요한 사항 2-4개. 사장님이 다음 자료를 찾을 때 검색어로 쓸 수 있게 구체적으로.
|
||||
- "본문에서 X 가 Y 라고 했지만 Z 데이터 출처는 명시 안 됨 — 원 데이터 찾아볼 것"
|
||||
- …
|
||||
- ...
|
||||
|
||||
## 🧩 정리자 노트 (원본 보강) — 선택
|
||||
*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 위 6개 핵심 섹션과
|
||||
구조적으로 격리되어, 독자가 "이건 화자가 말한 게 아니라 LLM 이 추론한 거" 라고
|
||||
명확히 인지하도록. 모든 항목은 \`[정리자 추론]\` 라벨로 시작.
|
||||
- **[정리자 추론]** 화자가 "여러 채널을 동시 시청" 하라 했지만, 입문자 페이스를 고려하면
|
||||
먼저 한 채널을 깊게 따라가는 것도 한 가지 시작점이 될 수 있음.
|
||||
- …
|
||||
### 구체 수치·데이터
|
||||
**표 만들지 말 것** — bullet로, *완성된 정보만*. 본문에 모호하거나 정리자 추측이 들어가야 할 데이터는 통째로 생략. 항목: \`{이름}: {값} (mm:ss)\` 형태.
|
||||
- ...
|
||||
(본문에 명시된 구체 수치 없으면 이 sub-section 통째로 한 줄: "본문에 명시된 구체 수치 없음.")
|
||||
|
||||
특별히 보강할 게 없으면 이 섹션 통째로 "정리자 추가 노트 없음 — 본문 그대로가 명확함" 한 줄.`;
|
||||
---
|
||||
|
||||
## 정리자 노트 (선택)
|
||||
|
||||
*본문에 없지만* 정리자가 추가로 짚고 싶은 맥락·해석·연결·경고. 모두 \`[정리자 추론]\` 라벨로 시작 — 독자가 "이건 화자가 말한 게 아니라 LLM이 추론한 것"으로 즉시 식별.
|
||||
- **[정리자 추론]** ...
|
||||
- ...
|
||||
|
||||
보강할 게 없으면 이 ## 섹션 통째로 한 줄: "정리자 추가 노트 없음 — 본문 그대로가 명확함."`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 고객 피드백 누적 저장소.
|
||||
*
|
||||
* 단일 운영자(대표) 모드에서 슬랙·이메일·CS 채널에 흩어진 고객 피드백을
|
||||
* `/feedback <텍스트>` 한 줄로 모아 둔다. 패턴 분석은 `/feedback summary` 로
|
||||
* LLM 이 누적 데이터를 보고 카테고리 분포 + 반복 주제를 추출.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 누적·append-only, 사람이
|
||||
* 직접 편집 가능, grep / 백업 친화. 위치: `<workspace>/.astra/customer-feedback.jsonl`.
|
||||
*
|
||||
* 워크스페이스 폴더가 없으면 저장 불가 (null 반환). 사용자가 `/feedback path` 로
|
||||
* 위치 확인 가능. 민감 정보 포함 가능성 있으므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import { createEventStore } from '../_shared/eventSourcedStore';
|
||||
|
||||
const STORE_REL_PATH = '.astra/customer-feedback.jsonl';
|
||||
|
||||
export interface FeedbackEntry {
|
||||
/** unique id — timestamp 기반 (정렬·dedup 용도). */
|
||||
id: string;
|
||||
/** ISO timestamp of when this entry was captured. */
|
||||
timestamp: string;
|
||||
/** 사용자가 입력한 원본 텍스트 (그대로 보존). */
|
||||
text: string;
|
||||
/** 선택적 출처 — 'slack' / 'email' / 'cs' / 'review' 등. */
|
||||
source?: string;
|
||||
/** LLM 이 부여한 카테고리 (1~3개). */
|
||||
categories?: string[];
|
||||
/** LLM 판정 — 'positive' / 'neutral' / 'negative'. */
|
||||
sentiment?: 'positive' | 'neutral' | 'negative';
|
||||
}
|
||||
|
||||
const _store = createEventStore<FeedbackEntry>({
|
||||
relPath: STORE_REL_PATH,
|
||||
validate: (e): e is FeedbackEntry => !!e
|
||||
&& typeof (e as any).id === 'string'
|
||||
&& typeof (e as any).text === 'string',
|
||||
});
|
||||
|
||||
export const getFeedbackFilePath = _store.getFilePath;
|
||||
export const readFeedback = _store.read;
|
||||
export const appendFeedback = _store.append;
|
||||
export const countFeedback = _store.count;
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 채용 파이프라인 트래커.
|
||||
*
|
||||
* 4인 → 5인 이상 확장 시점에 후보자가 여러 명, 여러 역할(개발/기획/디자인) 로
|
||||
* 들어오기 시작 — 노션·스프레드시트·이메일에 흩어진 정보를 한 명령으로 본다.
|
||||
*
|
||||
* Event-sourced (customersStore 와 동일 패턴) — append-only 이벤트 로그를
|
||||
* 재생해 후보자별 현재 단계 + 노트 누적 도출.
|
||||
*
|
||||
* 위치: `<workspace>/.astra/hire.jsonl`. 사람 직접 편집 가능.
|
||||
* 민감 정보(이름, 연봉, 거절 사유) 포함 — 로컬 only.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { createEventStore } from '../_shared/eventSourcedStore';
|
||||
|
||||
const STORE_REL_PATH = '.astra/hire.jsonl';
|
||||
|
||||
export type HireEventType = 'add' | 'stage' | 'note' | 'offer' | 'reject' | 'decline' | 'hire';
|
||||
|
||||
/**
|
||||
* 기본 파이프라인 단계. 사용자가 다른 단계명 지정 가능 — 그냥 문자열로 저장.
|
||||
* 표시 순서·정렬용으로 알려진 단계는 가중치 부여.
|
||||
*/
|
||||
export const KNOWN_STAGES = ['inbox', 'screened', 'interview', 'final', 'offer', 'accepted', 'hired', 'rejected', 'declined'] as const;
|
||||
export type KnownStage = typeof KNOWN_STAGES[number];
|
||||
|
||||
export interface HireEvent {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
role: string;
|
||||
type: HireEventType;
|
||||
/** stage 전환 시 새 단계. add 시 시작 단계 (기본 'inbox'). */
|
||||
stage?: string;
|
||||
/** offer 의 연봉 — KRW. */
|
||||
salary?: number;
|
||||
/** 입사 예정일 / 거절·이탈 사유. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
export interface CandidateState {
|
||||
candidateId: string;
|
||||
candidateName: string;
|
||||
role: string;
|
||||
stage: string;
|
||||
salary?: number;
|
||||
addedAt: string;
|
||||
lastEventAt: string;
|
||||
eventCount: number;
|
||||
notes: { timestamp: string; type: HireEventType; memo: string }[];
|
||||
}
|
||||
|
||||
const _store = createEventStore<HireEvent>({
|
||||
relPath: STORE_REL_PATH,
|
||||
validate: (e): e is HireEvent => !!e
|
||||
&& typeof (e as any).id === 'string'
|
||||
&& typeof (e as any).candidateId === 'string'
|
||||
&& typeof (e as any).type === 'string',
|
||||
});
|
||||
|
||||
export const getHireFilePath = _store.getFilePath;
|
||||
export const readHireEvents = _store.read;
|
||||
export const appendHireEvent = _store.append;
|
||||
|
||||
export function candidateIdFromName(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/\s+/g, '-').replace(/[^\w가-힣-]/g, '');
|
||||
}
|
||||
|
||||
export function computeCandidateStates(): Map<string, CandidateState> {
|
||||
const events = readHireEvents().slice().sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
const states = new Map<string, CandidateState>();
|
||||
for (const e of events) {
|
||||
let s = states.get(e.candidateId);
|
||||
if (!s) {
|
||||
s = {
|
||||
candidateId: e.candidateId,
|
||||
candidateName: e.candidateName,
|
||||
role: e.role || '',
|
||||
stage: e.stage || 'inbox',
|
||||
addedAt: e.timestamp,
|
||||
lastEventAt: e.timestamp,
|
||||
eventCount: 0,
|
||||
notes: [],
|
||||
};
|
||||
states.set(e.candidateId, s);
|
||||
}
|
||||
s.candidateName = e.candidateName || s.candidateName;
|
||||
s.role = e.role || s.role;
|
||||
s.lastEventAt = e.timestamp;
|
||||
s.eventCount += 1;
|
||||
|
||||
switch (e.type) {
|
||||
case 'add':
|
||||
s.stage = e.stage || s.stage || 'inbox';
|
||||
break;
|
||||
case 'stage':
|
||||
if (e.stage) s.stage = e.stage;
|
||||
break;
|
||||
case 'offer':
|
||||
s.stage = 'offer';
|
||||
if (e.salary !== undefined) s.salary = e.salary;
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'offer', memo: e.memo });
|
||||
break;
|
||||
case 'hire':
|
||||
s.stage = 'hired';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'hire', memo: e.memo });
|
||||
break;
|
||||
case 'reject':
|
||||
s.stage = 'rejected';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'reject', memo: e.memo });
|
||||
break;
|
||||
case 'decline':
|
||||
s.stage = 'declined';
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'decline', memo: e.memo });
|
||||
break;
|
||||
case 'note':
|
||||
if (e.memo) s.notes.push({ timestamp: e.timestamp, type: 'note', memo: e.memo });
|
||||
break;
|
||||
}
|
||||
}
|
||||
return states;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Runway / Cash 누적 저장소.
|
||||
*
|
||||
* 4인 기업 운영의 가장 중요한 숫자 — 현금 잔고 / 월 소진율 / 남은 개월수 — 를
|
||||
* 한 명령 (`/runway`) 로 본다. 회계 시스템은 아니고, 대표가 머리에 가지고 있는
|
||||
* "지금 통장에 얼마, 한 달에 얼마 나감" 을 코드 옆에서 잡는 가벼운 트래커.
|
||||
*
|
||||
* 저장 형식: JSON Lines (.jsonl) — 한 줄 = 한 entry. 외부 회계 SaaS 연동 안 함.
|
||||
* 위치: `<workspace>/.astra/runway.jsonl`. 사람이 직접 편집 가능, grep / 백업 친화.
|
||||
*
|
||||
* 민감 정보(현금 잔고) 포함되므로 외부로 안 보냄 — 로컬 only.
|
||||
*/
|
||||
|
||||
import { createEventStore } from '../_shared/eventSourcedStore';
|
||||
|
||||
const STORE_REL_PATH = '.astra/runway.jsonl';
|
||||
|
||||
export type RunwayEntryType = 'snapshot' | 'expense' | 'revenue' | 'burn';
|
||||
|
||||
export interface RunwayEntry {
|
||||
/** unique id — timestamp 기반. */
|
||||
id: string;
|
||||
/** ISO timestamp. */
|
||||
timestamp: string;
|
||||
/** 항목 종류 — snapshot(잔고) / expense(지출) / revenue(수입) / burn(월 소진율 수동 설정). */
|
||||
type: RunwayEntryType;
|
||||
/** 금액 — KRW 기본 단위, 소수점 허용. */
|
||||
amount: number;
|
||||
/** 통화 — 기본 'KRW'. 추후 'USD' 등 확장 가능. */
|
||||
currency?: string;
|
||||
/** 카테고리 — expense 의 경우 'salary' / 'rent' / 'saas' / 'misc' 등. */
|
||||
category?: string;
|
||||
/** 자유 메모. */
|
||||
memo?: string;
|
||||
}
|
||||
|
||||
const _store = createEventStore<RunwayEntry>({
|
||||
relPath: STORE_REL_PATH,
|
||||
validate: (e): e is RunwayEntry => !!e
|
||||
&& typeof (e as any).id === 'string'
|
||||
&& typeof (e as any).amount === 'number'
|
||||
&& typeof (e as any).type === 'string',
|
||||
});
|
||||
|
||||
export const getRunwayFilePath = _store.getFilePath;
|
||||
export const readRunway = _store.read;
|
||||
export const appendRunway = _store.append;
|
||||
|
||||
/**
|
||||
* 현재 상태 계산 — 최신 snapshot, 최근 30일 net burn, 명시적 burn 설정 중 우선순위.
|
||||
*
|
||||
* - latestCash: 가장 최근 'snapshot' entry 의 amount (없으면 null).
|
||||
* - explicitBurn: 가장 최근 'burn' entry — 사용자가 수동 설정한 월 소진율.
|
||||
* - computedBurn: 최근 30일 expense - revenue, 30일 미만이면 일 평균 × 30 으로 보정.
|
||||
* - effectiveBurn: explicitBurn 우선, 없으면 computedBurn.
|
||||
* - runwayMonths: latestCash / effectiveBurn — burn 이 0 이하면 Infinity.
|
||||
*/
|
||||
export interface RunwayStatus {
|
||||
latestCash: number | null;
|
||||
latestCashAt: string | null;
|
||||
explicitBurn: number | null;
|
||||
computedBurn: number | null;
|
||||
effectiveBurn: number | null;
|
||||
runwayMonths: number | null;
|
||||
last30Expense: number;
|
||||
last30Revenue: number;
|
||||
last30Days: number;
|
||||
totalEntries: number;
|
||||
}
|
||||
|
||||
export function computeRunwayStatus(now: Date = new Date()): RunwayStatus {
|
||||
const entries = readRunway();
|
||||
const nowMs = now.getTime();
|
||||
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
let latestCash: number | null = null;
|
||||
let latestCashAt: string | null = null;
|
||||
let explicitBurn: number | null = null;
|
||||
let last30Expense = 0;
|
||||
let last30Revenue = 0;
|
||||
let oldestRecentMs = nowMs;
|
||||
let hasRecent = false;
|
||||
|
||||
for (const e of entries) {
|
||||
const t = Date.parse(e.timestamp);
|
||||
if (e.type === 'snapshot') {
|
||||
if (!latestCashAt || (Date.parse(e.timestamp) >= Date.parse(latestCashAt))) {
|
||||
latestCash = e.amount;
|
||||
latestCashAt = e.timestamp;
|
||||
}
|
||||
} else if (e.type === 'burn') {
|
||||
if (!explicitBurn || t >= (entries.find(x => x.type === 'burn' && x.amount === explicitBurn)?.timestamp ? Date.parse(e.timestamp) : 0)) {
|
||||
explicitBurn = e.amount;
|
||||
}
|
||||
} else if (e.type === 'expense' && nowMs - t <= thirtyDaysMs) {
|
||||
last30Expense += e.amount;
|
||||
if (t < oldestRecentMs) oldestRecentMs = t;
|
||||
hasRecent = true;
|
||||
} else if (e.type === 'revenue' && nowMs - t <= thirtyDaysMs) {
|
||||
last30Revenue += e.amount;
|
||||
if (t < oldestRecentMs) oldestRecentMs = t;
|
||||
hasRecent = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 최신 burn 정확히 다시 — 위 로직이 꼬여서 단순화.
|
||||
explicitBurn = null;
|
||||
let burnAt = 0;
|
||||
for (const e of entries) {
|
||||
if (e.type !== 'burn') continue;
|
||||
const t = Date.parse(e.timestamp);
|
||||
if (t >= burnAt) { explicitBurn = e.amount; burnAt = t; }
|
||||
}
|
||||
|
||||
const netBurn30 = last30Expense - last30Revenue;
|
||||
let computedBurn: number | null = null;
|
||||
let last30Days = 0;
|
||||
if (hasRecent) {
|
||||
const span = Math.max(1, Math.ceil((nowMs - oldestRecentMs) / (24 * 60 * 60 * 1000)));
|
||||
last30Days = Math.min(30, span);
|
||||
// 30일 미만이면 일 평균 × 30 으로 환산.
|
||||
if (last30Days < 30) computedBurn = (netBurn30 / last30Days) * 30;
|
||||
else computedBurn = netBurn30;
|
||||
}
|
||||
|
||||
const effectiveBurn = explicitBurn ?? computedBurn;
|
||||
let runwayMonths: number | null = null;
|
||||
if (latestCash !== null && effectiveBurn !== null && effectiveBurn > 0) {
|
||||
runwayMonths = latestCash / effectiveBurn;
|
||||
} else if (latestCash !== null && effectiveBurn !== null && effectiveBurn <= 0) {
|
||||
runwayMonths = Infinity;
|
||||
}
|
||||
|
||||
return {
|
||||
latestCash,
|
||||
latestCashAt,
|
||||
explicitBurn,
|
||||
computedBurn,
|
||||
effectiveBurn,
|
||||
runwayMonths,
|
||||
last30Expense,
|
||||
last30Revenue,
|
||||
last30Days,
|
||||
totalEntries: entries.length,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { logInfo } from '../../utils';
|
||||
import { readStocksStore, addStock, removeStock, getStocksFilePath } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
import { fetchAllPrices, fetchYahooPrice, fetchYahooHistory, evalMa224Recovery, evalDropRecovery, evalMaAlignment, evalRsi14, type Ma224RecoveryResult, type DropRecoveryResult, type MaAlignmentResult, type Rsi14Result } from './yahooClient';
|
||||
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
|
||||
import { AIService } from '../../core/services';
|
||||
import { classifyAll } from './signalClassifier';
|
||||
import { writeStocksStore } from './stocksStore';
|
||||
import { syncToSheets } from './sheetsSync';
|
||||
@@ -144,6 +146,342 @@ async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (r.rationale) chunk(view, `\n근거:\n${r.rationale}\n`);
|
||||
}
|
||||
|
||||
// ─── /stocks analysis <심볼> — 단일 종목 심층 분석 (judge 보다 깊음) ───
|
||||
// judge: 4-criteria 펀더멘털 평가만 (stocks.json 에 저장된 데이터 사용)
|
||||
// analysis: Naver 펀더멘털 fresh fetch + Yahoo 1년 시세 기반 기술 지표(224회복/낙폭과대)
|
||||
// + LLM 종합 평가 (권장도/근거/리스크). stocks.json 에 종목이 없어도 동작.
|
||||
|
||||
const ANALYSIS_SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 종합 평가 보조 도구다. 사용자가 제공한 한 종목의 *Naver 펀더멘털*',
|
||||
'+ *Yahoo 1년 시세 기반 기술 지표* 를 보고 종합 평가를 내린다.',
|
||||
'외부 정보 추측 금지 — 주어진 데이터에서만 판단.',
|
||||
'',
|
||||
'**평가 6차원:**',
|
||||
' 1. 가치 — PBR / PER (저평가 여부)',
|
||||
' 2. 수익성 — ROE / 영업이익률 절대 수준',
|
||||
' 3. 재무 안정성 — 유보율, 시가총액, **부채비율** (200% 이하 안전, 100% 이하 우량)',
|
||||
' 4. 추세 (기술) — 224일선 회복 + **MA 정배열/역배열** (5/20/60/120일)',
|
||||
' · 정배열 = 추세 매수 안전',
|
||||
' · 역배열 = 펀더 좋아도 대기 권장',
|
||||
' · MA224 "N/A — 추세 확립" 상태는 **부정 신호 아님** (현재가가 MA224 한참 위라 회복 패턴 평가 무관). passed:false 와 혼동 금지.',
|
||||
' 5. 안전마진 — 낙폭과대 (1년 고점 대비 하락 + 최근 저점에서 반등). raw 데이터의 `failReason` 가 명시되면 그것을 그대로 인용 — 인과 거꾸로(예: "반등이 커서 미통과" 같은 오해석) 금지.',
|
||||
' 6. 진입 타이밍 — **RSI(14)**',
|
||||
' · 과열 (≥70) = 매수 자제',
|
||||
' · 침체 (≤30) = 단기 반등 여지',
|
||||
' · 중립 (30~70) = 정상',
|
||||
'',
|
||||
'**판단 절제 규칙 — 과잉 해석 차단:**',
|
||||
' a. **PBR/PER 절대값으로 "고평가/저평가" 단정 금지.** 업종 평균 데이터가 컨텍스트에 없으면 "PBR X.X (업종 평균 데이터 부재 — 절대값만 제시)" 식으로만. "부담" / "고평가" 같은 강한 단어는 업종 비교나 5년 역사적 평균이 있을 때만 사용.',
|
||||
' b. **거래량 ±20% 미만 변동은 무의미.** 5일/60일 평균 차이가 약 -10% 정도면 "유의미한 변화 아님"으로 처리하고 "상승 동력 약화" 같은 강한 해석으로 끌고 가지 말 것. 진짜 약화 신호는 -20% 이상 또는 추세적 감소.',
|
||||
' c. **우선주 (symbol 끝자리 5/7/9) 는 특이 항목 반영 필수.** 컨텍스트에 `[우선주 정보]` 블록이 있으면 보통주 대비 할인율을 매수 의견 근거에 한 줄 언급. 우선주는 일반적으로 보통주 대비 10~30% 할인이 정상 — 그 범위 내면 "정상", 좁으면 "프리미엄", 넓으면 "확대" 로 평가. 배당수익률 데이터는 현재 미수집이므로 추측 금지.',
|
||||
' d. **오탈자 자기 점검.** 출력 직전 한국어 토큰 깨짐(예: "순위" → "순칭", "근거" → "근례")이 없는지 확인. 의심스러우면 다시 쓸 것.',
|
||||
'',
|
||||
'**출력 형식 (정확히 이대로, 다른 헤더 추가 금지):**',
|
||||
'## 종합 평가',
|
||||
'2-3 문장 핵심 의견.',
|
||||
'',
|
||||
'## 매수 의견',
|
||||
'- **권장도**: 매수권 / 관망 / 회피 중 하나',
|
||||
'- **근거**: 위 6차원 중 핵심 2-3개를 *수치 인용* 으로',
|
||||
'',
|
||||
'## 매매 타점',
|
||||
'*가격·% 모두 raw 데이터(MA값, 1년 최고가, 60일 저점, RSI, 부채비율 등)에서 직접 도출. 추측·외부 정보 금지. `__원` 같은 placeholder가 아니라 실제 수치로 채울 것.*',
|
||||
'',
|
||||
'### 매수 진입 타점',
|
||||
'권장도가 **회피**면 이 sub-section 통째로 한 줄: "회피 권장 — 매수 타점 산정 생략."',
|
||||
'권장도가 매수권/관망이면 *현재 기술 상태에 부합하는 시나리오만* 1-2개:',
|
||||
'- **1순위 — MA20 지지**: MA 정배열일 때만. MA20 ±1% 영역(가격 범위 X원~Y원). 거래량 감소 후 반등 캔들 확인.',
|
||||
'- **2순위 — MA60 눌림**: 단기 조정 심화 시. MA60 부근(Z원). 낙폭과대 동시 충족 시 적극.',
|
||||
'- **3순위 — 낙폭과대 + RSI ≤30**: RSI 30 이하 + 60일 저점(W원) 근접. 부채비율·유보율 이상 없을 것.',
|
||||
'현재 데이터로 어떤 시나리오도 부합 안 하면 한 줄: "현재 매수 타점 조건 부적합 — 추가 조정 대기."',
|
||||
'',
|
||||
'### 손절 기준',
|
||||
'**종가 기준 이탈**만 적용 (장중 터치는 손절 미해당). *위 매수 시나리오에 출력한 항목에 한해서만:*',
|
||||
'- MA20 진입 시 → MA20(X원) 종가 이탈, 최대 허용 약 -5%',
|
||||
'- MA60 진입 시 → MA60(X원) 종가 이탈, 최대 허용 약 -8%',
|
||||
'- 낙폭과대 진입 시 → 60일 저점(X원) 종가 이탈, 최대 허용 약 -10%',
|
||||
'',
|
||||
'### 익절 타점',
|
||||
'- **1차**: 1년 최고가 × 0.98 (가격 X원), 보유 비중 30~50% 축소',
|
||||
'- **2차**: 다음 심리적 라운드 넘버(현재가 위 가장 가까운 10,000/50,000/100,000원 단위, X원), 잔량 추가 축소',
|
||||
'- **3차**: 동종 업종 평균 PER 대비 20% 초과 시 전량 익절 고려. *업종 평균 PER 데이터는 현재 미수집 — 사용자가 수동 비교 필요.*',
|
||||
'',
|
||||
'### 관망 해제 트리거',
|
||||
'권장도가 **관망**일 때만 작성. 매수권/회피면 이 sub-section 통째로 생략(line drop).',
|
||||
'아래 *2개 이상 동시 충족* 시 진입 전환:',
|
||||
'1. MA20 또는 MA60 종가 지지 확인',
|
||||
'2. 거래량 60일 평균 (현재 약 X) 이하 감소 후 반등',
|
||||
'3. RSI 50 이하로 눌린 후 재반등 (현재 RSI Y)',
|
||||
'4. 1년 최고가 대비 -10% 이상 추가 조정',
|
||||
'',
|
||||
'## 리스크',
|
||||
'1-2줄, 주의해야 할 점.',
|
||||
].join('\n');
|
||||
|
||||
/**
|
||||
* 한국 우선주 → 보통주 심볼 도출. 우선주 마지막 자리는 5/7/9 (1종/2종/3종 우선주).
|
||||
* 보통주는 마지막 자리 0. 예: 005935 → 005930, 005385 → 005380.
|
||||
* 패턴이 아니면 null (보통주거나 다른 형식).
|
||||
*/
|
||||
function deriveCommonStockSymbol(symbol: string): string | null {
|
||||
if (!/^[0-9]{6}$/.test(symbol)) return null;
|
||||
const last = symbol[symbol.length - 1];
|
||||
if (last === '5' || last === '7' || last === '9') {
|
||||
return symbol.slice(0, -1) + '0';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface PreferredStockInfo {
|
||||
/** 우선주 → 보통주로 환산한 6자리 심볼. */
|
||||
commonSymbol: string;
|
||||
/** Yahoo 에서 가져온 보통주 현재가. */
|
||||
commonPrice: number;
|
||||
/** 우선주 현재가. */
|
||||
preferredPrice: number;
|
||||
/** (보통주 - 우선주) / 보통주 × 100. 양수 = 우선주가 더 쌈(할인), 음수 = 프리미엄. */
|
||||
discountPct: number;
|
||||
}
|
||||
|
||||
function buildAnalysisContext(
|
||||
symbol: string,
|
||||
f: Fundamentals,
|
||||
ma224: Ma224RecoveryResult | null,
|
||||
drop: DropRecoveryResult | null,
|
||||
align: MaAlignmentResult | null,
|
||||
rsi: Rsi14Result | null,
|
||||
preferred: PreferredStockInfo | null,
|
||||
): string {
|
||||
const lines: string[] = [
|
||||
`종목 심볼: ${symbol}`,
|
||||
`섹터: ${f.sectorHint ?? '-'}`,
|
||||
'',
|
||||
'── Naver 펀더멘털 ──',
|
||||
`시가총액: ${f.marketCapEok !== undefined ? Math.round(f.marketCapEok).toLocaleString() + '억' : '-'}`,
|
||||
`ROE: ${f.roe !== undefined ? f.roe.toFixed(2) + '%' : '-'}`,
|
||||
`영업이익률: ${f.operatingMargin !== undefined ? f.operatingMargin.toFixed(1) + '%' : '-'}`,
|
||||
`유보율: ${f.retentionRatio !== undefined ? Math.round(f.retentionRatio).toLocaleString() + '%' : '-'}`,
|
||||
`부채비율: ${f.debtRatio !== undefined ? f.debtRatio.toFixed(1) + '%' : '-'}`,
|
||||
`PER: ${f.per !== undefined ? f.per.toFixed(1) : '-'}`,
|
||||
`PBR: ${f.pbr !== undefined ? f.pbr.toFixed(2) : '-'}`,
|
||||
`현재가: ${f.currentPrice !== undefined ? f.currentPrice.toLocaleString() + '원' : '-'}`,
|
||||
'',
|
||||
'── Yahoo 1년 기술 지표 ──',
|
||||
];
|
||||
if (ma224) {
|
||||
const ma = ma224.ma224Today !== undefined ? Math.round(ma224.ma224Today).toLocaleString() : '-';
|
||||
const cp = ma224.currentPrice !== undefined ? ma224.currentPrice.toLocaleString() : '-';
|
||||
if (ma224.notApplicable) {
|
||||
// N/A 상태 — passed:false 와 시각적으로 구분 (⚪ vs ❌). LLM 도 이걸 보고 음성 신호 아님으로 인지.
|
||||
lines.push(
|
||||
`224일선(MA224) 회복: ⚪ N/A — ${ma224.notApplicableReason ?? '추세 확립, 회복 패턴 평가 무관'}`,
|
||||
` · 현재가 ${cp} vs MA224 ${ma}`,
|
||||
' · ⚠️ 부정 신호가 아닙니다 — 회복 패턴은 *장기 하락 후 상향 돌파* 신호이므로, 이미 한참 위에 있는 종목엔 적용 무관.',
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`224일선(MA224) 회복: ${ma224.passed ? '✅ 통과' : '❌ 미통과'}`,
|
||||
` · 현재가 ${cp} vs MA224 ${ma}`,
|
||||
` · 최근 30일 중 MA224 아래 일수: ${ma224.daysBelowLast30 ?? '-'}`,
|
||||
` · 거래량 확인: 5일평균 ${ma224.vol5dAvg !== undefined ? Math.round(ma224.vol5dAvg).toLocaleString() : '-'} vs 60일평균 ${ma224.vol60dAvg !== undefined ? Math.round(ma224.vol60dAvg).toLocaleString() : '-'} → ${ma224.volumeConfirmed ? '✅' : '❌'}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
lines.push('224일선 분석: 시세 데이터 부족 (224일 미만)');
|
||||
}
|
||||
if (drop) {
|
||||
const cp = drop.currentPrice !== undefined ? drop.currentPrice.toLocaleString() : '-';
|
||||
const hi = drop.high1y !== undefined ? drop.high1y.toLocaleString() : '-';
|
||||
const lo = drop.low60d !== undefined ? drop.low60d.toLocaleString() : '-';
|
||||
const fromHigh = drop.fromHighRatio !== undefined ? `${(drop.fromHighRatio * 100).toFixed(1)}% 수준` : '-';
|
||||
const fromLow = drop.fromLowRatio !== undefined ? `+${((drop.fromLowRatio - 1) * 100).toFixed(1)}% 반등` : '-';
|
||||
const verdict = drop.passed
|
||||
? '✅ 통과'
|
||||
: `❌ 미통과${drop.failReason ? ` — ${drop.failReason}` : ''}`;
|
||||
lines.push(
|
||||
`낙폭과대: ${verdict}`,
|
||||
` · 1년 최고가 ${hi} → 현재가 ${cp} (${fromHigh})`,
|
||||
` · 60일 저점 ${lo} → 현재가 ${cp} (${fromLow})`,
|
||||
);
|
||||
} else {
|
||||
lines.push('낙폭과대 분석: 시세 데이터 부족 (60일 미만)');
|
||||
}
|
||||
if (align) {
|
||||
const fmt = (n?: number) => n !== undefined ? Math.round(n).toLocaleString() : '-';
|
||||
lines.push(
|
||||
`MA 배열: ${align.alignment === '정배열' ? '✅ 정배열' : align.alignment === '역배열' ? '❌ 역배열' : '⚠️ 혼조'}`,
|
||||
` · MA5 ${fmt(align.ma5)} / MA20 ${fmt(align.ma20)} / MA60 ${fmt(align.ma60)} / MA120 ${fmt(align.ma120)}`,
|
||||
);
|
||||
} else {
|
||||
lines.push('MA 배열: 시세 데이터 부족 (120일 미만)');
|
||||
}
|
||||
if (rsi) {
|
||||
const tag = rsi.classification === '과열' ? '🔥 과열' : rsi.classification === '침체' ? '🧊 침체' : '🟢 중립';
|
||||
lines.push(`RSI(14): ${rsi.rsi.toFixed(1)} (${tag})`);
|
||||
} else {
|
||||
lines.push('RSI: 시세 데이터 부족 (15일 미만)');
|
||||
}
|
||||
if (preferred) {
|
||||
const sign = preferred.discountPct > 0 ? '할인' : '프리미엄';
|
||||
// 우선주 할인 범위 가이드: 통상 10~30% 정상, 좁으면 프리미엄, 넓으면 확대.
|
||||
const rangeNote = preferred.discountPct >= 30 ? '확대 (보통주 대비 매우 저평가)'
|
||||
: preferred.discountPct >= 10 ? '정상 범위 (10~30%)'
|
||||
: preferred.discountPct > 0 ? '좁음 (10% 미만 — 보통주 대비 프리미엄 수준)'
|
||||
: '역전 (우선주가 보통주보다 비쌈 — 극히 이례적)';
|
||||
lines.push(
|
||||
'',
|
||||
'── 우선주 특이정보 ──',
|
||||
`보통주 ${preferred.commonSymbol} 현재가: ${preferred.commonPrice.toLocaleString()}원`,
|
||||
`우선주 ${symbol} 현재가: ${preferred.preferredPrice.toLocaleString()}원`,
|
||||
`보통주 대비 ${sign}율: ${Math.abs(preferred.discountPct).toFixed(1)}% — ${rangeNote}`,
|
||||
'※ 우선주는 배당수익률 + 보통주 대비 할인율이 핵심 투자 포인트. 배당 데이터는 미수집(사용자 확인 필요).',
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function cmdAnalysis(arg: string, view: Webview | undefined): Promise<void> {
|
||||
const symbol = (arg.trim().split(/\s+/)[0] || '').trim();
|
||||
if (!symbol) {
|
||||
chunk(view, '\n사용법: `/stocks analysis <심볼>` — 펀더멘털 + 1년 차트 패턴 종합 분석 (judge보다 깊음). stocks.json 미등록 종목도 가능.\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n🔍 **${symbol} 심층 분석** — Naver 펀더멘털 + Yahoo 1년 시세 + LLM 종합...\n`);
|
||||
|
||||
// 1. Naver 펀더멘털
|
||||
const fundsMap = await fetchAllFundamentals([symbol]);
|
||||
const f = fundsMap.get(symbol);
|
||||
if (!f) {
|
||||
chunk(view, '\n❌ Naver 에서 펀더멘털 데이터를 못 가져왔습니다. 심볼을 확인해주세요(코스피/코스닥 6자리).\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, ' · Naver 펀더멘털 OK\n');
|
||||
|
||||
// 2. Yahoo 1년 시세 + 기술 지표
|
||||
const history = await fetchYahooHistory(symbol);
|
||||
const ma224 = history ? evalMa224Recovery(history) : null;
|
||||
const drop = history ? evalDropRecovery(history) : null;
|
||||
const align = history ? evalMaAlignment(history) : null;
|
||||
const rsi = history ? evalRsi14(history) : null;
|
||||
chunk(view, history
|
||||
? ` · Yahoo 1년 시세 OK (${history.closes.length} 거래일)\n`
|
||||
: ' ⚠️ Yahoo 시세 fetch 실패 — 펀더멘털만으로 분석\n');
|
||||
|
||||
// 2-b. 우선주(symbol 끝자리 5/7/9) 면 보통주 현재가도 가져와 할인율 계산.
|
||||
// 배당 데이터까지는 아직 미수집 — 추후 Naver crawl 확장 필요.
|
||||
let preferred: PreferredStockInfo | null = null;
|
||||
const commonSymbol = deriveCommonStockSymbol(symbol);
|
||||
if (commonSymbol && f.currentPrice !== undefined && f.currentPrice > 0) {
|
||||
chunk(view, ` · 우선주 감지 — 보통주 ${commonSymbol} 현재가 fetch 중...\n`);
|
||||
const commonPrice = await fetchYahooPrice(commonSymbol);
|
||||
if (typeof commonPrice === 'number' && commonPrice > 0) {
|
||||
preferred = {
|
||||
commonSymbol,
|
||||
commonPrice,
|
||||
preferredPrice: f.currentPrice,
|
||||
discountPct: (commonPrice - f.currentPrice) / commonPrice * 100,
|
||||
};
|
||||
chunk(view, ` · 보통주 ${commonSymbol} 현재가 ${commonPrice.toLocaleString()}원 (할인율 ${preferred.discountPct.toFixed(1)}%)\n`);
|
||||
} else {
|
||||
chunk(view, ` ⚠️ 보통주 ${commonSymbol} 가격 fetch 실패 — 할인율 계산 skip\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 데이터 요약 표시 (모델에 보내는 것과 동일 — 투명성)
|
||||
const summary = buildAnalysisContext(symbol, f, ma224, drop, align, rsi, preferred);
|
||||
chunk(view, `\n\`\`\`\n${summary}\n\`\`\`\n`);
|
||||
|
||||
// 4. LLM 종합 분석
|
||||
chunk(view, '\n🤖 LLM 종합 분석 중...\n');
|
||||
const ai = new AIService();
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: ANALYSIS_SYSTEM_PROMPT,
|
||||
user: summary + '\n\n위 데이터로 종합 평가, 매수 의견, 리스크를 출력 형식 그대로 작성하시오.',
|
||||
});
|
||||
if (result.empty || !result.content.trim()) {
|
||||
chunk(view, '\n❌ LLM 빈 응답.\n');
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n${result.content.trim()}\n`);
|
||||
logInfo('Stocks analysis 완료.', { symbol, model: result.model });
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ LLM 호출 실패: ${e?.message ?? String(e)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── /stocks position [심볼] <총자산> <리스크%> <손절%> — 포지션 사이징 ───
|
||||
// 공식: 권장 투자금 = 총자산 × (리스크%/100) ÷ (손절%/100)
|
||||
// 심볼을 주면 Yahoo 현재가로 매수 가능 주수까지 계산.
|
||||
|
||||
async function cmdPosition(arg: string, view: Webview | undefined): Promise<void> {
|
||||
const parts = arg.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length < 3) {
|
||||
chunk(view, [
|
||||
'\n사용법:',
|
||||
' `/stocks position <총자산> <리스크%> <손절%>` — 단순 계산',
|
||||
' `/stocks position <심볼> <총자산> <리스크%> <손절%>` — + 현재가로 매수 가능 주수',
|
||||
'',
|
||||
'예: `/stocks position 50000000 2 5` → 50M × 2% ÷ 5% = 20M',
|
||||
'예: `/stocks position 019010 50000000 2 5` → 20M / 현재가 = N주',
|
||||
'',
|
||||
].join('\n'));
|
||||
return;
|
||||
}
|
||||
// 첫 인자가 6자리 숫자면 심볼, 아니면 곧바로 숫자 인자.
|
||||
let symbol: string | undefined;
|
||||
let nums: string[];
|
||||
if (/^[0-9]{6}$/.test(parts[0])) {
|
||||
symbol = parts[0];
|
||||
nums = parts.slice(1);
|
||||
} else {
|
||||
nums = parts;
|
||||
}
|
||||
if (nums.length < 3) {
|
||||
chunk(view, '\n❌ 인자 부족 — 총자산, 리스크%, 손절% 3개 필요.\n');
|
||||
return;
|
||||
}
|
||||
const total = Number(nums[0]);
|
||||
const riskPct = Number(nums[1]);
|
||||
const stopPct = Number(nums[2]);
|
||||
if (!Number.isFinite(total) || !Number.isFinite(riskPct) || !Number.isFinite(stopPct)
|
||||
|| total <= 0 || riskPct <= 0 || stopPct <= 0) {
|
||||
chunk(view, '\n❌ 잘못된 입력 — 모두 양수여야 합니다.\n');
|
||||
return;
|
||||
}
|
||||
if (riskPct > 100 || stopPct > 100) {
|
||||
chunk(view, '\n❌ %는 100 이하로 입력하세요 (예: 2 = 2%).\n');
|
||||
return;
|
||||
}
|
||||
const positionWon = total * (riskPct / 100) / (stopPct / 100);
|
||||
const maxLoss = positionWon * (stopPct / 100);
|
||||
const positionRatio = positionWon / total * 100;
|
||||
|
||||
chunk(view, '\n💰 **포지션 사이징 계산**\n');
|
||||
chunk(view, ` · 총 자산: ${total.toLocaleString()}원\n`);
|
||||
chunk(view, ` · 리스크 허용: ${riskPct}% (= 최대 손실 ${Math.round(maxLoss).toLocaleString()}원)\n`);
|
||||
chunk(view, ` · 손절폭: ${stopPct}%\n`);
|
||||
chunk(view, `\n → **권장 투자금: ${Math.round(positionWon).toLocaleString()}원** (총자산의 ${positionRatio.toFixed(1)}%)\n`);
|
||||
|
||||
if (positionRatio > 50) {
|
||||
chunk(view, '\n ⚠️ 권장 투자금이 총자산의 50%를 초과 — 손절폭이 너무 좁거나 리스크 허용이 너무 큽니다. 입력값 재검토 권장.\n');
|
||||
}
|
||||
|
||||
if (symbol) {
|
||||
chunk(view, `\n📈 ${symbol} 현재가 fetch 중...\n`);
|
||||
const price = await fetchYahooPrice(symbol);
|
||||
if (typeof price === 'number') {
|
||||
const shares = Math.floor(positionWon / price);
|
||||
const actualWon = shares * price;
|
||||
chunk(view, ` · 현재가: ${price.toLocaleString()}원\n`);
|
||||
chunk(view, ` → **매수 가능 주수: ${shares.toLocaleString()}주** (실제 투자금 ${actualWon.toLocaleString()}원)\n`);
|
||||
} else {
|
||||
chunk(view, ' ⚠️ Yahoo 현재가 fetch 실패 — 주수 계산 skip\n');
|
||||
}
|
||||
}
|
||||
chunk(view, '\n');
|
||||
}
|
||||
|
||||
async function cmdReport(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||
chunk(view, '\n📨 텔레그램 보고서 발송 중...\n');
|
||||
const r = await sendStocksReport(context);
|
||||
@@ -225,7 +563,9 @@ function cmdHelp(view: Webview | undefined): void {
|
||||
' `/stocks sync` — Google Sheets 동기화',
|
||||
' `/stocks add <심볼> <이름>` — 종목 추가',
|
||||
' `/stocks remove <심볼>` — 종목 제거',
|
||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가',
|
||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
||||
' `/stocks analysis <심볼>` — 심층 분석 (펀더멘털 + MA 정배열 + RSI + LLM 종합)',
|
||||
' `/stocks position [심볼] <총자산> <리스크%> <손절%>` — 포지션 사이징 (적정 투자금)',
|
||||
' `/stocks discover [min] [max]` — Naver 크롤 발굴 (시총 억 단위, default 1000-5000)',
|
||||
' `/stocks report` — 텔레그램 보고서 즉시 발송',
|
||||
' `/stocks run` — Watcher 1회 즉시 실행',
|
||||
@@ -260,6 +600,8 @@ export async function handleStocksCommand(
|
||||
case 'add': await cmdAdd(rest, view); return true;
|
||||
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
||||
case 'judge': await cmdJudge(rest, view); return true;
|
||||
case 'analysis': case 'analyze': await cmdAnalysis(rest, view); return true;
|
||||
case 'position': case 'size': await cmdPosition(rest, view); return true;
|
||||
case 'discover': await cmdDiscover(rest, view, context); return true;
|
||||
case 'report':
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음.\n'); return true; }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { logInfo } from '../../utils';
|
||||
import { screenMarket, type Market, type ScreenerEntry } from './naverScreener';
|
||||
import { fetchAllFundamentals, type Fundamentals } from './naverFundamentals';
|
||||
import { fetchAllHistories, evalMa224Recovery, evalDropRecovery } from './yahooClient';
|
||||
import type { Stock } from './types';
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,10 @@ function evaluateKeywords(f: Fundamentals): string[] {
|
||||
if (techKeywords.some(k => sector.includes(k)) && pbr >= 2) passed.push('기술력');
|
||||
if (retention >= 3000 && mktCap >= 5000) passed.push('안정성');
|
||||
if (pbr > 0 && pbr <= 1.5) passed.push('PBR');
|
||||
// 저평가 강조 — PBR이 낮을수록 추가 가산. 사용자 요청("저평가된 종목을 기준으로")에 맞춰
|
||||
// 1.0 / 0.7 두 단계로 elevate. PBR=0.5짜리는 PBR+저평가+초저평가 = 3 키워드 자동 통과.
|
||||
if (pbr > 0 && pbr <= 1.0) passed.push('저평가');
|
||||
if (pbr > 0 && pbr <= 0.7) passed.push('초저평가');
|
||||
|
||||
return passed;
|
||||
}
|
||||
@@ -128,17 +133,42 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
|
||||
if (i % 10 === 0 || i === total) progress(` 펀더멘털 ${i}/${total} 처리 중...`);
|
||||
});
|
||||
|
||||
// (4) 8 키워드 평가.
|
||||
const candidates: DiscoveredCandidate[] = [];
|
||||
// (4) 펀더멘털 키워드 평가 — 1차 통과 후보 추림.
|
||||
const prelim: { entry: ScreenerEntry; f: Fundamentals; passed: string[] }[] = [];
|
||||
for (const entry of allEntries) {
|
||||
const f = fundsMap.get(entry.symbol);
|
||||
if (!f) continue;
|
||||
const passed = evaluateKeywords(f);
|
||||
if (passed.length < 3) continue;
|
||||
// 상위 3개만 골라서 텍스트화 (judge 와 동일 패턴).
|
||||
prelim.push({ entry, f, passed });
|
||||
}
|
||||
|
||||
// (5) 1년 시세 → 224일선 회복 패턴 보너스 키워드.
|
||||
// 영상의 "주가가 224일선 아래 머물다 돌파 = 추세 전환" 신호를 펀더멘털 통과 후보에만
|
||||
// 얹는다(전체 1차후보에 안 돌리는 이유: Yahoo 1초/심볼 throttle 비용 절감).
|
||||
if (prelim.length > 0) {
|
||||
progress(`\n📈 ${prelim.length}개 후보의 1년 시세로 224일선 회복 패턴 확인 중 (Yahoo 1초/종목, ~${prelim.length}초)...`);
|
||||
const histMap = await fetchAllHistories(
|
||||
prelim.map(p => p.entry.symbol),
|
||||
(_sym, _ok, i, total) => {
|
||||
if (i % 10 === 0 || i === total) progress(` 시세 ${i}/${total} 처리 중...`);
|
||||
},
|
||||
);
|
||||
for (const p of prelim) {
|
||||
const h = histMap.get(p.entry.symbol);
|
||||
if (!h) continue;
|
||||
const r = evalMa224Recovery(h);
|
||||
if (r?.passed) p.passed.push('224회복');
|
||||
const dr = evalDropRecovery(h);
|
||||
if (dr?.passed) p.passed.push('낙폭과대');
|
||||
}
|
||||
}
|
||||
|
||||
// (6) DiscoveredCandidate 변환.
|
||||
const candidates: DiscoveredCandidate[] = prelim.map(({ entry, f, passed }) => {
|
||||
const top3 = passed.slice(0, 3);
|
||||
const filterText = `[발굴 자동] 충족 (${top3.join(', ')})`;
|
||||
candidates.push({
|
||||
return {
|
||||
symbol: entry.symbol,
|
||||
name: entry.name,
|
||||
market: entry.market,
|
||||
@@ -146,11 +176,18 @@ export async function discoverStocks(opts: DiscoverOptions = {}): Promise<Discov
|
||||
passedKeywords: passed,
|
||||
asStock: fundamentalsToStock(entry, f, filterText),
|
||||
fundamentals: f,
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// sortScore — 통과 키워드 수 내림차순.
|
||||
candidates.sort((a, b) => b.passedKeywords.length - a.passedKeywords.length);
|
||||
// (7) sortScore — 통과 키워드 수 desc, 동점 시 PBR asc(저평가가 위로) 타이브레이커.
|
||||
candidates.sort((a, b) => {
|
||||
if (b.passedKeywords.length !== a.passedKeywords.length) {
|
||||
return b.passedKeywords.length - a.passedKeywords.length;
|
||||
}
|
||||
const pbrA = a.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
|
||||
const pbrB = b.fundamentals.pbr ?? Number.MAX_SAFE_INTEGER;
|
||||
return pbrA - pbrB;
|
||||
});
|
||||
const limited = candidates.slice(0, limit);
|
||||
|
||||
logInfo(`Stocks discovery: ${allEntries.length} 1차 후보 → ${candidates.length} 매수후보 → ${limited.length} 표시.`);
|
||||
|
||||
@@ -58,3 +58,296 @@ export async function fetchAllPrices(
|
||||
logInfo('Yahoo 일괄 갱신 완료.', { total: symbols.length, success: [...out.values()].filter(p => p !== null).length });
|
||||
return out;
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// 1년 일봉 시세 — 224일 이동평균 + 회복 패턴 판정에 사용.
|
||||
// 영상(주식단테 "224일선 안착 = 추세 전환") 신호를 펀더멘털 발굴에 보조 기준으로 얹기 위한 입력.
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface YahooHistory {
|
||||
/** 종가 배열 (오래된→최신 순). null 종가는 제거됨. */
|
||||
closes: number[];
|
||||
/** 거래량 배열 (closes와 같은 인덱스 정렬, null은 0으로 채움). */
|
||||
volumes: number[];
|
||||
}
|
||||
|
||||
export async function fetchYahooHistory(symbol: string, timeoutMs = 10000): Promise<YahooHistory | null> {
|
||||
if (!symbol) return null;
|
||||
const candidates: string[] = symbol.includes('.')
|
||||
? [symbol]
|
||||
: [`${symbol}.KQ`, `${symbol}.KS`];
|
||||
|
||||
for (const yahooSymbol of candidates) {
|
||||
try {
|
||||
const url = `https://query1.finance.yahoo.com/v8/finance/chart/${yahooSymbol}?range=1y&interval=1d`;
|
||||
const res = await fetch(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) continue;
|
||||
const data: any = await res.json();
|
||||
const q = data?.chart?.result?.[0]?.indicators?.quote?.[0];
|
||||
const closesRaw: any[] = q?.close ?? [];
|
||||
const volumesRaw: any[] = q?.volume ?? [];
|
||||
// closes·volumes를 같은 인덱스로 정렬해 유지 — null close는 양쪽 모두 제거,
|
||||
// null volume은 0으로 보전(낙폭과대/거래량 평균 계산이 흔들리지 않게).
|
||||
const closes: number[] = [];
|
||||
const volumes: number[] = [];
|
||||
for (let i = 0; i < closesRaw.length; i++) {
|
||||
const c = closesRaw[i];
|
||||
if (typeof c !== 'number' || !Number.isFinite(c)) continue;
|
||||
closes.push(c);
|
||||
const v = volumesRaw[i];
|
||||
volumes.push(typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
||||
}
|
||||
if (closes.length === 0) continue;
|
||||
return { closes, volumes };
|
||||
} catch (e: any) {
|
||||
if (yahooSymbol === candidates[candidates.length - 1]) {
|
||||
logError('Yahoo Finance 1년 시세 조회 실패.', { symbol, yahooSymbol, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function fetchAllHistories(
|
||||
symbols: string[],
|
||||
onProgress?: (symbol: string, ok: boolean, i: number, total: number) => void,
|
||||
): Promise<Map<string, YahooHistory | null>> {
|
||||
const out = new Map<string, YahooHistory | null>();
|
||||
for (let i = 0; i < symbols.length; i++) {
|
||||
const symbol = symbols[i];
|
||||
const h = await fetchYahooHistory(symbol);
|
||||
out.set(symbol, h);
|
||||
onProgress?.(symbol, h !== null, i + 1, symbols.length);
|
||||
await new Promise(r => setTimeout(r, 1000)); // Yahoo rate limit 보호
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** window 길이 단순 이동평균 (오래된→최신 순 입력, 출력 길이 = arr.length - window + 1). */
|
||||
function rollingMean(arr: number[], window: number): number[] {
|
||||
const out: number[] = [];
|
||||
if (arr.length < window) return out;
|
||||
let sum = 0;
|
||||
for (let i = 0; i < window; i++) sum += arr[i];
|
||||
out.push(sum / window);
|
||||
for (let i = window; i < arr.length; i++) {
|
||||
sum += arr[i] - arr[i - window];
|
||||
out.push(sum / window);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export interface Ma224RecoveryResult {
|
||||
/** 회복 패턴 + 거래량 확인을 모두 통과했는지. */
|
||||
passed: boolean;
|
||||
/**
|
||||
* 평가 자체가 부적용인 상태 (현재가가 MA224 보다 +20% 이상 위 → 추세 이미 확립).
|
||||
* `passed:false`와 의미가 다름 — passed:false 는 "회복 시도했으나 미달", N/A는 "회복할 게 없음".
|
||||
* UI/LLM 은 이를 "음성 신호" 가 아니라 "무관" 으로 해석해야 함.
|
||||
*/
|
||||
notApplicable?: boolean;
|
||||
notApplicableReason?: string;
|
||||
/** 오늘 시점 MA224 값. */
|
||||
ma224Today?: number;
|
||||
/** 최근 30거래일 중 종가가 그 시점 MA224 아래였던 일수. */
|
||||
daysBelowLast30?: number;
|
||||
/** 현재 종가. */
|
||||
currentPrice?: number;
|
||||
/** 거래량 확인 통과 여부 (최근 5일 평균 ≥ 60일 평균 × 1.2). 거래량 데이터 부족 시 true. */
|
||||
volumeConfirmed?: boolean;
|
||||
/** 최근 5일 평균 거래량. */
|
||||
vol5dAvg?: number;
|
||||
/** 최근 60일 평균 거래량. */
|
||||
vol60dAvg?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 224일선(약 1년 이평) 회복 패턴 판정.
|
||||
*
|
||||
* 영상의 "주가가 224일선 아래 머물다 → 강한 거래량으로 돌파 → 안착 = 추세 전환" 신호를
|
||||
* 정량화한 단순 버전:
|
||||
* - 현재 종가 ≥ 오늘 시점 MA224 (현재 위에 있음)
|
||||
* - 최근 30거래일 중 5일 이상 그 시점 MA224 아래에 머문 적 있음 (직전까지 저평가 영역)
|
||||
*
|
||||
* 데이터가 부족(시세 224일 미만)하면 null — 호출자가 신호 미적용.
|
||||
*/
|
||||
export function evalMa224Recovery(history: YahooHistory): Ma224RecoveryResult | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 224) return null;
|
||||
const ma = rollingMean(closes, 224);
|
||||
const ma224Today = ma[ma.length - 1];
|
||||
const currentPrice = closes[closes.length - 1];
|
||||
if (!Number.isFinite(ma224Today) || !Number.isFinite(currentPrice)) return null;
|
||||
// 현재가가 MA224 한참 위(+20% 초과) → 추세 이미 확립. 회복 패턴 평가가 무관(N/A).
|
||||
// 이 상태를 passed:false 로 보고하면 사용자/LLM 이 "음성 신호" 로 오해함 (실제로는 정상).
|
||||
if (currentPrice / ma224Today > 1.20) {
|
||||
const pctAbove = ((currentPrice / ma224Today - 1) * 100).toFixed(0);
|
||||
return {
|
||||
passed: false,
|
||||
notApplicable: true,
|
||||
notApplicableReason: `현재가가 MA224 보다 +${pctAbove}% 위 — 추세 이미 확립, 회복 패턴 평가 무관(부정 신호 아님)`,
|
||||
ma224Today, currentPrice,
|
||||
};
|
||||
}
|
||||
if (currentPrice < ma224Today) {
|
||||
return { passed: false, ma224Today, daysBelowLast30: 0, currentPrice };
|
||||
}
|
||||
const lookback = Math.min(30, ma.length);
|
||||
let daysBelow = 0;
|
||||
for (let i = ma.length - lookback; i < ma.length; i++) {
|
||||
const priceAtI = closes[i + 224 - 1]; // ma[i] = mean of closes[i..i+223]
|
||||
if (priceAtI < ma[i]) daysBelow++;
|
||||
}
|
||||
const priceCondition = daysBelow >= 5;
|
||||
// 거래량 확인 — 영상(주식단테 "강한 거래량으로 돌파") 의 정량화. 거짓 돌파(일시적
|
||||
// 가격 튐) 필터. 데이터 부족(거래량 60일 미만)이면 가격 조건만으로 판정한다.
|
||||
let volumeConfirmed = true;
|
||||
let vol5dAvg: number | undefined;
|
||||
let vol60dAvg: number | undefined;
|
||||
if (history.volumes && history.volumes.length >= 60) {
|
||||
const v5 = history.volumes.slice(-5);
|
||||
const v60 = history.volumes.slice(-60);
|
||||
vol5dAvg = v5.reduce((a, b) => a + b, 0) / 5;
|
||||
vol60dAvg = v60.reduce((a, b) => a + b, 0) / 60;
|
||||
volumeConfirmed = vol60dAvg > 0 && vol5dAvg >= vol60dAvg * 1.2;
|
||||
}
|
||||
return {
|
||||
passed: priceCondition && volumeConfirmed,
|
||||
ma224Today, daysBelowLast30: daysBelow, currentPrice,
|
||||
volumeConfirmed, vol5dAvg, vol60dAvg,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DropRecoveryResult {
|
||||
/** 낙폭과대 + 반등 초입 패턴 통과 여부. */
|
||||
passed: boolean;
|
||||
/** 실패 사유 (둘 중 어느 조건이 어긋났는지 명시) — passed:false 일 때만. */
|
||||
failReason?: string;
|
||||
/** 1년 최고가 (history 전 구간 max). */
|
||||
high1y?: number;
|
||||
/** 60거래일 최저가. */
|
||||
low60d?: number;
|
||||
/** 현재 종가. */
|
||||
currentPrice?: number;
|
||||
/** 1년 최고가 대비 현재가 비율 (0~1+, 낮을수록 많이 빠짐). */
|
||||
fromHighRatio?: number;
|
||||
/** 60일 저점 대비 현재가 비율 (1+, 높을수록 많이 반등). */
|
||||
fromLowRatio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* "낙폭과대 + 반등 초입" 패턴 판정. 영상(주식단테 "이미 빠진 종목 + 바닥 찍고 회복")의
|
||||
* 정량화. 224회복(추세 전환)과는 다른 각도 — 안전마진 + 회복 초입.
|
||||
*
|
||||
* - 현재가 ≤ 1년 최고가 × 0.75 (= 25% 이상 하락 — 안전마진)
|
||||
* - AND 현재가 ≥ 60일 최저가 × 1.10 (= 최근 저점에서 10% 이상 반등 — 회복 초입)
|
||||
*
|
||||
* 데이터 부족(시세 60일 미만)이면 null — 호출자가 신호 미적용.
|
||||
*/
|
||||
export function evalDropRecovery(history: YahooHistory): DropRecoveryResult | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 60) return null;
|
||||
const currentPrice = closes[closes.length - 1];
|
||||
const high1y = Math.max(...closes);
|
||||
const low60d = Math.min(...closes.slice(-60));
|
||||
if (!Number.isFinite(high1y) || !Number.isFinite(low60d) || high1y <= 0 || low60d <= 0) return null;
|
||||
const fromHighRatio = currentPrice / high1y;
|
||||
const fromLowRatio = currentPrice / low60d;
|
||||
const passed = fromHighRatio <= 0.75 && fromLowRatio >= 1.10;
|
||||
let failReason: string | undefined;
|
||||
if (!passed) {
|
||||
// 어느 조건이 *실제로* 어긋났는지를 명시 — LLM 이 인과를 거꾸로 설명("64% 반등이라서 미통과"
|
||||
// 같은 잘못된 해석) 하는 것을 차단. 두 조건 모두 fail 이면 더 의미 있는 쪽(고점 근접)을 우선.
|
||||
if (fromHighRatio > 0.75) {
|
||||
const dropPct = ((1 - fromHighRatio) * 100).toFixed(1);
|
||||
failReason = `1년 고점 대비 ${dropPct}%만 하락 — 안전마진 부족 (조건: ≥25% 하락)`;
|
||||
} else if (fromLowRatio < 1.10) {
|
||||
const reboundPct = ((fromLowRatio - 1) * 100).toFixed(1);
|
||||
failReason = `60일 저점에서 ${reboundPct}%만 반등 — 회복 초입 신호 부족 (조건: ≥10% 반등)`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
passed, failReason,
|
||||
high1y, low60d, currentPrice, fromHighRatio, fromLowRatio,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MaAlignmentResult {
|
||||
/** 5/20/60/120일 이평선의 상대 위치 분류. */
|
||||
alignment: '정배열' | '역배열' | '혼조';
|
||||
ma5?: number;
|
||||
ma20?: number;
|
||||
ma60?: number;
|
||||
ma120?: number;
|
||||
currentPrice?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 이동평균선 배열 판정 — 가이드 4단계 "정배열/역배열".
|
||||
* - 정배열 (강세): MA5 > MA20 > MA60 > MA120 — 추세 매수 안전
|
||||
* - 역배열 (약세): MA5 < MA20 < MA60 < MA120 — 펀더 좋아도 대기 권장
|
||||
* - 혼조: 그 외
|
||||
*
|
||||
* 데이터 부족(120일 미만)이면 null.
|
||||
*/
|
||||
export function evalMaAlignment(history: YahooHistory): MaAlignmentResult | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 120) return null;
|
||||
const meanLast = (n: number) => closes.slice(-n).reduce((a, b) => a + b, 0) / n;
|
||||
const ma5 = meanLast(5);
|
||||
const ma20 = meanLast(20);
|
||||
const ma60 = meanLast(60);
|
||||
const ma120 = meanLast(120);
|
||||
const currentPrice = closes[closes.length - 1];
|
||||
let alignment: '정배열' | '역배열' | '혼조' = '혼조';
|
||||
if (ma5 > ma20 && ma20 > ma60 && ma60 > ma120) alignment = '정배열';
|
||||
else if (ma5 < ma20 && ma20 < ma60 && ma60 < ma120) alignment = '역배열';
|
||||
return { alignment, ma5, ma20, ma60, ma120, currentPrice };
|
||||
}
|
||||
|
||||
export interface Rsi14Result {
|
||||
/** 14일 RSI 값 (0~100). */
|
||||
rsi: number;
|
||||
/** 과열 (≥70) / 침체 (≤30) / 중립 (그 외). */
|
||||
classification: '과열' | '중립' | '침체';
|
||||
}
|
||||
|
||||
/**
|
||||
* RSI(14) — 가이드 4단계 "과매수/과매도" 지표. Wilder's smoothing.
|
||||
* - 과열 (≥70): 매수 자제
|
||||
* - 침체 (≤30): 단기 반등 여지
|
||||
* - 중립 (30~70): 정상
|
||||
*
|
||||
* 데이터 부족(15일 미만)이면 null.
|
||||
*/
|
||||
export function evalRsi14(history: YahooHistory): Rsi14Result | null {
|
||||
const closes = history.closes;
|
||||
if (closes.length < 15) return null;
|
||||
// 첫 14개 변동분 — 단순 평균.
|
||||
let avgGain = 0;
|
||||
let avgLoss = 0;
|
||||
for (let i = 1; i <= 14; i++) {
|
||||
const diff = closes[i] - closes[i - 1];
|
||||
if (diff > 0) avgGain += diff;
|
||||
else avgLoss += -diff;
|
||||
}
|
||||
avgGain /= 14;
|
||||
avgLoss /= 14;
|
||||
// Wilder's smoothing — 이후 모든 일자.
|
||||
for (let i = 15; i < closes.length; i++) {
|
||||
const diff = closes[i] - closes[i - 1];
|
||||
const gain = diff > 0 ? diff : 0;
|
||||
const loss = diff < 0 ? -diff : 0;
|
||||
avgGain = (avgGain * 13 + gain) / 14;
|
||||
avgLoss = (avgLoss * 13 + loss) / 14;
|
||||
}
|
||||
if (avgLoss === 0) return { rsi: 100, classification: '과열' };
|
||||
const rs = avgGain / avgLoss;
|
||||
const rsi = 100 - 100 / (1 + rs);
|
||||
const classification: '과열' | '중립' | '침체' =
|
||||
rsi >= 70 ? '과열' : rsi <= 30 ? '침체' : '중립';
|
||||
return { rsi, classification };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* System handlers — /memory · /glossary · /help (인프라·관리·발견).
|
||||
*
|
||||
* v2.2.200 에서 slashRouter.ts 에서 분리. 4인 팀 운영 도메인 아닌
|
||||
* "ASTRA 자체 인프라" 슬래시 명령. teamops handlers 와 같은 패턴.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk, listSlashCommands } from '../datacollect/slashRouter';
|
||||
import { getConfig } from '../../config';
|
||||
import {
|
||||
MemoryManager,
|
||||
distillStaleEpisodes,
|
||||
getLastDistillationRun,
|
||||
recordDistillationRun,
|
||||
type DistillationArchiveMode,
|
||||
} from '../../memory';
|
||||
import {
|
||||
getGlossaryFilePath,
|
||||
GLOSSARY_TEMPLATE,
|
||||
clearGlossaryCache,
|
||||
} from '../../retrieval/terminologyBlock';
|
||||
import { clearTermValidatorCache } from '../../agent/termValidator';
|
||||
|
||||
// ─── /memory — 메모리 라이프사이클 관리 ─────────────────────────────────
|
||||
|
||||
function _formatDate(epoch: number | undefined): string {
|
||||
if (!epoch) return '-';
|
||||
return new Date(epoch).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function _memoryOverview(view: any): void {
|
||||
const cfg = getConfig();
|
||||
const brainPath = cfg.localBrainPath;
|
||||
if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return; }
|
||||
|
||||
const mgr = new MemoryManager(brainPath, {
|
||||
longTermMaxEntries: cfg.memoryLongTermFiles ?? 100,
|
||||
episodicMaxEpisodes: 50,
|
||||
});
|
||||
const lt = mgr.getLongTermMemory();
|
||||
const ep = mgr.getEpisodicMemory();
|
||||
const allLt = lt.getAllEntries({ includeExpired: true });
|
||||
const activeLt = lt.getAllEntries();
|
||||
const expiredLt = allLt.length - activeLt.length;
|
||||
const allEpisodes = ep.loadAllEpisodes();
|
||||
const stalePromoted = allEpisodes.filter((e) => e.promoted).length;
|
||||
const staleCandidates = ep.findStaleEpisodes(cfg.distillationAgeThresholdDays).length;
|
||||
const last = getLastDistillationRun(brainPath);
|
||||
|
||||
chunk(view, '\n🧠 **/memory — 메모리 라이프사이클**\n');
|
||||
chunk(view, `\n## 📊 현재 상태\n`);
|
||||
chunk(view, `- **LongTerm**: 활성 ${activeLt.length}개${expiredLt > 0 ? ` (만료 ${expiredLt}개 숨김)` : ''}\n`);
|
||||
|
||||
const catCounts = new Map<string, number>();
|
||||
for (const e of activeLt) catCounts.set(e.category, (catCounts.get(e.category) || 0) + 1);
|
||||
if (catCounts.size > 0) {
|
||||
const parts = [...catCounts.entries()].map(([c, n]) => `${c} ${n}`).join(' · ');
|
||||
chunk(view, ` - 카테고리: ${parts}\n`);
|
||||
}
|
||||
|
||||
chunk(view, `- **Episodic**: 전체 ${allEpisodes.length}개 (승급 ${stalePromoted} · 미승급 stale 후보 ${staleCandidates})\n`);
|
||||
|
||||
chunk(view, `\n## 🔄 Distillation\n`);
|
||||
chunk(view, `- 임계: **${cfg.distillationAgeThresholdDays}일** 이상 stale episode → LongTerm 'episode-digest' 승급\n`);
|
||||
chunk(view, `- 자동 트리거 간격: ${cfg.distillationIntervalDays}일\n`);
|
||||
chunk(view, `- Archive 모드: \`${cfg.distillationArchiveMode}\`\n`);
|
||||
if (last) {
|
||||
const ago = Math.floor((Date.now() - last.timestamp) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- 마지막 실행: ${_formatDate(last.timestamp)} (${ago}일 전) — 승급 ${last.report?.promotedCount ?? 0}개\n`);
|
||||
} else {
|
||||
chunk(view, `- 마지막 실행: _없음_ — \`/memory distill\` 로 첫 실행\n`);
|
||||
}
|
||||
|
||||
const upcoming = activeLt
|
||||
.filter((e) => e.expiresAt && (e.expiresAt - Date.now()) <= 30 * 24 * 60 * 60 * 1000)
|
||||
.sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
|
||||
if (upcoming.length > 0) {
|
||||
chunk(view, `\n## ⏰ 30일 내 만료 LongTerm (${upcoming.length}개)\n`);
|
||||
for (const e of upcoming.slice(0, 5)) {
|
||||
const days = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- D-${days} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 80)}\n`);
|
||||
}
|
||||
if (upcoming.length > 5) chunk(view, `- _…+${upcoming.length - 5}개_\n`);
|
||||
}
|
||||
|
||||
chunk(view, `\n_명령어:_ \`/memory distill\` · \`/memory expire <id> <YYYY-MM-DD>\` · \`/memory list-expiring [days]\` · \`/memory help\`\n`);
|
||||
}
|
||||
|
||||
async function runMemory(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { _memoryOverview(view); return true; }
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const sub = parts[0].toLowerCase();
|
||||
const cfg = getConfig();
|
||||
const brainPath = cfg.localBrainPath;
|
||||
if (!brainPath) { chunk(view, '\n❌ Brain path 미설정.\n'); return true; }
|
||||
|
||||
if (sub === 'help' || sub === '?') {
|
||||
chunk(view, [
|
||||
'\n🧠 **/memory — 메모리 라이프사이클**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/memory` — 현재 상태 (LongTerm/Episodic 카운트, distillation, 만료 임박)',
|
||||
' `/memory distill [age_days]` — Stale episodes → LongTerm digest 승급 (기본 30일 임계)',
|
||||
' `/memory expire <id-prefix> <YYYY-MM-DD>` — LongTerm entry 에 만료일 설정',
|
||||
' `/memory list-expiring [days]` — N일 내 만료 LongTerm 목록 (기본 30)',
|
||||
' `/memory list-promoted` — 승급된 episodes (digest 형태로 LongTerm 에 살아 있음)',
|
||||
'',
|
||||
'Temporal Markers: LongTerm entry 의 expiresAt < now 이면 검색에서 자동 제외.',
|
||||
'Distillation Loop: 자동 트리거는 세션 종료 시 (interval 기준), 수동은 `/memory distill`.',
|
||||
'저장: `{brainPath}/memory/long_term.json` + `{brainPath}/memory/episodes/*.json`.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'distill') {
|
||||
const ageOverride = parts[1] ? parseInt(parts[1], 10) : undefined;
|
||||
const age = Number.isFinite(ageOverride) && (ageOverride as number) > 0
|
||||
? (ageOverride as number)
|
||||
: cfg.distillationAgeThresholdDays;
|
||||
chunk(view, `\n🔄 Distillation 시작 — ${age}일 이상 stale episodes 대상...\n`);
|
||||
const mgr = new MemoryManager(brainPath);
|
||||
const report = distillStaleEpisodes(mgr.getEpisodicMemory(), mgr.getLongTermMemory(), brainPath, {
|
||||
ageThresholdDays: age,
|
||||
archiveMode: cfg.distillationArchiveMode as DistillationArchiveMode,
|
||||
});
|
||||
recordDistillationRun(brainPath, report);
|
||||
chunk(view, `\n✅ **Distillation 완료** (${report.durationMs}ms)\n`);
|
||||
chunk(view, `- 후보 ${report.candidateCount}개 → 승급 ${report.promotedCount}개`);
|
||||
chunk(view, cfg.distillationArchiveMode === 'archive-file' ? ` · 아카이브 ${report.archivedCount}개\n` : '\n');
|
||||
if (report.skipped.length > 0) {
|
||||
chunk(view, `- ⚠️ 스킵 ${report.skipped.length}개:\n`);
|
||||
for (const s of report.skipped.slice(0, 3)) chunk(view, ` - \`${s.episodeId.slice(0, 8)}\`: ${s.reason}\n`);
|
||||
}
|
||||
if (report.longTermDigestIds.length > 0) {
|
||||
chunk(view, `\n_생성된 digest IDs (앞 8자):_ ${report.longTermDigestIds.slice(0, 5).map((id) => `\`${id.slice(0, 8)}\``).join(' · ')}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'expire') {
|
||||
const idPrefix = parts[1];
|
||||
const dateStr = parts[2];
|
||||
if (!idPrefix || !dateStr) {
|
||||
chunk(view, '\n❌ 사용법: `/memory expire <id-prefix> <YYYY-MM-DD>`\n예: `/memory expire a3f7d2c9 2026-09-30`\n');
|
||||
return true;
|
||||
}
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(dateStr)) {
|
||||
chunk(view, `\n❌ 날짜 형식: YYYY-MM-DD (입력: "${dateStr}")\n`);
|
||||
return true;
|
||||
}
|
||||
const expiresAt = Date.parse(dateStr + 'T23:59:59');
|
||||
if (!Number.isFinite(expiresAt)) { chunk(view, `\n❌ 날짜 파싱 실패: "${dateStr}"\n`); return true; }
|
||||
const mgr = new MemoryManager(brainPath);
|
||||
const updated = mgr.getLongTermMemory().setExpiration(idPrefix, expiresAt);
|
||||
if (!updated) { chunk(view, `\n❌ "${idPrefix}" prefix 매치 entry 없음.\n`); return true; }
|
||||
chunk(view, `\n⏰ **${updated.category}** \`${updated.id.slice(0, 8)}\` 만료일 설정 → ${dateStr}\n`);
|
||||
chunk(view, `> ${updated.content.slice(0, 120)}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'list-expiring') {
|
||||
const days = parts[1] ? parseInt(parts[1], 10) : 30;
|
||||
const window = Number.isFinite(days) && days > 0 ? days : 30;
|
||||
const mgr = new MemoryManager(brainPath);
|
||||
const all = mgr.getLongTermMemory().getAllEntries();
|
||||
const horizon = Date.now() + window * 24 * 60 * 60 * 1000;
|
||||
const upcoming = all
|
||||
.filter((e) => e.expiresAt && e.expiresAt <= horizon)
|
||||
.sort((a, b) => (a.expiresAt || 0) - (b.expiresAt || 0));
|
||||
if (upcoming.length === 0) { chunk(view, `\nℹ️ ${window}일 내 만료 예정 entry 없음.\n`); return true; }
|
||||
chunk(view, `\n⏰ **${window}일 내 만료 LongTerm (${upcoming.length}개)**\n\n`);
|
||||
for (const e of upcoming) {
|
||||
const d = Math.ceil(((e.expiresAt || 0) - Date.now()) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- D-${d} [${e.category}] \`${e.id.slice(0, 8)}\` ${e.content.slice(0, 100)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'list-promoted') {
|
||||
const mgr = new MemoryManager(brainPath);
|
||||
const promoted = mgr.getEpisodicMemory().loadAllEpisodes().filter((e) => e.promoted);
|
||||
if (promoted.length === 0) { chunk(view, '\nℹ️ 승급된 episode 없음. `/memory distill` 로 첫 승급.\n'); return true; }
|
||||
chunk(view, `\n📚 **승급된 episodes (${promoted.length}개)** — LongTerm 에 digest 형태로 살아 있음\n\n`);
|
||||
for (const e of promoted.slice(0, 15)) {
|
||||
const date = (new Date(e.timestamp)).toISOString().slice(0, 10);
|
||||
chunk(view, `- \`${date}\` ${e.title} → digest \`${(e.promotedToLongTermId || '').slice(0, 8)}\`\n`);
|
||||
}
|
||||
if (promoted.length > 15) chunk(view, `- _…+${promoted.length - 15}개_\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/memory help\` 참조.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /glossary — Terminology Dictionary 사용자 진입점 ───────────────────
|
||||
|
||||
async function runGlossary(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
const cfg = getConfig();
|
||||
const fp = getGlossaryFilePath(cfg.glossaryPath || '.astra/glossary.md');
|
||||
|
||||
if (trimmed === 'help' || trimmed === '?') {
|
||||
chunk(view, [
|
||||
'\n📖 **/glossary — Terminology Dictionary**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/glossary` — 상태 (파일 존재/크기, system prompt 주입 여부)',
|
||||
' `/glossary path` — 글로서리 파일 절대 경로',
|
||||
' `/glossary init` — 권장 템플릿으로 글로서리 파일 생성 (이미 있으면 덮어쓰지 않음)',
|
||||
' `/glossary reload` — 캐시 비우기 (편집 직후 즉시 반영 강제)',
|
||||
'',
|
||||
'편집 방법: 위 path 의 markdown 파일을 *VS Code 에서 직접 편집*.',
|
||||
'효과: 다음 채팅 turn 부터 ASTRA 시스템 프롬프트의 [TERMINOLOGY DICTIONARY] 블록에 자동 주입.',
|
||||
'글로서리 본문은 자유 markdown — H2/H3 섹션 구분 권장.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!fp) { chunk(view, '\n❌ Workspace 폴더 없음 — 글로서리 사용 불가.\n'); return true; }
|
||||
|
||||
if (trimmed === 'path') { chunk(view, `\n📂 \`${fp}\`\n`); return true; }
|
||||
|
||||
if (trimmed === 'init') {
|
||||
if (fs.existsSync(fp)) {
|
||||
chunk(view, `\nℹ️ 이미 존재: \`${fp}\` — 덮어쓰지 않음. 새로 시작하려면 파일 직접 삭제 후 재실행.\n`);
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(fp), { recursive: true });
|
||||
fs.writeFileSync(fp, GLOSSARY_TEMPLATE, 'utf-8');
|
||||
clearGlossaryCache();
|
||||
chunk(view, `\n✅ 글로서리 초기화 — \`${fp}\`\n`);
|
||||
chunk(view, '편집 후 다음 채팅 turn 부터 자동 반영. 즉시 반영 강제는 `/glossary reload`.\n');
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ 생성 실패: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (trimmed === 'reload') {
|
||||
clearGlossaryCache();
|
||||
clearTermValidatorCache();
|
||||
chunk(view, '\n🔄 글로서리 캐시 비움 (system prompt + Term Validator 모두). 다음 채팅 turn 에 파일 재읽기.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, '\n📖 **/glossary — Terminology Dictionary 상태**\n');
|
||||
chunk(view, `\n- 경로: \`${fp}\`\n`);
|
||||
if (!fs.existsSync(fp)) {
|
||||
chunk(view, `- ❌ 파일 없음 — \`/glossary init\` 로 권장 템플릿 생성 가능\n`);
|
||||
chunk(view, '- system prompt 주입: ⊘ (no-op)\n');
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const stat = fs.statSync(fp);
|
||||
const raw = fs.readFileSync(fp, 'utf-8');
|
||||
const len = raw.length;
|
||||
const cap = cfg.glossaryMaxBodyLength ?? 4000;
|
||||
const truncated = len > cap;
|
||||
const mtime = new Date(stat.mtimeMs).toISOString().slice(0, 10);
|
||||
chunk(view, `- ✅ 파일 ${len}자 (${truncated ? `cap ${cap} 초과 — 잘림` : `cap ${cap} 이내`}) · 마지막 편집 ${mtime}\n`);
|
||||
chunk(view, `- Enabled: ${cfg.glossaryEnabled !== false ? '✓' : '✗'}\n`);
|
||||
chunk(view, `- system prompt 주입: ${cfg.glossaryEnabled !== false && len > 0 ? '✓ 매 turn 자동' : '⊘'}\n`);
|
||||
const preview = raw.split('\n').slice(0, 5).join('\n');
|
||||
chunk(view, `\n### 미리보기 (첫 5줄)\n\`\`\`\n${preview}\n\`\`\`\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `- ⚠️ 읽기 실패: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /help — 카테고리별 슬래시 명령 브라우저 ─────────────────────────────
|
||||
|
||||
interface HelpCategory {
|
||||
title: string;
|
||||
emoji: string;
|
||||
match: (name: string) => boolean;
|
||||
blurb?: string;
|
||||
}
|
||||
|
||||
const HELP_CATEGORIES: HelpCategory[] = [
|
||||
{
|
||||
title: '일과 리듬 (Daily Cycle)',
|
||||
emoji: '☀️',
|
||||
match: (n) => ['/morning', '/evening', '/weekly', '/cohort', '/standup'].includes(n),
|
||||
blurb: '아침 (`/morning`) · 저녁 (`/evening`) · 주말 (`/weekly`) · 분기 (`/cohort`) · 팀 공유 (`/standup`)',
|
||||
},
|
||||
{
|
||||
title: '4인 팀 운영 트래커',
|
||||
emoji: '🏢',
|
||||
match: (n) => ['/runway', '/customers', '/hire'].includes(n),
|
||||
blurb: '재무 (`/runway`) · 매출 (`/customers`) · 채용 (`/hire`) — 모두 event-sourced `.astra/*.jsonl`',
|
||||
},
|
||||
{
|
||||
title: '작업·블로커·1:1',
|
||||
emoji: '📋',
|
||||
match: (n) => ['/task', '/blocked', '/onesie', '/decisions'].includes(n),
|
||||
blurb: 'Google Tasks 등록 (`/task`) · 지연 분석 (`/blocked`) · 멤버 1:1 카드 (`/onesie`) · ADR (`/decisions`)',
|
||||
},
|
||||
{
|
||||
title: '외부 출력·기록',
|
||||
emoji: '✉️',
|
||||
match: (n) => ['/draft', '/feedback'].includes(n),
|
||||
blurb: 'email/slack/blog/newsletter 초안 (`/draft`) · 고객 피드백 누적+분석 (`/feedback`)',
|
||||
},
|
||||
{
|
||||
title: '리서치·분석',
|
||||
emoji: '🔬',
|
||||
match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
|
||||
blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록',
|
||||
},
|
||||
{
|
||||
title: '시스템·메모리',
|
||||
emoji: '⚙️',
|
||||
match: (n) => ['/memory', '/glossary'].includes(n),
|
||||
blurb: 'Temporal Markers + Distillation (`/memory`) · 용어집 (`/glossary`)',
|
||||
},
|
||||
{
|
||||
title: '주식·외부',
|
||||
emoji: '📈',
|
||||
match: (n) => ['/stocks'].includes(n),
|
||||
blurb: 'Yahoo 가격 + Sheets 동기화 + 텔레그램 보고서 + LLM judge',
|
||||
},
|
||||
];
|
||||
|
||||
async function runHelp(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
const allCommands = listSlashCommands();
|
||||
|
||||
if (trimmed && trimmed !== 'help' && trimmed !== '?') {
|
||||
const target = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
|
||||
const def = allCommands.find((c) => c.name.toLowerCase() === target);
|
||||
if (!def) {
|
||||
chunk(view, `\n❌ 명령 "${target}" 등록되지 않음. \`/help\` 로 전체 목록.\n`);
|
||||
return true;
|
||||
}
|
||||
chunk(view, `\n📘 **${def.name}**\n\n${def.description || '_(설명 없음)_'}\n\n`);
|
||||
chunk(view, `세부 도움말은 \`${def.name} help\` 또는 \`${def.name} ?\` 로 직접 호출 (대부분 명령이 자체 도움말 제공).\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📚 **ASTRA 슬래시 명령 — 전체 ${allCommands.length}개**\n`);
|
||||
chunk(view, '_사용: `/<명령>` · 세부 도움말: `/<명령> help` · 특정 명령 검색: `/help <명령>`_\n');
|
||||
|
||||
const usedNames = new Set<string>();
|
||||
for (const cat of HELP_CATEGORIES) {
|
||||
const matched = allCommands.filter((c) => cat.match(c.name.toLowerCase()));
|
||||
if (matched.length === 0) continue;
|
||||
chunk(view, `\n## ${cat.emoji} ${cat.title}\n`);
|
||||
if (cat.blurb) chunk(view, `_${cat.blurb}_\n\n`);
|
||||
for (const c of matched) {
|
||||
usedNames.add(c.name.toLowerCase());
|
||||
chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const uncategorized = allCommands.filter((c) => !usedNames.has(c.name.toLowerCase()) && c.name !== '/help');
|
||||
if (uncategorized.length > 0) {
|
||||
chunk(view, '\n## 🔌 기타·외부 플러그인\n\n');
|
||||
for (const c of uncategorized) {
|
||||
chunk(view, `- \`${c.name}\` — ${c.description || '_(설명 없음)_'}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const cfg = getConfig();
|
||||
chunk(view, '\n---\n\n## 🛡️ ASTRA 추론 엔진 (v2.2.183~ 누적)\n');
|
||||
chunk(view, '_각 turn 시스템 프롬프트 자동 주입 (casual 모드 제외):_\n\n');
|
||||
const engineStatus = [
|
||||
{ label: 'Intent Clarification', on: cfg.intentClarificationEnabled !== false, key: '[INTENT CLARIFICATION GUIDANCE]', when: '답변 시작 전' },
|
||||
{ label: 'Terminology Dictionary', on: cfg.glossaryEnabled !== false, key: '[TERMINOLOGY DICTIONARY]', when: '답변 작성 중' },
|
||||
{ label: 'Conflict Surface', on: cfg.conflictHighlightingEnabled !== false, key: '[CONFLICT WARNINGS]', when: '답변 작성 중' },
|
||||
{ label: 'Chain-of-Verification (CoVe)', on: cfg.coveEnabled !== false, key: '[VERIFICATION CHECKLIST]', when: '답변 작성 중' },
|
||||
{ label: 'Citation Trace', on: cfg.citationTraceEnabled !== false, key: '[CITATION TRACE]', when: '답변 끝' },
|
||||
{ label: 'Post-hoc Self-Check', on: cfg.selfCheckEnabled === true, key: 'footer (별도 LLM 호출)', when: '답변 완료 후' },
|
||||
];
|
||||
for (const e of engineStatus) {
|
||||
chunk(view, `- ${e.on ? '✓' : '⊘'} **${e.label}** — \`${e.key}\` _(${e.when})_\n`);
|
||||
}
|
||||
chunk(view, '\n_추가 검색 신호:_ Recency · Actionability · Hierarchical Level · Semantic Re-rank (opt-in)\n');
|
||||
chunk(view, '_메모리 관리:_ Temporal Markers (만료일) · Distillation Loop (stale episodes → LongTerm digest)\n');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/memory', description: '메모리 라이프사이클 — Temporal Markers (만료일) + Distillation Loop (stale episodes → LongTerm digest)', handler: runMemory });
|
||||
registerSlashCommand({ name: '/glossary', description: 'Terminology Dictionary — 표준 용어집 (.astra/glossary.md) 상태/생성/리로드. 다음 turn 부터 system prompt 자동 주입', handler: runGlossary });
|
||||
registerSlashCommand({ name: '/help', description: '슬래시 명령 전체 목록 (카테고리별) + ASTRA 추론 엔진 상태. `/help <명령>` 으로 특정 명령 정보.', handler: runHelp });
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* TeamOps handlers 공통 헬퍼 — 4인 팀 운영 슬래시 명령 클러스터가 공유하는 함수·상수.
|
||||
*
|
||||
* 이전 위치: `src/features/datacollect/slashRouter.ts` 내부 module-local 함수
|
||||
* (v2.2.196 에서 분리). 핸들러 도메인별 분할 시 공통 항목만 여기에.
|
||||
*
|
||||
* 추후 datacollect / system 핸들러도 비슷한 공유 helpers 가 필요해지면
|
||||
* `src/features/_shared/` 로 promote 고려.
|
||||
*/
|
||||
|
||||
/** 한국식 KRW 숫자 포맷 — 만/억 단위 자동. 마이너스 부호 보존. */
|
||||
export function fmtKrw(n: number): string {
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
if (abs >= 100_000_000) return `${sign}${(abs / 100_000_000).toFixed(2)}억`;
|
||||
if (abs >= 10_000) return `${sign}${(abs / 10_000).toFixed(0)}만`;
|
||||
return `${sign}${abs.toLocaleString('ko-KR')}`;
|
||||
}
|
||||
|
||||
/** 한국식 금액 토큰 파싱 — "5000만", "1.5억", "300000", "1,500,000", "10k", "5m" 모두 인식. */
|
||||
export function parseAmount(token: string): number | null {
|
||||
if (!token) return null;
|
||||
const s = token.replace(/[,_]/g, '').trim();
|
||||
const m = s.match(/^(-?[\d.]+)\s*(억|만|k|m|b)?$/i);
|
||||
if (!m) return null;
|
||||
const base = parseFloat(m[1]);
|
||||
if (!Number.isFinite(base)) return null;
|
||||
let mul = 1;
|
||||
const unit = (m[2] || '').toLowerCase();
|
||||
if (unit === '억') mul = 100_000_000;
|
||||
else if (unit === '만') mul = 10_000;
|
||||
else if (unit === 'k') mul = 1_000;
|
||||
else if (unit === 'm') mul = 1_000_000;
|
||||
else if (unit === 'b') mul = 1_000_000_000;
|
||||
return base * mul;
|
||||
}
|
||||
|
||||
/** D-day 계산 — ISO 날짜 (YYYY-MM-DD) 받아 오늘부터 며칠 후인지. 음수면 지난 일수. */
|
||||
export function daysUntil(isoDate: string | undefined, now: Date = new Date()): number | null {
|
||||
if (!isoDate) return null;
|
||||
const t = Date.parse(isoDate);
|
||||
if (!Number.isFinite(t)) return null;
|
||||
return Math.ceil((t - now.getTime()) / (24 * 60 * 60 * 1000));
|
||||
}
|
||||
|
||||
export interface ParsedTaskOwner { owner: string | undefined; displayTitle: string; }
|
||||
|
||||
/**
|
||||
* Task 제목·notes 에서 owner 추출. 두 패턴 지원:
|
||||
* 1. 제목 prefix `[멤버] 실제 제목` — /task, /meet 가 등록한 형식
|
||||
* 2. notes 의 `담당: @이름` 또는 `담당: 이름` — 일부 외부 등록 호환
|
||||
*/
|
||||
export function parseTaskOwner(title: string, notes?: string): ParsedTaskOwner {
|
||||
const titlePrefix = title.match(/^\[([^\]]+)\]\s*(.+)$/);
|
||||
if (titlePrefix) return { owner: titlePrefix[1].trim(), displayTitle: titlePrefix[2].trim() };
|
||||
const notesMatch = (notes || '').match(/담당:\s*(?:@)?([\S]+)/);
|
||||
if (notesMatch) return { owner: notesMatch[1].trim(), displayTitle: title };
|
||||
return { owner: undefined, displayTitle: title };
|
||||
}
|
||||
|
||||
/** /hire 파이프라인 단계 정렬 가중치. inbox 1, hired 7, terminal 99. */
|
||||
export const STAGE_ORDER: Record<string, number> = {
|
||||
inbox: 1, screened: 2, interview: 3, final: 4, offer: 5,
|
||||
accepted: 6, hired: 7, rejected: 99, declined: 99,
|
||||
};
|
||||
|
||||
/** 종료된 후보 단계. 검색·통계에서 active 와 구분. */
|
||||
export const TERMINAL_STAGES = new Set(['hired', 'rejected', 'declined']);
|
||||
|
||||
/** 단계별 emoji — UI 표시. */
|
||||
export function stageEmoji(stage: string): string {
|
||||
switch (stage) {
|
||||
case 'inbox': return '📥';
|
||||
case 'screened': return '🔍';
|
||||
case 'interview': return '💬';
|
||||
case 'final': return '🎯';
|
||||
case 'offer': return '📨';
|
||||
case 'accepted': return '🤝';
|
||||
case 'hired': return '🎉';
|
||||
case 'rejected': return '❌';
|
||||
case 'declined': return '🚪';
|
||||
default: return '•';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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 });
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup.
|
||||
*
|
||||
* v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등
|
||||
* "팀 운영의 실시간 부분" 클러스터.
|
||||
*
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. 옛 slashRouter 의 local parseTaskOwner/
|
||||
* parseFlexibleDate 도 여기로 (parseFlexibleDate 는 /task 만 사용).
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||||
import { parseTaskOwner } from './_shared';
|
||||
import { createCalendarEvent, createTask, listTasks, _addDaysDate } from '../../calendar';
|
||||
import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
|
||||
|
||||
// ─── 공통 헬퍼 — /task 전용 ──────────────────────────────────────────────
|
||||
|
||||
/** 유연한 한국 날짜 파서. YY/MM/DD · YYYY/MM/DD · YYYY-MM-DD 지원. */
|
||||
function parseFlexibleDate(s: string): string | null {
|
||||
if (!s) return null;
|
||||
let y: number, mo: number, d: number;
|
||||
let m = s.match(/^(\d{2})\/(\d{1,2})\/(\d{1,2})$/);
|
||||
if (m) { y = 2000 + Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
|
||||
else if ((m = s.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
|
||||
else if ((m = s.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/))) { y = Number(m[1]); mo = Number(m[2]); d = Number(m[3]); }
|
||||
else return null;
|
||||
if (mo < 1 || mo > 12 || d < 1 || d > 31) return null;
|
||||
const date = new Date(y, mo - 1, d);
|
||||
if (Number.isNaN(date.getTime()) || date.getMonth() !== mo - 1 || date.getDate() !== d) return null;
|
||||
return `${y}-${String(mo).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// ─── /task — Google Tasks + Calendar 동시 등록 ──────────────────────────
|
||||
|
||||
async function runTask(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /task 실행 불가.\n'); return true; }
|
||||
if (!arg.trim()) {
|
||||
chunk(view, [
|
||||
'\n📋 **/task — Google Tasks + Calendar 동시 등록**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/task [@담당자] <제목> <시작일> <완료일>` — 기간 작업',
|
||||
' `/task [@담당자] <제목> <날짜>` — 하루짜리 작업',
|
||||
'',
|
||||
'날짜 형식: `YY/MM/DD` (예: `26/05/27`) · `YYYY-MM-DD` · `YYYY/MM/DD`',
|
||||
'담당자: `@` 접두사로 첫 토큰에 (예: `@기획자`). 생략 가능 — 있으면 제목 앞 `[담당자]` 로 prefix 됨.',
|
||||
'',
|
||||
'예시:',
|
||||
' `/task @기획자 Apple 계정 생성 요청 26/05/27 26/06/28`',
|
||||
' `/task @디자이너 메인 화면 시안 2026-07-01 2026-07-15`',
|
||||
' `/task 약값 결제 26/06/01` (담당자 없음)',
|
||||
'',
|
||||
'Tasks API + Calendar API 양쪽 등록. Tasks 는 마감일만(시작일은 노트), Calendar 는 기간 all-day 일정.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
const tokens = arg.trim().split(/\s+/);
|
||||
if (tokens.length < 2) { chunk(view, '\n❌ 인자 부족 — 최소 제목 + 날짜 1개 필요.\n'); return true; }
|
||||
|
||||
let owner: string | undefined;
|
||||
if (tokens[0]?.startsWith('@') && tokens[0].length > 1) {
|
||||
owner = tokens[0].slice(1);
|
||||
tokens.shift();
|
||||
if (tokens.length < 1) { chunk(view, '\n❌ 제목·날짜 누락 (담당자만 입력됨).\n'); return true; }
|
||||
}
|
||||
|
||||
const lastDate = parseFlexibleDate(tokens[tokens.length - 1]);
|
||||
const secondLastDate = tokens.length >= 3 ? parseFlexibleDate(tokens[tokens.length - 2]) : null;
|
||||
let startYmd: string, endYmd: string, titleTokens: string[];
|
||||
if (lastDate && secondLastDate) {
|
||||
startYmd = secondLastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -2);
|
||||
} else if (lastDate) {
|
||||
startYmd = lastDate; endYmd = lastDate; titleTokens = tokens.slice(0, -1);
|
||||
} else {
|
||||
chunk(view, `\n❌ 마지막 토큰이 날짜 형식이 아닙니다 ("${tokens[tokens.length - 1]}"). 사용 가능 형식: YY/MM/DD · YYYY-MM-DD · YYYY/MM/DD.\n사용법: \`/task\` (인자 없이) 실행해 도움말 보기.\n`);
|
||||
return true;
|
||||
}
|
||||
const baseTitle = titleTokens.join(' ').trim();
|
||||
if (!baseTitle) { chunk(view, '\n❌ 제목 누락.\n'); return true; }
|
||||
if (startYmd > endYmd) { chunk(view, `\n❌ 시작일(${startYmd})이 완료일(${endYmd})보다 늦습니다.\n`); return true; }
|
||||
|
||||
const title = owner ? `[${owner}] ${baseTitle}` : baseTitle;
|
||||
const isRange = startYmd !== endYmd;
|
||||
const periodLabel = isRange ? `${startYmd} ~ ${endYmd}` : startYmd;
|
||||
chunk(view, `\n📝 **Google Tasks + Calendar 등록**: ${title}\n · 기간: ${periodLabel}${owner ? ` · 담당: @${owner}` : ''}\n`);
|
||||
|
||||
const notes = `${owner ? `담당: @${owner}\n` : ''}Astra /task 직접 등록\n기간: ${periodLabel}`;
|
||||
const successes: string[] = [];
|
||||
const failures: string[] = [];
|
||||
let calLink: string | undefined;
|
||||
|
||||
const taskNotes = isRange ? `${notes}\n(Tasks 는 마감일만 사용 — 시작일은 노트 참조)` : notes;
|
||||
const taskResult = await createTask(context, { title, due: endYmd, notes: taskNotes });
|
||||
if (taskResult.ok) successes.push('Tasks');
|
||||
else failures.push(`Tasks: ${taskResult.error}`);
|
||||
|
||||
const calEnd = _addDaysDate(endYmd, 1);
|
||||
const calResult = await createCalendarEvent(context, { title, start: startYmd, end: calEnd, allDay: true, description: notes });
|
||||
if (calResult.ok) { successes.push('Calendar'); calLink = calResult.event.htmlLink; }
|
||||
else failures.push(`Calendar: ${calResult.error}`);
|
||||
|
||||
if (failures.length === 0) chunk(view, `✅ 등록 완료 — ${successes.join(' + ')}\n`);
|
||||
else if (successes.length > 0) {
|
||||
chunk(view, `✅ 부분 성공 — ${successes.join(' + ')}\n`);
|
||||
for (const f of failures) chunk(view, ` ⚠️ ${f}\n`);
|
||||
} else {
|
||||
chunk(view, `❌ 모두 실패\n`);
|
||||
for (const f of failures) chunk(view, ` · ${f}\n`);
|
||||
}
|
||||
if (calLink) chunk(view, `🔗 Calendar 일정 열기: ${calLink}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /decisions — Chronicle ADR 검색 ─────────────────────────────────────
|
||||
|
||||
async function runDecisions(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /decisions 실행 불가.\n'); return true; }
|
||||
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
let ownerFilter: string | undefined;
|
||||
const keywordParts: string[] = [];
|
||||
for (const t of tokens) {
|
||||
if (t.startsWith('@') && t.length > 1) ownerFilter = t.slice(1);
|
||||
else keywordParts.push(t);
|
||||
}
|
||||
const keyword = keywordParts.join(' ').toLowerCase().trim();
|
||||
|
||||
if (!keyword && !ownerFilter) {
|
||||
chunk(view, [
|
||||
'\n📋 **/decisions — Chronicle 결정 기록 검색**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/decisions <키워드>` — 키워드로 ADR 검색',
|
||||
' `/decisions @<담당자>` — 담당자 언급 결정만',
|
||||
' `/decisions <키워드> @<담당자>` — 둘 다 만족',
|
||||
'',
|
||||
'예시:',
|
||||
' `/decisions 환불 정책`',
|
||||
' `/decisions @기획자`',
|
||||
' `/decisions 결제 흐름 @개발`',
|
||||
'',
|
||||
'Chronicle ADR 파일 (`<recordRoot>/decisions/ADR-NNNN-*.md`) 을 스캔합니다. 최신순 정렬, 최대 20건.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
const store = new ChronicleProjectStore(context);
|
||||
const profiles = store.getAll();
|
||||
if (profiles.length === 0) {
|
||||
chunk(view, '\n❌ Chronicle 프로젝트가 없습니다. workspace 폴더를 열고 사이드바에서 chronicle 활성화하세요.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
interface Hit { project: string; file: string; filePath: string; mtime: number; title: string; snippet: string; }
|
||||
const hits: Hit[] = [];
|
||||
|
||||
for (const profile of profiles) {
|
||||
const decisionsDir = path.join(profile.recordRoot, 'decisions');
|
||||
if (!fs.existsSync(decisionsDir)) continue;
|
||||
let fileNames: string[] = [];
|
||||
try { fileNames = fs.readdirSync(decisionsDir); } catch { continue; }
|
||||
for (const fileName of fileNames) {
|
||||
if (!fileName.endsWith('.md') || !fileName.startsWith('ADR-')) continue;
|
||||
const filePath = path.join(decisionsDir, fileName);
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(filePath, 'utf-8'); } catch { continue; }
|
||||
const lower = content.toLowerCase();
|
||||
|
||||
if (keyword && !lower.includes(keyword)) continue;
|
||||
if (ownerFilter && !content.includes(ownerFilter)) continue;
|
||||
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : fileName.replace(/\.md$/, '');
|
||||
|
||||
let snippet = '';
|
||||
if (keyword) {
|
||||
const idx = lower.indexOf(keyword);
|
||||
if (idx >= 0) {
|
||||
const start = Math.max(0, idx - 60);
|
||||
const end = Math.min(content.length, idx + 180);
|
||||
snippet = content.slice(start, end).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
} else {
|
||||
const paragraphs = content.split(/\n\s*\n/).map(p => p.trim()).filter(p => p && !p.startsWith('#') && !p.startsWith('>'));
|
||||
snippet = (paragraphs[0] || '').slice(0, 220).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(filePath).mtimeMs; } catch { /* keep 0 */ }
|
||||
hits.push({ project: profile.projectName, file: fileName, filePath, mtime, title, snippet });
|
||||
}
|
||||
}
|
||||
|
||||
if (hits.length === 0) {
|
||||
const filterDesc = [keyword && `키워드 "${keyword}"`, ownerFilter && `@${ownerFilter}`].filter(Boolean).join(' + ');
|
||||
chunk(view, `\nℹ️ ${filterDesc} 에 매치되는 결정 기록 없음. (검색 대상: ${profiles.length}개 프로젝트)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
hits.sort((a, b) => b.mtime - a.mtime);
|
||||
const filterDesc = [keyword && `키워드: ${keyword}`, ownerFilter && `담당: @${ownerFilter}`].filter(Boolean).join(' · ');
|
||||
chunk(view, `\n📋 **결정 검색 결과 ${hits.length}건** (${filterDesc})\n\n`);
|
||||
const MAX_SHOW = 20;
|
||||
for (const h of hits.slice(0, MAX_SHOW)) {
|
||||
const date = h.mtime ? new Date(h.mtime).toISOString().slice(0, 10) : '날짜 미상';
|
||||
chunk(view, `### ${h.title}\n`);
|
||||
chunk(view, `- 📅 ${date} · 📁 ${h.project} · \`${h.file}\`\n`);
|
||||
if (h.snippet) chunk(view, `- 💬 …${h.snippet}…\n`);
|
||||
chunk(view, `- 🔗 \`${h.filePath}\`\n\n`);
|
||||
}
|
||||
if (hits.length > MAX_SHOW) chunk(view, `_…+${hits.length - MAX_SHOW}건 더 (필터를 좁히면 줄어듭니다)_\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /onesie — 멤버별 1:1 미팅 준비 카드 ─────────────────────────────────
|
||||
|
||||
async function runOnesie(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /onesie 실행 불가.\n'); return true; }
|
||||
const memberRaw = arg.trim().split(/\s+/)[0] || '';
|
||||
const member = memberRaw.replace(/^@/, '').trim();
|
||||
if (!member) {
|
||||
chunk(view, [
|
||||
'\n📋 **/onesie [멤버] — 1:1 미팅 준비 카드**',
|
||||
'',
|
||||
'사용법: `/onesie <담당자>` 또는 `/onesie @<담당자>`',
|
||||
'예: `/onesie 기획자` · `/onesie @디자이너` · `/onesie 개발`',
|
||||
'',
|
||||
'대상자의 Tasks 진행 상황(완료/지연/다가오는)과 최근 Chronicle 결정 기록을 모아 1:1 준비 카드 생성. 자동 대화 토픽 제안 포함.',
|
||||
'',
|
||||
'※ `/task @<멤버> ...` 로 task 를 등록해 두면 자동으로 잡힙니다. `/meet` 액션 아이템도 owner 가 있으면 잡힘.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📋 **1:1 준비 카드 — @${member}**\n`);
|
||||
|
||||
chunk(view, '\n📥 Tasks 가져오는 중...\n');
|
||||
const taskResult = await listTasks(context, { showCompleted: true, maxResults: 200 });
|
||||
const allTasks = taskResult.ok ? taskResult.tasks : [];
|
||||
if (!taskResult.ok) chunk(view, `\n⚠️ Tasks 조회 실패: ${taskResult.error}\n (Chronicle 검색은 계속 진행)\n`);
|
||||
|
||||
const memberPrefix = `[${member}]`;
|
||||
const memberTasks = allTasks.filter((t) =>
|
||||
t.title.includes(memberPrefix)
|
||||
|| (t.notes || '').includes(`@${member}`)
|
||||
|| (t.notes || '').includes(`담당: ${member}`),
|
||||
);
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const thirtyDaysAgoIso = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const completedRecent = memberTasks
|
||||
.filter((t) => t.status === 'completed' && (t.completed || '') >= thirtyDaysAgoIso)
|
||||
.sort((a, b) => (b.completed || '').localeCompare(a.completed || ''));
|
||||
const overdue = memberTasks
|
||||
.filter((t) => t.status === 'needsAction' && t.due && t.due < today)
|
||||
.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
|
||||
const upcoming = memberTasks
|
||||
.filter((t) => t.status === 'needsAction' && t.due && t.due >= today)
|
||||
.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
|
||||
const noDate = memberTasks.filter((t) => t.status === 'needsAction' && !t.due);
|
||||
|
||||
const store = new ChronicleProjectStore(context);
|
||||
const profiles = store.getAll();
|
||||
interface AdrHit { date: string; mtime: number; title: string; file: string; project: string; }
|
||||
const adrHits: AdrHit[] = [];
|
||||
for (const profile of profiles) {
|
||||
const decisionsDir = path.join(profile.recordRoot, 'decisions');
|
||||
if (!fs.existsSync(decisionsDir)) continue;
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(decisionsDir); } catch { continue; }
|
||||
for (const fn of names) {
|
||||
if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
|
||||
const fp = path.join(decisionsDir, fn);
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
||||
if (!content.includes(member)) continue;
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(fp).mtimeMs; } catch { /* keep 0 */ }
|
||||
const date = mtime ? new Date(mtime).toISOString().slice(0, 10) : '';
|
||||
adrHits.push({ date, mtime, title, file: fn, project: profile.projectName });
|
||||
}
|
||||
}
|
||||
adrHits.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
chunk(view, `\n## 최근 30일 완료 (${completedRecent.length}건)\n`);
|
||||
if (completedRecent.length === 0) chunk(view, '_없음_\n');
|
||||
else for (const t of completedRecent.slice(0, 10)) {
|
||||
const d = (t.completed || '').slice(0, 10);
|
||||
chunk(view, `- ✅ ${d} — ${t.title}\n`);
|
||||
}
|
||||
if (completedRecent.length > 10) chunk(view, `_…+${completedRecent.length - 10}건 더_\n`);
|
||||
|
||||
chunk(view, `\n## 지연 (${overdue.length}건)\n`);
|
||||
if (overdue.length === 0) chunk(view, '_없음_\n');
|
||||
else for (const t of overdue) chunk(view, `- 🔴 ${t.due} (마감 지남) — ${t.title}\n`);
|
||||
|
||||
chunk(view, `\n## 진행 중 / 다가오는 (${upcoming.length}건)\n`);
|
||||
if (upcoming.length === 0) chunk(view, '_없음_\n');
|
||||
else for (const t of upcoming.slice(0, 10)) chunk(view, `- 🟡 ${t.due} — ${t.title}\n`);
|
||||
if (upcoming.length > 10) chunk(view, `_…+${upcoming.length - 10}건 더_\n`);
|
||||
|
||||
if (noDate.length > 0) {
|
||||
chunk(view, `\n## 마감일 없음 (${noDate.length}건)\n`);
|
||||
for (const t of noDate.slice(0, 5)) chunk(view, `- ⚪ ${t.title}\n`);
|
||||
if (noDate.length > 5) chunk(view, `_…+${noDate.length - 5}건 더_\n`);
|
||||
}
|
||||
|
||||
chunk(view, `\n## 최근 결정 — @${member} 언급 (${adrHits.length}건)\n`);
|
||||
if (adrHits.length === 0) chunk(view, '_없음_\n');
|
||||
else for (const h of adrHits.slice(0, 5)) chunk(view, `- 📋 ${h.date} — ${h.title} (\`${h.file}\`)\n`);
|
||||
|
||||
const topics: string[] = [];
|
||||
if (overdue.length > 0) topics.push(`🔴 지연 ${overdue.length}건 블로커 확인 — 무엇이 막혔나, 도와줄 일은`);
|
||||
if (upcoming.length > 5) topics.push(`🟡 다가오는 마감 ${upcoming.length}건 — 우선순위 합의·과부하 여부`);
|
||||
if (completedRecent.length > 5) topics.push(`✅ 최근 완료 ${completedRecent.length}건 많음 — 회고 / 잘 된 점 / 패턴`);
|
||||
else if (completedRecent.length === 0 && memberTasks.length > 0) topics.push('⚠️ 최근 30일 완료 0건 — 어떤 일에 시간을 쓰고 있는지 확인');
|
||||
else if (memberTasks.length === 0) topics.push('⚠️ 등록된 Task 자체가 0 — 일하는 게 안 보임. owner 태깅 시작 필요');
|
||||
if (noDate.length > 3) topics.push(`⚪ 마감일 없는 작업 ${noDate.length}건 — 우선순위 합의로 마감 부여`);
|
||||
if (adrHits.length > 0) topics.push(`📋 최근 결정 (${adrHits[0].title.slice(0, 40)}…) — 이해·실행 상황 확인`);
|
||||
|
||||
chunk(view, `\n## 💬 1:1 대화 토픽 제안\n`);
|
||||
if (topics.length === 0) chunk(view, '_특이사항 없음 — 일반 안부 + 다음 주 우선순위 정도_\n');
|
||||
else for (const t of topics) chunk(view, `- ${t}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /blocked — 전사 across 지연·블로커 한 화면 ──────────────────────────
|
||||
|
||||
async function runBlocked(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /blocked 실행 불가.\n'); return true; }
|
||||
|
||||
const memberFilter = (arg.trim().split(/\s+/)[0] || '').replace(/^@/, '').trim() || undefined;
|
||||
|
||||
chunk(view, `\n🚨 **전사 블로커·지연 뷰**${memberFilter ? ` — @${memberFilter}` : ''}\n`);
|
||||
chunk(view, '\n📥 Tasks 가져오는 중...\n');
|
||||
|
||||
const result = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (!result.ok) { chunk(view, `\n❌ Tasks 조회 실패: ${result.error}\n`); return true; }
|
||||
const tasks = result.tasks;
|
||||
|
||||
interface Row { due?: string; owner?: string; title: string; }
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
const overdue: Row[] = [];
|
||||
const thisWeek: Row[] = [];
|
||||
const noDate: Row[] = [];
|
||||
|
||||
for (const t of tasks) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
if (memberFilter && (owner || '').toLowerCase() !== memberFilter.toLowerCase()) continue;
|
||||
const row: Row = { due: t.due, owner, title: displayTitle };
|
||||
if (!t.due) noDate.push(row);
|
||||
else if (t.due < today) overdue.push(row);
|
||||
else if (t.due <= weekLater) thisWeek.push(row);
|
||||
}
|
||||
overdue.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
|
||||
thisWeek.sort((a, b) => (a.due || '').localeCompare(b.due || ''));
|
||||
noDate.sort((a, b) => (a.owner || 'zzz').localeCompare(b.owner || 'zzz'));
|
||||
|
||||
const fmtRow = (r: Row): string => {
|
||||
const o = r.owner ? `@${r.owner}` : '(owner 없음)';
|
||||
return `- 📅 \`${r.due || '----------'}\` · **${o}** — ${r.title}`;
|
||||
};
|
||||
|
||||
const totalShown = overdue.length + thisWeek.length + noDate.length;
|
||||
if (totalShown === 0) {
|
||||
chunk(view, `\n✅ 지연·임박 항목 없음${memberFilter ? ` (@${memberFilter})` : ''}. 진행 상황 양호.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (overdue.length > 0) {
|
||||
chunk(view, `\n## 🔴 지연 ${overdue.length}건 — 즉시 확인 필요\n`);
|
||||
const MAX_OVERDUE = 20;
|
||||
for (const r of overdue.slice(0, MAX_OVERDUE)) chunk(view, fmtRow(r) + '\n');
|
||||
if (overdue.length > MAX_OVERDUE) chunk(view, `_…+${overdue.length - MAX_OVERDUE}건 더_\n`);
|
||||
}
|
||||
if (thisWeek.length > 0) {
|
||||
chunk(view, `\n## 🟡 이번 주 마감 ${thisWeek.length}건 (~${weekLater})\n`);
|
||||
const MAX_WEEK = 15;
|
||||
for (const r of thisWeek.slice(0, MAX_WEEK)) chunk(view, fmtRow(r) + '\n');
|
||||
if (thisWeek.length > MAX_WEEK) chunk(view, `_…+${thisWeek.length - MAX_WEEK}건 더_\n`);
|
||||
}
|
||||
if (noDate.length > 0) {
|
||||
chunk(view, `\n## ⚪ 마감일 없음 ${noDate.length}건 — 우선순위 합의 필요\n`);
|
||||
const MAX_NODATE = 10;
|
||||
for (const r of noDate.slice(0, MAX_NODATE)) chunk(view, fmtRow(r) + '\n');
|
||||
if (noDate.length > MAX_NODATE) chunk(view, `_…+${noDate.length - MAX_NODATE}건 더_\n`);
|
||||
}
|
||||
|
||||
if (!memberFilter && (overdue.length + thisWeek.length) > 0) {
|
||||
const counts = new Map<string, { overdue: number; week: number }>();
|
||||
for (const r of overdue) {
|
||||
const k = r.owner || '(없음)';
|
||||
const c = counts.get(k) || { overdue: 0, week: 0 };
|
||||
c.overdue++;
|
||||
counts.set(k, c);
|
||||
}
|
||||
for (const r of thisWeek) {
|
||||
const k = r.owner || '(없음)';
|
||||
const c = counts.get(k) || { overdue: 0, week: 0 };
|
||||
c.week++;
|
||||
counts.set(k, c);
|
||||
}
|
||||
const ranked = [...counts.entries()]
|
||||
.sort((a, b) => (b[1].overdue * 2 + b[1].week) - (a[1].overdue * 2 + a[1].week));
|
||||
chunk(view, `\n## 📊 멤버별 압박 ${ranked.length}명\n`);
|
||||
for (const [member, c] of ranked) {
|
||||
chunk(view, `- **@${member}** — 지연 ${c.overdue}건${c.week ? ` · 이번 주 ${c.week}건` : ''}\n`);
|
||||
}
|
||||
chunk(view, '\n💡 압박 큰 멤버부터 `/onesie @<멤버>` 로 1:1 카드 확인 권장.\n');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /standup — 팀 스탠드업 카드 (슬랙 복붙 친화) ────────────────────────
|
||||
|
||||
async function runStandup(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /standup 실행 불가.\n'); return true; }
|
||||
|
||||
const mode = (arg.trim().split(/\s+/)[0] || 'weekly').toLowerCase();
|
||||
const windowDays = mode === 'daily' ? 1 : mode === 'monthly' ? 30 : 7;
|
||||
const modeLabel = mode === 'daily' ? '일일' : mode === 'monthly' ? '월간' : '주간';
|
||||
|
||||
if (arg.trim() && !['daily', 'weekly', 'monthly', ''].includes(mode)) {
|
||||
chunk(view, [
|
||||
'\n📋 **/standup [daily/weekly/monthly] — 팀 스탠드업 카드**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/standup` — 주간 (기본, 7일 윈도우)',
|
||||
' `/standup daily` — 일일 (1일 윈도우)',
|
||||
' `/standup weekly` — 주간 (7일)',
|
||||
' `/standup monthly` — 월간 (30일)',
|
||||
'',
|
||||
'멤버별로 완료 / 진행·예정 / 블로커 3-row + 이번 기간 결정 목록을 슬랙·노션에 복붙 가능한 마크다운으로 출력.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📊 **팀 스탠드업 — ${modeLabel} (${windowDays}일 윈도우)**\n · ${new Date().toISOString().slice(0, 10)} 기준\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 today = new Date().toISOString().slice(0, 10);
|
||||
const windowAgoIso = new Date(Date.now() - windowDays * 24 * 60 * 60 * 1000).toISOString();
|
||||
const upcomingEnd = new Date(Date.now() + windowDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
|
||||
interface MemberSlot { completed: { date: string; title: string }[]; upcoming: { due: string; title: string }[]; overdue: { due: string; title: string }[]; noDate: { title: string }[]; }
|
||||
const byMember = new Map<string, MemberSlot>();
|
||||
const ensure = (k: string): MemberSlot => {
|
||||
let s = byMember.get(k);
|
||||
if (!s) { s = { completed: [], upcoming: [], overdue: [], noDate: [] }; byMember.set(k, s); }
|
||||
return s;
|
||||
};
|
||||
|
||||
for (const t of result.tasks) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
const member = owner || '(owner 없음)';
|
||||
const slot = ensure(member);
|
||||
if (t.status === 'completed') {
|
||||
if ((t.completed || '') >= windowAgoIso) {
|
||||
slot.completed.push({ date: (t.completed || '').slice(0, 10), title: displayTitle });
|
||||
}
|
||||
} else {
|
||||
if (!t.due) slot.noDate.push({ title: displayTitle });
|
||||
else if (t.due < today) slot.overdue.push({ due: t.due, title: displayTitle });
|
||||
else if (t.due <= upcomingEnd) slot.upcoming.push({ due: t.due, title: displayTitle });
|
||||
}
|
||||
}
|
||||
for (const s of byMember.values()) {
|
||||
s.completed.sort((a, b) => b.date.localeCompare(a.date));
|
||||
s.upcoming.sort((a, b) => a.due.localeCompare(b.due));
|
||||
s.overdue.sort((a, b) => a.due.localeCompare(b.due));
|
||||
}
|
||||
|
||||
if (byMember.size === 0) {
|
||||
chunk(view, '\nℹ️ 이 기간에 활동이 있는 task 가 없습니다. `/task @<멤버> ...` 로 등록 시작.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
const store = new ChronicleProjectStore(context);
|
||||
const profiles = store.getAll();
|
||||
interface RecentAdr { date: string; title: string; file: string; mtime: number; }
|
||||
const recentAdrs: RecentAdr[] = [];
|
||||
const windowAgoMs = Date.now() - windowDays * 24 * 60 * 60 * 1000;
|
||||
for (const profile of profiles) {
|
||||
const decisionsDir = path.join(profile.recordRoot, 'decisions');
|
||||
if (!fs.existsSync(decisionsDir)) continue;
|
||||
let names: string[] = [];
|
||||
try { names = fs.readdirSync(decisionsDir); } catch { continue; }
|
||||
for (const fn of names) {
|
||||
if (!fn.endsWith('.md') || !fn.startsWith('ADR-')) continue;
|
||||
const fp = path.join(decisionsDir, fn);
|
||||
let mtime = 0;
|
||||
try { mtime = fs.statSync(fp).mtimeMs; } catch { continue; }
|
||||
if (mtime < windowAgoMs) continue;
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
||||
const titleMatch = content.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1].trim() : fn.replace(/\.md$/, '');
|
||||
recentAdrs.push({ date: new Date(mtime).toISOString().slice(0, 10), title, file: fn, mtime });
|
||||
}
|
||||
}
|
||||
recentAdrs.sort((a, b) => b.mtime - a.mtime);
|
||||
|
||||
const memberRanked = [...byMember.entries()].sort((a, b) => {
|
||||
const score = (s: MemberSlot) => s.completed.length + s.upcoming.length + s.overdue.length * 2;
|
||||
return score(b[1]) - score(a[1]);
|
||||
});
|
||||
|
||||
chunk(view, '\n---\n');
|
||||
chunk(view, `\n## 📊 팀 스탠드업 — ${modeLabel}\n`);
|
||||
chunk(view, `*${windowDays}일 윈도우 · 기준일 ${today}*\n`);
|
||||
|
||||
for (const [member, s] of memberRanked) {
|
||||
chunk(view, `\n### @${member}\n`);
|
||||
|
||||
if (s.completed.length === 0) chunk(view, `**✅ 완료**: _없음_\n`);
|
||||
else {
|
||||
chunk(view, `**✅ 완료 (${s.completed.length})**:\n`);
|
||||
const MAX = 8;
|
||||
for (const c of s.completed.slice(0, MAX)) chunk(view, `- ${c.date} — ${c.title}\n`);
|
||||
if (s.completed.length > MAX) chunk(view, `- _…+${s.completed.length - MAX}건_\n`);
|
||||
}
|
||||
|
||||
if (s.upcoming.length === 0 && s.noDate.length === 0) chunk(view, `**🎯 진행/예정**: _없음_\n`);
|
||||
else {
|
||||
chunk(view, `**🎯 진행/예정 (${s.upcoming.length + s.noDate.length})**:\n`);
|
||||
const MAX = 6;
|
||||
for (const u of s.upcoming.slice(0, MAX)) chunk(view, `- ${u.due} — ${u.title}\n`);
|
||||
for (const n of s.noDate.slice(0, Math.max(0, MAX - s.upcoming.length))) chunk(view, `- (마감 미정) — ${n.title}\n`);
|
||||
const total = s.upcoming.length + s.noDate.length;
|
||||
if (total > MAX) chunk(view, `- _…+${total - MAX}건_\n`);
|
||||
}
|
||||
|
||||
if (s.overdue.length === 0) chunk(view, `**🚧 블로커**: _없음_\n`);
|
||||
else {
|
||||
chunk(view, `**🚧 블로커 (${s.overdue.length}건 지연)**:\n`);
|
||||
for (const o of s.overdue.slice(0, 5)) chunk(view, `- 🔴 ${o.due} (지남) — ${o.title}\n`);
|
||||
if (s.overdue.length > 5) chunk(view, `- _…+${s.overdue.length - 5}건_\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (recentAdrs.length > 0) {
|
||||
chunk(view, `\n---\n\n## 📋 이번 ${modeLabel} 결정 (${recentAdrs.length}건)\n`);
|
||||
for (const a of recentAdrs.slice(0, 10)) chunk(view, `- ${a.date} — ${a.title}\n`);
|
||||
if (recentAdrs.length > 10) chunk(view, `- _…+${recentAdrs.length - 10}건_\n`);
|
||||
}
|
||||
|
||||
chunk(view, '\n---\n\n💡 위 마크다운을 그대로 슬랙·노션에 복붙하세요. 멤버 활동 순 정렬.\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask });
|
||||
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 });
|
||||
registerSlashCommand({ name: '/standup', description: '팀 스탠드업 카드 (멤버별 완료/진행/블로커, 슬랙 복붙 친화)', handler: runStandup });
|
||||
@@ -0,0 +1,806 @@
|
||||
/**
|
||||
* TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬).
|
||||
*
|
||||
* v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers +
|
||||
* hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공.
|
||||
*
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek,
|
||||
* _morningActions 등) 는 이 파일 안에.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||||
import {
|
||||
fmtKrw as _fmtKrw, daysUntil as _daysUntil,
|
||||
parseTaskOwner, stageEmoji as _stageEmoji,
|
||||
STAGE_ORDER, TERMINAL_STAGES,
|
||||
} from './_shared';
|
||||
import { listTasks } from '../../calendar';
|
||||
import { ChronicleProjectStore } from '../../../sidebar/managers/chronicleProjectStore';
|
||||
import {
|
||||
computeRunwayStatus, readRunway, type RunwayEntry,
|
||||
} from '../../runway/runwayStore';
|
||||
import {
|
||||
computeCustomerStates, readEvents as readCustomerEvents,
|
||||
type CustomerEvent, type CustomerState,
|
||||
} from '../../customers/customersStore';
|
||||
import {
|
||||
computeCandidateStates, readHireEvents,
|
||||
type HireEvent, type CandidateState,
|
||||
} from '../../hire/hireStore';
|
||||
|
||||
// ─── /morning — 매일 아침 통합 대시보드 ──────────────────────────────────
|
||||
|
||||
async function runMorning(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const mode = (arg.trim().split(/\s+/)[0] || '').toLowerCase();
|
||||
const brief = mode === 'brief' || mode === 'short';
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
chunk(view, `\n☀️ **오늘 (${today}) — 통합 대시보드**\n`);
|
||||
|
||||
const runway = computeRunwayStatus();
|
||||
const customerStates = computeCustomerStates();
|
||||
const customers = Array.from(customerStates.values());
|
||||
const candidateStates = computeCandidateStates();
|
||||
const candidates = Array.from(candidateStates.values());
|
||||
|
||||
let tasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) tasks = res.tasks;
|
||||
else tasksError = res.error;
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const urgent: string[] = [];
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
|
||||
urgent.push(`🔴 **런웨이 ${runway.runwayMonths.toFixed(1)}개월** — 즉시 자금 조달/절감 필요`);
|
||||
} else if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 6) {
|
||||
urgent.push(`🟡 런웨이 ${runway.runwayMonths.toFixed(1)}개월 — 자금 계획 점검 권장`);
|
||||
}
|
||||
const atRiskCustomers = customers.filter((c) => c.status === 'at-risk');
|
||||
const atRiskMrr = atRiskCustomers.reduce((s, c) => s + c.mrr, 0);
|
||||
if (atRiskCustomers.length > 0) {
|
||||
urgent.push(`⚠️ 위험 고객 **${atRiskCustomers.length}곳** (MRR ${_fmtKrw(atRiskMrr)}원/월 노출)`);
|
||||
}
|
||||
const upcomingRenewals = [...customers].filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
|
||||
if (upcomingRenewals.length > 0) urgent.push(`🔔 7일 내 갱신 **${upcomingRenewals.length}건**`);
|
||||
const overdueTasks = tasks.filter((t) => t.due && t.due < today);
|
||||
if (overdueTasks.length > 0) urgent.push(`🚧 지연 작업 **${overdueTasks.length}건**`);
|
||||
const activeCandidate = candidates.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||||
const stalled = activeCandidate.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
|
||||
if (stalled.length > 0) urgent.push(`⏰ 정체 후보 **${stalled.length}명** (7일+ 미변동)`);
|
||||
|
||||
if (urgent.length === 0) chunk(view, '\n## ✅ 긴급 알림 없음\n');
|
||||
else {
|
||||
chunk(view, `\n## 🚨 긴급 (${urgent.length}건)\n`);
|
||||
for (const u of urgent) chunk(view, `- ${u}\n`);
|
||||
}
|
||||
|
||||
if (brief) {
|
||||
chunk(view, '\n## 📋 오늘의 액션 (top 3)\n');
|
||||
const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
|
||||
for (const a of actions.slice(0, 3)) chunk(view, `- ${a}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, '\n## 💰 재무\n');
|
||||
if (runway.latestCash === null) {
|
||||
chunk(view, '- _데이터 없음_ — `/runway cash <금액>` 으로 시작\n');
|
||||
} else {
|
||||
chunk(view, `- 현금 **${_fmtKrw(runway.latestCash)}원** _(${(runway.latestCashAt || '').slice(0, 10)})_\n`);
|
||||
if (runway.effectiveBurn !== null) {
|
||||
chunk(view, `- 월 burn ${_fmtKrw(runway.effectiveBurn)}원 ${runway.explicitBurn !== null ? '_(수동)_' : '_(30일 실적)_'}\n`);
|
||||
}
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths)) {
|
||||
const emoji = runway.runwayMonths < 3 ? '🔴' : runway.runwayMonths < 6 ? '🟡' : '🟢';
|
||||
chunk(view, `- 런웨이 ${emoji} **${runway.runwayMonths.toFixed(1)}개월**\n`);
|
||||
} else if (runway.runwayMonths !== null) {
|
||||
chunk(view, '- 런웨이 ♾️ 흑자 운영\n');
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 📒 고객\n');
|
||||
if (customers.length === 0) {
|
||||
chunk(view, '- _데이터 없음_ — `/customers add` 로 시작\n');
|
||||
} else {
|
||||
const active = customers.filter((c) => c.status === 'active');
|
||||
const totalMrr = [...active, ...atRiskCustomers].reduce((s, c) => s + c.mrr, 0);
|
||||
chunk(view, `- 총 MRR **${_fmtKrw(totalMrr)}원/월** _(연 ${_fmtKrw(totalMrr * 12)}원)_\n`);
|
||||
chunk(view, `- 활성 ${active.length} · 위험 ${atRiskCustomers.length} · 이탈 ${customers.length - active.length - atRiskCustomers.length}\n`);
|
||||
if (upcomingRenewals.length > 0) {
|
||||
for (const { c, days } of upcomingRenewals.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 3 ? '🔴' : '🟡';
|
||||
chunk(view, ` - ${emoji} ${c.customerName} — D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 👥 팀\n');
|
||||
if (tasksError) {
|
||||
chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
} else if (tasks.length === 0) {
|
||||
chunk(view, '- _Tasks 없음_ — `/task` 로 등록 시작\n');
|
||||
} else {
|
||||
const memberOverdue = new Map<string, number>();
|
||||
const weekLater = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
let weekCount = 0;
|
||||
for (const t of tasks) {
|
||||
const { owner } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
if (t.due && t.due < today) memberOverdue.set(k, (memberOverdue.get(k) || 0) + 1);
|
||||
if (t.due && t.due >= today && t.due <= weekLater) weekCount++;
|
||||
}
|
||||
chunk(view, `- 지연 ${overdueTasks.length}건 · 이번 주 ${weekCount}건 (전체 ${tasks.length})\n`);
|
||||
if (memberOverdue.size > 0) {
|
||||
const ranked = [...memberOverdue.entries()].sort((a, b) => b[1] - a[1]).slice(0, 4);
|
||||
for (const [member, n] of ranked) chunk(view, ` - **@${member}** 지연 ${n}건\n`);
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 🎯 채용\n');
|
||||
if (candidates.length === 0) {
|
||||
chunk(view, '- _데이터 없음_\n');
|
||||
} else {
|
||||
const hired = candidates.filter((c) => c.stage === 'hired').length;
|
||||
chunk(view, `- 진행 중 ${activeCandidate.length}명 · 합격 ${hired}\n`);
|
||||
const stageCount = new Map<string, number>();
|
||||
for (const c of activeCandidate) stageCount.set(c.stage, (stageCount.get(c.stage) || 0) + 1);
|
||||
const stages = [...stageCount.entries()].sort((a, b) => (STAGE_ORDER[a[0]] ?? 50) - (STAGE_ORDER[b[0]] ?? 50));
|
||||
if (stages.length > 0) {
|
||||
const parts = stages.map(([s, n]) => `${_stageEmoji(s)} ${s} ${n}`);
|
||||
chunk(view, ` - ${parts.join(' · ')}\n`);
|
||||
}
|
||||
if (stalled.length > 0) {
|
||||
chunk(view, `- ⏰ 정체 ${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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 📋 오늘의 액션\n');
|
||||
const actions = _morningActions(runway, customers, upcomingRenewals, overdueTasks, stalled, candidates);
|
||||
if (actions.length === 0) {
|
||||
chunk(view, '- ✨ 특별한 조치 필요 없음. 깊은 작업 시간 확보 권장.\n');
|
||||
} else {
|
||||
for (const a of actions.slice(0, 5)) chunk(view, `- ${a}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function _morningActions(
|
||||
runway: ReturnType<typeof computeRunwayStatus>,
|
||||
customers: CustomerState[],
|
||||
upcomingRenewals: Array<{ c: CustomerState; days: number | null }>,
|
||||
overdueTasks: any[],
|
||||
stalled: CandidateState[],
|
||||
candidates: CandidateState[],
|
||||
): string[] {
|
||||
const actions: string[] = [];
|
||||
if (runway.runwayMonths !== null && Number.isFinite(runway.runwayMonths) && runway.runwayMonths < 3) {
|
||||
actions.push(`💸 **자금 조달 계획** — 런웨이 ${runway.runwayMonths.toFixed(1)}개월. 투자자 미팅 / 비용 절감 즉시.`);
|
||||
}
|
||||
const atRisk = customers.filter((c) => c.status === 'at-risk').sort((a, b) => b.mrr - a.mrr);
|
||||
if (atRisk.length > 0) {
|
||||
const top = atRisk[0];
|
||||
actions.push(`📞 **${top.customerName}** 위험 대응 — MRR ${_fmtKrw(top.mrr)}원. 사유 점검 후 액션.`);
|
||||
}
|
||||
if (upcomingRenewals.length > 0) {
|
||||
const next = upcomingRenewals[0];
|
||||
actions.push(`📨 **${next.c.customerName}** 갱신 D-${next.days} — 갱신 의사 확인 / 가격 협의.`);
|
||||
}
|
||||
if (overdueTasks.length >= 5) {
|
||||
actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 멤버별 확인 후 우선순위 재조정.`);
|
||||
} else if (overdueTasks.length > 0) {
|
||||
actions.push(`🚧 지연 작업 ${overdueTasks.length}건 — \`/blocked\` 로 확인.`);
|
||||
}
|
||||
if (stalled.length > 0) {
|
||||
const next = stalled[0];
|
||||
const days = Math.floor((Date.now() - Date.parse(next.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
actions.push(`👥 **${next.candidateName}** 채용 후속 — ${next.stage} 단계 ${days}일 정체.`);
|
||||
}
|
||||
const inboxCount = candidates.filter((c) => c.stage === 'inbox').length;
|
||||
if (inboxCount >= 5) actions.push(`📥 채용 inbox ${inboxCount}명 누적 — 스크리닝 시간 확보.`);
|
||||
return actions;
|
||||
}
|
||||
|
||||
// ─── /evening — 하루 마무리 카드 ─────────────────────────────────────────
|
||||
|
||||
async function runEvening(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const tomorrow = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const dayStartMs = Date.parse(today + 'T00:00:00');
|
||||
|
||||
chunk(view, `\n🌙 **오늘 (${today}) — 마무리 카드**\n`);
|
||||
|
||||
let completedTasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: true, maxResults: 300 });
|
||||
if (res.ok) {
|
||||
completedTasks = res.tasks.filter((t: any) => t.status === 'completed' && t.completed && Date.parse(t.completed) >= dayStartMs);
|
||||
} else { tasksError = res.error; }
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const customerEvents = readCustomerEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
const hireEvents = readHireEvents().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
const runwayToday = readRunway().filter((e) => Date.parse(e.timestamp) >= dayStartMs);
|
||||
|
||||
chunk(view, '\n## ✅ 오늘의 진척\n');
|
||||
const progressEmpty = completedTasks.length === 0 && customerEvents.length === 0 && hireEvents.length === 0 && runwayToday.length === 0;
|
||||
if (progressEmpty) {
|
||||
chunk(view, '- _기록된 진척 없음._ (작업 완료 / 고객 이벤트 / 채용 이동 등이 오늘 입력되지 않음)\n');
|
||||
if (tasksError) chunk(view, ` _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
} else {
|
||||
if (completedTasks.length > 0) {
|
||||
chunk(view, `\n### 작업 완료 (${completedTasks.length}건)\n`);
|
||||
const byOwner = new Map<string, any[]>();
|
||||
for (const t of completedTasks) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
if (!byOwner.has(k)) byOwner.set(k, []);
|
||||
byOwner.get(k)!.push({ title: displayTitle });
|
||||
}
|
||||
const ranked = [...byOwner.entries()].sort((a, b) => b[1].length - a[1].length);
|
||||
for (const [owner, list] of ranked) {
|
||||
chunk(view, `- **@${owner}** (${list.length}건)\n`);
|
||||
for (const t of list.slice(0, 5)) chunk(view, ` - ${t.title}\n`);
|
||||
if (list.length > 5) chunk(view, ` - _…+${list.length - 5}건_\n`);
|
||||
}
|
||||
}
|
||||
if (customerEvents.length > 0) {
|
||||
chunk(view, `\n### 📒 고객 이벤트 (${customerEvents.length}건)\n`);
|
||||
for (const e of customerEvents.slice(0, 10)) {
|
||||
const tagEmoji = e.type === 'add' ? '➕' : e.type === 'renew' ? '🔄' : e.type === 'risk' ? '⚠️' : e.type === 'churn' ? '💀' : '📝';
|
||||
const detail = e.type === 'add' || e.type === 'renew' || e.type === 'update'
|
||||
? (e.mrr !== undefined ? ` MRR ${_fmtKrw(e.mrr)}원` : '')
|
||||
: (e.memo ? ` — ${e.memo.slice(0, 60)}` : '');
|
||||
chunk(view, `- ${tagEmoji} ${e.customerName} ${e.type}${detail}\n`);
|
||||
}
|
||||
}
|
||||
if (hireEvents.length > 0) {
|
||||
chunk(view, `\n### 🎯 채용 이벤트 (${hireEvents.length}건)\n`);
|
||||
for (const e of hireEvents.slice(0, 10)) {
|
||||
const stageNote = e.stage ? ` → ${e.stage}` : '';
|
||||
const memo = e.memo ? ` — ${e.memo.slice(0, 60)}` : '';
|
||||
chunk(view, `- ${e.candidateName} ${e.type}${stageNote}${memo}\n`);
|
||||
}
|
||||
}
|
||||
if (runwayToday.length > 0) {
|
||||
chunk(view, `\n### 💰 재무 기록 (${runwayToday.length}건)\n`);
|
||||
for (const e of runwayToday) {
|
||||
const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
|
||||
chunk(view, `- ${typeLabel}: ${_fmtKrw(e.amount)}원${e.memo ? ` — ${e.memo}` : ''}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, '\n## 🌅 내일 준비\n');
|
||||
let tomorrowTasks: any[] = [];
|
||||
if (context && !tasksError) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) tomorrowTasks = res.tasks.filter((t: any) => t.due === tomorrow);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (tomorrowTasks.length > 0) {
|
||||
chunk(view, `\n### 내일 마감 (${tomorrowTasks.length}건)\n`);
|
||||
for (const t of tomorrowTasks.slice(0, 8)) {
|
||||
const { owner, displayTitle } = parseTaskOwner(t.title, t.notes);
|
||||
chunk(view, `- **@${owner || '(미지정)'}** — ${displayTitle}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const customers = Array.from(computeCustomerStates().values());
|
||||
const upcomingRenewals = customers.filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 7);
|
||||
if (upcomingRenewals.length > 0) {
|
||||
chunk(view, `\n### 🔔 7일 내 갱신 (${upcomingRenewals.length}건)\n`);
|
||||
for (const { c, days } of upcomingRenewals.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 3 ? '🔴' : '🟡';
|
||||
chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\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`);
|
||||
}
|
||||
}
|
||||
|
||||
if (tomorrowTasks.length === 0 && upcomingRenewals.length === 0 && stalled.length === 0) {
|
||||
chunk(view, '- _내일 마감·갱신 임박·정체 후보 모두 없음._ ✨\n');
|
||||
}
|
||||
|
||||
const reflections = [
|
||||
'오늘 가장 중요한 한 가지는 무엇이었나? 의도한 대로 됐나?',
|
||||
'내일 무엇을 안 하기로 했나? 안 할 일을 정해야 할 일이 또렷해진다.',
|
||||
'오늘 한 결정 중 일주일 뒤에도 옳을 결정은 어느 것인가?',
|
||||
'시간이 가장 많이 든 활동은 가장 영향력 있는 활동과 일치했나?',
|
||||
'오늘 멤버들에게 충분한 명확함을 줬나? 무엇을 미루지 않고 답해야 하나?',
|
||||
'에너지가 가장 좋았던 30분은 무엇을 하던 때였나?',
|
||||
'오늘 안 한 일 중 내일도 안 해도 되는 일은 무엇인가?',
|
||||
'리스크 한 가지를 꼽는다면? 그것에 대해 누구와 이야기해야 하나?',
|
||||
];
|
||||
const idx = (Date.parse(today) / (24 * 60 * 60 * 1000)) % reflections.length;
|
||||
chunk(view, `\n## 🧭 회고\n> ${reflections[idx]}\n`);
|
||||
chunk(view, '\n_명령 한 줄로 기록 남기기:_ `/decisions` · `/feedback` · `/customers note` · `/hire note`\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /cohort — MoM 추세 분석 ─────────────────────────────────────────────
|
||||
|
||||
interface MonthlyBucket {
|
||||
yearMonth: string;
|
||||
newCustomers: number;
|
||||
churnedCustomers: number;
|
||||
renewals: number;
|
||||
mrrDelta: number;
|
||||
expenseTotal: number;
|
||||
revenueTotal: number;
|
||||
cashSnapshots: number[];
|
||||
}
|
||||
|
||||
function _yearMonth(iso: string): string {
|
||||
return (iso || '').slice(0, 7);
|
||||
}
|
||||
|
||||
function _buildMonthlyBuckets(monthsBack: number): Map<string, MonthlyBucket> {
|
||||
const map = new Map<string, MonthlyBucket>();
|
||||
const now = new Date();
|
||||
for (let i = monthsBack - 1; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
|
||||
const ym = d.toISOString().slice(0, 7);
|
||||
map.set(ym, {
|
||||
yearMonth: ym, newCustomers: 0, churnedCustomers: 0, renewals: 0,
|
||||
mrrDelta: 0, expenseTotal: 0, revenueTotal: 0, cashSnapshots: [],
|
||||
});
|
||||
}
|
||||
for (const e of readCustomerEvents()) {
|
||||
const ym = _yearMonth(e.timestamp);
|
||||
const b = map.get(ym);
|
||||
if (!b) continue;
|
||||
if (e.type === 'add') {
|
||||
b.newCustomers++;
|
||||
if (e.mrr) b.mrrDelta += e.mrr;
|
||||
} else if (e.type === 'churn') b.churnedCustomers++;
|
||||
else if (e.type === 'renew') b.renewals++;
|
||||
}
|
||||
for (const e of readRunway()) {
|
||||
const ym = _yearMonth(e.timestamp);
|
||||
const b = map.get(ym);
|
||||
if (!b) continue;
|
||||
if (e.type === 'expense') b.expenseTotal += e.amount;
|
||||
else if (e.type === 'revenue') b.revenueTotal += e.amount;
|
||||
else if (e.type === 'snapshot') b.cashSnapshots.push(e.amount);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function _cohortDashboard(view: any, monthsBack: number): void {
|
||||
const buckets = _buildMonthlyBuckets(monthsBack);
|
||||
if (buckets.size === 0) {
|
||||
chunk(view, '\nℹ️ 데이터 없음. `/customers add` / `/runway cash` 로 시작.\n');
|
||||
return;
|
||||
}
|
||||
const rows = Array.from(buckets.values());
|
||||
chunk(view, `\n📈 **/cohort — 최근 ${monthsBack}개월 추세**\n`);
|
||||
|
||||
chunk(view, '\n## 고객 & MRR 추이\n');
|
||||
chunk(view, '| 월 | 신규 | 갱신 | 이탈 | MRR Δ |\n');
|
||||
chunk(view, '|---|---:|---:|---:|---:|\n');
|
||||
for (const r of rows) {
|
||||
chunk(view, `| ${r.yearMonth} | ${r.newCustomers} | ${r.renewals} | ${r.churnedCustomers} | ${r.mrrDelta > 0 ? '+' : ''}${_fmtKrw(r.mrrDelta)} |\n`);
|
||||
}
|
||||
const totNew = rows.reduce((s, r) => s + r.newCustomers, 0);
|
||||
const totChurn = rows.reduce((s, r) => s + r.churnedCustomers, 0);
|
||||
const totMrr = rows.reduce((s, r) => s + r.mrrDelta, 0);
|
||||
chunk(view, `\n- **누적 ${monthsBack}개월**: 신규 +${totNew} · 이탈 -${totChurn} · 순 ${totNew - totChurn >= 0 ? '+' : ''}${totNew - totChurn}\n`);
|
||||
chunk(view, `- **MRR 순증**: ${totMrr >= 0 ? '+' : ''}${_fmtKrw(totMrr)}원/월 _(추가된 신규 MRR 만, 이탈로 인한 감소는 history 부재로 미반영)_\n`);
|
||||
const avgNew = totNew / monthsBack;
|
||||
const avgChurn = totChurn / monthsBack;
|
||||
if (avgNew > 0 || avgChurn > 0) {
|
||||
chunk(view, `- 월평균 신규 ${avgNew.toFixed(1)}곳, 월평균 이탈 ${avgChurn.toFixed(1)}곳`);
|
||||
if (totNew > 0) chunk(view, ` (이탈/신규 비율 ${((totChurn / totNew) * 100).toFixed(0)}%)\n`);
|
||||
else chunk(view, '\n');
|
||||
}
|
||||
|
||||
chunk(view, '\n## 재무 추이\n');
|
||||
chunk(view, '| 월 | 지출 | 수입 | 순 burn | 월말 잔고 |\n');
|
||||
chunk(view, '|---|---:|---:|---:|---:|\n');
|
||||
for (const r of rows) {
|
||||
const netBurn = r.expenseTotal - r.revenueTotal;
|
||||
const lastCash = r.cashSnapshots.length > 0 ? r.cashSnapshots[r.cashSnapshots.length - 1] : null;
|
||||
const cashCell = lastCash !== null ? _fmtKrw(lastCash) : '-';
|
||||
chunk(view, `| ${r.yearMonth} | ${_fmtKrw(r.expenseTotal)} | ${_fmtKrw(r.revenueTotal)} | ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)} | ${cashCell} |\n`);
|
||||
}
|
||||
const totExp = rows.reduce((s, r) => s + r.expenseTotal, 0);
|
||||
const totRev = rows.reduce((s, r) => s + r.revenueTotal, 0);
|
||||
const totBurn = totExp - totRev;
|
||||
const avgBurn = totBurn / monthsBack;
|
||||
chunk(view, `\n- **${monthsBack}개월 누계**: 지출 ${_fmtKrw(totExp)} · 수입 ${_fmtKrw(totRev)} · 순 burn ${totBurn > 0 ? '+' : ''}${_fmtKrw(totBurn)}\n`);
|
||||
chunk(view, `- **월평균 burn**: ${_fmtKrw(avgBurn)}원/월\n`);
|
||||
|
||||
chunk(view, '\n## 💡 인사이트\n');
|
||||
const insights: string[] = [];
|
||||
if (monthsBack >= 6) {
|
||||
const recent3 = rows.slice(-3);
|
||||
const prior3 = rows.slice(-6, -3);
|
||||
const recentNew = recent3.reduce((s, r) => s + r.newCustomers, 0);
|
||||
const priorNew = prior3.reduce((s, r) => s + r.newCustomers, 0);
|
||||
if (recentNew > priorNew * 1.2) insights.push('🟢 최근 3개월 신규 획득 가속 (이전 3개월 대비 +20%↑)');
|
||||
else if (recentNew < priorNew * 0.8 && priorNew >= 2) insights.push('🟡 최근 3개월 신규 획득 둔화 (이전 3개월 대비 -20%↓)');
|
||||
const recentBurn = recent3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
|
||||
const priorBurn = prior3.reduce((s, r) => s + (r.expenseTotal - r.revenueTotal), 0);
|
||||
if (priorBurn > 0 && recentBurn > priorBurn * 1.3) insights.push('🔴 최근 3개월 burn 가속 (이전 3개월 대비 +30%↑) — 비용 점검 권장');
|
||||
}
|
||||
if (avgBurn > 0 && totRev > 0) {
|
||||
const coverage = totRev / totExp;
|
||||
if (coverage > 0.8) insights.push(`🟢 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 흑자 진입 임박`);
|
||||
else if (coverage < 0.2 && totExp > 0) insights.push(`🟡 매출 커버리지 ${(coverage * 100).toFixed(0)}% — 매출 기반 약함`);
|
||||
}
|
||||
if (insights.length === 0) chunk(view, '- _데이터 부족 또는 추세 신호 약함._ 더 누적되면 인사이트 표시.\n');
|
||||
else for (const i of insights) chunk(view, `- ${i}\n`);
|
||||
|
||||
chunk(view, '\n_데이터 출처: `.astra/customers.jsonl` + `.astra/runway.jsonl`. 더 많은 이벤트 누적 시 추세 정확도↑._\n');
|
||||
}
|
||||
|
||||
async function runCohort(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
if (trimmed === 'help' || trimmed === '?') {
|
||||
chunk(view, [
|
||||
'\n📈 **/cohort — MoM 추세 분석**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/cohort` — 최근 6개월 추세 (기본)',
|
||||
' `/cohort yearly` — 최근 12개월',
|
||||
' `/cohort <N>` — 최근 N개월 (1~24)',
|
||||
'',
|
||||
'데이터 출처: `/customers` events + `/runway` events 의 timestamp 월별 그룹핑.',
|
||||
'표시 항목: 신규/갱신/이탈 + MRR 변화 + 지출/수입/burn + 월말 잔고 + 인사이트 한 줄.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
let monthsBack = 6;
|
||||
if (trimmed === 'yearly' || trimmed === 'year') monthsBack = 12;
|
||||
else if (/^\d+$/.test(trimmed)) monthsBack = Math.max(1, Math.min(24, parseInt(trimmed, 10)));
|
||||
_cohortDashboard(view, monthsBack);
|
||||
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<string, number>;
|
||||
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<boolean> {
|
||||
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 });
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* TeamOps handlers 배럴 — extension activate 시 1회 import 로 모든 핸들러 등록.
|
||||
*
|
||||
* 각 핸들러 파일은 module scope 에서 `registerSlashCommand({...})` 호출하므로
|
||||
* import 만으로 등록 완료. 새 핸들러 파일은 여기에 한 줄 추가.
|
||||
*
|
||||
* v2.2.196 — trackers (runway/customers/hire) 분리. coordination/communication/
|
||||
* dashboards 는 다음 빌드.
|
||||
*/
|
||||
|
||||
import './trackers';
|
||||
import './dashboards';
|
||||
import './coordination';
|
||||
import './communication';
|
||||
@@ -0,0 +1,704 @@
|
||||
/**
|
||||
* TeamOps Trackers — /runway · /customers · /hire (event-sourced 트래커 3종).
|
||||
*
|
||||
* v2.2.196 에서 slashRouter.ts 에서 분리. 모두 `.astra/*.jsonl` event log 를
|
||||
* 읽고 (createEventStore via 각 store 모듈) 대시보드 / 수정 명령 제공.
|
||||
*
|
||||
* 공통 헬퍼 (fmtKrw / parseAmount / daysUntil / stageEmoji / STAGE_ORDER /
|
||||
* TERMINAL_STAGES) 는 `./_shared.ts` 에서.
|
||||
*/
|
||||
|
||||
import { registerSlashCommand, chunk } from '../../datacollect/slashRouter';
|
||||
import {
|
||||
fmtKrw, parseAmount, daysUntil,
|
||||
stageEmoji, STAGE_ORDER, TERMINAL_STAGES,
|
||||
} from './_shared';
|
||||
import {
|
||||
appendRunway, readRunway, getRunwayFilePath, computeRunwayStatus,
|
||||
type RunwayEntry, type RunwayEntryType,
|
||||
} from '../../runway/runwayStore';
|
||||
import {
|
||||
appendEvent as appendCustomerEvent, readEvents as readCustomerEvents,
|
||||
getCustomersFilePath, customerIdFromName, computeCustomerStates,
|
||||
type CustomerEvent, type CustomerState,
|
||||
} from '../../customers/customersStore';
|
||||
import {
|
||||
appendHireEvent, readHireEvents, getHireFilePath, candidateIdFromName,
|
||||
computeCandidateStates, type HireEvent, type CandidateState,
|
||||
} from '../../hire/hireStore';
|
||||
|
||||
// ─── /runway ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _runwayShowStatus(view: any): void {
|
||||
const s = computeRunwayStatus();
|
||||
chunk(view, '\n💰 **/runway — 현금 / 월 소진율 / 런웨이**\n');
|
||||
if (s.latestCash === null) {
|
||||
chunk(view, '\nℹ️ 잔고 기록 없음. 시작: `/runway cash 5000만` (현재 통장 잔고 입력)\n');
|
||||
return;
|
||||
}
|
||||
const cashDate = (s.latestCashAt || '').slice(0, 10);
|
||||
chunk(view, `\n## 현재 현금\n- **${fmtKrw(s.latestCash)}원** _(기준: ${cashDate})_\n`);
|
||||
|
||||
chunk(view, '\n## 월 소진율 (burn)\n');
|
||||
if (s.explicitBurn !== null) {
|
||||
chunk(view, `- **${fmtKrw(s.explicitBurn)}원/월** _(수동 설정)_\n`);
|
||||
} else if (s.computedBurn !== null) {
|
||||
const ann = s.last30Days < 30 ? ` _(${s.last30Days}일 데이터 → 30일 환산)_` : ' _(최근 30일 실적)_';
|
||||
chunk(view, `- **${fmtKrw(s.computedBurn)}원/월**${ann}\n`);
|
||||
chunk(view, ` · 지출 ${fmtKrw(s.last30Expense)}원 − 수입 ${fmtKrw(s.last30Revenue)}원\n`);
|
||||
} else {
|
||||
chunk(view, '- _데이터 부족_ — `/runway burn 1500만` 또는 `/runway expense 300만 급여` 로 기록\n');
|
||||
}
|
||||
|
||||
chunk(view, '\n## 런웨이\n');
|
||||
if (s.runwayMonths === null) {
|
||||
chunk(view, '- _계산 불가_ (잔고 또는 burn 미정)\n');
|
||||
} else if (!Number.isFinite(s.runwayMonths)) {
|
||||
chunk(view, '- ♾️ **흑자 운영** (지출 ≤ 수입)\n');
|
||||
} else {
|
||||
const m = s.runwayMonths;
|
||||
const emoji = m < 3 ? '🔴' : m < 6 ? '🟡' : '🟢';
|
||||
const months = m.toFixed(1);
|
||||
const exitDate = new Date(Date.now() + m * 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
chunk(view, `- ${emoji} **${months}개월** _(예상 소진: ${exitDate})_\n`);
|
||||
if (m < 3) chunk(view, ' · ⚠️ **3개월 미만** — 즉시 자금 조달 또는 비용 절감 필요\n');
|
||||
else if (m < 6) chunk(view, ' · ⚠️ **6개월 미만** — 자금 계획 점검 권장\n');
|
||||
}
|
||||
|
||||
chunk(view, `\n_누적 ${s.totalEntries}건 기록. \`/runway log\` 로 전체 보기, \`/runway path\` 로 파일 위치._\n`);
|
||||
}
|
||||
|
||||
function _runwayLog(view: any, limit: number): void {
|
||||
const all = readRunway().slice().sort((a, b) => (b.timestamp || '').localeCompare(a.timestamp || '')).slice(0, limit);
|
||||
if (all.length === 0) { chunk(view, '\nℹ️ 기록 없음. `/runway cash 5000만` 으로 시작.\n'); return; }
|
||||
chunk(view, `\n📒 **최근 ${all.length}건** (최신순)\n\n`);
|
||||
const emoji: Record<RunwayEntryType, string> = {
|
||||
snapshot: '💰', expense: '💸', revenue: '💵', burn: '🔥',
|
||||
};
|
||||
for (const e of all) {
|
||||
const date = (e.timestamp || '').slice(0, 10);
|
||||
const cat = e.category ? ` [${e.category}]` : '';
|
||||
const memo = e.memo ? ` — ${e.memo}` : '';
|
||||
const typeLabel = e.type === 'snapshot' ? '잔고' : e.type === 'expense' ? '지출' : e.type === 'revenue' ? '수입' : '월 burn';
|
||||
chunk(view, `- ${emoji[e.type]} \`${date}\` ${typeLabel}: ${fmtKrw(e.amount)}원${cat}${memo}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRunway(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { _runwayShowStatus(view); return true; }
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const sub = parts[0].toLowerCase();
|
||||
|
||||
if (sub === 'help' || sub === '?') {
|
||||
chunk(view, [
|
||||
'\n💰 **/runway — 현금 / 월 소진율 / 런웨이**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/runway` — 현재 상태 카드 (현금 / burn / 남은 개월수)',
|
||||
' `/runway cash <금액> [메모]` — 통장 잔고 스냅샷 기록',
|
||||
' `/runway expense <금액> [메모]` — 지출 기록 (월 burn 자동 계산에 반영)',
|
||||
' `/runway revenue <금액> [메모]` — 수입 기록 (burn 상쇄)',
|
||||
' `/runway burn <월 금액>` — 월 소진율 수동 설정 (자동 계산보다 우선)',
|
||||
' `/runway log [N]` — 최근 N건 기록 (기본 20)',
|
||||
' `/runway path` — .jsonl 파일 경로',
|
||||
'',
|
||||
'금액 단위: `5000만` / `1.5억` / `300000` 모두 OK. 소수점·콤마 허용.',
|
||||
'저장 위치: `<workspace>/.astra/runway.jsonl` (로컬 only, 외부 안 보냄).\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'path') {
|
||||
const p = getRunwayFilePath();
|
||||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음 — 저장 위치 결정 불가.\n'); return true; }
|
||||
const count = readRunway().length;
|
||||
chunk(view, `\n📂 \`${p}\`\n · 누적 ${count}건 (.jsonl 형식, 한 줄=한 항목)\n · 사람이 직접 편집 가능.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'log') {
|
||||
const n = parts[1] ? parseInt(parts[1], 10) : 20;
|
||||
_runwayLog(view, Number.isFinite(n) && n > 0 ? n : 20);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'cash' || sub === 'expense' || sub === 'revenue' || sub === 'burn') {
|
||||
const amount = parseAmount(parts[1] || '');
|
||||
if (amount === null) {
|
||||
chunk(view, `\n❌ 금액 파싱 실패: "${parts[1] || ''}". 예: \`5000만\` / \`1.5억\` / \`300000\`\n`);
|
||||
return true;
|
||||
}
|
||||
const memo = parts.slice(2).join(' ').trim() || undefined;
|
||||
const typeMap: Record<string, RunwayEntryType> = { cash: 'snapshot', expense: 'expense', revenue: 'revenue', burn: 'burn' };
|
||||
const entry: RunwayEntry = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
type: typeMap[sub],
|
||||
amount,
|
||||
currency: 'KRW',
|
||||
memo,
|
||||
};
|
||||
const res = appendRunway(entry);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
const labels: Record<string, string> = { cash: '잔고 스냅샷', expense: '지출', revenue: '수입', burn: '월 burn 설정' };
|
||||
chunk(view, `\n✅ ${labels[sub]} 기록: **${fmtKrw(amount)}원**${memo ? ` — ${memo}` : ''}\n`);
|
||||
_runwayShowStatus(view);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/runway help\` 참조.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /customers ───────────────────────────────────────────────────────────
|
||||
|
||||
function _customersDashboard(view: any): void {
|
||||
const states = computeCustomerStates();
|
||||
const all = Array.from(states.values());
|
||||
if (all.length === 0) {
|
||||
chunk(view, '\n📒 **/customers — 고객사 / MRR / 갱신**\n\nℹ️ 등록된 고객 없음. 시작: `/customers add <이름> <MRR> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const active = all.filter((c) => c.status === 'active');
|
||||
const atRisk = all.filter((c) => c.status === 'at-risk');
|
||||
const churned = all.filter((c) => c.status === 'churned');
|
||||
const totalMrr = active.reduce((sum, c) => sum + c.mrr, 0) + atRisk.reduce((sum, c) => sum + c.mrr, 0);
|
||||
const riskMrr = atRisk.reduce((sum, c) => sum + c.mrr, 0);
|
||||
|
||||
chunk(view, '\n📒 **/customers — 고객사 대시보드**\n');
|
||||
chunk(view, '\n## 요약\n');
|
||||
chunk(view, `- **MRR**: ${fmtKrw(totalMrr)}원/월 _(연 ${fmtKrw(totalMrr * 12)}원)_\n`);
|
||||
chunk(view, `- 활성 ${active.length}곳 · 위험 ${atRisk.length}곳 · 이탈 ${churned.length}곳\n`);
|
||||
if (riskMrr > 0) chunk(view, `- ⚠️ **위험 MRR**: ${fmtKrw(riskMrr)}원/월 _(전체의 ${((riskMrr / totalMrr) * 100).toFixed(0)}%)_\n`);
|
||||
|
||||
const upcoming = [...active, ...atRisk]
|
||||
.map((c) => ({ c, days: daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 30)
|
||||
.sort((a, b) => (a.days as number) - (b.days as number));
|
||||
if (upcoming.length > 0) {
|
||||
chunk(view, '\n## 🔔 30일 내 갱신\n');
|
||||
for (const { c, days } of upcoming) {
|
||||
const emoji = c.status === 'at-risk' ? '⚠️' : (days as number) <= 7 ? '🔴' : (days as number) <= 14 ? '🟡' : '🟢';
|
||||
chunk(view, `- ${emoji} **${c.customerName}** — ${c.renewalAt} _(D-${days})_ · ${fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (atRisk.length > 0) {
|
||||
chunk(view, '\n## ⚠️ 위험 고객\n');
|
||||
for (const c of atRisk.sort((a, b) => b.mrr - a.mrr)) {
|
||||
const lastRisk = c.notes.slice().reverse().find((n) => n.type === 'risk');
|
||||
chunk(view, `- **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${lastRisk ? ` · ${lastRisk.memo.slice(0, 60)}` : ''}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (active.length > 0) {
|
||||
chunk(view, '\n## 활성 고객 (MRR 순)\n');
|
||||
const top = active.slice().sort((a, b) => b.mrr - a.mrr).slice(0, 10);
|
||||
for (const c of top) {
|
||||
const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
|
||||
chunk(view, `- ${c.customerName} — ${fmtKrw(c.mrr)}원/월${renewalNote}\n`);
|
||||
}
|
||||
if (active.length > 10) chunk(view, `- _…+${active.length - 10}곳_\n`);
|
||||
}
|
||||
|
||||
chunk(view, `\n_누적 이벤트 ${readCustomerEvents().length}건. \`/customers help\` 로 명령어._\n`);
|
||||
}
|
||||
|
||||
function _customersList(view: any, filter: string | undefined): void {
|
||||
const states = computeCustomerStates();
|
||||
let all = Array.from(states.values());
|
||||
if (filter === 'active' || filter === 'at-risk' || filter === 'risk' || filter === 'churned') {
|
||||
const target = filter === 'risk' ? 'at-risk' : filter;
|
||||
all = all.filter((c) => c.status === target);
|
||||
}
|
||||
if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
|
||||
chunk(view, `\n📋 **고객 목록 (${all.length}곳${filter ? `, ${filter}` : ''})**\n\n`);
|
||||
const sorted = all.slice().sort((a, b) => b.mrr - a.mrr);
|
||||
for (const c of sorted) {
|
||||
const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
|
||||
const renewalNote = c.renewalAt ? ` · 갱신 ${c.renewalAt}` : '';
|
||||
const planNote = c.plan ? ` · ${c.plan}` : '';
|
||||
chunk(view, `- ${emoji} **${c.customerName}** — ${fmtKrw(c.mrr)}원/월${planNote}${renewalNote}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function _customersShow(view: any, name: string): void {
|
||||
const states = computeCustomerStates();
|
||||
const cid = customerIdFromName(name);
|
||||
const c = states.get(cid);
|
||||
if (!c) {
|
||||
const candidates = Array.from(states.values()).filter((x) => x.customerName.toLowerCase().includes(name.toLowerCase()));
|
||||
if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
|
||||
if (candidates.length > 1) {
|
||||
chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}곳:\n`);
|
||||
for (const x of candidates) chunk(view, `- ${x.customerName}\n`);
|
||||
return;
|
||||
}
|
||||
return _customersShow(view, candidates[0].customerName);
|
||||
}
|
||||
|
||||
const emoji = c.status === 'active' ? '🟢' : c.status === 'at-risk' ? '🟡' : '🔴';
|
||||
chunk(view, `\n${emoji} **${c.customerName}** _(${c.status})_\n`);
|
||||
chunk(view, `\n- MRR: **${fmtKrw(c.mrr)}원/월** _(연 ${fmtKrw(c.mrr * 12)}원)_\n`);
|
||||
if (c.plan) chunk(view, `- 요금제: ${c.plan}\n`);
|
||||
if (c.renewalAt) {
|
||||
const d = daysUntil(c.renewalAt);
|
||||
const dn = d !== null ? (d >= 0 ? `D-${d}` : `${-d}일 지남`) : '';
|
||||
chunk(view, `- 갱신일: ${c.renewalAt} _(${dn})_\n`);
|
||||
}
|
||||
chunk(view, `- 시작: ${(c.startedAt || '').slice(0, 10)} · 누적 이벤트 ${c.eventCount}건\n`);
|
||||
|
||||
if (c.notes.length > 0) {
|
||||
chunk(view, `\n## 메모·이벤트 (${c.notes.length}건, 최신순)\n`);
|
||||
const recent = c.notes.slice().reverse().slice(0, 10);
|
||||
for (const n of recent) {
|
||||
const date = (n.timestamp || '').slice(0, 10);
|
||||
const tagEmoji = n.type === 'risk' ? '⚠️' : n.type === 'churn' ? '💀' : '📝';
|
||||
chunk(view, `- ${tagEmoji} \`${date}\` ${n.memo}\n`);
|
||||
}
|
||||
if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runCustomers(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { _customersDashboard(view); return true; }
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const sub = parts[0].toLowerCase();
|
||||
|
||||
if (sub === 'help' || sub === '?') {
|
||||
chunk(view, [
|
||||
'\n📒 **/customers — 고객사 / MRR / 갱신 트래커**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/customers` — 대시보드 (MRR, 위험, 갱신 임박)',
|
||||
' `/customers add <이름> <MRR> [갱신일] [요금제]` — 신규 등록',
|
||||
' `/customers update <이름> mrr=<금액> renewal=<날짜>`— 정보 수정',
|
||||
' `/customers renew <이름> <새 갱신일> [새 MRR]` — 갱신 처리 (active 로 복귀)',
|
||||
' `/customers risk <이름> <사유>` — 위험 표시',
|
||||
' `/customers churn <이름> <사유>` — 이탈 처리 (MRR=0)',
|
||||
' `/customers note <이름> <텍스트>` — 자유 메모',
|
||||
' `/customers show <이름>` — 상세 (부분 매치 OK)',
|
||||
' `/customers list [active/risk/churned]` — 필터 목록',
|
||||
' `/customers path` — .jsonl 파일 경로',
|
||||
'',
|
||||
'MRR 금액 단위: `200만` / `1.5억` / `300000` 모두 OK.',
|
||||
'갱신일: `YYYY-MM-DD` (예: `2026-12-01`).',
|
||||
'저장: `<workspace>/.astra/customers.jsonl` (로컬 only, 외부 안 보냄).\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'path') {
|
||||
const p = getCustomersFilePath();
|
||||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
|
||||
chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readCustomerEvents().length}건 (.jsonl).\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'list') { _customersList(view, parts[1]?.toLowerCase()); return true; }
|
||||
|
||||
if (sub === 'show') {
|
||||
const name = parts.slice(1).join(' ').trim();
|
||||
if (!name) { chunk(view, '\n❌ 사용법: `/customers show <이름>`\n'); return true; }
|
||||
_customersShow(view, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const name = parts[1];
|
||||
const mrrToken = parts[2];
|
||||
const renewalToken = parts[3];
|
||||
const planToken = parts[4];
|
||||
if (!name || !mrrToken) {
|
||||
chunk(view, '\n❌ 사용법: `/customers add <이름> <MRR> [갱신일] [요금제]`\n예: `/customers add 큐브앤코 200만 2026-12-01 enterprise`\n');
|
||||
return true;
|
||||
}
|
||||
const mrr = parseAmount(mrrToken);
|
||||
if (mrr === null) { chunk(view, `\n❌ MRR 파싱 실패: "${mrrToken}"\n`); return true; }
|
||||
const event: CustomerEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
customerId: customerIdFromName(name),
|
||||
customerName: name,
|
||||
type: 'add',
|
||||
mrr,
|
||||
renewalAt: renewalToken && /^\d{4}-\d{2}-\d{2}$/.test(renewalToken) ? renewalToken : undefined,
|
||||
plan: planToken,
|
||||
};
|
||||
const res = appendCustomerEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n✅ **${name}** 등록 — MRR ${fmtKrw(mrr)}원/월${event.renewalAt ? ` · 갱신 ${event.renewalAt}` : ''}${event.plan ? ` · ${event.plan}` : ''}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'renew') {
|
||||
const name = parts[1];
|
||||
const newRenewal = parts[2];
|
||||
const newMrrToken = parts[3];
|
||||
if (!name || !newRenewal) { chunk(view, '\n❌ 사용법: `/customers renew <이름> <새 갱신일> [새 MRR]`\n'); return true; }
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(newRenewal)) { chunk(view, `\n❌ 갱신일 형식: YYYY-MM-DD (입력: "${newRenewal}")\n`); return true; }
|
||||
const newMrr = newMrrToken ? parseAmount(newMrrToken) : null;
|
||||
const event: CustomerEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
customerId: customerIdFromName(name),
|
||||
customerName: name,
|
||||
type: 'renew',
|
||||
renewalAt: newRenewal,
|
||||
mrr: newMrr ?? undefined,
|
||||
};
|
||||
const res = appendCustomerEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n🔄 **${name}** 갱신 — ${newRenewal}${newMrr !== null ? ` · MRR ${fmtKrw(newMrr)}원/월` : ''}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'risk' || sub === 'churn' || sub === 'note') {
|
||||
const name = parts[1];
|
||||
const memo = parts.slice(2).join(' ').trim();
|
||||
if (!name || !memo) {
|
||||
chunk(view, `\n❌ 사용법: \`/customers ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`);
|
||||
return true;
|
||||
}
|
||||
const event: CustomerEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
customerId: customerIdFromName(name),
|
||||
customerName: name,
|
||||
type: sub,
|
||||
memo,
|
||||
};
|
||||
const res = appendCustomerEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
const emoji = sub === 'risk' ? '⚠️' : sub === 'churn' ? '💀' : '📝';
|
||||
const label = sub === 'risk' ? '위험 표시' : sub === 'churn' ? '이탈 처리' : '메모 추가';
|
||||
chunk(view, `\n${emoji} **${name}** ${label}: ${memo}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'update') {
|
||||
const name = parts[1];
|
||||
if (!name) { chunk(view, '\n❌ 사용법: `/customers update <이름> mrr=<금액> renewal=<YYYY-MM-DD> plan=<요금제>`\n'); return true; }
|
||||
const rest = parts.slice(2);
|
||||
const event: CustomerEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
customerId: customerIdFromName(name),
|
||||
customerName: name,
|
||||
type: 'update',
|
||||
};
|
||||
let touched = false;
|
||||
for (const kv of rest) {
|
||||
const m = kv.match(/^(\w+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const k = m[1].toLowerCase();
|
||||
const v = m[2];
|
||||
if (k === 'mrr') {
|
||||
const n = parseAmount(v);
|
||||
if (n !== null) { event.mrr = n; touched = true; }
|
||||
} else if (k === 'renewal' || k === 'renewalat') {
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { event.renewalAt = v; touched = true; }
|
||||
} else if (k === 'plan') {
|
||||
event.plan = v; touched = true;
|
||||
}
|
||||
}
|
||||
if (!touched) { chunk(view, '\n❌ 변경할 필드 없음. 예: `/customers update 큐브앤코 mrr=300만 renewal=2026-12-15`\n'); return true; }
|
||||
const res = appendCustomerEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
const changes: string[] = [];
|
||||
if (event.mrr !== undefined) changes.push(`MRR=${fmtKrw(event.mrr)}원`);
|
||||
if (event.renewalAt) changes.push(`갱신=${event.renewalAt}`);
|
||||
if (event.plan) changes.push(`요금제=${event.plan}`);
|
||||
chunk(view, `\n✏️ **${name}** 업데이트: ${changes.join(' · ')}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/customers help\` 참조.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /hire ────────────────────────────────────────────────────────────────
|
||||
|
||||
function _hireDashboard(view: any): void {
|
||||
const states = computeCandidateStates();
|
||||
const all = Array.from(states.values());
|
||||
if (all.length === 0) {
|
||||
chunk(view, '\n👥 **/hire — 채용 파이프라인**\n\nℹ️ 등록된 후보 없음. 시작: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드`\n');
|
||||
return;
|
||||
}
|
||||
|
||||
const active = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||||
const hired = all.filter((c) => c.stage === 'hired');
|
||||
const rejected = all.filter((c) => c.stage === 'rejected' || c.stage === 'declined');
|
||||
|
||||
chunk(view, '\n👥 **/hire — 채용 파이프라인**\n');
|
||||
chunk(view, '\n## 요약\n');
|
||||
chunk(view, `- 진행 중 **${active.length}명** · 합격 ${hired.length}명 · 종료 ${rejected.length}명\n`);
|
||||
|
||||
const byRole = new Map<string, CandidateState[]>();
|
||||
for (const c of active) {
|
||||
const role = c.role || '미지정';
|
||||
if (!byRole.has(role)) byRole.set(role, []);
|
||||
byRole.get(role)!.push(c);
|
||||
}
|
||||
if (byRole.size > 0) {
|
||||
chunk(view, '\n## 역할별 진행\n');
|
||||
for (const [role, cs] of byRole) {
|
||||
chunk(view, `- **${role}**: ${cs.length}명\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const byStage = new Map<string, CandidateState[]>();
|
||||
for (const c of active) {
|
||||
if (!byStage.has(c.stage)) byStage.set(c.stage, []);
|
||||
byStage.get(c.stage)!.push(c);
|
||||
}
|
||||
const sortedStages = Array.from(byStage.keys()).sort((a, b) => (STAGE_ORDER[a] ?? 50) - (STAGE_ORDER[b] ?? 50));
|
||||
if (sortedStages.length > 0) {
|
||||
chunk(view, '\n## 단계별\n');
|
||||
for (const stage of sortedStages) {
|
||||
const list = byStage.get(stage)!;
|
||||
chunk(view, `\n### ${stageEmoji(stage)} ${stage} (${list.length})\n`);
|
||||
for (const c of list.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || ''))) {
|
||||
const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
const stale = daysIn > 7 ? ` ⏰ ${daysIn}일 정체` : '';
|
||||
const salary = c.salary !== undefined ? ` · ${fmtKrw(c.salary)}원` : '';
|
||||
chunk(view, `- ${c.candidateName} _(${c.role || '미지정'})_${salary}${stale}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hired.length > 0) {
|
||||
chunk(view, `\n## 🎉 최근 합격 (${hired.length}명)\n`);
|
||||
const recent = hired.slice().sort((a, b) => (b.lastEventAt || '').localeCompare(a.lastEventAt || '')).slice(0, 5);
|
||||
for (const c of recent) {
|
||||
chunk(view, `- ${c.candidateName} _(${c.role})_ — ${(c.lastEventAt || '').slice(0, 10)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
chunk(view, `\n_누적 이벤트 ${readHireEvents().length}건. \`/hire help\` 로 명령어._\n`);
|
||||
}
|
||||
|
||||
function _hireList(view: any, filter: string | undefined): void {
|
||||
const states = computeCandidateStates();
|
||||
let all = Array.from(states.values());
|
||||
if (filter) {
|
||||
const f = filter.toLowerCase();
|
||||
if (f === 'active') all = all.filter((c) => !TERMINAL_STAGES.has(c.stage));
|
||||
else if (f === 'closed' || f === 'terminal') all = all.filter((c) => TERMINAL_STAGES.has(c.stage));
|
||||
else all = all.filter((c) => c.stage === f || (c.role || '').toLowerCase() === f);
|
||||
}
|
||||
if (all.length === 0) { chunk(view, `\nℹ️ 매치 없음${filter ? ` (필터: ${filter})` : ''}.\n`); return; }
|
||||
chunk(view, `\n📋 **후보 목록 (${all.length}명${filter ? `, ${filter}` : ''})**\n\n`);
|
||||
const sorted = all.slice().sort((a, b) => (STAGE_ORDER[a.stage] ?? 50) - (STAGE_ORDER[b.stage] ?? 50));
|
||||
for (const c of sorted) {
|
||||
const daysIn = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- ${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_ · ${c.stage} · ${daysIn}일 전\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function _hireShow(view: any, name: string): void {
|
||||
const states = computeCandidateStates();
|
||||
const cid = candidateIdFromName(name);
|
||||
let c = states.get(cid);
|
||||
if (!c) {
|
||||
const candidates = Array.from(states.values()).filter((x) => x.candidateName.toLowerCase().includes(name.toLowerCase()));
|
||||
if (candidates.length === 0) { chunk(view, `\n❌ "${name}" 매치 0건.\n`); return; }
|
||||
if (candidates.length > 1) {
|
||||
chunk(view, `\nℹ️ "${name}" 부분 매치 ${candidates.length}명:\n`);
|
||||
for (const x of candidates) chunk(view, `- ${x.candidateName} (${x.role})\n`);
|
||||
return;
|
||||
}
|
||||
c = candidates[0];
|
||||
}
|
||||
|
||||
chunk(view, `\n${stageEmoji(c.stage)} **${c.candidateName}** _(${c.role || '미지정'})_\n`);
|
||||
chunk(view, `\n- 단계: **${c.stage}**\n`);
|
||||
if (c.salary !== undefined) chunk(view, `- 제안 연봉: ${fmtKrw(c.salary)}원\n`);
|
||||
chunk(view, `- 시작: ${(c.addedAt || '').slice(0, 10)} · 최근 변경: ${(c.lastEventAt || '').slice(0, 10)} · 이벤트 ${c.eventCount}건\n`);
|
||||
|
||||
if (c.notes.length > 0) {
|
||||
chunk(view, `\n## 메모·이벤트 (${c.notes.length}건)\n`);
|
||||
const recent = c.notes.slice().reverse().slice(0, 10);
|
||||
for (const n of recent) {
|
||||
const date = (n.timestamp || '').slice(0, 10);
|
||||
chunk(view, `- \`${date}\` ${n.memo}\n`);
|
||||
}
|
||||
if (c.notes.length > 10) chunk(view, `- _…+${c.notes.length - 10}건_\n`);
|
||||
}
|
||||
}
|
||||
|
||||
async function runHire(arg: string, view: any): Promise<boolean> {
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { _hireDashboard(view); return true; }
|
||||
|
||||
const parts = trimmed.split(/\s+/);
|
||||
const sub = parts[0].toLowerCase();
|
||||
|
||||
if (sub === 'help' || sub === '?') {
|
||||
chunk(view, [
|
||||
'\n👥 **/hire — 채용 파이프라인**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/hire` — 파이프라인 대시보드',
|
||||
' `/hire add <이름> <역할>` — 신규 후보 (inbox 단계)',
|
||||
' `/hire stage <이름> <새 단계>` — 단계 이동',
|
||||
' `/hire note <이름> <텍스트>` — 자유 메모',
|
||||
' `/hire offer <이름> <연봉> [입사일]` — 오퍼 발송 (단계=offer)',
|
||||
' `/hire hire <이름> [입사일]` — 입사 확정 (단계=hired)',
|
||||
' `/hire reject <이름> <사유>` — 거절 (회사 측)',
|
||||
' `/hire decline <이름> <사유>` — 후보 사양',
|
||||
' `/hire show <이름>` — 상세 + 이력',
|
||||
' `/hire list [active/closed/단계명/역할]` — 필터 목록',
|
||||
' `/hire path` — 파일 위치',
|
||||
'',
|
||||
'단계 (기본 파이프라인): inbox → screened → interview → final → offer → accepted → hired',
|
||||
'터미널: rejected · declined',
|
||||
'',
|
||||
'연봉 단위: `4500만` / `1억` / `45000000` 모두 OK.',
|
||||
'입사일: `YYYY-MM-DD`.',
|
||||
'저장: `<workspace>/.astra/hire.jsonl` (로컬 only).\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'path') {
|
||||
const p = getHireFilePath();
|
||||
if (!p) { chunk(view, '\n⚠️ 워크스페이스 폴더 없음.\n'); return true; }
|
||||
chunk(view, `\n📂 \`${p}\`\n · 누적 이벤트 ${readHireEvents().length}건 (.jsonl).\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'list') { _hireList(view, parts[1]); return true; }
|
||||
if (sub === 'show') {
|
||||
const name = parts.slice(1).join(' ').trim();
|
||||
if (!name) { chunk(view, '\n❌ 사용법: `/hire show <이름>`\n'); return true; }
|
||||
_hireShow(view, name);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'add') {
|
||||
const name = parts[1];
|
||||
const role = parts.slice(2).join(' ').trim();
|
||||
if (!name || !role) { chunk(view, '\n❌ 사용법: `/hire add <이름> <역할>`\n예: `/hire add 김개발 백엔드 시니어`\n'); return true; }
|
||||
const event: HireEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
candidateId: candidateIdFromName(name),
|
||||
candidateName: name,
|
||||
role,
|
||||
type: 'add',
|
||||
stage: 'inbox',
|
||||
};
|
||||
const res = appendHireEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n📥 **${name}** 등록 — ${role} (inbox)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'stage') {
|
||||
const name = parts[1];
|
||||
const newStage = parts[2]?.toLowerCase();
|
||||
if (!name || !newStage) { chunk(view, '\n❌ 사용법: `/hire stage <이름> <단계>`\n'); return true; }
|
||||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음. \`/hire add\` 먼저.\n`); return true; }
|
||||
const event: HireEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
candidateId: existing.candidateId,
|
||||
candidateName: existing.candidateName,
|
||||
role: existing.role,
|
||||
type: 'stage',
|
||||
stage: newStage,
|
||||
};
|
||||
const res = appendHireEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n${stageEmoji(newStage)} **${existing.candidateName}**: ${existing.stage} → ${newStage}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'offer') {
|
||||
const name = parts[1];
|
||||
const salaryToken = parts[2];
|
||||
const startDate = parts[3];
|
||||
if (!name || !salaryToken) { chunk(view, '\n❌ 사용법: `/hire offer <이름> <연봉> [입사일]`\n예: `/hire offer 김개발 6000만 2026-07-01`\n'); return true; }
|
||||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||||
const salary = parseAmount(salaryToken);
|
||||
if (salary === null) { chunk(view, `\n❌ 연봉 파싱 실패: "${salaryToken}"\n`); return true; }
|
||||
const event: HireEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
candidateId: existing.candidateId,
|
||||
candidateName: existing.candidateName,
|
||||
role: existing.role,
|
||||
type: 'offer',
|
||||
stage: 'offer',
|
||||
salary,
|
||||
memo: startDate ? `오퍼 발송 (입사 예정: ${startDate})` : '오퍼 발송',
|
||||
};
|
||||
const res = appendHireEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n📨 **${existing.candidateName}** 오퍼 — ${fmtKrw(salary)}원${startDate ? ` · 입사 ${startDate}` : ''}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'hire') {
|
||||
const name = parts[1];
|
||||
const startDate = parts[2];
|
||||
if (!name) { chunk(view, '\n❌ 사용법: `/hire hire <이름> [입사일]`\n'); return true; }
|
||||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||||
const event: HireEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
candidateId: existing.candidateId,
|
||||
candidateName: existing.candidateName,
|
||||
role: existing.role,
|
||||
type: 'hire',
|
||||
stage: 'hired',
|
||||
memo: startDate ? `입사 확정 (시작: ${startDate})` : '입사 확정',
|
||||
};
|
||||
const res = appendHireEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
chunk(view, `\n🎉 **${existing.candidateName}** 입사 확정 — ${existing.role}${startDate ? ` (${startDate})` : ''}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sub === 'reject' || sub === 'decline' || sub === 'note') {
|
||||
const name = parts[1];
|
||||
const memo = parts.slice(2).join(' ').trim();
|
||||
if (!name || !memo) { chunk(view, `\n❌ 사용법: \`/hire ${sub} <이름> <${sub === 'note' ? '텍스트' : '사유'}>\`\n`); return true; }
|
||||
const existing = computeCandidateStates().get(candidateIdFromName(name));
|
||||
if (!existing) { chunk(view, `\n❌ "${name}" 등록되지 않음.\n`); return true; }
|
||||
const event: HireEvent = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
candidateId: existing.candidateId,
|
||||
candidateName: existing.candidateName,
|
||||
role: existing.role,
|
||||
type: sub,
|
||||
stage: sub === 'note' ? undefined : sub === 'reject' ? 'rejected' : 'declined',
|
||||
memo,
|
||||
};
|
||||
const res = appendHireEvent(event);
|
||||
if (!res.ok) { chunk(view, `\n❌ 저장 실패: ${res.error}\n`); return true; }
|
||||
const labels: Record<string, string> = { reject: '❌ 거절', decline: '🚪 사양', note: '📝 메모' };
|
||||
chunk(view, `\n${labels[sub]} **${existing.candidateName}**: ${memo}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n❌ 알 수 없는 서브명령: "${sub}". \`/hire help\` 참조.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/runway', description: '현금 / 월 소진율 / 런웨이 — 4인 기업 CEO 의 가장 중요한 숫자 (로컬 .jsonl)', handler: runRunway });
|
||||
registerSlashCommand({ name: '/customers', description: '고객사 / MRR / 갱신 / 위험 트래커 — event-sourced 로그 (로컬 .jsonl)', handler: runCustomers });
|
||||
registerSlashCommand({ name: '/hire', description: '채용 파이프라인 — 후보자 단계·오퍼·합격 트래커 (로컬 .jsonl)', handler: runHire });
|
||||
@@ -15,6 +15,14 @@ import {
|
||||
mapWeightToRetrievalRatio,
|
||||
ResolvedKnowledgeMix,
|
||||
} from '../../retrieval/knowledgeMix';
|
||||
import { buildConflictWarningsBlock, ConflictThresholdSetting } from '../../retrieval/conflictBlock';
|
||||
import { buildCoveChecklistBlock } from '../../retrieval/coveBlock';
|
||||
import { captureWorkStateSignals } from '../../retrieval/actionabilityScoring';
|
||||
import { getRecentSlashCommands } from '../../features/datacollect/slashRouter';
|
||||
import { semanticRerank, DEFAULT_SEMANTIC_RERANK_OPTIONS } from '../../retrieval/semanticRerank';
|
||||
import { detectAmbiguity, buildIntentClarificationBlock, IntentStrictness } from '../../retrieval/intentClarification';
|
||||
import { buildCitationTraceBlock } from '../../retrieval/citationTrace';
|
||||
import { buildTerminologyBlock } from '../../retrieval/terminologyBlock';
|
||||
|
||||
/**
|
||||
* 한 turn 의 RAG / 5-layer memory 컨텍스트 빌드.
|
||||
@@ -54,6 +62,17 @@ export interface TurnContextSink {
|
||||
retrieval: TurnRetrievalSummary | null;
|
||||
lessons: string[];
|
||||
knowledgeMix: ResolvedKnowledgeMix | null;
|
||||
/**
|
||||
* 동적 시스템 프롬프트 블록 — id → 본문. memoryContext 가 이 turn 에 채우고,
|
||||
* buildAstraModeSystemPrompt 가 iterate 해서 [CONTEXT] 밖에 join 주입.
|
||||
* Casual conversation 모드면 자동 skip. 빈 본문은 자동 제외.
|
||||
*
|
||||
* 등록 순서대로 prompt 에 join — `intent-clarification` → `terminology` →
|
||||
* `conflict-warnings` → `cove-checklist` → `citation-trace`.
|
||||
*/
|
||||
dynamicBlocks: Map<string, string>;
|
||||
/** Post-hoc Self-Check 용 — selected chunks 의 (title, excerpt) 요약. */
|
||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||
}
|
||||
|
||||
export interface MemoryContextDeps {
|
||||
@@ -163,6 +182,12 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
|
||||
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
|
||||
|
||||
// Actionability — work-state 신호 캡처 (최근 슬래시 명령 + 열린 파일).
|
||||
// 설정으로 disable 가능. 신호 없으면 retrieve() 가 legacy 동작.
|
||||
const workStateSignals = config.actionabilityEnabled !== false
|
||||
? captureWorkStateSignals(getRecentSlashCommands())
|
||||
: undefined;
|
||||
|
||||
// Unified RAG Pipeline 호출.
|
||||
const result = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, {
|
||||
brain: deps.activeBrain,
|
||||
@@ -180,8 +205,29 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
workStateSignals,
|
||||
hierarchicalReweightEnabled: config.hierarchicalReweightEnabled !== false,
|
||||
});
|
||||
|
||||
// Semantic Re-rank (LLM, async) — selectedChunks 의 *순서* 만 재배치. 토큰 예산을
|
||||
// 통과한 chunks 안에서 의도-부합도 순으로 재정렬해 LLM attention bias 활용.
|
||||
// 기본 OFF — latency 우려. 사용자가 명시 enable 시만.
|
||||
if (config.semanticRerankEnabled && result.selectedChunks.length >= 3) {
|
||||
const rerankModel = (config.semanticRerankModel || '').trim() || config.defaultModel;
|
||||
if (rerankModel && config.ollamaUrl) {
|
||||
const rerankRes = await semanticRerank(deps.currentPrompt, result.selectedChunks, {
|
||||
ollamaUrl: config.ollamaUrl,
|
||||
model: rerankModel,
|
||||
candidateK: config.semanticRerankCandidateK ?? DEFAULT_SEMANTIC_RERANK_OPTIONS.candidateK,
|
||||
timeoutMs: (config.semanticRerankTimeoutSec ?? 8) * 1000,
|
||||
excerptLength: DEFAULT_SEMANTIC_RERANK_OPTIONS.excerptLength,
|
||||
});
|
||||
// In-place 교체 — buildContextString 가 이 배열을 그대로 읽음.
|
||||
result.selectedChunks = rerankRes.rerankedChunks;
|
||||
result.fusionLog.push(`Semantic re-rank: ${rerankRes.success ? '✓' : '✗'} ${rerankRes.note} (${rerankRes.durationMs}ms)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로
|
||||
// steady-state turn 은 작업량 0 — 다음 turn 이 혜택.
|
||||
if (config.embeddingModel) {
|
||||
@@ -225,6 +271,55 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
};
|
||||
|
||||
deps.turnCtx.lessons = lessonChunks.map((c) => c.content);
|
||||
|
||||
// 동적 시스템 프롬프트 블록 빌드 — 등록 순서대로 turnCtx.dynamicBlocks 에 set.
|
||||
// 옛 named field 5개 (conflictWarnings/coveChecklist/intentClarification/citationTrace/
|
||||
// terminology) 통합. 새 블록 추가 = 여기서 setBlock 한 줄.
|
||||
const blocks = deps.turnCtx.dynamicBlocks;
|
||||
|
||||
// Intent Clarification — 답변보다 *역질문 우선*. 모호 아닐 때 빈 문자열 → join 시 자동 제외.
|
||||
if (config.intentClarificationEnabled !== false) {
|
||||
const strict = (config.intentClarificationStrictness || 'medium') as IntentStrictness;
|
||||
const ambig = detectAmbiguity(deps.currentPrompt, strict);
|
||||
blocks.set('intent-clarification', buildIntentClarificationBlock(ambig));
|
||||
}
|
||||
|
||||
// Terminology Dictionary — 사용자 편집 글로서리. 파일 없으면 빈 문자열.
|
||||
if (config.glossaryEnabled !== false) {
|
||||
blocks.set('terminology', buildTerminologyBlock({
|
||||
relPath: config.glossaryPath || '.astra/glossary.md',
|
||||
maxBodyLength: config.glossaryMaxBodyLength ?? 4000,
|
||||
}));
|
||||
}
|
||||
|
||||
// Conflict Surface — selectedChunks 의 per-doc conflictSeverity + 교차-문서 발산.
|
||||
if (config.conflictHighlightingEnabled !== false) {
|
||||
const threshold: ConflictThresholdSetting = (config.conflictSeverityThreshold || 'medium') as ConflictThresholdSetting;
|
||||
blocks.set('conflict-warnings', buildConflictWarningsBlock(result.selectedChunks, {
|
||||
selfFlagThreshold: threshold,
|
||||
crossDivergenceEnabled: config.conflictCrossDocEnabled !== false,
|
||||
}));
|
||||
}
|
||||
|
||||
// CoVe — 답변 *작성 전* 그라운딩 체크리스트.
|
||||
if (config.coveEnabled !== false) {
|
||||
blocks.set('cove-checklist', buildCoveChecklistBlock(result.selectedChunks, deps.currentPrompt, {
|
||||
topSourcesCount: config.coveTopSourcesCount ?? 5,
|
||||
strictMode: config.coveStrictMode === true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Citation Trace — 답변 끝 출처 한 줄.
|
||||
if (config.citationTraceEnabled !== false && result.selectedChunks.length > 0) {
|
||||
blocks.set('citation-trace', buildCitationTraceBlock(result.selectedChunks));
|
||||
}
|
||||
|
||||
// Self-Check 용 source 미리보기 — agent.ts 가 post-stream 에서 사용.
|
||||
deps.turnCtx.selfCheckSources = result.selectedChunks.slice(0, 5).map((c) => ({
|
||||
title: c.title || '(제목 없음)',
|
||||
excerpt: (c.content || '').slice(0, 200),
|
||||
}));
|
||||
|
||||
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
|
||||
// 살아남게.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* mtime-keyed file cache utility — 파일을 *parse 결과* 까지 캐싱.
|
||||
*
|
||||
* 배경: terminologyBlock.ts 와 termValidator.ts 가 같은 글로서리 파일에 *별도*
|
||||
* 캐시 2개를 보유. "함께 무효화" 가 사람이 손으로 보장하는 invariant — 한쪽만
|
||||
* 잊고 invalidate 안 하면 stale read 위험.
|
||||
*
|
||||
* 이 유틸은:
|
||||
* - 같은 파일을 캐싱하는 모든 consumer 가 같은 cache 인스턴스 사용
|
||||
* - mtime 자동 체크 — 파일이 바뀌면 자동 재read
|
||||
* - `invalidate(filePath)` 가 한 번 호출되면 그 파일의 *모든 parse 결과* 무효화
|
||||
*
|
||||
* 사용:
|
||||
* const cache = createMtimeFileCache<MyParsed>('terminology', (raw) => parseMine(raw));
|
||||
* const data = cache.read('/path/to/file.md'); // 캐시 hit or read+parse
|
||||
* cache.invalidate('/path/to/file.md'); // 캐시 비움
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
interface CacheEntry<T> {
|
||||
mtimeMs: number;
|
||||
parsed: T;
|
||||
}
|
||||
|
||||
export interface MtimeFileCache<T> {
|
||||
/** 파일 read + parse + 캐시. mtime 같으면 캐시 hit. 파일 없으면 null. */
|
||||
read(filePath: string): T | null;
|
||||
/** 특정 파일 캐시 무효화. */
|
||||
invalidate(filePath: string): void;
|
||||
/** 모든 캐시 비움. */
|
||||
clear(): void;
|
||||
/** 디버그용 — 현재 캐시 사이즈. */
|
||||
size(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 mtime-keyed 캐시 생성. `name` 은 디버그용 라벨 (consumer 식별).
|
||||
* `parse` 는 raw 파일 본문 → 파싱 결과. throw 시 read() 가 null 반환.
|
||||
*/
|
||||
export function createMtimeFileCache<T>(
|
||||
_name: string,
|
||||
parse: (raw: string, filePath: string) => T,
|
||||
): MtimeFileCache<T> {
|
||||
const cache = new Map<string, CacheEntry<T>>();
|
||||
|
||||
return {
|
||||
read(filePath: string): T | null {
|
||||
try {
|
||||
if (!fs.existsSync(filePath)) return null;
|
||||
const stat = fs.statSync(filePath);
|
||||
const cached = cache.get(filePath);
|
||||
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.parsed;
|
||||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||||
const parsed = parse(raw, filePath);
|
||||
cache.set(filePath, { mtimeMs: stat.mtimeMs, parsed });
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
invalidate(filePath: string): void {
|
||||
cache.delete(filePath);
|
||||
},
|
||||
clear(): void {
|
||||
cache.clear();
|
||||
},
|
||||
size(): number {
|
||||
return cache.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -130,7 +130,12 @@ export class EpisodicMemory {
|
||||
* 프롬프트와 관련된 에피소드를 검색합니다.
|
||||
*/
|
||||
public findRelevantEpisodes(prompt: string, limit = 3): EpisodicEntry[] {
|
||||
const episodes = this.loadAllEpisodes();
|
||||
// Temporal + Distillation 필터: 만료된 episode 와 LongTerm 으로 이미 promote 된
|
||||
// episode 는 검색에서 제외 (digest 가 LongTerm 에 있으니 중복 노출 방지).
|
||||
const now = Date.now();
|
||||
const episodes = this.loadAllEpisodes()
|
||||
.filter((ep) => !ep.expiresAt || ep.expiresAt > now)
|
||||
.filter((ep) => !ep.promoted);
|
||||
const promptLower = prompt.toLowerCase();
|
||||
const terms = promptLower
|
||||
.split(/[^a-z0-9가-힣_]+/g)
|
||||
@@ -276,6 +281,45 @@ export class EpisodicMemory {
|
||||
.map(([word]) => word);
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 episode 의 promoted 플래그를 true 로 마킹하고 LongTerm digest id 를 기록.
|
||||
* Distillation Loop 가 호출. 파일 rewrite 1회.
|
||||
*/
|
||||
public markPromoted(episodeId: string, longTermDigestId: string): boolean {
|
||||
const episodes = this.loadAllEpisodes();
|
||||
const ep = episodes.find((e) => e.id === episodeId);
|
||||
if (!ep) return false;
|
||||
ep.promoted = true;
|
||||
ep.promotedToLongTermId = longTermDigestId;
|
||||
// Find the file holding this episode and rewrite.
|
||||
try {
|
||||
const files = fs.readdirSync(this.episodeDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const full = path.join(this.episodeDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as EpisodicEntry;
|
||||
if (parsed.id === episodeId) {
|
||||
parsed.promoted = true;
|
||||
parsed.promotedToLongTermId = longTermDigestId;
|
||||
fs.writeFileSync(full, JSON.stringify(parsed, null, 2), 'utf-8');
|
||||
this._episodeCache = null; // dir mtime bump → cache 다음 호출에 갱신
|
||||
return true;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Distillation 후보 — 지정 일수보다 오래되고 아직 promoted 되지 않은 episodes. */
|
||||
public findStaleEpisodes(ageThresholdDays: number): EpisodicEntry[] {
|
||||
const cutoff = Date.now() - ageThresholdDays * 24 * 60 * 60 * 1000;
|
||||
return this.loadAllEpisodes()
|
||||
.filter((ep) => !ep.promoted)
|
||||
.filter((ep) => ep.timestamp < cutoff);
|
||||
}
|
||||
|
||||
private pruneOldEpisodes(): void {
|
||||
try {
|
||||
const files = fs.readdirSync(this.episodeDir)
|
||||
|
||||
@@ -53,7 +53,13 @@ export class LongTermMemory {
|
||||
|
||||
// ─── CRUD ───
|
||||
|
||||
public addEntry(category: LongTermCategory, content: string, source: string, confidence = 0.8): LongTermEntry {
|
||||
public addEntry(
|
||||
category: LongTermCategory,
|
||||
content: string,
|
||||
source: string,
|
||||
confidence = 0.8,
|
||||
opts: { expiresAt?: number } = {},
|
||||
): LongTermEntry {
|
||||
const entry: LongTermEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
category,
|
||||
@@ -62,7 +68,8 @@ export class LongTermMemory {
|
||||
confidence,
|
||||
createdAt: Date.now(),
|
||||
lastReferencedAt: Date.now(),
|
||||
referenceCount: 0
|
||||
referenceCount: 0,
|
||||
...(opts.expiresAt ? { expiresAt: opts.expiresAt } : {}),
|
||||
};
|
||||
this.store.entries.push(entry);
|
||||
// Enforce the retention cap — drop the oldest entries (by createdAt) once
|
||||
@@ -87,12 +94,32 @@ export class LongTermMemory {
|
||||
return false;
|
||||
}
|
||||
|
||||
public getAllEntries(): LongTermEntry[] {
|
||||
return [...this.store.entries];
|
||||
/** 만료된 entry 를 필터링한 활성 entries (검색·context build 가 보는 것). */
|
||||
private getActiveEntries(): LongTermEntry[] {
|
||||
const now = Date.now();
|
||||
return this.store.entries.filter((e) => !e.expiresAt || e.expiresAt > now);
|
||||
}
|
||||
|
||||
public getAllEntries(opts: { includeExpired?: boolean } = {}): LongTermEntry[] {
|
||||
return opts.includeExpired ? [...this.store.entries] : this.getActiveEntries();
|
||||
}
|
||||
|
||||
public getEntriesByCategory(category: LongTermCategory): LongTermEntry[] {
|
||||
return this.store.entries.filter((e) => e.category === category);
|
||||
return this.getActiveEntries().filter((e) => e.category === category);
|
||||
}
|
||||
|
||||
/**
|
||||
* 기존 entry 의 expiresAt 갱신. id 또는 id prefix (8자 이상) 로 식별.
|
||||
* 반환: 갱신된 entry 또는 null (못 찾음).
|
||||
*/
|
||||
public setExpiration(idOrPrefix: string, expiresAt: number): LongTermEntry | null {
|
||||
const match = this.store.entries.find((e) => e.id === idOrPrefix)
|
||||
|| (idOrPrefix.length >= 8 ? this.store.entries.find((e) => e.id.startsWith(idOrPrefix)) : undefined);
|
||||
if (!match) return null;
|
||||
match.expiresAt = expiresAt;
|
||||
this.dirty = true;
|
||||
this.save();
|
||||
return match;
|
||||
}
|
||||
|
||||
// ─── Context Building ───
|
||||
@@ -101,7 +128,9 @@ export class LongTermMemory {
|
||||
* 프롬프트와 관련성이 높은 Long-Term 기억을 반환합니다.
|
||||
*/
|
||||
public buildContext(currentPrompt: string, maxEntries = 10): MemoryContextResult | null {
|
||||
if (this.store.entries.length === 0) return null;
|
||||
// 만료된 entry 는 검색에서 자동 제외 — Temporal Markers 의 핵심.
|
||||
const activeEntries = this.getActiveEntries();
|
||||
if (activeEntries.length === 0) return null;
|
||||
|
||||
const promptLower = currentPrompt.toLowerCase();
|
||||
const terms = promptLower
|
||||
@@ -109,7 +138,7 @@ export class LongTermMemory {
|
||||
.filter((t) => t.length >= 2);
|
||||
|
||||
// Score entries by relevance to prompt
|
||||
const scored = this.store.entries.map((entry) => {
|
||||
const scored = activeEntries.map((entry) => {
|
||||
let score = 0;
|
||||
const contentLower = entry.content.toLowerCase();
|
||||
|
||||
@@ -134,8 +163,8 @@ export class LongTermMemory {
|
||||
.slice(0, maxEntries);
|
||||
|
||||
if (relevant.length === 0) {
|
||||
// Still include all rules and goals even without prompt match
|
||||
const alwaysInclude = this.store.entries
|
||||
// Still include all rules and goals even without prompt match — 만료 제외.
|
||||
const alwaysInclude = activeEntries
|
||||
.filter((e) => e.category === 'rule' || e.category === 'goal')
|
||||
.slice(0, 5);
|
||||
if (alwaysInclude.length === 0) return null;
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급.
|
||||
*
|
||||
* 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지난 에피소드는
|
||||
* "지금 이 순간 관련 가능성" 보다 "역사적 패턴" 가치가 커서, 디테일을 압축해
|
||||
* Long-Term 으로 옮기고 원본은 archive 하는 게 효율적.
|
||||
*
|
||||
* v1 설계 (LLM-less, 예측 가능):
|
||||
* - LLM 호출 없이 기존 EpisodicEntry 의 title/summary/keyDecisions/topics 를
|
||||
* 구조적으로 결합해 LongTerm 'episode-digest' content 생성
|
||||
* - 장점: 비용 0, 결정적·재현 가능, LM Studio 다운 시에도 동작
|
||||
* - 단점: LLM 요약보다 농축도 낮음 — 추후 strict 모드에서 LLM 패스 추가 가능
|
||||
*
|
||||
* 원본 episode 처리: 두 가지 옵션 — 사용자 설정으로 결정.
|
||||
* - 'mark-promoted' (기본): promoted=true 마킹만, 파일 보존. 검색에서 제외되나
|
||||
* 히스토리·디버깅용으로 디스크에 남음.
|
||||
* - 'archive-file': promoted 마킹 + 파일을 memory/episodes/archive/ 로 이동.
|
||||
* 디스크 정리에 더 깔끔하나 복구 시 수동.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { EpisodicMemory } from './EpisodicMemory';
|
||||
import { LongTermMemory } from './LongTermMemory';
|
||||
import { EpisodicEntry } from './types';
|
||||
|
||||
export type DistillationArchiveMode = 'mark-promoted' | 'archive-file';
|
||||
|
||||
export interface DistillationOptions {
|
||||
/** 며칠 이상 지난 episode 를 대상으로. 기본 30. */
|
||||
ageThresholdDays: number;
|
||||
/** Archive 처리 방식. 기본 'mark-promoted'. */
|
||||
archiveMode: DistillationArchiveMode;
|
||||
/** 한 번에 처리할 최대 episode 수 (안전장치). 기본 50. */
|
||||
maxBatchSize: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_DISTILLATION_OPTIONS: DistillationOptions = {
|
||||
ageThresholdDays: 30,
|
||||
archiveMode: 'mark-promoted',
|
||||
maxBatchSize: 50,
|
||||
};
|
||||
|
||||
export interface DistillationReport {
|
||||
candidateCount: number;
|
||||
promotedCount: number;
|
||||
archivedCount: number;
|
||||
longTermDigestIds: string[];
|
||||
skipped: { episodeId: string; reason: string }[];
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Episode → LongTerm 'episode-digest' content 변환. 결정적·LLM 없음.
|
||||
*/
|
||||
function episodeToDigestContent(ep: EpisodicEntry): string {
|
||||
const date = new Date(ep.timestamp).toISOString().slice(0, 10);
|
||||
const parts: string[] = [];
|
||||
parts.push(`[${date}] ${ep.title}`);
|
||||
if (ep.summary && ep.summary.trim()) parts.push(`요약: ${ep.summary.trim()}`);
|
||||
if (ep.keyDecisions && ep.keyDecisions.length > 0) {
|
||||
parts.push(`결정: ${ep.keyDecisions.slice(0, 5).join(' · ')}`);
|
||||
}
|
||||
if (ep.topics && ep.topics.length > 0) {
|
||||
parts.push(`토픽: ${ep.topics.slice(0, 8).join(', ')}`);
|
||||
}
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Distillation 실행 — stale episodes 를 LongTerm digest 로 승급 + archive.
|
||||
*
|
||||
* 호출자: `/memory distill` 슬래시 명령 + 세션 종료 시 auto-trigger (선택).
|
||||
*/
|
||||
export function distillStaleEpisodes(
|
||||
episodicMemory: EpisodicMemory,
|
||||
longTermMemory: LongTermMemory,
|
||||
brainPath: string,
|
||||
options: Partial<DistillationOptions> = {},
|
||||
): DistillationReport {
|
||||
const opts: DistillationOptions = { ...DEFAULT_DISTILLATION_OPTIONS, ...options };
|
||||
const start = Date.now();
|
||||
const report: DistillationReport = {
|
||||
candidateCount: 0,
|
||||
promotedCount: 0,
|
||||
archivedCount: 0,
|
||||
longTermDigestIds: [],
|
||||
skipped: [],
|
||||
durationMs: 0,
|
||||
};
|
||||
|
||||
const candidates = episodicMemory.findStaleEpisodes(opts.ageThresholdDays).slice(0, opts.maxBatchSize);
|
||||
report.candidateCount = candidates.length;
|
||||
|
||||
if (candidates.length === 0) {
|
||||
report.durationMs = Date.now() - start;
|
||||
return report;
|
||||
}
|
||||
|
||||
const archiveDir = path.join(brainPath, 'memory', 'episodes', 'archive');
|
||||
if (opts.archiveMode === 'archive-file') {
|
||||
try { fs.mkdirSync(archiveDir, { recursive: true }); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
for (const ep of candidates) {
|
||||
try {
|
||||
// 1. LongTerm digest entry 생성. confidence 약간 낮춤 (압축 손실 반영).
|
||||
const digestContent = episodeToDigestContent(ep);
|
||||
const digest = longTermMemory.addEntry(
|
||||
'episode-digest',
|
||||
digestContent,
|
||||
`episodic:${ep.id}`,
|
||||
0.7,
|
||||
);
|
||||
report.longTermDigestIds.push(digest.id);
|
||||
|
||||
// 2. 원본 episode 처리.
|
||||
const marked = episodicMemory.markPromoted(ep.id, digest.id);
|
||||
if (!marked) {
|
||||
report.skipped.push({ episodeId: ep.id, reason: 'markPromoted failed (file not found)' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (opts.archiveMode === 'archive-file') {
|
||||
// 파일 위치 찾아서 archive 디렉터리로 이동.
|
||||
const moved = tryMoveEpisodeFileToArchive(ep.id, path.join(brainPath, 'memory', 'episodes'), archiveDir);
|
||||
if (moved) report.archivedCount++;
|
||||
}
|
||||
report.promotedCount++;
|
||||
} catch (e: any) {
|
||||
report.skipped.push({ episodeId: ep.id, reason: e?.message || String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
report.durationMs = Date.now() - start;
|
||||
return report;
|
||||
}
|
||||
|
||||
function tryMoveEpisodeFileToArchive(episodeId: string, episodeDir: string, archiveDir: string): boolean {
|
||||
try {
|
||||
const files = fs.readdirSync(episodeDir).filter((f) => f.endsWith('.json'));
|
||||
for (const file of files) {
|
||||
const full = path.join(episodeDir, file);
|
||||
try {
|
||||
const raw = fs.readFileSync(full, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as EpisodicEntry;
|
||||
if (parsed.id === episodeId) {
|
||||
fs.renameSync(full, path.join(archiveDir, file));
|
||||
return true;
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distillation 마지막 실행 시각을 저장·조회 — 자동 트리거가 *너무 자주* 안 돌도록.
|
||||
* brainPath 의 marker 파일 사용 (vscode.globalState 안 쓰는 이유: 메모리 인프라가
|
||||
* BrainProfile-scoped 라 brain 디렉터리에 두는 게 일관성 있음).
|
||||
*/
|
||||
const MARKER_FILE = 'distillation_last_run.json';
|
||||
|
||||
export interface DistillationMarker {
|
||||
timestamp: number;
|
||||
report?: Partial<DistillationReport>;
|
||||
}
|
||||
|
||||
export function getLastDistillationRun(brainPath: string): DistillationMarker | null {
|
||||
try {
|
||||
const fp = path.join(brainPath, 'memory', MARKER_FILE);
|
||||
if (!fs.existsSync(fp)) return null;
|
||||
return JSON.parse(fs.readFileSync(fp, 'utf-8')) as DistillationMarker;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
export function recordDistillationRun(brainPath: string, report: DistillationReport): void {
|
||||
try {
|
||||
const dir = path.join(brainPath, 'memory');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const marker: DistillationMarker = {
|
||||
timestamp: Date.now(),
|
||||
report: {
|
||||
candidateCount: report.candidateCount,
|
||||
promotedCount: report.promotedCount,
|
||||
archivedCount: report.archivedCount,
|
||||
durationMs: report.durationMs,
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(path.join(dir, MARKER_FILE), JSON.stringify(marker, null, 2), 'utf-8');
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 자동 트리거 게이트 — 마지막 실행 후 N일 경과 시 true. */
|
||||
export function shouldAutoDistill(brainPath: string, intervalDays: number): boolean {
|
||||
const last = getLastDistillationRun(brainPath);
|
||||
if (!last) return true;
|
||||
const elapsed = (Date.now() - last.timestamp) / (1000 * 60 * 60 * 24);
|
||||
return elapsed >= intervalDays;
|
||||
}
|
||||
+38
-1
@@ -20,6 +20,12 @@ import { ProceduralMemory } from './ProceduralMemory';
|
||||
import { EpisodicMemory } from './EpisodicMemory';
|
||||
import { MemoryExtractor } from './MemoryExtractor';
|
||||
import { MemoryContextResult, MemoryConfig } from './types';
|
||||
import {
|
||||
distillStaleEpisodes,
|
||||
shouldAutoDistill,
|
||||
recordDistillationRun,
|
||||
type DistillationArchiveMode,
|
||||
} from './distillation';
|
||||
|
||||
export { ShortTermMemory } from './ShortTermMemory';
|
||||
export { LongTermMemory } from './LongTermMemory';
|
||||
@@ -27,6 +33,17 @@ export { ProjectMemory } from './ProjectMemory';
|
||||
export { ProceduralMemory } from './ProceduralMemory';
|
||||
export { EpisodicMemory } from './EpisodicMemory';
|
||||
export { MemoryExtractor } from './MemoryExtractor';
|
||||
export {
|
||||
distillStaleEpisodes,
|
||||
getLastDistillationRun,
|
||||
recordDistillationRun,
|
||||
shouldAutoDistill,
|
||||
DEFAULT_DISTILLATION_OPTIONS,
|
||||
type DistillationOptions,
|
||||
type DistillationReport,
|
||||
type DistillationMarker,
|
||||
type DistillationArchiveMode,
|
||||
} from './distillation';
|
||||
export * from './types';
|
||||
|
||||
export class MemoryManager {
|
||||
@@ -132,7 +149,14 @@ export class MemoryManager {
|
||||
public onSessionEnd(
|
||||
sessionId: string,
|
||||
messages: Array<{ role: string; content: string; timestamp?: number }>,
|
||||
workspacePath?: string
|
||||
workspacePath?: string,
|
||||
distillationOpts?: {
|
||||
enabled: boolean;
|
||||
ageThresholdDays: number;
|
||||
intervalDays: number;
|
||||
archiveMode: DistillationArchiveMode;
|
||||
brainPath: string;
|
||||
},
|
||||
): void {
|
||||
if (!this.config.enabled) return;
|
||||
|
||||
@@ -153,6 +177,19 @@ export class MemoryManager {
|
||||
|
||||
// Persist long-term memory
|
||||
this.longTerm.save();
|
||||
|
||||
// Auto-distillation — Distillation Loop 가 enabled 이고 interval 충족 시 stale
|
||||
// episodes 를 LongTerm digest 로 승급. 세션 종료 시점이 자연스러움 — 사용자가
|
||||
// 다음 세션 시작 전 한 번 cleanup.
|
||||
if (distillationOpts?.enabled && shouldAutoDistill(distillationOpts.brainPath, distillationOpts.intervalDays)) {
|
||||
try {
|
||||
const report = distillStaleEpisodes(this.episodic, this.longTerm, distillationOpts.brainPath, {
|
||||
ageThresholdDays: distillationOpts.ageThresholdDays,
|
||||
archiveMode: distillationOpts.archiveMode,
|
||||
});
|
||||
recordDistillationRun(distillationOpts.brainPath, report);
|
||||
} catch { /* distillation should never break session end */ }
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Direct Access (for UI & advanced features) ───
|
||||
|
||||
+26
-1
@@ -28,7 +28,12 @@ export interface ShortTermMessage {
|
||||
|
||||
// ─── ② Long-Term Memory ───
|
||||
|
||||
export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal';
|
||||
/**
|
||||
* Long-term category.
|
||||
* - 'episode-digest' 는 Distillation Loop 가 stale episodic memory 를 long-term 으로
|
||||
* 승급시킬 때 사용. 사용자가 직접 만드는 'decision' / 'rule' 등과 시각적으로 구분.
|
||||
*/
|
||||
export type LongTermCategory = 'preference' | 'rule' | 'decision' | 'goal' | 'episode-digest';
|
||||
|
||||
export interface LongTermEntry {
|
||||
id: string;
|
||||
@@ -39,6 +44,14 @@ export interface LongTermEntry {
|
||||
createdAt: number;
|
||||
lastReferencedAt: number;
|
||||
referenceCount: number;
|
||||
/**
|
||||
* Temporal marker — 이 사실이 *유효한 마지막 시점* (epoch ms).
|
||||
* 검색·context build 단계에서 expiresAt < now 인 entry 는 자동 제외.
|
||||
* undefined 면 영구 유효 (legacy 동작).
|
||||
*
|
||||
* 사용 예: "Q3 2026 마케팅 계획은 9월 30일까지만 유효" → expiresAt = 2026-09-30 epoch.
|
||||
*/
|
||||
expiresAt?: number;
|
||||
}
|
||||
|
||||
export interface LongTermStore {
|
||||
@@ -105,6 +118,18 @@ export interface EpisodicEntry {
|
||||
timestamp: number;
|
||||
duration: number; // 세션 길이 (ms)
|
||||
messageCount: number;
|
||||
/**
|
||||
* Temporal marker — 에피소드의 *유효 마지막 시점* (epoch ms). 검색에서 자동 제외.
|
||||
* undefined 면 영구 (Distillation 이 archive 할 때까지).
|
||||
*/
|
||||
expiresAt?: number;
|
||||
/**
|
||||
* Distillation Loop 가 이 episode 를 LongTerm digest 로 promote 했음을 표시.
|
||||
* promoted=true 면 검색·context build 에서 제외 (LongTerm 에 digest 가 있으니).
|
||||
*/
|
||||
promoted?: boolean;
|
||||
/** promoted 인 경우 — 생성된 LongTerm digest entry id (역참조용). */
|
||||
promotedToLongTermId?: string;
|
||||
}
|
||||
|
||||
export interface EpisodicStore {
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중.
|
||||
*
|
||||
* 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는
|
||||
* 작업과 *직접 연결* 된 문서" 가 우선되지 않음. 예: 사용자가 `/runway` 명령을
|
||||
* 막 실행했다면 runway / 재무 관련 문서가 같은 키워드 매치 점수여도 더 위로 와야 함.
|
||||
*
|
||||
* v1 신호 (사용자 선택):
|
||||
* 1. **최근 슬래시 명령** — 마지막 N개 실행된 슬래시 명령 이름을 키워드로 활용
|
||||
* → 명령 이름이 chunk title/content 에 포함되면 boost
|
||||
* 2. **열린 파일 경로** — VS Code 활성 에디터의 파일 이름·확장자·부모 디렉터리를
|
||||
* 키워드로 활용
|
||||
*
|
||||
* 점수 결합: TF-IDF normalized score (0~1) × (1 + actionabilityScore × weight)
|
||||
* - weight 기본 0.3 → actionability=1.0 인 chunk 는 30% boost
|
||||
* - actionability=0.0 인 chunk 는 변화 없음
|
||||
* - TF-IDF 가 여전히 dominant 인 보수적 합산
|
||||
*
|
||||
* 향후 신호 (#1 v2 후보 — 사용자 선택 안 함):
|
||||
* 3. 최근 7일 Chronicle ADR / decisions
|
||||
* 4. 최근 24시간 customers/hire/runway 이벤트
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface WorkStateSignals {
|
||||
/** 최근 실행된 슬래시 명령 이름 목록 ('/' 포함, 최신 순). 빈 배열이면 신호 없음. */
|
||||
recentSlashCommands: string[];
|
||||
/** VS Code 활성 에디터의 파일 절대 경로. undefined 면 신호 없음. */
|
||||
openFilePath?: string;
|
||||
}
|
||||
|
||||
export interface ActionabilityWeights {
|
||||
/** 슬래시 명령 매치당 boost. 기본 0.30. */
|
||||
slashCommandMatch: number;
|
||||
/** 파일명 매치 boost. 기본 0.40 (가장 강함). */
|
||||
openFileNameMatch: number;
|
||||
/** 부모 디렉터리 매치 boost. 기본 0.20. */
|
||||
openFileParentDirMatch: number;
|
||||
/** 확장자 의미 매치 boost (e.g. .ts → typescript/tsx 단어). 기본 0.10. */
|
||||
openFileExtMatch: number;
|
||||
/** 최종 결합 가중치 — finalScore = base × (1 + actionability × this). 기본 0.30. */
|
||||
combinedWeight: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_ACTIONABILITY_WEIGHTS: ActionabilityWeights = {
|
||||
slashCommandMatch: 0.30,
|
||||
openFileNameMatch: 0.40,
|
||||
openFileParentDirMatch: 0.20,
|
||||
openFileExtMatch: 0.10,
|
||||
combinedWeight: 0.30,
|
||||
};
|
||||
|
||||
/** VS Code 활성 에디터·최근 슬래시 명령에서 work-state 신호 캡처. */
|
||||
export function captureWorkStateSignals(recentSlashCommands: string[]): WorkStateSignals {
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
return {
|
||||
recentSlashCommands: recentSlashCommands.slice(0, 5), // 최신 5개로 cap
|
||||
openFilePath: editor?.document.uri.fsPath,
|
||||
};
|
||||
}
|
||||
|
||||
const EXTENSION_KEYWORDS: Record<string, RegExp> = {
|
||||
'.ts': /\b(typescript|tsx?|ts)\b/i,
|
||||
'.tsx': /\b(typescript|tsx|react)\b/i,
|
||||
'.js': /\b(javascript|jsx?)\b/i,
|
||||
'.jsx': /\b(javascript|jsx|react)\b/i,
|
||||
'.py': /\b(python|py)\b/i,
|
||||
'.md': /\b(markdown|md|문서)\b/i,
|
||||
'.json': /\b(json|config)\b/i,
|
||||
'.go': /\b(golang|go)\b/i,
|
||||
'.rs': /\b(rust|rs)\b/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* 한 chunk 의 actionability 점수 계산 — 0.0 ~ 1.0 (cap).
|
||||
* 매치 boost 들의 단순 합산 후 1.0 cap.
|
||||
*/
|
||||
export function computeActionabilityScore(
|
||||
chunk: RetrievalChunk,
|
||||
signals: WorkStateSignals,
|
||||
weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS,
|
||||
): number {
|
||||
if (!chunk) return 0;
|
||||
const haystack = ((chunk.title || '') + ' ' + (chunk.content || '')).toLowerCase();
|
||||
if (!haystack.trim()) return 0;
|
||||
|
||||
let score = 0;
|
||||
|
||||
// Signal 1: 최근 슬래시 명령 — '/runway' → 'runway' 키워드 매치
|
||||
for (const cmd of signals.recentSlashCommands) {
|
||||
const kw = cmd.replace(/^\//, '').toLowerCase().trim();
|
||||
if (kw.length < 3) continue; // 너무 짧으면 노이즈 (/q 등)
|
||||
// 단어 경계 매치 — substring 이 아닌 단어 단위 (영문 한정, 한글은 substring)
|
||||
const isAscii = /^[a-z0-9-]+$/.test(kw);
|
||||
const wordRe = isAscii ? new RegExp(`\\b${escapeRegex(kw)}\\b`, 'i') : null;
|
||||
if (wordRe ? wordRe.test(haystack) : haystack.includes(kw)) {
|
||||
score += weights.slashCommandMatch;
|
||||
}
|
||||
}
|
||||
|
||||
// Signal 2: 열린 파일 — 파일명·부모 디렉터리·확장자
|
||||
if (signals.openFilePath) {
|
||||
const fp = signals.openFilePath;
|
||||
const ext = path.extname(fp).toLowerCase();
|
||||
const base = path.basename(fp, ext).toLowerCase();
|
||||
const parent = path.basename(path.dirname(fp)).toLowerCase();
|
||||
|
||||
// 파일 자체가 chunk 의 filePath 와 같으면 강한 boost (max)
|
||||
const chunkFile = chunk.metadata?.filePath?.toLowerCase();
|
||||
if (chunkFile && chunkFile === fp.toLowerCase()) {
|
||||
score += weights.openFileNameMatch * 1.5; // exact file = 보너스
|
||||
} else if (base.length >= 3) {
|
||||
const baseRe = new RegExp(`\\b${escapeRegex(base)}\\b`, 'i');
|
||||
if (baseRe.test(haystack)) score += weights.openFileNameMatch;
|
||||
}
|
||||
|
||||
if (parent.length >= 3 && parent !== 'src' && parent !== 'lib') {
|
||||
// 'src' / 'lib' 같은 일반 디렉터리는 신호로서 약함 — 제외
|
||||
const parentRe = new RegExp(`\\b${escapeRegex(parent)}\\b`, 'i');
|
||||
if (parentRe.test(haystack)) score += weights.openFileParentDirMatch;
|
||||
}
|
||||
|
||||
const extRe = EXTENSION_KEYWORDS[ext];
|
||||
if (extRe && extRe.test(haystack)) score += weights.openFileExtMatch;
|
||||
}
|
||||
|
||||
return Math.min(score, 1.0);
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Chunks 배열의 score 를 actionability 로 재가중. *원본 score 를 덮어씀* — 호출자는
|
||||
* 반드시 retrieval pipeline 안에서 normalizeScores 직후, selectWithinBudget 직전에만 호출.
|
||||
*
|
||||
* 각 chunk 에 actionabilityScore 를 metadata 에 기록 (디버깅·UI 표시 용도).
|
||||
*/
|
||||
export function applyActionabilityBoost(
|
||||
chunks: RetrievalChunk[],
|
||||
signals: WorkStateSignals,
|
||||
weights: ActionabilityWeights = DEFAULT_ACTIONABILITY_WEIGHTS,
|
||||
): void {
|
||||
if (!signals || (signals.recentSlashCommands.length === 0 && !signals.openFilePath)) return;
|
||||
for (const c of chunks) {
|
||||
const a = computeActionabilityScore(c, signals, weights);
|
||||
if (a > 0) {
|
||||
(c.metadata as any).actionabilityScore = a;
|
||||
c.score = c.score * (1 + a * weights.combinedWeight);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Citation Trace — 답변 *끝* 에 "출처:" 한 줄 명시 지시.
|
||||
*
|
||||
* CoVe Strict 모드 (v2.2.184) 와 차이:
|
||||
* - CoVe Strict: 모든 사실 주장 뒤에 inline `[S1]` 인용 강제 — verbose, 학술적
|
||||
* - Citation Trace: 답변 끝에 *사용된 출처* 한 줄 정리 — 가벼움, 항상 ON 권장
|
||||
*
|
||||
* 둘은 함께 동작 가능. CoVe 가 [S1]..[SN] 라벨을 system prompt 에 노출하면,
|
||||
* Citation Trace 는 LLM 에게 "그 라벨들 중 답변에 *실제로 사용된* 것을 끝에 한 줄
|
||||
* 정리" 라고 지시.
|
||||
*
|
||||
* 효과: 사용자가 답변 검증 가능 — "이 답변이 어느 출처에 기반했나" 명시.
|
||||
* 할루시네이션 억제 — LLM 이 출처 없는 주장 줄임.
|
||||
*
|
||||
* 비용: 시스템 프롬프트 ~10줄 추가. LLM 출력에 1줄 추가.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface CitationTraceOptions {
|
||||
/** 답변 끝 *출처 한 줄* 형식. 'tail' 만 v1 지원. */
|
||||
format: 'tail';
|
||||
}
|
||||
|
||||
/**
|
||||
* Citation Trace 블록 — chunks 가 *있어야* 의미 있으니 비어 있으면 빈 문자열.
|
||||
* Casual conversation 모드는 호출자가 미리 걸러야.
|
||||
*/
|
||||
export function buildCitationTraceBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
options: Partial<CitationTraceOptions> = {},
|
||||
): string {
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[CITATION TRACE]');
|
||||
lines.push('답변에서 *검색된 출처를 사용했다면*, 답변 끝에 다음 형식으로 *한 줄* 정리:');
|
||||
lines.push('');
|
||||
lines.push('*출처:* `파일명.md` · `chunk-title` · `chunk-title2`');
|
||||
lines.push('');
|
||||
lines.push('[규칙]');
|
||||
lines.push('1. 실제 답변 작성에 *사용한* 출처만 나열. 검색됐지만 안 쓴 출처는 제외.');
|
||||
lines.push('2. 출처 라벨은 파일명(있으면) 또는 chunk title 그대로 — 임의 변형 금지.');
|
||||
lines.push('3. 일반 모델 지식만 사용했다면: *출처: 모델 지식 (검색 출처 미사용)*');
|
||||
lines.push('4. 답변이 검증 가능하도록 — 사용자가 그 파일을 열면 답변 근거를 확인할 수 있어야.');
|
||||
lines.push('5. *출처:* 라인은 답변 *맨 끝* 한 번만 — 본문 중간에 흩어 놓지 말 것.');
|
||||
lines.push('[/CITATION TRACE]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Conflict Surface — [CONFLICT WARNINGS] 시스템 프롬프트 블록 생성.
|
||||
*
|
||||
* 기존 scoring.ts 가 문서당 conflictSeverity(NONE/LOW/MEDIUM/HIGH) 를 *이미*
|
||||
* 계산하지만(반대/논란/vs 등 indicator 단어 카운트), LLM 은 그 사실을 모름.
|
||||
* buildAstraModeSystemPrompt 의 v4 정책 텍스트는 이미 "[CONFLICT WARNING] 플래그"
|
||||
* 를 *언급*하나, 실제 어떤 문서가 충돌인지 LLM 에게 *전달되지 않음* — 정책이
|
||||
* 명시되어 있지만 데이터가 없어 무용한 상태.
|
||||
*
|
||||
* 이 모듈이 그 갭을 메움:
|
||||
* 1. 자기-신호(self-flag) — chunk.metadata.conflictSeverity ≥ threshold
|
||||
* 2. 교차-문서 발산(cross-divergence) — 같은 주제 2 chunks, Jaccard < 임계
|
||||
*
|
||||
* 둘을 합쳐 마크다운 블록 한 개로. 결과가 비면 빈 문자열 반환 — 호출자가
|
||||
* 안전하게 무조건 join 가능.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk, ConflictSeverity } from './types';
|
||||
import { tokenize } from './scoring';
|
||||
|
||||
/** 사용자 설정 임계값. 'low' = LOW 부터, 'medium' = MEDIUM 부터, 'high' = HIGH 만. */
|
||||
export type ConflictThresholdSetting = 'low' | 'medium' | 'high';
|
||||
|
||||
export interface ConflictBlockOptions {
|
||||
/** 자기-신호 surface 시 최소 severity. 기본 'medium'. */
|
||||
selfFlagThreshold: ConflictThresholdSetting;
|
||||
/** 교차 발산 감지 enable. 기본 true. */
|
||||
crossDivergenceEnabled: boolean;
|
||||
/** 자기-신호 / 교차 발산 각각 표시 최대 건수. 기본 5. */
|
||||
maxPerSection: number;
|
||||
/** Chunk 미리보기 길이. 기본 220 chars. */
|
||||
excerptLength: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: ConflictBlockOptions = {
|
||||
selfFlagThreshold: 'medium',
|
||||
crossDivergenceEnabled: true,
|
||||
maxPerSection: 5,
|
||||
excerptLength: 220,
|
||||
};
|
||||
|
||||
function severityRank(s: ConflictSeverity | undefined): number {
|
||||
switch (s) {
|
||||
case 'HIGH': return 3;
|
||||
case 'MEDIUM': return 2;
|
||||
case 'LOW': return 1;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function thresholdRank(t: ConflictThresholdSetting): number {
|
||||
switch (t) {
|
||||
case 'high': return 3;
|
||||
case 'medium': return 2;
|
||||
case 'low': return 1;
|
||||
}
|
||||
}
|
||||
|
||||
function severityEmoji(s: ConflictSeverity | undefined): string {
|
||||
switch (s) {
|
||||
case 'HIGH': return '🔴';
|
||||
case 'MEDIUM': return '🟡';
|
||||
case 'LOW': return '🟠';
|
||||
default: return '⚪';
|
||||
}
|
||||
}
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
/** 두 토큰 집합의 Jaccard 유사도. */
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 || b.size === 0) return 0;
|
||||
let intersect = 0;
|
||||
for (const t of a) if (b.has(t)) intersect++;
|
||||
const union = a.size + b.size - intersect;
|
||||
return union === 0 ? 0 : intersect / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* 교차-문서 발산 후보 쌍 찾기.
|
||||
*
|
||||
* 휴리스틱:
|
||||
* 1. 각 chunk 의 title 토큰(최대 5개) 으로 "주제 키" 생성
|
||||
* 2. 동일 주제 키 2개 이상 공유하는 chunk 쌍을 후보로
|
||||
* 3. 본문 토큰 Jaccard < 0.30 이면 발산으로 판정 (같은 주제 다른 내용)
|
||||
* 4. 점수 = (공유 토픽 토큰 수) × (1 - Jaccard) — 발산이 클수록 우선
|
||||
*
|
||||
* 한 chunk 가 여러 쌍에 등장 가능 — 상위 N 쌍만 반환.
|
||||
*/
|
||||
interface DivergencePair {
|
||||
a: RetrievalChunk;
|
||||
b: RetrievalChunk;
|
||||
sharedTopicTokens: string[];
|
||||
contentJaccard: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
function findCrossDivergence(chunks: RetrievalChunk[], topicJaccardMax: number = 0.30): DivergencePair[] {
|
||||
if (chunks.length < 2) return [];
|
||||
|
||||
// Pre-compute title topic tokens + content token sets — n^2 비교 전에 한 번만.
|
||||
const titleTokenSets: Set<string>[] = [];
|
||||
const contentTokenSets: Set<string>[] = [];
|
||||
for (const c of chunks) {
|
||||
const titleTokens = tokenize(c.title || '').filter((t) => t.length >= 2);
|
||||
titleTokenSets.push(new Set(titleTokens.slice(0, 8)));
|
||||
contentTokenSets.push(new Set(tokenize(c.content || '')));
|
||||
}
|
||||
|
||||
const pairs: DivergencePair[] = [];
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
for (let j = i + 1; j < chunks.length; j++) {
|
||||
// 1. 같은 주제 — title 토큰 공유 ≥ 2
|
||||
const shared: string[] = [];
|
||||
for (const t of titleTokenSets[i]) if (titleTokenSets[j].has(t)) shared.push(t);
|
||||
if (shared.length < 2) continue;
|
||||
|
||||
// 2. 본문 발산 — Jaccard < 임계
|
||||
const cj = jaccard(contentTokenSets[i], contentTokenSets[j]);
|
||||
if (cj >= topicJaccardMax) continue;
|
||||
|
||||
pairs.push({
|
||||
a: chunks[i],
|
||||
b: chunks[j],
|
||||
sharedTopicTokens: shared,
|
||||
contentJaccard: cj,
|
||||
score: shared.length * (1 - cj),
|
||||
});
|
||||
}
|
||||
}
|
||||
pairs.sort((p, q) => q.score - p.score);
|
||||
return pairs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 프롬프트용 [CONFLICT WARNINGS] 블록 생성. 충돌 없으면 빈 문자열 반환.
|
||||
*
|
||||
* 호출 측은 무조건 join 해도 안전 — 빈 문자열이면 프롬프트에 추가 줄바꿈 없음.
|
||||
*/
|
||||
export function buildConflictWarningsBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
options: Partial<ConflictBlockOptions> = {},
|
||||
): string {
|
||||
const opts: ConflictBlockOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
// ─── Section 1: self-flag ───
|
||||
const threshold = thresholdRank(opts.selfFlagThreshold);
|
||||
const selfFlagged = chunks
|
||||
.filter((c) => severityRank(c.metadata?.conflictSeverity) >= threshold)
|
||||
.sort((a, b) => severityRank(b.metadata?.conflictSeverity) - severityRank(a.metadata?.conflictSeverity))
|
||||
.slice(0, opts.maxPerSection);
|
||||
|
||||
// ─── Section 2: cross-doc divergence ───
|
||||
const divergence = opts.crossDivergenceEnabled
|
||||
? findCrossDivergence(chunks).slice(0, opts.maxPerSection)
|
||||
: [];
|
||||
|
||||
if (selfFlagged.length === 0 && divergence.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[CONFLICT WARNINGS]');
|
||||
lines.push('다음 검색된 출처에서 충돌 신호 감지. 단일 결론을 강요하지 말고, 상충되는 관점을 명시하고 사용자 판단에 위임할 것.');
|
||||
lines.push('');
|
||||
|
||||
if (selfFlagged.length > 0) {
|
||||
lines.push('## 자기-신호 (출처 내부에서 충돌/논란 키워드 감지)');
|
||||
for (const c of selfFlagged) {
|
||||
const sev = c.metadata?.conflictSeverity || 'NONE';
|
||||
const emoji = severityEmoji(sev);
|
||||
const src = c.source;
|
||||
const title = c.title || '(제목 없음)';
|
||||
lines.push(`- ${emoji} **[${sev}]** \`${src}\` · ${title}`);
|
||||
lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (divergence.length > 0) {
|
||||
lines.push('## 교차-문서 발산 (같은 주제·다른 내용 ─ 잠재적 모순)');
|
||||
for (const p of divergence) {
|
||||
const topics = p.sharedTopicTokens.slice(0, 5).join(' · ');
|
||||
const cjPct = (p.contentJaccard * 100).toFixed(0);
|
||||
lines.push(`- 🔀 **공유 주제**: ${topics} _(본문 중복 ${cjPct}%)_`);
|
||||
lines.push(` - A: \`${p.a.source}\` · ${p.a.title || '(제목 없음)'}`);
|
||||
lines.push(` > ${shortExcerpt(p.a.content, opts.excerptLength)}`);
|
||||
lines.push(` - B: \`${p.b.source}\` · ${p.b.title || '(제목 없음)'}`);
|
||||
lines.push(` > ${shortExcerpt(p.b.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push('[지침]');
|
||||
lines.push('1. 답변에 위 출처 중 하나라도 사용한다면, 충돌 가능성을 명시 (예: "출처 A 는 X 라 하나 출처 B 는 Y").');
|
||||
lines.push('2. 어느 쪽이 옳다고 단정하지 말고, 사용자가 판단할 수 있도록 근거를 분리해 제시.');
|
||||
lines.push('3. 충돌이 답변과 무관하면 무시 가능 — 다만 무관 판단 자체도 한 줄로 기록.');
|
||||
lines.push('[/CONFLICT WARNINGS]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Chain-of-Verification (CoVe) — [VERIFICATION CHECKLIST] 시스템 프롬프트 블록 생성.
|
||||
*
|
||||
* 배경: 사용자 피드백 "추론 결과가 나오기 직전, 이 결론이 확보된 지식(제2뇌)에만
|
||||
* 근거하고 있는가? 를 스스로 질문하고 검증하는 로직" — 할루시네이션 방지 + 그라운딩
|
||||
* 명확화.
|
||||
*
|
||||
* 원논문 CoVe 는 2-pass (draft → verify → revise). 하지만 ASTRA 는 local-first 라
|
||||
* 추가 LLM 호출 비용이 크고, 같은 모델이 self-verify 하는 효과도 제한적. 그래서
|
||||
* v1 은 *instructional* CoVe — 시스템 프롬프트에 명시적 검증 체크리스트를 주입해
|
||||
* 모델이 한 패스 안에서 "답변 작성 전" 그라운딩 점검을 *내재화* 하도록.
|
||||
*
|
||||
* 향후 strict 모드에서 두 번째 verification pass 추가 가능 (config knob 준비).
|
||||
*
|
||||
* Conflict Surface 와의 관계: [CONFLICT WARNINGS] 가 "충돌 출처 데이터" 제공,
|
||||
* 이 CoVe 블록이 "그 데이터를 어떻게 verify 할지" 지시. 둘은 서로 보완.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface CoveBlockOptions {
|
||||
/** 체크리스트에 나열할 최상위 출처 수. 기본 5. */
|
||||
topSourcesCount: number;
|
||||
/**
|
||||
* Strict 모드 — 켜면 LLM 에게 *모든 주장에 출처 ID 를 inline 으로 인용*하라고 지시.
|
||||
* 끄면 일반 가이드만. 기본 off (자연스러운 답변 유지).
|
||||
*/
|
||||
strictMode: boolean;
|
||||
/** 출처 미리보기 길이. 기본 140 chars. */
|
||||
excerptLength: number;
|
||||
/** 사용자 query 일부를 체크리스트에 echo 할지. 기본 true — 모델이 vague answer 방지. */
|
||||
echoQuery: boolean;
|
||||
/** Query echo 최대 길이. 기본 180. */
|
||||
queryEchoMaxLength: number;
|
||||
}
|
||||
|
||||
const DEFAULT_OPTIONS: CoveBlockOptions = {
|
||||
topSourcesCount: 5,
|
||||
strictMode: false,
|
||||
excerptLength: 140,
|
||||
echoQuery: true,
|
||||
queryEchoMaxLength: 180,
|
||||
};
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* CoVe 블록 생성. 검색된 chunks 가 없으면 빈 문자열 — 그라운딩할 출처가 없는 상태에서
|
||||
* CoVe 를 강요하면 모델이 "출처 없음" 으로 답변 거부할 수 있음. 단, 사용자 query 가
|
||||
* 사실 검증 류일 때만 의미가 있으므로 호출자가 enable/disable 결정 가능.
|
||||
*/
|
||||
export function buildCoveChecklistBlock(
|
||||
chunks: RetrievalChunk[],
|
||||
userPrompt: string,
|
||||
options: Partial<CoveBlockOptions> = {},
|
||||
): string {
|
||||
const opts: CoveBlockOptions = { ...DEFAULT_OPTIONS, ...options };
|
||||
if (!chunks || chunks.length === 0) return '';
|
||||
|
||||
// 점수 순 상위 N — 다양한 source 가 섞이도록 source 별로 1개씩 round-robin 도 고려했으나,
|
||||
// CoVe 는 *근거 강한 출처* 가 더 중요해서 score 단순 정렬 채택.
|
||||
const top = chunks
|
||||
.filter((c) => c.source !== 'brain-trace') // brain-trace 는 trace 표시용, 본문 없음
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, opts.topSourcesCount);
|
||||
|
||||
if (top.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[VERIFICATION CHECKLIST — Chain-of-Verification]');
|
||||
lines.push('답변을 *작성하기 전* 다음을 점검. 검증 통과한 주장만 답변에 포함할 것.');
|
||||
|
||||
if (opts.echoQuery && userPrompt && userPrompt.trim()) {
|
||||
const q = userPrompt.replace(/\s+/g, ' ').trim();
|
||||
const echo = q.length > opts.queryEchoMaxLength ? q.slice(0, opts.queryEchoMaxLength) + '…' : q;
|
||||
lines.push('');
|
||||
lines.push(`> **사용자 질의**: ${echo}`);
|
||||
}
|
||||
|
||||
// ─── Section 1: 근거 매핑 ───
|
||||
lines.push('');
|
||||
lines.push('## 1. 근거 매핑 (Grounding Inventory)');
|
||||
lines.push('이 답변의 핵심 주장 각각이 *어느 출처* 에서 왔는지 명시 가능한가?');
|
||||
lines.push('');
|
||||
for (let i = 0; i < top.length; i++) {
|
||||
const c = top[i];
|
||||
const scoreFmt = c.score.toFixed(2);
|
||||
const sev = c.metadata?.conflictSeverity && c.metadata.conflictSeverity !== 'NONE'
|
||||
? ` ⚠️${c.metadata.conflictSeverity}` : '';
|
||||
lines.push(`- **[S${i + 1}]** \`${c.source}\` · ${c.title || '(제목 없음)'} _(score ${scoreFmt})_${sev}`);
|
||||
lines.push(` > ${shortExcerpt(c.content, opts.excerptLength)}`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('출처 미매핑(=어느 S 도 직접 지지하지 않음) 주장은 *모델 일반 지식*. 그 사실을 답변에 명시.');
|
||||
|
||||
// ─── Section 2: 자기 질문 ───
|
||||
lines.push('');
|
||||
lines.push('## 2. 답변 직전 자기 질문 (Pre-Output Self-Check)');
|
||||
lines.push('답변 보내기 전 *반드시* 답하라:');
|
||||
lines.push('- (a) 이 답변의 결론이 위 [S1..SN] 중 어디에 직접 근거하나? 매핑 안 되는 결론 = 일반 지식 → 명시.');
|
||||
lines.push('- (b) "확실하다", "반드시", "이미 결정됨" 같은 단정적 표현을 쓴다면 출처가 그 강도를 지지하는가? 아니면 톤 완화.');
|
||||
lines.push('- (c) 사용자에게 다음 *구체적 액션* 을 제시했는가, 아니면 추상적 조언만 했는가?');
|
||||
lines.push('- (d) [CONFLICT WARNINGS] 블록과 결합 — 충돌 출처 사용 시 양측 명시했는가?');
|
||||
|
||||
// ─── Section 3: Strict 모드 (옵션) ───
|
||||
if (opts.strictMode) {
|
||||
lines.push('');
|
||||
lines.push('## 3. ⚙️ STRICT 모드 — Inline Citation 강제');
|
||||
lines.push('각 사실 주장 뒤에 `[S1]`, `[S2]` 형식으로 출처 ID 를 *반드시* 인용. 인용 없으면 모델 지식으로 간주되어 답변 신뢰도 감점.');
|
||||
lines.push('예) "큐브앤코는 enterprise 요금제다 [S2]." / "일반적으로 SaaS B2B 는 ~ (모델 지식, 직접 출처 없음)."');
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
lines.push('[/VERIFICATION CHECKLIST]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서의 *추상도 레벨* 매칭으로 검색 노이즈 감소.
|
||||
*
|
||||
* 사용자 제안: "사용자가 '배포해줘' 라고 하면 L1(실행) 우선, '전략 검토' 라고 하면
|
||||
* L3(전략) 우선". 같은 키워드 매치 점수여도 추상도가 안 맞으면 noise.
|
||||
*
|
||||
* v1 — 3-level 휴리스틱 (LLM 호출 없음, 결정적):
|
||||
* - `concrete` — 코드, 로그, 디버그, 실행 명령
|
||||
* - `operational` — 작업, 일정, 운영 절차, 회의록 (기본/기본값)
|
||||
* - `strategic` — 전략, 비전, 의사결정 근거, 아키텍처 방향
|
||||
*
|
||||
* 매칭 정책:
|
||||
* - 같은 레벨 → 보너스 (× 1.15)
|
||||
* - 인접 레벨 (concrete↔operational, operational↔strategic) → 변화 없음
|
||||
* - 양 끝 mismatch (concrete↔strategic) → 페널티 (× 0.7)
|
||||
*
|
||||
* 약한 시그널 — TF-IDF dominant 유지, 동점 깨기 역할. 검색 결과를 *제외* 하지 않음.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export type AbstractionLevel = 'concrete' | 'operational' | 'strategic';
|
||||
|
||||
const QUERY_STRATEGIC_KEYWORDS = [
|
||||
'전략', '방향', '비전', '미션', '목표', '의사결정', '아키텍처', '설계 방향',
|
||||
'왜 이렇게', '왜 그렇게', '뭐가 맞', '어떤 게 좋', '어떻게 가야', '어떤 방향',
|
||||
'판단', '결정', '관점', '평가', '검토',
|
||||
'strategy', 'vision', 'mission', 'roadmap', 'direction', 'goal',
|
||||
'why', 'rationale', 'pros and cons', 'tradeoff', 'evaluate',
|
||||
];
|
||||
|
||||
const QUERY_CONCRETE_KEYWORDS = [
|
||||
'코드', '함수', '버그', '에러', '로그', '실행', '명령어', '스크립트', '디버그',
|
||||
'고쳐', '수정', '리팩토링', '리팩터', '커밋', '머지', '배포해', '돌려',
|
||||
'에러 메시지', '스택 트레이스', 'syntax', 'compile',
|
||||
'code', 'function', 'bug', 'error', 'log', 'execute', 'command', 'script',
|
||||
'debug', 'fix', 'refactor', 'commit', 'merge', 'deploy', 'run',
|
||||
];
|
||||
|
||||
const FOLDER_STRATEGIC_HINTS = ['strategy', 'vision', 'mission', 'roadmap', 'decision', 'principle', '전략', '비전'];
|
||||
const FOLDER_OPERATIONAL_HINTS = ['playbook', 'runbook', 'operation', 'process', 'sop', '운영', '절차', 'meeting', '회의'];
|
||||
const FOLDER_CONCRETE_HINTS = ['code', 'snippet', 'log', 'debug', 'fix', 'patch', '디버그', 'commit'];
|
||||
|
||||
const TITLE_STRATEGIC_HINTS = ['strategy', 'vision', 'rationale', 'direction', 'decision', 'plan', '전략', '계획', '방향', '결정', '평가'];
|
||||
const TITLE_CONCRETE_HINTS = ['fix', 'bug', 'error', 'log', 'script', 'command', '버그', '에러', '로그', '커밋'];
|
||||
|
||||
function countMatches(text: string, keywords: string[]): number {
|
||||
const lower = text.toLowerCase();
|
||||
let n = 0;
|
||||
for (const k of keywords) if (lower.includes(k.toLowerCase())) n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* 질의 추상도 분류. 키워드 카운트 우열로 결정, 동률·없음이면 'operational' (기본).
|
||||
*/
|
||||
export function classifyQueryLevel(query: string): AbstractionLevel {
|
||||
if (!query) return 'operational';
|
||||
const s = countMatches(query, QUERY_STRATEGIC_KEYWORDS);
|
||||
const c = countMatches(query, QUERY_CONCRETE_KEYWORDS);
|
||||
if (s > c && s >= 1) return 'strategic';
|
||||
if (c > s && c >= 1) return 'concrete';
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
/**
|
||||
* 한 chunk 의 추상도 분류 — 폴더 경로 → 파일명/제목 → 본문 순으로 강도 감소.
|
||||
* 어느 신호도 없으면 'operational' (기본).
|
||||
*/
|
||||
export function classifyChunkLevel(chunk: RetrievalChunk): AbstractionLevel {
|
||||
// 1. 폴더 경로 (가장 강함)
|
||||
const fp = (chunk.metadata?.filePath || '').toLowerCase();
|
||||
if (fp) {
|
||||
for (const h of FOLDER_STRATEGIC_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'strategic';
|
||||
for (const h of FOLDER_CONCRETE_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'concrete';
|
||||
for (const h of FOLDER_OPERATIONAL_HINTS) if (fp.includes(`/${h}`) || fp.includes(`\\${h}`)) return 'operational';
|
||||
}
|
||||
|
||||
// 2. 제목
|
||||
const t = (chunk.title || '').toLowerCase();
|
||||
if (t) {
|
||||
let strat = 0, conc = 0;
|
||||
for (const h of TITLE_STRATEGIC_HINTS) if (t.includes(h.toLowerCase())) strat++;
|
||||
for (const h of TITLE_CONCRETE_HINTS) if (t.includes(h.toLowerCase())) conc++;
|
||||
if (strat > conc && strat >= 1) return 'strategic';
|
||||
if (conc > strat && conc >= 1) return 'concrete';
|
||||
}
|
||||
|
||||
return 'operational';
|
||||
}
|
||||
|
||||
const LEVEL_INDEX: Record<AbstractionLevel, number> = {
|
||||
concrete: 0, operational: 1, strategic: 2,
|
||||
};
|
||||
|
||||
export interface HierarchicalWeights {
|
||||
/** 같은 레벨 매치 multiplier. 기본 1.15. */
|
||||
sameLevelBonus: number;
|
||||
/** 양 끝 mismatch (concrete↔strategic) multiplier. 기본 0.70. */
|
||||
farMismatchPenalty: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_HIERARCHICAL_WEIGHTS: HierarchicalWeights = {
|
||||
sameLevelBonus: 1.15,
|
||||
farMismatchPenalty: 0.70,
|
||||
};
|
||||
|
||||
/**
|
||||
* 질의 레벨에 따라 chunks 의 score 를 hierarchical 매칭으로 재가중. in-place.
|
||||
* metadata 에 분류 결과 기록 (debug/UI 노출).
|
||||
*/
|
||||
export function applyHierarchicalReweight(
|
||||
chunks: RetrievalChunk[],
|
||||
queryLevel: AbstractionLevel,
|
||||
weights: HierarchicalWeights = DEFAULT_HIERARCHICAL_WEIGHTS,
|
||||
): { sameLevel: number; farMismatch: number } {
|
||||
let sameLevel = 0;
|
||||
let farMismatch = 0;
|
||||
const qi = LEVEL_INDEX[queryLevel];
|
||||
for (const c of chunks) {
|
||||
const cl = classifyChunkLevel(c);
|
||||
(c.metadata as any).abstractionLevel = cl;
|
||||
const ci = LEVEL_INDEX[cl];
|
||||
const diff = Math.abs(qi - ci);
|
||||
if (diff === 0) {
|
||||
c.score *= weights.sameLevelBonus;
|
||||
sameLevel++;
|
||||
} else if (diff === 2) {
|
||||
c.score *= weights.farMismatchPenalty;
|
||||
farMismatch++;
|
||||
}
|
||||
// diff === 1: 인접 레벨 → 변화 없음
|
||||
}
|
||||
return { sameLevel, farMismatch };
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { selectWithinBudget, assembleContext, estimateTokens } from './contextBu
|
||||
import { getBrainTokenIndex, getBrainEmbeddings } from './brainIndex';
|
||||
import { extractLessonEssence } from './lessonHelpers';
|
||||
import { cosineSimilarity } from './embeddings';
|
||||
import { applyActionabilityBoost, WorkStateSignals, ActionabilityWeights } from './actionabilityScoring';
|
||||
import { applyHierarchicalReweight, classifyQueryLevel, AbstractionLevel, HierarchicalWeights } from './hierarchicalLevel';
|
||||
|
||||
export { tokenize, expandQuery, scoreTfIdf, scoreTfIdfPreTokenized, extractBestExcerpt } from './scoring';
|
||||
export { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
@@ -81,6 +83,20 @@ interface RetrievalOptions {
|
||||
embeddingModel?: string;
|
||||
/** Blend weight: 0 = TF-IDF only, 1 = cosine only. Default 0.5. */
|
||||
embeddingBlendAlpha?: number;
|
||||
/**
|
||||
* Actionability — "현재 작업 상태" 신호(최근 슬래시 명령 + 열린 파일) 로 검색 결과 재가중.
|
||||
* undefined 면 actionability re-rank 안 함 (legacy 동작).
|
||||
*/
|
||||
workStateSignals?: WorkStateSignals;
|
||||
/** Actionability 결합 가중치. undefined 면 default. */
|
||||
actionabilityWeights?: ActionabilityWeights;
|
||||
/**
|
||||
* Hierarchical Context Window — 질의·문서 추상도 매칭 재가중.
|
||||
* true 면 query 추상도 분류 후 chunks 재가중. false / undefined 면 skip.
|
||||
*/
|
||||
hierarchicalReweightEnabled?: boolean;
|
||||
/** Hierarchical 가중치 override. undefined 면 default. */
|
||||
hierarchicalWeights?: HierarchicalWeights;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
@@ -148,6 +164,25 @@ export class RetrievalOrchestrator {
|
||||
this.normalizeScores(allChunks);
|
||||
fusionLog.push(`Total chunks before budget: ${allChunks.length}`);
|
||||
|
||||
// ── ③-b Actionability Re-rank — work-state 신호로 점수 boost ──
|
||||
// normalize 직후, budget 전 — actionability 가 어떤 chunk 가 살아남는지에 영향.
|
||||
if (options.workStateSignals) {
|
||||
applyActionabilityBoost(allChunks, options.workStateSignals, options.actionabilityWeights);
|
||||
const boosted = allChunks.filter((c) => (c.metadata as any).actionabilityScore > 0).length;
|
||||
const cmds = options.workStateSignals.recentSlashCommands.slice(0, 3).join(',');
|
||||
const openFile = options.workStateSignals.openFilePath ? path.basename(options.workStateSignals.openFilePath) : '-';
|
||||
fusionLog.push(`Actionability re-rank: ${boosted} chunks boosted (cmds=[${cmds}], openFile=${openFile})`);
|
||||
}
|
||||
|
||||
// ── ③-c Hierarchical Context Window — 추상도 레벨 매칭 ──
|
||||
// 질의·문서 추상도 매칭 점수 조정. 같은 레벨 bonus, 양 끝 mismatch penalty.
|
||||
// Actionability 직후 — 두 재가중을 합쳐 한 번의 budget selection.
|
||||
if (options.hierarchicalReweightEnabled) {
|
||||
const queryLevel = classifyQueryLevel(query);
|
||||
const { sameLevel, farMismatch } = applyHierarchicalReweight(allChunks, queryLevel, options.hierarchicalWeights);
|
||||
fusionLog.push(`Hierarchical re-rank (query=${queryLevel}): ${sameLevel} same-level (+), ${farMismatch} far-mismatch (-)`);
|
||||
}
|
||||
|
||||
// ── ④ Context Budget Selection ──
|
||||
const { selected, dropped, tokensUsed } = selectWithinBudget(
|
||||
allChunks,
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Intent Clarification — 모호한 질의에서 *추측 답변 대신 질문 던지기* 지시.
|
||||
*
|
||||
* 사용자 피드백: "ASTRA 는 질문을 받으면 즉시 답변을 생성하려 함. 하지만 '날카로운'
|
||||
* 파악을 위해서는 질문 뒤의 '실행 목적' 을 먼저 정의해야". 예: "배포해줘" → 환경
|
||||
* (dev/prod) / 태그 묻기. "그 부분 고쳐줘" → 어느 파일/모듈인지 묻기.
|
||||
*
|
||||
* 현재 ASTRA: 모호 감지 메커니즘 없음. CoVe(v2.2.184) 가 *답변 작성 시* 출처 매핑
|
||||
* 검증하지만, *질문 자체가 모호한 경우* 는 다루지 않음. 이 모듈이 그 갭.
|
||||
*
|
||||
* 설계:
|
||||
* - 휴리스틱 차원(환경/대상/범위/포맷/마감) 별로 *trigger 키워드 + 명시 키워드* 정의
|
||||
* - trigger 가 있는데 명시가 없으면 missing
|
||||
* - missing 차원이 strictness 임계 이상이면 ambiguous → 시스템 프롬프트에 질문 지시
|
||||
*
|
||||
* 위험: false positive → 사용자가 "그냥 답해" 짜증. strictness 로 조절.
|
||||
*/
|
||||
|
||||
export type IntentStrictness = 'low' | 'medium' | 'high';
|
||||
|
||||
interface AmbiguityDimensionDef {
|
||||
key: string;
|
||||
label: string; // 한국어 표시명
|
||||
/** 이 차원이 *문제 되는 지* 판정하는 trigger 단어들 (있으면 의심 시작). */
|
||||
triggers: string[];
|
||||
/** 차원이 *명시* 됐다고 보는 단어들 (있으면 ambiguity 해소). */
|
||||
specifiers: string[];
|
||||
/** missing 일 때 사용자에게 권장 질문 예시. */
|
||||
suggestedQuestion: string;
|
||||
}
|
||||
|
||||
const DIMENSIONS: AmbiguityDimensionDef[] = [
|
||||
{
|
||||
key: 'environment',
|
||||
label: '환경 (dev/prod/staging)',
|
||||
triggers: ['배포', '롤백', 'deploy', 'rollback', 'release', '릴리스', '릴리즈', '띄워', '재시작', 'restart'],
|
||||
specifiers: ['dev', 'prod', 'staging', 'local', '로컬', '개발', '운영', '프로덕션', '스테이징', '본번', '본 번', '본번에', '운영에'],
|
||||
suggestedQuestion: '어느 환경에 작업할지 (dev/prod/staging) 명시해 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'target',
|
||||
label: '대상 (파일/모듈/멤버)',
|
||||
triggers: ['고쳐', '고처', '수정', '바꿔', '추가', '제거', '리팩토', '리팩터', '리팩터링', '리팩토링', '개선', '정리', '리뷰', '검토해'],
|
||||
specifiers: ['.ts', '.tsx', '.js', '.py', '.md', '.json', '.go', '.rs', '파일', '함수', '클래스', '모듈', '@', 'src/', 'lib/', 'features/', '폴더'],
|
||||
suggestedQuestion: '어느 파일/모듈/함수를 대상으로 할지 명시해 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'scope',
|
||||
label: '범위 (전체/부분)',
|
||||
triggers: ['리팩토', '리팩터', '리팩터링', '리팩토링', '정리해', '개선', '최적화', '튜닝', '청소', '정비'],
|
||||
specifiers: ['전체', '전부', '모두', '일부', '특정', '하나만', '이것만', '여기만', '단', '단지'],
|
||||
suggestedQuestion: '범위가 전체인지 특정 부분인지 알려 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'format',
|
||||
label: '출력 포맷',
|
||||
triggers: ['요약', '보고서', '리포트', '정리', '문서', '카드', '발표', '슬라이드', '프레젠테이션'],
|
||||
specifiers: ['표', '리스트', 'json', 'markdown', '마크다운', '단락', 'bullet', '글머리표', '한장', '한 장', '슬라이드', 'pdf', '문장으로', '항목별', '단계별'],
|
||||
suggestedQuestion: '어떤 형식 (표/리스트/단락 등) 으로 받고 싶은지 알려 주실 수 있나요?',
|
||||
},
|
||||
{
|
||||
key: 'deadline',
|
||||
label: '마감/긴급도',
|
||||
triggers: ['언제까지', '마감', '빨리', '급함', '오늘 안에', '내일까지'],
|
||||
specifiers: ['오늘', '내일', '이번 주', '다음 주', '월', '일', '시', '분'],
|
||||
suggestedQuestion: '마감일이나 긴급도를 알려 주실 수 있나요?',
|
||||
},
|
||||
];
|
||||
|
||||
export interface AmbiguityResult {
|
||||
ambiguous: boolean;
|
||||
missingDimensions: { key: string; label: string; suggestedQuestion: string }[];
|
||||
triggerCount: number;
|
||||
promptLength: number;
|
||||
}
|
||||
|
||||
function hasAnyKeyword(text: string, keywords: string[]): boolean {
|
||||
const lower = text.toLowerCase();
|
||||
return keywords.some((k) => lower.includes(k.toLowerCase()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 모호 감지. strictness 에 따라 threshold 변동:
|
||||
* - low: 2개 이상 missing → ambiguous
|
||||
* - medium: 1개 이상 missing → ambiguous (기본)
|
||||
* - high: 1개 이상 missing OR 프롬프트 짧음 (<20 chars) → ambiguous
|
||||
*/
|
||||
export function detectAmbiguity(prompt: string, strictness: IntentStrictness = 'medium'): AmbiguityResult {
|
||||
const result: AmbiguityResult = {
|
||||
ambiguous: false,
|
||||
missingDimensions: [],
|
||||
triggerCount: 0,
|
||||
promptLength: (prompt || '').length,
|
||||
};
|
||||
if (!prompt || !prompt.trim()) return result;
|
||||
|
||||
for (const dim of DIMENSIONS) {
|
||||
const hasTrigger = hasAnyKeyword(prompt, dim.triggers);
|
||||
if (!hasTrigger) continue;
|
||||
result.triggerCount++;
|
||||
const hasSpecifier = hasAnyKeyword(prompt, dim.specifiers);
|
||||
if (!hasSpecifier) {
|
||||
result.missingDimensions.push({
|
||||
key: dim.key,
|
||||
label: dim.label,
|
||||
suggestedQuestion: dim.suggestedQuestion,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const missingCount = result.missingDimensions.length;
|
||||
if (strictness === 'low') result.ambiguous = missingCount >= 2;
|
||||
else if (strictness === 'medium') result.ambiguous = missingCount >= 1;
|
||||
else result.ambiguous = missingCount >= 1 || (result.promptLength < 20 && result.triggerCount > 0);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 시스템 프롬프트용 [INTENT CLARIFICATION GUIDANCE] 블록.
|
||||
* ambiguous=false 면 빈 문자열 반환.
|
||||
*/
|
||||
export function buildIntentClarificationBlock(result: AmbiguityResult): string {
|
||||
if (!result.ambiguous || result.missingDimensions.length === 0) return '';
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[INTENT CLARIFICATION GUIDANCE]');
|
||||
lines.push('사용자 질의에서 다음 의도 차원이 *명시되지 않음* — 추측 답변보다 *짧은 역질문* 우선:');
|
||||
lines.push('');
|
||||
for (const d of result.missingDimensions) {
|
||||
lines.push(`- **${d.label}** — 예: "${d.suggestedQuestion}"`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('[지침]');
|
||||
lines.push('1. 모호 차원이 답변의 *방향* 을 좌우하는 경우, 1~2개 핵심 질문을 *먼저* 던질 것 (전체 답변 미리 만들지 말 것).');
|
||||
lines.push('2. 사용자가 이미 "추정해도 OK", "그냥 진행", "알아서" 같은 표현을 했으면 합리적 가정 + *가정 명시* 후 진행.');
|
||||
lines.push('3. 모호 차원이 답변과 *무관* 한 정보성/탐색성 질의면 그대로 답변 OK.');
|
||||
lines.push('4. 질문 던질 때 사용자가 다시 입력하기 쉽도록 *선택지 2~3개* 또는 *기대 형식* 같이 명시.');
|
||||
lines.push('[/INTENT CLARIFICATION GUIDANCE]');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* LLM Semantic Re-ranking — TF-IDF / 임베딩이 놓치는 *의도* 매치를 작은 LLM 호출
|
||||
* 한 번으로 잡는다.
|
||||
*
|
||||
* 동작:
|
||||
* 1. 1차 검색(TF-IDF + embedding + 부스트들) 결과의 *상위 K* (기본 15) 후보를 추출
|
||||
* 2. 가벼운 프롬프트로 LLM 에게 "이 중 query 의도에 가장 부합하는 순서로 ID 나열" 요청
|
||||
* 3. LLM 응답을 파싱해 순서 적용 — 응답 실패/누락 ID 는 원순서 유지
|
||||
*
|
||||
* 비용·위험 관리:
|
||||
* - 기본 OFF (g1nation.semanticRerankEnabled). 사용자가 latency 감수할 의지 있을 때만.
|
||||
* - 짧은 timeout (기본 8초) — 초과 시 원순서 그대로 반환, 검색 실패 안 됨.
|
||||
* - 후보 K 제한 — 토큰 비용 cap.
|
||||
* - 별도 빠른 모델 지정 가능 (`g1nation.semanticRerankModel`) — 메인 모델 외 작은 모델.
|
||||
*
|
||||
* 인터페이스: input chunks 순서는 *원본 score 내림차순* 으로 들어와야 함.
|
||||
* 반환: re-rank 가 성공하면 새 순서의 RetrievalChunk[], 실패하면 원순서.
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
|
||||
export interface SemanticRerankOptions {
|
||||
ollamaUrl: string;
|
||||
/** Re-rank 전용 모델 ID. 비면 fallback model 사용. */
|
||||
model: string;
|
||||
/** 후보로 LLM 에 넘길 최대 chunk 개수. 기본 15. */
|
||||
candidateK: number;
|
||||
/** LLM 호출 타임아웃 (ms). 기본 8000. */
|
||||
timeoutMs: number;
|
||||
/** 각 chunk 미리보기 길이. 기본 240 chars. */
|
||||
excerptLength: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SEMANTIC_RERANK_OPTIONS: Omit<SemanticRerankOptions, 'ollamaUrl' | 'model'> = {
|
||||
candidateK: 15,
|
||||
timeoutMs: 8000,
|
||||
excerptLength: 240,
|
||||
};
|
||||
|
||||
export interface SemanticRerankResult {
|
||||
rerankedChunks: RetrievalChunk[];
|
||||
/** true 면 LLM 응답으로 순서 변경됨. false 면 원순서 (실패/타임아웃/파싱 실패). */
|
||||
success: boolean;
|
||||
durationMs: number;
|
||||
/** 디버그·footer 표시용 — re-rank 가 어떻게 동작했는지. */
|
||||
note: string;
|
||||
}
|
||||
|
||||
function shortExcerpt(text: string, n: number): string {
|
||||
if (!text) return '';
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
return cleaned.length <= n ? cleaned : cleaned.slice(0, n) + '…';
|
||||
}
|
||||
|
||||
function buildRerankPrompt(query: string, candidates: RetrievalChunk[], excerptLength: number): { system: string; user: string } {
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const c = candidates[i];
|
||||
lines.push(`[C${i + 1}] (${c.source}) ${c.title || '(제목 없음)'}`);
|
||||
lines.push(` ${shortExcerpt(c.content, excerptLength)}`);
|
||||
}
|
||||
|
||||
const system = [
|
||||
'당신은 검색 결과 재정렬기 (re-ranker). 사용자 질의의 *의도* 와 각 후보 문서의 *내용 부합도* 를 평가해 가장 유용한 순서로 정렬.',
|
||||
'',
|
||||
'[규칙]',
|
||||
'1. 응답은 *반드시* 한 줄의 JSON: `{"ranking":[3,1,5,2,4,...]}` 형식.',
|
||||
'2. ranking 배열 원소 = 입력 [C1], [C2] 의 *번호* (1-based).',
|
||||
'3. 모든 입력 후보를 한 번씩만 포함. 누락·중복·번호 외 값 금지.',
|
||||
'4. 다른 설명·코드 블록·텍스트 출력 절대 금지 — JSON 한 줄만.',
|
||||
'5. 평가 기준: (a) 질의 의도와의 직접 부합도 > (b) 키워드 매치 > (c) 문맥 풍부도.',
|
||||
].join('\n');
|
||||
|
||||
const user = [
|
||||
`[사용자 질의]\n${query}`,
|
||||
'',
|
||||
`[후보 ${candidates.length}개]`,
|
||||
...lines,
|
||||
'',
|
||||
'위 후보를 가장 부합도 높은 순서로 정렬한 ranking 배열만 JSON 한 줄로 출력.',
|
||||
].join('\n');
|
||||
|
||||
return { system, user };
|
||||
}
|
||||
|
||||
/**
|
||||
* Ollama / OpenAI 호환 endpoint 로 단발 호출. agents/factory.ts 의 BaseAgent.callLLM
|
||||
* 패턴 단순화. timeout, retry 1회만.
|
||||
*/
|
||||
async function callLlmForRerank(
|
||||
ollamaUrl: string,
|
||||
model: string,
|
||||
system: string,
|
||||
user: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 256 },
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
stream: false, temperature: 0.0, max_tokens: 256,
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const content =
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'';
|
||||
return String(content || '');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
||||
function parseRanking(raw: string, expectedSize: number): number[] | null {
|
||||
if (!raw) return null;
|
||||
// JSON 한 줄 추출 — { ... } 안에 ranking
|
||||
const match = raw.match(/\{[\s\S]*?\}/);
|
||||
if (!match) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(match[0]);
|
||||
const arr = parsed?.ranking;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const seen = new Set<number>();
|
||||
const out: number[] = [];
|
||||
for (const v of arr) {
|
||||
const n = typeof v === 'number' ? v : parseInt(String(v), 10);
|
||||
if (!Number.isFinite(n) || n < 1 || n > expectedSize) continue;
|
||||
if (seen.has(n)) continue;
|
||||
seen.add(n);
|
||||
out.push(n);
|
||||
}
|
||||
// 누락 보충 — LLM 이 일부 빠뜨렸으면 원순서로 뒤에 붙임.
|
||||
for (let i = 1; i <= expectedSize; i++) {
|
||||
if (!seen.has(i)) out.push(i);
|
||||
}
|
||||
return out.length === expectedSize ? out : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function semanticRerank(
|
||||
query: string,
|
||||
chunks: RetrievalChunk[],
|
||||
options: SemanticRerankOptions,
|
||||
): Promise<SemanticRerankResult> {
|
||||
const start = Date.now();
|
||||
const k = Math.max(2, Math.min(options.candidateK, chunks.length));
|
||||
if (chunks.length < 2 || k < 2) {
|
||||
return { rerankedChunks: chunks, success: false, durationMs: 0, note: 'too few candidates' };
|
||||
}
|
||||
// 입력은 score 내림차순 가정 — 상위 K 가 re-rank 대상, 나머지는 그대로 꼬리.
|
||||
const candidates = chunks.slice(0, k);
|
||||
const tail = chunks.slice(k);
|
||||
|
||||
const { system, user } = buildRerankPrompt(query, candidates, options.excerptLength);
|
||||
|
||||
let raw = '';
|
||||
try {
|
||||
raw = await callLlmForRerank(options.ollamaUrl, options.model, system, user, options.timeoutMs);
|
||||
} catch (e: any) {
|
||||
return {
|
||||
rerankedChunks: chunks,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
const ranking = parseRanking(raw, candidates.length);
|
||||
if (!ranking) {
|
||||
return {
|
||||
rerankedChunks: chunks,
|
||||
success: false,
|
||||
durationMs: Date.now() - start,
|
||||
note: 'unparseable LLM response',
|
||||
};
|
||||
}
|
||||
|
||||
const reranked = ranking.map((i) => candidates[i - 1]);
|
||||
return {
|
||||
rerankedChunks: [...reranked, ...tail],
|
||||
success: true,
|
||||
durationMs: Date.now() - start,
|
||||
note: `re-ranked top ${k} (changed positions: ${ranking.filter((v, i) => v !== i + 1).length})`,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Terminology Dictionary — 프로젝트 표준 용어집을 시스템 프롬프트에 주입.
|
||||
*
|
||||
* 사용자 제안: "표준 표기 강제 + 답변 내 표기 일관성 검증". 예: `runway` vs `런웨이`,
|
||||
* `P-Reinforce` vs `p-reinforce`, `Chronicle` vs `크로니클`.
|
||||
*
|
||||
* 설계 — 사용자 편집 markdown 파일:
|
||||
* - 위치: `<workspace>/.astra/glossary.md`
|
||||
* - 형식: 자유 markdown. ASTRA 는 *형식을 강제하지 않고* 통째로 주입
|
||||
* - 권장 컨벤션: H2/H3 섹션으로 표준 표기 / 영-한 컨벤션 / 금지 용어 등 그룹핑
|
||||
*
|
||||
* 시스템 프롬프트 블록 `[TERMINOLOGY DICTIONARY]`:
|
||||
* - 글로서리 본문 + Term Check 지침 (#1 typo/용어 self-check 사용자 제안 통합)
|
||||
* - 답변 작성 시 표준 표기 우선 + 답변 직전 자기 점검 + 새 용어 도입 시 명시
|
||||
*
|
||||
* 캐시: 파일 mtime 기반 — 매 turn 디스크 read 안 함.
|
||||
*/
|
||||
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { createMtimeFileCache } from '../lib/mtimeFileCache';
|
||||
|
||||
const DEFAULT_GLOSSARY_REL_PATH = '.astra/glossary.md';
|
||||
|
||||
/** Raw 본문 캐시 — mtime 기반, 파일 편집 시 자동 재read. */
|
||||
const _rawCache = createMtimeFileCache<string>('terminology-raw', (raw) => raw.trim());
|
||||
|
||||
export function getGlossaryFilePath(relPath: string = DEFAULT_GLOSSARY_REL_PATH): string | null {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (!folders || folders.length === 0) return null;
|
||||
return path.join(folders[0].uri.fsPath, relPath);
|
||||
}
|
||||
|
||||
function readGlossary(relPath: string): string {
|
||||
const fp = getGlossaryFilePath(relPath);
|
||||
if (!fp) return '';
|
||||
return _rawCache.read(fp) ?? '';
|
||||
}
|
||||
|
||||
export function clearGlossaryCache(): void {
|
||||
_rawCache.clear();
|
||||
}
|
||||
|
||||
export interface TerminologyBlockOptions {
|
||||
/** Glossary 파일 상대 경로. 기본 '.astra/glossary.md'. */
|
||||
relPath: string;
|
||||
/** 본문 최대 길이 (chars). 너무 큰 글로서리는 시스템 프롬프트 비대 — cap. 기본 4000. */
|
||||
maxBodyLength: number;
|
||||
/** 길이 초과 시 잘릴 안내 표시 여부. */
|
||||
showTruncationNote: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_TERMINOLOGY_OPTIONS: TerminologyBlockOptions = {
|
||||
relPath: DEFAULT_GLOSSARY_REL_PATH,
|
||||
maxBodyLength: 4000,
|
||||
showTruncationNote: true,
|
||||
};
|
||||
|
||||
export function buildTerminologyBlock(options: Partial<TerminologyBlockOptions> = {}): string {
|
||||
const opts: TerminologyBlockOptions = { ...DEFAULT_TERMINOLOGY_OPTIONS, ...options };
|
||||
const raw = readGlossary(opts.relPath);
|
||||
if (!raw) return ''; // 파일 없음 → 블록 안 만듦 (no-op)
|
||||
|
||||
let body = raw;
|
||||
let truncated = false;
|
||||
if (body.length > opts.maxBodyLength) {
|
||||
body = body.slice(0, opts.maxBodyLength);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push('[TERMINOLOGY DICTIONARY]');
|
||||
lines.push('프로젝트 표준 용어집. 답변 생성 시 다음 표기·컨벤션을 *최우선* 으로 사용.');
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(body);
|
||||
if (truncated && opts.showTruncationNote) {
|
||||
lines.push('');
|
||||
lines.push(`_…(글로서리 ${raw.length - opts.maxBodyLength}자 잘림 — 핵심 용어를 앞쪽에 배치해 주세요)_`);
|
||||
}
|
||||
lines.push('---');
|
||||
|
||||
// Term Check 지침 — 사용자 제안 #1 (typo/용어 self-check) 통합.
|
||||
lines.push('');
|
||||
lines.push('[Term Check — 답변 직전 자기 점검]');
|
||||
lines.push('1. **표준 표기 우선**: 위 용어가 답변에 등장하면 *글로서리의 표기를 그대로* 사용. 변형·번역 임의 적용 금지.');
|
||||
lines.push('2. **표기 흔들림 방지**: 같은 용어를 한 답변 안에서 *동일 표기* 로 일관 사용 (예: "Chronicle" 과 "크로니클" 섞지 말 것).');
|
||||
lines.push('3. **새 용어 도입 시**: 글로서리에 없는 고유 명사·약어 처음 사용 시 *"새 용어: X"* 라고 한 번 명시.');
|
||||
lines.push('4. **금지 표기 검증**: 답변 직전, 글로서리의 *금지·비추* 항목이 답변에 들어가지 않았는지 검토. 들어갔으면 *재작성*.');
|
||||
lines.push('5. **모르겠으면 글로서리**: 표기 확신 없을 때 "글로서리에 없어 일반 표기 사용" 한 줄 명시 후 진행.');
|
||||
lines.push('[/TERMINOLOGY DICTIONARY]');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 글로서리 파일 작성 도우미 — 처음 사용자가 만들 때 권장 컨벤션 템플릿.
|
||||
* 슬래시 명령 `/glossary init` 등에서 호출.
|
||||
*/
|
||||
export const GLOSSARY_TEMPLATE = `# 프로젝트 용어집
|
||||
|
||||
ASTRA 가 답변 시 표준 표기로 사용. 사용자가 자유롭게 편집 가능.
|
||||
파일 저장 후 다음 채팅 turn 부터 자동 반영.
|
||||
|
||||
## 표준 표기
|
||||
|
||||
- **ASTRA** (X: astra, Astra 외) — 본 VS Code extension 이름
|
||||
- **P-Reinforce v3.0** (X: p-reinforce, p reinforce) — 지식 압축 규칙
|
||||
- **Chronicle ADR** (X: chronicle, ADR 단독) — 의사결정 기록
|
||||
|
||||
## 영-한 표기 컨벤션
|
||||
|
||||
- Performance → 성능
|
||||
- Bug → 버그
|
||||
- Memory → 메모리
|
||||
|
||||
## 금지·비추 표현
|
||||
|
||||
- ❌ "절대적", "반드시" (단정적 표현 — 정책 충돌 위험)
|
||||
- ❌ "에이전트가 알아서" (그라운딩 위반)
|
||||
- ❌ 한·영 깨짐 (예: "결ently", "p-rein동")
|
||||
|
||||
## 슬래시 명령 표기
|
||||
|
||||
원문 그대로 — 한국어 번역 금지:
|
||||
- /runway, /customers, /hire, /morning, /evening, /weekly, /cohort, /memory, /glossary
|
||||
`;
|
||||
@@ -1890,6 +1890,25 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
|
||||
// Pixel Office: 분석 시작 표시 (LLM 콜 직전).
|
||||
try { this.pixelOfficeOnAlignmentStart(opts.userPrompt); } catch { /* noop */ }
|
||||
|
||||
// 모드 전환 직전 일반 채팅 요약 — Intent Alignment 가 *이미 논의된 맥락* 을
|
||||
// 재질문하지 않도록. 후속 라운드(previousContract 있음) 면 chatHistory 가
|
||||
// 이미 contract 에 흡수됐으므로 중복 첨부 안 함.
|
||||
let priorChatSummary: string | undefined;
|
||||
if (!opts.previousContract) {
|
||||
try {
|
||||
const history = this._agent.getHistory();
|
||||
const visible = history.filter((m) => !m.internal && (m.role === 'user' || m.role === 'assistant'));
|
||||
// 마지막 N=10 턴, content 200자 cap — 토큰 폭주 방지.
|
||||
const recent = visible.slice(-10);
|
||||
if (recent.length > 0) {
|
||||
priorChatSummary = recent
|
||||
.map((m) => `${m.role}: ${String(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 200)}`)
|
||||
.join('\n');
|
||||
}
|
||||
} catch { /* history 못 가져와도 alignment 자체는 동작 */ }
|
||||
}
|
||||
|
||||
const analysis = await analyzeIntent(
|
||||
new AIService(),
|
||||
{
|
||||
@@ -1898,6 +1917,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
previousContract: opts.previousContract,
|
||||
activePipelineName: activePipeline?.name,
|
||||
availableRoleCategories: extractActiveRoleCategories(state),
|
||||
priorChatSummary,
|
||||
},
|
||||
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
|
||||
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||||
|
||||
Reference in New Issue
Block a user