Compare commits
24 Commits
0a97324f1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc2558550 | |||
| ef3628c6eb | |||
| a52bf6ee85 | |||
| 6d06311d60 | |||
| b4ddd4f79a | |||
| b733864375 | |||
| d39eb27c90 | |||
| b94e6ad1da | |||
| 116dafd3cf | |||
| 681cfd2393 | |||
| eb4bef0744 | |||
| 7e96e56381 | |||
| 6b017b0d31 | |||
| 2ea5185cd6 | |||
| 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.210` _(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**: 441 source files, ~71,522 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-09T02:20:50.409Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 438
|
||||
|
||||
## 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/>280 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/>112 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -55,10 +56,10 @@ flowchart LR
|
||||
|
||||
## Hub Files
|
||||
> Imported by many other files — touching these has wide blast radius.
|
||||
- `src/utils.ts` — referenced by **87** files
|
||||
- `src/utils.ts` — referenced by **88** 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,58 +67,58 @@ flowchart LR
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 247 files, ~45,859 lines
|
||||
### `src/` — 280 files, ~53,526 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (87) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
||||
- `src/features/` (103) — 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/` (18) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `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/extension/` (9) — 9 files (.ts)
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
- `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/utils.ts` (472 lines)
|
||||
- `src/config.ts` (585 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` (3180 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/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/features/datacollect/slashRouter.ts` (201 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/retrieval/brainIndex.ts` (536 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `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,785 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
|
||||
- `media/settings-panel.js` (469 lines)
|
||||
- `media/settings-panel.html` (440 lines) — Astra Settings
|
||||
- `media/settings-panel.js` (505 lines)
|
||||
- `media/settings-panel.css` (254 lines) — Stylesheet
|
||||
|
||||
### `tests/` — 37 files, ~5,875 lines
|
||||
*Depends on*: `src/`
|
||||
@@ -164,44 +165,47 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 99 files, ~3,631 lines
|
||||
### `docs/` — 112 files, ~3,927 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (86) — Astra Project Chronicle Records
|
||||
- `docs/docs/` (5) — docs Chronicle Records
|
||||
- `docs/records/` (99) — 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` (248 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`
|
||||
- **Activation events**: `onStartupFinished`
|
||||
- **Commands** (29):
|
||||
- **Commands** (31):
|
||||
- `g1nation.newChat` — Astra: New Chat
|
||||
- `g1nation.eval.retrieval` — Astra: 검색 평가 실행 (recall@k / MRR)
|
||||
- `g1nation.embeddings.backfill` — Astra: 두뇌 임베딩 전체 색인
|
||||
- `g1nation.exportChat` — Astra: Export Chat as Markdown
|
||||
- `g1nation.explainSelection` — Astra: Explain Selected Code
|
||||
- `g1nation.focusChat` — Astra: Focus Chat Input
|
||||
@@ -230,14 +234,20 @@ 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** (129 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.datacollectBridgeTarget` *(string)* _(default: `"local"`)_
|
||||
- `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — [local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project).
|
||||
- `g1nation.datacollectBridgeNasUrl` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectBridgeNasToken` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
|
||||
- `g1nation.datacollectCrawlDepth` *(number)* _(default: `1`)_
|
||||
- `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.
|
||||
@@ -285,13 +295,7 @@ flowchart LR
|
||||
- `g1nation.skillKnowledgeMap` *(object)* _(default: `{}`)_ — Inline fallback for the agent ↔ knowledge mapping. Used only when the JSON file is missing. Shape: { defaultAgent?, agents: [{ name, knowledgeFolders, model?, description? }] }. Folder paths can be ab
|
||||
- `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_
|
||||
- _…and 69 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -339,8 +343,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-09T02:20:50.409Z · signature `18ddda74`_
|
||||
<!-- 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 검토 필요
|
||||
|
||||
+1389
-959
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1779670266607,
|
||||
"createdAt": 1781085022765,
|
||||
"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-10T09:50:22.767Z\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) (20ms)\n",
|
||||
"createdAt": 1781085022767,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-25T00:51:06.593Z",
|
||||
"totalElapsedMs": 15,
|
||||
"startTime": "2026-06-10T09:50:22.743Z",
|
||||
"totalElapsedMs": 25,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 13,
|
||||
"durationMs": 20,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-05-25T00:51:06.606Z"
|
||||
"ts": "2026-06-10T09:50:22.763Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 2,
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-25T00:51:06.608Z"
|
||||
"ts": "2026-06-10T09:50:22.768Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779670273525,
|
||||
"createdAt": 1781085029846,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1779670273525,
|
||||
"createdAt": 1781085029845,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1779670273513,
|
||||
"createdAt": 1781085029841,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1779670273519,
|
||||
"createdAt": 1781085029843,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+11
-11
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1779670273495",
|
||||
"missionId": "stress_conflict_1781085029821",
|
||||
"status": "completed",
|
||||
"startTime": "2026-05-25T00:51:13.495Z",
|
||||
"totalElapsedMs": 30,
|
||||
"startTime": "2026-06-10T09:50:29.821Z",
|
||||
"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-10T09:50:29.840Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 6,
|
||||
"durationMs": 2,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-05-25T00:51:13.513Z"
|
||||
"ts": "2026-06-10T09:50:29.842Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 6,
|
||||
"durationMs": 2,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-05-25T00:51:13.519Z"
|
||||
"ts": "2026-06-10T09:50:29.844Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 6,
|
||||
"durationMs": 2,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-05-25T00:51:13.525Z"
|
||||
"ts": "2026-06-10T09:50:29.846Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
+1621
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-09T03:09:47.208Z"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR: outlook에서도 이메일 내용을 가져올 수 있나?
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
outlook에서도 이메일 내용을 가져올 수 있나?
|
||||
|
||||
## Decision
|
||||
결론부터 말씀드리면, 네, 가능합니다. 하지만 단순히 "가져온다"는 것을 넘어, 어떤 방식으로 자동화할 것인지(단순 복사 vs API 연동)에 따라 구현 난이도와 활용 범위가 완전히 달라집니다. 현재 사용자의 환경과 목적에 따라 두 가지 경로로 나누어 제안해 드립니다. --- 1. [초급] 단순 데이터 수집 (Manual/Semi-Auto) 단순히 이메일 내용을 Astra의 지식 베이스(Second Brain)나 프로젝트 기록으로 남기고 싶은 경우입니다. - 방법: Outlook에서 중요한 이메일을 `PDF` 또는 `MSG` 파일로 저장한 뒤, Astra에게 "이 파일 내용 분석해줘"라고 요청하거나 텍2파일(`.txt`)로 변정하여 전달합니다. -장점: 추가 개발 비용이 없고 가장 확실합니다. - 단점: 매번 수동으로 파일을 옮겨야 하므로 대량의 이메일을 처리하기엔 부적합합니다. 2. [고급] 자동화 파이프라인 구축 (Automated Integration) 이메일이 오면 Astra...
|
||||
|
||||
## 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: 아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. ...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. E:\Wiki\connectai
|
||||
|
||||
## Decision
|
||||
이 프로젝트는 단순한 챗봇을 넘어, 사용자의 로컬 컨텍스트(파일, 일정, 브레인 메모리)를 능동적으로 관리하고 전략적 의사결정을 지원하는 '개인용 인텔리전스 레이어(Personal Intelligence Layer)'로서 매우 탄탄한 구조를 갖추고 있습니다. 현재 Astra의 아키텍처는 `Context Manager`와 `Memory Layer`(Short/Medium/Long-term)가 분리되어 있어 데이터의 계층적 관리가 가능하며, 특히 `1인 기업 모드(Company Mode)`를 통해 에이전트 기반의 작업 할당 구조를 설계한 점은 매우 혁신적입니다. 하지만 Astra가 더 정확(Accurate)하고 정교(Sophisticated)하게 개선되기 위해서는 '정보의 수집' 단계를 넘어, 수집된 정보 간의 '관계 추론'과 '실행 자동화' 단계에서의 고도화가 필요합니다. --- 1. 정밀도(Accuracy) 향상을 위한 검토: "Context의 질적 관리" 현재 Astra는 주어진 ...
|
||||
|
||||
## 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: 아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. ...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. E:\Wiki\connectai
|
||||
|
||||
## Decision
|
||||
이 프로젝트는 단순한 챗봇을 넘어, 사용자의 로컬 컨텍스트(파일, 일정, 브레인 메모리)를 능동적으로 관리하고 전략적 의사결정을 지원하는 '개인용 인텔리전스 레이어(Personal Intelligence Layer)'로서 매우 탄탄한 구조를 갖추고 있습니다. 현재 Astra의 아키텍처는 `Context Manager`와 `Memory Layer`(Short/Medium/Long-term)가 분리되어 있어 데이터의 계층적 관리가 가능하며, 특히 `1인 기업 모드(Company Mode)`를 통해 에이전트 기반의 작업 할류 구조를 설계한 점은 매우 혁신적입니다. 하지만 Astra가 더 정확(Accurate)하고 정교(Sophisticated)하게 개선되기 위해서는 '정보의 수집' 단계를 넘어, 수집된 정보 간의 '관계 추론'과 '실행 자동화' 단계에서의 고도화가 필요합니다. --- 1. 정밀도(Accuracy) 향상을 위한 검토: "Context의 질적 관리" 현재 Astra는 주어진 ...
|
||||
|
||||
## 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.
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Development Log: 아니 지금 코드 업데이트를 했거든. 다시 코드를 검토하고 의견을 줘.
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
코드가 업데이트되었다면, 제가 변경된 로직을 정확히 파악할 수 있도록 수정된 파일의 경로와 내용을 알려주세요. 현재 저는 사용자의 로컬 파일 시스템에 직접 접근하여 파일을 읽을 수 있는 권한이 있습니다. 따라서 번거롭게 코드를 복사해서 붙여넣기보다는, 아래와 같이 요청해 주시면 제가 즉시 `read_file` 액션을 통해 분석을 시작하겠습니다. 예시: - " `src/agent.ts` 파일 다시 검토해줘." - "`src/lib/contextManager.ts` 업데이트된 내용 확인하고 의견 줘." 파일 경로를 알려주시면, 제가 즉시 해당 파일을 읽고 [결론 -> 분석 -> 개선 제안] 순으로 정밀 검토 결과를 드리겠습니다. 어떤 파일부터 살펴볼까요?
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- src/agent.ts
|
||||
- src/lib/contextManager.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,42 @@
|
||||
|
||||
## 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
|
||||
|
||||
## 2026-06-05
|
||||
- Auto decision record created: decisions\ADR-0030-outlook에서도-이메일-내용을-가져올-수-있나.md
|
||||
|
||||
## 2026-06-08
|
||||
- Auto decision record created: decisions\ADR-0031-아래-프로젝트의-로직을-분석하고-어떻게-하면-astra가-더-정확하고-정교하게-개선할-수-있는-부분이-있는지.md
|
||||
|
||||
## 2026-06-08
|
||||
- Auto decision record created: decisions\ADR-0032-아래-프로젝트의-로직을-분석하고-어떻게-하면-astra가-더-정확하고-정교하게-개선할-수-있는-부분이-있는지.md
|
||||
|
||||
## 2026-06-08
|
||||
- Auto development record created: development\2026-06-08_아니-지금-코드-업데이트를-했거든-다시-코드를-검토하고-의견을-줘_implementation.md
|
||||
|
||||
@@ -24,6 +24,18 @@ body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 빌드 버전 배지 — 이 값이 보이면 새 빌드가 로딩된 것(검증 마커). */
|
||||
.ver {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 10px;
|
||||
padding: 1px 7px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.section {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 8px;
|
||||
@@ -42,6 +54,38 @@ body {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* ─── Tabs (카테고리 네비) ─── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap; /* 좁은 사이드뷰에서 자동 줄바꿈 */
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.tab {
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
background: transparent;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--vscode-foreground);
|
||||
background: var(--vscode-list-hoverBackground);
|
||||
}
|
||||
.tab.active {
|
||||
background: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border-color: var(--vscode-button-background);
|
||||
}
|
||||
/* 비활성 탭 섹션 숨김 — DOM 에는 그대로 두고 표시만 끈다(JS refs/state 무영향). */
|
||||
.section[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
|
||||
+46
-12
@@ -8,15 +8,22 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="hd">
|
||||
<h1>Astra Settings</h1>
|
||||
<h1>Astra Settings <span class="ver" id="settingsVersion">v__VERSION__</span></h1>
|
||||
<button id="openVscodeSettings" class="link">VS Code Settings 열기</button>
|
||||
</header>
|
||||
|
||||
<div id="bannerError" class="banner" hidden></div>
|
||||
|
||||
<nav class="tabs" id="settingsTabs">
|
||||
<button type="button" class="tab" data-tab="model">모델·연결</button>
|
||||
<button type="button" class="tab" data-tab="knowledge">지식·기억</button>
|
||||
<button type="button" class="tab" data-tab="integrations">연동</button>
|
||||
<button type="button" class="tab" data-tab="advanced">동작·고급</button>
|
||||
</nav>
|
||||
|
||||
<main id="root">
|
||||
<!-- Connection -->
|
||||
<section class="section" data-section="connection">
|
||||
<section class="section" data-section="connection" data-tab="model">
|
||||
<h2>연결</h2>
|
||||
<p class="hint">로컬 AI 엔진(Ollama 또는 LM Studio) 위치와 기본 모델을 설정합니다.</p>
|
||||
<div class="row">
|
||||
@@ -46,16 +53,43 @@
|
||||
</section>
|
||||
|
||||
<!-- Datacollect -->
|
||||
<section class="section" data-section="datacollect">
|
||||
<section class="section" data-section="datacollect" data-tab="integrations">
|
||||
<h2>Datacollect (slash 명령)</h2>
|
||||
<p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. Bridge는 Datacollect 프로젝트에서 <code>npm run bridge</code> 로 실행해야 합니다.</p>
|
||||
<p class="hint">채팅에서 <code>/research</code> · <code>/benchmark</code> · <code>/youtube</code> 를 입력하면 Datacollect Bridge로 위임됩니다. <strong>타깃</strong>으로 로컬(<code>npm run bridge</code>) 또는 NAS의 경량 Bridge 중 어디를 호출할지 선택합니다.</p>
|
||||
<div class="row">
|
||||
<label for="dcBridgeUrl">Bridge URL</label>
|
||||
<label for="dcBridgeTarget">Bridge 타깃</label>
|
||||
<div class="input-group narrow">
|
||||
<select id="dcBridgeTarget">
|
||||
<option value="local">로컬 (Local)</option>
|
||||
<option value="nas">NAS</option>
|
||||
</select>
|
||||
<button data-save="datacollect.bridgeTarget">저장</button>
|
||||
</div>
|
||||
<small class="hint"><strong>local</strong> = 아래 로컬 Bridge URL 사용. <strong>nas</strong> = NAS Bridge URL(+토큰) 사용. nas인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다.</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dcBridgeUrl">로컬 Bridge URL</label>
|
||||
<div class="input-group">
|
||||
<input id="dcBridgeUrl" type="text" placeholder="http://127.0.0.1:3002" spellcheck="false" />
|
||||
<button data-save="datacollect.bridgeUrl">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dcBridgeNasUrl">NAS Bridge URL</label>
|
||||
<div class="input-group">
|
||||
<input id="dcBridgeNasUrl" type="text" placeholder="https://your-nas-domain 또는 http://nas-ip:3002" spellcheck="false" />
|
||||
<button data-save="datacollect.bridgeNasUrl">저장</button>
|
||||
</div>
|
||||
<small class="hint">타깃이 <strong>nas</strong>일 때 호출할 NAS 경량 Bridge 주소.</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dcBridgeNasToken">NAS Bridge 토큰</label>
|
||||
<div class="input-group">
|
||||
<input id="dcBridgeNasToken" type="text" placeholder="(NAS의 BRIDGE_AUTH_TOKEN 값)" spellcheck="false" />
|
||||
<button data-save="datacollect.bridgeNasToken">저장</button>
|
||||
</div>
|
||||
<small class="hint">NAS Bridge의 <code>x-bridge-token</code>. <strong>nas</strong> 타깃일 때만 요청 헤더에 실립니다.</small>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dcSavePath">결과물 저장 폴더</label>
|
||||
<div class="input-group">
|
||||
@@ -91,7 +125,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Memory -->
|
||||
<section class="section" data-section="memory">
|
||||
<section class="section" data-section="memory" data-tab="knowledge">
|
||||
<h2>메모리</h2>
|
||||
<p class="hint">대화 응답 전에 주입되는 단기/중기/장기 메모리의 양을 조정합니다.</p>
|
||||
<div class="row toggle">
|
||||
@@ -121,7 +155,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Brain -->
|
||||
<section class="section" data-section="brain">
|
||||
<section class="section" data-section="brain" data-tab="knowledge">
|
||||
<h2>두뇌 (지식 폴더)</h2>
|
||||
<p class="hint">현재 활성 두뇌 프로필 정보입니다. 추가·수정은 사이드바의 [변경 ▾ → 두뇌] 또는 VS Code Settings에서 처리합니다.</p>
|
||||
<div class="row">
|
||||
@@ -138,7 +172,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Telegram -->
|
||||
<section class="section" data-section="telegram">
|
||||
<section class="section" data-section="telegram" data-tab="integrations">
|
||||
<h2>Telegram 봇</h2>
|
||||
<p class="hint">텔레그램으로 Astra와 대화하고 싶다면 BotFather에서 봇을 만들고 토큰을 여기에 저장하세요. Astra의 다른 기능에는 영향이 없습니다.</p>
|
||||
|
||||
@@ -178,7 +212,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Google (Calendar + Sheets) -->
|
||||
<section class="section" data-section="google">
|
||||
<section class="section" data-section="google" data-tab="integrations">
|
||||
<h2>Google (Calendar · Sheets)</h2>
|
||||
<p class="hint">회의록·할일을 Google Calendar 에 자동 등록하고 Sheets 를 읽고 쓰려면 OAuth 가 필요합니다. <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a> 에서 Desktop OAuth Client 만들고 Client ID/Secret 을 아래에 붙여넣으세요.</p>
|
||||
|
||||
@@ -258,7 +292,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Cloud LLM Providers -->
|
||||
<section class="section" data-section="providers">
|
||||
<section class="section" data-section="providers" data-tab="model">
|
||||
<h2>Cloud LLM Providers</h2>
|
||||
<p class="hint">Ollama / LM Studio 로컬 외에 cloud API 를 붙여서 모델 선택지를 확장. API key 는 모두 Secret Storage 에 저장 (settings.json 침범 X). 사이드바 모델 dropdown 에서 활성 provider 의 모델이 함께 표시됩니다.</p>
|
||||
|
||||
@@ -330,7 +364,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Devil Agent (도현) -->
|
||||
<section class="section" data-section="devilAgent">
|
||||
<section class="section" data-section="devilAgent" data-tab="advanced">
|
||||
<h2>🎭 Devil's Advocate (도현)</h2>
|
||||
<p class="hint">매 답변 직후 별도 LLM 호출로 *비판적 sparring partner* 가 한 문단 반박. 사용자의 사고를 능동적 방어로 전환. 같은 모델 재사용 (~10-15% 추가 비용).</p>
|
||||
<div class="row toggle">
|
||||
@@ -342,7 +376,7 @@
|
||||
</section>
|
||||
|
||||
<!-- Advanced -->
|
||||
<section class="section" data-section="advanced">
|
||||
<section class="section" data-section="advanced" data-tab="advanced">
|
||||
<h2>고급</h2>
|
||||
<p class="hint">대부분의 사용자는 건드릴 필요 없습니다.</p>
|
||||
<div class="row toggle">
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
const cnModelHint = $('cnModelHint');
|
||||
|
||||
// ---- Datacollect ----
|
||||
const dcBridgeTarget = $('dcBridgeTarget');
|
||||
const dcBridgeUrl = $('dcBridgeUrl');
|
||||
const dcBridgeNasUrl = $('dcBridgeNasUrl');
|
||||
const dcBridgeNasToken = $('dcBridgeNasToken');
|
||||
const dcSavePath = $('dcSavePath');
|
||||
const dcCrawlDepth = $('dcCrawlDepth');
|
||||
const dcMaxPages = $('dcMaxPages');
|
||||
@@ -125,9 +128,18 @@
|
||||
);
|
||||
|
||||
// ---- Datacollect listeners ----
|
||||
document.querySelector('[data-save="datacollect.bridgeTarget"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'datacollect.update', bridgeTarget: dcBridgeTarget.value })
|
||||
);
|
||||
document.querySelector('[data-save="datacollect.bridgeUrl"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'datacollect.update', bridgeUrl: dcBridgeUrl.value })
|
||||
);
|
||||
document.querySelector('[data-save="datacollect.bridgeNasUrl"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'datacollect.update', bridgeNasUrl: dcBridgeNasUrl.value })
|
||||
);
|
||||
document.querySelector('[data-save="datacollect.bridgeNasToken"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'datacollect.update', bridgeNasToken: dcBridgeNasToken.value })
|
||||
);
|
||||
document.querySelector('[data-save="datacollect.savePath"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'datacollect.update', savePath: dcSavePath.value })
|
||||
);
|
||||
@@ -385,7 +397,12 @@
|
||||
// ---- Datacollect ----
|
||||
const dc = state.datacollect;
|
||||
if (dc) {
|
||||
if (dcBridgeTarget && document.activeElement !== dcBridgeTarget && (dc.bridgeTarget === 'local' || dc.bridgeTarget === 'nas')) {
|
||||
dcBridgeTarget.value = dc.bridgeTarget;
|
||||
}
|
||||
setIfNotFocused(dcBridgeUrl, dc.bridgeUrl);
|
||||
setIfNotFocused(dcBridgeNasUrl, dc.bridgeNasUrl);
|
||||
setIfNotFocused(dcBridgeNasToken, dc.bridgeNasToken);
|
||||
setIfNotFocused(dcSavePath, dc.savePath);
|
||||
setIfNotFocused(dcCrawlDepth, dc.crawlDepth);
|
||||
setIfNotFocused(dcMaxPages, dc.maxPages);
|
||||
@@ -465,5 +482,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tabs (카테고리 네비) — 표현 계층만. 기존 refs/리스너/state 에 무영향 ----
|
||||
(function initTabs() {
|
||||
const tabButtons = Array.from(document.querySelectorAll('.tab'));
|
||||
const sections = Array.from(document.querySelectorAll('.section'));
|
||||
if (tabButtons.length === 0) return;
|
||||
const valid = new Set(tabButtons.map((b) => b.dataset.tab));
|
||||
function setActiveTab(tab) {
|
||||
tabButtons.forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
// 숨김만(hidden) — DOM 제거 X. renderState 는 숨겨진 입력에도 값을 채운다.
|
||||
sections.forEach((s) => { s.hidden = s.dataset.tab !== tab; });
|
||||
try { const st = vscode.getState() || {}; vscode.setState({ ...st, activeTab: tab }); } catch (e) { /* noop */ }
|
||||
}
|
||||
tabButtons.forEach((b) => b.addEventListener('click', () => setActiveTab(b.dataset.tab)));
|
||||
let initial = 'model';
|
||||
try { const saved = (vscode.getState() || {}).activeTab; if (saved && valid.has(saved)) initial = saved; } catch (e) { /* noop */ }
|
||||
if (!valid.has(initial)) initial = tabButtons[0].dataset.tab;
|
||||
setActiveTab(initial);
|
||||
})();
|
||||
|
||||
vscode.postMessage({ type: 'ready' });
|
||||
})();
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "astra",
|
||||
"version": "2.2.154",
|
||||
"version": "2.2.212",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "astra",
|
||||
"version": "2.2.154",
|
||||
"version": "2.2.212",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+230
-2
@@ -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.212",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -42,6 +42,14 @@
|
||||
"title": "Astra: New Chat",
|
||||
"icon": "$(add)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.eval.retrieval",
|
||||
"title": "Astra: 검색 평가 실행 (recall@k / MRR)"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.embeddings.backfill",
|
||||
"title": "Astra: 두뇌 임베딩 전체 색인"
|
||||
},
|
||||
{
|
||||
"command": "g1nation.exportChat",
|
||||
"title": "Astra: Export Chat as Markdown"
|
||||
@@ -204,10 +212,29 @@
|
||||
"default": false,
|
||||
"description": "Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks."
|
||||
},
|
||||
"g1nation.datacollectBridgeTarget": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"local",
|
||||
"nas"
|
||||
],
|
||||
"default": "local",
|
||||
"markdownDescription": "Datacollect 백엔드(Bridge)를 어디로 보낼지 선택. **`local`**(기본) = `g1nation.datacollectBridgeUrl`(로컬 `npm run bridge`). **`nas`** = `g1nation.datacollectBridgeNasUrl`(NAS의 경량 Bridge). `nas`인데 URL이 비어 있으면 안전하게 로컬로 폴백합니다."
|
||||
},
|
||||
"g1nation.datacollectBridgeUrl": {
|
||||
"type": "string",
|
||||
"default": "http://127.0.0.1:3002",
|
||||
"description": "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)."
|
||||
"description": "[local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project)."
|
||||
},
|
||||
"g1nation.datacollectBridgeNasUrl": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[nas 타깃] NAS에서 도는 경량 Bridge URL (예: `https://your-nas-domain` 또는 `http://nas-ip:3002`). `datacollectBridgeTarget`을 `nas`로 두면 여기로 호출합니다. 비워두면 로컬로 폴백."
|
||||
},
|
||||
"g1nation.datacollectBridgeNasToken": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[nas 타깃] NAS Bridge가 요구하는 `x-bridge-token` 값(Bridge의 `BRIDGE_AUTH_TOKEN`과 일치). `nas` 타깃일 때만 요청 헤더에 실립니다. 로컬 타깃에는 영향 없음."
|
||||
},
|
||||
"g1nation.datacollectSavePath": {
|
||||
"type": "string",
|
||||
@@ -242,6 +269,27 @@
|
||||
"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.meetVerifyPass": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"markdownDescription": "`/meet` 회의록 생성 후 **검증 패스** 실행 여부. 결정 사항·액션 아이템을 녹취록(또는 추출 노트)과 LLM 으로 대조해, 근거를 못 찾는 항목을 `⚠️ 검증 결과` 섹션으로 표시한다 (날조 검출). LLM 호출이 1회 추가되어 그만큼 느려짐 — 중요한 회의에만 켜는 것을 권장."
|
||||
},
|
||||
"g1nation.teamVoiceGuide": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"editPresentation": "multilineText",
|
||||
"markdownDescription": "`/draft` 외부 커뮤니케이션 초안 작성 시 모든 생성에 적용되는 **팀 보이스 가이드**. 말투/금기 표현/자주 쓰는 표현/회사 약어 정의 등을 자유 형식으로. 예: '회사명은 항상 \"Astra\" 로 표기. 존댓말 기본. 이모지는 슬랙에서만. 약어 ASAP/FYI 사용 금지...' 비워두면 가이드 없이 일반 초안 생성."
|
||||
},
|
||||
"g1nation.memoryEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
@@ -540,6 +588,176 @@
|
||||
"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.chunkLevelRetrieval": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "섹션 청크 단위 검색 (Phase 1-가). 켜면 두뇌 검색이 파일이 아니라 '##' 헤딩 기준 섹션 청크 단위로 색인·스코어링하고 매치된 섹션을 주입합니다. 긴 다주제 문서의 검색 정밀도를 높입니다. '검색 평가 실행'으로 끄고/켜고 비교해 보세요. 기본 false."
|
||||
},
|
||||
"g1nation.chunkTargetChars": {
|
||||
"type": "number",
|
||||
"default": 1200,
|
||||
"minimum": 400,
|
||||
"maximum": 4000,
|
||||
"description": "섹션 청크의 목표 길이(문자). 섹션이 이보다 길면 문단 경계로 더 쪼갭니다. chunkLevelRetrieval 가 켜져 있을 때만 사용. 값을 바꾸면 다음 검색 때 청크 색인이 재생성됩니다. 기본 1200."
|
||||
},
|
||||
"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 +857,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}_`;
|
||||
}
|
||||
+167
@@ -60,6 +60,127 @@ export interface IAgentConfig {
|
||||
* Default 0.5 = equal weight, a reasonable starting point.
|
||||
*/
|
||||
embeddingBlendAlpha: number;
|
||||
/**
|
||||
* Section-level chunking (Phase 1-가). true 면 brain 검색이 파일 단위가 아니라
|
||||
* `##` 헤딩 기준 *섹션 청크* 단위로 색인·스코어링한다. 긴 다주제 문서의 recall 을
|
||||
* 올린다. 기본 false (= 기존 파일 단위) — 평가 하니스로 A/B 비교 후 켜기 위함.
|
||||
*/
|
||||
chunkLevelRetrieval: boolean;
|
||||
/** 섹션 청크 목표 길이(문자). 이보다 길면 문단 경계로 더 쪼갠다. */
|
||||
chunkTargetChars: 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 +237,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 +444,34 @@ 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))),
|
||||
chunkLevelRetrieval: cfg.get<boolean>('chunkLevelRetrieval', false),
|
||||
chunkTargetChars: Math.max(400, Math.min(4000, cfg.get<number>('chunkTargetChars', 1200))),
|
||||
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 +487,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,
|
||||
@@ -37,6 +42,7 @@ import { startStocksWatcher } from './features/stocks';
|
||||
import { registerProviderCommands } from './extension/providerCommands';
|
||||
import { registerScaffoldCommand } from './extension/scaffoldCommand';
|
||||
import { registerLessonCommands } from './extension/lessonCommands';
|
||||
import { registerEvalCommands } from './extension/evalCommands';
|
||||
import { registerTelegramCommands, TELEGRAM_TOKEN_SECRET_KEY, type TelegramTokenStore } from './extension/telegramCommands';
|
||||
import { setupSettingsPanel } from './extension/settingsSetup';
|
||||
import { createTelegramBot } from './integrations/telegram/telegramSetup';
|
||||
@@ -136,6 +142,14 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
// _sendModels is best-effort; the provider may not have a webview
|
||||
// attached yet during very early activation.
|
||||
void provider?._sendModels(touchedUrl);
|
||||
// 모델이 *어디서든*(설정 패널·settings.json·사이드바) 바뀌면 lifecycle 을
|
||||
// 깨워 이전 모델 자동 언로드 → 새 모델 로드. 이게 없으면 설정 패널에서
|
||||
// 전환 시 lifecycle 이 모른 채 추론 시점에 JIT 로드만 돼 VRAM 이 안 비고
|
||||
// 로드 실패가 난다.
|
||||
if (touchedModel) {
|
||||
const newModel = (vscode.workspace.getConfiguration('g1nation').get<string>('defaultModel', '') || '').trim();
|
||||
if (newModel) lifecycle.onModelSelected(newModel);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -262,6 +276,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
...registerTelegramCommands(context, { telegramBot, telegramClient, tokenStore }),
|
||||
// knowledge map + lesson cards → `src/extension/lessonCommands.ts`
|
||||
...registerLessonCommands({ getAgent: () => agent }),
|
||||
// 검색 평가 하니스 (recall@k / MRR) → `src/extension/evalCommands.ts`
|
||||
...registerEvalCommands(),
|
||||
// architecture / company / calendar / devil commands → `src/extension/providerCommands.ts`
|
||||
...registerProviderCommands(context, { getProvider: () => provider }),
|
||||
);
|
||||
@@ -273,6 +289,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
||||
const { settingsPanel, disposables: settingsDisposables } = setupSettingsPanel(context, {
|
||||
telegramClient,
|
||||
telegramBot,
|
||||
// 모델 dropdown 이 보유 모델 전부를 보이도록 SDK 다운로드 목록을 전달.
|
||||
lmStudioDownloaded: () => lmStudioClient.listDownloadedCached(),
|
||||
});
|
||||
context.subscriptions.push(...settingsDisposables);
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../config';
|
||||
import { getActiveBrainProfile, findBrainFiles, logInfo, logError } from '../utils';
|
||||
import { RetrievalOrchestrator } from '../retrieval';
|
||||
import { getBrainTokenIndex, backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../retrieval/brainIndex';
|
||||
import { embedQuery, embedTexts } from '../retrieval/embeddings';
|
||||
import {
|
||||
loadGoldenSet,
|
||||
runRetrievalEval,
|
||||
formatReportMarkdown,
|
||||
GOLDEN_TEMPLATE,
|
||||
GOLDEN_REL_JSONL,
|
||||
} from '../retrieval/evalHarness';
|
||||
|
||||
/**
|
||||
* 검색 평가 명령 묶음 (Phase 1-나).
|
||||
*
|
||||
* `g1nation.eval.retrieval` — 활성 두뇌의 골든셋(.astra/eval/golden.jsonl)으로 검색
|
||||
* recall@k / MRR 를 측정해 마크다운 리포트를 남긴다. 골든셋이 없으면 템플릿을 만들어
|
||||
* 열어준다. 청킹(Phase 1-가) 도입 전/후를 같은 골든셋으로 돌려 개선을 *숫자로* 증명하는 것이 목적.
|
||||
*/
|
||||
export function registerEvalCommands(): vscode.Disposable[] {
|
||||
return [
|
||||
vscode.commands.registerCommand('g1nation.eval.retrieval', runRetrievalEvalCommand),
|
||||
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
||||
];
|
||||
}
|
||||
|
||||
const EVAL_KS = [1, 3, 5, 10];
|
||||
|
||||
async function runRetrievalEvalCommand(): Promise<void> {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
|
||||
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다. 먼저 두뇌를 추가/선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) 골든셋 로드 — 없으면 템플릿 스캐폴드 후 열어주고 종료.
|
||||
const { entries, sourcePath, parseErrors } = loadGoldenSet(brain.localBrainPath);
|
||||
if (entries.length === 0) {
|
||||
const goldenPath = path.join(brain.localBrainPath, GOLDEN_REL_JSONL);
|
||||
const created = await scaffoldGoldenSet(goldenPath, sourcePath, parseErrors);
|
||||
if (created) {
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(goldenPath));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
vscode.window.showInformationMessage(
|
||||
'골든셋 템플릿을 만들었습니다. 질문→기대문서 쌍을 채운 뒤 다시 "Astra: 검색 평가 실행"을 실행하세요.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: 'Astra 검색 평가', cancellable: false },
|
||||
async (progress) => {
|
||||
// 2) 인덱스 워밍업 — 전체 brain 파일을 토크나이즈 인덱스에 로드 (backfill 의 전제).
|
||||
progress.report({ message: '인덱스 로드 중…' });
|
||||
const allFiles = findBrainFiles(brain.localBrainPath);
|
||||
getBrainTokenIndex(brain.localBrainPath, allFiles);
|
||||
|
||||
// 3) 임베딩 backfill — 설정된 경우 dense 항이 공정하게 평가되도록 모든 파일 벡터를 채운다.
|
||||
const useEmbeddings = !!config.embeddingModel && (config.embeddingBlendAlpha ?? 0) > 0;
|
||||
if (useEmbeddings) {
|
||||
progress.report({ message: `임베딩 채우는 중 (${config.embeddingModel})…` });
|
||||
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel });
|
||||
try {
|
||||
if (config.chunkLevelRetrieval === true) {
|
||||
await backfillBrainChunkEmbeddings(brain.localBrainPath, allFiles, config.embeddingModel, embed, config.chunkTargetChars);
|
||||
} else {
|
||||
await backfillBrainEmbeddings(brain.localBrainPath, allFiles, config.embeddingModel, embed);
|
||||
}
|
||||
} catch (e: any) {
|
||||
logInfo('Eval embedding backfill failed — continuing TF-IDF only.', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// 4) 평가 실행. ranker 는 프로덕션과 동일한 scoring 경로를 쓰되 budget 적용 전 랭킹을 본다.
|
||||
const orchestrator = new RetrievalOrchestrator();
|
||||
let done = 0;
|
||||
const ranker = async (query: string): Promise<string[]> => {
|
||||
done++;
|
||||
progress.report({ message: `질의 ${done}/${entries.length} 평가 중…` });
|
||||
let queryEmbedding: number[] | undefined;
|
||||
if (useEmbeddings) {
|
||||
try {
|
||||
queryEmbedding = await Promise.race([
|
||||
embedQuery(query, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
|
||||
]);
|
||||
} catch { queryEmbedding = undefined; }
|
||||
}
|
||||
return orchestrator
|
||||
.rankBrainForEval(query, brain, {
|
||||
limit: Math.max(...EVAL_KS) + 5,
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
|
||||
chunkTargetChars: config.chunkTargetChars,
|
||||
})
|
||||
.map((r) => r.relativePath);
|
||||
};
|
||||
|
||||
const report = await runRetrievalEval({ entries, ks: EVAL_KS, ranker });
|
||||
|
||||
// 5) 리포트 저장 + 열기.
|
||||
const now = new Date();
|
||||
const stamp = now.toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const mode = config.chunkLevelRetrieval === true
|
||||
? `섹션 청크 (target=${config.chunkTargetChars}자)`
|
||||
: '파일 단위 (baseline)';
|
||||
const noteParts = [`검색 모드: ${mode}`];
|
||||
if (parseErrors > 0) noteParts.push(`골든셋 파싱 실패 ${parseErrors}줄 (무시됨)`);
|
||||
const md = formatReportMarkdown(report, {
|
||||
brainName: brain.name,
|
||||
dateStr: now.toLocaleString(),
|
||||
embeddingModel: useEmbeddings ? config.embeddingModel : '',
|
||||
alpha: config.embeddingBlendAlpha ?? 0,
|
||||
notes: noteParts.join(' · '),
|
||||
});
|
||||
const reportPath = path.join(brain.localBrainPath, '.astra', 'eval', `report-${stamp}.md`);
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.writeFileSync(reportPath, md, 'utf8');
|
||||
logInfo('Retrieval eval complete.', {
|
||||
queries: report.total,
|
||||
recallAt5: report.recallAtK[5],
|
||||
mrr: report.mrr,
|
||||
reportPath,
|
||||
});
|
||||
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
|
||||
await vscode.window.showTextDocument(doc, { preview: false });
|
||||
vscode.window.showInformationMessage(
|
||||
`검색 평가 완료 · recall@5 ${(report.recallAtK[5] * 100).toFixed(0)}% · MRR ${report.mrr.toFixed(2)} (질의 ${report.total}개)`,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
logError('Retrieval eval command failed.', { error: err?.message || String(err) });
|
||||
vscode.window.showErrorMessage(`검색 평가 실패: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 두뇌 전체 임베딩 색인 채우기. 평소엔 턴마다 *검색된 파일* 만 lazy backfill 되므로 dense
|
||||
* 검색이 충분히 효과를 내려면 오래 걸린다 — 이 명령으로 한 번에 채운다. 청크 모드면 청크
|
||||
* 단위 벡터를, 아니면 파일 단위 벡터를 채운다. 엔진 호출 크기를 제한하려 파일 배치로 처리.
|
||||
*/
|
||||
async function backfillEmbeddingsCommand(): Promise<void> {
|
||||
try {
|
||||
const brain = getActiveBrainProfile();
|
||||
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
|
||||
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
const config = getConfig();
|
||||
if (!config.embeddingModel) {
|
||||
vscode.window.showWarningMessage(
|
||||
'임베딩 모델이 설정되지 않았습니다. 엔진(Ollama/LM Studio)에 임베딩 모델을 로드한 뒤 ' +
|
||||
'g1nation.embeddingModel 에 그 모델명을 입력하세요. (없어도 TF-IDF 검색은 동작합니다.)',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkMode = config.chunkLevelRetrieval === true;
|
||||
await vscode.window.withProgress(
|
||||
{ location: vscode.ProgressLocation.Notification, title: `Astra 임베딩 색인 (${config.embeddingModel})`, cancellable: true },
|
||||
async (progress, token) => {
|
||||
const allFiles = findBrainFiles(brain.localBrainPath);
|
||||
getBrainTokenIndex(brain.localBrainPath, allFiles);
|
||||
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel });
|
||||
|
||||
const BATCH = 40;
|
||||
let embedded = 0;
|
||||
for (let i = 0; i < allFiles.length; i += BATCH) {
|
||||
if (token.isCancellationRequested) break;
|
||||
const slice = allFiles.slice(i, i + BATCH);
|
||||
progress.report({
|
||||
message: `${Math.min(i + BATCH, allFiles.length)}/${allFiles.length} 파일 · 임베딩 ${embedded}개`,
|
||||
increment: (BATCH / allFiles.length) * 100,
|
||||
});
|
||||
try {
|
||||
embedded += chunkMode
|
||||
? await backfillBrainChunkEmbeddings(brain.localBrainPath, slice, config.embeddingModel, embed, config.chunkTargetChars)
|
||||
: await backfillBrainEmbeddings(brain.localBrainPath, slice, config.embeddingModel, embed);
|
||||
} catch (e: any) {
|
||||
logInfo('Embedding batch failed — continuing.', { batchStart: i, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
logInfo('Full-brain embedding backfill done.', { mode: chunkMode ? 'chunk' : 'file', files: allFiles.length, embedded });
|
||||
vscode.window.showInformationMessage(
|
||||
`임베딩 색인 완료 · ${chunkMode ? '청크' : '파일'} 단위 · 신규 ${embedded}개 (${allFiles.length} 파일 스캔). ` +
|
||||
`이제 '검색 평가 실행'으로 dense 효과를 측정해 보세요.`,
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (err: any) {
|
||||
logError('Embedding backfill command failed.', { error: err?.message || String(err) });
|
||||
vscode.window.showErrorMessage(`임베딩 색인 실패: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 골든셋 파일이 없을 때 템플릿을 만든다. 이미 (깨진/빈) 파일이 있으면 덮어쓰지 않는다. */
|
||||
async function scaffoldGoldenSet(goldenPath: string, existingSource: string | null, parseErrors: number): Promise<boolean> {
|
||||
if (existingSource && fs.existsSync(existingSource)) {
|
||||
// 파일은 있는데 유효 항목이 0개 — 사용자가 작성 중이거나 오타. 덮어쓰지 않고 안내만.
|
||||
vscode.window.showWarningMessage(
|
||||
`골든셋(${path.basename(existingSource)})에 유효한 항목이 없습니다${parseErrors ? ` (파싱 실패 ${parseErrors}줄)` : ''}. ` +
|
||||
'각 줄을 {"query": "...", "expected": ["파일명.md"]} 형식으로 작성하세요.',
|
||||
);
|
||||
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(existingSource));
|
||||
await vscode.window.showTextDocument(doc);
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(goldenPath), { recursive: true });
|
||||
fs.writeFileSync(goldenPath, GOLDEN_TEMPLATE, 'utf8');
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
vscode.window.showErrorMessage(`골든셋 템플릿 생성 실패: ${e?.message ?? e}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import type { TelegramHttpClient } from '../integrations/telegram/telegramClient
|
||||
export interface SettingsSetupDeps {
|
||||
telegramClient: TelegramHttpClient;
|
||||
telegramBot: TelegramBot;
|
||||
/** LM Studio SDK 다운로드 모델 목록 콜백 — 모델 dropdown 이 보유 모델 전부를 보이도록 전달. */
|
||||
lmStudioDownloaded?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,7 @@ export function setupSettingsPanel(
|
||||
secrets: context.secrets,
|
||||
telegramClient: deps.telegramClient,
|
||||
telegramBot: deps.telegramBot,
|
||||
lmStudioDownloaded: deps.lmStudioDownloaded,
|
||||
});
|
||||
|
||||
const disposables: vscode.Disposable[] = [
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4,19 +4,36 @@ import * as vscode from 'vscode';
|
||||
* Datacollect (Wiki/Datacollect 프로젝트)의 MCP Bridge HTTP 클라이언트.
|
||||
*
|
||||
* Bridge는 사용자가 별도로 띄우는 Node Express 서버(`npm run bridge`)이고
|
||||
* 기본 포트는 3002. Research(NotebookLM)/Web Benchmark(Playwright)/YouTube
|
||||
* (yt-dlp+transcript) 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를
|
||||
* thin client로 호출만 한다 — Playwright/Chrome/NotebookLM-MCP 의존성을
|
||||
* Astra가 직접 들고 갈 필요 없음.
|
||||
* 기본 포트는 3002. Web Benchmark(Playwright)/YouTube(yt-dlp+transcript)/Wikify
|
||||
* 같은 무거운 기능을 노출하므로, Astra는 이 endpoint를 thin client로 호출만 한다
|
||||
* — Playwright/Chrome/Python 의존성을 Astra가 직접 들고 갈 필요 없음.
|
||||
* (NotebookLM Deep Research 는 ASTRA 에서 제거 — 로컬 Datacollect 앱 전용.)
|
||||
*
|
||||
* URL은 `astra.datacollectBridgeUrl` VS Code 설정으로 override 가능, 기본값
|
||||
* `http://127.0.0.1:3002`. 사용자가 다른 머신/포트에서 띄우면 그쪽으로 가게.
|
||||
* 타깃은 `g1nation.datacollectBridgeTarget`(`local`|`nas`)으로 전환한다.
|
||||
* - local(기본): `g1nation.datacollectBridgeUrl` (기본 `http://127.0.0.1:3002`)
|
||||
* - nas: `g1nation.datacollectBridgeNasUrl` (+ `datacollectBridgeNasToken` 헤더)
|
||||
* nas 인데 URL 이 비어 있으면 안전하게 local 로 폴백한다(절대 깨지지 않게).
|
||||
*/
|
||||
|
||||
export function getBridgeBaseUrl(): string {
|
||||
const raw = vscode.workspace.getConfiguration('g1nation').get<string>('datacollectBridgeUrl');
|
||||
const url = (raw && raw.trim()) || 'http://127.0.0.1:3002';
|
||||
return url.replace(/\/$/, '');
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const localUrl = (cfg.get<string>('datacollectBridgeUrl')?.trim()) || 'http://127.0.0.1:3002';
|
||||
if (cfg.get<string>('datacollectBridgeTarget', 'local') === 'nas') {
|
||||
const nasUrl = cfg.get<string>('datacollectBridgeNasUrl')?.trim();
|
||||
if (nasUrl) return nasUrl.replace(/\/$/, '');
|
||||
// nas 선택했으나 URL 미설정 → 로컬로 폴백 (구동 끊기지 않게).
|
||||
}
|
||||
return localUrl.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* nas 타깃일 때 NAS Bridge 의 `x-bridge-token` 값. local 이거나 미설정이면 ''.
|
||||
* bridgeFetch 가 이 값을 요청 헤더에 실어 보낸다(빈 문자열이면 헤더 미부착).
|
||||
*/
|
||||
export function getBridgeAuthToken(): string {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
if (cfg.get<string>('datacollectBridgeTarget', 'local') !== 'nas') return '';
|
||||
return (cfg.get<string>('datacollectBridgeNasToken')?.trim()) || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,19 +43,13 @@ export function getBridgeBaseUrl(): string {
|
||||
* 바뀌면 5+ 곳을 동시 수정. 이 객체로 모아 한 곳만 바꾸면 됨.
|
||||
*
|
||||
* 카테고리:
|
||||
* - research: NotebookLM Deep Research 워크플로
|
||||
* - youtube: yt-dlp + youtube-transcript-api 기반 영상 분석
|
||||
* - web: Playwright 기반 웹 페이지 추출·벤치마크
|
||||
* - wiki: 생성된 위키 문서 디스크 저장
|
||||
* - lm: 사용자의 LM Studio / Ollama 로 LLM 호출 프록시
|
||||
*/
|
||||
export const BRIDGE_API = {
|
||||
research: {
|
||||
start: '/api/research/start',
|
||||
status: '/api/research/status',
|
||||
import: '/api/research/import',
|
||||
synthesize: '/api/research/synthesize',
|
||||
},
|
||||
// research(NotebookLM)는 ASTRA 에서 제거됨(v2.2.205) — 로컬 Datacollect 앱 전용.
|
||||
youtube: {
|
||||
extract: '/api/youtube/extract',
|
||||
},
|
||||
@@ -104,11 +115,13 @@ export async function bridgeFetch<T = any>(
|
||||
}
|
||||
|
||||
try {
|
||||
const token = getBridgeAuthToken();
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'x-bridge-token': token } : {}),
|
||||
...(init?.headers || {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,724 @@
|
||||
/**
|
||||
* Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet.
|
||||
* (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리)
|
||||
*
|
||||
* 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, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
|
||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||
import {
|
||||
addBusinessDays,
|
||||
toYmd,
|
||||
extractMeetingDate,
|
||||
resolveTaskDate,
|
||||
parseActionItems,
|
||||
} from './scheduling/calendarHelpers';
|
||||
|
||||
// ───────────────────────────── /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;
|
||||
}
|
||||
// v2.2.211: 60K 하드 자르기 폐지 → 세그먼트 추출(Map) + 병합(Reduce).
|
||||
// 단일샷 60K 는 로컬 32K 컨텍스트에서 잘리거나 lost-in-the-middle 로 중간
|
||||
// 안건이 증발하던 원인. 12K 조각별 추출은 입력이 짧아 누락·날조 둘 다 준다.
|
||||
const SEG_SIZE = 12000; // 조각 크기 (로컬 컨텍스트에 여유)
|
||||
const SINGLE_SHOT_MAX = 14000; // 이하면 기존 단일샷 경로
|
||||
const MAX_SEGMENTS = 12; // 런타임 상한 (~144K자 — 기존 60K 의 2.4배 커버)
|
||||
const segLimit = SEG_SIZE * MAX_SEGMENTS;
|
||||
const overCap = transcript.length > segLimit;
|
||||
if (overCap) transcript = transcript.slice(0, segLimit);
|
||||
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${overCap ? `, 상한 ${segLimit.toLocaleString()}자 초과로 일부 잘림` : ''})\n\n`);
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
const meetSystem = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
||||
let report: string;
|
||||
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
|
||||
try {
|
||||
if (transcript.length <= SINGLE_SHOT_MAX) {
|
||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||
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`);
|
||||
} else {
|
||||
// ── Map: 줄 경계 기준 조각 분할 → 조각별 사실 추출 ──
|
||||
const segments: string[] = [];
|
||||
let buf = '';
|
||||
for (const line of transcript.split('\n')) {
|
||||
if (buf.length + line.length + 1 > SEG_SIZE && buf) { segments.push(buf); buf = ''; }
|
||||
buf += (buf ? '\n' : '') + line;
|
||||
}
|
||||
if (buf) segments.push(buf);
|
||||
chunk(view, `🧩 **긴 녹취록 — 2단계 합성** (조각 ${segments.length}개 × ~${(SEG_SIZE / 1000) | 0}K자, 모델 \`${model}\`)\n`);
|
||||
const extractSystem = '당신은 회의 녹취 사실 추출기입니다. 제공된 조각에 명시된 내용만 형식대로 추출하고, 없는 사실을 만들지 않습니다. 모든 출력은 한국어입니다.';
|
||||
const notes: string[] = [];
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
chunk(view, ` ⏳ 조각 ${i + 1}/${segments.length} 추출 중…`);
|
||||
const t0 = Date.now();
|
||||
const note = await callLmSynthesis(
|
||||
buildMeetExtractPrompt(segments[i], metadata, i + 1, segments.length),
|
||||
extractSystem,
|
||||
);
|
||||
if (!note) throw new Error(`조각 ${i + 1} 추출 결과가 비어 있습니다.`);
|
||||
notes.push(`### ─── 조각 ${i + 1}/${segments.length} 노트 ───\n${note.trim()}`);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n`);
|
||||
}
|
||||
groundingNotes = notes.join('\n\n');
|
||||
// ── Reduce: 노트 병합 → 최종 회의록 ──
|
||||
chunk(view, ` 🧪 최종 회의록 병합 중…`);
|
||||
const t1 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetReducePrompt(groundingNotes, metadata), meetSystem);
|
||||
if (!report) throw new Error('병합 단계 LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t1) / 1000)}s)\n\n`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n\n⚠️ 회의록 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 검증 패스 (옵션, g1nation.meetVerifyPass) — 결정·액션을 근거 소스와 대조 ──
|
||||
if (cfg.get<boolean>('meetVerifyPass', false)) {
|
||||
try {
|
||||
chunk(view, `🔍 **검증 패스** — 결정·액션 근거 대조 중…`);
|
||||
const t2 = Date.now();
|
||||
const source = groundingNotes || transcript.slice(0, 28000);
|
||||
const flagged = await callLmSynthesis(
|
||||
buildMeetVerifyPrompt(report, source),
|
||||
'당신은 회의록 검증자입니다. 회의록의 각 결정·액션이 근거 소스에 실제로 존재하는지만 판정합니다. 한국어로 출력합니다.',
|
||||
);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t2) / 1000)}s)\n\n`);
|
||||
if (flagged && !/검증\s*통과/.test(flagged)) {
|
||||
report += `\n\n---\n## ⚠️ 검증 결과 (자동)\n${flagged.trim()}\n`;
|
||||
}
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n⚠️ 검증 패스 실패(회의록은 유지): ${e?.message || String(e)}\n`);
|
||||
}
|
||||
}
|
||||
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 detailLine = task.detail?.trim()
|
||||
? task.detail.trim()
|
||||
: '(녹취록에서 작업 상세가 추출되지 않음 — 회의록 본문 참조)';
|
||||
const notes = [
|
||||
`■ 작업 상세`,
|
||||
detailLine,
|
||||
``,
|
||||
`■ 맥락`,
|
||||
`· 회의록: ${meetTitle}`,
|
||||
`· 담당: ${task.owner || '(미지정)'}`,
|
||||
`· 기한: ${task.due?.trim() || '(미표기)'} → ${date}${tentative ? ' (미확정·자동 산정)' : ''}`,
|
||||
``,
|
||||
`— Astra /meet 자동 등록`,
|
||||
].join('\n');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
||||
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
||||
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
||||
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 '';
|
||||
}
|
||||
@@ -6,11 +6,13 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
|
||||
const metaBlock = metadata.trim()
|
||||
|| '(메타데이터 미입력 — 녹취록 내용에서 추론하거나 "확인 불가"로 표기)';
|
||||
return `# 임무 (Objective)
|
||||
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 외부 지식 없이 사실 기반의
|
||||
구조화된 회의록(Actionable Minutes)을 생성한다.
|
||||
제공된 회의 녹취 텍스트와 메타데이터를 기반으로, 사실 기반의 구조화된
|
||||
회의록(Actionable Minutes)을 생성한다. 외부/도메인 지식은 *STT 오타 보정과 용어
|
||||
해석*에만 사용하고, *녹취록에 없는 새로운 사실을 추가*하는 데는 절대 쓰지 않는다.
|
||||
|
||||
# 역할 (Role)
|
||||
- Fact Extractor: 사실만 추출
|
||||
- Fact Extractor: 녹취록에 명시적으로 존재하는 사실만 추출
|
||||
- Attribution Tracker: 누가 무엇을 말했는지 발언 주체를 끝까지 추적해 오귀속을 방지
|
||||
- Decision Tracker: 결정 여부 구분
|
||||
- Action Organizer: 실행 항목 구조화
|
||||
- Context Filter: 불필요한 발언(잡담) 제거
|
||||
@@ -18,18 +20,36 @@ export function buildMeetPrompt(transcript: string, metadata: string): string {
|
||||
# 데이터 우선순위 (Data Priority)
|
||||
1순위: 메타데이터 / 2순위: 녹취록 내용. 충돌 시 반드시 메타데이터를 사용한다.
|
||||
|
||||
# STT 오타 보정 (Transcription Noise Handling — 이 녹취록은 음성→텍스트 변환물이라 오타가 많다)
|
||||
- 발음이 유사한 단어가 잘못 표기돼 있다(예: "Dovrunner"→"Doverunner", "페어플레이"→"페어플래이"). **한 단어의 철자에 집착하지 말고 주변 문맥(앞뒤 키워드)으로 의미를 복원하라.**
|
||||
- 발음이 유사한 명백한 오타는 문맥상 맞는 기술 용어·고유명사로 **정규화**하라. 흔한 기술 용어(DRM, SDK, 딥링크, API, 렌더링, 페어플레이, 암호화 등)는 도메인 지식으로 보정해도 된다.
|
||||
- 메타데이터에 인명·기업명·제품명·용어가 주어졌으면 그것을 **정답 표기**로 보고, 녹취록의 유사 오타를 그 표기로 맞춘다(메타데이터는 사실상 용어집 역할).
|
||||
- **핵심 구분 (절대 혼동 금지)**: '녹취록에 *있는* 단어의 철자를 문맥으로 바로잡는 것'은 허용·권장된다. '녹취록에 *없는* 사실(수치·결정·없던 항목)을 지어내는 것'은 금지다. **철자 보정 ≠ 사실 날조.**
|
||||
- 철자가 틀려도 문맥상 의미가 분명하면 그 의미를 확정된 것으로 다뤄라 — **오타 하나 때문에 멀쩡한 내용 전체를 "확인 불가"로 막지 말 것.**
|
||||
- 정규화는 했지만 문맥으로도 정체가 끝내 모호한 용어에 한해, 정규화 표기 옆에 원문을 함께 남긴다: 예) \`Doverunner(원문: "Dovrunner", 표기 불확실)\`.
|
||||
|
||||
# 처리 절차 (Processing Flow)
|
||||
1. Deconstruction — 잡담 제거, 의미 단위로 분해, 발언자 ID는 무시한다.
|
||||
2. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
|
||||
3. Decision Logic
|
||||
1. Speaker Tracking — 발언자 ID/이름을 끝까지 유지한다. **누가 한 말인지를 절대 임의로 바꾸거나 합치지 말 것.**
|
||||
2. Topic Reclustering — 이 녹취록은 비선형이다(A→B→Z→다시 A 식으로 주제가 튄다). 녹취록 전체를 훑어 **흩어진 발언을 주제별로 다시 묶은 뒤** 정리한다. 녹취록상 앞뒤로 붙어 있다는 이유만으로 두 발언을 인과·연결 관계로 엮지 말 것(**인접 ≠ 연결**).
|
||||
3. Deconstruction — 잡담을 제거하고 의미 단위로 분해하되, 각 단위에 발언 주체를 보존한다.
|
||||
4. Classification — 모든 내용을 Fact / Discussion / Decision / Risk / Action 으로 분류한다.
|
||||
5. Decision Logic
|
||||
- 명확한 합의 표현 → Decision
|
||||
- 실행 주체 + 행동 → Action
|
||||
- 제안/의견 → Discussion
|
||||
- 조건 부족 → Open Issue
|
||||
4. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
|
||||
- 조건 부족 / 합의 불명확 → Open Issue
|
||||
6. Structuring — 중복 제거, 핵심만 유지, 짧고 명확한 문장을 사용한다.
|
||||
|
||||
# 근거·정확성 규칙 (Grounding Rules — 반드시 준수, 할루시네이션 방지)
|
||||
- **녹취록에 명시된 내용만 적는다.** 추론으로 빈칸을 메우거나, 별개의 발언을 하나의 인과 사슬로 합성하지 말 것.
|
||||
- **근거 인용 의무**: 모든 '결정 사항'과 '액션 아이템'에는 근거가 된 발언 원문 일부(20자 내외)를 따옴표로 함께 적는다(오타는 보정 표기로 인용 가능). **인용할 원문 발언을 녹취록에서 찾을 수 없는 항목은 결정·액션이 아니다** — 그런 항목은 만들지 말거나 오픈 이슈로 내려라. 이 인용은 날조 방지 장치다.
|
||||
- **발언 주체가 불명확하면 추측하지 말 것.** 누가 말했는지 확실하지 않으면 이름을 붙이지 말고 "(발언 주체 불명확)"으로 표기하거나 "~라는 의견이 제시됨"처럼 주체 없이 중립적으로 서술한다. 어떤 발언자의 말을 다른 발언자의 결론으로 옮기는 것은 가장 심각한 오류다.
|
||||
- **녹취록에 없는 숫자·날짜·금액·결정·없던 항목을 만들어내지 말 것.** (단, 녹취록에 *있는데 철자만 틀린* 용어·고유명사를 문맥으로 정규화하는 것은 허용 — 위 'STT 오타 보정' 참조.) 정말 근거 없는 *사실*만 "확인 필요"로 둔다.
|
||||
- 어떤 항목의 *내용 자체*가 녹취록에서 약하거나 모호하면(=철자 문제가 아니라 사실이 불확실) 지어내지 말고 해당 항목 끝에 "(확인 필요)"를 붙인다. 단순 표기 오타는 여기 해당하지 않는다.
|
||||
- Decision은 명시적 합의 표현이 있을 때만 '결정됨'이다. 합의가 불명확하면 '논의 중' 또는 오픈 이슈로 둔다.
|
||||
|
||||
# 출력 검증 (Validation)
|
||||
출력 전 내부적으로 점검한다: Decision은 실제 합의인가 / Action은 실행 가능한가.
|
||||
출력 전 내부적으로 점검한다: ① 각 발언이 올바른 주체에게 귀속됐는가 ② 인접 발언을 임의로 연결하지 않았는가 ③ Decision은 실제 합의인가 ④ 녹취록에 없는 정보를 추가하지 않았는가 ⑤ Action은 실행 가능한가.
|
||||
단, 검증 과정·체크 로그는 출력하지 말 것. 최종 회의록만 출력한다.
|
||||
|
||||
[메타데이터]
|
||||
@@ -40,7 +60,11 @@ ${metaBlock}
|
||||
${transcript}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
${OUTPUT_FORMAT}`;
|
||||
}
|
||||
|
||||
/** 최종 회의록 출력 형식 — 단일샷(buildMeetPrompt)과 병합 단계(buildMeetReducePrompt)가 공유. */
|
||||
const OUTPUT_FORMAT = `# 출력 형식 (Output Format — 정확히 이 구조를 유지)
|
||||
|
||||
# [회의 제목]
|
||||
|
||||
@@ -55,18 +79,116 @@ ${transcript}
|
||||
각 안건마다 아래 구조로:
|
||||
### [안건 제목]
|
||||
- **현황**:
|
||||
- **핵심 논의**:
|
||||
- **핵심 논의**: 쟁점이 되거나 주체가 중요한 발언은 "OOO: ~" 형태로 발언자를 밝힌다. 주체가 불명확하면 이름을 붙이지 말 것.
|
||||
- **결론**: [결정됨 / 논의 중 / 보류]
|
||||
|
||||
## 2. 리스크 및 이슈
|
||||
|
||||
## 3. 결정 사항
|
||||
각 결정 끝에 근거 발언을 인용한다: \`- [결정 내용] — 근거: "발언 원문 일부"\`
|
||||
|
||||
## 4. 오픈 이슈
|
||||
|
||||
## 5. 액션 아이템
|
||||
| 담당 | 작업 내용 | 기한 |
|
||||
| --- | --- | --- |
|
||||
각 행은 반드시 녹취록 근거로 작성한다. 표 셀 안에서는 줄바꿈과 \`|\` 문자를 쓰지 말 것.
|
||||
| 담당 | 작업 내용 | 작업 상세 | 기한 |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
- **작업 내용**: 한 줄짜리 작업명. 캘린더 일정 제목으로 그대로 쓰이므로 그 자체로 무슨 일인지 식별되게 작성한다. ("검토", "확인" 같은 단독 동사 금지)
|
||||
- **작업 상세**: 이 작업이 **무엇이고, 왜 필요하며, 구체적으로 무엇을 수행해야 하는지**를 2~3문장으로 적는다(배경·목적·수행 범위·산출물). 녹취록에서 언급된 대상·수치·조건을 그대로 인용하고, **마지막에 근거 발언 원문 일부를 \`근거: "…"\` 형태로 덧붙인다**. 근거가 부족하면 "추가 확인 필요: …" 형태로 무엇을 확인해야 하는지 명시한다. 단순히 작업명을 반복하지 말 것.
|
||||
|
||||
위 형식을 정확히 따르고, 모든 내용은 한국어로 작성하시오.`;
|
||||
|
||||
/**
|
||||
* [세그먼트 추출 단계 — Map] 긴 녹취록(단일 컨텍스트 초과)을 조각으로 나눠
|
||||
* 각 조각에서 사실만 추출한다. 입력이 짧아 모델이 충실해지고(lost-in-the-middle
|
||||
* 방지), 60K 자르기로 후반부가 통째로 사라지던 문제를 없앤다.
|
||||
*/
|
||||
export function buildMeetExtractPrompt(segment: string, metadata: string, segIndex: number, segTotal: number): string {
|
||||
const metaBlock = metadata.trim() || '(메타데이터 없음)';
|
||||
return `# 임무
|
||||
긴 회의 녹취록의 **${segIndex}/${segTotal}번째 조각**이다. 이 조각에 *명시된 내용만* 아래 형식으로 추출하라.
|
||||
최종 회의록은 나중에 모든 조각을 합쳐 작성하므로, 여기서는 요약·해석하지 말고 **누락 없이 추출**하는 것이 임무다.
|
||||
|
||||
# 규칙 (할루시네이션 방지)
|
||||
- 이 조각에 없는 사실·수치·결정을 만들지 말 것. 발언 주체가 불명확하면 "(주체 불명확)"으로 표기.
|
||||
- STT 오타는 문맥과 메타데이터(용어집 역할)로 정규화하되, 없는 사실을 지어내는 것은 금지.
|
||||
- 각 항목 끝에 근거 발언 원문 일부(20자 내외)를 \`근거: "…"\` 로 붙인다.
|
||||
- 조각 경계에서 잘린 문장은 무리하게 해석하지 말고 "(조각 경계에서 잘림)"으로 표기.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[녹취록 조각 ${segIndex}/${segTotal}]
|
||||
\`\`\`
|
||||
${segment}
|
||||
\`\`\`
|
||||
|
||||
# 출력 형식 (이 조각에 해당 항목이 없으면 "없음")
|
||||
## 발언자
|
||||
(이 조각에 등장한 발언자 이름/ID 목록)
|
||||
## 사실(Fact)
|
||||
- [발언자] 내용 — 근거: "…"
|
||||
## 논의(Discussion)
|
||||
- [발언자] 내용 — 근거: "…"
|
||||
## 결정(Decision)
|
||||
- 내용 — 근거: "…"
|
||||
## 리스크/이슈
|
||||
- 내용 — 근거: "…"
|
||||
## 액션(Action)
|
||||
- [담당] 작업 내용 (기한: …) — 근거: "…"
|
||||
## 언급된 수치·날짜·금액
|
||||
- 항목: 값 — 근거: "…"`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [병합 단계 — Reduce] 조각별 추출 노트를 합쳐 최종 회의록을 작성한다.
|
||||
* 입력은 원문이 아니라 추출 노트이므로, 노트에 없는 내용을 추가하면 안 된다.
|
||||
*/
|
||||
export function buildMeetReducePrompt(notes: string, metadata: string): string {
|
||||
const metaBlock = metadata.trim() || '(메타데이터 미입력 — 노트에서 추론하거나 "확인 불가"로 표기)';
|
||||
return `# 임무
|
||||
긴 회의 녹취록을 조각별로 추출한 노트들이 아래에 있다. 이 노트만 근거로 최종 회의록(Actionable Minutes)을 작성하라.
|
||||
|
||||
# 규칙
|
||||
- **노트에 있는 내용만** 사용한다. 노트에 없는 사실·수치·결정을 추가하지 말 것.
|
||||
- 같은 주제가 여러 조각에 흩어져 있으면 주제별로 다시 묶는다(Topic Reclustering). 단, 서로 다른 발언을 하나의 인과 사슬로 합성하지 말 것.
|
||||
- 발언 주체 귀속을 그대로 유지한다. "(주체 불명확)" 항목에 임의로 이름을 붙이지 말 것.
|
||||
- 중복 항목은 병합하되 근거 인용은 유지한다. 결정(Decision)은 명시적 합의가 노트에 있을 때만 '결정됨'.
|
||||
- 메타데이터와 노트가 충돌하면 메타데이터를 우선한다.
|
||||
|
||||
[메타데이터]
|
||||
${metaBlock}
|
||||
|
||||
[조각별 추출 노트]
|
||||
${notes}
|
||||
|
||||
${OUTPUT_FORMAT}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* [검증 패스 — 옵션] 완성된 회의록의 결정·액션·수치가 근거 소스(녹취록 또는
|
||||
* 추출 노트)에 실제로 존재하는지 대조한다. 날조 검출용 2차 방어선.
|
||||
*/
|
||||
export function buildMeetVerifyPrompt(report: string, source: string): string {
|
||||
return `# 임무
|
||||
아래 [회의록]의 '결정 사항'과 '액션 아이템'(및 그 안의 수치·날짜·금액)을 [근거 소스]와 대조하라.
|
||||
|
||||
# 규칙
|
||||
- 각 항목의 내용이 근거 소스에서 확인되면 통과. 찾을 수 없으면 FLAG.
|
||||
- 표기·철자 차이는 무시하고 의미로 대조한다 (STT 보정 감안).
|
||||
- 새 해석·제안을 추가하지 말 것. 판정만 한다.
|
||||
|
||||
# 출력 형식
|
||||
- 모든 항목이 확인되면 정확히 한 줄: \`검증 통과\`
|
||||
- FLAG 가 있으면 항목별로:
|
||||
- ❗ [결정|액션] "<항목 요약>" — 근거 소스에서 확인 불가: <짧은 사유>
|
||||
|
||||
[회의록]
|
||||
${report}
|
||||
|
||||
[근거 소스]
|
||||
\`\`\`
|
||||
${source}
|
||||
\`\`\``;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* `/weekly` 주간 보고서 LLM 프롬프트.
|
||||
*
|
||||
* 입력: 캘린더(Google Tasks)에서 버킷팅된 금주/차주 task 목록 + 각 task 의
|
||||
* 제목·마감·상태·메모(= /meet·/task 가 넣은 "작업 상세 / 맥락"). 금주/차주 분류는
|
||||
* **호출부(coordination.ts)에서 due/completed 날짜로 이미 끝낸 상태**로 들어온다.
|
||||
* 이 프롬프트는 *재분류하지 않고* 정해진 포맷으로 **서술만** 한다.
|
||||
*
|
||||
* 하위 bullet 은 오직 각 task 의 메모에서만 끌어온다 — 없는 사실을 만들지 않는다.
|
||||
*/
|
||||
|
||||
export interface WeeklyTask {
|
||||
title: string;
|
||||
/** 'YYYY-MM-DD' 또는 '' (마감 미정). */
|
||||
due: string;
|
||||
status: 'needsAction' | 'completed';
|
||||
/** 완료 시각 'YYYY-MM-DD' (status 'completed' 일 때만). */
|
||||
completedYmd?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface WeeklyPromptInput {
|
||||
thisWeek: WeeklyTask[];
|
||||
nextWeek: WeeklyTask[];
|
||||
thisRange: [string, string];
|
||||
nextRange: [string, string];
|
||||
}
|
||||
|
||||
/** /weekly 전용 시스템 프롬프트 — 기본(UX 분석가) 대신 주간 보고 작성자로 역할 고정. */
|
||||
export const WEEKLY_SYSTEM =
|
||||
'당신은 팀 주간 업무 보고서를 작성하는 PM 보조다. '
|
||||
+ '제공된 task 데이터(제목·마감·상태·메모)에 있는 사실만으로 보고서를 쓴다. '
|
||||
+ '메모에 없는 일정·숫자·고유명사·결정을 절대 지어내지 않으며, 모든 출력은 한국어로 작성한다.';
|
||||
|
||||
function serializeTasks(tasks: WeeklyTask[]): string {
|
||||
if (tasks.length === 0) return '(해당 범위에 task 없음)';
|
||||
return tasks.map((t, i) => {
|
||||
const statusLabel = t.status === 'completed'
|
||||
? `완료(${t.completedYmd || '시점 미상'})`
|
||||
: '진행/예정';
|
||||
const notes = (t.notes || '').trim();
|
||||
const notesBlock = notes
|
||||
? notes.split('\n').map((l) => ` ${l}`).join('\n')
|
||||
: ' (메모 없음)';
|
||||
return [
|
||||
`${i + 1}. 제목: ${t.title}`,
|
||||
` 마감: ${t.due || '미정'} | 상태: ${statusLabel}`,
|
||||
` 메모:`,
|
||||
notesBlock,
|
||||
].join('\n');
|
||||
}).join('\n\n');
|
||||
}
|
||||
|
||||
export function buildWeeklyPrompt(input: WeeklyPromptInput): string {
|
||||
const { thisWeek, nextWeek, thisRange, nextRange } = input;
|
||||
return `# 임무 (Objective)
|
||||
캘린더(Google Tasks)에서 추출한 금주·차주 작업 목록을 기반으로, **아래 정확한 포맷**의 주간 업무 보고서를 작성한다. 외부 지식 없이 제공된 데이터만 사용한다.
|
||||
|
||||
# 분류 규칙 (이미 끝남 — 재분류 금지)
|
||||
- 금주/차주 분류는 호출부에서 날짜로 이미 끝냈다. [금주 작업]에 들어온 항목은 [금주] 섹션에, [차주 작업]은 [차주] 섹션에 그대로 배치한다. **임의로 옮기지 말 것.**
|
||||
- 금주 기간: ${thisRange[0]} ~ ${thisRange[1]} / 차주 기간: ${nextRange[0]} ~ ${nextRange[1]}
|
||||
|
||||
# 작성 규칙 (Rules)
|
||||
1. 각 task 는 한 줄로: \`: [태그] 작업명 (M/D)\` 형식. 제목 앞의 \`[태그]\`(예: [이머시브], [3D App], [기타])는 그대로 유지한다.
|
||||
- 날짜는 M/D 형식(예: 6/12). 완료된 작업은 \`(6/4 완료)\`, 예정은 \`(6/8)\`, 마감이 차주인데 금주에 진행 중이면 \`(6/12 완료 예상)\`처럼 메모 근거가 있을 때만 표기.
|
||||
- 제목 끝의 \`(미확정)\` 같은 자동 꼬리표는 빼고 작업명만 쓴다.
|
||||
2. 같은 \`[태그]\`끼리 인접하도록 정렬한다.
|
||||
3. 각 task 아래에 하위 항목(\` - \`)으로 세부 내용을 2~4개 적는다. **이 세부 내용은 오직 해당 task 의 '메모'에서만** 끌어온다(작업 상세·맥락·기한 표기). 메모를 짧은 서술형으로 다듬되, 날짜·대상·수치는 메모에 적힌 그대로 인용한다.
|
||||
4. **메모에 없는 내용을 추가하지 말 것.** 세부 정보가 부족하면 지어내지 말고 "추가 확인 필요" 또는 메모에 적힌 "(확인 필요)"를 그대로 옮긴다.
|
||||
5. 해당 섹션에 task 가 하나도 없으면 그 섹션 본문에 \`: (해당 작업 없음)\` 한 줄만 적는다.
|
||||
|
||||
# 출력 포맷 (정확히 이 구조 — 헤더 문구·대괄호 그대로)
|
||||
|
||||
[금주]
|
||||
[주요 일정]
|
||||
: [태그] 작업명 (M/D)
|
||||
- 세부 내용
|
||||
- 세부 내용
|
||||
|
||||
: [태그] 작업명 (M/D)
|
||||
- 세부 내용
|
||||
|
||||
[차주]
|
||||
[예상 일정]
|
||||
: [태그] 작업명 (M/D)
|
||||
- 세부 내용
|
||||
|
||||
---
|
||||
[금주 작업] (${thisWeek.length}건)
|
||||
${serializeTasks(thisWeek)}
|
||||
|
||||
[차주 작업] (${nextWeek.length}건)
|
||||
${serializeTasks(nextWeek)}
|
||||
|
||||
위 포맷을 정확히 따르고, 보고서 본문만 출력한다(설명·머리말·코드펜스 금지).`;
|
||||
}
|
||||
@@ -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이 추론한 것"으로 즉시 식별.
|
||||
- **[정리자 추론]** ...
|
||||
- ...
|
||||
|
||||
보강할 게 없으면 이 ## 섹션 통째로 한 줄: "정리자 추가 노트 없음 — 본문 그대로가 명확함."`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -70,9 +70,13 @@ export function resolveTaskDate(due: string, meetingDate: Date, today: Date): {
|
||||
return { date: toYmd(addBusinessDays(today, 5)), tentative: true };
|
||||
}
|
||||
|
||||
/** 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱. */
|
||||
export function parseActionItems(report: string): { owner: string; work: string; due: string }[] {
|
||||
const rows: { owner: string; work: string; due: string }[] = [];
|
||||
/**
|
||||
* 회의록 본문의 "## 5. 액션 아이템" 마크다운 표에서 행을 파싱.
|
||||
* 4열 표(담당 | 작업 내용 | 작업 상세 | 기한)와 구(舊) 3열 표(담당 | 작업 내용 | 기한)를
|
||||
* 모두 지원한다. 3열일 때 detail 은 빈 문자열.
|
||||
*/
|
||||
export function parseActionItems(report: string): { owner: string; work: string; detail: string; due: string }[] {
|
||||
const rows: { owner: string; work: string; detail: string; due: string }[] = [];
|
||||
let inSection = false;
|
||||
for (const line of report.split('\n')) {
|
||||
if (/^#{1,6}\s*5\.\s*액션\s*아이템/.test(line)) { inSection = true; continue; }
|
||||
@@ -83,7 +87,11 @@ export function parseActionItems(report: string): { owner: string; work: string;
|
||||
if (cells.length < 3) continue;
|
||||
if (/^:?-+:?$/.test(cells[0])) continue; // 표 구분선
|
||||
if (cells[0] === '담당' || cells[1] === '작업 내용') continue; // 헤더
|
||||
rows.push({ owner: cells[0], work: cells[1], due: cells[2] });
|
||||
if (cells.length >= 4) {
|
||||
rows.push({ owner: cells[0], work: cells[1], detail: cells[2], due: cells[3] });
|
||||
} else {
|
||||
rows.push({ owner: cells[0], work: cells[1], detail: '', due: cells[2] });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -44,6 +44,12 @@ export interface SettingsPanelDeps {
|
||||
telegramClient: ITelegramClient;
|
||||
/** Returns the live bot instance for enrollNextChat. */
|
||||
telegramBot: TelegramBot;
|
||||
/**
|
||||
* LM Studio SDK 의 '다운로드된 모든 LLM' 목록 콜백 (보통 lmStudioClient.listDownloadedCached).
|
||||
* 모델 dropdown 이 보유 모델 전부를 보여주도록 discoverModels 에 전달한다.
|
||||
* 없으면 REST `/v1/models` 만 사용 → JIT 환경에서 로드된 1개만 나오는 회귀 발생.
|
||||
*/
|
||||
lmStudioDownloaded?: () => Promise<string[]>;
|
||||
}
|
||||
|
||||
interface SettingsState {
|
||||
@@ -88,7 +94,13 @@ interface SettingsState {
|
||||
polishPersonaOverride: string;
|
||||
};
|
||||
datacollect: {
|
||||
/** 'local' | 'nas' — 어느 Bridge 인스턴스를 호출할지. */
|
||||
bridgeTarget: string;
|
||||
bridgeUrl: string;
|
||||
/** NAS 경량 Bridge URL (nas 타깃일 때). */
|
||||
bridgeNasUrl: string;
|
||||
/** NAS Bridge 의 x-bridge-token (nas 타깃일 때 헤더로 전송). */
|
||||
bridgeNasToken: string;
|
||||
/** Empty → results saved to the Bridge's WIKI_RAW_PATH default. */
|
||||
savePath: string;
|
||||
crawlDepth: number;
|
||||
@@ -428,7 +440,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
this._modelsLoading = true;
|
||||
await this._refreshState();
|
||||
try {
|
||||
const models = await discoverModels(url);
|
||||
const models = await discoverModels(url, { lmStudioDownloaded: this._deps.lmStudioDownloaded });
|
||||
this._modelsCache = {
|
||||
url,
|
||||
models,
|
||||
@@ -605,9 +617,19 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
// savePath 가 비어 있으면 Bridge 의 WIKI_RAW_PATH 환경변수가 저장 위치를 결정한다.
|
||||
|
||||
private async _handleDatacollectUpdate(msg: any): Promise<void> {
|
||||
if (typeof msg.bridgeTarget === 'string') {
|
||||
const t = msg.bridgeTarget.trim() === 'nas' ? 'nas' : 'local';
|
||||
await this._safeConfigUpdate('datacollectBridgeTarget', t);
|
||||
}
|
||||
if (typeof msg.bridgeUrl === 'string') {
|
||||
await this._safeConfigUpdate('datacollectBridgeUrl', msg.bridgeUrl.trim());
|
||||
}
|
||||
if (typeof msg.bridgeNasUrl === 'string') {
|
||||
await this._safeConfigUpdate('datacollectBridgeNasUrl', msg.bridgeNasUrl.trim());
|
||||
}
|
||||
if (typeof msg.bridgeNasToken === 'string') {
|
||||
await this._safeConfigUpdate('datacollectBridgeNasToken', msg.bridgeNasToken.trim());
|
||||
}
|
||||
if (typeof msg.savePath === 'string') {
|
||||
await this._safeConfigUpdate('datacollectSavePath', msg.savePath.trim());
|
||||
}
|
||||
@@ -675,7 +697,10 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
polishPersonaOverride: cfg.get<string>('polishPersonaOverride', '') ?? '',
|
||||
},
|
||||
datacollect: {
|
||||
bridgeTarget: cfg.get<string>('datacollectBridgeTarget', 'local') || 'local',
|
||||
bridgeUrl: cfg.get<string>('datacollectBridgeUrl', '') || '',
|
||||
bridgeNasUrl: cfg.get<string>('datacollectBridgeNasUrl', '') || '',
|
||||
bridgeNasToken: cfg.get<string>('datacollectBridgeNasToken', '') || '',
|
||||
savePath: cfg.get<string>('datacollectSavePath', '') || '',
|
||||
crawlDepth: cfg.get<number>('datacollectCrawlDepth', 1) ?? 1,
|
||||
maxPages: cfg.get<number>('datacollectMaxPages', 8) ?? 8,
|
||||
@@ -698,9 +723,11 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
const scriptUri = webview.asWebviewUri(vscode.Uri.joinPath(mediaRoot, 'settings-panel.js')).toString();
|
||||
const tplPath = path.join(this._deps.extensionUri.fsPath, 'media', 'settings-panel.html');
|
||||
const tpl = fs.readFileSync(tplPath, 'utf8');
|
||||
const version = (this._deps.context?.extension?.packageJSON?.version as string) || '';
|
||||
return tpl
|
||||
.replace('__STYLES_URI__', stylesUri)
|
||||
.replace('__SCRIPT_URI__', scriptUri);
|
||||
.replace('__SCRIPT_URI__', scriptUri)
|
||||
.replace('__VERSION__', version);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export async function runDatacollectSetup(): Promise<void> {
|
||||
if (after.missingPackages.length === 0) {
|
||||
output.appendLine('\n✅ 설치 후 import 검증 통과. Datacollect 슬래시 명령을 바로 쓸 수 있습니다.');
|
||||
vscode.window.showInformationMessage(
|
||||
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube /research 등 다시 시도해 보세요.`,
|
||||
`Astra Setup 완료: ${probe.missingPackages.join(', ')} 설치됨. /youtube 등 다시 시도해 보세요.`,
|
||||
);
|
||||
} else {
|
||||
output.appendLine(`\n⚠️ pip 은 성공으로 끝났지만 import 검증에서 여전히 ${after.missingPackages.join(', ')} 가 안 보입니다.`);
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* `/stocks judge` 의 결정론적 기준 평가기.
|
||||
*
|
||||
* 기존에는 "유보율: 5,800%" 같은 문자열 파싱과 임계값 비교(ROE ≥ 10% 등)까지
|
||||
* 전부 LLM 에게 맡겼는데, 로컬 소형 모델은 콤마 숫자 파싱·다중 기준 동시 비교에서
|
||||
* 자주 틀린다. 수치 기준은 코드로 100% 정확하게 계산하고, LLM 은 ① '기술력'
|
||||
* 도메인 정성 판단(키워드 매칭이 모호할 때만) ② 근거 문장 서술만 담당한다.
|
||||
* 충족/미충족 판정과 대표 키워드 선택은 사용자가 명시한 규칙(투자성향별
|
||||
* 우선순위)을 그대로 코드화했다.
|
||||
*
|
||||
* v2.2.212 정밀화 (퀀트 실증 근거 반영):
|
||||
* - ROE 에 레버리지 가드(부채비율 ≤150%) — 빚으로 부풀린 ROE 배제 (듀폰분해).
|
||||
* - 성장성을 실측 YoY(매출 ≥10% / 영업이익 ≥15%) 1순위로 — 마진 수준은 성장이 아님.
|
||||
* - 안정성에 부채비율 ≤100% 가드 — 유보율은 자본금 크기에 왜곡되는 약한 지표.
|
||||
* - PER ≤12배 키워드 신설 — 보유 데이터인데 미사용이던 가격 매력(이익수익률) 축.
|
||||
*
|
||||
* signalClassifier 의 `.includes("충족")` 계약과 "충족 (A, B, C)" 출력 형식은
|
||||
* 기존 그대로 유지된다.
|
||||
*/
|
||||
import type { Stock } from './types';
|
||||
import type { Fundamentals } from './naverFundamentals';
|
||||
|
||||
export type CriterionState = 'pass' | 'fail' | 'unknown' | 'llm';
|
||||
export interface CriterionResult {
|
||||
keyword: string;
|
||||
state: CriterionState; // unknown = 데이터 없음, llm = 정성 판단 필요(기술력 도메인)
|
||||
detail: string; // 수치 근거 한 줄 (rationale 합성·로그용)
|
||||
/** 대표 키워드 표기 시 사용할 라벨 (예: 영업이익률 ≥ 20% 이면 '수익성 개선'). */
|
||||
label?: string;
|
||||
}
|
||||
export interface CriteriaEvaluation {
|
||||
results: CriterionResult[];
|
||||
/** 데이터 출처 표기 ('Naver 실시간 YYYY-MM-DD' | 'stocks.json 저장값'). */
|
||||
dataSource: string;
|
||||
/** 파싱된 수치 (LLM 프롬프트·rationale 에 인용). */
|
||||
numbers: Record<string, string>;
|
||||
}
|
||||
|
||||
// ── 문자열 → 숫자 파싱 (stocks.json 의 한글 포맷 대응) ──────────────────────
|
||||
function num(raw: string | number | undefined): number | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (typeof raw === 'number') return Number.isFinite(raw) ? raw : undefined;
|
||||
const cleaned = String(raw).replace(/[,%\s원]/g, '');
|
||||
if (!cleaned || cleaned === '-') return undefined;
|
||||
const n = parseFloat(cleaned);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
/** "1조 2,000억" / "5,000억" / "5000" → 억 단위 숫자. */
|
||||
export function marketCapEok(raw: string | undefined): number | undefined {
|
||||
if (!raw) return undefined;
|
||||
const s = String(raw).replace(/\s/g, '');
|
||||
const jo = s.match(/([\d,.]+)조/);
|
||||
const eok = s.match(/(?:조)?([\d,]+)억/);
|
||||
if (jo || eok) {
|
||||
const j = jo ? parseFloat(jo[1].replace(/,/g, '')) : 0;
|
||||
const e = eok ? parseInt(eok[1].replace(/,/g, ''), 10) : 0;
|
||||
const total = j * 10000 + e;
|
||||
return total > 0 ? total : undefined;
|
||||
}
|
||||
return num(s);
|
||||
}
|
||||
|
||||
/** 상장일 'YYYY-MM-DD' → 상장 후 경과 연수. 파싱 불가면 undefined. */
|
||||
function yearsSinceListing(listed: string | undefined, now: Date): number | undefined {
|
||||
if (!listed) return undefined;
|
||||
const d = new Date(listed);
|
||||
if (Number.isNaN(d.getTime())) return undefined;
|
||||
return (now.getTime() - d.getTime()) / (365.25 * 24 * 3600 * 1000);
|
||||
}
|
||||
|
||||
// '기술력' 도메인 키워드 — 명백히 기술 영역이면 LLM 호출 없이 통과.
|
||||
const TECH_KEYWORDS = /ai|인공지능|반도체|배터리|2차전지|이차전지|바이오|로봇|소프트웨어|플랫폼|클라우드|데이터|센서|팹리스|디스플레이|통신장비|자율주행|드론|우주|방산레이더|보안솔루션/i;
|
||||
|
||||
const fmt = (v: number | undefined, suffix = '%') => (v === undefined ? '-' : `${v.toLocaleString()}${suffix}`);
|
||||
|
||||
/**
|
||||
* 8개 기준 평가. fresh(나버 실시간)가 있으면 그 수치를 우선 사용하고,
|
||||
* 없으면 stocks.json 의 저장 문자열을 파싱한다.
|
||||
*/
|
||||
export function evaluateCriteria(stock: Stock, fresh?: Fundamentals, now: Date = new Date()): CriteriaEvaluation {
|
||||
const roe = fresh?.roe ?? num(stock['ROE(25E)']);
|
||||
const opm = fresh?.operatingMargin ?? num(stock['영업이익률(25E)']);
|
||||
const ret = fresh?.retentionRatio ?? num(stock.유보율);
|
||||
const pbr = fresh?.pbr ?? num(stock.PBR);
|
||||
const per = fresh?.per ?? num(stock['PER(25E)']);
|
||||
const debt = fresh?.debtRatio; // 부채비율 — fresh 전용 (stocks.json 미보유)
|
||||
const revYoY = fresh?.revenueGrowthYoY; // 매출 YoY % — fresh 전용
|
||||
const opYoY = fresh?.opProfitGrowthYoY; // 영업이익 YoY % — fresh 전용
|
||||
const cap = fresh?.marketCapEok ?? marketCapEok(stock.시가총액);
|
||||
const listedYears = yearsSinceListing(stock.상장일, now);
|
||||
const biz = (stock['최대 먹거리'] || '').trim();
|
||||
|
||||
const R = (keyword: string, cond: boolean | undefined, detail: string, label?: string): CriterionResult =>
|
||||
({ keyword, state: cond === undefined ? 'unknown' : cond ? 'pass' : 'fail', detail, label });
|
||||
|
||||
const results: CriterionResult[] = [];
|
||||
|
||||
// ROE — 듀폰분해상 레버리지로 부풀린 ROE 를 거른다: 부채비율(있으면) ≤150% 동반 요구.
|
||||
const roeBase = roe === undefined ? undefined : roe >= 10;
|
||||
const leverageOk = debt === undefined ? true : debt <= 150;
|
||||
results.push(R('ROE', roeBase === undefined ? undefined : (roeBase && leverageOk),
|
||||
`ROE ${fmt(roe)} (기준 ≥10%${roe !== undefined && roe >= 15 ? ', 15% 이상 우수' : ''})`
|
||||
+ (debt !== undefined ? ` · 부채비율 ${fmt(debt)} (레버리지 가드 ≤150%)` : '')));
|
||||
|
||||
// 성장성 — 실측 YoY(매출 ≥10% 또는 영업이익 ≥15%)를 1순위로. YoY 미확보 시에만
|
||||
// 기존 대용물(마진 수준·상장 연차)로 폴백 — 마진 15%는 '수익성 수준'이지 성장이 아님.
|
||||
let growth: boolean | undefined;
|
||||
let growthDetail: string;
|
||||
if (revYoY !== undefined || opYoY !== undefined) {
|
||||
growth = (revYoY !== undefined && revYoY >= 10) || (opYoY !== undefined && opYoY >= 15);
|
||||
growthDetail = `매출 YoY ${fmt(revYoY)} (기준 ≥10%) 또는 영업이익 YoY ${fmt(opYoY)} (기준 ≥15%) [실측]`;
|
||||
} else {
|
||||
const growthByMargin = opm === undefined ? undefined : opm >= 15;
|
||||
const growthByListing = listedYears === undefined ? undefined : listedYears <= 3;
|
||||
growth = growthByMargin === true || growthByListing === true ? true
|
||||
: growthByMargin === undefined && growthByListing === undefined ? undefined : false;
|
||||
growthDetail = `영업이익률 ${fmt(opm)} (기준 ≥15%) 또는 상장 ${listedYears === undefined ? '미상' : listedYears.toFixed(1) + '년'} (기준 ≤3년) [YoY 미확보 폴백]`;
|
||||
}
|
||||
results.push(R('성장성', growth, growthDetail));
|
||||
|
||||
results.push(R('유동성', ret === undefined ? undefined : ret >= 1000,
|
||||
`유보율 ${fmt(ret)} (기준 ≥1,000%)`));
|
||||
|
||||
const profitImproved = opm !== undefined && opm >= 20;
|
||||
results.push(R('수익성', opm === undefined ? undefined : opm >= 10,
|
||||
`영업이익률 ${fmt(opm)} (기준 ≥10%${profitImproved ? ', 20% 이상 → 수익성 개선' : ''})`,
|
||||
profitImproved ? '수익성 개선' : undefined));
|
||||
|
||||
const eff = opm === undefined || roe === undefined ? undefined : (opm >= 15 && roe >= 8);
|
||||
results.push(R('영업효율', eff, `영업이익률 ${fmt(opm)} ≥15% AND ROE ${fmt(roe)} ≥8%`));
|
||||
|
||||
// 기술력: PBR ≥ 2 는 결정론. 도메인은 키워드 명중 시 결정론, 아니면 LLM 정성 판단.
|
||||
const pbrOk = pbr === undefined ? undefined : pbr >= 2;
|
||||
let tech: CriterionResult;
|
||||
if (pbrOk === false) tech = R('기술력', false, `PBR ${fmt(pbr, '')} < 2 (기술 프리미엄 미인정)`);
|
||||
else if (pbrOk === undefined) tech = R('기술력', undefined, 'PBR 데이터 없음');
|
||||
else if (!biz) tech = R('기술력', false, `PBR ${fmt(pbr, '')} ≥2 이나 '최대 먹거리' 미입력`);
|
||||
else if (TECH_KEYWORDS.test(biz)) tech = R('기술력', true, `PBR ${fmt(pbr, '')} ≥2 + 최대먹거리 '${biz}' 기술영역`);
|
||||
else tech = { keyword: '기술력', state: 'llm', detail: `PBR ${fmt(pbr, '')} ≥2, 최대먹거리 '${biz}' — 기술영역 여부 정성 판단 필요` };
|
||||
results.push(tech);
|
||||
|
||||
// 안정성 — 유보율·시총에 더해 부채비율(있으면) ≤100% 동반 요구. 유보율은 자본금
|
||||
// 크기에 왜곡되는 지표라 단독으론 약함 — 부채 가드가 실질 안전판.
|
||||
const stabilityBase = ret === undefined || cap === undefined ? undefined : (ret >= 3000 && cap >= 5000);
|
||||
const stabilityDebtOk = debt === undefined ? true : debt <= 100;
|
||||
results.push(R('안정성',
|
||||
stabilityBase === undefined ? undefined : (stabilityBase && stabilityDebtOk),
|
||||
`유보율 ${fmt(ret)} ≥3,000% AND 시총 ${cap === undefined ? '-' : cap.toLocaleString() + '억'} ≥5,000억`
|
||||
+ (debt !== undefined ? ` AND 부채비율 ${fmt(debt)} ≤100%` : '')));
|
||||
|
||||
results.push(R('PBR', pbr === undefined ? undefined : pbr <= 1.5,
|
||||
`PBR ${fmt(pbr, '')} (기준 ≤1.5)`));
|
||||
|
||||
// PER — 가격 매력(이익수익률). 데이터가 이미 있는데 안 쓰던 지표.
|
||||
// 2026 시장 평균 PER ~20배 환경에서 ≤12 는 뚜렷한 저평가 신호.
|
||||
results.push(R('PER', per === undefined ? undefined : per <= 12,
|
||||
`PER ${fmt(per, '배')} (기준 ≤12배)`));
|
||||
|
||||
return {
|
||||
results,
|
||||
dataSource: fresh ? `Naver 실시간 ${now.toISOString().slice(0, 10)}` : 'stocks.json 저장값',
|
||||
numbers: {
|
||||
ROE: fmt(roe), 영업이익률: fmt(opm), 유보율: fmt(ret),
|
||||
PBR: fmt(pbr, ''), PER: fmt(per, '배'),
|
||||
부채비율: fmt(debt), 시가총액: cap === undefined ? '-' : `${cap.toLocaleString()}억`,
|
||||
'매출 YoY': fmt(revYoY), '영업이익 YoY': fmt(opYoY),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── 판정 + 대표 키워드 선택 (사용자 명시 규칙의 코드화) ─────────────────────
|
||||
const PRIORITY: Record<string, string[]> = {
|
||||
'스윙/중기': ['ROE', '성장성', '유동성', '수익성'],
|
||||
'장기투자': ['성장성', '유동성', '기술력', '영업효율'],
|
||||
'저평가우량주': ['PBR', 'PER', 'ROE', '성장성', '수익성', '안정성'],
|
||||
};
|
||||
|
||||
export interface Verdict {
|
||||
/** "충족 (ROE, 성장성, 유동성)" / "미충족 (사유: …)" — signalClassifier 계약 유지. */
|
||||
text: string;
|
||||
passed: string[];
|
||||
failed: string[];
|
||||
}
|
||||
|
||||
/** 기술력의 LLM 정성 판단 결과(techPass)를 반영해 최종 판정·대표 3개를 결정. */
|
||||
export function buildVerdict(ev: CriteriaEvaluation, style: Stock['투자성향'], techPass?: boolean): Verdict {
|
||||
const state = (r: CriterionResult): CriterionState =>
|
||||
r.keyword === '기술력' && r.state === 'llm' ? (techPass === true ? 'pass' : 'fail') : r.state;
|
||||
const passed = ev.results.filter(r => state(r) === 'pass');
|
||||
const failed = ev.results.filter(r => state(r) === 'fail' || state(r) === 'unknown');
|
||||
const passedNames = passed.map(r => r.keyword);
|
||||
|
||||
if (passed.length < 3) {
|
||||
const weak = failed.slice(0, 2).map(r => r.detail).join(' / ') || '데이터 부족';
|
||||
return { text: `미충족 (사유: ${weak})`, passed: passedNames, failed: failed.map(r => r.keyword) };
|
||||
}
|
||||
|
||||
// 대표 3개: 투자성향 우선 키워드 → 나머지 통과 키워드 순.
|
||||
const prio = PRIORITY[style || '스윙/중기'] || PRIORITY['스윙/중기'];
|
||||
const ordered = [
|
||||
...prio.filter(k => passedNames.includes(k)),
|
||||
...passedNames.filter(k => !prio.includes(k)),
|
||||
];
|
||||
const top3 = ordered.slice(0, 3)
|
||||
.map(k => passed.find(r => r.keyword === k)!)
|
||||
.map(r => r.label || r.keyword);
|
||||
return { text: `충족 (${top3.join(', ')})`, passed: passedNames, failed: failed.map(r => r.keyword) };
|
||||
}
|
||||
@@ -1,127 +1,125 @@
|
||||
import { AIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, updateStock } from './stocksStore';
|
||||
import type { Stock } from './types';
|
||||
import { evaluateCriteria, buildVerdict, type CriteriaEvaluation } from './criteriaEval';
|
||||
import type { Fundamentals } from './naverFundamentals';
|
||||
|
||||
/**
|
||||
* `/stocks judge <심볼>` 의 코어 — 종목의 재무 데이터를 LLM 에게 던져
|
||||
* "3/4 필터" 4-criteria (ROE / 성장성 / 유동성 / 수익성) 평가를 받고
|
||||
* stocks.json 의 `"3/4 필터"` 필드를 업데이트.
|
||||
* `/stocks judge <심볼>` 의 코어 — "3/4 필터" 평가.
|
||||
*
|
||||
* LLM 신뢰도 이슈는 사용자가 인지한 trade-off — 자동화 결과를 *제안* 으로 보고
|
||||
* 결과 텍스트에 "자동 평가" prefix 를 박아 수동 판정과 구분 가능하게.
|
||||
* v2.2.211 재설계: 임계값 비교(ROE ≥ 10% 등)는 더 이상 LLM 에게 맡기지 않는다.
|
||||
* 소형 로컬 모델은 "5,800%" 파싱·다중 수치 비교에서 자주 틀리므로,
|
||||
* - 수치 기준 7개 + 충족/미충족 판정 + 대표 키워드 3개 선택 = criteriaEval(코드, 결정론)
|
||||
* - LLM 역할 = ① '기술력' 도메인 정성 판단(키워드 매칭이 모호할 때만)
|
||||
* ② 평가 근거 2-3문장 서술
|
||||
* LLM 이 실패해도 판정은 항상 나온다(근거만 결정론 폴백) — judge 가 LLM 형식
|
||||
* 오류로 실패하던 경로 자체를 제거.
|
||||
*
|
||||
* 출력 형식 (LLM 에게 강제):
|
||||
* 첫 줄: "충족 (ROE, 성장성, 유동성)" 또는 "미충족"
|
||||
* 둘째 줄 ~ : (선택) 이유 한두 줄 — 호출자가 webview 에 같이 보여줌
|
||||
*
|
||||
* `.includes("충족")` 매칭은 unchanged → signalClassifier 가 자동으로 인식.
|
||||
* `.includes("충족")` 매칭(signalClassifier)과 "[자동 평가] 충족 (A, B, C)"
|
||||
* 텍스트 계약은 기존 그대로.
|
||||
*/
|
||||
|
||||
export interface JudgeResult {
|
||||
ok: boolean;
|
||||
/** stocks.json 에 저장된 새 "3/4 필터" 텍스트. */
|
||||
filterText?: string;
|
||||
/** LLM 이 제시한 평가 근거 (사용자에게 보여줌). */
|
||||
/** 평가 근거 (사용자에게 표시). LLM 서술 또는 결정론 폴백. */
|
||||
rationale?: string;
|
||||
/** 수치 데이터 출처 ('Naver 실시간 YYYY-MM-DD' | 'stocks.json 저장값'). */
|
||||
dataSource?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
'당신은 한국 주식 가치 평가 보조 도구다. 사용자가 제공한 종목의 *재무 지표만* 보고',
|
||||
'필터 평가를 내린다. 외부 정보 추측 금지 — 주어진 숫자에서만 판단.',
|
||||
'',
|
||||
'**사용 가능한 8개 평가 키워드** (사용자의 실제 분류 패턴):',
|
||||
' - ROE: ROE(25E) ≥ 10% 이면 통과. 15% 이상은 우수 (통과 강하게).',
|
||||
' - 성장성: 영업이익률(25E) ≥ 15% 또는 상장일이 3년 이내 신규성장주.',
|
||||
' - 유동성: 유보율 ≥ 1,000% 이면 통과 (자기자본 충분).',
|
||||
' - 수익성: 영업이익률(25E) ≥ 10% 이면 통과. 20% 이상이면 "수익성 개선" 표기 가능.',
|
||||
' - 영업효율: 영업이익률(25E) ≥ 15% + ROE ≥ 8% 동시 만족 (효율성 시그널).',
|
||||
' - 기술력: "최대 먹거리" 가 AI / 반도체 / 배터리 / 바이오 / 기술 영역이고 PBR ≥ 2 (시장이 기술 premium 인정).',
|
||||
' - 안정성: 유보율 ≥ 3,000% + 시가총액 ≥ 5,000억 (대형주 안전).',
|
||||
' - PBR: PBR ≤ 1.5 이면 통과 (저평가 시그널, 저평가우량주에서 주로 사용).',
|
||||
'',
|
||||
'**투자성향별 우선 적용 키워드:**',
|
||||
' - 스윙/중기: ROE / 성장성 / 유동성 / 수익성 위주.',
|
||||
' - 장기투자: 성장성 / 유동성 / 기술력 / 영업효율 위주.',
|
||||
' - 저평가우량주: PBR / ROE 필수 + (성장성 또는 수익성 또는 안정성) 중 1개.',
|
||||
'',
|
||||
'**판정 규칙:**',
|
||||
' - 위 8개 키워드 중 *3개 이상* 통과 → "충족 (통과한 항목들 콤마 구분)" 형식.',
|
||||
' 예: "충족 (ROE, 성장성, 유동성)" / "충족 (PBR, ROE, 안정성)" / "충족 (성장성, 유동성, 수익성 개선)"',
|
||||
' - 3개 미만 → "미충족 (사유: 핵심 약점 한 줄)"',
|
||||
'',
|
||||
'**키워드 선택 가이드:**',
|
||||
' - 한 종목에서 통과한 키워드 *모두* 나열하지 말고 *그 종목 성격을 가장 잘 보여주는 3개* 만 선택.',
|
||||
' - 투자성향별 우선 키워드를 먼저 포함시키고, 빠진 부분은 다른 키워드로 메움.',
|
||||
'',
|
||||
'**실제 패턴 예시** (학습용 — 사용자가 이미 분류한 종목들):',
|
||||
' - 마녀공장 (ROE 15.6%, 영업이익률 18.0%, 유보율 5,800%, 스윙/중기) → "충족 (ROE, 성장성, 유동성)"',
|
||||
' - 기가비스 (ROE 7.23%, 영업이익률 25.7%, 유보율 4,250%, 신규 상장 2년차, 스윙/중기) → "충족 (성장성, 유동성, 수익성 개선)" (ROE 가 10% 미만이라 빠짐, 대신 수익성 강함)',
|
||||
' - 엔켐 (ROE 12.4%, 영업이익률 8.5%, 유보율 1,250%, 스윙/중기) → "충족 (성장성, 유동성)" (영업이익률이 10% 미만이라 수익성 빠짐)',
|
||||
'',
|
||||
'**출력 형식 (반드시 이대로):**',
|
||||
' 1번째 줄: 위 형식의 판정 문구만 (다른 텍스트 절대 금지)',
|
||||
' 2번째 줄: 빈 줄',
|
||||
' 3번째 줄 이후: 평가 근거 2-3 문장 — 어떤 지표가 어떤 threshold 를 통과/미통과했는지 *구체 수치 인용*.',
|
||||
'당신은 한국 주식 평가 보조 도구다. 아래 [계산 결과]는 코드가 이미 정확하게',
|
||||
'계산한 결과다 — 숫자를 재계산하거나 통과/미통과 판정을 뒤집지 말 것.',
|
||||
'요청된 출력 형식 외의 텍스트를 절대 추가하지 말 것.',
|
||||
].join('\n');
|
||||
|
||||
function buildUserPrompt(s: Stock): string {
|
||||
function buildUserPrompt(name: string, symbol: string, ev: CriteriaEvaluation, askTech: string | null): string {
|
||||
const table = ev.results
|
||||
.map(r => `- ${r.keyword}: ${r.state === 'pass' ? '통과' : r.state === 'fail' ? '미통과' : r.state === 'llm' ? '판단 필요' : '데이터 없음'} — ${r.detail}`)
|
||||
.join('\n');
|
||||
const lines = [
|
||||
`종목: ${s.이름} (${s.심볼})`,
|
||||
`상장일: ${s.상장일 ?? '미상'}`,
|
||||
`투자성향: ${s.투자성향 ?? '미분류'}`,
|
||||
`유보율: ${s.유보율 ?? '-'}`,
|
||||
`ROE(25E): ${s['ROE(25E)'] ?? '-'}`,
|
||||
`영업이익률(25E): ${s['영업이익률(25E)'] ?? '-'}`,
|
||||
`EPS(25E): ${s['EPS(25E)'] ?? '-'}`,
|
||||
`PER(25E): ${s['PER(25E)'] ?? '-'}`,
|
||||
`PBR: ${s.PBR ?? '-'}`,
|
||||
`시가총액: ${s.시가총액 ?? '-'}`,
|
||||
`최대 먹거리: ${s['최대 먹거리'] ?? '-'}`,
|
||||
`특이사항: ${s.특이사항 ?? '-'}`,
|
||||
`종목: ${name} (${symbol})`,
|
||||
'',
|
||||
'[계산 결과 — 코드가 임계값을 이미 비교 완료]',
|
||||
table,
|
||||
'',
|
||||
'위 데이터로 4-criteria 필터 판정.',
|
||||
];
|
||||
if (askTech) {
|
||||
lines.push(
|
||||
`[질문 1] 이 종목의 최대 먹거리 '${askTech}' 가 기술 영역(AI/반도체/배터리/바이오/로봇/소프트웨어 등 기술 프리미엄이 인정되는 사업)에 해당하는가?`,
|
||||
'첫 줄에 정확히 `기술력: YES` 또는 `기술력: NO` 로만 답하라.',
|
||||
'',
|
||||
);
|
||||
}
|
||||
lines.push(
|
||||
`[질문 ${askTech ? '2' : '1'}] 위 계산 결과를 근거로 이 종목의 평가 근거를 2-3문장으로 서술하라.`,
|
||||
'구체 수치를 인용하되 표의 판정을 그대로 따르고, 새 수치·판정을 만들지 말 것.',
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export async function judgeStock(symbol: string): Promise<JudgeResult> {
|
||||
/** LLM 실패 시에도 판정 근거를 제공하는 결정론 폴백. */
|
||||
function fallbackRationale(ev: CriteriaEvaluation): string {
|
||||
const passed = ev.results.filter(r => r.state === 'pass').map(r => r.detail);
|
||||
const failed = ev.results.filter(r => r.state === 'fail').map(r => r.detail);
|
||||
const parts: string[] = [];
|
||||
if (passed.length) parts.push(`통과: ${passed.join(' · ')}`);
|
||||
if (failed.length) parts.push(`미통과: ${failed.join(' · ')}`);
|
||||
return parts.join('\n') || '데이터 부족으로 세부 근거 없음';
|
||||
}
|
||||
|
||||
export async function judgeStock(symbol: string, opts?: { fresh?: Fundamentals }): Promise<JudgeResult> {
|
||||
const store = readStocksStore();
|
||||
const stock = store.find(s => s.심볼 === symbol);
|
||||
if (!stock) {
|
||||
return { ok: false, error: `심볼 ${symbol} 을 stocks.json 에서 못 찾음` };
|
||||
}
|
||||
|
||||
const ai = new AIService();
|
||||
// 1) 결정론 평가 (fresh 수치가 있으면 우선 사용)
|
||||
const ev = evaluateCriteria(stock, opts?.fresh);
|
||||
const techRow = ev.results.find(r => r.keyword === '기술력');
|
||||
const needTechLlm = techRow?.state === 'llm';
|
||||
|
||||
// 2) LLM — 기술력 정성 판단(필요시) + 근거 서술. 실패해도 판정은 계속.
|
||||
let techPass: boolean | undefined;
|
||||
let rationale: string | undefined;
|
||||
try {
|
||||
const ai = new AIService();
|
||||
const result = await ai.chat({
|
||||
system: SYSTEM_PROMPT,
|
||||
user: buildUserPrompt(stock),
|
||||
user: buildUserPrompt(stock.이름, symbol, ev, needTechLlm ? (stock['최대 먹거리'] || '') : null),
|
||||
});
|
||||
if (result.empty || !result.content.trim()) {
|
||||
return { ok: false, error: 'LLM 이 빈 응답 반환' };
|
||||
const content = (result.content || '').trim();
|
||||
if (!result.empty && content) {
|
||||
if (needTechLlm) {
|
||||
const m = content.match(/기술력\s*[::]\s*(YES|NO)/i);
|
||||
if (m) techPass = m[1].toUpperCase() === 'YES';
|
||||
rationale = content.replace(/^.*기술력\s*[::]\s*(YES|NO).*$/im, '').trim() || undefined;
|
||||
} else {
|
||||
rationale = content;
|
||||
}
|
||||
}
|
||||
const lines = result.content.split('\n');
|
||||
const firstLine = (lines[0] || '').trim();
|
||||
const rationale = lines.slice(2).join('\n').trim() || undefined;
|
||||
|
||||
if (!firstLine) return { ok: false, error: 'LLM 응답 형식 오류 (첫 줄 비어있음)' };
|
||||
|
||||
// 텍스트 형식 검증 — "충족" 또는 "미충족" 으로 시작해야 signalClassifier 가 인식.
|
||||
if (!/^(충족|미충족)/.test(firstLine)) {
|
||||
return { ok: false, error: `LLM 응답 형식 오류 (충족/미충족 prefix 없음): ${firstLine.slice(0, 80)}` };
|
||||
}
|
||||
|
||||
// "자동 평가" prefix 박아서 수동/자동 구분 가능하게.
|
||||
const filterText = `[자동 평가] ${firstLine}`;
|
||||
const wrote = updateStock(symbol, { '3/4 필터': filterText });
|
||||
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
|
||||
|
||||
logInfo('Stocks LLM judge 완료.', { symbol, filterText, model: result.model });
|
||||
return { ok: true, filterText, rationale };
|
||||
} catch (e: any) {
|
||||
logError('Stocks LLM judge 실패.', { symbol, error: e?.message ?? String(e) });
|
||||
return { ok: false, error: e?.message ?? String(e) };
|
||||
logError('Stocks judge LLM 보조 호출 실패 — 결정론 폴백 사용.', { symbol, error: e?.message ?? String(e) });
|
||||
}
|
||||
if (needTechLlm && techPass === undefined) {
|
||||
// LLM 무응답/형식불량 → 보수적으로 미통과 처리 (지어내지 않음)
|
||||
techPass = false;
|
||||
}
|
||||
if (!rationale) rationale = fallbackRationale(ev);
|
||||
|
||||
// 3) 판정 + 대표 3개 (코드) → 저장
|
||||
const verdict = buildVerdict(ev, stock.투자성향, techPass);
|
||||
const filterText = `[자동 평가] ${verdict.text}`;
|
||||
const wrote = updateStock(symbol, { '3/4 필터': filterText });
|
||||
if (!wrote) return { ok: false, error: 'stocks.json 쓰기 실패' };
|
||||
|
||||
logInfo('Stocks judge 완료 (결정론 판정).', {
|
||||
symbol, filterText, dataSource: ev.dataSource,
|
||||
passed: verdict.passed.join(','), techLlm: needTechLlm ? String(techPass) : 'n/a',
|
||||
});
|
||||
return { ok: true, filterText, rationale, dataSource: ev.dataSource };
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ export interface Fundamentals {
|
||||
operatingMargin?: number; // % (영업이익률)
|
||||
retentionRatio?: number; // % (유보율)
|
||||
debtRatio?: number; // % (부채비율)
|
||||
/** 최근 확정 2개 연도 기준 YoY 성장률 (%). '성장성' 기준의 실측치 — 마진 수준이 아닌 진짜 성장. */
|
||||
revenueGrowthYoY?: number; // 매출액 YoY %
|
||||
opProfitGrowthYoY?: number; // 영업이익 YoY %
|
||||
/** integration API 의 현재가 + 평가지표. */
|
||||
per?: number;
|
||||
pbr?: number;
|
||||
@@ -125,18 +128,32 @@ export async function fetchFundamentals(symbol: string, timeoutMs = 10000): Prom
|
||||
|
||||
// finance/annual — rowList 에서 최근 확정 연도 컬럼 값.
|
||||
if (fin?.financeInfo?.rowList && fin.financeInfo.rowList.length > 0) {
|
||||
const latestKey = pickLatestConfirmedKey(fin.financeInfo.trTitleList);
|
||||
const confirmedKeys = (fin.financeInfo.trTitleList || [])
|
||||
.filter(t => t.isConsensus === 'N').map(t => t.key).sort().reverse();
|
||||
const latestKey = confirmedKeys[0] ?? null;
|
||||
const prevKey = confirmedKeys[1] ?? null;
|
||||
if (latestKey) {
|
||||
const rowByTitle = new Map(fin.financeInfo.rowList.map(r => [r.title, r]));
|
||||
const valueOf = (title: string): number | undefined => {
|
||||
const valueOf = (title: string, key: string = latestKey): number | undefined => {
|
||||
const row = rowByTitle.get(title);
|
||||
if (!row) return undefined;
|
||||
return parseNumber(row.columns[latestKey]?.value);
|
||||
return parseNumber(row.columns[key]?.value);
|
||||
};
|
||||
out.roe = valueOf('ROE');
|
||||
out.operatingMargin = valueOf('영업이익률');
|
||||
out.retentionRatio = valueOf('유보율');
|
||||
out.debtRatio = valueOf('부채비율');
|
||||
// YoY 성장률 — 최근 확정 2개 연도. '성장성'을 마진 수준이 아닌 실측 성장으로 판정.
|
||||
if (prevKey) {
|
||||
const yoy = (title: string): number | undefined => {
|
||||
const a = valueOf(title, latestKey);
|
||||
const b = valueOf(title, prevKey);
|
||||
if (a === undefined || b === undefined || b === 0) return undefined;
|
||||
return ((a - b) / Math.abs(b)) * 100;
|
||||
};
|
||||
out.revenueGrowthYoY = yoy('매출액');
|
||||
out.opProfitGrowthYoY = yoy('영업이익');
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -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';
|
||||
@@ -134,16 +136,360 @@ async function cmdRemove(arg: string, view: Webview | undefined): Promise<void>
|
||||
async function cmdJudge(arg: string, view: Webview | undefined): Promise<void> {
|
||||
if (!arg.trim()) { chunk(view, '\n사용법: `/stocks judge <심볼>`\n'); return; }
|
||||
const symbol = arg.trim();
|
||||
chunk(view, `\n🤖 LLM 4-criteria 평가 중: ${symbol}...\n`);
|
||||
const r = await judgeStock(symbol);
|
||||
// 저장값은 분기 실적 이후 stale 할 수 있어 판정 전에 Naver 실시간 수치를 시도.
|
||||
// 실패하면 stocks.json 저장값으로 폴백(결과에 데이터 출처 표기).
|
||||
chunk(view, `\n📡 Naver 펀더멘털 갱신 중: ${symbol}...\n`);
|
||||
let fresh: Fundamentals | undefined;
|
||||
try {
|
||||
fresh = (await fetchAllFundamentals([symbol])).get(symbol) ?? undefined;
|
||||
} catch { /* 폴백 — 저장값 사용 */ }
|
||||
chunk(view, fresh ? '✅ 실시간 수치 확보\n' : '⚠️ 실시간 조회 실패 — 저장값으로 평가\n');
|
||||
chunk(view, `🤖 필터 평가 중 (수치 판정=코드, 근거 서술=LLM)...\n`);
|
||||
const r = await judgeStock(symbol, { fresh });
|
||||
if (!r.ok) {
|
||||
chunk(view, `\n❌ 평가 실패: ${r.error}\n`);
|
||||
return;
|
||||
}
|
||||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n`);
|
||||
chunk(view, `\n✅ 평가 완료: ${r.filterText}\n📅 데이터: ${r.dataSource}\n`);
|
||||
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 +571,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 +608,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) => ['/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
|
||||
blurb: 'Datacollect bridge 통합 — 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록 (NotebookLM Deep Research 는 로컬 Datacollect 앱으로 분리)',
|
||||
},
|
||||
{
|
||||
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,665 @@
|
||||
/**
|
||||
* 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';
|
||||
import { callLmSynthesis } from '../../datacollect/llm';
|
||||
import { buildWeeklyPrompt, WEEKLY_SYSTEM, type WeeklyTask } from '../../datacollect/prompts/weeklyPrompt';
|
||||
|
||||
// ─── 공통 헬퍼 — /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;
|
||||
}
|
||||
|
||||
// ─── /weekly — 캘린더 task 기반 주간 보고서 (금주/차주) ──────────────────
|
||||
// `/weekly <차주시작일> <차주종료일>` — 입력한 날짜는 **차주** 기준.
|
||||
// 금주(차주 시작 직전 7일)는 자동 역산해 함께 검색한다. 버킷팅은 due/completed
|
||||
// 날짜로 코드가 처리(예측 가능), 서술 포맷팅만 LLM(meet 메모 → narrative bullet).
|
||||
|
||||
async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 — /weekly 실행 불가.\n'); return true; }
|
||||
|
||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||
if (tokens.length < 2) {
|
||||
chunk(view, [
|
||||
'\n📋 **/weekly — 주간 업무 보고서 (금주/차주)**',
|
||||
'',
|
||||
'사용법: `/weekly <차주 시작일> <차주 종료일>`',
|
||||
' · 입력한 두 날짜는 **차주** 기준입니다.',
|
||||
' · 금주(차주 시작 직전 7일)는 자동으로 역산해 함께 검색합니다.',
|
||||
'',
|
||||
'날짜 형식: `YYYY-MM-DD` · `YYYY/MM/DD` · `YY/MM/DD`',
|
||||
'',
|
||||
'예시: `/weekly 2026-06-08 2026-06-12` → 차주 6/8~6/12, 금주 6/1~6/7',
|
||||
'',
|
||||
'캘린더(Google Tasks)에 등록된 작업의 마감·완료일로 금주/차주를 나누고,',
|
||||
'각 작업의 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 채웁니다.',
|
||||
'※ `/meet`·`/task` 로 등록한 작업이 소스입니다 — 메모가 충실할수록 보고서가 정확합니다.',
|
||||
'',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
let nextStart = parseFlexibleDate(tokens[0]);
|
||||
let nextEnd = parseFlexibleDate(tokens[1]);
|
||||
if (!nextStart || !nextEnd) {
|
||||
chunk(view, `\n❌ 날짜 형식 오류 — "${tokens[0]}" / "${tokens[1]}". 사용 가능: YYYY-MM-DD · YYYY/MM/DD · YY/MM/DD.\n`);
|
||||
return true;
|
||||
}
|
||||
if (nextStart > nextEnd) { [nextStart, nextEnd] = [nextEnd, nextStart]; }
|
||||
|
||||
// 금주 = 차주 시작 직전 7일 (차주 시작 -7 ~ 차주 시작 -1).
|
||||
const thisStart = _addDaysDate(nextStart, -7);
|
||||
const thisEnd = _addDaysDate(nextStart, -1);
|
||||
|
||||
chunk(view, `\n📊 **주간 보고서**\n · 금주: ${thisStart} ~ ${thisEnd}\n · 차주: ${nextStart} ~ ${nextEnd}\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 inRange = (d: string, a: string, b: string) => !!d && d >= a && d <= b;
|
||||
const toWeekly = (t: typeof result.tasks[number]): WeeklyTask => ({
|
||||
title: t.title,
|
||||
due: t.due || '',
|
||||
status: t.status,
|
||||
completedYmd: t.completed ? t.completed.slice(0, 10) : undefined,
|
||||
notes: t.notes,
|
||||
});
|
||||
|
||||
const thisWeek: WeeklyTask[] = [];
|
||||
const nextWeek: WeeklyTask[] = [];
|
||||
for (const t of result.tasks) {
|
||||
const completedYmd = (t.completed || '').slice(0, 10);
|
||||
if (inRange(t.due || '', nextStart, nextEnd)) {
|
||||
nextWeek.push(toWeekly(t));
|
||||
} else if (inRange(t.due || '', thisStart, thisEnd) || inRange(completedYmd, thisStart, thisEnd)) {
|
||||
thisWeek.push(toWeekly(t));
|
||||
}
|
||||
}
|
||||
|
||||
if (thisWeek.length === 0 && nextWeek.length === 0) {
|
||||
chunk(view, `\nℹ️ 금주(${thisStart}~${thisEnd})·차주(${nextStart}~${nextEnd}) 범위에 등록된 task 가 없습니다.\n (\`/meet\` 또는 \`/task\` 로 등록하면 잡힙니다.)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n· 금주 ${thisWeek.length}건 · 차주 ${nextWeek.length}건 → 보고서 합성 중…\n`);
|
||||
|
||||
let report = '';
|
||||
try {
|
||||
report = await callLmSynthesis(
|
||||
buildWeeklyPrompt({ thisWeek, nextWeek, thisRange: [thisStart, thisEnd], nextRange: [nextStart, nextEnd] }),
|
||||
WEEKLY_SYSTEM,
|
||||
);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n⚠️ 보고서 합성 실패: ${e?.message || String(e)}\n(LM 서버가 떠 있는지, \`g1nation.ollamaUrl\` / \`defaultModel\` 설정을 확인하세요.)\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, '\n' + report.trim() + '\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/task', description: '단일 작업을 Google Tasks + Calendar 양쪽에 등록 (제목 + 시작일 + 완료일)', handler: runTask });
|
||||
registerSlashCommand({ name: '/weekly', description: '캘린더 task 기반 주간 보고서 — 차주 날짜 입력, 금주 자동 역산 (금주/차주 포맷)', handler: runWeekly });
|
||||
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,493 @@
|
||||
/**
|
||||
* TeamOps Dashboards — /morning · /evening · /cohort (CEO 일·월 리듬).
|
||||
*
|
||||
* v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers +
|
||||
* hire + runway + Chronicle ADR) 으로 일/월 단위 시야 제공.
|
||||
* (구 /weekly CEO 리뷰 카드는 v2.2.204 에서 제거 — /weekly 는 task 기반 금주/차주
|
||||
* 보고서로 일원화, coordination.ts 참조.)
|
||||
*
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_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;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
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 });
|
||||
@@ -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 });
|
||||
@@ -7,7 +7,7 @@ import type { MemoryManager } from '../../memory';
|
||||
import type { RetrievalOrchestrator } from '../../retrieval';
|
||||
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
||||
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
||||
import { backfillBrainEmbeddings } from '../../retrieval/brainIndex';
|
||||
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||
import {
|
||||
resolveKnowledgeMix,
|
||||
@@ -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,31 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
workStateSignals,
|
||||
hierarchicalReweightEnabled: config.hierarchicalReweightEnabled !== false,
|
||||
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
|
||||
chunkTargetChars: config.chunkTargetChars,
|
||||
});
|
||||
|
||||
// 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) {
|
||||
@@ -190,12 +238,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
.map((c) => c.metadata.filePath!)
|
||||
.filter((p, i, arr) => arr.indexOf(p) === i);
|
||||
if (scoredFilePaths.length > 0) {
|
||||
void backfillBrainEmbeddings(
|
||||
deps.activeBrain.localBrainPath,
|
||||
scoredFilePaths,
|
||||
config.embeddingModel,
|
||||
(texts) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||
);
|
||||
const embed = (texts: string[]) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel });
|
||||
// 청크 모드면 청크 단위 벡터를, 아니면 파일 단위 벡터를 채운다 (불필요한 작업 회피).
|
||||
if (config.chunkLevelRetrieval === true) {
|
||||
void backfillBrainChunkEmbeddings(deps.activeBrain.localBrainPath, scoredFilePaths, config.embeddingModel, embed, config.chunkTargetChars);
|
||||
} else {
|
||||
void backfillBrainEmbeddings(deps.activeBrain.localBrainPath, scoredFilePaths, config.embeddingModel, embed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,6 +274,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 })));
|
||||
|
||||
@@ -3,17 +3,44 @@ import { resolveEngine, buildApiUrl, logError, logInfo } from '../utils';
|
||||
/**
|
||||
* Discover the model list exposed by the local AI engine at `baseUrl`.
|
||||
*
|
||||
* Same wire format as the sidebar's `_sendModels` (which still owns the
|
||||
* sidebar-specific caching/UI logic) — extracted here so the settings panel
|
||||
* can fetch the same list without depending on the sidebar provider.
|
||||
* [가이드라인] 보유한 모델이 *전부* 나와야 한다. LM Studio 의 REST `/v1/models`
|
||||
* 는 JIT(Just-In-Time) 로딩 설정에서 *현재 로드된* 모델만 반환하므로, 그것만
|
||||
* 쓰면 dropdown 에 1개만 뜨는 회귀가 생긴다. 따라서 LM Studio 에서는 SDK
|
||||
* `system.listDownloadedModels('llm')`(다운로드된 모든 LLM)을 **우선** 시도하고,
|
||||
* 실패/0개일 때만 REST 로 폴백한다. 사이드바 `ModelDiscovery` 와 동일한 정책 —
|
||||
* 두 경로가 갈라지면 또 회귀하므로 반드시 같은 우선순위를 유지할 것.
|
||||
*
|
||||
* `opts.lmStudioDownloaded` 는 LM Studio SDK 의 다운로드 모델 목록 콜백
|
||||
* (보통 `lmStudioClient.listDownloadedCached`). 제공되지 않으면 REST 만 사용.
|
||||
*
|
||||
* Returns an empty array on any failure (offline engine, parse error, etc.).
|
||||
* Callers should treat the result as a hint, not a hard list.
|
||||
*/
|
||||
export async function discoverModels(baseUrl: string, timeoutMs: number = 5000): Promise<string[]> {
|
||||
export async function discoverModels(
|
||||
baseUrl: string,
|
||||
opts: { timeoutMs?: number; lmStudioDownloaded?: () => Promise<string[]> } = {},
|
||||
): Promise<string[]> {
|
||||
const { timeoutMs = 5000, lmStudioDownloaded } = opts;
|
||||
const url = (baseUrl || '').trim();
|
||||
if (!url) return [];
|
||||
const engine = resolveEngine(url);
|
||||
|
||||
// 1) LM Studio + SDK 우선 — 다운로드된 모든 모델(로드 여부 무관).
|
||||
if (engine === 'lmstudio' && lmStudioDownloaded) {
|
||||
try {
|
||||
const sdk = await lmStudioDownloaded();
|
||||
const filtered = sdk.filter((m): m is string => typeof m === 'string' && m.length > 0);
|
||||
if (filtered.length > 0) {
|
||||
logInfo('discoverModels: SDK 다운로드 모델 사용', { count: filtered.length });
|
||||
return filtered;
|
||||
}
|
||||
logInfo('discoverModels: SDK 0개 — REST 폴백', { engine });
|
||||
} catch (e: any) {
|
||||
logInfo('discoverModels: SDK 실패 — REST 폴백', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// 2) REST 폴백 (`/v1/models` lmstudio · `/api/tags` ollama)
|
||||
const modelsUrl = buildApiUrl(url, engine, 'models');
|
||||
try {
|
||||
const res = await fetch(modelsUrl, { signal: AbortSignal.timeout(timeoutMs) });
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -260,24 +260,36 @@ export class ModelLifecycleManager {
|
||||
this.cancelLoad();
|
||||
this.clearIdleTimer();
|
||||
|
||||
// ── 1) Unload 이전 모델 (있으면) ──────────────────────────────────────
|
||||
// 의도: 메모리 회수. 실패해도 load 는 *무조건* 진행 — LM Studio 가 unload
|
||||
// 못 한 모델은 보통 그냥 그대로 메모리에 떠 있고, load 가 새 모델로 메모리를
|
||||
// 덮어쓰면서 자연 회수되는 경우가 많다. 여기서 throw 하면 사용자가 모델
|
||||
// 교체 자체를 못 함.
|
||||
// 또한 unload 실패해도 currentModel 은 null 로 정리 — 다음 단계에서 어차피
|
||||
// modelKey 로 덮어쓰지만, 그 사이에 다른 코드가 currentModel 을 읽을 때
|
||||
// "이미 없는 prev" 를 가리키지 않도록.
|
||||
if (this.state === 'loaded' && this.currentModel && this.currentModel !== modelKey) {
|
||||
const prev = this.currentModel;
|
||||
this.state = 'unloading';
|
||||
try {
|
||||
await this.deps.client.unload(prev);
|
||||
} catch (e: any) {
|
||||
logError('LM Studio unload before switch failed — load 진행 강행.', { prev, error: e?.message ?? String(e) });
|
||||
// ── 1) 타깃 외 *로드된 모든 LLM* 언로드 (VRAM 회수) ───────────────────
|
||||
// lifecycle 이 추적하는 currentModel 뿐 아니라, 수동 로드·JIT·이전 세션으로
|
||||
// LM Studio 에 떠 있는 다른 모델까지 모두 내린다. (예: 26b 가 떠 있는 상태에서
|
||||
// 12b 로 전환 시 26b 를 자동 언로드해 VRAM 을 비워야 12b 가 로드된다.)
|
||||
// 보호: ① 타깃 모델 ② 설정된 draft 모델(speculative decoding) ③ 임베딩 모델
|
||||
// (검색 기능이 의존) 은 언로드하지 않는다.
|
||||
// 실패해도 load 는 무조건 진행 — 한 모델 unload 실패가 전체 전환을 막지 않게.
|
||||
this.state = 'unloading';
|
||||
const cfg0 = this.deps.getConfig();
|
||||
const keep = new Set<string>([modelKey, cfg0.draftModel].filter((m): m is string => !!m));
|
||||
try {
|
||||
const loaded = await this.deps.client.listLoaded();
|
||||
for (const m of loaded) {
|
||||
if (keep.has(m)) continue;
|
||||
if (/embed/i.test(m)) continue; // 임베딩 모델 보호
|
||||
try {
|
||||
await this.deps.client.unload(m);
|
||||
logInfo('LM Studio: 전환 전 다른 모델 언로드 (VRAM 회수).', { unloaded: m, target: modelKey });
|
||||
} catch (e: any) {
|
||||
logError('LM Studio unload before switch failed — 계속 진행.', { model: m, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
// listLoaded 실패 시: 추적 중인 currentModel 만이라도 언로드 (기존 동작).
|
||||
logError('listLoaded failed before switch — tracked currentModel 만 언로드 시도.', { error: e?.message ?? String(e) });
|
||||
if (this.currentModel && this.currentModel !== modelKey) {
|
||||
try { await this.deps.client.unload(this.currentModel); } catch { /* noop */ }
|
||||
}
|
||||
this.currentModel = null;
|
||||
}
|
||||
this.currentModel = null;
|
||||
|
||||
this.checkMemoryBudget(modelKey);
|
||||
|
||||
|
||||
@@ -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,12 +163,18 @@ 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;
|
||||
|
||||
// 표시되는(=사용되는) 자동 추출 항목의 만료를 연장.
|
||||
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
|
||||
for (const e of alwaysInclude) {
|
||||
if (e.expiresAt) { e.expiresAt = refreshAt; this.dirty = true; }
|
||||
}
|
||||
|
||||
const content = alwaysInclude
|
||||
.map((e) => `- [${e.category}] ${e.content}`)
|
||||
.join('\n');
|
||||
@@ -152,10 +187,13 @@ export class LongTermMemory {
|
||||
};
|
||||
}
|
||||
|
||||
// Mark as referenced
|
||||
// Mark as referenced — 자동 추출(만료 있음) 항목은 참조 시 만료를 슬라이딩 연장해
|
||||
// '쓰면 살아남고, 안 쓰면 TTL 뒤 소멸'. 영속(수동) 항목은 expiresAt 이 없어 무영향.
|
||||
const refreshAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
|
||||
for (const { entry } of relevant) {
|
||||
entry.lastReferencedAt = Date.now();
|
||||
entry.referenceCount++;
|
||||
if (entry.expiresAt) entry.expiresAt = refreshAt;
|
||||
}
|
||||
this.dirty = true;
|
||||
|
||||
@@ -173,6 +211,34 @@ export class LongTermMemory {
|
||||
|
||||
// ─── Extraction Helpers ───
|
||||
|
||||
/** 자동 추출 장기기억 기본 TTL (14일). 참조될 때마다 슬라이딩 연장된다. */
|
||||
public static readonly AUTO_EXTRACT_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** 짧은 후보 문자열에 박힌 구체적 에러 시그니처(예외명/에러코드/스택 조각) 탐지. */
|
||||
private static readonly ERROR_NOISE = /\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError|Exception|Traceback|ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET|errno|npm ERR!)\b|error\s+TS\d|:\d+:\d+\)|File ".+", line \d/i;
|
||||
|
||||
/**
|
||||
* 붙여넣은 에러 로그·스택 트레이스·실패 출력처럼 보이는 텍스트인지 *보수적으로* 추정.
|
||||
* 이런 입력은 '분석 대상'(휘발)이지 '지식'(영속)이 아니므로 장기 기억 채굴에서 제외한다.
|
||||
* 일반 산문이 'error' 를 한 번 언급한 정도로는 걸리지 않게 강한/약한 신호를 구분한다.
|
||||
*/
|
||||
public static looksLikeErrorLog(text: string): boolean {
|
||||
if (!text) return false;
|
||||
const strong = [
|
||||
/Traceback \(most recent call last\)/,
|
||||
/^\s*at\s+.+\(.+:\d+:\d+\)/m, // JS 스택 프레임
|
||||
/\bFile ".+", line \d+/, // Python 프레임
|
||||
/npm ERR!/,
|
||||
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError|KeyError|ValueError)\b/,
|
||||
/\b(?:ECONNREFUSED|ETIMEDOUT|ENOENT|EACCES|ECONNRESET)\b/,
|
||||
/error\s+TS\d{3,}/i, // tsc 에러
|
||||
];
|
||||
if (strong.some((re) => re.test(text))) return true;
|
||||
const weak = (text.match(/\b(?:error|errors|exception|failed|failure|stacktrace|errno|fatal|panic|assertion)\b/gi) || []).length
|
||||
+ (text.match(/\[(?:error|warn|fatal)\]/gi) || []).length;
|
||||
return weak >= 3 && text.split('\n').length >= 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 대화 메시지에서 장기 기억 후보를 패턴 매칭으로 추출합니다.
|
||||
* LLM 호출 없이 동작합니다.
|
||||
@@ -206,6 +272,8 @@ export class LongTermMemory {
|
||||
for (const msg of messages) {
|
||||
if (msg.role !== 'user') continue;
|
||||
const text = msg.content;
|
||||
// 에러 로그/스택 트레이스 덤프는 '분석 대상'(휘발)이므로 통째로 채굴 제외.
|
||||
if (LongTermMemory.looksLikeErrorLog(text)) continue;
|
||||
|
||||
for (const pattern of rulePatterns) {
|
||||
pattern.lastIndex = 0;
|
||||
@@ -240,9 +308,11 @@ export class LongTermMemory {
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate by content
|
||||
// Deduplicate by content + 에러 시그니처가 박힌 후보 제거
|
||||
// ('goal: fix ECONNREFUSED ...' 같은 에러 내용이 지식으로 흡수되는 오염 방지).
|
||||
const seen = new Set<string>();
|
||||
return candidates.filter((c) => {
|
||||
if (LongTermMemory.ERROR_NOISE.test(c.content)) return false;
|
||||
const key = c.content.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
|
||||
@@ -38,13 +38,18 @@ export class MemoryExtractor {
|
||||
};
|
||||
|
||||
// 1. Long-Term Memory 추출
|
||||
// 자동 추출 항목엔 TTL(14일)을 부여 — 참조될 때마다 슬라이딩 연장되므로 실제로
|
||||
// 쓰이는 지식은 살아남고, 한 번 들어온 일회성·잡음 내용은 14일 뒤 자연 소멸한다.
|
||||
// (에러 로그/실패 데이터는 extractCandidates 단계에서 이미 걸러짐.)
|
||||
const candidates = LongTermMemory.extractCandidates(messages);
|
||||
const expiresAt = Date.now() + LongTermMemory.AUTO_EXTRACT_TTL_MS;
|
||||
for (const candidate of candidates) {
|
||||
longTermMemory.addEntry(
|
||||
candidate.category,
|
||||
candidate.content,
|
||||
`session:${sessionId}`,
|
||||
0.7 // 자동 추출이므로 기본 신뢰도 0.7
|
||||
0.7, // 자동 추출이므로 기본 신뢰도 0.7
|
||||
{ expiresAt },
|
||||
);
|
||||
}
|
||||
result.longTermCandidates = candidates.length;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+39
-2
@@ -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,11 +149,18 @@ 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;
|
||||
|
||||
const projectMemory = workspacePath
|
||||
const projectMemory = workspacePath
|
||||
? this.getProjectMemory(workspacePath)
|
||||
: null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+215
-4
@@ -15,12 +15,13 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { splitIntoSections } from './chunker';
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
// v4 adds optional per-file `embedding` for hybrid (sparse+dense) retrieval.
|
||||
// Older v3 indexes are auto-rebuilt on first load — no migration needed because
|
||||
// the cache is derivable from the brain itself.
|
||||
const INDEX_VERSION = 4;
|
||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
||||
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||
// needed because the cache is fully derivable from the brain itself.
|
||||
const INDEX_VERSION = 5;
|
||||
const INDEX_DIR = '.astra';
|
||||
const INDEX_FILE = 'brain-index.json';
|
||||
/** 인덱스가 이 개수를 넘으면 이번 스캔에서 못 본 항목을 정리합니다 (삭제된 파일 누적 방지). */
|
||||
@@ -45,11 +46,32 @@ interface IndexEntry {
|
||||
embedding?: number[];
|
||||
/** Embedding model the vector was produced with — invalidates the vector when the user switches models. */
|
||||
embeddingModel?: string;
|
||||
/**
|
||||
* Section-level chunks (Phase 1-가). 지연 계산 — chunk 모드 검색이 처음 요청할 때
|
||||
* `getBrainChunkIndex` 가 채운다. 파일이 바뀌면 (재색인 시 entry 가 새로 만들어져)
|
||||
* 자동으로 사라지므로 stale chunk 가 남지 않는다.
|
||||
*/
|
||||
chunks?: ChunkEntry[];
|
||||
}
|
||||
|
||||
interface ChunkEntry {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[]; // tokenize(`${title} ${headingPath} ${sectionText}`) — 문서 제목이 모든 청크에 기여
|
||||
headingTokens: string[]; // tokenize(`${title} ${headingPath}`)
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
/** 청크 단위 dense 벡터 (Phase 1-가 후속). 파일 단위보다 정밀. 지연 backfill. */
|
||||
embedding?: number[];
|
||||
/** 이 벡터를 만든 임베딩 모델 — 모델 변경 시 무효화. */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
interface PersistedIndex {
|
||||
version: number;
|
||||
entries: Record<string, IndexEntry>; // keyed by absolute file path
|
||||
/** chunks 를 어떤 targetChars 로 만들었는지 — 설정이 바뀌면 chunk 층을 재생성. */
|
||||
chunkTargetChars?: number;
|
||||
}
|
||||
|
||||
export interface IndexedBrainDoc {
|
||||
@@ -64,6 +86,23 @@ export interface IndexedBrainDoc {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Flat chunk view returned by `getBrainChunkIndex` — 한 파일이 여러 청크로 펼쳐진다. */
|
||||
export interface IndexedChunk {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
/** 파일 내 청크 순번 (0-based). */
|
||||
chunkIndex: number;
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[];
|
||||
headingTokens: string[];
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
mtimeMs: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface BrainState {
|
||||
index: PersistedIndex;
|
||||
dirty: boolean;
|
||||
@@ -223,6 +262,99 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* lesson/playbook/qa-finding 카드는 통째로 한 청크 — 섹션 분할이 essence 추출을
|
||||
* 깨뜨리지 않도록. 일반 노트는 `splitIntoSections` 로 섹션 청크화. 문서 제목을 모든
|
||||
* 청크 토큰에 prepend 해 제목 매치 강도(파일 모드의 titleTokens)를 보존한다.
|
||||
*/
|
||||
function buildChunkEntries(entry: IndexEntry, content: string, targetChars: number): ChunkEntry[] {
|
||||
const whole = (): ChunkEntry[] => [{
|
||||
heading: entry.title,
|
||||
headingPath: [entry.title],
|
||||
tokens: entry.tokens,
|
||||
headingTokens: entry.titleTokens,
|
||||
charStart: 0,
|
||||
charEnd: content.length,
|
||||
}];
|
||||
if (entry.kind && entry.kind !== '') return whole();
|
||||
|
||||
const sections = splitIntoSections(content, {
|
||||
targetChars,
|
||||
minChars: Math.min(200, Math.floor(targetChars / 4)),
|
||||
maxChars: targetChars * 2,
|
||||
});
|
||||
if (sections.length === 0) return whole();
|
||||
|
||||
return sections.map((s) => {
|
||||
const headingText = [entry.title, ...s.headingPath].join(' ');
|
||||
return {
|
||||
heading: s.heading || entry.title,
|
||||
headingPath: s.headingPath.length ? s.headingPath : [entry.title],
|
||||
tokens: tokenize(`${headingText} ${s.text}`),
|
||||
headingTokens: tokenize(headingText),
|
||||
charStart: s.charStart,
|
||||
charEnd: s.charEnd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-level chunk view (Phase 1-가). 먼저 `getBrainTokenIndex` 로 파일 entry 를
|
||||
* 최신화한 뒤, 각 파일의 chunk 층을 (없으면) 계산·캐시해서 flat 하게 펼쳐 반환한다.
|
||||
* `targetChars` 가 직전 빌드값과 다르면 전체 chunk 캐시를 버리고 재생성한다.
|
||||
* Steady-state(변경 없음 + 같은 target)에서는 디스크/CPU 작업 0.
|
||||
*/
|
||||
export function getBrainChunkIndex(brainPath: string, files: string[], targetChars: number): IndexedChunk[] {
|
||||
if (!brainPath || !Array.isArray(files) || files.length === 0) return [];
|
||||
// 1) 파일 entry 최신화 (토큰/메타/prune 까지 여기서 처리).
|
||||
getBrainTokenIndex(brainPath, files);
|
||||
const st = loadState(brainPath);
|
||||
|
||||
// 2) targetChars 변경 시 chunk 층 전체 무효화.
|
||||
if (st.index.chunkTargetChars !== targetChars) {
|
||||
for (const key of Object.keys(st.index.entries)) {
|
||||
const e = st.index.entries[key];
|
||||
if (e) e.chunks = undefined;
|
||||
}
|
||||
st.index.chunkTargetChars = targetChars;
|
||||
st.dirty = true;
|
||||
}
|
||||
|
||||
const out: IndexedChunk[] = [];
|
||||
let built = 0;
|
||||
for (const file of files) {
|
||||
const entry = st.index.entries[file];
|
||||
if (!entry) continue;
|
||||
if (!entry.chunks) {
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
||||
entry.chunks = buildChunkEntries(entry, content, targetChars);
|
||||
st.dirty = true;
|
||||
built++;
|
||||
}
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: entry.relativePath,
|
||||
title: entry.title,
|
||||
chunkIndex: ci,
|
||||
heading: ch.heading,
|
||||
headingPath: ch.headingPath,
|
||||
tokens: ch.tokens,
|
||||
headingTokens: ch.headingTokens,
|
||||
charStart: ch.charStart,
|
||||
charEnd: ch.charEnd,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
kind: entry.kind || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (built > 0) logInfo('Brain chunk index built.', { brainPath, files: files.length, filesChunked: built, totalChunks: out.length, targetChars });
|
||||
if (st.dirty) scheduleWrite(st, brainPath);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (filePath, embedding) for every file in `filePaths` that has a current
|
||||
* cached vector under `model`. Caller uses this to rank top TF-IDF candidates
|
||||
@@ -310,6 +442,85 @@ export async function backfillBrainEmbeddings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 청크 단위 임베딩 조회. `${filePath}#${chunkIndex}` → vector. 모델 불일치/미존재 청크는 생략.
|
||||
* searchBrainChunks 가 dense blend 에 사용 (파일 단위 공유보다 정밀).
|
||||
*/
|
||||
export function getBrainChunkEmbeddings(brainPath: string, model: string): Map<string, number[]> {
|
||||
const out = new Map<string, number[]>();
|
||||
if (!brainPath || !model.trim()) return out;
|
||||
const st = _states.get(brainPath);
|
||||
if (!st) return out;
|
||||
for (const [fp, entry] of Object.entries(st.index.entries)) {
|
||||
if (!entry?.chunks) continue;
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
if (!ch.embedding || ch.embeddingModel !== model) continue;
|
||||
if (!Array.isArray(ch.embedding) || ch.embedding.length === 0) continue;
|
||||
out.set(`${fp}#${ci}`, ch.embedding);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background fill — 주어진 `files` 의 청크 중 현재 모델 벡터가 없는 것만 임베딩한다.
|
||||
* 청크 텍스트는 캐시된 토큰에서 재구성(파일 단위 backfill 과 동일 전략 — 파일 재read 회피).
|
||||
* Fire-and-forget 용. 새로 임베딩한 청크 수를 반환.
|
||||
*/
|
||||
export async function backfillBrainChunkEmbeddings(
|
||||
brainPath: string,
|
||||
files: string[],
|
||||
model: string,
|
||||
embedFn: (texts: string[]) => Promise<number[][]>,
|
||||
targetChars: number,
|
||||
): Promise<number> {
|
||||
if (!brainPath || !model.trim() || !Array.isArray(files) || files.length === 0) return 0;
|
||||
// 청크 층 보장 (없으면 생성).
|
||||
getBrainChunkIndex(brainPath, files, targetChars);
|
||||
const st = _states.get(brainPath);
|
||||
if (!st) return 0;
|
||||
|
||||
const texts: string[] = [];
|
||||
const refs: Array<{ fp: string; ci: number }> = [];
|
||||
for (const fp of files) {
|
||||
const entry = st.index.entries[fp];
|
||||
if (!entry?.chunks) continue;
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
if (ch.embedding && ch.embeddingModel === model) continue;
|
||||
const text = Array.isArray(ch.tokens) && ch.tokens.length > 0 ? ch.tokens.join(' ') : '';
|
||||
if (!text.trim()) continue;
|
||||
texts.push(text);
|
||||
refs.push({ fp, ci });
|
||||
}
|
||||
}
|
||||
if (texts.length === 0) return 0;
|
||||
try {
|
||||
const vectors = await embedFn(texts);
|
||||
let n = 0;
|
||||
for (let i = 0; i < vectors.length && i < refs.length; i++) {
|
||||
const v = vectors[i];
|
||||
if (!Array.isArray(v) || v.length === 0) continue;
|
||||
const entry = st.index.entries[refs[i].fp];
|
||||
const ch = entry?.chunks?.[refs[i].ci];
|
||||
if (!ch) continue;
|
||||
ch.embedding = v;
|
||||
ch.embeddingModel = model;
|
||||
st.dirty = true;
|
||||
n++;
|
||||
}
|
||||
if (n > 0) {
|
||||
logInfo('Brain chunk embeddings backfilled.', { brainPath, model, embedded: n });
|
||||
scheduleWrite(st, brainPath);
|
||||
}
|
||||
return n;
|
||||
} catch (e: any) {
|
||||
logInfo('Brain chunk embedding backfill failed (TF-IDF still works).', { brainPath, model, error: e?.message ?? String(e) });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the in-memory index (and pending write) for one brain, or all brains. The disk file is left as-is. */
|
||||
export function clearBrainTokenIndex(brainPath?: string): void {
|
||||
if (brainPath === undefined) {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Markdown Section Chunker (Phase 1-가)
|
||||
*
|
||||
* 긴 노트를 `#`~`######` 헤딩 경계로 *섹션 청크* 로 나눈다. 파일 단위 색인은 5000자
|
||||
* 다주제 문서를 하나의 흐릿한 단위로 만들어 검색 정밀도를 떨어뜨린다 — 섹션 단위로
|
||||
* 쪼개면 질의가 정확히 해당 섹션에 매치된다 (제2뇌의 "문서 청킹 전략" 지식 그대로).
|
||||
*
|
||||
* 규칙:
|
||||
* - 각 헤딩 ~ 다음 헤딩 직전까지가 raw 섹션. 첫 헤딩 이전 본문(preamble)도 한 섹션.
|
||||
* - 헤딩 breadcrumb(상위 헤딩 경로)을 함께 보존 → 청크가 문맥을 잃지 않음.
|
||||
* - minChars 미만의 짧은 섹션은 다음 섹션과 병합(헤딩만 있고 본문 적은 경우 흔함).
|
||||
* - targetChars 초과 누적 시 청크 확정. maxChars 초과 단일 섹션은 문단 경계로 재분할.
|
||||
*
|
||||
* 순수 함수 (fs/네트워크 의존 없음) — 단위 테스트·재현 용이.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
export interface Section {
|
||||
/** 이 섹션의 헤딩 텍스트 ('' = preamble). */
|
||||
heading: string;
|
||||
/** 루트→자기까지 헤딩 경로 (문맥용 breadcrumb). preamble 이면 []. */
|
||||
headingPath: string[];
|
||||
/** 섹션 본문(헤딩 라인 포함, 원문 그대로). */
|
||||
text: string;
|
||||
/** 원문 내 시작/끝 문자 오프셋 (디버그/추적용). */
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
}
|
||||
|
||||
export interface ChunkOptions {
|
||||
/** 청크 목표 길이. 누적이 이 값을 넘으면 확정. 기본 1200. */
|
||||
targetChars: number;
|
||||
/** 이보다 짧은 섹션은 다음과 병합. 기본 200. */
|
||||
minChars: number;
|
||||
/** 단일 청크가 이보다 길면 문단 경계로 재분할. 기본 = targetChars * 2. */
|
||||
maxChars: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: ChunkOptions = { targetChars: 1200, minChars: 200, maxChars: 2400 };
|
||||
|
||||
interface RawSection {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*$/;
|
||||
|
||||
/**
|
||||
* 원문을 헤딩 경계 raw 섹션으로 분해. fenced code block(```) 안의 `#` 라인은
|
||||
* 헤딩으로 보지 않는다 (코드 주석이 섹션을 깨는 것 방지).
|
||||
*/
|
||||
function parseRawSections(content: string): RawSection[] {
|
||||
const lines = content.split('\n');
|
||||
const sections: RawSection[] = [];
|
||||
const stack: Array<{ level: number; title: string }> = [];
|
||||
|
||||
let offset = 0;
|
||||
let curStart = 0;
|
||||
let curHeading = '';
|
||||
let curPath: string[] = [];
|
||||
let inFence = false;
|
||||
let started = false;
|
||||
|
||||
const pushCurrent = (end: number) => {
|
||||
if (!started) return;
|
||||
sections.push({ heading: curHeading, headingPath: [...curPath], start: curStart, end });
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineStart = offset;
|
||||
offset += line.length + 1; // +1 for the '\n' we split on
|
||||
|
||||
const fence = line.trimStart().startsWith('```');
|
||||
if (fence) { inFence = !inFence; }
|
||||
|
||||
const m = !inFence ? line.match(HEADING_RE) : null;
|
||||
if (m) {
|
||||
// close previous section at this heading's start
|
||||
pushCurrent(lineStart);
|
||||
const level = m[1].length;
|
||||
const title = m[2].trim();
|
||||
// maintain breadcrumb stack by level
|
||||
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
|
||||
stack.push({ level, title });
|
||||
curStart = lineStart;
|
||||
curHeading = title;
|
||||
curPath = stack.map((s) => s.title);
|
||||
started = true;
|
||||
} else if (!started) {
|
||||
// preamble before the first heading
|
||||
started = true;
|
||||
curStart = 0;
|
||||
curHeading = '';
|
||||
curPath = [];
|
||||
}
|
||||
}
|
||||
pushCurrent(content.length);
|
||||
return sections.filter((s) => s.end > s.start);
|
||||
}
|
||||
|
||||
/** 긴 텍스트를 문단(\n\n) 경계로 target 길이 이하 조각으로. 단일 문단이 maxChars 초과면 하드 컷. */
|
||||
function splitLongText(text: string, target: number, maxChars: number): string[] {
|
||||
if (text.length <= maxChars) return [text];
|
||||
const paras = text.split(/\n{2,}/);
|
||||
const pieces: string[] = [];
|
||||
let buf = '';
|
||||
const flush = () => { if (buf.trim()) pieces.push(buf); buf = ''; };
|
||||
for (const para of paras) {
|
||||
if (para.length > maxChars) {
|
||||
flush();
|
||||
// hard slice a giant paragraph
|
||||
for (let i = 0; i < para.length; i += target) pieces.push(para.slice(i, i + target));
|
||||
continue;
|
||||
}
|
||||
if (buf && (buf.length + para.length + 2) > target) flush();
|
||||
buf = buf ? `${buf}\n\n${para}` : para;
|
||||
}
|
||||
flush();
|
||||
return pieces.length ? pieces : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 섹션 청크로 분해. 짧은 섹션 병합 + 긴 섹션 재분할 적용.
|
||||
* 결과가 비면(빈 파일 등) 전체를 한 청크로 반환.
|
||||
*/
|
||||
export function splitIntoSections(content: string, opts?: Partial<ChunkOptions>): Section[] {
|
||||
const o: ChunkOptions = { ...DEFAULTS, ...(opts || {}) };
|
||||
if (o.maxChars < o.targetChars) o.maxChars = o.targetChars * 2;
|
||||
|
||||
const raw = parseRawSections(content);
|
||||
if (raw.length === 0) {
|
||||
const t = content.trim();
|
||||
return t ? [{ heading: '', headingPath: [], text: content, charStart: 0, charEnd: content.length }] : [];
|
||||
}
|
||||
|
||||
// 1) 짧은 섹션 병합 — 연속이므로 [firstStart, lastEnd] 로 span 유지.
|
||||
const merged: RawSection[] = [];
|
||||
let buf: RawSection | null = null;
|
||||
for (const s of raw) {
|
||||
if (!buf) { buf = { ...s }; continue; }
|
||||
const bufLen = buf.end - buf.start;
|
||||
if (bufLen < o.minChars) {
|
||||
buf = { heading: buf.heading, headingPath: buf.headingPath, start: buf.start, end: s.end };
|
||||
} else {
|
||||
merged.push(buf);
|
||||
buf = { ...s };
|
||||
}
|
||||
if ((buf.end - buf.start) >= o.targetChars) { merged.push(buf); buf = null; }
|
||||
}
|
||||
if (buf) merged.push(buf);
|
||||
|
||||
// 2) 긴 섹션 재분할 + Section 객체화.
|
||||
const out: Section[] = [];
|
||||
for (const s of merged) {
|
||||
const text = content.slice(s.start, s.end);
|
||||
if (text.length <= o.maxChars) {
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text, charStart: s.start, charEnd: s.end });
|
||||
continue;
|
||||
}
|
||||
let cursor = s.start;
|
||||
for (const piece of splitLongText(text, o.targetChars, o.maxChars)) {
|
||||
const idx = content.indexOf(piece, cursor);
|
||||
const start = idx >= 0 ? idx : cursor;
|
||||
const end = start + piece.length;
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text: piece, charStart: start, charEnd: end });
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
return out.filter((s) => s.text.trim().length > 0);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -90,6 +90,19 @@ export function selectWithinBudget(
|
||||
return { selected, dropped, tokensUsed };
|
||||
}
|
||||
|
||||
/**
|
||||
* 청크의 '주제(Subject)' 태그를 도출한다 — 서로 다른 프로젝트/주제의 정보가 한
|
||||
* 컨텍스트에 섞일 때 모델이 경계를 인지하도록(무성 교차오염 방지). category 가 있으면
|
||||
* 그걸, 없으면 title/filePath 의 최상위 폴더 세그먼트를 주제로 본다. 파일명만 있으면 ''.
|
||||
*/
|
||||
function deriveSubject(chunk: RetrievalChunk): string {
|
||||
const cat = (chunk.metadata.category || '').trim();
|
||||
if (cat) return cat;
|
||||
const ref = (chunk.title || chunk.metadata.filePath || '').replace(/\\/g, '/');
|
||||
const seg = ref.split('/').filter(Boolean);
|
||||
return seg.length >= 2 ? seg[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 청크들을 하나의 컨텍스트 문자열로 조립합니다.
|
||||
* 소스별로 그룹화하여 가독성을 높입니다.
|
||||
@@ -123,9 +136,11 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
|
||||
const items = groupChunks
|
||||
.map((c) => {
|
||||
const metadata = c.metadata;
|
||||
const subject = deriveSubject(c);
|
||||
const subjectTag = subject ? `[${subject}] ` : '';
|
||||
const conflictTag = metadata.conflictDetected ? ` [⚠️ CONFLICT: ${metadata.conflictSeverity}]` : '';
|
||||
const coverageTag = metadata.queryCoverage !== undefined ? ` (Coverage: ${metadata.queryCoverage.toFixed(2)})` : '';
|
||||
return `- ${c.title}${conflictTag}${coverageTag}: ${c.content}`;
|
||||
return `- ${subjectTag}${c.title}${conflictTag}${coverageTag}: ${c.content}`;
|
||||
})
|
||||
.join('\n');
|
||||
sections.push(`### ${label}\n${items}`);
|
||||
@@ -134,6 +149,7 @@ export function assembleContext(chunks: RetrievalChunk[]): string {
|
||||
return [
|
||||
'[MEMORY CONTEXT]',
|
||||
'Review this layered memory before preparing the answer. Use it only when relevant, and prefer the current user request when there is conflict.',
|
||||
'각 항목 앞의 [주제] 태그와 섹션 출처를 확인하라. **현재 요청과 다른 프로젝트·주제의 항목은 사용하지 마라** — 서로 다른 프로젝트의 규칙·결정·수치·고유명사를 섞지 말 것. 어느 항목이 현재 작업과 관련 있는지 불확실하면 그 항목에 의존하지 마라.',
|
||||
'',
|
||||
sections.join('\n\n')
|
||||
].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,218 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Retrieval Evaluation Harness
|
||||
*
|
||||
* 골든셋(질문 → 기대 문서)으로 brain 검색 품질을 recall@k / MRR 로 *결정적으로* 측정한다.
|
||||
* 청킹·re-rank·embedding alpha 등 어떤 변경이 실제로 recall 을 올렸는지 숫자로 증명하기
|
||||
* 위한 토대 — 이게 있어야 RAG 개선이 "감(感)" 이 아니라 무결성 있는 엔지니어링이 된다.
|
||||
*
|
||||
* 의도적으로 LLM 을 쓰지 않는다 (재현 가능 + 무료 + CI 가능). LLM-as-Judge 기반의
|
||||
* faithfulness/answer-relevance 평가는 후속 단계에서 별도 하니스로 추가한다.
|
||||
*
|
||||
* 골든셋 위치: <brain>/.astra/eval/golden.jsonl (한 줄당 JSON 1개)
|
||||
* { "query": "RAG 청킹 전략 비교", "expected": ["문서 청킹 전략.md"], "note": "선택" }
|
||||
* `expected` 매칭은 대소문자 무시 + 경로 suffix 매칭이라 사용자가 파일명만 적어도 된다
|
||||
* (예: "문서 청킹 전략.md" 가 "10_Wiki/Topics/Topics_Rag/문서 청킹 전략.md" 에 매치).
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
export const GOLDEN_REL_JSONL = path.join('.astra', 'eval', 'golden.jsonl');
|
||||
export const GOLDEN_REL_JSON = path.join('.astra', 'eval', 'golden.json');
|
||||
|
||||
export interface GoldenEntry {
|
||||
query: string;
|
||||
/** 기대 문서 — 상대 경로 또는 파일명. 하나라도 top-k 에 들면 hit. */
|
||||
expected: string[];
|
||||
note?: string;
|
||||
}
|
||||
|
||||
export interface PerQueryResult {
|
||||
query: string;
|
||||
expected: string[];
|
||||
/** 1-based rank of the first expected doc, or null if not in the returned ranking. */
|
||||
firstHitRank: number | null;
|
||||
/** k → 기대 문서가 top-k 안에 하나라도 있었는지. */
|
||||
hitAtK: Record<number, boolean>;
|
||||
/** 디버그용 — 검색이 실제로 반환한 상위 경로들. */
|
||||
topPaths: string[];
|
||||
}
|
||||
|
||||
export interface EvalReport {
|
||||
ks: number[];
|
||||
total: number;
|
||||
/** k → recall@k (= hit-rate, 기대 문서가 top-k 에 든 질의 비율). */
|
||||
recallAtK: Record<number, number>;
|
||||
/** Mean Reciprocal Rank — 첫 hit 의 1/rank 평균. miss 는 0. */
|
||||
mrr: number;
|
||||
perQuery: PerQueryResult[];
|
||||
}
|
||||
|
||||
/** 골든셋 작성 안내가 포함된 스캐폴드 템플릿 (jsonl — 주석 줄은 로더가 무시). */
|
||||
export const GOLDEN_TEMPLATE = [
|
||||
'// Astra 검색 평가 골든셋. 한 줄당 JSON 1개. `//` 로 시작하는 줄과 빈 줄은 무시됩니다.',
|
||||
'// query: 실제로 던질 질문. expected: 그 질문에 떠야 하는 문서(상대경로 또는 파일명) 목록.',
|
||||
'// 20~30개를 채우면 신뢰할 만한 baseline 이 됩니다. 예시 두 줄을 지우고 본인 두뇌에 맞게 작성하세요.',
|
||||
'{"query": "RAG 청킹 전략은 어떤 게 있나", "expected": ["문서 청킹 전략.md"]}',
|
||||
'{"query": "벡터 데이터베이스 어떤 걸 골라야 하나", "expected": ["벡터 데이터베이스 비교.md"]}',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function normRel(p: string): string {
|
||||
return (p || '').replace(/\\/g, '/').trim().toLowerCase();
|
||||
}
|
||||
|
||||
/** ranked 의 한 경로가 expected 항목과 매치되는지: 정확히 같거나, suffix(파일명만 적은 경우)거나. */
|
||||
function pathMatches(rankedRel: string, expected: string): boolean {
|
||||
const a = normRel(rankedRel);
|
||||
const b = normRel(expected);
|
||||
if (!a || !b) return false;
|
||||
if (a === b) return true;
|
||||
// expected 가 파일명/부분 경로면 ranked 의 끝과 매치 (구분자 경계 존중).
|
||||
return a === b || a.endsWith('/' + b) || a.endsWith(b) && (a.length === b.length || a[a.length - b.length - 1] === '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 골든셋 로드. jsonl 우선, 없으면 json 배열. 파일이 없으면 [] 반환 (호출자가 스캐폴드 안내).
|
||||
* 깨진 줄은 건너뛴다 (한 줄 오타가 전체 평가를 막지 않도록).
|
||||
*/
|
||||
export function loadGoldenSet(brainPath: string): { entries: GoldenEntry[]; sourcePath: string | null; parseErrors: number } {
|
||||
const jsonlPath = path.join(brainPath, GOLDEN_REL_JSONL);
|
||||
const jsonPath = path.join(brainPath, GOLDEN_REL_JSON);
|
||||
|
||||
let raw = '';
|
||||
let sourcePath: string | null = null;
|
||||
if (fs.existsSync(jsonlPath)) {
|
||||
try { raw = fs.readFileSync(jsonlPath, 'utf8'); sourcePath = jsonlPath; } catch { /* fall through */ }
|
||||
}
|
||||
if (!sourcePath && fs.existsSync(jsonPath)) {
|
||||
try {
|
||||
const arr = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
|
||||
const entries = Array.isArray(arr) ? arr.filter(isValidEntry) : [];
|
||||
return { entries, sourcePath: jsonPath, parseErrors: 0 };
|
||||
} catch {
|
||||
return { entries: [], sourcePath: jsonPath, parseErrors: 1 };
|
||||
}
|
||||
}
|
||||
if (!sourcePath) return { entries: [], sourcePath: null, parseErrors: 0 };
|
||||
|
||||
const entries: GoldenEntry[] = [];
|
||||
let parseErrors = 0;
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t || t.startsWith('//') || t.startsWith('#')) continue;
|
||||
try {
|
||||
const obj = JSON.parse(t);
|
||||
if (isValidEntry(obj)) entries.push(obj);
|
||||
else parseErrors++;
|
||||
} catch {
|
||||
parseErrors++;
|
||||
}
|
||||
}
|
||||
return { entries, sourcePath, parseErrors };
|
||||
}
|
||||
|
||||
function isValidEntry(o: any): o is GoldenEntry {
|
||||
return o && typeof o.query === 'string' && o.query.trim().length > 0
|
||||
&& Array.isArray(o.expected) && o.expected.length > 0
|
||||
&& o.expected.every((e: any) => typeof e === 'string');
|
||||
}
|
||||
|
||||
/**
|
||||
* 평가 실행. `ranker` 는 한 질의에 대해 검색이 반환한 *상대 경로 랭킹(점수 내림차순)* 을
|
||||
* 돌려주는 함수다 (임베딩 배선은 호출자가 책임 → 이 모듈은 LLM/네트워크 의존 없이 순수).
|
||||
*/
|
||||
export async function runRetrievalEval(params: {
|
||||
entries: GoldenEntry[];
|
||||
ks: number[];
|
||||
ranker: (query: string) => Promise<string[]>;
|
||||
}): Promise<EvalReport> {
|
||||
const ks = [...params.ks].sort((a, b) => a - b);
|
||||
const perQuery: PerQueryResult[] = [];
|
||||
|
||||
for (const entry of params.entries) {
|
||||
let ranked: string[] = [];
|
||||
try {
|
||||
ranked = await params.ranker(entry.query);
|
||||
} catch {
|
||||
ranked = [];
|
||||
}
|
||||
let firstHitRank: number | null = null;
|
||||
for (let i = 0; i < ranked.length; i++) {
|
||||
if (entry.expected.some((exp) => pathMatches(ranked[i], exp))) {
|
||||
firstHitRank = i + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const hitAtK: Record<number, boolean> = {};
|
||||
for (const k of ks) hitAtK[k] = firstHitRank !== null && firstHitRank <= k;
|
||||
|
||||
perQuery.push({
|
||||
query: entry.query,
|
||||
expected: entry.expected,
|
||||
firstHitRank,
|
||||
hitAtK,
|
||||
topPaths: ranked.slice(0, Math.max(...ks, 5)),
|
||||
});
|
||||
}
|
||||
|
||||
const total = perQuery.length || 1;
|
||||
const recallAtK: Record<number, number> = {};
|
||||
for (const k of ks) {
|
||||
const hits = perQuery.filter((q) => q.hitAtK[k]).length;
|
||||
recallAtK[k] = hits / total;
|
||||
}
|
||||
const mrr = perQuery.reduce((sum, q) => sum + (q.firstHitRank ? 1 / q.firstHitRank : 0), 0) / total;
|
||||
|
||||
return { ks, total: perQuery.length, recallAtK, mrr, perQuery };
|
||||
}
|
||||
|
||||
/** 사람이 읽는 마크다운 리포트. baseline 비교를 위해 표 형태로. */
|
||||
export function formatReportMarkdown(report: EvalReport, meta: { brainName: string; dateStr: string; embeddingModel: string; alpha: number; notes?: string }): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`# Astra 검색 평가 리포트`);
|
||||
lines.push('');
|
||||
lines.push(`- 두뇌: **${meta.brainName}**`);
|
||||
lines.push(`- 일시: ${meta.dateStr}`);
|
||||
lines.push(`- 임베딩: ${meta.embeddingModel || '(없음 — TF-IDF only)'}${meta.embeddingModel ? ` · alpha=${meta.alpha}` : ''}`);
|
||||
lines.push(`- 질의 수: ${report.total}`);
|
||||
if (meta.notes) lines.push(`- 메모: ${meta.notes}`);
|
||||
lines.push('');
|
||||
lines.push(`## 종합 지표`);
|
||||
lines.push('');
|
||||
lines.push(`| 지표 | 값 |`);
|
||||
lines.push(`|---|---|`);
|
||||
for (const k of report.ks) lines.push(`| recall@${k} | ${(report.recallAtK[k] * 100).toFixed(1)}% |`);
|
||||
lines.push(`| MRR | ${report.mrr.toFixed(3)} |`);
|
||||
lines.push('');
|
||||
lines.push(`> recall@k = 기대 문서가 상위 k개 안에 든 질의 비율. MRR = 첫 정답의 1/순위 평균 (1에 가까울수록 좋음).`);
|
||||
lines.push('');
|
||||
lines.push(`## 질의별 상세`);
|
||||
lines.push('');
|
||||
lines.push(`| # | 질의 | 첫 정답 순위 | top-k hit | 기대 문서 |`);
|
||||
lines.push(`|---|---|---|---|---|`);
|
||||
report.perQuery.forEach((q, i) => {
|
||||
const rank = q.firstHitRank ? `#${q.firstHitRank}` : '**miss**';
|
||||
const kHits = report.ks.map((k) => q.hitAtK[k] ? `@${k}✓` : `@${k}✗`).join(' ');
|
||||
const exp = q.expected.join(', ').replace(/\|/g, '\\|');
|
||||
const query = q.query.replace(/\|/g, '\\|').slice(0, 60);
|
||||
lines.push(`| ${i + 1} | ${query} | ${rank} | ${kHits} | ${exp} |`);
|
||||
});
|
||||
lines.push('');
|
||||
|
||||
// miss 한 질의는 무엇이 떴는지 별도로 — 골든셋 수정 vs 엔진 개선을 가르는 진단.
|
||||
const misses = report.perQuery.filter((q) => q.firstHitRank === null);
|
||||
if (misses.length > 0) {
|
||||
lines.push(`## Miss 진단 (top 결과가 기대와 어긋난 질의)`);
|
||||
lines.push('');
|
||||
for (const q of misses) {
|
||||
lines.push(`- **${q.query}**`);
|
||||
lines.push(` - 기대: ${q.expected.join(', ')}`);
|
||||
lines.push(` - 실제 상위: ${q.topPaths.length ? q.topPaths.slice(0, 5).join(' · ') : '(검색 결과 없음)'}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
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 };
|
||||
}
|
||||
+236
-3
@@ -21,9 +21,11 @@ import { MemoryManager } from '../memory';
|
||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||
import { tokenize, expandQuery, scoreTfIdfPreTokenized, extractBestExcerpt, extractBestSection } from './scoring';
|
||||
import { selectWithinBudget, assembleContext, estimateTokens } from './contextBudget';
|
||||
import { getBrainTokenIndex, getBrainEmbeddings } from './brainIndex';
|
||||
import { getBrainTokenIndex, getBrainEmbeddings, getBrainChunkIndex, getBrainChunkEmbeddings } 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,28 @@ 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;
|
||||
/**
|
||||
* Section-level chunking (Phase 1-가). true 면 brain 검색이 파일이 아니라 섹션 청크
|
||||
* 단위로 색인·스코어링하고, 매치된 *섹션* 을 그대로 주입한다. false/undefined 면 기존
|
||||
* 파일 단위 동작.
|
||||
*/
|
||||
chunkLevelRetrieval?: boolean;
|
||||
/** 섹션 청크 목표 길이(문자). 기본 1200. chunkLevelRetrieval 일 때만 사용. */
|
||||
chunkTargetChars?: number;
|
||||
}
|
||||
|
||||
export class RetrievalOrchestrator {
|
||||
@@ -113,7 +137,9 @@ export class RetrievalOrchestrator {
|
||||
scopeFolders,
|
||||
options.queryEmbedding,
|
||||
options.embeddingModel,
|
||||
options.embeddingBlendAlpha
|
||||
options.embeddingBlendAlpha,
|
||||
options.chunkLevelRetrieval || false,
|
||||
options.chunkTargetChars ?? 1200,
|
||||
)
|
||||
: [];
|
||||
allChunks.push(...brainChunks);
|
||||
@@ -148,6 +174,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,
|
||||
@@ -178,6 +223,58 @@ export class RetrievalOrchestrator {
|
||||
return assembleContext(result.selectedChunks);
|
||||
}
|
||||
|
||||
/**
|
||||
* 평가 전용 — 한 질의에 대한 brain 파일 랭킹(점수 내림차순)을 *context budget 적용 전*
|
||||
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
||||
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
|
||||
*/
|
||||
public rankBrainForEval(
|
||||
query: string,
|
||||
brain: BrainProfile,
|
||||
opts: {
|
||||
limit?: number;
|
||||
scopeFolders?: string[];
|
||||
includeRawConversations?: boolean;
|
||||
queryEmbedding?: number[];
|
||||
embeddingModel?: string;
|
||||
embeddingBlendAlpha?: number;
|
||||
chunkLevelRetrieval?: boolean;
|
||||
chunkTargetChars?: number;
|
||||
} = {},
|
||||
): Array<{ relativePath: string; filePath: string; score: number }> {
|
||||
const limit = opts.limit ?? 20;
|
||||
const expandedTokens = expandQuery(tokenize(query));
|
||||
// chunk 모드는 파일당 여러 청크를 반환하므로, recall 을 *파일 단위* 로 측정하려면
|
||||
// 넉넉히 받아 dedup 한다 (limit 개의 고유 파일 확보).
|
||||
const internalLimit = opts.chunkLevelRetrieval ? limit * 3 : limit;
|
||||
const chunks = this.searchBrainFiles(
|
||||
query,
|
||||
expandedTokens,
|
||||
brain,
|
||||
internalLimit,
|
||||
opts.includeRawConversations ?? false,
|
||||
opts.scopeFolders ?? [],
|
||||
opts.queryEmbedding,
|
||||
opts.embeddingModel,
|
||||
opts.embeddingBlendAlpha,
|
||||
opts.chunkLevelRetrieval || false,
|
||||
opts.chunkTargetChars ?? 1200,
|
||||
);
|
||||
// dedup by file, 점수 내림차순 순서 유지 → 파일 단위 랭킹.
|
||||
const out: Array<{ relativePath: string; filePath: string; score: number }> = [];
|
||||
const seen = new Set<string>();
|
||||
const brainRoot = brain.localBrainPath;
|
||||
for (const c of chunks) {
|
||||
const filePath = (c.metadata.filePath as string) || '';
|
||||
if (!filePath || seen.has(filePath)) continue;
|
||||
seen.add(filePath);
|
||||
const relativePath = filePath ? (path.relative(brainRoot, filePath) || c.title) : c.title;
|
||||
out.push({ relativePath, filePath, score: c.score });
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ─── Brain File Search ───
|
||||
|
||||
private searchBrainFiles(
|
||||
@@ -190,16 +287,29 @@ export class RetrievalOrchestrator {
|
||||
queryEmbedding?: number[],
|
||||
embeddingModel?: string,
|
||||
embeddingBlendAlpha?: number,
|
||||
chunkLevel: boolean = false,
|
||||
chunkTargetChars: number = 1200,
|
||||
): RetrievalChunk[] {
|
||||
try {
|
||||
const scoped = (file: string) => scopeFolders.length === 0
|
||||
|| scopeFolders.some((folder) => isInside(folder, file));
|
||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
||||
.filter(scoped)
|
||||
.filter((file) => includeRaw || !this.isRawConversation(path.relative(brain.localBrainPath, file)));
|
||||
.filter((file) => {
|
||||
const rel = path.relative(brain.localBrainPath, file);
|
||||
return (includeRaw || !this.isRawConversation(rel)) && !this.isOperationalPath(rel);
|
||||
});
|
||||
|
||||
if (allFiles.length === 0) return [];
|
||||
|
||||
// Phase 1-가: 섹션 청크 단위 검색 경로. 파일 단위와 분리해 회귀 위험 격리.
|
||||
if (chunkLevel) {
|
||||
return this.searchBrainChunks(
|
||||
expandedTokens, brain, allFiles, limit, chunkTargetChars,
|
||||
queryEmbedding, embeddingModel, embeddingBlendAlpha,
|
||||
);
|
||||
}
|
||||
|
||||
// Tokenized docs from the persistent mtime-keyed index — unchanged files are not re-read
|
||||
// or re-tokenized, so per-query work over a large brain drops from O(total content) to O(files) stats.
|
||||
const indexed = getBrainTokenIndex(brain.localBrainPath, allFiles);
|
||||
@@ -308,6 +418,118 @@ export class RetrievalOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Brain Chunk Search (Phase 1-가) ───
|
||||
|
||||
/**
|
||||
* 섹션 청크 단위 검색. 파일 단위 `searchBrainFiles` 와 동일한 TF-IDF scoring 을
|
||||
* *청크* 에 적용하고, 매치된 섹션 본문을 그대로 발췌(파일 모드의 read-time
|
||||
* extractBestSection 불필요). dense blend 는 v1 에서 파일 단위 임베딩을 그 파일의
|
||||
* 모든 청크에 공유 적용한다(청크별 임베딩은 후속 단계). 한 파일이 결과를 독식하지
|
||||
* 않도록 파일당 청크 수를 제한한다.
|
||||
*/
|
||||
private searchBrainChunks(
|
||||
expandedTokens: string[],
|
||||
brain: BrainProfile,
|
||||
allFiles: string[],
|
||||
limit: number,
|
||||
chunkTargetChars: number,
|
||||
queryEmbedding?: number[],
|
||||
embeddingModel?: string,
|
||||
embeddingBlendAlpha?: number,
|
||||
): RetrievalChunk[] {
|
||||
const chunks = getBrainChunkIndex(brain.localBrainPath, allFiles, chunkTargetChars);
|
||||
if (chunks.length === 0) return [];
|
||||
|
||||
const scored = scoreTfIdfPreTokenized(
|
||||
expandedTokens,
|
||||
chunks.map((c) => ({
|
||||
tokens: c.tokens,
|
||||
titleTokens: c.headingTokens,
|
||||
lastModified: c.mtimeMs,
|
||||
conflictCount: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
// Hybrid: 청크 단위 임베딩(`${filePath}#${chunkIndex}`)으로 dense blend. 청크 벡터가
|
||||
// 아직 없는 항목은 파일 단위 임베딩으로 fallback → 둘 다 없으면 순수 TF-IDF 유지.
|
||||
if (queryEmbedding && embeddingModel && (embeddingBlendAlpha ?? 0) > 0) {
|
||||
const alpha = Math.max(0, Math.min(1, embeddingBlendAlpha!));
|
||||
const chunkEmb = getBrainChunkEmbeddings(brain.localBrainPath, embeddingModel);
|
||||
const filePaths = Array.from(new Set(chunks.map((c) => c.filePath)));
|
||||
const fileEmb = getBrainEmbeddings(brain.localBrainPath, filePaths, embeddingModel);
|
||||
if (chunkEmb.size > 0 || fileEmb.size > 0) {
|
||||
const maxTfidf = scored.reduce((m, s) => (s.score > m ? s.score : m), 0) || 1;
|
||||
for (const s of scored) {
|
||||
const c = chunks[s.index];
|
||||
const vec = chunkEmb.get(`${c.filePath}#${c.chunkIndex}`) || fileEmb.get(c.filePath);
|
||||
if (!vec) continue;
|
||||
const cos = cosineSimilarity(queryEmbedding, vec);
|
||||
s.score = (1 - alpha) * (s.score / maxTfidf) + alpha * Math.max(0, cos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ranked = scored.filter((x) => x.score > 0).sort((a, b) => b.score - a.score);
|
||||
|
||||
// 파일당 청크 상한 — 한 문서가 top 슬롯을 독식하지 않게.
|
||||
const PER_FILE_CAP = 3;
|
||||
const perFile = new Map<string, number>();
|
||||
const chosen: typeof ranked = [];
|
||||
for (const s of ranked) {
|
||||
const fp = chunks[s.index].filePath;
|
||||
const n = perFile.get(fp) || 0;
|
||||
if (n >= PER_FILE_CAP) continue;
|
||||
perFile.set(fp, n + 1);
|
||||
chosen.push(s);
|
||||
if (chosen.length >= limit) break;
|
||||
}
|
||||
|
||||
const fileContentCache = new Map<string, string>();
|
||||
const readFile = (fp: string): string => {
|
||||
let c = fileContentCache.get(fp);
|
||||
if (c === undefined) {
|
||||
try { c = fs.readFileSync(fp, 'utf8'); } catch { c = ''; }
|
||||
fileContentCache.set(fp, c);
|
||||
}
|
||||
return c;
|
||||
};
|
||||
|
||||
const topResults: RetrievalChunk[] = [];
|
||||
for (const s of chosen) {
|
||||
const c = chunks[s.index];
|
||||
const content = readFile(c.filePath);
|
||||
if (!content) continue;
|
||||
const isLesson = (c.kind || '') !== '';
|
||||
// 일반 노트: 매치된 섹션 본문 그대로. lesson 카드: 통째 청크라 essence 추출 유지.
|
||||
let body = isLesson
|
||||
? (extractLessonEssence(content, 1200) || content.slice(c.charStart, c.charEnd))
|
||||
: content.slice(c.charStart, c.charEnd);
|
||||
const cap = isLesson ? 1200 : 700;
|
||||
// 섹션 breadcrumb 을 본문 맨 앞에 — 모델이 어느 맥락의 섹션인지 알도록.
|
||||
const crumb = !isLesson && c.headingPath.length ? `〔${c.headingPath.join(' › ')}〕\n` : '';
|
||||
body = crumb + body.trim();
|
||||
topResults.push({
|
||||
id: `brain-chunk-${s.index}`,
|
||||
source: 'brain-memory' as const,
|
||||
title: c.relativePath,
|
||||
content: summarizeText(body, cap + crumb.length),
|
||||
score: s.score,
|
||||
tokenEstimate: estimateTokens(body),
|
||||
metadata: {
|
||||
filePath: c.filePath,
|
||||
category: this.inferCategory(c.relativePath),
|
||||
isProjectEvidence: this.isProjectEvidence(c.relativePath, content),
|
||||
lastUpdated: c.mtimeMs,
|
||||
conflictDetected: s.conflictDetected,
|
||||
conflictSeverity: s.conflictSeverity,
|
||||
queryCoverage: s.queryCoverage,
|
||||
...(isLesson ? { isLesson: true, lessonKind: c.kind } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
return topResults;
|
||||
}
|
||||
|
||||
// ─── Memory Layer Search ───
|
||||
|
||||
private searchMemoryLayers(
|
||||
@@ -496,6 +718,17 @@ export class RetrievalOrchestrator {
|
||||
return /(^|[\\/])(00_Raw|raw-data|conversations?|transcripts?)([\\/]|$)/i.test(relativePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 운영(operational) 로그 — 지식이 아니라 세션/메모리/프로젝트 로그. 사용자 wiki taxonomy
|
||||
* 에 정의된 폴더 fragment 들. 지식 검색에서 제외한다 (= raw 대화와 동일 취급). recall 지표를
|
||||
* 올리진 않지만, 로그를 "지식"으로 끌어오는 의미적 오류와 인덱스/토큰 낭비를 막는다.
|
||||
*/
|
||||
private isOperationalPath(relativePath: string): boolean {
|
||||
return /(^|[\\/])(sessions|_agents|_company|memory|Project_Logs|_Archive_Orphans|Post_Drafts|UX_Scenarios)([\\/])/i.test(relativePath)
|
||||
|| /docs[\\/]records([\\/]|$)/i.test(relativePath)
|
||||
|| /Harness_Research_/i.test(relativePath);
|
||||
}
|
||||
|
||||
private inferCategory(relativePath: string): string {
|
||||
const normalized = relativePath.toLowerCase();
|
||||
if (/(decisions?|adr|planning)/i.test(normalized)) return 'decision';
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
+80
-41
@@ -1196,8 +1196,29 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
*/
|
||||
private async _commitBrainProfileChange(nextProfiles: any[], nextActiveId: string, systemMessage: string): Promise<void> {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
||||
await cfg.update('activeBrainId', nextActiveId, vscode.ConfigurationTarget.Global);
|
||||
try {
|
||||
await cfg.update('brainProfiles', nextProfiles, vscode.ConfigurationTarget.Global);
|
||||
await cfg.update('activeBrainId', nextActiveId, vscode.ConfigurationTarget.Global);
|
||||
} catch (err: any) {
|
||||
logError('Failed to persist brain profiles.', { error: err?.message || String(err) });
|
||||
vscode.window.showErrorMessage(`두뇌 프로필 저장 실패 (settings.json 쓰기 오류): ${err?.message ?? err}`);
|
||||
throw err;
|
||||
}
|
||||
// Read-back 검증 — cfg.update 가 성공처럼 반환해도 effective config 에 반영 안 될 수 있다:
|
||||
// (a) Workspace/Folder scope 의 g1nation.brainProfiles 가 Global 값을 가림,
|
||||
// (b) settings.json 쓰기 권한/프로필 문제.
|
||||
// 둘 다 화면상 "추가가 안 됨" 으로만 보였던 silent failure → 이제 명시적으로 알린다.
|
||||
const written = vscode.workspace.getConfiguration('g1nation').get<any[]>('brainProfiles', []) || [];
|
||||
const landed = written.some((p) => p && p.id === nextActiveId);
|
||||
if (!landed) {
|
||||
const inspected = vscode.workspace.getConfiguration('g1nation').inspect<any[]>('brainProfiles');
|
||||
const hasWorkspace = !!(inspected?.workspaceValue || inspected?.workspaceFolderValue);
|
||||
const reason = hasWorkspace
|
||||
? 'Workspace 설정(.vscode/settings.json)의 g1nation.brainProfiles 가 전역 값을 가리고 있습니다. 그 항목을 지우거나 그곳에 추가하세요.'
|
||||
: 'settings.json 쓰기가 반영되지 않았습니다 (파일 권한 또는 VS Code 프로필 설정을 확인하세요).';
|
||||
logError('Brain profile write did not land in effective config.', { hasWorkspace });
|
||||
vscode.window.showErrorMessage(`두뇌 추가 실패: ${reason}`);
|
||||
}
|
||||
this._currentSessionBrainId = nextActiveId;
|
||||
this._postBrainProfiles(nextProfiles, nextActiveId);
|
||||
await this._sendBrainStatus();
|
||||
@@ -1205,48 +1226,46 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
}
|
||||
|
||||
async _addBrainProfile() {
|
||||
const selected = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Use as Brain'
|
||||
});
|
||||
try {
|
||||
const selected = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: '이 폴더를 두뇌로 사용'
|
||||
});
|
||||
|
||||
const folder = selected?.[0]?.fsPath;
|
||||
if (!folder) return;
|
||||
const folder = selected?.[0]?.fsPath;
|
||||
if (!folder) return; // 폴더 선택 취소 — 정상 종료 (에러 아님)
|
||||
|
||||
const defaultName = path.basename(folder) || 'New Brain';
|
||||
const name = await vscode.window.showInputBox({
|
||||
prompt: 'Name this brain profile',
|
||||
value: defaultName,
|
||||
validateInput: (value) => value.trim() ? null : 'Brain name is required.'
|
||||
});
|
||||
if (!name) return;
|
||||
// 구조 개선: 예전엔 폴더 선택 후 이름·설명·repo 입력창 3개가 연속으로 떴고, '이름' 입력창을
|
||||
// Esc/바깥클릭으로 닫으면 `if (!name) return` 으로 전체 추가가 *조용히* 취소됐다. 이것이
|
||||
// "추가가 안 된다" 의 주원인. 이제 폴더만 있으면 추가가 보장되고, 이름은 비우거나 취소해도
|
||||
// 폴더명으로 진행한다. 설명/repo 는 추가 후 [수정] 에서 채운다 (다이얼로그 체인 최소화).
|
||||
const defaultName = path.basename(folder) || 'New Brain';
|
||||
const nameInput = await vscode.window.showInputBox({
|
||||
prompt: '두뇌 이름 (비워두면 폴더명 사용)',
|
||||
value: defaultName
|
||||
});
|
||||
const name = (nameInput && nameInput.trim()) ? nameInput.trim() : defaultName;
|
||||
|
||||
const description = await vscode.window.showInputBox({
|
||||
prompt: 'Optional description shown in the Astra sidebar',
|
||||
value: ''
|
||||
});
|
||||
|
||||
const repo = await vscode.window.showInputBox({
|
||||
prompt: 'Optional Second Brain Git repository URL',
|
||||
value: ''
|
||||
});
|
||||
|
||||
// Read raw settings directly to avoid virtual default-brain (injected in memory by getConfig())
|
||||
// being saved into the settings file and corrupting the profile list on next load.
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
|
||||
const id = generateUniqueBrainId(name, existingRaw);
|
||||
const newProfile = {
|
||||
id,
|
||||
name: name.trim(),
|
||||
localBrainPath: folder,
|
||||
secondBrainRepo: (repo || '').trim(),
|
||||
description: (description || '').trim()
|
||||
};
|
||||
const nextProfiles = [...existingRaw, newProfile];
|
||||
await this._commitBrainProfileChange(nextProfiles, id, `**[Brain Added]** ${name.trim()}\n\`${folder}\``);
|
||||
// getConfig() 가 메모리에 주입하는 가상 default-brain 이 저장되지 않도록 raw 설정을 직접 읽는다.
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
const existingRaw: any[] = cfg.get<any[]>('brainProfiles', []) || [];
|
||||
const id = generateUniqueBrainId(name, existingRaw);
|
||||
const newProfile = {
|
||||
id,
|
||||
name,
|
||||
localBrainPath: folder,
|
||||
secondBrainRepo: '',
|
||||
description: ''
|
||||
};
|
||||
const nextProfiles = [...existingRaw, newProfile];
|
||||
await this._commitBrainProfileChange(nextProfiles, id, `**[Brain Added]** ${name}\n\`${folder}\``);
|
||||
vscode.window.showInformationMessage(`두뇌 추가됨: ${name}`);
|
||||
} catch (err: any) {
|
||||
logError('Failed to add brain profile.', { error: err?.message || String(err) });
|
||||
vscode.window.showErrorMessage(`두뇌 추가 중 오류: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async _editBrainProfile(profileId?: string) {
|
||||
@@ -1890,6 +1909,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 +1936,7 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
previousContract: opts.previousContract,
|
||||
activePipelineName: activePipeline?.name,
|
||||
availableRoleCategories: extractActiveRoleCategories(state),
|
||||
priorChatSummary,
|
||||
},
|
||||
// 분류기와 같은 작은 모델을 재사용 — 이 단계도 빠르고 가벼워야 함.
|
||||
{ model: cfg.companyIntentClassifierModel || cfg.defaultModel },
|
||||
|
||||
@@ -234,6 +234,7 @@ Then reply with one short line stating what was started and where.
|
||||
2. [NO MARKDOWN MARKERS] PLAIN TEXT ONLY. Do NOT emit "#", "##", "###", "**", "__", "> ", "* " as formatting. Section labels are bare Korean words on their own line (e.g. a line that says just "핵심 요약" — no "#", no "**"). Bullets use "- " only. Inline code with backticks (e.g. \`src/agent.ts\`) and triple-backtick code blocks for actual code are fine.
|
||||
3. [NO INTERNAL LOGS] Never output <details>, "2nd Brain Trace", or "Debug JSON" blocks.
|
||||
4. [NO SECTION LEAKAGE] Never output sections named "요청 요약", "사용자 의도 추론", "프로젝트 기록 대상 확인", "핵심 확인 질문", or "근거 파일 경로".
|
||||
5. [확인 불가 — 사실 날조 금지] 지식 베이스·제공된 컨텍스트·이번 세션에 읽은 파일에 근거가 없는 사실(수치, 날짜, 금액, 고유명사, 파일/함수/포트명, 결정 사항, "이미 ~다/~로 정해졌다" 류 단정)은 지어내지 마라. 근거가 없으면 추측으로 메우지 말고 "확인 불가" 또는 "근거 없음 — 확인 필요"라고 명시하라. 불확실하면 단정 톤을 낮춰라("~로 보인다", "확인 필요"). 단, 이 규칙은 *사실 주장*에만 적용된다 — R7 의 '합리적 가정 후 진행'은 *작업 수행*의 기본값 선택에는 그대로 유효하다(가정은 "가정:" 한 줄로 밝힌다).
|
||||
|
||||
[OUTPUT FORMAT — 7 hard rules]
|
||||
These rules override any other formatting habit. Apply them to EVERY answer.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user