feat(retrieval): 청킹/평가 하니스 + 검색 인덱스 개선
- src/retrieval/chunker.ts: 문서 청킹 로직 추가 - src/retrieval/evalHarness.ts + src/extension/evalCommands.ts: 검색 품질 평가 하니스 - brainIndex.ts / retrieval/index.ts / memoryContext.ts: 인덱싱·컨텍스트 빌더 개선 - config.ts / extension.ts / sidebarProvider.ts / package.json 갱신 - ADR-0030~0032 및 개발 기록, .astra 런타임 상태 동기화 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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.207` _(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,464 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-06-01T02:30:44.120Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 428
|
||||
- **Time**: 2026-06-08T10:21:24.781Z
|
||||
- **Files newly analysed**: 0
|
||||
- **Files reused from cache**: 441
|
||||
|
||||
## 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,468 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-08T10:21:24.781Z · signature `e8d4a49a`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-06-01T02:30:44.174Z",
|
||||
"generatedAt": "2026-06-08T10:21:24.842Z",
|
||||
"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,
|
||||
@@ -725,13 +739,14 @@
|
||||
]
|
||||
},
|
||||
"src/extension.ts": {
|
||||
"mtimeMs": 1780280902153.7483,
|
||||
"size": 17342,
|
||||
"lines": 358,
|
||||
"mtimeMs": 1780909035802.1045,
|
||||
"size": 17590,
|
||||
"lines": 362,
|
||||
"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": 1780882651758.066,
|
||||
"size": 35067,
|
||||
"lines": 728,
|
||||
"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",
|
||||
@@ -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": 1780914060109.104,
|
||||
"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": 1780882719870,
|
||||
"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-08T01:38:39.872Z\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) (21ms)\n",
|
||||
"createdAt": 1780882719872,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-06-02T02:39:33.967Z",
|
||||
"totalElapsedMs": 28,
|
||||
"startTime": "2026-06-08T01:38:39.847Z",
|
||||
"totalElapsedMs": 26,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 25,
|
||||
"durationMs": 21,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-06-02T02:39:33.992Z"
|
||||
"ts": "2026-06-08T01:38:39.868Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 3,
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-02T02:39:33.995Z"
|
||||
"ts": "2026-06-08T01:38:39.873Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1780367980792,
|
||||
"createdAt": 1780882727041,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1780367980791,
|
||||
"createdAt": 1780882727039,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1780367980787,
|
||||
"createdAt": 1780882727035,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1780367980789,
|
||||
"createdAt": 1780882727037,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+10
-10
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1780367980767",
|
||||
"missionId": "stress_conflict_1780882727018",
|
||||
"status": "completed",
|
||||
"startTime": "2026-06-02T02:39:40.767Z",
|
||||
"totalElapsedMs": 26,
|
||||
"startTime": "2026-06-08T01:38:47.018Z",
|
||||
"totalElapsedMs": 24,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 19,
|
||||
"durationMs": 15,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-06-02T02:39:40.786Z"
|
||||
"ts": "2026-06-08T01:38:47.033Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 3,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-06-02T02:39:40.789Z"
|
||||
"ts": "2026-06-08T01:38:47.036Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 1,
|
||||
"durationMs": 2,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-06-02T02:39:40.790Z"
|
||||
"ts": "2026-06-08T01:38:47.038Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 2,
|
||||
"durationMs": 4,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-06-02T02:39:40.792Z"
|
||||
"ts": "2026-06-08T01:38:47.042Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-06-01T02:30:37.893Z"
|
||||
"updatedAt": "2026-06-08T10:21:00.108Z"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -572,6 +580,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,
|
||||
|
||||
@@ -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';
|
||||
@@ -267,6 +268,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 }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+215
-4
@@ -15,12 +15,13 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { tokenize, countConflictIndicators } from './scoring';
|
||||
import { detectLessonKind } from './lessonHelpers';
|
||||
import { splitIntoSections } from './chunker';
|
||||
import { logInfo } from '../utils';
|
||||
|
||||
// v4 adds optional per-file `embedding` for hybrid (sparse+dense) retrieval.
|
||||
// Older v3 indexes are auto-rebuilt on first load — no migration needed because
|
||||
// the cache is derivable from the brain itself.
|
||||
const INDEX_VERSION = 4;
|
||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
||||
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||
// needed because the cache is fully derivable from the brain itself.
|
||||
const INDEX_VERSION = 5;
|
||||
const INDEX_DIR = '.astra';
|
||||
const INDEX_FILE = 'brain-index.json';
|
||||
/** 인덱스가 이 개수를 넘으면 이번 스캔에서 못 본 항목을 정리합니다 (삭제된 파일 누적 방지). */
|
||||
@@ -45,11 +46,32 @@ interface IndexEntry {
|
||||
embedding?: number[];
|
||||
/** Embedding model the vector was produced with — invalidates the vector when the user switches models. */
|
||||
embeddingModel?: string;
|
||||
/**
|
||||
* Section-level chunks (Phase 1-가). 지연 계산 — chunk 모드 검색이 처음 요청할 때
|
||||
* `getBrainChunkIndex` 가 채운다. 파일이 바뀌면 (재색인 시 entry 가 새로 만들어져)
|
||||
* 자동으로 사라지므로 stale chunk 가 남지 않는다.
|
||||
*/
|
||||
chunks?: ChunkEntry[];
|
||||
}
|
||||
|
||||
interface ChunkEntry {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[]; // tokenize(`${title} ${headingPath} ${sectionText}`) — 문서 제목이 모든 청크에 기여
|
||||
headingTokens: string[]; // tokenize(`${title} ${headingPath}`)
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
/** 청크 단위 dense 벡터 (Phase 1-가 후속). 파일 단위보다 정밀. 지연 backfill. */
|
||||
embedding?: number[];
|
||||
/** 이 벡터를 만든 임베딩 모델 — 모델 변경 시 무효화. */
|
||||
embeddingModel?: string;
|
||||
}
|
||||
|
||||
interface PersistedIndex {
|
||||
version: number;
|
||||
entries: Record<string, IndexEntry>; // keyed by absolute file path
|
||||
/** chunks 를 어떤 targetChars 로 만들었는지 — 설정이 바뀌면 chunk 층을 재생성. */
|
||||
chunkTargetChars?: number;
|
||||
}
|
||||
|
||||
export interface IndexedBrainDoc {
|
||||
@@ -64,6 +86,23 @@ export interface IndexedBrainDoc {
|
||||
kind: string;
|
||||
}
|
||||
|
||||
/** Flat chunk view returned by `getBrainChunkIndex` — 한 파일이 여러 청크로 펼쳐진다. */
|
||||
export interface IndexedChunk {
|
||||
filePath: string;
|
||||
relativePath: string;
|
||||
title: string;
|
||||
/** 파일 내 청크 순번 (0-based). */
|
||||
chunkIndex: number;
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
tokens: string[];
|
||||
headingTokens: string[];
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
mtimeMs: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface BrainState {
|
||||
index: PersistedIndex;
|
||||
dirty: boolean;
|
||||
@@ -223,6 +262,99 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* lesson/playbook/qa-finding 카드는 통째로 한 청크 — 섹션 분할이 essence 추출을
|
||||
* 깨뜨리지 않도록. 일반 노트는 `splitIntoSections` 로 섹션 청크화. 문서 제목을 모든
|
||||
* 청크 토큰에 prepend 해 제목 매치 강도(파일 모드의 titleTokens)를 보존한다.
|
||||
*/
|
||||
function buildChunkEntries(entry: IndexEntry, content: string, targetChars: number): ChunkEntry[] {
|
||||
const whole = (): ChunkEntry[] => [{
|
||||
heading: entry.title,
|
||||
headingPath: [entry.title],
|
||||
tokens: entry.tokens,
|
||||
headingTokens: entry.titleTokens,
|
||||
charStart: 0,
|
||||
charEnd: content.length,
|
||||
}];
|
||||
if (entry.kind && entry.kind !== '') return whole();
|
||||
|
||||
const sections = splitIntoSections(content, {
|
||||
targetChars,
|
||||
minChars: Math.min(200, Math.floor(targetChars / 4)),
|
||||
maxChars: targetChars * 2,
|
||||
});
|
||||
if (sections.length === 0) return whole();
|
||||
|
||||
return sections.map((s) => {
|
||||
const headingText = [entry.title, ...s.headingPath].join(' ');
|
||||
return {
|
||||
heading: s.heading || entry.title,
|
||||
headingPath: s.headingPath.length ? s.headingPath : [entry.title],
|
||||
tokens: tokenize(`${headingText} ${s.text}`),
|
||||
headingTokens: tokenize(headingText),
|
||||
charStart: s.charStart,
|
||||
charEnd: s.charEnd,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Section-level chunk view (Phase 1-가). 먼저 `getBrainTokenIndex` 로 파일 entry 를
|
||||
* 최신화한 뒤, 각 파일의 chunk 층을 (없으면) 계산·캐시해서 flat 하게 펼쳐 반환한다.
|
||||
* `targetChars` 가 직전 빌드값과 다르면 전체 chunk 캐시를 버리고 재생성한다.
|
||||
* Steady-state(변경 없음 + 같은 target)에서는 디스크/CPU 작업 0.
|
||||
*/
|
||||
export function getBrainChunkIndex(brainPath: string, files: string[], targetChars: number): IndexedChunk[] {
|
||||
if (!brainPath || !Array.isArray(files) || files.length === 0) return [];
|
||||
// 1) 파일 entry 최신화 (토큰/메타/prune 까지 여기서 처리).
|
||||
getBrainTokenIndex(brainPath, files);
|
||||
const st = loadState(brainPath);
|
||||
|
||||
// 2) targetChars 변경 시 chunk 층 전체 무효화.
|
||||
if (st.index.chunkTargetChars !== targetChars) {
|
||||
for (const key of Object.keys(st.index.entries)) {
|
||||
const e = st.index.entries[key];
|
||||
if (e) e.chunks = undefined;
|
||||
}
|
||||
st.index.chunkTargetChars = targetChars;
|
||||
st.dirty = true;
|
||||
}
|
||||
|
||||
const out: IndexedChunk[] = [];
|
||||
let built = 0;
|
||||
for (const file of files) {
|
||||
const entry = st.index.entries[file];
|
||||
if (!entry) continue;
|
||||
if (!entry.chunks) {
|
||||
let content = '';
|
||||
try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }
|
||||
entry.chunks = buildChunkEntries(entry, content, targetChars);
|
||||
st.dirty = true;
|
||||
built++;
|
||||
}
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
out.push({
|
||||
filePath: file,
|
||||
relativePath: entry.relativePath,
|
||||
title: entry.title,
|
||||
chunkIndex: ci,
|
||||
heading: ch.heading,
|
||||
headingPath: ch.headingPath,
|
||||
tokens: ch.tokens,
|
||||
headingTokens: ch.headingTokens,
|
||||
charStart: ch.charStart,
|
||||
charEnd: ch.charEnd,
|
||||
mtimeMs: entry.mtimeMs,
|
||||
kind: entry.kind || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (built > 0) logInfo('Brain chunk index built.', { brainPath, files: files.length, filesChunked: built, totalChunks: out.length, targetChars });
|
||||
if (st.dirty) scheduleWrite(st, brainPath);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (filePath, embedding) for every file in `filePaths` that has a current
|
||||
* cached vector under `model`. Caller uses this to rank top TF-IDF candidates
|
||||
@@ -310,6 +442,85 @@ export async function backfillBrainEmbeddings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 청크 단위 임베딩 조회. `${filePath}#${chunkIndex}` → vector. 모델 불일치/미존재 청크는 생략.
|
||||
* searchBrainChunks 가 dense blend 에 사용 (파일 단위 공유보다 정밀).
|
||||
*/
|
||||
export function getBrainChunkEmbeddings(brainPath: string, model: string): Map<string, number[]> {
|
||||
const out = new Map<string, number[]>();
|
||||
if (!brainPath || !model.trim()) return out;
|
||||
const st = _states.get(brainPath);
|
||||
if (!st) return out;
|
||||
for (const [fp, entry] of Object.entries(st.index.entries)) {
|
||||
if (!entry?.chunks) continue;
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
if (!ch.embedding || ch.embeddingModel !== model) continue;
|
||||
if (!Array.isArray(ch.embedding) || ch.embedding.length === 0) continue;
|
||||
out.set(`${fp}#${ci}`, ch.embedding);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background fill — 주어진 `files` 의 청크 중 현재 모델 벡터가 없는 것만 임베딩한다.
|
||||
* 청크 텍스트는 캐시된 토큰에서 재구성(파일 단위 backfill 과 동일 전략 — 파일 재read 회피).
|
||||
* Fire-and-forget 용. 새로 임베딩한 청크 수를 반환.
|
||||
*/
|
||||
export async function backfillBrainChunkEmbeddings(
|
||||
brainPath: string,
|
||||
files: string[],
|
||||
model: string,
|
||||
embedFn: (texts: string[]) => Promise<number[][]>,
|
||||
targetChars: number,
|
||||
): Promise<number> {
|
||||
if (!brainPath || !model.trim() || !Array.isArray(files) || files.length === 0) return 0;
|
||||
// 청크 층 보장 (없으면 생성).
|
||||
getBrainChunkIndex(brainPath, files, targetChars);
|
||||
const st = _states.get(brainPath);
|
||||
if (!st) return 0;
|
||||
|
||||
const texts: string[] = [];
|
||||
const refs: Array<{ fp: string; ci: number }> = [];
|
||||
for (const fp of files) {
|
||||
const entry = st.index.entries[fp];
|
||||
if (!entry?.chunks) continue;
|
||||
for (let ci = 0; ci < entry.chunks.length; ci++) {
|
||||
const ch = entry.chunks[ci];
|
||||
if (ch.embedding && ch.embeddingModel === model) continue;
|
||||
const text = Array.isArray(ch.tokens) && ch.tokens.length > 0 ? ch.tokens.join(' ') : '';
|
||||
if (!text.trim()) continue;
|
||||
texts.push(text);
|
||||
refs.push({ fp, ci });
|
||||
}
|
||||
}
|
||||
if (texts.length === 0) return 0;
|
||||
try {
|
||||
const vectors = await embedFn(texts);
|
||||
let n = 0;
|
||||
for (let i = 0; i < vectors.length && i < refs.length; i++) {
|
||||
const v = vectors[i];
|
||||
if (!Array.isArray(v) || v.length === 0) continue;
|
||||
const entry = st.index.entries[refs[i].fp];
|
||||
const ch = entry?.chunks?.[refs[i].ci];
|
||||
if (!ch) continue;
|
||||
ch.embedding = v;
|
||||
ch.embeddingModel = model;
|
||||
st.dirty = true;
|
||||
n++;
|
||||
}
|
||||
if (n > 0) {
|
||||
logInfo('Brain chunk embeddings backfilled.', { brainPath, model, embedded: n });
|
||||
scheduleWrite(st, brainPath);
|
||||
}
|
||||
return n;
|
||||
} catch (e: any) {
|
||||
logInfo('Brain chunk embedding backfill failed (TF-IDF still works).', { brainPath, model, error: e?.message ?? String(e) });
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the in-memory index (and pending write) for one brain, or all brains. The disk file is left as-is. */
|
||||
export function clearBrainTokenIndex(brainPath?: string): void {
|
||||
if (brainPath === undefined) {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* Markdown Section Chunker (Phase 1-가)
|
||||
*
|
||||
* 긴 노트를 `#`~`######` 헤딩 경계로 *섹션 청크* 로 나눈다. 파일 단위 색인은 5000자
|
||||
* 다주제 문서를 하나의 흐릿한 단위로 만들어 검색 정밀도를 떨어뜨린다 — 섹션 단위로
|
||||
* 쪼개면 질의가 정확히 해당 섹션에 매치된다 (제2뇌의 "문서 청킹 전략" 지식 그대로).
|
||||
*
|
||||
* 규칙:
|
||||
* - 각 헤딩 ~ 다음 헤딩 직전까지가 raw 섹션. 첫 헤딩 이전 본문(preamble)도 한 섹션.
|
||||
* - 헤딩 breadcrumb(상위 헤딩 경로)을 함께 보존 → 청크가 문맥을 잃지 않음.
|
||||
* - minChars 미만의 짧은 섹션은 다음 섹션과 병합(헤딩만 있고 본문 적은 경우 흔함).
|
||||
* - targetChars 초과 누적 시 청크 확정. maxChars 초과 단일 섹션은 문단 경계로 재분할.
|
||||
*
|
||||
* 순수 함수 (fs/네트워크 의존 없음) — 단위 테스트·재현 용이.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
export interface Section {
|
||||
/** 이 섹션의 헤딩 텍스트 ('' = preamble). */
|
||||
heading: string;
|
||||
/** 루트→자기까지 헤딩 경로 (문맥용 breadcrumb). preamble 이면 []. */
|
||||
headingPath: string[];
|
||||
/** 섹션 본문(헤딩 라인 포함, 원문 그대로). */
|
||||
text: string;
|
||||
/** 원문 내 시작/끝 문자 오프셋 (디버그/추적용). */
|
||||
charStart: number;
|
||||
charEnd: number;
|
||||
}
|
||||
|
||||
export interface ChunkOptions {
|
||||
/** 청크 목표 길이. 누적이 이 값을 넘으면 확정. 기본 1200. */
|
||||
targetChars: number;
|
||||
/** 이보다 짧은 섹션은 다음과 병합. 기본 200. */
|
||||
minChars: number;
|
||||
/** 단일 청크가 이보다 길면 문단 경계로 재분할. 기본 = targetChars * 2. */
|
||||
maxChars: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: ChunkOptions = { targetChars: 1200, minChars: 200, maxChars: 2400 };
|
||||
|
||||
interface RawSection {
|
||||
heading: string;
|
||||
headingPath: string[];
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const HEADING_RE = /^(#{1,6})[ \t]+(.+?)[ \t]*#*$/;
|
||||
|
||||
/**
|
||||
* 원문을 헤딩 경계 raw 섹션으로 분해. fenced code block(```) 안의 `#` 라인은
|
||||
* 헤딩으로 보지 않는다 (코드 주석이 섹션을 깨는 것 방지).
|
||||
*/
|
||||
function parseRawSections(content: string): RawSection[] {
|
||||
const lines = content.split('\n');
|
||||
const sections: RawSection[] = [];
|
||||
const stack: Array<{ level: number; title: string }> = [];
|
||||
|
||||
let offset = 0;
|
||||
let curStart = 0;
|
||||
let curHeading = '';
|
||||
let curPath: string[] = [];
|
||||
let inFence = false;
|
||||
let started = false;
|
||||
|
||||
const pushCurrent = (end: number) => {
|
||||
if (!started) return;
|
||||
sections.push({ heading: curHeading, headingPath: [...curPath], start: curStart, end });
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const lineStart = offset;
|
||||
offset += line.length + 1; // +1 for the '\n' we split on
|
||||
|
||||
const fence = line.trimStart().startsWith('```');
|
||||
if (fence) { inFence = !inFence; }
|
||||
|
||||
const m = !inFence ? line.match(HEADING_RE) : null;
|
||||
if (m) {
|
||||
// close previous section at this heading's start
|
||||
pushCurrent(lineStart);
|
||||
const level = m[1].length;
|
||||
const title = m[2].trim();
|
||||
// maintain breadcrumb stack by level
|
||||
while (stack.length && stack[stack.length - 1].level >= level) stack.pop();
|
||||
stack.push({ level, title });
|
||||
curStart = lineStart;
|
||||
curHeading = title;
|
||||
curPath = stack.map((s) => s.title);
|
||||
started = true;
|
||||
} else if (!started) {
|
||||
// preamble before the first heading
|
||||
started = true;
|
||||
curStart = 0;
|
||||
curHeading = '';
|
||||
curPath = [];
|
||||
}
|
||||
}
|
||||
pushCurrent(content.length);
|
||||
return sections.filter((s) => s.end > s.start);
|
||||
}
|
||||
|
||||
/** 긴 텍스트를 문단(\n\n) 경계로 target 길이 이하 조각으로. 단일 문단이 maxChars 초과면 하드 컷. */
|
||||
function splitLongText(text: string, target: number, maxChars: number): string[] {
|
||||
if (text.length <= maxChars) return [text];
|
||||
const paras = text.split(/\n{2,}/);
|
||||
const pieces: string[] = [];
|
||||
let buf = '';
|
||||
const flush = () => { if (buf.trim()) pieces.push(buf); buf = ''; };
|
||||
for (const para of paras) {
|
||||
if (para.length > maxChars) {
|
||||
flush();
|
||||
// hard slice a giant paragraph
|
||||
for (let i = 0; i < para.length; i += target) pieces.push(para.slice(i, i + target));
|
||||
continue;
|
||||
}
|
||||
if (buf && (buf.length + para.length + 2) > target) flush();
|
||||
buf = buf ? `${buf}\n\n${para}` : para;
|
||||
}
|
||||
flush();
|
||||
return pieces.length ? pieces : [text];
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 섹션 청크로 분해. 짧은 섹션 병합 + 긴 섹션 재분할 적용.
|
||||
* 결과가 비면(빈 파일 등) 전체를 한 청크로 반환.
|
||||
*/
|
||||
export function splitIntoSections(content: string, opts?: Partial<ChunkOptions>): Section[] {
|
||||
const o: ChunkOptions = { ...DEFAULTS, ...(opts || {}) };
|
||||
if (o.maxChars < o.targetChars) o.maxChars = o.targetChars * 2;
|
||||
|
||||
const raw = parseRawSections(content);
|
||||
if (raw.length === 0) {
|
||||
const t = content.trim();
|
||||
return t ? [{ heading: '', headingPath: [], text: content, charStart: 0, charEnd: content.length }] : [];
|
||||
}
|
||||
|
||||
// 1) 짧은 섹션 병합 — 연속이므로 [firstStart, lastEnd] 로 span 유지.
|
||||
const merged: RawSection[] = [];
|
||||
let buf: RawSection | null = null;
|
||||
for (const s of raw) {
|
||||
if (!buf) { buf = { ...s }; continue; }
|
||||
const bufLen = buf.end - buf.start;
|
||||
if (bufLen < o.minChars) {
|
||||
buf = { heading: buf.heading, headingPath: buf.headingPath, start: buf.start, end: s.end };
|
||||
} else {
|
||||
merged.push(buf);
|
||||
buf = { ...s };
|
||||
}
|
||||
if ((buf.end - buf.start) >= o.targetChars) { merged.push(buf); buf = null; }
|
||||
}
|
||||
if (buf) merged.push(buf);
|
||||
|
||||
// 2) 긴 섹션 재분할 + Section 객체화.
|
||||
const out: Section[] = [];
|
||||
for (const s of merged) {
|
||||
const text = content.slice(s.start, s.end);
|
||||
if (text.length <= o.maxChars) {
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text, charStart: s.start, charEnd: s.end });
|
||||
continue;
|
||||
}
|
||||
let cursor = s.start;
|
||||
for (const piece of splitLongText(text, o.targetChars, o.maxChars)) {
|
||||
const idx = content.indexOf(piece, cursor);
|
||||
const start = idx >= 0 ? idx : cursor;
|
||||
const end = start + piece.length;
|
||||
out.push({ heading: s.heading, headingPath: s.headingPath, text: piece, charStart: start, charEnd: end });
|
||||
cursor = end;
|
||||
}
|
||||
}
|
||||
return out.filter((s) => s.text.trim().length > 0);
|
||||
}
|
||||
@@ -0,0 +1,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';
|
||||
|
||||
+42
-23
@@ -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');
|
||||
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() {
|
||||
try {
|
||||
const selected = await vscode.window.showOpenDialog({
|
||||
canSelectFiles: false,
|
||||
canSelectFolders: true,
|
||||
canSelectMany: false,
|
||||
openLabel: 'Use as Brain'
|
||||
openLabel: '이 폴더를 두뇌로 사용'
|
||||
});
|
||||
|
||||
const folder = selected?.[0]?.fsPath;
|
||||
if (!folder) return;
|
||||
if (!folder) return; // 폴더 선택 취소 — 정상 종료 (에러 아님)
|
||||
|
||||
// 구조 개선: 예전엔 폴더 선택 후 이름·설명·repo 입력창 3개가 연속으로 떴고, '이름' 입력창을
|
||||
// Esc/바깥클릭으로 닫으면 `if (!name) return` 으로 전체 추가가 *조용히* 취소됐다. 이것이
|
||||
// "추가가 안 된다" 의 주원인. 이제 폴더만 있으면 추가가 보장되고, 이름은 비우거나 취소해도
|
||||
// 폴더명으로 진행한다. 설명/repo 는 추가 후 [수정] 에서 채운다 (다이얼로그 체인 최소화).
|
||||
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.'
|
||||
const nameInput = await vscode.window.showInputBox({
|
||||
prompt: '두뇌 이름 (비워두면 폴더명 사용)',
|
||||
value: defaultName
|
||||
});
|
||||
if (!name) return;
|
||||
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.
|
||||
// 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: name.trim(),
|
||||
name,
|
||||
localBrainPath: folder,
|
||||
secondBrainRepo: (repo || '').trim(),
|
||||
description: (description || '').trim()
|
||||
secondBrainRepo: '',
|
||||
description: ''
|
||||
};
|
||||
const nextProfiles = [...existingRaw, newProfile];
|
||||
await this._commitBrainProfileChange(nextProfiles, id, `**[Brain Added]** ${name.trim()}\n\`${folder}\``);
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user