Compare commits
14 Commits
ebfce17b03
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| cbc2558550 | |||
| ef3628c6eb | |||
| a52bf6ee85 | |||
| 6d06311d60 | |||
| b4ddd4f79a | |||
| b733864375 | |||
| d39eb27c90 | |||
| b94e6ad1da | |||
| 116dafd3cf | |||
| 681cfd2393 | |||
| eb4bef0744 | |||
| 7e96e56381 | |||
| 6b017b0d31 | |||
| 2ea5185cd6 |
@@ -3,15 +3,15 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `connectai` `v2.2.200` _(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**: 431 source files, ~70,417 lines across 5 top-level modules.
|
||||
- **Stats**: 441 source files, ~71,522 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-06-01T02:30:44.120Z
|
||||
- **Time**: 2026-06-09T02:20:50.409Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 428
|
||||
- **Files reused from cache**: 438
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
@@ -40,11 +40,11 @@ mindmap
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>274 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/>108 files"]
|
||||
docs["docs/<br/>112 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -56,7 +56,7 @@ 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/core/services.ts` — referenced by **15** files
|
||||
@@ -67,58 +67,58 @@ flowchart LR
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 274 files, ~52,627 lines
|
||||
### `src/` — 280 files, ~53,526 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (100) — 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/agent/` (29) — Post-answer hook registry — 답변 완료 후 실행되는 부가 작업 모음. 새 hook 추가 = 1 객체 push. agent.ts 는 이 배열을 iterate 만 함. 현재 등록 순서 (v2.2.1
|
||||
- `src/lib/` (29) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/retrieval/` (16) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/retrieval/` (18) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/extension/` (9) — 9 files (.ts)
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
- `src/extension/` (8) — 8 files (.ts)
|
||||
- `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/config.ts` (557 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/core/services.ts` (176 lines)
|
||||
- `src/sidebarProvider.ts` (3186 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/features/datacollect/slashRouter.ts` (1240 lines)
|
||||
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
||||
- `src/lib/paths.ts` (151 lines)
|
||||
- `src/agent/actions/types.ts` (41 lines)
|
||||
- `src/skills/agentKnowledgeMap.ts` (374 lines)
|
||||
- `src/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/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)
|
||||
|
||||
### `media/` — 6 files, ~7,671 lines
|
||||
### `media/` — 6 files, ~7,785 lines
|
||||
|
||||
**Key files**
|
||||
- `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/`
|
||||
@@ -165,17 +165,17 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 108 files, ~3,835 lines
|
||||
### `docs/` — 112 files, ~3,927 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (95) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `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` (236 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
|
||||
@@ -202,8 +202,10 @@ flowchart LR
|
||||
## 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
|
||||
@@ -232,9 +234,12 @@ flowchart LR
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||
- **Configuration** (122 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`)_
|
||||
@@ -290,10 +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.conflictHighlightingEnabled` *(boolean)* _(default: `true`)_ — Conflict Surface — 검색된 출처에서 충돌/논란 신호 감지 시 [CONFLICT WARNINGS] 블록을 시스템 프롬프트에 주입. LLM 이 상충되는 관점을 명시하고 사용자 판단에 위임하도록. 기본 켜짐.
|
||||
- `g1nation.conflictSeverityThreshold` *(string)* _(default: `"medium"`)_ — Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만.
|
||||
- _…and 62 more_
|
||||
- _…and 69 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -341,7 +343,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-06-01T02:30:44.120Z · signature `a95021db`_
|
||||
_Last auto-scan: 2026-06-09T02:20:50.409Z · signature `18ddda74`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-06-01T02:30:44.174Z",
|
||||
"generatedAt": "2026-06-09T02:20:50.597Z",
|
||||
"files": {
|
||||
"src/agent/actions/brainOps.ts": {
|
||||
"mtimeMs": 1779764602582.9768,
|
||||
@@ -462,9 +462,9 @@
|
||||
]
|
||||
},
|
||||
"src/config.ts": {
|
||||
"mtimeMs": 1780039183821.263,
|
||||
"size": 31114,
|
||||
"lines": 557,
|
||||
"mtimeMs": 1780910530256.6797,
|
||||
"size": 32937,
|
||||
"lines": 585,
|
||||
"role": "",
|
||||
"imports": []
|
||||
},
|
||||
@@ -647,6 +647,20 @@
|
||||
"src/features/calendar"
|
||||
]
|
||||
},
|
||||
"src/extension/evalCommands.ts": {
|
||||
"mtimeMs": 1780913394558.156,
|
||||
"size": 12681,
|
||||
"lines": 228,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/config",
|
||||
"src/utils",
|
||||
"src/retrieval",
|
||||
"src/retrieval/brainIndex",
|
||||
"src/retrieval/embeddings",
|
||||
"src/retrieval/evalHarness"
|
||||
]
|
||||
},
|
||||
"src/extension/initialSetup.ts": {
|
||||
"mtimeMs": 1779764602604.987,
|
||||
"size": 2982,
|
||||
@@ -702,9 +716,9 @@
|
||||
]
|
||||
},
|
||||
"src/extension/settingsSetup.ts": {
|
||||
"mtimeMs": 1779764602608.0176,
|
||||
"size": 3127,
|
||||
"lines": 64,
|
||||
"mtimeMs": 1780968650740.2703,
|
||||
"size": 3361,
|
||||
"lines": 67,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/features/settings/settingsPanelProvider",
|
||||
@@ -725,13 +739,14 @@
|
||||
]
|
||||
},
|
||||
"src/extension.ts": {
|
||||
"mtimeMs": 1780280902153.7483,
|
||||
"size": 17342,
|
||||
"lines": 358,
|
||||
"mtimeMs": 1780971569736.942,
|
||||
"size": 18383,
|
||||
"lines": 372,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/features/teamops/handlers",
|
||||
"src/features/system/handlers",
|
||||
"src/features/datacollect/handlers",
|
||||
"src/utils",
|
||||
"src/config",
|
||||
"src/agent",
|
||||
@@ -757,6 +772,7 @@
|
||||
"src/extension/providerCommands",
|
||||
"src/extension/scaffoldCommand",
|
||||
"src/extension/lessonCommands",
|
||||
"src/extension/evalCommands",
|
||||
"src/extension/telegramCommands",
|
||||
"src/extension/settingsSetup",
|
||||
"src/integrations/telegram/telegramSetup",
|
||||
@@ -902,7 +918,7 @@
|
||||
]
|
||||
},
|
||||
"src/features/calendar/oauth.ts": {
|
||||
"mtimeMs": 1779869692244.0415,
|
||||
"mtimeMs": 1780654426655.6082,
|
||||
"size": 10986,
|
||||
"lines": 236,
|
||||
"role": "Google OAuth 2.0 — loopback (Desktop app) 흐름. Google 은 Desktop 앱 OAuth client 에 대해 http://127.0.0.1:<ephemeralport> redirect URI 를 허용한다. 본 모듈은: 1. ephemeral port 에 일회용 HTTP 서버 띄움 2. 사용자 브라우저로 Google 로",
|
||||
@@ -964,9 +980,9 @@
|
||||
]
|
||||
},
|
||||
"src/features/company/dispatcher.ts": {
|
||||
"mtimeMs": 1779764602614.1453,
|
||||
"size": 70872,
|
||||
"lines": 1366,
|
||||
"mtimeMs": 1780367676001.7544,
|
||||
"size": 70731,
|
||||
"lines": 1364,
|
||||
"role": "Sequential dispatcher for 1인 기업 모드. Drives one company \"turn\": user prompt → CEO planner (JSON {brief, tasks}) → for each task in plan: dispatch one specialist (sequentially) - build specialist prompt",
|
||||
"imports": [
|
||||
"src/core/services",
|
||||
@@ -1021,9 +1037,9 @@
|
||||
]
|
||||
},
|
||||
"src/features/company/intentAlignment.ts": {
|
||||
"mtimeMs": 1779764602615.1484,
|
||||
"size": 16048,
|
||||
"lines": 343,
|
||||
"mtimeMs": 1780366738786.6855,
|
||||
"size": 18078,
|
||||
"lines": 367,
|
||||
"role": "Intent Alignment — 사용자의 자연어 요청을 실행 가능한 작업 조건으로 변환. 사용자는 자기 의도와 배경지식이 에이전트에게 충분히 전달되었다고 착각하는 경향이 있다 (투명성의 착각·지식의 저주·공통 기반 부족). 그래서 에이전트가 즉시 작업에 돌입하면 사용자가 머릿속에 가진 것과 다른 결과를 만들어 낸다. 이 모듈은 그 격차를 메꾸는 한 단계 ",
|
||||
"imports": [
|
||||
"src/core/services",
|
||||
@@ -1042,9 +1058,9 @@
|
||||
]
|
||||
},
|
||||
"src/features/company/pipelineTemplates.ts": {
|
||||
"mtimeMs": 1779065453876.108,
|
||||
"size": 15403,
|
||||
"lines": 278,
|
||||
"mtimeMs": 1780367702872.9678,
|
||||
"size": 16572,
|
||||
"lines": 285,
|
||||
"role": "Built-in pipeline templates for 1인 기업 모드. These are blueprints, not data — they're surfaced in the manage panel's \"템플릿에서 추가\" dropdown so a non-developer user can stamp out a working pipeline in one cl",
|
||||
"imports": [
|
||||
"src/features/company/types"
|
||||
@@ -1149,16 +1165,42 @@
|
||||
]
|
||||
},
|
||||
"src/features/datacollect/bridgeClient.ts": {
|
||||
"mtimeMs": 1779764602617.1548,
|
||||
"size": 7210,
|
||||
"lines": 164,
|
||||
"mtimeMs": 1780624716182.2822,
|
||||
"size": 8197,
|
||||
"lines": 177,
|
||||
"role": "",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/datacollect/handlers.ts": {
|
||||
"mtimeMs": 1780624678671.3047,
|
||||
"size": 35540,
|
||||
"lines": 666,
|
||||
"role": "Datacollect handlers — /benchmark · /youtube · /blog · /wikify · /meet. (/research(NotebookLM)는 v2.2.205 에서 제거 — 로컬 Datacollect 앱 전용으로 분리) v2.2.201 에서 slashRouter.ts 에서 분리. Datacollect bridge (port 30",
|
||||
"imports": [
|
||||
"src/features/datacollect/slashRouter",
|
||||
"src/features/datacollect/llm",
|
||||
"src/features/datacollect/bridgeClient",
|
||||
"src/features/datacollect/prompts/synthesisPrompt",
|
||||
"src/features/datacollect/prompts/youtubePrompts",
|
||||
"src/features/datacollect/prompts/wikifyPrompt",
|
||||
"src/features/datacollect/prompts/meetPrompt",
|
||||
"src/features/calendar",
|
||||
"src/features/datacollect/scheduling/calendarHelpers"
|
||||
]
|
||||
},
|
||||
"src/features/datacollect/llm.ts": {
|
||||
"mtimeMs": 1780281843749.1597,
|
||||
"size": 6940,
|
||||
"lines": 122,
|
||||
"role": "Datacollect LLM 호출 인프라 — bridge /api/lm 프록시 통해 OpenAI 호환 chat completion 단발 호출. v2.2.201 에서 slashRouter.ts 에서 분리. 옛 위치는 slashRouter.callLmSynthesis 였으나 datacollect handlers + teamops/communication 양쪽이",
|
||||
"imports": [
|
||||
"src/features/datacollect/bridgeClient"
|
||||
]
|
||||
},
|
||||
"src/features/datacollect/prompts/meetPrompt.ts": {
|
||||
"mtimeMs": 1779764602618.1577,
|
||||
"size": 2593,
|
||||
"lines": 72,
|
||||
"mtimeMs": 1780626429387.7917,
|
||||
"size": 7379,
|
||||
"lines": 95,
|
||||
"role": "회의 녹취 텍스트 → 사실 기반 구조화 회의록(Actionable Minutes) LLM 프롬프트. 사용자 정의 규칙: Fact/Discussion/Decision/Risk/Action 분류, 메타데이터 우선.",
|
||||
"imports": []
|
||||
},
|
||||
@@ -1169,6 +1211,13 @@
|
||||
"role": "/benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세.",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/datacollect/prompts/weeklyPrompt.ts": {
|
||||
"mtimeMs": 1780554147645.3784,
|
||||
"size": 4702,
|
||||
"lines": 96,
|
||||
"role": "/weekly 주간 보고서 LLM 프롬프트. 입력: 캘린더(Google Tasks)에서 버킷팅된 금주/차주 task 목록 + 각 task 의 제목·마감·상태·메모(= /meet·/task 가 넣은 \"작업 상세 / 맥락\"). 금주/차주 분류는 호출부(coordination.ts)에서 due/completed 날짜로 이미 끝낸 상태로 들어온다. 이 프롬프트는 ",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/datacollect/prompts/wikifyPrompt.ts": {
|
||||
"mtimeMs": 1779764602619.1604,
|
||||
"size": 4597,
|
||||
@@ -1184,33 +1233,23 @@
|
||||
"imports": []
|
||||
},
|
||||
"src/features/datacollect/scheduling/calendarHelpers.ts": {
|
||||
"mtimeMs": 1779764602620.1636,
|
||||
"size": 4222,
|
||||
"lines": 89,
|
||||
"mtimeMs": 1780538342100.897,
|
||||
"size": 4615,
|
||||
"lines": 97,
|
||||
"role": "/meet 슬래시 명령의 후처리 — 회의록에서 action items 를 뽑아 캘린더 task 일정을 계산하는 stateless helpers. slashRouter 의 inline 블록을 분리. - addBusinessDays(base, n) — 토·일 제외 영업일 n 일 후 날짜 - toYmd(d) — Date → 'YYYY-MM-DD' - extrac",
|
||||
"imports": []
|
||||
},
|
||||
"src/features/datacollect/slashRouter.ts": {
|
||||
"mtimeMs": 1780280990100.2815,
|
||||
"size": 67318,
|
||||
"lines": 1240,
|
||||
"mtimeMs": 1780281950731.9368,
|
||||
"size": 10210,
|
||||
"lines": 201,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
"src/features/datacollect/bridgeClient",
|
||||
"src/features/calendar",
|
||||
"src/sidebar/managers/chronicleProjectStore",
|
||||
"src/memory",
|
||||
"src/config",
|
||||
"src/retrieval/terminologyBlock",
|
||||
"src/agent/termValidator",
|
||||
"src/features/teamops/handlers/_shared",
|
||||
"src/features/datacollect/llm",
|
||||
"src/features/setup/datacollectSetup",
|
||||
"src/features/datacollect/prompts/synthesisPrompt",
|
||||
"src/features/datacollect/prompts/youtubePrompts",
|
||||
"src/features/datacollect/prompts/wikifyPrompt",
|
||||
"src/features/datacollect/prompts/meetPrompt",
|
||||
"src/features/datacollect/scheduling/calendarHelpers"
|
||||
"src/features/stocks"
|
||||
]
|
||||
},
|
||||
"src/features/devilAgent/devilPrompt.ts": {
|
||||
@@ -1460,9 +1499,9 @@
|
||||
]
|
||||
},
|
||||
"src/features/settings/settingsPanelProvider.ts": {
|
||||
"mtimeMs": 1779764602623.2012,
|
||||
"size": 33801,
|
||||
"lines": 707,
|
||||
"mtimeMs": 1780968616137.0164,
|
||||
"size": 35518,
|
||||
"lines": 734,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/integrations/telegram/telegramClient",
|
||||
@@ -1475,8 +1514,8 @@
|
||||
]
|
||||
},
|
||||
"src/features/setup/datacollectSetup.ts": {
|
||||
"mtimeMs": 1779764602624.204,
|
||||
"size": 12689,
|
||||
"mtimeMs": 1780624672053.5078,
|
||||
"size": 12679,
|
||||
"lines": 267,
|
||||
"role": "Datacollect 의존성(Python 패키지) 자동 설치/검증 모듈. 의도: Astra extension 만 깔고 끝나면 /youtube, /research 같은 datacollect 슬래시 명령은 bridge 의 Python 의존성 (yt-dlp, youtube-transcript-api) 이 없어서 실패한다. 사용자가 그걸 매번 수동으로 깔아야 하는",
|
||||
"imports": [
|
||||
@@ -1664,8 +1703,8 @@
|
||||
]
|
||||
},
|
||||
"src/features/system/handlers.ts": {
|
||||
"mtimeMs": 1780280889224.8892,
|
||||
"size": 20518,
|
||||
"mtimeMs": 1780624661739.6692,
|
||||
"size": 20556,
|
||||
"lines": 394,
|
||||
"role": "System handlers — /memory · /glossary · /help (인프라·관리·발견). v2.2.200 에서 slashRouter.ts 에서 분리. 4인 팀 운영 도메인 아닌 \"ASTRA 자체 인프라\" 슬래시 명령. teamops handlers 와 같은 패턴.",
|
||||
"imports": [
|
||||
@@ -1693,32 +1732,35 @@
|
||||
"imports": []
|
||||
},
|
||||
"src/features/teamops/handlers/communication.ts": {
|
||||
"mtimeMs": 1780280515102.4714,
|
||||
"size": 13781,
|
||||
"lines": 254,
|
||||
"mtimeMs": 1780281864898.1965,
|
||||
"size": 13821,
|
||||
"lines": 255,
|
||||
"role": "TeamOps Communication — /draft · /feedback (외부 출력·기록). v2.2.199 에서 slashRouter.ts 에서 분리. (원래는 v2.2.200 예정이었으나 coordination 추출 시 register 라인이 인접해 묶여 함께 진행.)",
|
||||
"imports": [
|
||||
"src/features/datacollect/slashRouter",
|
||||
"src/features/datacollect/llm",
|
||||
"src/features/feedback/feedbackStore"
|
||||
]
|
||||
},
|
||||
"src/features/teamops/handlers/coordination.ts": {
|
||||
"mtimeMs": 1780280322364.4475,
|
||||
"size": 30738,
|
||||
"lines": 572,
|
||||
"mtimeMs": 1780554184648.2307,
|
||||
"size": 35565,
|
||||
"lines": 665,
|
||||
"role": "TeamOps Coordination — /task · /decisions · /onesie · /blocked · /standup. v2.2.199 에서 slashRouter.ts 에서 분리. 작업·결정·1:1·블로커·스탠드업 등 \"팀 운영의 실시간 부분\" 클러스터. 공통 헬퍼는 ./shared.ts 에서. 옛 slashRouter 의 local pars",
|
||||
"imports": [
|
||||
"src/features/datacollect/slashRouter",
|
||||
"src/features/teamops/handlers/_shared",
|
||||
"src/features/calendar",
|
||||
"src/sidebar/managers/chronicleProjectStore"
|
||||
"src/sidebar/managers/chronicleProjectStore",
|
||||
"src/features/datacollect/llm",
|
||||
"src/features/datacollect/prompts/weeklyPrompt"
|
||||
]
|
||||
},
|
||||
"src/features/teamops/handlers/dashboards.ts": {
|
||||
"mtimeMs": 1780279486170.9111,
|
||||
"size": 42079,
|
||||
"lines": 806,
|
||||
"role": "TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬). v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공. 공통 헬퍼는",
|
||||
"mtimeMs": 1780554715659.2327,
|
||||
"size": 26379,
|
||||
"lines": 493,
|
||||
"role": "TeamOps Dashboards — /morning · /evening · /cohort (CEO 일·월 리듬). v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers + hire + runway + Chronicle ADR) 으로 일/월 단위 시야 제공. (구 /weekly CEO 리뷰 ",
|
||||
"imports": [
|
||||
"src/features/datacollect/slashRouter",
|
||||
"src/features/teamops/handlers/_shared",
|
||||
@@ -1918,9 +1960,9 @@
|
||||
]
|
||||
},
|
||||
"src/lib/contextBuilders/memoryContext.ts": {
|
||||
"mtimeMs": 1780276328602.9944,
|
||||
"size": 16862,
|
||||
"lines": 328,
|
||||
"mtimeMs": 1780911285246.217,
|
||||
"size": 17357,
|
||||
"lines": 331,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/agent",
|
||||
@@ -2063,9 +2105,9 @@
|
||||
]
|
||||
},
|
||||
"src/lib/discoverModels.ts": {
|
||||
"mtimeMs": 1778549008905.0132,
|
||||
"size": 1590,
|
||||
"lines": 35,
|
||||
"mtimeMs": 1780968581182.5598,
|
||||
"size": 3083,
|
||||
"lines": 62,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils"
|
||||
@@ -2126,9 +2168,9 @@
|
||||
]
|
||||
},
|
||||
"src/lmstudio/lifecycleManager.ts": {
|
||||
"mtimeMs": 1779764602651.63,
|
||||
"size": 13048,
|
||||
"lines": 315,
|
||||
"mtimeMs": 1780971557004.6528,
|
||||
"size": 13855,
|
||||
"lines": 327,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/lmstudio/client",
|
||||
@@ -2185,18 +2227,18 @@
|
||||
]
|
||||
},
|
||||
"src/memory/LongTermMemory.ts": {
|
||||
"mtimeMs": 1780029930791.1965,
|
||||
"size": 10507,
|
||||
"lines": 281,
|
||||
"mtimeMs": 1780625897996.766,
|
||||
"size": 13457,
|
||||
"lines": 322,
|
||||
"role": "Long-Term Memory (장기 기억) 사용자의 취향, 프로젝트 목표, 반복 규칙, 과거 결정 사항을 영구적으로 저장하고 관리합니다. 저장 위치: {brainPath}/memory/longterm.json",
|
||||
"imports": [
|
||||
"src/memory/types"
|
||||
]
|
||||
},
|
||||
"src/memory/MemoryExtractor.ts": {
|
||||
"mtimeMs": 1778028987415.8738,
|
||||
"size": 3953,
|
||||
"lines": 115,
|
||||
"mtimeMs": 1780625907142.6775,
|
||||
"size": 4402,
|
||||
"lines": 120,
|
||||
"role": "Memory Extractor (기억 추출기) 대화 종료 시 히스토리를 분석하여 각 메모리 레이어에 저장할 정보를 자동으로 추출합니다. LLM 호출 없이 패턴 매칭 기반으로 동작합니다.",
|
||||
"imports": [
|
||||
"src/memory/LongTermMemory",
|
||||
@@ -2249,16 +2291,24 @@
|
||||
]
|
||||
},
|
||||
"src/retrieval/brainIndex.ts": {
|
||||
"mtimeMs": 1778667252738.4248,
|
||||
"size": 13270,
|
||||
"lines": 325,
|
||||
"mtimeMs": 1780911242607.242,
|
||||
"size": 22019,
|
||||
"lines": 536,
|
||||
"role": "Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에",
|
||||
"imports": [
|
||||
"src/retrieval/scoring",
|
||||
"src/retrieval/lessonHelpers",
|
||||
"src/retrieval/chunker",
|
||||
"src/utils"
|
||||
]
|
||||
},
|
||||
"src/retrieval/chunker.ts": {
|
||||
"mtimeMs": 1780910566332.277,
|
||||
"size": 6927,
|
||||
"lines": 174,
|
||||
"role": "Markdown Section Chunker (Phase 1-가) 긴 노트를 #~###### 헤딩 경계로 섹션 청크 로 나눈다. 파일 단위 색인은 5000자 다주제 문서를 하나의 흐릿한 단위로 만들어 검색 정밀도를 떨어뜨린다 — 섹션 단위로 쪼개면 질의가 정확히 해당 섹션에 매치된다 (제2뇌의 \"문서 청킹 전략\" 지식 그대로). 규칙: - 각 헤딩 ~ 다음",
|
||||
"imports": []
|
||||
},
|
||||
"src/retrieval/citationTrace.ts": {
|
||||
"mtimeMs": 1780032397612.986,
|
||||
"size": 2348,
|
||||
@@ -2279,9 +2329,9 @@
|
||||
]
|
||||
},
|
||||
"src/retrieval/contextBudget.ts": {
|
||||
"mtimeMs": 1779764602653.6396,
|
||||
"size": 5276,
|
||||
"lines": 140,
|
||||
"mtimeMs": 1780654426656.6118,
|
||||
"size": 6442,
|
||||
"lines": 156,
|
||||
"role": "Context Budget Manager (컨텍스트 예산 관리) 시스템 프롬프트의 토큰 예산을 관리하여 로컬 모델의 context window를 효율적으로 활용합니다.",
|
||||
"imports": [
|
||||
"src/retrieval/types"
|
||||
@@ -2305,6 +2355,13 @@
|
||||
"src/utils"
|
||||
]
|
||||
},
|
||||
"src/retrieval/evalHarness.ts": {
|
||||
"mtimeMs": 1780908975047.264,
|
||||
"size": 9812,
|
||||
"lines": 218,
|
||||
"role": "Retrieval Evaluation Harness 골든셋(질문 → 기대 문서)으로 brain 검색 품질을 recall@k / MRR 로 결정적으로 측정한다. 청킹·re-rank·embedding alpha 등 어떤 변경이 실제로 recall 을 올렸는지 숫자로 증명하기 위한 토대 — 이게 있어야 RAG 개선이 \"감(感)\" 이 아니라 무결성 있는 엔지니어링",
|
||||
"imports": []
|
||||
},
|
||||
"src/retrieval/hierarchicalLevel.ts": {
|
||||
"mtimeMs": 1780030714979.2786,
|
||||
"size": 5908,
|
||||
@@ -2315,9 +2372,9 @@
|
||||
]
|
||||
},
|
||||
"src/retrieval/index.ts": {
|
||||
"mtimeMs": 1780030794352.5571,
|
||||
"size": 26702,
|
||||
"lines": 549,
|
||||
"mtimeMs": 1780913354474.1672,
|
||||
"size": 36447,
|
||||
"lines": 747,
|
||||
"role": "RetrievalOrchestrator — Unified RAG Pipeline Astra의 모든 검색 소스를 통합 관리하는 오케스트레이터입니다. 검색 흐름: ① Query Planning — 의도 분류 + 검색 전략 결정 ② Parallel Search — Brain + Memory + Project + Episode 동시 검색 ③ Result Fusio",
|
||||
"imports": [
|
||||
"src/config",
|
||||
@@ -2386,7 +2443,7 @@
|
||||
]
|
||||
},
|
||||
"src/retrieval/types.ts": {
|
||||
"mtimeMs": 1779764602656.6587,
|
||||
"mtimeMs": 1780654426658.6223,
|
||||
"size": 2485,
|
||||
"lines": 66,
|
||||
"role": "Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다.",
|
||||
@@ -2752,9 +2809,9 @@
|
||||
]
|
||||
},
|
||||
"src/sidebarProvider.ts": {
|
||||
"mtimeMs": 1779764602678.0857,
|
||||
"size": 153639,
|
||||
"lines": 3186,
|
||||
"mtimeMs": 1780907511341.9016,
|
||||
"size": 157039,
|
||||
"lines": 3180,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -2867,9 +2924,9 @@
|
||||
"imports": []
|
||||
},
|
||||
"src/utils.ts": {
|
||||
"mtimeMs": 1779764602679.089,
|
||||
"size": 26357,
|
||||
"lines": 471,
|
||||
"mtimeMs": 1780626069575.9587,
|
||||
"size": 27057,
|
||||
"lines": 472,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/config",
|
||||
@@ -2877,23 +2934,23 @@
|
||||
]
|
||||
},
|
||||
"media/settings-panel.css": {
|
||||
"mtimeMs": 1778549008877.4587,
|
||||
"size": 4756,
|
||||
"lines": 210,
|
||||
"mtimeMs": 1780882667511.7163,
|
||||
"size": 6041,
|
||||
"lines": 254,
|
||||
"role": "Stylesheet",
|
||||
"imports": []
|
||||
},
|
||||
"media/settings-panel.html": {
|
||||
"mtimeMs": 1779764602573.4797,
|
||||
"size": 23829,
|
||||
"lines": 406,
|
||||
"mtimeMs": 1780882658555.451,
|
||||
"size": 26332,
|
||||
"lines": 440,
|
||||
"role": "Astra Settings",
|
||||
"imports": []
|
||||
},
|
||||
"media/settings-panel.js": {
|
||||
"mtimeMs": 1779764602573.4797,
|
||||
"size": 22596,
|
||||
"lines": 469,
|
||||
"mtimeMs": 1780881704898.5125,
|
||||
"size": 24963,
|
||||
"lines": 505,
|
||||
"role": "",
|
||||
"imports": []
|
||||
},
|
||||
@@ -3494,7 +3551,7 @@
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/chronicle.config.json": {
|
||||
"mtimeMs": 1780281037893.3647,
|
||||
"mtimeMs": 1780969084053.7776,
|
||||
"size": 371,
|
||||
"lines": 11,
|
||||
"role": "JSON configuration",
|
||||
@@ -3703,6 +3760,27 @@
|
||||
"role": "ADR: 여전히 오타가 있어. 그러고 어색한 답변도 있네. 오타 및 문맥 수정 사항 S.M.A.T -> S.M.A.R.T: 하드디스크의 자가 진단 기능을...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0030-outlook에서도-이메일-내용을-가져올-수-있나.md": {
|
||||
"mtimeMs": 1780655594198.4128,
|
||||
"size": 1444,
|
||||
"lines": 19,
|
||||
"role": "ADR: outlook에서도 이메일 내용을 가져올 수 있나?",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0031-아래-프로젝트의-로직을-분석하고-어떻게-하면-astra가-더-정확하고-정교하게-개선할-수-있는-부분이-있는지.md": {
|
||||
"mtimeMs": 1780908096070.1074,
|
||||
"size": 1660,
|
||||
"lines": 19,
|
||||
"role": "ADR: 아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. ...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0032-아래-프로젝트의-로직을-분석하고-어떻게-하면-astra가-더-정확하고-정교하게-개선할-수-있는-부분이-있는지.md": {
|
||||
"mtimeMs": 1780914029189.1228,
|
||||
"size": 1660,
|
||||
"lines": 19,
|
||||
"role": "ADR: 아래 프로젝트의 로직을 분석하고 어떻게 하면 astra가 더 정확하고, 정교하게 개선할 수 있는 부분이 있는지 검토해주고 의견을 주면 좋겠어. ...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-05-02_answer-format-readability-tuning.md": {
|
||||
"mtimeMs": 1778028987330.4185,
|
||||
"size": 1564,
|
||||
@@ -3948,6 +4026,13 @@
|
||||
"role": "Development Log: 아스트라를 지금 보다 더 날카롭고 신뢰감이 높은 아이 그리고 나의 의도를 잘 파악하게 하기 위해서는 어떤부분을 강화하는게 좋을까?",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-06-08_아니-지금-코드-업데이트를-했거든-다시-코드를-검토하고-의견을-줘_implementation.md": {
|
||||
"mtimeMs": 1780914060075.0564,
|
||||
"size": 1413,
|
||||
"lines": 23,
|
||||
"role": "Development Log: 아니 지금 코드 업데이트를 했거든. 다시 코드를 검토하고 의견을 줘.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업-할-거야.md": {
|
||||
"mtimeMs": 1778720117404.2578,
|
||||
"size": 668,
|
||||
@@ -4047,9 +4132,9 @@
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/timeline.md": {
|
||||
"mtimeMs": 1780281037885.788,
|
||||
"size": 15049,
|
||||
"lines": 236,
|
||||
"mtimeMs": 1780914060076.0566,
|
||||
"size": 15782,
|
||||
"lines": 248,
|
||||
"role": "Project Timeline",
|
||||
"imports": []
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1780367973993,
|
||||
"createdAt": 1781085022765,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-06-02T02:39:33.994Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (25ms)\n",
|
||||
"createdAt": 1780367973995,
|
||||
"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-06-02T02:39:33.967Z",
|
||||
"totalElapsedMs": 28,
|
||||
"startTime": "2026-06-10T09:50:22.743Z",
|
||||
"totalElapsedMs": 25,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 25,
|
||||
"durationMs": 20,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-06-02T02:39:33.992Z"
|
||||
"ts": "2026-06-10T09:50:22.763Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 3,
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-02T02:39:33.995Z"
|
||||
"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": 1780367980792,
|
||||
"createdAt": 1781085029846,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1780367980791,
|
||||
"createdAt": 1781085029845,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1780367980787,
|
||||
"createdAt": 1781085029841,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1780367980789,
|
||||
"createdAt": 1781085029843,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+8
-8
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1780367980767",
|
||||
"missionId": "stress_conflict_1781085029821",
|
||||
"status": "completed",
|
||||
"startTime": "2026-06-02T02:39:40.767Z",
|
||||
"startTime": "2026-06-10T09:50:29.821Z",
|
||||
"totalElapsedMs": 26,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
@@ -16,28 +16,28 @@
|
||||
"to": "outline",
|
||||
"durationMs": 19,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-06-02T02:39:40.786Z"
|
||||
"ts": "2026-06-10T09:50:29.840Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 3,
|
||||
"durationMs": 2,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-06-02T02:39:40.789Z"
|
||||
"ts": "2026-06-10T09:50:29.842Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 1,
|
||||
"durationMs": 2,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-06-02T02:39:40.790Z"
|
||||
"ts": "2026-06-10T09:50:29.844Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 2,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-02T02:39:40.792Z"
|
||||
"ts": "2026-06-10T09:50:29.846Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,39 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.207 (2026-06-08)
|
||||
### 🏷️ Settings 헤더에 빌드 버전 배지
|
||||
- "Astra Settings" 옆에 현재 확장 버전(`v2.2.207`)을 표시 — 새 vsix가 실제로 로딩됐는지 한눈에 확인하는 마커. provider 가 `__VERSION__` 를 package 버전으로 주입(자동 동기화). ([settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts) `_renderShell`).
|
||||
|
||||
## v2.2.206 (2026-06-08)
|
||||
### 🗂️ Settings 패널 카테고리 탭 정리
|
||||
- 길게 나열되던 9개 설정 섹션을 **4개 탭**으로 그룹핑 — **모델·연결**(연결+Cloud Providers) / **지식·기억**(메모리+두뇌) / **연동**(Datacollect+Telegram+Google) / **동작·고급**(Devil+고급).
|
||||
- 표현 계층만 추가(탭 네비 + show/hide) — 요소 ID·`data-save`·리스너·`renderState`·provider 전부 무수정이라 저장 동작 그대로. 좁은 사이드뷰 대응(탭 줄바꿈), 활성 탭 기억(setState).
|
||||
- 변경: [settings-panel.html](media/settings-panel.html) · [settings-panel.css](media/settings-panel.css) · [settings-panel.js](media/settings-panel.js).
|
||||
|
||||
## v2.2.205 (2026-06-05)
|
||||
### 🧹 백엔드 분리 준비 — Bridge 타깃 토글(로컬/NAS) + /research 제거
|
||||
- **Datacollect Bridge 타깃 설정** 추가 — Astra Settings 패널에서 `로컬/NAS` 전환 + NAS URL/토큰(`x-bridge-token`). 기본 `로컬` = 현행 동작 그대로. ([bridgeClient.ts](src/features/datacollect/bridgeClient.ts) · [settings-panel](media/settings-panel.html) · [settingsPanelProvider.ts](src/features/settings/settingsPanelProvider.ts))
|
||||
- **`/research`(NotebookLM) 제거** — Chrome/Google 로그인 의존이라 로컬 Datacollect 앱 전용으로 분리. benchmark/youtube/wikify/blog/meet 는 유지. ([handlers.ts](src/features/datacollect/handlers.ts))
|
||||
|
||||
### 🛡️ 환각·오염 방지 강화 (코드 검토 기반)
|
||||
- **에러로그 오염 차단** — STT/스택트레이스/에러덤프를 장기기억 채굴에서 제외(`looksLikeErrorLog`, `ERROR_NOISE`) + 자동 추출 항목에 14일 TTL(참조 시 슬라이딩 연장). 기존·수동 항목 무영향. ([LongTermMemory.ts](src/memory/LongTermMemory.ts) · [MemoryExtractor.ts](src/memory/MemoryExtractor.ts))
|
||||
- **컨텍스트 주제 태깅** — 검색 청크에 `[주제]` 태그 + "다른 프로젝트·주제 섞지 말라" 경계 지침으로 무성 교차오염 방지. ([contextBudget.ts](src/retrieval/contextBudget.ts))
|
||||
- **"확인 불가" 블랭킷 규칙** — 근거 없는 사실 날조 금지(수치/날짜/고유명사/결정), R7(가정 후 진행)과 구분. ([utils.ts](src/utils.ts))
|
||||
|
||||
### 🎙️ /meet STT 오타 보정
|
||||
- 음성→텍스트 오타를 문맥·도메인 지식으로 정규화하되 **"철자 보정 ≠ 사실 날조"** 명시 — 오타 하나로 전체를 "확인 불가"로 막지 않게. metadata 를 즉석 용어집으로 활용. ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts))
|
||||
|
||||
## v2.2.204 (2026-06-04)
|
||||
### ✨ `/weekly` 전면 교체 — 캘린더 task 기반 주간 보고서 (금주/차주)
|
||||
- **기존 `/weekly`(대표용 CEO 주간 리뷰 카드 — 고객/채용/런웨이 집계)는 제거**하고, `/weekly` 를 task 기반 금주/차주 보고서로 일원화. (제거: [dashboards.ts](src/features/teamops/handlers/dashboards.ts) `runWeekly` + weekly 전용 헬퍼)
|
||||
- `/weekly <차주 시작일> <차주 종료일>` — 입력 날짜는 **차주** 기준, **금주**(차주 시작 직전 7일)는 자동 역산해 함께 검색.
|
||||
- Google Tasks 의 마감/완료일로 금주·차주를 **코드가 버킷팅**(예측 가능), 각 task 메모(작업 상세·맥락)를 근거로 하위 세부 항목을 LLM 이 서술. `[태그]`(예: [이머시브]/[3D App]/[기타])별로 정렬된 `금주/차주` 포맷 출력.
|
||||
- 신규 [weeklyPrompt.ts](src/features/datacollect/prompts/weeklyPrompt.ts) + [coordination.ts](src/features/teamops/handlers/coordination.ts) `runWeekly`.
|
||||
|
||||
### 🎯 `/meet` 정확도·실용성 개선
|
||||
- **캘린더 task 상세 강화** — 액션 아이템 표에 "작업 상세" 열 추가, 캘린더 notes 가 *무슨 작업이고 무엇을 해야 하는지*를 담도록 재구성 ([meetPrompt.ts](src/features/datacollect/prompts/meetPrompt.ts) · [handlers.ts](src/features/datacollect/handlers.ts) · [calendarHelpers.ts](src/features/datacollect/scheduling/calendarHelpers.ts)).
|
||||
- **할루시네이션·발언 오귀속 억제** — "발언자 무시" 규칙을 "발언자 추적"으로 반전, 비선형 회의 재조립("인접 ≠ 연결") + 근거·정확성 규칙(추론 금지, 불명확 시 "확인 필요", 없는 정보 날조 금지) 추가.
|
||||
|
||||
## v2.2.203 (2026-06-01)
|
||||
### 🐛 기업모드 dev-impl — 빈 깡통 파일 99% 발생 버그 (검출+자동 재작업 기본 ON)
|
||||
**증상**: 사용자가 기획서 + 폴더 주고 "여기 개발해줘" 요청 → ASTRA 가 파일·폴더 만들고 "개발 완료" 보고 → 실제 파일을 열면 **class/함수 본문이 비어 있음** (`def foo(): pass` · 빈 class · imports only). 99% 확률 재발.
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-06-01T02:30:37.893Z"
|
||||
"updatedAt": "2026-06-09T03:09:47.208Z"
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
+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.
|
||||
@@ -234,3 +234,15 @@
|
||||
|
||||
## 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.203",
|
||||
"version": "2.2.212",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lmstudio/sdk": "^1.5.0",
|
||||
|
||||
+60
-5
@@ -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.203",
|
||||
"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",
|
||||
@@ -252,6 +279,11 @@
|
||||
"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": "",
|
||||
@@ -556,6 +588,18 @@
|
||||
"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,
|
||||
@@ -563,7 +607,11 @@
|
||||
},
|
||||
"g1nation.conflictSeverityThreshold": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"default": "medium",
|
||||
"description": "Conflict 자기-신호 surface 시 최소 severity 임계. low=가장 민감(노이즈 가능), medium=균형(기본), high=강한 충돌만."
|
||||
},
|
||||
@@ -615,7 +663,10 @@
|
||||
},
|
||||
"g1nation.distillationArchiveMode": {
|
||||
"type": "string",
|
||||
"enum": ["mark-promoted", "archive-file"],
|
||||
"enum": [
|
||||
"mark-promoted",
|
||||
"archive-file"
|
||||
],
|
||||
"default": "mark-promoted",
|
||||
"description": "Distillation 후 원본 episode 처리: 'mark-promoted'=플래그만 (파일 보존, 기본), 'archive-file'=memory/episodes/archive/ 로 파일 이동."
|
||||
},
|
||||
@@ -655,7 +706,11 @@
|
||||
},
|
||||
"g1nation.intentClarificationStrictness": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"enum": [
|
||||
"low",
|
||||
"medium",
|
||||
"high"
|
||||
],
|
||||
"default": "medium",
|
||||
"description": "모호 판정 임계. low=가장 덜 묻기(2개+ missing), medium=균형(1개+), high=가장 자주 묻기 (1개+ OR 짧은 질의+trigger)."
|
||||
},
|
||||
|
||||
@@ -60,6 +60,14 @@ 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) 가 이미
|
||||
@@ -436,6 +444,8 @@ 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),
|
||||
|
||||
@@ -42,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';
|
||||
@@ -141,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);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -267,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 }),
|
||||
);
|
||||
@@ -278,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[] = [
|
||||
|
||||
@@ -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 || {}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Datacollect handlers — /research · /benchmark · /youtube · /blog · /wikify · /meet.
|
||||
* 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 웹 스캔 ·
|
||||
@@ -23,7 +24,7 @@ import {
|
||||
build4LensPrompt,
|
||||
} from './prompts/youtubePrompts';
|
||||
import { buildWikifyPrompt } from './prompts/wikifyPrompt';
|
||||
import { buildMeetPrompt } from './prompts/meetPrompt';
|
||||
import { buildMeetPrompt, buildMeetExtractPrompt, buildMeetReducePrompt, buildMeetVerifyPrompt } from './prompts/meetPrompt';
|
||||
import { createCalendarEvent, createTask, readCalendarConfig } from '../calendar';
|
||||
import {
|
||||
addBusinessDays,
|
||||
@@ -33,100 +34,6 @@ import {
|
||||
parseActionItems,
|
||||
} from './scheduling/calendarHelpers';
|
||||
|
||||
// ───────────────────────────── /research ─────────────────────────────
|
||||
|
||||
async function runResearch(topic: string, view: Webview | undefined): Promise<boolean> {
|
||||
if (!topic) {
|
||||
chunk(view, `사용법: \`/research <주제>\`\n주제를 입력해 NotebookLM Deep Research를 시작합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `🚀 **Research 시작**: \`${topic}\`\n\n`);
|
||||
const start = await bridgeFetch<{ success: boolean; notebookId: string; taskId: string }>(
|
||||
BRIDGE_API.research.start,
|
||||
{ method: 'POST', body: JSON.stringify({ topic }) },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
chunk(view, `- notebookId: \`${start.notebookId}\`\n- taskId: \`${start.taskId}\`\n\n⏳ 상태 polling (5초 간격, 최대 10분)…\n`);
|
||||
|
||||
const deadline = Date.now() + 10 * 60_000;
|
||||
const HEARTBEAT_MS = 30_000;
|
||||
const MAX_CONSECUTIVE_FAILS = 5;
|
||||
const COMPLETED_SET = new Set(['completed', 'done', 'success', 'finished']);
|
||||
const FAILED_SET = new Set(['failed', 'error', 'cancelled', 'canceled', 'aborted']);
|
||||
|
||||
let lastStatus = '';
|
||||
let lastChangeAt = Date.now();
|
||||
let consecutiveFails = 0;
|
||||
let pollCount = 0;
|
||||
let researchOk = false;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise(r => setTimeout(r, 5_000));
|
||||
pollCount++;
|
||||
let st: { success: boolean; result: any } | undefined;
|
||||
try {
|
||||
st = await bridgeFetch<{ success: boolean; result: any }>(
|
||||
`${BRIDGE_API.research.status}?notebookId=${encodeURIComponent(start.notebookId)}&taskId=${encodeURIComponent(start.taskId)}`,
|
||||
{ method: 'GET' },
|
||||
{ timeoutMs: 60_000 },
|
||||
);
|
||||
consecutiveFails = 0;
|
||||
} catch (e: any) {
|
||||
consecutiveFails++;
|
||||
if (consecutiveFails >= MAX_CONSECUTIVE_FAILS) {
|
||||
chunk(view, `\n❌ Status polling 연속 실패 ${consecutiveFails}회 — bridge 가 응답하지 않습니다. 중단합니다.\n(원인: ${e?.message || String(e)})\n`);
|
||||
return true;
|
||||
}
|
||||
chunk(view, `\n · status 호출 실패 ${consecutiveFails}/${MAX_CONSECUTIVE_FAILS} (${e?.message || 'unknown'})\n`);
|
||||
continue;
|
||||
}
|
||||
const status = String(st.result?.status || st.result || '').trim().toLowerCase();
|
||||
if (status && status !== lastStatus) {
|
||||
chunk(view, ` · ${status}\n`);
|
||||
lastStatus = status;
|
||||
lastChangeAt = Date.now();
|
||||
} else if (Date.now() - lastChangeAt > HEARTBEAT_MS) {
|
||||
chunk(view, ` · ⏳ 대기 중 (${Math.round((Date.now() - lastChangeAt) / 1000)}s, 폴링 ${pollCount}회)\n`);
|
||||
lastChangeAt = Date.now();
|
||||
}
|
||||
if (COMPLETED_SET.has(status)) { researchOk = true; break; }
|
||||
if (FAILED_SET.has(status)) {
|
||||
chunk(view, `\n❌ Research 실패: ${JSON.stringify(st.result).slice(0, 400)}\n`);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!researchOk) {
|
||||
chunk(view, `\n❌ 10분 polling 후에도 완료 신호가 오지 않았습니다 (마지막 status: \`${lastStatus || '(없음)'}\`). 중단합니다.\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
chunk(view, `\n📥 import…\n`);
|
||||
await bridgeFetch(BRIDGE_API.research.import, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, taskId: start.taskId }),
|
||||
}, {
|
||||
timeoutMs: 300_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · import 진행 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
});
|
||||
|
||||
chunk(view, `🧪 synthesize…\n\n`);
|
||||
const synth = await bridgeFetch<{ success: boolean; markdown?: string; result?: string }>(
|
||||
BRIDGE_API.research.synthesize,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notebookId: start.notebookId, topic, rootTopic: topic, includeKnowledgeConnections: true }),
|
||||
},
|
||||
{
|
||||
timeoutMs: 600_000,
|
||||
onHeartbeat: (elapsedMs) => chunk(view, ` · synthesize LLM 작업 중 (${Math.round(elapsedMs / 1000)}s)\n`),
|
||||
},
|
||||
);
|
||||
const md = synth.markdown || synth.result || '(빈 응답)';
|
||||
chunk(view, `---\n\n${md}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||
|
||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||
@@ -627,25 +534,83 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
chunk(view, `\n\n⚠️ 파일 내용이 거의 비어 있습니다.\n`);
|
||||
return true;
|
||||
}
|
||||
const MAX = 60000;
|
||||
const truncated = transcript.length > MAX;
|
||||
if (truncated) transcript = transcript.slice(0, MAX);
|
||||
chunk(view, `\n✅ 파일 읽기 완료 (${transcript.length.toLocaleString()}자${truncated ? ', 상한 초과로 일부 잘림' : ''})\n\n`);
|
||||
// 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 = '당신은 회의록 작성 전문가입니다. 제공된 녹취록과 메타데이터만 근거로 사실 기반의 구조화된 회의록을 작성하며, 외부 지식이나 추측을 절대 섞지 않습니다. 모든 출력은 한국어로 작성합니다.';
|
||||
chunk(view, `🧪 **회의록 합성** (모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…`);
|
||||
let report: string;
|
||||
let groundingNotes = ''; // 검증 패스용 — 세그먼트 경로에서 추출 노트 보관
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
report = await callLmSynthesis(buildMeetPrompt(transcript, metadata), meetSystem);
|
||||
if (!report) throw new Error('LLM 응답이 비어 있습니다.');
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - t0) / 1000)}s)\n\n`);
|
||||
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 {
|
||||
@@ -696,7 +661,20 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
const { date, tentative } = resolveTaskDate(task.due, meetingDate, today);
|
||||
if (tentative) tentativeCount++;
|
||||
const evTitle = tentative ? `${task.work} (미확정)` : task.work;
|
||||
const notes = `회의록: ${meetTitle}\n담당: ${task.owner}\n기한 표기: ${task.due || '(없음)'}\nAstra /meet 자동 등록`;
|
||||
const 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[] = [];
|
||||
@@ -736,7 +714,9 @@ async function runMeet(arg: string, view: Webview | undefined, context?: vscode.
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/research', description: 'NotebookLM Deep Research 호출 후 위키 합성', handler: runResearch });
|
||||
// /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 });
|
||||
|
||||
@@ -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)}
|
||||
|
||||
위 포맷을 정확히 따르고, 보고서 본문만 출력한다(설명·머리말·코드펜스 금지).`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -136,13 +136,21 @@ 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`);
|
||||
}
|
||||
|
||||
|
||||
@@ -311,8 +311,8 @@ const HELP_CATEGORIES: HelpCategory[] = [
|
||||
{
|
||||
title: '리서치·분석',
|
||||
emoji: '🔬',
|
||||
match: (n) => ['/research', '/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
|
||||
blurb: 'Datacollect bridge 통합 — Deep Research / 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록',
|
||||
match: (n) => ['/benchmark', '/youtube', '/blog', '/wikify', '/meet'].includes(n),
|
||||
blurb: 'Datacollect bridge 통합 — 웹 벤치마크 / YouTube 4-렌즈 / Blog 파이프라인 / Wikify / 회의록 (NotebookLM Deep Research 는 로컬 Datacollect 앱으로 분리)',
|
||||
},
|
||||
{
|
||||
title: '시스템·메모리',
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 전용 ──────────────────────────────────────────────
|
||||
|
||||
@@ -563,9 +565,100 @@ async function runStandup(arg: string, view: any, context?: vscode.ExtensionCont
|
||||
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 });
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* TeamOps Dashboards — /morning · /evening · /cohort · /weekly (CEO 일·주 리듬).
|
||||
* TeamOps Dashboards — /morning · /evening · /cohort (CEO 일·월 리듬).
|
||||
*
|
||||
* v2.2.198 에서 slashRouter.ts 에서 분리. cross-data 합성 (Tasks + customers +
|
||||
* hire + runway + Chronicle ADR) 으로 일/주/월 단위 시야 제공.
|
||||
* hire + runway + Chronicle ADR) 으로 일/월 단위 시야 제공.
|
||||
* (구 /weekly CEO 리뷰 카드는 v2.2.204 에서 제거 — /weekly 는 task 기반 금주/차주
|
||||
* 보고서로 일원화, coordination.ts 참조.)
|
||||
*
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_isoWeek, _aggregateWeek,
|
||||
* _morningActions 등) 는 이 파일 안에.
|
||||
* 공통 헬퍼는 `./_shared.ts` 에서. dashboards 전용 helper (_morningActions 등) 는
|
||||
* 이 파일 안에.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
@@ -484,323 +486,8 @@ async function runCohort(arg: string, view: any): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── /weekly — 주간 리뷰 카드 (CEO 전략 시야) ────────────────────────────
|
||||
|
||||
function _isoWeek(d: Date): { year: number; week: number; label: string } {
|
||||
const target = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||||
const dayNum = (target.getUTCDay() + 6) % 7;
|
||||
target.setUTCDate(target.getUTCDate() - dayNum + 3);
|
||||
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
||||
const firstDayNum = (firstThursday.getUTCDay() + 6) % 7;
|
||||
firstThursday.setUTCDate(firstThursday.getUTCDate() - firstDayNum + 3);
|
||||
const week = 1 + Math.round((target.getTime() - firstThursday.getTime()) / (7 * 24 * 60 * 60 * 1000));
|
||||
return { year: target.getUTCFullYear(), week, label: `${target.getUTCFullYear()}-W${String(week).padStart(2, '0')}` };
|
||||
}
|
||||
|
||||
interface WeeklyWindow {
|
||||
startIso: string;
|
||||
endIso: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function _thisWeekWindow(now: Date = new Date()): WeeklyWindow {
|
||||
const dayOfWeek = now.getDay();
|
||||
const daysFromMonday = (dayOfWeek + 6) % 7;
|
||||
const monday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysFromMonday);
|
||||
const sunday = new Date(monday.getFullYear(), monday.getMonth(), monday.getDate() + 6, 23, 59, 59);
|
||||
const { label: yw } = _isoWeek(monday);
|
||||
const startIso = monday.toISOString().slice(0, 10);
|
||||
const endIso = sunday.toISOString().slice(0, 10);
|
||||
const shortStart = `${monday.getMonth() + 1}/${monday.getDate()}`;
|
||||
const shortEnd = `${sunday.getMonth() + 1}/${sunday.getDate()}`;
|
||||
return { startIso, endIso, startMs: monday.getTime(), endMs: sunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
|
||||
}
|
||||
|
||||
function _priorWeekWindow(thisWeek: WeeklyWindow): WeeklyWindow {
|
||||
const priorMonday = new Date(thisWeek.startMs - 7 * 24 * 60 * 60 * 1000);
|
||||
const priorSunday = new Date(thisWeek.startMs - 1000);
|
||||
const { label: yw } = _isoWeek(priorMonday);
|
||||
const startIso = priorMonday.toISOString().slice(0, 10);
|
||||
const endIso = priorSunday.toISOString().slice(0, 10);
|
||||
const shortStart = `${priorMonday.getMonth() + 1}/${priorMonday.getDate()}`;
|
||||
const shortEnd = `${priorSunday.getMonth() + 1}/${priorSunday.getDate()}`;
|
||||
return { startIso, endIso, startMs: priorMonday.getTime(), endMs: priorSunday.getTime(), label: `${yw} (${shortStart}-${shortEnd})` };
|
||||
}
|
||||
|
||||
interface WeeklyAggregate {
|
||||
taskCompleted: number;
|
||||
taskByOwner: Map<string, number>;
|
||||
customerEvents: number;
|
||||
customerNewCount: number;
|
||||
customerRenewCount: number;
|
||||
customerRiskCount: number;
|
||||
customerChurnCount: number;
|
||||
customerNewMrr: number;
|
||||
hireEvents: number;
|
||||
hireMoved: number;
|
||||
hireAdded: number;
|
||||
hireHired: number;
|
||||
runwayExpense: number;
|
||||
runwayRevenue: number;
|
||||
runwayLastCash: number | null;
|
||||
runwayFirstCash: number | null;
|
||||
adrCount: number;
|
||||
}
|
||||
|
||||
function _aggregateWeek(
|
||||
win: WeeklyWindow,
|
||||
completedTasks: any[],
|
||||
cevs: CustomerEvent[],
|
||||
hevs: HireEvent[],
|
||||
rs: RunwayEntry[],
|
||||
adrs: { date: string; title: string }[],
|
||||
): WeeklyAggregate {
|
||||
const agg: WeeklyAggregate = {
|
||||
taskCompleted: 0, taskByOwner: new Map(),
|
||||
customerEvents: 0, customerNewCount: 0, customerRenewCount: 0,
|
||||
customerRiskCount: 0, customerChurnCount: 0, customerNewMrr: 0,
|
||||
hireEvents: 0, hireMoved: 0, hireAdded: 0, hireHired: 0,
|
||||
runwayExpense: 0, runwayRevenue: 0, runwayLastCash: null, runwayFirstCash: null,
|
||||
adrCount: 0,
|
||||
};
|
||||
for (const t of completedTasks) {
|
||||
if (!t.completed) continue;
|
||||
const ms = Date.parse(t.completed);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.taskCompleted++;
|
||||
const { owner } = parseTaskOwner(t.title, t.notes);
|
||||
const k = owner || '(미지정)';
|
||||
agg.taskByOwner.set(k, (agg.taskByOwner.get(k) || 0) + 1);
|
||||
}
|
||||
for (const e of cevs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.customerEvents++;
|
||||
if (e.type === 'add') { agg.customerNewCount++; if (e.mrr) agg.customerNewMrr += e.mrr; }
|
||||
else if (e.type === 'renew') agg.customerRenewCount++;
|
||||
else if (e.type === 'risk') agg.customerRiskCount++;
|
||||
else if (e.type === 'churn') agg.customerChurnCount++;
|
||||
}
|
||||
for (const e of hevs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
agg.hireEvents++;
|
||||
if (e.type === 'add') agg.hireAdded++;
|
||||
else if (e.type === 'stage') agg.hireMoved++;
|
||||
else if (e.type === 'hire') agg.hireHired++;
|
||||
}
|
||||
const cashInWin = rs
|
||||
.filter((r) => r.type === 'snapshot')
|
||||
.filter((r) => { const ms = Date.parse(r.timestamp); return ms >= win.startMs && ms <= win.endMs; })
|
||||
.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''));
|
||||
if (cashInWin.length > 0) {
|
||||
agg.runwayFirstCash = cashInWin[0].amount;
|
||||
agg.runwayLastCash = cashInWin[cashInWin.length - 1].amount;
|
||||
}
|
||||
for (const e of rs) {
|
||||
const ms = Date.parse(e.timestamp);
|
||||
if (ms < win.startMs || ms > win.endMs) continue;
|
||||
if (e.type === 'expense') agg.runwayExpense += e.amount;
|
||||
else if (e.type === 'revenue') agg.runwayRevenue += e.amount;
|
||||
}
|
||||
for (const a of adrs) {
|
||||
if (a.date >= win.startIso && a.date <= win.endIso) agg.adrCount++;
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
function _deltaSymbol(now: number, prev: number): string {
|
||||
if (prev === 0 && now === 0) return '→';
|
||||
if (prev === 0) return `↑${now}`;
|
||||
const diff = now - prev;
|
||||
if (diff > 0) return `↑${diff}`;
|
||||
if (diff < 0) return `↓${Math.abs(diff)}`;
|
||||
return '→';
|
||||
}
|
||||
|
||||
async function runWeekly(arg: string, view: any, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
const trimmed = arg.trim().toLowerCase();
|
||||
if (trimmed === 'help' || trimmed === '?') {
|
||||
chunk(view, [
|
||||
'\n📅 **/weekly — 주간 리뷰 카드 (대표용)**',
|
||||
'',
|
||||
'사용법:',
|
||||
' `/weekly` — 이번 주 (월~일) 리뷰 + 지난 주 대비 + 다음 주 준비',
|
||||
'',
|
||||
'`/standup weekly` 와 차이:',
|
||||
'- `/standup weekly` → 팀 공유용 (멤버별 완료/진행/블로커, 슬랙 복붙)',
|
||||
'- `/weekly` → 대표용 전략 시야 (cross-data delta, 회고 프롬프트)',
|
||||
'',
|
||||
'데이터 출처: Google Tasks + /customers + /hire + /runway + Chronicle ADR.\n',
|
||||
].join('\n'));
|
||||
return true;
|
||||
}
|
||||
|
||||
const thisWeek = _thisWeekWindow();
|
||||
const priorWeek = _priorWeekWindow(thisWeek);
|
||||
chunk(view, `\n📅 **/weekly — 주간 리뷰 ${thisWeek.label}**\n`);
|
||||
|
||||
let completedTasks: any[] = [];
|
||||
let tasksError: string | undefined;
|
||||
if (context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: true, maxResults: 500 });
|
||||
if (res.ok) completedTasks = res.tasks.filter((t: any) => t.status === 'completed');
|
||||
else tasksError = res.error;
|
||||
} catch (e: any) { tasksError = e?.message || String(e); }
|
||||
}
|
||||
|
||||
const cevs = readCustomerEvents();
|
||||
const hevs = readHireEvents();
|
||||
const rs = readRunway();
|
||||
|
||||
const adrs: { date: string; title: string }[] = [];
|
||||
try {
|
||||
const folders = vscode.workspace.workspaceFolders;
|
||||
if (folders && folders.length > 0 && context) {
|
||||
const cs = new ChronicleProjectStore(context);
|
||||
const projects = cs.getAll();
|
||||
for (const p of projects) {
|
||||
const decisionsDir = path.join(p.recordRoot, 'decisions');
|
||||
if (!fs.existsSync(decisionsDir)) continue;
|
||||
const files = fs.readdirSync(decisionsDir).filter((f) => f.startsWith('ADR-') && f.endsWith('.md'));
|
||||
for (const f of files) {
|
||||
try {
|
||||
const full = path.join(decisionsDir, f);
|
||||
const stat = fs.statSync(full);
|
||||
const d = new Date(stat.mtimeMs).toISOString().slice(0, 10);
|
||||
const title = f.replace(/^ADR-\d+-/, '').replace(/\.md$/, '').replace(/[-_]/g, ' ');
|
||||
adrs.push({ date: d, title });
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const aggNow = _aggregateWeek(thisWeek, completedTasks, cevs, hevs, rs, adrs);
|
||||
const aggPrev = _aggregateWeek(priorWeek, completedTasks, cevs, hevs, rs, adrs);
|
||||
|
||||
chunk(view, '\n## ✅ 이번 주 진척\n');
|
||||
if (tasksError) chunk(view, `- _Tasks 조회 실패: ${tasksError}_\n`);
|
||||
else if (aggNow.taskCompleted === 0) chunk(view, '- _완료된 작업 없음._\n');
|
||||
else {
|
||||
chunk(view, `\n### 작업 (${aggNow.taskCompleted}건 완료)\n`);
|
||||
const ranked = [...aggNow.taskByOwner.entries()].sort((a, b) => b[1] - a[1]);
|
||||
for (const [owner, n] of ranked) {
|
||||
const prev = aggPrev.taskByOwner.get(owner) || 0;
|
||||
chunk(view, `- **@${owner}**: ${n}건 ${_deltaSymbol(n, prev)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (aggNow.customerEvents > 0) {
|
||||
chunk(view, `\n### 📒 고객 (${aggNow.customerEvents} 이벤트)\n`);
|
||||
if (aggNow.customerNewCount > 0) chunk(view, `- 신규 ${aggNow.customerNewCount}곳 _(MRR +${_fmtKrw(aggNow.customerNewMrr)})_\n`);
|
||||
if (aggNow.customerRenewCount > 0) chunk(view, `- 갱신 ${aggNow.customerRenewCount}곳\n`);
|
||||
if (aggNow.customerRiskCount > 0) chunk(view, `- ⚠️ 위험 표시 ${aggNow.customerRiskCount}곳\n`);
|
||||
if (aggNow.customerChurnCount > 0) chunk(view, `- 💀 이탈 ${aggNow.customerChurnCount}곳\n`);
|
||||
}
|
||||
|
||||
if (aggNow.hireEvents > 0) {
|
||||
chunk(view, `\n### 🎯 채용 (${aggNow.hireEvents} 이벤트)\n`);
|
||||
if (aggNow.hireAdded > 0) chunk(view, `- 신규 후보 ${aggNow.hireAdded}명\n`);
|
||||
if (aggNow.hireMoved > 0) chunk(view, `- 단계 이동 ${aggNow.hireMoved}건\n`);
|
||||
if (aggNow.hireHired > 0) chunk(view, `- 🎉 합격 ${aggNow.hireHired}명\n`);
|
||||
}
|
||||
|
||||
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0 || aggNow.runwayLastCash !== null) {
|
||||
chunk(view, '\n### 💰 재무\n');
|
||||
if (aggNow.runwayExpense > 0 || aggNow.runwayRevenue > 0) {
|
||||
const netBurn = aggNow.runwayExpense - aggNow.runwayRevenue;
|
||||
chunk(view, `- 지출 ${_fmtKrw(aggNow.runwayExpense)} · 수입 ${_fmtKrw(aggNow.runwayRevenue)} · 순 burn ${netBurn > 0 ? '+' : ''}${_fmtKrw(netBurn)}\n`);
|
||||
}
|
||||
if (aggNow.runwayLastCash !== null && aggNow.runwayFirstCash !== null) {
|
||||
const delta = aggNow.runwayLastCash - aggNow.runwayFirstCash;
|
||||
chunk(view, `- 잔고: ${_fmtKrw(aggNow.runwayFirstCash)} → ${_fmtKrw(aggNow.runwayLastCash)} _(Δ ${delta >= 0 ? '+' : ''}${_fmtKrw(delta)})_\n`);
|
||||
} else if (aggNow.runwayLastCash !== null) {
|
||||
chunk(view, `- 현재 잔고: ${_fmtKrw(aggNow.runwayLastCash)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
if (aggNow.adrCount > 0) {
|
||||
chunk(view, `\n### 📋 결정 (ADR ${aggNow.adrCount}건)\n`);
|
||||
const weekAdrs = adrs.filter((a) => a.date >= thisWeek.startIso && a.date <= thisWeek.endIso).slice(0, 5);
|
||||
for (const a of weekAdrs) chunk(view, `- ${a.date} — ${a.title}\n`);
|
||||
}
|
||||
|
||||
chunk(view, `\n## 🔄 지난 주 대비 (${priorWeek.label})\n`);
|
||||
chunk(view, `- 작업: ${aggNow.taskCompleted}건 ${_deltaSymbol(aggNow.taskCompleted, aggPrev.taskCompleted)} _(이번 ${aggNow.taskCompleted} ← 지난 ${aggPrev.taskCompleted})_\n`);
|
||||
chunk(view, `- 신규 고객: ${aggNow.customerNewCount} ${_deltaSymbol(aggNow.customerNewCount, aggPrev.customerNewCount)}\n`);
|
||||
chunk(view, `- 이탈: ${aggNow.customerChurnCount} ${_deltaSymbol(aggNow.customerChurnCount, aggPrev.customerChurnCount)}\n`);
|
||||
const burnNow = aggNow.runwayExpense - aggNow.runwayRevenue;
|
||||
const burnPrev = aggPrev.runwayExpense - aggPrev.runwayRevenue;
|
||||
if (burnNow !== 0 || burnPrev !== 0) {
|
||||
const diff = burnNow - burnPrev;
|
||||
const arrow = diff > 0 ? '↑' : diff < 0 ? '↓' : '→';
|
||||
chunk(view, `- 순 burn: ${_fmtKrw(burnNow)} ${arrow} _(지난 ${_fmtKrw(burnPrev)})_\n`);
|
||||
}
|
||||
chunk(view, `- 채용 이벤트: ${aggNow.hireEvents} ${_deltaSymbol(aggNow.hireEvents, aggPrev.hireEvents)}\n`);
|
||||
|
||||
chunk(view, '\n## 🌅 다음 주 준비\n');
|
||||
const customerStates = computeCustomerStates();
|
||||
const upcoming = Array.from(customerStates.values())
|
||||
.filter((c) => c.status !== 'churned')
|
||||
.map((c) => ({ c, days: _daysUntil(c.renewalAt) }))
|
||||
.filter((x) => x.days !== null && x.days >= 0 && x.days <= 14)
|
||||
.sort((a, b) => (a.days as number) - (b.days as number));
|
||||
if (upcoming.length > 0) {
|
||||
chunk(view, `\n### 🔔 14일 내 갱신 (${upcoming.length}건)\n`);
|
||||
for (const { c, days } of upcoming.slice(0, 5)) {
|
||||
const emoji = (days as number) <= 7 ? '🔴' : '🟡';
|
||||
chunk(view, `- ${emoji} ${c.customerName} D-${days} · ${_fmtKrw(c.mrr)}원/월\n`);
|
||||
}
|
||||
}
|
||||
|
||||
let nextWeekDue = 0;
|
||||
if (!tasksError && context) {
|
||||
try {
|
||||
const res = await listTasks(context, { showCompleted: false, maxResults: 300 });
|
||||
if (res.ok) {
|
||||
const startNext = new Date(thisWeek.endMs + 1000);
|
||||
const endNext = new Date(thisWeek.endMs + 7 * 24 * 60 * 60 * 1000);
|
||||
const startIso = startNext.toISOString().slice(0, 10);
|
||||
const endIso = endNext.toISOString().slice(0, 10);
|
||||
nextWeekDue = res.tasks.filter((t: any) => t.due && t.due >= startIso && t.due <= endIso).length;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
if (nextWeekDue > 0) chunk(view, `\n- 📅 다음 주 마감 작업: ${nextWeekDue}건\n`);
|
||||
|
||||
const stalled = Array.from(computeCandidateStates().values())
|
||||
.filter((c) => !TERMINAL_STAGES.has(c.stage))
|
||||
.filter((c) => (Date.now() - Date.parse(c.lastEventAt)) > 7 * 24 * 60 * 60 * 1000);
|
||||
if (stalled.length > 0) {
|
||||
chunk(view, `\n### ⏰ 정체 후보 (${stalled.length}명)\n`);
|
||||
for (const c of stalled.slice(0, 3)) {
|
||||
const days = Math.floor((Date.now() - Date.parse(c.lastEventAt)) / (24 * 60 * 60 * 1000));
|
||||
chunk(view, `- ${c.candidateName} _(${c.role})_ · ${c.stage} · ${days}일 정체\n`);
|
||||
}
|
||||
}
|
||||
|
||||
const reflections = [
|
||||
'이번 주 가장 중요한 결정은 무엇이었나? 다음 주에 어떤 결정을 미루면 안 되나?',
|
||||
'이번 주 가장 시간을 많이 쓴 활동이 가장 영향력 있는 활동과 일치했나?',
|
||||
'이번 주 멤버 중 누구와 1:1 시간이 충분치 않았나? 다음 주 일정 잡기.',
|
||||
'이번 주 *안 한 일* 중 다음 주에도 안 해도 되는 일은 무엇인가?',
|
||||
'이번 주 발견한 리스크 한 가지를 꼽는다면? 누구와 이야기해야 하나?',
|
||||
'이번 주 가장 좋았던 30분은 무엇을 하던 때였나? 다음 주에 어떻게 복제할까?',
|
||||
];
|
||||
const weekKey = Math.floor(thisWeek.startMs / (1000 * 60 * 60 * 24 * 7));
|
||||
const idx = weekKey % reflections.length;
|
||||
chunk(view, `\n## 💭 주간 회고\n> ${reflections[idx]}\n`);
|
||||
chunk(view, '\n_답을 어디 적으면 좋은지:_ `/decisions` (ADR 작성) · `/feedback` (고객 학습) · `/onesie @멤버` (1:1 준비)\n');
|
||||
return true;
|
||||
}
|
||||
|
||||
// ─── 등록 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
registerSlashCommand({ name: '/morning', description: '매일 아침 통합 대시보드 — runway/customers/tasks/hire 핵심 한 화면 + 오늘의 액션', handler: runMorning });
|
||||
registerSlashCommand({ name: '/evening', description: '하루 마무리 카드 — 오늘의 진척 + 내일 준비 + 회고 한 줄', handler: runEvening });
|
||||
registerSlashCommand({ name: '/cohort', description: 'MoM 추세 분석 — customers + runway events 월별 그룹핑 (신규/이탈/MRR/burn)', handler: runCohort });
|
||||
registerSlashCommand({ name: '/weekly', description: '주간 리뷰 카드 (대표용) — 이번 주 진척 + 지난 주 대비 + 다음 주 준비 + 회고', handler: runWeekly });
|
||||
|
||||
@@ -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,
|
||||
@@ -207,6 +207,8 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
workStateSignals,
|
||||
hierarchicalReweightEnabled: config.hierarchicalReweightEnabled !== false,
|
||||
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
|
||||
chunkTargetChars: config.chunkTargetChars,
|
||||
});
|
||||
|
||||
// Semantic Re-rank (LLM, async) — selectedChunks 의 *순서* 만 재배치. 토큰 예산을
|
||||
@@ -236,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -169,6 +169,12 @@ export class LongTermMemory {
|
||||
.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');
|
||||
@@ -181,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;
|
||||
|
||||
@@ -202,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 호출 없이 동작합니다.
|
||||
@@ -235,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;
|
||||
@@ -269,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;
|
||||
|
||||
+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);
|
||||
}
|
||||
@@ -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,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');
|
||||
}
|
||||
+201
-3
@@ -21,7 +21,7 @@ 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';
|
||||
@@ -97,6 +97,14 @@ interface RetrievalOptions {
|
||||
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 {
|
||||
@@ -129,7 +137,9 @@ export class RetrievalOrchestrator {
|
||||
scopeFolders,
|
||||
options.queryEmbedding,
|
||||
options.embeddingModel,
|
||||
options.embeddingBlendAlpha
|
||||
options.embeddingBlendAlpha,
|
||||
options.chunkLevelRetrieval || false,
|
||||
options.chunkTargetChars ?? 1200,
|
||||
)
|
||||
: [];
|
||||
allChunks.push(...brainChunks);
|
||||
@@ -213,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(
|
||||
@@ -225,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);
|
||||
@@ -343,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(
|
||||
@@ -531,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';
|
||||
|
||||
+60
-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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -33,6 +33,8 @@ class FakeLMStudioClient implements ILMStudioClient {
|
||||
public failNextUnload: Error | null = null;
|
||||
public loadDelayMs = 0;
|
||||
public lastLoadSignal: AbortSignal | undefined;
|
||||
/** 실제 로드 상태 추적 — listLoaded()가 이를 반영해야 lifecycle 의 '전체 언로드'를 검증할 수 있다. */
|
||||
public loaded = new Set<string>();
|
||||
|
||||
setBaseUrl(_: string): void { /* noop */ }
|
||||
|
||||
@@ -54,6 +56,7 @@ class FakeLMStudioClient implements ILMStudioClient {
|
||||
this.failNextLoad = null;
|
||||
throw err;
|
||||
}
|
||||
this.loaded.add(modelKey); // 성공 시에만 로드 상태로
|
||||
}
|
||||
|
||||
async unload(modelKey: string): Promise<void> {
|
||||
@@ -61,13 +64,14 @@ class FakeLMStudioClient implements ILMStudioClient {
|
||||
if (this.failNextUnload) {
|
||||
const err = this.failNextUnload;
|
||||
this.failNextUnload = null;
|
||||
throw err;
|
||||
throw err; // 실패 시 로드 상태 유지
|
||||
}
|
||||
this.loaded.delete(modelKey);
|
||||
}
|
||||
|
||||
async listLoaded(): Promise<string[]> {
|
||||
this.listLoadedCalls++;
|
||||
return [];
|
||||
return [...this.loaded];
|
||||
}
|
||||
|
||||
async isReachable(): Promise<boolean> {
|
||||
@@ -75,7 +79,7 @@ class FakeLMStudioClient implements ILMStudioClient {
|
||||
}
|
||||
|
||||
async listLoadedCached(): Promise<string[]> {
|
||||
return [];
|
||||
return [...this.loaded];
|
||||
}
|
||||
|
||||
async listDownloaded(): Promise<string[]> {
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* criteriaEval — `/stocks judge` 결정론 평가기 테스트.
|
||||
* 픽스처는 옛 LLM 프롬프트에 명시돼 있던 사용자의 실제 분류 예시 3종
|
||||
* (마녀공장/기가비스/엔켐) — 코드 판정이 사용자 패턴과 일치해야 한다.
|
||||
*/
|
||||
import { evaluateCriteria, buildVerdict, marketCapEok } from '../src/features/stocks/criteriaEval';
|
||||
import type { Stock } from '../src/features/stocks/types';
|
||||
|
||||
const NOW = new Date('2026-06-10');
|
||||
|
||||
function judge(stock: Stock, techPass?: boolean) {
|
||||
const ev = evaluateCriteria(stock, undefined, NOW);
|
||||
return { ev, verdict: buildVerdict(ev, stock.투자성향, techPass) };
|
||||
}
|
||||
|
||||
describe('stocks criteriaEval', () => {
|
||||
test('마녀공장 패턴 — 충족 (ROE, 성장성, 유동성)', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '마녀공장', 심볼: '439090', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '15.6%', '영업이익률(25E)': '18.0%', 유보율: '5,800%',
|
||||
PBR: '1.2', 시가총액: '4,000억',
|
||||
});
|
||||
expect(verdict.text).toBe('충족 (ROE, 성장성, 유동성)');
|
||||
});
|
||||
|
||||
test('기가비스 패턴 — ROE 10% 미만이라 빠지고 수익성 개선 표기', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '기가비스', 심볼: '420770', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '7.23%', '영업이익률(25E)': '25.7%', 유보율: '4,250%',
|
||||
상장일: '2024-05-24', PBR: '1.3', 시가총액: '3,000억',
|
||||
});
|
||||
// 통과: 성장성(영업이익률≥15), 유동성, 수익성(≥20→개선), PBR — ROE 는 미통과
|
||||
expect(verdict.passed).not.toContain('ROE');
|
||||
expect(verdict.text).toBe('충족 (성장성, 유동성, 수익성 개선)');
|
||||
});
|
||||
|
||||
test('엔켐 패턴 — 통과 2개면 미충족', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '엔켐', 심볼: '348370', 투자성향: '스윙/중기',
|
||||
'ROE(25E)': '12.4%', '영업이익률(25E)': '8.5%', 유보율: '1,250%',
|
||||
PBR: '3.5', 시가총액: '2조 1,000억',
|
||||
});
|
||||
// 통과: ROE, 유동성 (성장성·수익성·영업효율·PBR 미통과, 기술력은 먹거리 미입력→fail)
|
||||
expect(verdict.passed.sort()).toEqual(['ROE', '유동성']);
|
||||
expect(verdict.text).toMatch(/^미충족/);
|
||||
});
|
||||
|
||||
test('저평가우량주 — PBR·ROE 우선 선택', () => {
|
||||
const { verdict } = judge({
|
||||
이름: '가상우량', 심볼: '000001', 투자성향: '저평가우량주',
|
||||
'ROE(25E)': '11%', '영업이익률(25E)': '12%', 유보율: '3,500%',
|
||||
PBR: '0.9', 시가총액: '6,000억',
|
||||
});
|
||||
// 통과: PBR, ROE, 유동성, 수익성, 안정성 → 우선순위로 PBR, ROE 먼저
|
||||
expect(verdict.text).toBe('충족 (PBR, ROE, 수익성)');
|
||||
});
|
||||
|
||||
test('기술력 — 키워드 명중 시 LLM 없이 통과', () => {
|
||||
const { ev } = judge({
|
||||
이름: '테크주', 심볼: '000002', 투자성향: '장기투자',
|
||||
'ROE(25E)': '9%', '영업이익률(25E)': '16%', 유보율: '1,500%',
|
||||
PBR: '2.5', '최대 먹거리': 'AI 반도체 설계',
|
||||
});
|
||||
const tech = ev.results.find(r => r.keyword === '기술력')!;
|
||||
expect(tech.state).toBe('pass');
|
||||
});
|
||||
|
||||
test('기술력 — 도메인 모호하면 llm 상태, techPass 반영', () => {
|
||||
const stock: Stock = {
|
||||
이름: '모호주', 심볼: '000003', 투자성향: '장기투자',
|
||||
'ROE(25E)': '9%', '영업이익률(25E)': '16%', 유보율: '1,500%',
|
||||
PBR: '2.5', '최대 먹거리': '프리미엄 화장품 ODM',
|
||||
};
|
||||
const ev = evaluateCriteria(stock, undefined, NOW);
|
||||
expect(ev.results.find(r => r.keyword === '기술력')!.state).toBe('llm');
|
||||
// techPass=true → 충족에 기여, false → 제외
|
||||
const yes = buildVerdict(ev, '장기투자', true);
|
||||
const no = buildVerdict(ev, '장기투자', false);
|
||||
expect(yes.passed).toContain('기술력');
|
||||
expect(no.passed).not.toContain('기술력');
|
||||
});
|
||||
|
||||
test('데이터 없음(unknown)은 통과로 치지 않는다', () => {
|
||||
const { verdict } = judge({ 이름: '빈데이터', 심볼: '000004' });
|
||||
expect(verdict.text).toMatch(/^미충족/);
|
||||
});
|
||||
|
||||
test('marketCapEok — 조/억 텍스트 파싱', () => {
|
||||
expect(marketCapEok('5,000억')).toBe(5000);
|
||||
expect(marketCapEok('1조 2,000억')).toBe(12000);
|
||||
expect(marketCapEok('2조')).toBe(20000);
|
||||
});
|
||||
|
||||
// ── v2.2.212 정밀화 규칙 ──────────────────────────────────────────────
|
||||
|
||||
test('ROE 레버리지 가드 — 부채비율 200% 면 ROE 15% 라도 미통과', () => {
|
||||
const stock: Stock = { 이름: '레버리지주', 심볼: '000010', 'ROE(25E)': '15%' };
|
||||
const evHigh = evaluateCriteria(stock, { symbol: '000010', roe: 15, debtRatio: 200 }, NOW);
|
||||
expect(evHigh.results.find(r => r.keyword === 'ROE')!.state).toBe('fail');
|
||||
const evLow = evaluateCriteria(stock, { symbol: '000010', roe: 15, debtRatio: 80 }, NOW);
|
||||
expect(evLow.results.find(r => r.keyword === 'ROE')!.state).toBe('pass');
|
||||
});
|
||||
|
||||
test('성장성 — 실측 YoY 가 있으면 마진 폴백 대신 YoY 로 판정', () => {
|
||||
const stock: Stock = { 이름: '진짜성장주', 심볼: '000011', '영업이익률(25E)': '5%' };
|
||||
// 마진 5% (폴백이면 fail) 이지만 매출 YoY 25% → pass
|
||||
const grow = evaluateCriteria(stock, { symbol: '000011', operatingMargin: 5, revenueGrowthYoY: 25 }, NOW);
|
||||
expect(grow.results.find(r => r.keyword === '성장성')!.state).toBe('pass');
|
||||
// 마진 18% (폴백이면 pass) 이지만 매출 YoY -5%·영업이익 YoY 3% → 실측 우선으로 fail
|
||||
const noGrow = evaluateCriteria(stock, { symbol: '000011', operatingMargin: 18, revenueGrowthYoY: -5, opProfitGrowthYoY: 3 }, NOW);
|
||||
expect(noGrow.results.find(r => r.keyword === '성장성')!.state).toBe('fail');
|
||||
});
|
||||
|
||||
test('안정성 부채 가드 — 유보율·시총 통과여도 부채비율 150% 면 미통과', () => {
|
||||
const stock: Stock = { 이름: '부채대형주', 심볼: '000012', 유보율: '4,000%', 시가총액: '1조' };
|
||||
const ev = evaluateCriteria(stock, { symbol: '000012', retentionRatio: 4000, marketCapEok: 10000, debtRatio: 150 }, NOW);
|
||||
expect(ev.results.find(r => r.keyword === '안정성')!.state).toBe('fail');
|
||||
});
|
||||
|
||||
test('PER 키워드 — ≤12배 통과, 저평가우량주 우선순위에 포함', () => {
|
||||
const { ev, verdict } = judge({
|
||||
이름: '저PER주', 심볼: '000013', 투자성향: '저평가우량주',
|
||||
'ROE(25E)': '11%', 'PER(25E)': '8', PBR: '0.9', 유보율: '1,200%',
|
||||
});
|
||||
expect(ev.results.find(r => r.keyword === 'PER')!.state).toBe('pass');
|
||||
// 통과: ROE, 유동성, PBR, PER → 우선순위 [PBR, PER, ROE, ...]
|
||||
expect(verdict.text).toBe('충족 (PBR, PER, ROE)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user