Compare commits
8 Commits
26cde3afb0
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d6b8b509f | |||
| a31d273bfe | |||
| 6e4de611e6 | |||
| 5feacb4799 | |||
| 137b83d8b6 | |||
| 47b3b9f93a | |||
| 98d533f045 | |||
| 443488a3b6 |
@@ -3,15 +3,15 @@
|
|||||||
<!-- ASTRA:AUTO-START -->
|
<!-- ASTRA:AUTO-START -->
|
||||||
|
|
||||||
## Snapshot
|
## Snapshot
|
||||||
- **Workspace**: `connectai` `v2.2.248` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
- **Workspace**: `connectai` `v2.2.312` _(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.
|
- **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
|
- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
|
||||||
- **Stats**: 560 source files, ~83,596 lines across 5 top-level modules.
|
- **Stats**: 617 source files, ~90,403 lines across 5 top-level modules.
|
||||||
|
|
||||||
## Last Refresh
|
## Last Refresh
|
||||||
- **Time**: 2026-06-15T02:56:35.947Z
|
- **Time**: 2026-07-20T04:20:03.384Z
|
||||||
- **Files newly analysed**: 1
|
- **Files newly analysed**: 1
|
||||||
- **Files reused from cache**: 559
|
- **Files reused from cache**: 616
|
||||||
|
|
||||||
## Directory Map
|
## Directory Map
|
||||||
```mermaid
|
```mermaid
|
||||||
@@ -19,11 +19,11 @@ mindmap
|
|||||||
root((connectai))
|
root((connectai))
|
||||||
src/
|
src/
|
||||||
features/
|
features/
|
||||||
sidebar/
|
|
||||||
lib/
|
lib/
|
||||||
agent/
|
agent/
|
||||||
intelligence/
|
sidebar/
|
||||||
retrieval/
|
retrieval/
|
||||||
|
intelligence/
|
||||||
media/
|
media/
|
||||||
tests/
|
tests/
|
||||||
helpers/
|
helpers/
|
||||||
@@ -41,11 +41,11 @@ mindmap
|
|||||||
> Arrows: which top-level module imports from which.
|
> Arrows: which top-level module imports from which.
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
src["src/<br/>317 files"]
|
src["src/<br/>342 files"]
|
||||||
media["media/<br/>6 files"]
|
media["media/<br/>6 files"]
|
||||||
tests["tests/<br/>58 files"]
|
tests["tests/<br/>86 files"]
|
||||||
core_py["core_py/<br/>6 files"]
|
core_py["core_py/<br/>6 files"]
|
||||||
docs["docs/<br/>173 files"]
|
docs["docs/<br/>177 files"]
|
||||||
tests --> src
|
tests --> src
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -57,71 +57,71 @@ flowchart LR
|
|||||||
|
|
||||||
## Hub Files
|
## Hub Files
|
||||||
> Imported by many other files — touching these has wide blast radius.
|
> Imported by many other files — touching these has wide blast radius.
|
||||||
- `src/utils.ts` — referenced by **102** files
|
- `src/utils.ts` — referenced by **116** files
|
||||||
- `src/config.ts` — referenced by **38** files
|
- `src/config.ts` — referenced by **44** files
|
||||||
- `src/agent.ts` — referenced by **34** files
|
- `src/agent.ts` — referenced by **37** files
|
||||||
- `src/core/services.ts` — referenced by **17** files
|
- `src/core/services.ts` — referenced by **23** files
|
||||||
- `src/features/company/index.ts` — referenced by **14** files · Public API for 1인 기업 모드. Consumers (sidebarProvider, chatHandlers, command handlers) import from this barrel so internal layout can move around without touching every call site.
|
- `src/retrieval/brainIndex.ts` — referenced by **14** files · Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||||
- `src/features/company/types.ts` — referenced by **14** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
- `src/agent/actions/types.ts` — referenced by **13** files
|
||||||
- `src/retrieval/brainIndex.ts` — referenced by **12** files · Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
- `src/integrations/telegram/telegramClient.ts` — referenced by **13** files
|
||||||
- `src/integrations/telegram/telegramClient.ts` — referenced by **12** files
|
- `src/features/company/types.ts` — referenced by **13** files · Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
||||||
|
|
||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
### `src/` — 317 files, ~61,246 lines
|
### `src/` — 342 files, ~65,381 lines
|
||||||
|
|
||||||
**Sub-directories**
|
**Sub-directories**
|
||||||
- `src/features/` (112) — Astra Office — public API. 다음 세션에서 추가될 OfficeSnapshot presenter / schema 도 같은 entry 로 노출 예정. 현재 노출: full webview panel H
|
- `src/features/` (126) — 데일리 브리핑 워처 — 평일(월~금) KST 09:30 에 "오늘의 할 일"을 텔레그램으로 발송. 소스: 1. Google Calendar — iCal 캐시 새로고침 후 오늘 일정 (readCalendarEvents
|
||||||
- `src/sidebar/` (35) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
- `src/lib/` (37) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||||
- `src/lib/` (33) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
- `src/agent/` (36) — <investigatefiles path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사. listfiles 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||||
- `src/agent/` (31) — 한·영 깨진 토큰 감지·수리 — 소형 로컬 모델의 토큰 붕괴 보정. 증상: 한국어 단어 중간에 영문 토큰이 섞임 — "덩어리"→"덩ey", "결과적으로"→"결ently". 프롬프트 규칙([출력 위생])으로는 못 막는
|
- `src/sidebar/` (32) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||||
- `src/intelligence/` (18) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
- `src/retrieval/` (20) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||||
- `src/retrieval/` (18) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
- `src/intelligence/` (19) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
||||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||||
- `src/extension/` (13) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
- `src/extension/` (13) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
||||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||||
|
- `src/integrations/` (7) — Per-chat conversation history for the Telegram bot. Why this exists: the previous bot was stateless — every inbound mess
|
||||||
- `src/docs/` (6) — Bug: Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.ts Edited agent.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) — [메모리·기동 개선] SDK 를 타입 전용으로만 import — 값 import 는 번들 로드(=확장 활성화) 시점에 SDK 모듈 초기화를 실행시킨다. 실제 모듈 로드는 getSdk() 첫 호출 때 require 로
|
||||||
- `src/lmstudio/` (4) — 4 files (.ts)
|
|
||||||
|
|
||||||
**Key files**
|
**Key files**
|
||||||
- `src/utils.ts` (485 lines)
|
- `src/utils.ts` (538 lines)
|
||||||
- `src/config.ts` (661 lines)
|
- `src/config.ts` (727 lines)
|
||||||
- `src/agent.ts` (1677 lines)
|
- `src/agent.ts` (1979 lines)
|
||||||
|
- `src/core/services.ts` (218 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/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` (2992 lines)
|
||||||
- `src/sidebarProvider.ts` (3468 lines)
|
|
||||||
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
|
||||||
- `src/agent/actions/types.ts` (41 lines)
|
- `src/agent/actions/types.ts` (41 lines)
|
||||||
- `src/lib/contextManager.ts` (278 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
- `src/integrations/telegram/telegramClient.ts` (154 lines)
|
||||||
- `src/retrieval/brainIndex.ts` (566 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
- `src/lib/contextManager.ts` (296 lines) — Context Manager (컨텍스트 한계 관리) "context length = 132k" 는 "답변을 132k 토큰까지 생성해도 된다" 가 아닙니다. 시스템 프롬프트 + 대화 기록 + 입력 문서 + 생성될 답변 + 여유분 ≤ context length 이 모듈은 요청을 보내기 전에 입력 토큰을 추정하고, - 동적으로 출력 상한(maxTokens)을 계
|
||||||
|
- `src/retrieval/brainIndex.ts` (645 lines) — Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||||
|
- `src/features/datacollect/slashRouter.ts` (211 lines)
|
||||||
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||||
- `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/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/stocks/types.ts` (61 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
|
||||||
- `src/lib/paths.ts` (151 lines)
|
- `src/lib/paths.ts` (151 lines)
|
||||||
- `src/skills/agentKnowledgeMap.ts` (374 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/retrieval/types.ts` (66 lines) — Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다.
|
||||||
- `src/intelligence/requirementGraph.ts` (273 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
- `src/intelligence/requirementGraph.ts` (312 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
||||||
|
- `src/security.ts` (159 lines)
|
||||||
- `src/lib/contextBuilders/promptDetection.ts` (107 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
|
- `src/lib/contextBuilders/promptDetection.ts` (107 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
|
||||||
- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||||
- `src/features/stocks/types.ts` (53 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
|
|
||||||
- `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/intelligence/confidenceEngine.ts` (165 lines) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정 기반 — Escalation Engine 의 입력. 설계 원칙 (termValidator 와 동일): LLM 호출 없음. 검색 그라운딩 신호(턴
|
|
||||||
- `src/intelligence/reflectionStore.ts` (162 lines) — Reflection Store — 업무 turn 회고 기록 + Failure Pattern 집계. Self-Evolving OS 마스터 플랜 Phase 1 / Track 2-4 (Reflection Engine v1) + Phase 3 / Track 3-6 (Failure Pattern DB v1 시드). 신뢰 조건 T5 "같은 실수를 반복하지 않는다" 의
|
|
||||||
- `src/extension/telegramCommands.ts` (103 lines)
|
- `src/extension/telegramCommands.ts` (103 lines)
|
||||||
- `src/security.ts` (159 lines)
|
- `src/lib/execUtil.ts` (104 lines)
|
||||||
|
- `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/features/calendar/calendarCache.ts` (271 lines) — Google Calendar (iCal) 캐시 — fetch + parse + 회사 shared/calendarcache.md 에 저장. Connectorigin 의 googlecalendar.py 를 TypeScript / native fetch 로 옮김. OAuth 없음. 사용자가 Google Calendar 설정 → "비공개 주소(iCal 형식)" 복
|
||||||
|
|
||||||
### `media/` — 6 files, ~7,836 lines
|
### `media/` — 6 files, ~7,582 lines
|
||||||
|
|
||||||
**Key files**
|
**Key files**
|
||||||
- `media/sidebar.css` (2132 lines) — Stylesheet
|
- `media/sidebar.css` (1957 lines) — Stylesheet
|
||||||
- `media/sidebar.js` (3966 lines)
|
- `media/sidebar.js` (3767 lines)
|
||||||
- `media/sidebar.html` (539 lines) — Astra
|
- `media/settings-panel.html` (541 lines) — Astra Settings
|
||||||
- `media/settings-panel.html` (440 lines) — Astra Settings
|
- `media/sidebar.html` (486 lines) — Astra
|
||||||
- `media/settings-panel.js` (505 lines)
|
- `media/settings-panel.js` (577 lines)
|
||||||
- `media/settings-panel.css` (254 lines) — Stylesheet
|
- `media/settings-panel.css` (254 lines) — Stylesheet
|
||||||
|
|
||||||
### `tests/` — 58 files, ~8,276 lines
|
### `tests/` — 86 files, ~11,085 lines
|
||||||
*Depends on*: `src/`
|
*Depends on*: `src/`
|
||||||
|
|
||||||
**Sub-directories**
|
**Sub-directories**
|
||||||
@@ -132,29 +132,29 @@ flowchart LR
|
|||||||
**Key files**
|
**Key files**
|
||||||
- `tests/helpers/mockLLMClient.ts` (112 lines) — MockLLMClient — IAIService 의 Mock 구현체. 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 경로를 CI 환경에서도 테스트 가능하게. 실제 Ollama / LM Studio 없이도 응답을 미리 정의하거나 동적으로 생성 가능. 사용 예: const ai = new
|
- `tests/helpers/mockLLMClient.ts` (112 lines) — MockLLMClient — IAIService 의 Mock 구현체. 의도: 회사 모드 dispatcher / ChunkedWriter / ceoPlanner 등 LLM 을 호출하는 코드 경로를 CI 환경에서도 테스트 가능하게. 실제 Ollama / LM Studio 없이도 응답을 미리 정의하거나 동적으로 생성 가능. 사용 예: const ai = new
|
||||||
- `tests/agentEngine.test.ts` (413 lines) — AgentEngine Tests — Chunked Writer Architecture 예전 buildup(planner → researcher → reflector → writer → synthesizer)을 단일 ChunkedWriter 의 outline → section[N] → polish 로 교체한 뒤의 회귀 테스트. 다루는 범위: 1. ErrorC
|
- `tests/agentEngine.test.ts` (413 lines) — AgentEngine Tests — Chunked Writer Architecture 예전 buildup(planner → researcher → reflector → writer → synthesizer)을 단일 ChunkedWriter 의 outline → section[N] → polish 로 교체한 뒤의 회귀 테스트. 다루는 범위: 1. ErrorC
|
||||||
- `tests/lmStudioLifecycle.test.ts` (330 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire
|
- `tests/lmStudioLifecycle.test.ts` (334 lines) — Unit tests for ModelLifecycleManager. Strategy: inject mock ILMStudioClient and a simple in-memory IActivityTracker. No real LM Studio or SDK is touched — the manager file does not import the SDK dire
|
||||||
- `tests/localPathPreflight.test.ts` (520 lines)
|
- `tests/localPathPreflight.test.ts` (520 lines)
|
||||||
- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur
|
- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur
|
||||||
- `tests/criticReflectionEval.test.ts` (200 lines) — Critic Agent / Reflection Store / Task Eval Harness (Self-Evolving OS P1 잔여 + P3) 테스트.
|
- `tests/criticReflectionEval.test.ts` (200 lines) — Critic Agent / Reflection Store / Task Eval Harness (Self-Evolving OS P1 잔여 + P3) 테스트.
|
||||||
- `tests/lmStudioStreamer.test.ts` (222 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
|
- `tests/emailCommand.test.ts` (282 lines) — v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트. COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정.
|
||||||
|
- `tests/lmStudioStreamer.test.ts` (295 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
|
||||||
|
- `tests/meetRegistration.test.ts` (232 lines) — /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
||||||
- `tests/secondBrainTrace.test.ts` (407 lines)
|
- `tests/secondBrainTrace.test.ts` (407 lines)
|
||||||
- `tests/approvalQueue.test.ts` (164 lines) — Unit tests for ApprovalQueue. Strategy: drive enqueue → approve / reject / clear / pre-empt directly, confirm the onChange event fires at the right moments and callbacks fire exactly once.
|
- `tests/approvalQueue.test.ts` (164 lines) — Unit tests for ApprovalQueue. Strategy: drive enqueue → approve / reject / clear / pre-empt directly, confirm the onChange event fires at the right moments and callbacks fire exactly once.
|
||||||
|
- `tests/compositeScore.test.ts` (131 lines) — 멀티팩터 합성점수 (v2.2.266) — 백분위 랭크·축 합성·value trap 가드·결측 처리.
|
||||||
|
- `tests/computeBudgetedRequest.test.ts` (147 lines) — Phase 1 — context-window alignment. The budgeter must clamp to the model's ACTUALLY-loaded window when it's smaller than the user's contextLength setting, so a model loaded with a smaller window than
|
||||||
- `tests/confidenceEngine.test.ts` (175 lines) — Confidence Engine + Escalation Engine (Self-Evolving OS Phase 2) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
- `tests/confidenceEngine.test.ts` (175 lines) — Confidence Engine + Escalation Engine (Self-Evolving OS Phase 2) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||||
- `tests/correctionLoop.test.ts` (158 lines) — Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증.
|
- `tests/correctionLoop.test.ts` (158 lines) — Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증.
|
||||||
|
- `tests/emailState.test.ts` (121 lines) — v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState). "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단.
|
||||||
|
- `tests/investigationPipeline.test.ts` (193 lines) — Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||||
- `tests/knowledgeOps.test.ts` (174 lines) — Knowledge Validation / Belief Revision / Decay / Debt (Self-Evolving OS Phase 4 — 지식 운영) 테스트.
|
- `tests/knowledgeOps.test.ts` (174 lines) — Knowledge Validation / Belief Revision / Decay / Debt (Self-Evolving OS Phase 4 — 지식 운영) 테스트.
|
||||||
- `tests/meetRegistration.test.ts` (110 lines) — /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
- `tests/largeInputMapReduce.test.ts` (159 lines) — Phase 2 — large-input map-reduce core. Pure orchestration with an injected callLLM, so no network / SDK is touched.
|
||||||
- `tests/needEngineQueue.test.ts` (159 lines) — Gap Detector / Need Engine / Knowledge Inventory / Learning Queue (Self-Evolving OS Phase 3 — 성장 루프 코어) 테스트.
|
- `tests/needEngineQueue.test.ts` (159 lines) — Gap Detector / Need Engine / Knowledge Inventory / Learning Queue (Self-Evolving OS Phase 3 — 성장 루프 코어) 테스트.
|
||||||
- `tests/projectScaffolder.test.ts` (135 lines) — Unit tests for FileSystemProjectScaffolder. Drives against a real temp directory so end-to-end file IO + path-traversal defenses are exercised.
|
- `tests/projectScaffolder.test.ts` (135 lines) — Unit tests for FileSystemProjectScaffolder. Drives against a real temp directory so end-to-end file IO + path-traversal defenses are exercised.
|
||||||
- `tests/requirementGraph.test.ts` (126 lines) — Requirement Graph (Self-Evolving OS Phase 1 / Track 2-1) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
- `tests/requirementGraph.test.ts` (146 lines) — Requirement Graph (Self-Evolving OS Phase 1 / Track 2-1) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||||
- `tests/researchSkill.test.ts` (122 lines) — Research Agent / Skill Score / Success Pattern DB (Self-Evolving OS Phase 6) 테스트.
|
- `tests/researchSkill.test.ts` (122 lines) — Research Agent / Skill Score / Success Pattern DB (Self-Evolving OS Phase 6) 테스트.
|
||||||
- `tests/resilience_stress.test.ts` (197 lines) — Resilience & Boundary Stress Test Suite (v2.77.3) 이 테스트는 ConnectAI 엔진이 극한의 환경(인증 실패, 네트워크 차단, 타임아웃 등)에서 얼마나 안정적으로 복구되고, 신뢰성 지표(Resilience Metrics)를 정확히 기록하는지 검증합니다.
|
- `tests/resilience_stress.test.ts` (197 lines) — Resilience & Boundary Stress Test Suite (v2.77.3) 이 테스트는 ConnectAI 엔진이 극한의 환경(인증 실패, 네트워크 차단, 타임아웃 등)에서 얼마나 안정적으로 복구되고, 신뢰성 지표(Resilience Metrics)를 정확히 기록하는지 검증합니다.
|
||||||
- `tests/retrievalEvalEmbedding.test.ts` (106 lines) — 하이브리드(sparse+dense) 검색 측정 — 청크 TF-IDF vs 청크+임베딩 (alpha sweep). 평소 테스트 런에서는 skip (실제 두뇌 + 로컬 임베딩 서버 필요). 수동 실행: ASTRAEVALBRAIN="E:/Wiki/2nd/10Wiki/Topics" \ ASTRAEVALEMBEDMODEL="text-embedding-nomic-em
|
- `tests/retrievalEvalEmbedding.test.ts` (106 lines) — 하이브리드(sparse+dense) 검색 측정 — 청크 TF-IDF vs 청크+임베딩 (alpha sweep). 평소 테스트 런에서는 skip (실제 두뇌 + 로컬 임베딩 서버 필요). 수동 실행: ASTRAEVALBRAIN="E:/Wiki/2nd/10Wiki/Topics" \ ASTRAEVALEMBEDMODEL="text-embedding-nomic-em
|
||||||
- `tests/skillInjectionService.test.ts` (172 lines) — Unit tests for FileSystemSkillInjectionService. Strategy: drive the service against a real temp directory so path-traversal defenses and writeFileSync paths are exercised end-to-end. The service accep
|
|
||||||
- `tests/sleepDigest.test.ts` (101 lines) — Sleep-time 사전 소화 — 순수 로직 테스트 (대상 선정·노후화 판정·노트 형식). LLM 호출(runSleepDigestOnce)은 제외 — 통합 검증은 수동 명령으로.
|
|
||||||
- `tests/stocksCriteria.test.ts` (129 lines) — criteriaEval — /stocks judge 결정론 평가기 테스트. 픽스처는 옛 LLM 프롬프트에 명시돼 있던 사용자의 실제 분류 예시 3종 (마녀공장/기가비스/엔켐) — 코드 판정이 사용자 패턴과 일치해야 한다.
|
|
||||||
- `tests/alignmentResearch.test.ts` (208 lines)
|
|
||||||
- `tests/conflictCheck.test.ts` (65 lines) — Schedule Conflict Check (Self-Evolving OS Track 6-2/6-3) 테스트.
|
|
||||||
- `tests/dataProcessor.test.ts` (87 lines) — / <reference types="jest" />
|
|
||||||
|
|
||||||
### `core_py/` — 6 files, ~409 lines
|
### `core_py/` — 6 files, ~409 lines
|
||||||
|
|
||||||
@@ -166,16 +166,16 @@ flowchart LR
|
|||||||
- `core_py/optimizer.py` (55 lines)
|
- `core_py/optimizer.py` (55 lines)
|
||||||
- `core_py/queue_worker.py` (82 lines)
|
- `core_py/queue_worker.py` (82 lines)
|
||||||
|
|
||||||
### `docs/` — 173 files, ~5,829 lines
|
### `docs/` — 177 files, ~5,946 lines
|
||||||
|
|
||||||
**Sub-directories**
|
**Sub-directories**
|
||||||
- `docs/records/` (157) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
- `docs/records/` (160) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||||
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||||
- `docs/plans/` (2) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
- `docs/plans/` (2) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||||
- `docs/Meeting/` (0)
|
- `docs/Meeting/` (0)
|
||||||
|
|
||||||
**Key files**
|
**Key files**
|
||||||
- `docs/records/ConnectAI/timeline.md` (422 lines) — Project Timeline
|
- `docs/records/ConnectAI/timeline.md` (431 lines) — Project Timeline
|
||||||
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
||||||
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
|
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
|
||||||
- `docs/SELF_EVOLVING_OS_MASTER_PLAN.md` (275 lines) — ASTRA Self-Evolving Digital Employee OS — 마스터 개발 계획 v1.1
|
- `docs/SELF_EVOLVING_OS_MASTER_PLAN.md` (275 lines) — ASTRA Self-Evolving Digital Employee OS — 마스터 개발 계획 v1.1
|
||||||
@@ -209,6 +209,7 @@ flowchart LR
|
|||||||
- `g1nation.eval.retrieval` — Astra: 검색 평가 실행 (recall@k / MRR)
|
- `g1nation.eval.retrieval` — Astra: 검색 평가 실행 (recall@k / MRR)
|
||||||
- `g1nation.eval.tasks` — Astra: 업무 평가 실행 (회의록 골든셋)
|
- `g1nation.eval.tasks` — Astra: 업무 평가 실행 (회의록 골든셋)
|
||||||
- `g1nation.growth.report` — Astra: 성장 리포트 (Reflection 추이)
|
- `g1nation.growth.report` — Astra: 성장 리포트 (Reflection 추이)
|
||||||
|
- `g1nation.growth.standingRules` — Astra: 상시 행동 규칙 (Standing Rules)
|
||||||
- `g1nation.growth.learningQueue` — Astra: 학습 큐 갱신 (Need Engine)
|
- `g1nation.growth.learningQueue` — Astra: 학습 큐 갱신 (Need Engine)
|
||||||
- `g1nation.knowledge.decayAudit` — Astra: 지식 노후 점검 (Knowledge Decay)
|
- `g1nation.knowledge.decayAudit` — Astra: 지식 노후 점검 (Knowledge Decay)
|
||||||
- `g1nation.research.runQueue` — Astra: 학습 실행 (Research Agent — 승인된 큐 항목)
|
- `g1nation.research.runQueue` — Astra: 학습 실행 (Research Agent — 승인된 큐 항목)
|
||||||
@@ -239,17 +240,21 @@ flowchart LR
|
|||||||
- `g1nation.company.toggle` — Astra: Toggle 1인 기업 Mode
|
- `g1nation.company.toggle` — Astra: Toggle 1인 기업 Mode
|
||||||
- `g1nation.company.manage` — Astra: Manage 1인 기업 Agents
|
- `g1nation.company.manage` — Astra: Manage 1인 기업 Agents
|
||||||
- `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder
|
- `g1nation.company.openSessions` — Astra: Open 1인 기업 Sessions Folder
|
||||||
- `g1nation.company.pixelOffice.open` — Astra: Open Pixel Office (Full Screen)
|
|
||||||
- `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅
|
- `g1nation.calendar.connect` — Astra: Google Calendar (iCal) 연결 📅
|
||||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||||
- **Configuration** (149 settings):
|
- **Configuration** (183 settings):
|
||||||
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
||||||
- `g1nation.datacollectBridgeTarget` *(string)* _(default: `"local"`)_
|
- `g1nation.datacollectBridgeTarget` *(string)* _(default: `"nas"`)_
|
||||||
- `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.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). 백엔드를 NAS로 옮긴 경우 보통 사
|
||||||
- `g1nation.datacollectBridgeNasUrl` *(string)* _(default: `""`)_
|
- `g1nation.datacollectBridgeNasUrl` *(string)* _(default: `"https://dc.koritips.com"`)_
|
||||||
- `g1nation.datacollectBridgeNasToken` *(string)* _(default: `""`)_
|
- `g1nation.datacollectBridgeNasToken` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.domainKnowledge.generalFolder` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.domainKnowledge.mathFolder` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.domainKnowledge.codingFolder` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.domainKnowledge.factsFolder` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.datacollectLocalProjectPath` *(string)* _(default: `""`)_
|
||||||
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
|
- `g1nation.datacollectSavePath` *(string)* _(default: `""`)_
|
||||||
- `g1nation.datacollectCrawlDepth` *(number)* _(default: `1`)_
|
- `g1nation.datacollectCrawlDepth` *(number)* _(default: `1`)_
|
||||||
- `g1nation.datacollectMaxPages` *(number)* _(default: `8`)_
|
- `g1nation.datacollectMaxPages` *(number)* _(default: `8`)_
|
||||||
@@ -257,7 +262,24 @@ flowchart LR
|
|||||||
- `g1nation.chatTemperature` *(number)* _(default: `0.3`)_
|
- `g1nation.chatTemperature` *(number)* _(default: `0.3`)_
|
||||||
- `g1nation.meetUsesTasks` *(boolean)* _(default: `true`)_
|
- `g1nation.meetUsesTasks` *(boolean)* _(default: `true`)_
|
||||||
- `g1nation.meetUsesCalendar` *(boolean)* _(default: `false`)_
|
- `g1nation.meetUsesCalendar` *(boolean)* _(default: `false`)_
|
||||||
- `g1nation.meetVerifyPass` *(boolean)* _(default: `false`)_
|
- `g1nation.meetVerifyPass` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.meetVerifyMaxRounds` *(number)* _(default: `2`)_
|
||||||
|
- `g1nation.meetEditorPass` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.meetTaskDateFallback` *(string)* _(default: `"today"`)_
|
||||||
|
- `g1nation.meetTaskDetailExpand` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.meetTeamRoster` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.meetMaxOutputTokens` *(number)* _(default: `8000`)_
|
||||||
|
- `g1nation.email.watcherEnabled` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.email.watchIntervalMinutes` *(number)* _(default: `60`)_
|
||||||
|
- `g1nation.email.overdueAlertHours` *(number)* _(default: `0`)_
|
||||||
|
- `g1nation.email.outlookEnabled` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.email.gmailEnabled` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.email.includeSubfolders` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.email.wikifySavePath` *(string)* _(default: `""`)_
|
||||||
|
- `g1nation.email.syncDays` *(number)* _(default: `7`)_
|
||||||
|
- `g1nation.messenger.stocksReportTimes` *(string)* _(default: `"09:00,15:00"`)_
|
||||||
|
- `g1nation.messenger.buySignalAlert` *(boolean)* _(default: `true`)_
|
||||||
|
- `g1nation.messenger.discoverTopFive` *(boolean)* _(default: `true`)_
|
||||||
- `g1nation.dailyBriefing.enabled` *(boolean)* _(default: `true`)_
|
- `g1nation.dailyBriefing.enabled` *(boolean)* _(default: `true`)_
|
||||||
- `g1nation.dailyBriefing.time` *(string)* _(default: `"09:30"`)_
|
- `g1nation.dailyBriefing.time` *(string)* _(default: `"09:30"`)_
|
||||||
- `g1nation.sleepDigest.enabled` *(boolean)* _(default: `true`)_
|
- `g1nation.sleepDigest.enabled` *(boolean)* _(default: `true`)_
|
||||||
@@ -280,32 +302,10 @@ flowchart LR
|
|||||||
- `g1nation.contextOverflowPolicy` *(string)* _(default: `"stopAtLimit"`)_ — Fallback behavior (LM Studio) if the prompt still exceeds the context window after Astra's own budgeting. 'stopAtLimit' fails clearly so you notice; 'truncateMiddle'/'rollingWindow' drop content silen
|
- `g1nation.contextOverflowPolicy` *(string)* _(default: `"stopAtLimit"`)_ — Fallback behavior (LM Studio) if the prompt still exceeds the context window after Astra's own budgeting. 'stopAtLimit' fails clearly so you notice; 'truncateMiddle'/'rollingWindow' drop content silen
|
||||||
- `g1nation.autoCompactHistory` *(boolean)* _(default: `true`)_ — Automatically drop the oldest conversation messages from the request when the prompt would exceed the context budget (the on-screen chat history is unaffected). Default: true
|
- `g1nation.autoCompactHistory` *(boolean)* _(default: `true`)_ — Automatically drop the oldest conversation messages from the request when the prompt would exceed the context budget (the on-screen chat history is unaffected). Default: true
|
||||||
- `g1nation.smallModelContextCap` *(number)* _(default: `0`)_ — Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with
|
- `g1nation.smallModelContextCap` *(number)* _(default: `0`)_ — Optional safety knob, OFF by default (0). Some very small models (≤3B) emit an empty/EOS response when given a prompt near their context window even though it nominally fits. If you observe that with
|
||||||
- `g1nation.autoContinueOnOutputLimit` *(boolean)* _(default: `true`)_ — When a reply is cut off because it hit the output-token limit, Astra continues it internally (compressed request — original question + the answer so far, not the whole context again) and shows one mer
|
- `g1nation.largeInputMapReduce` *(boolean)* _(default: `true`)_ — When a single message is too large to fit the model's context window, split it into chunks, extract only the request-relevant facts from each (no hallucination/summary), integrate them, and answer fro
|
||||||
- `g1nation.maxAutoContinuations` *(number)* _(default: `4`)_ — Maximum number of automatic continuation rounds per reply (prevents runaway loops). Raise it (e.g. 5–6) for long-form answers on slow local models; set 0 to disable auto-continuation. Default: 4
|
- `g1nation.mapReduceTriggerRatio` *(number)* _(default: `0.6`)_ — Map-reduce kicks in when a single message exceeds (effective context window × this ratio). Lower = engages sooner (safer for big inputs, more LLM calls). Default: 0.6
|
||||||
- `g1nation.finalOnlyRetryOnThoughtLeak` *(boolean)* _(default: `true`)_ — If the model emits only hidden reasoning (<think>, <|channel|>thought, "Thinking Process:" …) and no user-visible answer, Astra silently re-asks it for the final answer only. Hidden reasoning is never
|
- `g1nation.mapReduceConcurrency` *(number)* _(default: `2`)_ — How many chunk extractions run in parallel. Keep low on a single local GPU (one model serves them sequentially anyway). Default: 2
|
||||||
- `g1nation.lmStudio.idleTimeoutMs` *(number)* _(default: `300000`)_ — Auto-eject the loaded LM Studio model after this many milliseconds of inactivity. Set to 0 to disable. Default: 300000 (5 minutes).
|
- _…and 123 more_
|
||||||
- `g1nation.lmStudio.autoLoadOnSelect` *(boolean)* _(default: `true`)_ — Automatically load LM Studio models into memory when selected from the Astra sidebar.
|
|
||||||
- `g1nation.lmStudio.sampling.topP` *(number)* _(default: `0.9`)_ — Nucleus sampling cutoff. Small / quantized models often spew wrong-neighbour tokens (한글 깨짐: 붕괴→붕점) when the tail is wide. Lower (0.8–0.9) tightens; 1.0 disables. Applied to both SDK and REST paths.
|
|
||||||
- `g1nation.lmStudio.sampling.topK` *(number)* _(default: `20`)_ — Top-K sampling cutoff. 0 disables. Default 20 — tighter for small models, raise to 40–80 for large models that already sample well.
|
|
||||||
- `g1nation.lmStudio.sampling.minP` *(number)* _(default: `0.05`)_ — Min-P floor — discards tokens with probability below this fraction of the top token. Good defence against rare-token glitches. 0 disables.
|
|
||||||
- `g1nation.lmStudio.sampling.repeatPenalty` *(number)* _(default: `1.1`)_ — Repeat / frequency penalty to curb stutter (것입니다서입니다…). 1.0 disables. Values 1.05–1.2 are typical.
|
|
||||||
- `g1nation.lmStudio.statsInBudget` *(boolean)* _(default: `true`)_ — Show token/s and time-to-first-token from LM Studio prediction stats in the context-budget badge after each turn (SDK path only).
|
|
||||||
- `g1nation.lmStudio.draftModel` *(string)* _(default: `""`)_ — [Speculative decoding] LM Studio model key of a small draft model (e.g. 'gemma-2b-it') used to accelerate the main model. Empty disables. 1.5–3x throughput on large models. The draft must be downloade
|
|
||||||
- `g1nation.lmStudio.load.flashAttention` *(boolean)* _(default: `true`)_ — [Load option] Enable Flash Attention when loading models. Faster generation + lower memory on compatible hardware, especially helpful for long contexts. Default: true.
|
|
||||||
- `g1nation.lmStudio.load.gpuOffloadRatio` *(string)* _(default: `"max"`)_ — [Load option] How much of the model to offload to GPU. 'max' = all (default), 'off' = CPU only, or a number 0–1 (e.g. '0.5' = half). Numeric strings are parsed.
|
|
||||||
- `g1nation.lmStudio.load.offloadKVCacheToGpu` *(boolean)* _(default: `true`)_ — [Load option] Keep KV cache on GPU memory. Faster but requires VRAM headroom. Default: true.
|
|
||||||
- `g1nation.lmStudio.load.keepModelInMemory` *(boolean)* _(default: `true`)_ — [Load option] Prevent the model from being swapped out of system memory. Improves interactive responsiveness; raises RAM use. Default: true.
|
|
||||||
- `g1nation.lmStudio.load.useFp16ForKVCache` *(boolean)* _(default: `false`)_ — [Load option] Store KV cache in FP16 (halves cache memory). Tiny quality impact for most models — try if you run out of VRAM at long contexts. Default: false.
|
|
||||||
- `g1nation.lmStudio.load.evalBatchSize` *(number)* _(default: `0`)_ — [Load option] Token batch size during evaluation. 0 = engine default. Higher (512–1024) improves prefill speed on GPU at the cost of memory.
|
|
||||||
- `g1nation.localBrainPath` *(string)* _(default: `""`)_ — Folder path for your local Second Brain knowledge base. Leave empty to use the default folder.
|
|
||||||
- `g1nation.brainProfiles` *(array)* _(default: `[]`)_ — Multiple brain profiles. Each item supports id, name, localBrainPath, secondBrainRepo, and description.
|
|
||||||
- `g1nation.activeBrainId` *(string)* _(default: `""`)_ — Active brain profile id used for the current chat context.
|
|
||||||
- `g1nation.secondBrainRepo` *(string)* _(default: `""`)_ — Optional GitHub repository URL used for Second Brain sync.
|
|
||||||
- `g1nation.autoPushBrain` *(boolean)* _(default: `false`)_ — Automatically commit and push Second Brain changes after updates.
|
|
||||||
- `g1nation.maxContextSize` *(number)* _(default: `32000`)_ — Maximum character count for active file context. Default: 32000
|
|
||||||
- `g1nation.maxAutoSteps` *(number)* _(default: `50`)_ — Maximum autonomous steps the agent can take per request. Default: 50
|
|
||||||
- `g1nation.dryRun` *(boolean)* _(default: `false`)_ — If enabled, the agent will ask for approval before committing any file changes.
|
|
||||||
- _…and 89 more_
|
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||||
@@ -353,7 +353,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
|||||||
**Designed for High-Performance Decision Making.**
|
**Designed for High-Performance Decision Making.**
|
||||||
Copyright (C) **g1nation**. All rights reserved.
|
Copyright (C) **g1nation**. All rights reserved.
|
||||||
|
|
||||||
_Last auto-scan: 2026-06-15T02:56:35.947Z · signature `c66695ef`_
|
_Last auto-scan: 2026-07-20T04:20:03.384Z · signature `ff87bb88`_
|
||||||
<!-- ASTRA:AUTO-END -->
|
<!-- ASTRA:AUTO-END -->
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|||||||
+1007
-405
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||||
"createdAt": 1783247674941,
|
"createdAt": 1784521057661,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "---\nid: wiki_on\ndate: 2026-07-05T10:34:34.941Z\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) (11ms)\n",
|
"result": "---\nid: wiki_on\ndate: 2026-07-20T04:17:37.663Z\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) (18ms)\n",
|
||||||
"createdAt": 1783247674941,
|
"createdAt": 1784521057663,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "wiki_on",
|
"missionId": "wiki_on",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-05T10:34:34.929Z",
|
"startTime": "2026-07-20T04:17:37.641Z",
|
||||||
"totalElapsedMs": 12,
|
"totalElapsedMs": 24,
|
||||||
"results": {
|
"results": {
|
||||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||||
},
|
},
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "direct",
|
"to": "direct",
|
||||||
"durationMs": 11,
|
"durationMs": 18,
|
||||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||||
"ts": "2026-07-05T10:34:34.940Z"
|
"ts": "2026-07-20T04:17:37.659Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "direct",
|
"from": "direct",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 5,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-05T10:34:34.941Z"
|
"ts": "2026-07-20T04:17:37.664Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783247682033,
|
"createdAt": 1784521065426,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783247682033,
|
"createdAt": 1784521065425,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"createdAt": 1783247682031,
|
"createdAt": 1784521065420,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1783247682032,
|
"createdAt": 1784521065423,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "stress_conflict_1783247682020",
|
"missionId": "stress_conflict_1784521065403",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-05T10:34:42.020Z",
|
"startTime": "2026-07-20T04:17:45.404Z",
|
||||||
"totalElapsedMs": 13,
|
"totalElapsedMs": 23,
|
||||||
"results": {
|
"results": {
|
||||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
@@ -14,30 +14,30 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "outline",
|
"to": "outline",
|
||||||
"durationMs": 11,
|
"durationMs": 15,
|
||||||
"message": "답변 구조 잡는 중...",
|
"message": "답변 구조 잡는 중...",
|
||||||
"ts": "2026-07-05T10:34:42.031Z"
|
"ts": "2026-07-20T04:17:45.419Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "outline",
|
"from": "outline",
|
||||||
"to": "section",
|
"to": "section",
|
||||||
"durationMs": 1,
|
"durationMs": 3,
|
||||||
"message": "본문 작성 중...",
|
"message": "본문 작성 중...",
|
||||||
"ts": "2026-07-05T10:34:42.032Z"
|
"ts": "2026-07-20T04:17:45.422Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "section",
|
"from": "section",
|
||||||
"to": "polish",
|
"to": "polish",
|
||||||
"durationMs": 0,
|
"durationMs": 2,
|
||||||
"message": "최종 다듬기 중...",
|
"message": "최종 다듬기 중...",
|
||||||
"ts": "2026-07-05T10:34:42.032Z"
|
"ts": "2026-07-20T04:17:45.424Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "polish",
|
"from": "polish",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 3,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-05T10:34:42.033Z"
|
"ts": "2026-07-20T04:17:45.427Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
+116
@@ -1,5 +1,121 @@
|
|||||||
# Astra Patch Notes
|
# Astra Patch Notes
|
||||||
|
|
||||||
|
## v2.2.312 (2026-07-20)
|
||||||
|
### 📋 보고 품질 — "## 4.부터 시작하는 보고서" 버그 + 일반론 총평 차단
|
||||||
|
실사례: "connectai 프로젝트를 분석하고 보고해줘" → 답변이 "## 4. 종합 분석 결과"부터 시작(1~3 증발), 내용은 프로젝트명을 바꿔도 성립하는 7줄 일반론.
|
||||||
|
- **중간 라운드 본문 표시 버그 수정 (근본 원인)**: 액션 루프에서 액션과 *함께* 본문 섹션을 쓴 라운드는 화면에 한 번도 표시되지 않고(라이브 스트리밍은 depth 0 전용) 최종 라운드만 append 됐다. 모델은 히스토리에서 자기 이전 섹션(1~3)을 보고 "## 4."부터 이어 써서 사용자에겐 4번부터 도착. 이제 중간 라운드의 가시 본문을 액션 태그만 걷어내고(신규 stripForDisplay.ts) 버블에 순서대로 표시한다.
|
||||||
|
- **분석 보고 업무 유형 신설 (requirementGraph)**: "분석하고 보고해줘/현황 보고/검토 보고"가 어떤 유형에도 안 걸리던 갭을 막음. 필수 요소: 보고 개요(첫 줄에 대상—근거—결론 자기선언), **파일 근거**(주장마다 실제 읽은 파일 인용, 일반론 금지), 구조·현황, 핵심 발견, 다음 단계. 유형 감지 시 "최대 3섹션" 일반 규칙보다 필수 요소 커버 우선 명시(보고서가 얕아지는 상충 제거).
|
||||||
|
- **근거 없는 분석 감지 (Hollow 의 반대 방향)**: 파일을 읽고도(read/investigate ≥1) 답변에 파일 인용이 2개 미만인 장문 분석이면 "근거 인용 없음" footer 로 경고 — 그럴듯한 총평이 검증된 분석처럼 통과하는 것을 차단.
|
||||||
|
검증: tsc 무오류 + jest 954 통과(신규 13: analysis-report 3 + ungrounded 5 + stripForDisplay 5) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.311 (2026-07-20)
|
||||||
|
### ⚡ 응답 지연 근본 개선 — KV 캐시 친화 프롬프트 + 제2뇌 상주 캐시
|
||||||
|
실측(usage.jsonl): 출력 22토큰 답변에 94.7초 — 지연의 90%+ 가 생성이 아니라 **매 턴 13k+ 토큰 전체 재프리필**이었다. 원인은 (a) 시스템 프롬프트 상단의 분 단위 시각이 llama.cpp prompt cache 를 첫 토큰부터 무효화, (b) 턴마다 바뀌는 RAG/동적 블록이 전부 message[0] 에 있어 히스토리 캐시까지 연쇄 무효화, (c) 32K 창에서 검색 결과에만 8k 토큰 예산, (d) continuation 마다 빈 쿼리로 재검색(3~8초), (e) 매 질의 6,500 파일 디렉터리 워크 + statSync.
|
||||||
|
- **KV 캐시 분리 (`kvCachePromptSplit`, 기본 ON)**: message[0] 을 불변 정적 본문(페르소나·규칙·환경)으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를 *마지막 user 메시지 직전*의 internal system 메시지로 이동. 프리픽스(정적 프롬프트+과거 히스토리)가 캐시에서 재사용되어 재프리필이 "직전 교환 + 동적 컨텍스트"로 줄어든다. [CONTEXT] truncation 도 tail 에 적용. 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 경로 유지.
|
||||||
|
- **검색 토큰 예산 현실화 (`retrievalTokenBudget`, 0=자동)**: 자동값을 창의 25%(8k~80k) → 12%(2.5k~6k 클램프)로. 매 턴 재프리필되는 tail 을 직접 얇게 만든다.
|
||||||
|
- **continuation 은 depth-0 memoryCtx 재사용**: 액션 라운드마다의 재검색 제거 — 라운드당 3~8초 절감 + 빈 쿼리 재검색으로 청크가 갈리던 문제 제거 + 턴 내 프롬프트 안정화.
|
||||||
|
- **제2뇌 상주 캐시 (신규 brainWatch.ts)**: 브레인 루트에 재귀 fs.watch 를 걸고 세대 카운터로 무효화 — 변경 없으면 디렉터리 워크와 파일별 statSync(수천 회)를 통째로 생략하고 메모리의 파일 목록·토큰 인덱스를 신뢰한다. "제2뇌를 항상 메모리에" 아이디어의 안전한 구현: 원본 대신 검색 파생물을 상주시키고 워처가 정합성을 맡는다. 수정 직후 3초 창은 신뢰 명부에서 제외(이벤트 지연 레이스 가드), 워처 불가 환경은 자동으로 종전(매번 stat) 폴백. 활성화 시 백그라운드 워밍으로 첫 질의의 인덱스 파싱 비용도 선불 처리, 유휴 해제 30분→2시간.
|
||||||
|
- 기대 효과: 동일 세션 반복 턴의 프리필이 13k → 수백~2k 토큰 수준. LM Studio 쪽 GPU 오프로드/Flash Attention 설정과 병행 권장.
|
||||||
|
검증: tsc 무오류 + jest 941 통과(신규 15: split 6 + brainCache 4 + promptSplit 5) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.307 (2026-07-11)
|
||||||
|
### 🏗️ /benchmark 레이아웃 골격 분석 + 합성 안정화
|
||||||
|
koritips 실측 피드백 반영: ① 합성이 파트 2 에서 타임아웃("This operation was aborted")으로 죽으면서 105초 걸린 파트 1까지 폐기 ② 남은 원시 요약이 색·폰트뿐이라 "배너가 롤링인지, 각 페이지가 어떤 블록 순서인지" 알 길이 없었음.
|
||||||
|
- **레이아웃 골격 섹션 (신규 layoutSkeleton.ts — LLM 미개입)**: 스캔의 sectionRoles(태그+class/id 힌트+미리보기)에서 페이지별 위→아래 블록 구성을 결정론으로 그린다. class 신호(swiper/slick/carousel…→롤링 배너, hero/banner→정적 배너, grid/card→카드 그리드 등 12종 레지스트리)로 위젯을 판별하고 **"롤링 배너/캐러셀: 있음/신호 없음"에 즉답**. 신호가 없으면 지어내지 않고 "실사이트 확인 권장" 표기. 합성 성공/실패와 무관하게 보고서에 항상 포함 — LLM 이 죽어도 골격 분석은 남는다.
|
||||||
|
- **파트별 실패 격리**: 한 파트가 실패해도 완성된 파트를 버리지 않고 실패 표시 후 계속 진행. 전 파트 실패 시에만 원시 요약 폴백.
|
||||||
|
- **합성 타임아웃 120→300초**: 입력 JSON 이 큰 파트(IA·토큰)는 e4b 급 모델에서 120초를 넘긴다 — LmOpts.timeoutMs 신설, 벤치마크 합성에 300초 지정 (다른 호출자 기본 120초 유지).
|
||||||
|
- **파트 1 프롬프트 강화**: §2-1 페이지 골격 위→아래 서술 + 배너 롤링/정적 판정 의무화(신호 없으면 명시), §4-1 시각적 톤앤매너 한 문단(수치→성격 규정) 신설.
|
||||||
|
검증: tsc 무오류 + jest 888 통과(layoutSkeleton 9개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.306 (2026-07-11)
|
||||||
|
### 🗣️ AI 상투어 제거 — Tone Guard (금지 표현 레지스트리)
|
||||||
|
잡담 턴에서 "힘든 감정을 느끼고 계시군요. 괜찮으시다면 … 저는 언제든 여기에 있습니다." 같은 상담사 화법이 그대로 나오던 문제(실사례) 해결. 작업 턴에는 문체 규칙이 있었지만 casual 모드는 무방비였다.
|
||||||
|
- **AI_CLICHE_PATTERNS 레지스트리 (신규 toneGuard.ts)**: "~하시군요" 감정 미러링 / "괜찮으시다면 ~해 드릴 수 있습니다" 허락 구걸 / "저는 언제든 여기에 있습니다" 대기 선언 / "함께 ~해 보아요" / "물론입니다!" / "도움이 되었기를 바랍니다" / 감정 정당화 공식 등 8종을 단일 배열로 관리 — 새 클리셰 발견 시 한 곳만 추가 (블로그_v3 QA 레지스트리 철학).
|
||||||
|
- **구체적 금지 목록 + 대조 예시**로 주입: 소형 모델은 "자연스럽게 해" 같은 추상 지시보다 실사례 나쁜 답 vs 좋은 답 대조에 훨씬 잘 반응한다. "동료의 담백한 존댓말, 감정엔 상담사 공식이 아니라 사람의 짧은 반응, 오버해서 친한 척 금지" 방향 명시.
|
||||||
|
- **주입 위치 2곳**: casual 모드 블록(이번 사고 지점) + 기본 페르소나(작업 턴 포함 전 턴).
|
||||||
|
검증: tsc 무오류 + jest 879 통과(toneGuard 4개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.305 (2026-07-11)
|
||||||
|
### 🤝 Claude 구독을 Astra 엔진으로 — `claude:` 프로바이더 (Claude Code CLI 위임)
|
||||||
|
"Astra 가 쓰는 모델을 로컬 LLM 에서 Claude 로 바꿀 수 있게" 요청 구현. Anthropic 구독(Pro/Max)은 API 키가 아니라 Claude Code 로그인 방식이므로, 로그인된 로컬 CLI 를 추론 엔진으로 위임하는 4번째 클라우드 프로바이더를 추가 — **API 키·추가 과금 없이 구독 한도로 동작**.
|
||||||
|
- **모델 드롭다운에 자동 노출**: Claude Code CLI 가 설치돼 있으면 "Claude 구독 · sonnet/opus/haiku" 3종이 모델 목록에 나타난다 — 선택만 하면 채팅 두뇌가 Claude 로 전환. 기존 `anthropic:`(API 키·종량제) 프로바이더와 별개.
|
||||||
|
- **통합 방식**: 기존 클라우드 라우팅(`parseModelPrefix`)에 `claude:` 프리픽스 추가, CLI 의 stream-json 출력을 OpenAI 호환 SSE 로 변환해 기존 파서가 그대로 소비. 프롬프트는 ARG_MAX 회피를 위해 stdin 전달, 시스템 프롬프트·대화 이력은 구조화 평탄화. makeModelId 가 프리픽스 테이블을 따르도록 수정(id≠prefix 첫 사례).
|
||||||
|
- **coreChat 도 지원**: defaultModel 이 `claude:...` 면 멀티에이전트 워크플로우·Report QA·시맨틱 리랭크 등 내부 LLM 호출 전부가 구독 모델로 동작.
|
||||||
|
- **실패는 명확하게**: CLI 미설치/미로그인 시 설치(`npm i -g @anthropic-ai/claude-code`)·로그인 안내를 에러로 표시 (조용한 폴백 금지). CLI 경로 자동 탐지(+`g1nation.claude.cliPath` 수동 지정).
|
||||||
|
- 참고: /claude 명령(v2.2.303)은 "개발 작업 위임"용 터미널 실행이고, 이번 것은 "채팅 엔진 전환"— 용도가 다른 두 연동이다.
|
||||||
|
검증: tsc 무오류 + jest 875 통과(claudeCodeProvider 12개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.304 (2026-07-11)
|
||||||
|
### 🧳 지식 환경 이식성 — "새 컴퓨터에서 바꿀 경로는 두뇌 위치 1개"
|
||||||
|
지식(Wiki)이 Astra 소스 폴더 밖에 있는 구조는 유지(의도적 — Wiki 는 자체 git 데이터 저장소, Astra 는 vsix 설치)하되, 설정에 흩어진 절대 경로 5곳을 두뇌-상대 경로로 수렴시켜 컴퓨터 이동 시 `brainProfiles.localBrainPath` 하나만 바꾸면 되게 정리.
|
||||||
|
- **datacollectSavePath 상대 경로 지원**: pickWikiDir 가 비-절대 경로 설정을 활성 두뇌 루트 기준으로 해석 (`../../Premium/자료` 처럼 두뇌 밖 형제 폴더도 지정 가능). 기존엔 상대 경로를 조용히 무시하고 기본 폴더로 폴백했음.
|
||||||
|
- **사용자 설정 전환**: domainKnowledge 4개 키를 두뇌-상대(`Domain_Programming`/`Domain_General`/`_Common`/`_Common/Math`)로, datacollectSavePath 를 `../../Premium/Thinking & Reasoning` 으로 변경 (백업: settings.json.bak-portability). 도메인 스코프 해석기는 원래 상대 경로 지원 + 두뇌 밖 탈출 차단이 있어 코드 변경 불필요.
|
||||||
|
- **이사 체크리스트 문서**: Wiki 루트에 `컴퓨터_이사_체크리스트.md` 생성 — 새 컴퓨터에서 클론→vsix 설치→경로 1개 변경→NAS 토큰 입력 순서와 설계 이유 기록.
|
||||||
|
검증: tsc 무오류 + jest 864 통과(wikiSave 상대 경로 3개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.303 (2026-07-11)
|
||||||
|
### 🔗 벤치마킹→클라우드 개발 파이프라인 — handoff 모드 + /claude (Claude Code 위임)
|
||||||
|
"로컬 모델은 분석·명세까지, 실제 개발은 클라우드 모델로" 역할 분담 완성. 로컬 4b 모델의 요약 병목 없이 측정치를 그대로 클라우드에 전달한다.
|
||||||
|
- **`/benchmark <url> handoff` (신규)**: 스캔이 측정한 원시 값(rgb·px·폰트·카피 원문·페이지 인벤토리·원시 JSON 부록)을 **LLM 호출 0회**로 결정론 조립한 개발 인수인계 문서를 생성·저장. 소형 모델이 지시문을 작문하면 측정치가 "적절한 색상" 같은 모호어로 퇴화하는 문제를 원천 차단 — 클라우드 모델은 원시 구조화 데이터를 가장 잘 소화한다. 문서 구성: ⓪클라우드 모델용 지시문(측정치 그대로 사용·임의 대체 금지·모르면 질문) ①내 요구사항 슬롯(변형 지시 입력란 또는 체크리스트) ②디자인 토큰 ③마이크로카피 원문 ④IA 표 ⑤원시 JSON.
|
||||||
|
- **`/claude <지시>` (신규)**: 로컬에 설치·로그인된 Claude Code CLI 를 통합 터미널("Claude Code (Astra)")에서 실행 — **Anthropic 구독(Pro/Max) 인증을 그대로 위임** (API 키·추가 과금 없음). cwd=워크스페이스, 권한 승인은 터미널에서 사용자가 직접. 셸 안전 인자 정규화(따옴표·$·백틱 무해화). CLI 미설치 시 설치 안내 출력.
|
||||||
|
- **파이프라인 연결**: handoff 저장 완료 시 "다음 단계" 로 `/claude <저장경로> 파일을 읽고 명세대로 개발해줘` 명령을 제안 — 아스트라(무료 분석) → 핸드오프 문서 → Claude Code(구독 개발) 흐름이 명령 두 개로 이어진다.
|
||||||
|
검증: tsc 무오류 + jest 861 통과(handoffDoc·claudeCommand 10개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.302 (2026-07-11)
|
||||||
|
### 📊 /benchmark 강화 — 비즈니스 렌즈 + 스텝별 빌드 프롬프트 (벤치마킹→개발 틀)
|
||||||
|
"사이트 주소를 주면 구조·기능·서비스 아이디어·운영 방향성까지 제시하고, 매우 유사하게 만들 수 있게" 요청 구현. 기존 /benchmark 는 디자인 복원 시방서(4-렌즈+IA+재구축 명세)까지만 — 1인 AI 기업 벤치마킹의 핵심인 사업 분석이 없었다.
|
||||||
|
- **파트 4 — 비즈니스 벤치마킹 렌즈 (신규, 기본 포함)**: §10 서비스 정의·포지셔닝(헤드라인 공식 분석 — "Fire your X" 류) / §11 가격·수익 구조(티어 추출→AI 원가 시나리오 표: 상용 API vs 셀프호스팅→손익분기 부등식→수수료 반영 마진 시뮬레이션) / §12 기능 인벤토리·사용자 여정·백엔드 요구·**2~3일 MVP 컷** / §13 운영·방향성(시리즈화 3안·광고비 0원 채널·검증 지표·"접을 기준"). §13 에서만 사용자 보조 컨텍스트를 결합한 변형 제안 허용 — part 3 의 원본 복원 순수성은 그대로.
|
||||||
|
- **파트 5 — 스텝별 빌드 프롬프트 (build 모드)**: 스캔에서 추출한 실제 디자인 토큰·카피 원문을 박아 넣은 자기완결적 프롬프트 4개(STEP 1 랜딩 → 2 도구 UI → 3 API 연동 → 4 결제/대기명단)를 생성 — 바이브코딩 IDE 에 순서대로 붙여넣으면 유사 서비스가 완성되는 "프롬프트 교재" 형태.
|
||||||
|
- **모드**: `/benchmark <url> [mode]` — `full`(기본, 1~4) / `design`(기존 3파트) / `biz`(사업 분석만, 빠름) / `build`(1~5 풀코스). bare 키워드·`mode=` 표기 모두 인식. 가격 분석 목적이면 `depth=2` 권장 안내 추가.
|
||||||
|
- **품질 보증**: 비즈니스 렌즈(파트 4)는 날조 위험(임의 가격·매출)이 가장 큰 파트라 v2.2.301 의 Report QA 루프(채점→피드백 재작성→회귀 게이트)를 통과 — 재작성 호출에 스캔 JSON 이 포함된 합성 프롬프트를 근거로 전달해 근거 없는 수정 방지. 스캔에 가격이 없으면 "스캔 데이터 부족" 표기 강제(임의 추정 금지).
|
||||||
|
- 스캔 자체 강화(pricing 페이지 우선 크롤·가격 정규식·기술스택 신호)는 Datacollector Bridge 수정이 필요해 별도 단계로 보류.
|
||||||
|
검증: tsc 무오류 + jest 851 통과(benchmarkModes 9개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.301 (2026-07-11)
|
||||||
|
### 🎯 문맥·의도 이해 + 조사 보고서 품질 루프 (블로그_v3 방식 이식)
|
||||||
|
"문자 그대로만 답하지 말고 왜 묻는지를 이해하고 답하라" + "조사 답변은 블로그_v3 수준의 완성도로" 요청 구현. 블로그_v3 파이프라인의 검증 설계(실측치 주입·QA 규칙 레지스트리·회귀 게이트)를 보고서 도메인으로 개념 이식 (raw 복사 아님).
|
||||||
|
- **[답변 전 이해 원칙] (모든 실질 질문 턴, LLM 호출 0회)**: dynamicBlocks 행동 제약에 "문자 그대로 읽지 말고 대화 흐름과 함께 해석 → 왜 묻는지(목적)·진짜 궁금증·답의 용도를 먼저 판단 → 첫 문장은 그 목적에 답하는 결론" 지시를 상시 주입. truncation 보호 구역이라 컨텍스트 압박에서도 살아남는다.
|
||||||
|
- **[의도 브리핑] (워크플로우 선두, LLM 1회)**: 조사·복합 요청이 멀티에이전트 워크플로우에 들어오면 최근 대화 요지와 함께 질문을 분석해 {추정 목적 / 진짜 궁금증 1~3개 / 답의 용도} JSON 브리핑을 생성, outline→section→polish 전 단계 컨텍스트에 주입. "이 브리핑에 답하는 것이 성공 기준"으로 명시.
|
||||||
|
- **[Report QA Loop] (신규 src/agents/reportQa.ts)**: 조사성 요청(조사/리서치/보고서/레포트 등)의 최종 보고서를 ①결정적 실측치(본문 글자 수·소제목 수·출처 인용 흔적·나열 비율 — 코드 측정, 모델 눈대중 금지) 주입 ②REPORT_QA_RULES 레지스트리 12항(결론 선행·의도 부합·근거 명시·임의 수치 금지 등 critical 7항) 기준 JSON 채점 ③임계(기본 80) 미달 시 피드백 재주입 재작성(기본 최대 2회) ④**회귀 게이트** — 재작성본 점수가 떨어지면 폐기하고 최고점 초안 유지 (블로그_v3의 85→79 편집 회귀 사고 방지 장치 이식). 채점 실패는 로그로 드러내고 현재 최고 초안 반환 (조용한 폴백 금지).
|
||||||
|
- **라우팅 보강**: 명시적 조사·보고서 키워드는 chunkedSwitchTokens 절대 게이트보다 우선 발동 — 짧은 "X 조사해줘"도 파이프라인을 탄다 ('요약/리뷰' 류 일반 키워드는 기존 결정대로 게이트 아래 유지).
|
||||||
|
- 설정: `reportQaEnabled`(기본 ON) / `reportQaThreshold`(80) / `reportQaMaxRevisions`(2). 진행 상황은 사이드바 stage 인디케이터(⓪ 의도 분석 → ①②③ → ④ 품질 검증)로 표시.
|
||||||
|
검증: tsc 무오류 + jest 842 통과(reportQa 13개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.300 (2026-07-11)
|
||||||
|
### 🧹 채팅 화면 정리 — 생각 텍스트 실시간 차단 + 확신도 footer 기본 숨김
|
||||||
|
답변을 기다리는 동안 모델의 내부 추론(`<|channel|>thought …`, `<think>…`)이 화면에 그대로 흐르다가 완료 시에야 사라지던 문제, 그리고 인사말에까지 "확신도 40/100 · 사람 확인 필요" footer 가 붙던 노이즈를 정리.
|
||||||
|
- **LiveReasoningFilter (신규)**: 라이브 스트리밍(liveStreamTokens=true, 기본)에서 추론 구간을 *토큰 단위로* 걸러 화면에 아예 흐르지 않게 차단. 답변 본문은 기존처럼 실시간 표시 (TTFT 체감 유지). 마커가 토큰 두 개에 걸쳐 쪼개져 와도(`<th`+`ink>`) 잡는 보류(holdback) 상태 기계. Harmony 채널(thought/analysis/commentary/reasoning 숨김, final 통과)·`<think>`/`<thinking>`/`<analysis>` 지원. 놓친 마커는 완료 시 streamReplace 가 정리하는 이중 방어는 그대로. 본 스트림(agent)과 자동 이어쓰기 라운드(streamChatOnce) 모두 적용.
|
||||||
|
- **확신도·검토요청 footer 기본 비표시**: `confidenceEngineEnabled`/`escalationEnabled` 기본값 true→false (보고 싶으면 설정에서 opt-in). 점수 *계산* 과 Reflection 성장 기록(reflections.jsonl·Success Pattern DB)은 표시 여부와 무관하게 그대로 수행 — 성장 추이의 원천은 화면이 아니라 기록이므로.
|
||||||
|
- liveStreamTokens 관련 주석·설정 설명이 실제 기본값(true)과 어긋나 있던 것 정정.
|
||||||
|
검증: tsc 무오류 + jest 829 통과(LiveReasoningFilter 단위 11개 신규) + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.299 (2026-07-11)
|
||||||
|
### 🧩 아키텍처 수렴 — "코어를 우회하는 딴살림" 청산 (감사 후속)
|
||||||
|
전체 설계 감사에서 확인된 코어 우회 지점들을 단일 구현으로 수렴. 동작 변화가 목적이 아니라, 앞으로의 수정이 한 곳만 고치면 전체에 반영되는 구조를 만드는 리팩토링.
|
||||||
|
- **LLM 호출 수렴 → `coreChat`(core/services)**: 자체 fetch + 엔진 휴리스틱(`:11434` 판별)을 각자 들고 있던 4곳 — agents/factory(BaseAgent.callLLM), intelligence/llmCall(simpleChatCompletion), retrieval/semanticRerank, agent/postHocSelfCheck — 을 전부 AIService.chat 경유로 교체. 엔진 자동 폴백(lmstudio↔ollama)·타임아웃·로깅이 이제 이들에게도 동일 적용. 이를 위해 AIChatRequest 에 `temperature/maxTokens/numCtx` 옵션 추가(엔진별 payload 매핑). **의도적 예외**: features/datacollect/llm(finish_reason 이어쓰기·repeat_penalty 등 장문 생성 엔진 요구)은 유지하고 파일에 예외 사유를 명문화.
|
||||||
|
- **기업 모드 검색 승격**: 사이드바 기업 모드가 경량 검색(scopedBrainRetriever)만 쓰던 드리프트 해소 — RetrievalOrchestrator 전체 경로(임베딩 하이브리드·청크)를 쓰는 `retrieveBrainChunksScoped`/`retrieveBrainBlockForCompany` 를 신설하고 DispatcherDeps thunk 로 주입. 텔레그램 등 에이전트 없는 경로는 기존 경량 검색 폴백 유지.
|
||||||
|
- **프로세스 실행 수렴 → `lib/execUtil`**: execFile/exec Promise 래퍼 6곳·Python 인터프리터 탐지 3벌을 단일 모듈로 — calculate/runCode(캡처 실행), health/bridgeLauncher(셸 실행), selfReflector(동기 탐지 공유), datacollectSetup(후보 순서 공유). OS별 후보 순서·타임아웃·캐시가 이제 한 벌.
|
||||||
|
- **KST 스케줄 수렴 → `lib/kstSchedule`**: stocksWatcher·dailyBriefing·sleepDigest·growthCycleWatcher 가 각자 복제한 nowInKst()/HH:MM 파싱을 단일화 (발사 정책 — 복수 시각/평일만/매일/주간 — 은 각 워처 고유이므로 유지).
|
||||||
|
- **토큰 추정 통합**: retrieval/contextBudget 의 자체 estimateTokens(한글만 인식)를 lib/contextManager 구현(CJK 전반·보수적 계수)으로 통일.
|
||||||
|
- **설정 접근 규칙 명문화**: config.ts getConfig 에 "코어 계약 값은 getConfig 경유, 기능 전용 키는 직접 접근 허용, 둘 이상이 읽으면 승격" 기준을 주석으로 박제.
|
||||||
|
검증: tsc 무오류 + jest 818 통과 + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.298 (2026-07-11)
|
||||||
|
### 🧭 단계별 지식 폴더 — 지식 재편(멀티 에이전트 체계) 정합 + 조용한 폴백 제거
|
||||||
|
2026-07-11 두뇌 재편(_Common + Domain_*)에 맞춰 단계별 지식 스코프를 정합화.
|
||||||
|
- **설정 UI 문구·placeholder 갱신**: 새 분류 체계 경로(`_Common`/`_Common/Math`/`Domain_Programming`/`Domain_General`) 예시로 교체, 에이전트 매핑 우선순위·절대경로 허용 설명 추가. package.json 설정 설명도 동일 갱신.
|
||||||
|
- **[조용한 폴백 금지] Specialty 폴더 부재 시 로그**: 설정된 폴더가 존재하지 않으면(예: 재편 후 옛 경로) 조용히 전체 두뇌로 넘어가지 않고 출력 로그에 도메인·경로를 명시 — 이번에 실제로 사용자 설정이 옛 경로(Topic_Programming 등)를 가리켜 기능이 무력화돼 있던 것을 발견한 계기.
|
||||||
|
- 로직 체인 재검증: 에이전트 스코프 우선 → 도메인 스코프 → 전체 폴백, Grounding rescue, 도구 라우팅 힌트 모두 정상.
|
||||||
|
검증: jest 818 통과 + esbuild 정상.
|
||||||
|
|
||||||
|
## v2.2.297 (2026-07-05)
|
||||||
|
### ☁️ stocks 목록 클라우드 백업 — 컴퓨터 바꿔도 워치리스트 유지
|
||||||
|
stocks.json 이 로컬 워크스페이스에만 있어 기기를 바꾸면 목록이 사라지던 문제 해결. 기존 Sheet1~3 sync 는 분류 표시용이라 복원이 불가능했다.
|
||||||
|
- **자동 백업**: 목록이 바뀔 때마다(add/remove/check/discover 자동편입/워처 가격갱신 — writeStocksStore 성공 시 리스너 발화) 30초 디바운스 후 기존 spreadsheet(`g1nation.stocks.spreadsheetId`)의 `_backup` 탭에 **원본 JSON 그대로** 백업. 탭은 없으면 자동 생성(batchUpdate addSheet), 목록이 줄어도 잔여 행이 안 남게 클리어 후 덮어쓰기. `g1nation.stocks.autoBackup`(기본 ON)으로 끄기 가능.
|
||||||
|
- **`/stocks backup`**: 수동 즉시 백업. **`/stocks restore`**: 새 컴퓨터에서 한 번으로 복원(로컬 목록 교체, silent write 로 되백업 루프 차단, 손상 행은 skip).
|
||||||
|
- sheetsApi 에 `ensureSheetTab`(탭 존재 확인+생성) / `clearSheetRange`(values:clear) 추가 — OAuth·인프라는 기존 calendar/sheets 공유 그대로.
|
||||||
|
검증: tsc 무오류 + jest 818 통과 + esbuild 정상.
|
||||||
|
|
||||||
## v2.2.296 (2026-07-05)
|
## v2.2.296 (2026-07-05)
|
||||||
### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색
|
### 🧠 소형 모델 추론 강화 2탄 — 코딩 실행 루프 + 도구 라우팅 + 근거 재검색
|
||||||
로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다.
|
로드맵 2·3·4번 완결. 1탄(calculate·지식 스코프)과 합쳐 "모델은 판단만, 계산·실행·기억은 바깥에서" 아키텍처가 완성됐다.
|
||||||
|
|||||||
@@ -7,5 +7,5 @@
|
|||||||
"corePurpose": "",
|
"corePurpose": "",
|
||||||
"detailLevel": "standard",
|
"detailLevel": "standard",
|
||||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||||
"updatedAt": "2026-06-15T03:00:02.383Z"
|
"updatedAt": "2026-07-20T04:19:49.700Z"
|
||||||
}
|
}
|
||||||
|
|||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||||
|
|
||||||
|
## User Request Summary
|
||||||
|
E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||||
|
|
||||||
|
## Interpreted Intent
|
||||||
|
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
No explicit question was captured.
|
||||||
|
|
||||||
|
## Main Discussion
|
||||||
|
- E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
No decisions captured yet.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||||
|
|
||||||
|
## User Request Summary
|
||||||
|
E:\Wiki\connectai 프로젝트를 분석하고 구조에 대해서 설명해줘
|
||||||
|
|
||||||
|
## Interpreted Intent
|
||||||
|
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
No explicit question was captured.
|
||||||
|
|
||||||
|
## Main Discussion
|
||||||
|
- 반가워요. 어떻게 도와드릴까요?
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
No decisions captured yet.
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
# Discussion: E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘.
|
||||||
|
|
||||||
|
## User Request Summary
|
||||||
|
E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘.
|
||||||
|
|
||||||
|
## Interpreted Intent
|
||||||
|
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||||
|
|
||||||
|
## Questions
|
||||||
|
No explicit question was captured.
|
||||||
|
|
||||||
|
## Main Discussion
|
||||||
|
- ## 4. 종합 분석 결과 현재 ConnectAI 프로젝트는 로컬 LLM과 RAG 기술을 VS Code라는 개발 환경에 밀접하게 결합하려는 시도가 매우 인상적입니다. 구조적으로 에이전트 기반의 워크플로우와 계층적 메모리 시스템이 설계되어 있어, 단순한 챗봇 이상의 '지능형 코딩 어시스턴트'로 발전할 가능성이 높습니다. **핵심 요약:** - **강점**: 로컬 LLM 사용을 통한 보안성 및 비용 효율성 확보, RAG를 활용한 개인화된 지식 검색 기능의 구조적 완성도. - **기회**: 에이전트 구조(Planner, Researcher, Writer)를 통한 복잡한 개발 태스크 자동화 가능성. - **과제**: 대규모 코드베이스 인덱싱 시의 성능 최적화 및 사용자 경험(UX)의 직관성 강화. 결론적으로, 이 프로젝트는 단순한 도구 모음을 넘어 사용자의 코딩 맥락을 이해하는 '지능형 레이어'를 구축하는 데 초점이 맞춰져 있습니다.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
No decisions captured yet.
|
||||||
@@ -420,3 +420,12 @@
|
|||||||
|
|
||||||
## 2026-06-12
|
## 2026-06-12
|
||||||
- Auto development record created: development\2026-06-12_지금까지-너는-나에게-아이디어-핵심-내용-기대-효과-내용으로-써줬는데-법정-의무-교육은-다른-포멧으로-줬어-_implementation.md
|
- Auto development record created: development\2026-06-12_지금까지-너는-나에게-아이디어-핵심-내용-기대-효과-내용으로-써줬는데-법정-의무-교육은-다른-포멧으로-줬어-_implementation.md
|
||||||
|
|
||||||
|
## 2026-07-20
|
||||||
|
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-구조에-대해서-설명해줘.md
|
||||||
|
|
||||||
|
## 2026-07-20
|
||||||
|
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-구조에-대해서-설명해줘-2.md
|
||||||
|
|
||||||
|
## 2026-07-20
|
||||||
|
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-어떻게-되어-있는지-보고해줘.md
|
||||||
|
|||||||
@@ -182,32 +182,32 @@
|
|||||||
|
|
||||||
<section class="section" data-section="domainKnowledge" data-tab="knowledge">
|
<section class="section" data-section="domainKnowledge" data-tab="knowledge">
|
||||||
<h2>단계별 지식 폴더 (소형 모델 정밀 검색)</h2>
|
<h2>단계별 지식 폴더 (소형 모델 정밀 검색)</h2>
|
||||||
<p class="hint">질문이 <strong>수학·논리 / 코딩·디버깅 / 일반 상식</strong>으로 분류되면 전체 두뇌 대신 <strong>General + 해당 Specialty 폴더</strong>만 검색해 무관한 문서를 배제합니다. 경로는 활성 두뇌 기준 상대 경로이며, Specialty 가 비어 있는 단계는 기존처럼 전체 두뇌를 검색합니다.</p>
|
<p class="hint">질문이 <strong>수학·논리 / 코딩·디버깅 / 일반 상식</strong>으로 분류되면 전체 두뇌 대신 <strong>General(공통) + 해당 Specialty 폴더</strong>만 검색해 무관한 문서를 배제합니다. 멀티 에이전트 지식 체계(<code>_Common</code> + <code>Domain_*</code>)와 짝을 이루는 기능으로, 경로는 활성 두뇌 기준 상대 경로(절대 경로도 가능)입니다. Specialty 가 비어 있거나 폴더가 없는 단계는 전체 두뇌를 검색하며, 에이전트별 지식 매핑(agent-knowledge-map)이 있으면 그쪽이 우선합니다.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="dkGeneral">General Knowledge 폴더 (모든 단계 공통)</label>
|
<label for="dkGeneral">General Knowledge 폴더 (모든 단계 공통)</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="dkGeneral" type="text" placeholder="예: General" spellcheck="false" />
|
<input id="dkGeneral" type="text" placeholder="예: 10_Wiki/Topics/_Common" spellcheck="false" />
|
||||||
<button data-save="domainKnowledge.general">저장</button>
|
<button data-save="domainKnowledge.general">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="dkMath">수학·논리 Specialty 폴더</label>
|
<label for="dkMath">수학·논리 Specialty 폴더</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="dkMath" type="text" placeholder="예: Specialty_Math" spellcheck="false" />
|
<input id="dkMath" type="text" placeholder="예: 10_Wiki/Topics/_Common/Math" spellcheck="false" />
|
||||||
<button data-save="domainKnowledge.math">저장</button>
|
<button data-save="domainKnowledge.math">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="dkCoding">코딩·디버깅 Specialty 폴더</label>
|
<label for="dkCoding">코딩·디버깅 Specialty 폴더</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="dkCoding" type="text" placeholder="예: 10_Wiki/Topic_Programming" spellcheck="false" />
|
<input id="dkCoding" type="text" placeholder="예: 10_Wiki/Topics/Domain_Programming" spellcheck="false" />
|
||||||
<button data-save="domainKnowledge.coding">저장</button>
|
<button data-save="domainKnowledge.coding">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<label for="dkFacts">일반 상식·팩트 Specialty 폴더</label>
|
<label for="dkFacts">일반 상식·팩트 Specialty 폴더</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input id="dkFacts" type="text" placeholder="예: Specialty_Facts" spellcheck="false" />
|
<input id="dkFacts" type="text" placeholder="예: 10_Wiki/Topics/Domain_General" spellcheck="false" />
|
||||||
<button data-save="domainKnowledge.facts">저장</button>
|
<button data-save="domainKnowledge.facts">저장</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+57
-10
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "Astra",
|
"displayName": "Astra",
|
||||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||||
"version": "2.2.296",
|
"version": "2.2.312",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
"command": "g1nation.growth.report",
|
"command": "g1nation.growth.report",
|
||||||
"title": "Astra: 성장 리포트 (Reflection 추이)"
|
"title": "Astra: 성장 리포트 (Reflection 추이)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.growth.standingRules",
|
||||||
|
"title": "Astra: 상시 행동 규칙 (Standing Rules)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "g1nation.growth.learningQueue",
|
"command": "g1nation.growth.learningQueue",
|
||||||
"title": "Astra: 학습 큐 갱신 (Need Engine)"
|
"title": "Astra: 학습 큐 갱신 (Need Engine)"
|
||||||
@@ -267,22 +271,22 @@
|
|||||||
"g1nation.domainKnowledge.generalFolder": {
|
"g1nation.domainKnowledge.generalFolder": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "[단계별 지식] 모든 단계가 공통으로 참조하는 General Knowledge 폴더 (활성 두뇌 기준 상대 경로, 예: `General`). 질문이 수학/코딩/팩트로 분류될 때 해당 Specialty 폴더와 함께 검색됩니다."
|
"markdownDescription": "[단계별 지식] 모든 단계가 공통으로 참조하는 General Knowledge 폴더 (두뇌 기준 상대 경로, 예: `10_Wiki/Topics/_Common`). 질문이 수학/코딩/팩트로 분류될 때 해당 Specialty 폴더와 함께 검색됩니다."
|
||||||
},
|
},
|
||||||
"g1nation.domainKnowledge.mathFolder": {
|
"g1nation.domainKnowledge.mathFolder": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "[단계별 지식] 수학·논리 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `Specialty_Math`). 비워두면 수학 질문도 전체 두뇌를 검색합니다."
|
"markdownDescription": "[단계별 지식] 수학·논리 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/_Common/Math`). 비워두면 수학 질문도 전체 두뇌를 검색합니다."
|
||||||
},
|
},
|
||||||
"g1nation.domainKnowledge.codingFolder": {
|
"g1nation.domainKnowledge.codingFolder": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "[단계별 지식] 코딩·디버깅 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topic_Programming`). 비워두면 코딩 질문도 전체 두뇌를 검색합니다."
|
"markdownDescription": "[단계별 지식] 코딩·디버깅 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/Domain_Programming`). 비워두면 코딩 질문도 전체 두뇌를 검색합니다."
|
||||||
},
|
},
|
||||||
"g1nation.domainKnowledge.factsFolder": {
|
"g1nation.domainKnowledge.factsFolder": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "[단계별 지식] 일반 상식·팩트 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `Specialty_Facts`). 비워두면 팩트 질문도 전체 두뇌를 검색합니다."
|
"markdownDescription": "[단계별 지식] 일반 상식·팩트 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/Domain_General`). 비워두면 팩트 질문도 전체 두뇌를 검색합니다."
|
||||||
},
|
},
|
||||||
"g1nation.datacollectLocalProjectPath": {
|
"g1nation.datacollectLocalProjectPath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -292,7 +296,7 @@
|
|||||||
"g1nation.datacollectSavePath": {
|
"g1nation.datacollectSavePath": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "`/wikify` · `/meet` · `/benchmark` · `/youtube` · `/review` · `/email wikify` 가 생성하는 **지식 문서(markdown)의 저장 폴더** (v2.2.273부터 **PC 로컬에 직접 저장** — 이전에는 Bridge 서버에 저장돼 NAS 이전 후 파일이 안 보이던 문제 해소). **비워두면** 활성 두뇌 폴더의 `Datacollect_Wiki/` 에 저장돼 brain 검색에 자동 포함됩니다. 다른 위치를 쓰려면 절대경로 입력. Astra Settings 패널의 **지식·기억 탭**에서도 편집 가능."
|
"markdownDescription": "`/wikify` · `/meet` · `/benchmark` · `/youtube` · `/review` · `/email wikify` 가 생성하는 **지식 문서(markdown)의 저장 폴더**. 💡 **상대 경로를 넣으면 활성 두뇌 폴더 기준으로 해석** (v2.2.304 — 컴퓨터 이동 시 두뇌 경로 하나만 바꾸면 됨) (v2.2.273부터 **PC 로컬에 직접 저장** — 이전에는 Bridge 서버에 저장돼 NAS 이전 후 파일이 안 보이던 문제 해소). **비워두면** 활성 두뇌 폴더의 `Datacollect_Wiki/` 에 저장돼 brain 검색에 자동 포함됩니다. 다른 위치를 쓰려면 절대경로 입력. Astra Settings 패널의 **지식·기억 탭**에서도 편집 가능."
|
||||||
},
|
},
|
||||||
"g1nation.datacollectCrawlDepth": {
|
"g1nation.datacollectCrawlDepth": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
@@ -352,7 +356,10 @@
|
|||||||
"g1nation.meetTaskDateFallback": {
|
"g1nation.meetTaskDateFallback": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "today",
|
"default": "today",
|
||||||
"enum": ["today", "hold"],
|
"enum": [
|
||||||
|
"today",
|
||||||
|
"hold"
|
||||||
|
],
|
||||||
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
||||||
},
|
},
|
||||||
"g1nation.meetTaskDetailExpand": {
|
"g1nation.meetTaskDetailExpand": {
|
||||||
@@ -861,13 +868,28 @@
|
|||||||
},
|
},
|
||||||
"g1nation.confidenceEngineEnabled": {
|
"g1nation.confidenceEngineEnabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": false,
|
||||||
"description": "Confidence Engine — 답변 확신도 0~100 을 검색 그라운딩·출처 인용·충돌·커버리지 신호로 결정론적 산출, 업무 답변 아래 footer 표시. LLM 호출 없음. 기본 켜짐."
|
"description": "확신도 footer 표시 — 답변 아래에 확신도 0~100 배지를 표시. 점수 계산과 Reflection 성장 기록은 이 설정과 무관하게 항상 수행됩니다. 기본 꺼짐 (v2.2.300, 채팅 노이즈 방지)."
|
||||||
},
|
},
|
||||||
"g1nation.escalationEnabled": {
|
"g1nation.escalationEnabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false,
|
||||||
|
"description": "검토 요청 footer 표시 — 확신도 낮음/출처 충돌 시 답변 아래에 사람 검토 요청 카드를 표시. confidenceEngineEnabled 켜짐에 종속. 기본 꺼짐 (v2.2.300)."
|
||||||
|
},
|
||||||
|
"g1nation.reportQaEnabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
"description": "Escalation Engine — 확신도 낮음/출처 충돌/조사 출처 누락 시 footer 로 사람 검토를 명시적으로 요청. confidenceEngine 에 종속. 기본 켜짐."
|
"description": "조사·보고서 품질 루프 — 조사/리서치/보고서 요청 시 의도 분석 → 초안 → QA 채점 → 피드백 재작성 → 회귀 게이트(점수 하락 시 폐기)를 거쳐 완성도 높은 보고서를 생성. 재작성 시 답변이 느려질 수 있습니다. 기본 켜짐."
|
||||||
|
},
|
||||||
|
"g1nation.reportQaThreshold": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 80,
|
||||||
|
"description": "보고서 QA 통과 점수(50~95). 이 점수 미만이면 검수 피드백을 반영해 재작성합니다. 기본 80."
|
||||||
|
},
|
||||||
|
"g1nation.reportQaMaxRevisions": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 2,
|
||||||
|
"description": "보고서 QA 최대 재작성 횟수(0~5). 로컬 모델은 재작성당 수십 초가 걸릴 수 있어 기본 2회로 제한."
|
||||||
},
|
},
|
||||||
"g1nation.criticLoopEnabled": {
|
"g1nation.criticLoopEnabled": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -1060,6 +1082,11 @@
|
|||||||
"editPresentation": "multilineText",
|
"editPresentation": "multilineText",
|
||||||
"markdownDescription": "**ChunkedWriter 의 polish 단계 system prompt 를 직접 정의** — 답변 톤·구조를 도메인에 맞게 커스텀.\n\n비워두면 기본 polish persona (한 줄 요약 + subheading + 5-check self-review) 사용. 텍스트를 입력하면 그 내용이 그대로 polish 단계의 system prompt 로 들어갑니다.\n\n사용 예: 격식체 답변, 반말 답변, 법률·마케팅·의료 등 도메인 특화 톤. 사용자가 Astra Settings 패널 textarea 로 편집 권장."
|
"markdownDescription": "**ChunkedWriter 의 polish 단계 system prompt 를 직접 정의** — 답변 톤·구조를 도메인에 맞게 커스텀.\n\n비워두면 기본 polish persona (한 줄 요약 + subheading + 5-check self-review) 사용. 텍스트를 입력하면 그 내용이 그대로 polish 단계의 system prompt 로 들어갑니다.\n\n사용 예: 격식체 답변, 반말 답변, 법률·마케팅·의료 등 도메인 특화 톤. 사용자가 Astra Settings 패널 textarea 로 편집 권장."
|
||||||
},
|
},
|
||||||
|
"g1nation.claude.cliPath": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"markdownDescription": "**Claude 구독 연동** — Claude Code CLI 실행 파일 경로. 보통 비워두면 자동 탐지(`claude`, `/usr/local/bin/claude`, `/opt/homebrew/bin/claude` 등).\n\n사용법: ① 터미널에서 `npm install -g @anthropic-ai/claude-code` 설치 ② `claude` 실행해 **구독 계정으로 로그인** ③ Astra 모델 드롭다운에서 **`Claude 구독 · sonnet`** 선택. API 키·추가 과금 없이 구독 한도로 동작합니다."
|
||||||
|
},
|
||||||
"g1nation.liveStreamTokens": {
|
"g1nation.liveStreamTokens": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"default": true,
|
"default": true,
|
||||||
@@ -1238,6 +1265,11 @@
|
|||||||
"default": "",
|
"default": "",
|
||||||
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
|
"markdownDescription": "**Stocks Google Sheets ID** — `https://docs.google.com/spreadsheets/d/<여기>/...` URL 의 ID 부분. 미설정이면 watcher / `/stocks sync` 가 silent skip. OAuth scope 은 calendar 와 공유 (sheets scope 이미 포함)."
|
||||||
},
|
},
|
||||||
|
"g1nation.stocks.autoBackup": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"markdownDescription": "**stocks.json 자동 클라우드 백업** (v2.2.297) — 목록이 바뀔 때마다(추가/제거/가격갱신/발굴 편입) 30초 디바운스 후 `g1nation.stocks.spreadsheetId` spreadsheet 의 `_backup` 탭에 원본 JSON 그대로 백업. 컴퓨터를 바꿔도 `/stocks restore` 한 번으로 복원. spreadsheetId 미설정이면 조용히 skip. 수동 백업은 `/stocks backup`."
|
||||||
|
},
|
||||||
"g1nation.stocks.sheetSwing": {
|
"g1nation.stocks.sheetSwing": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "Sheet1",
|
"default": "Sheet1",
|
||||||
@@ -1257,6 +1289,21 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
|
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
|
||||||
|
},
|
||||||
|
"g1nation.investigationModel": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
|
||||||
|
},
|
||||||
|
"g1nation.kvCachePromptSplit": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": true,
|
||||||
|
"markdownDescription": "**KV 캐시 친화 프롬프트 분리** — 시스템 프롬프트를 '불변 본문(message[0])'과 '턴별 동적 컨텍스트(마지막 질문 직전 주입)'로 분리해 LM Studio/llama.cpp 의 prompt cache 프리픽스 재사용을 극대화. 매 턴 전체 재프리필(1만+ 토큰, 수십 초)을 직전 교환 분량으로 줄인다. 특정 모델 템플릿에서 응답 품질 문제가 보이면 끄세요."
|
||||||
|
},
|
||||||
|
"g1nation.retrievalTokenBudget": {
|
||||||
|
"type": "number",
|
||||||
|
"default": 0,
|
||||||
|
"markdownDescription": "**RAG 검색 토큰 예산 상한** — 두뇌/메모리 검색 결과가 프롬프트에서 차지할 최대 토큰. `0 = 자동`(컨텍스트 창의 12%, 2,500~6,000 클램프). 값이 클수록 근거는 풍부해지지만 로컬 모델 프리필이 느려진다."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+187
-24
@@ -5,6 +5,8 @@ import * as fs from 'fs';
|
|||||||
import {
|
import {
|
||||||
findBrainFiles,
|
findBrainFiles,
|
||||||
getSystemPrompt,
|
getSystemPrompt,
|
||||||
|
getStaticSystemPrompt,
|
||||||
|
getDateTimeContextBlock,
|
||||||
shouldAutoPushBrain,
|
shouldAutoPushBrain,
|
||||||
buildApiUrl,
|
buildApiUrl,
|
||||||
getActiveBrainProfile,
|
getActiveBrainProfile,
|
||||||
@@ -24,7 +26,8 @@ import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/
|
|||||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||||
import { extractUrls } from './features/web/webFetch';
|
import { extractUrls } from './features/web/webFetch';
|
||||||
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
|
import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
|
||||||
|
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
|
||||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||||
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
||||||
@@ -41,6 +44,7 @@ import {
|
|||||||
} from './lib/contextBuilders/promptDetection';
|
} from './lib/contextBuilders/promptDetection';
|
||||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||||
|
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
|
||||||
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
|
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
|
||||||
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
|
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
|
||||||
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './lib/contextBuilders/projectEvidence';
|
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './lib/contextBuilders/projectEvidence';
|
||||||
@@ -102,6 +106,7 @@ import {
|
|||||||
} from './features/secondBrainTrace';
|
} from './features/secondBrainTrace';
|
||||||
import { MemoryManager } from './memory';
|
import { MemoryManager } from './memory';
|
||||||
import { RetrievalOrchestrator } from './retrieval';
|
import { RetrievalOrchestrator } from './retrieval';
|
||||||
|
import { embedQuery } from './retrieval/embeddings';
|
||||||
import { isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
|
import { isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
|
||||||
import { buildKnowledgeMixPolicy, ResolvedKnowledgeMix } from './retrieval/knowledgeMix';
|
import { buildKnowledgeMixPolicy, ResolvedKnowledgeMix } from './retrieval/knowledgeMix';
|
||||||
import {
|
import {
|
||||||
@@ -161,6 +166,7 @@ import { applyRunCommandActions } from './agent/actions/runCommand';
|
|||||||
import { applyCalculateActions } from './agent/actions/calculate';
|
import { applyCalculateActions } from './agent/actions/calculate';
|
||||||
import { applyRunCodeActions } from './agent/actions/runCode';
|
import { applyRunCodeActions } from './agent/actions/runCode';
|
||||||
import { applyListFilesActions } from './agent/actions/listFiles';
|
import { applyListFilesActions } from './agent/actions/listFiles';
|
||||||
|
import { applyInvestigateFilesActions } from './agent/actions/investigateFiles';
|
||||||
import { applyWebFetchActions } from './agent/actions/webFetch';
|
import { applyWebFetchActions } from './agent/actions/webFetch';
|
||||||
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
||||||
import { applyCalendarActions } from './agent/actions/calendar';
|
import { applyCalendarActions } from './agent/actions/calendar';
|
||||||
@@ -314,6 +320,18 @@ export class AgentExecutor {
|
|||||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||||
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
|
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
|
||||||
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||||
|
/** [v2.2.309] 이번 turn 의 액션 실행 통계 — Hollow Investigation 감지용 (loop depth 누적). */
|
||||||
|
actionStats: { reads: number; lists: number; investigates: number };
|
||||||
|
/** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
|
||||||
|
investigationModelOverride: string | null;
|
||||||
|
/**
|
||||||
|
* [v2.2.311] depth 0 에서 빌드한 memoryCtx 문자열 캐시 — continuation depth 는
|
||||||
|
* 재검색하지 않고 이걸 재사용한다. 종전엔 depth 마다 buildMemoryContext 를 다시
|
||||||
|
* 돌렸는데, (a) 검색 3~8초가 라운드마다 추가되고 (b) continuation 의 prompt 는
|
||||||
|
* null 이라 *빈 쿼리로* 재검색해 엉뚱한 청크로 갈아끼우고 (c) 프롬프트가 흔들려
|
||||||
|
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
|
||||||
|
*/
|
||||||
|
memoryCtxCache: string | null;
|
||||||
} = {
|
} = {
|
||||||
retrieval: null,
|
retrieval: null,
|
||||||
lessons: [],
|
lessons: [],
|
||||||
@@ -321,6 +339,9 @@ export class AgentExecutor {
|
|||||||
dynamicBlocks: new Map(),
|
dynamicBlocks: new Map(),
|
||||||
selfCheckSources: [],
|
selfCheckSources: [],
|
||||||
confidenceSignals: null,
|
confidenceSignals: null,
|
||||||
|
actionStats: { reads: 0, lists: 0, investigates: 0 },
|
||||||
|
investigationModelOverride: null,
|
||||||
|
memoryCtxCache: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||||
@@ -331,6 +352,10 @@ export class AgentExecutor {
|
|||||||
this._turnCtx.dynamicBlocks.clear();
|
this._turnCtx.dynamicBlocks.clear();
|
||||||
this._turnCtx.selfCheckSources = [];
|
this._turnCtx.selfCheckSources = [];
|
||||||
this._turnCtx.confidenceSignals = null;
|
this._turnCtx.confidenceSignals = null;
|
||||||
|
// actionStats / investigationModelOverride 는 여기서 리셋하지 않는다 —
|
||||||
|
// resetTurnContext 는 continuation depth 에서도 호출되는데(메모리 컨텍스트 재구축),
|
||||||
|
// 이 둘은 사용자 turn 전체(모든 depth)에 걸쳐 누적/유지되어야 한다.
|
||||||
|
// 리셋은 loopDepth === 0 진입부에서만 (handlePrompt 초입).
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly options: AgentExecutorOptions;
|
private readonly options: AgentExecutorOptions;
|
||||||
@@ -358,6 +383,44 @@ export class AgentExecutor {
|
|||||||
this.restoreLastSession();
|
this.restoreLastSession();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [코어 수렴] 기업 모드(dispatcher)용 두뇌 컨텍스트 블록 — 경량 scopedBrainRetriever
|
||||||
|
* 대신 메인 오케스트레이터의 전체 검색 경로(임베딩 하이브리드·청크)를 태운다.
|
||||||
|
* 텔레그램 경량 경로는 의존성 최소화를 위해 의도적으로 그대로 둔다.
|
||||||
|
* 반환 포맷은 기존 buildContextBlock 과 동일 — specialist 프롬프트 형식 불변.
|
||||||
|
*/
|
||||||
|
public async retrieveBrainBlockForCompany(query: string, scopeFolders: string[], limit: number): Promise<string> {
|
||||||
|
const config = getConfig();
|
||||||
|
const brain = getActiveBrainProfile();
|
||||||
|
if (!brain?.localBrainPath) return '';
|
||||||
|
let queryEmbedding: number[] | undefined;
|
||||||
|
if (config.embeddingModel) {
|
||||||
|
try {
|
||||||
|
queryEmbedding = await Promise.race([
|
||||||
|
embedQuery(query, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||||
|
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
|
||||||
|
]);
|
||||||
|
} catch { queryEmbedding = undefined; }
|
||||||
|
}
|
||||||
|
const chunks = this.retrievalOrchestrator.retrieveBrainChunksScoped(query, brain, {
|
||||||
|
limit,
|
||||||
|
scopeFolders,
|
||||||
|
queryEmbedding,
|
||||||
|
embeddingModel: config.embeddingModel || undefined,
|
||||||
|
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||||
|
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
|
||||||
|
chunkTargetChars: config.chunkTargetChars,
|
||||||
|
});
|
||||||
|
if (chunks.length === 0) return '';
|
||||||
|
const header = scopeFolders.length > 0
|
||||||
|
? '[제2뇌 컨텍스트 — 매핑된 지식 폴더에서 검색]'
|
||||||
|
: '[제2뇌 컨텍스트 — 전체 브레인 검색]';
|
||||||
|
const body = chunks
|
||||||
|
.map((c, i) => `(#${i + 1}) ${c.title}\n${c.content}`)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
return `${header}\n\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
private async restoreLastSession() {
|
private async restoreLastSession() {
|
||||||
return restoreLastSessionFn({
|
return restoreLastSessionFn({
|
||||||
sessionManager: this.sessionManager,
|
sessionManager: this.sessionManager,
|
||||||
@@ -505,6 +568,10 @@ export class AgentExecutor {
|
|||||||
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
|
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
|
||||||
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
||||||
this.resetTurnContext();
|
this.resetTurnContext();
|
||||||
|
// [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
|
||||||
|
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
|
||||||
|
this._turnCtx.investigationModelOverride = null;
|
||||||
|
this._turnCtx.memoryCtxCache = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Prepare Context
|
// 1. Prepare Context
|
||||||
@@ -578,6 +645,7 @@ export class AgentExecutor {
|
|||||||
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
||||||
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
||||||
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
||||||
|
- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 <investigate_files path="상대경로" focus="조사 목적"/> 태그 하나를 emit 하라 — 시스템이 폴더의 모든 텍스트 파일을 실제로 읽어 파일별 노트를 주입한다. list_files 로 받은 *파일명*만 보고 내용을 서술하는 것은 날조다.
|
||||||
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
||||||
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
||||||
}
|
}
|
||||||
@@ -597,24 +665,28 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget
|
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
|
||||||
// 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl).
|
// 지적*("또 ~하네", "하지 말라고 했잖아")이면 fire-and-forget 캡처 — 오류 분류 →
|
||||||
// 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다.
|
// 태깅 레슨 + 회귀 케이스. 행동 지적은 추가로 Standing Rule 로 정규화되어
|
||||||
|
// 다음 턴부터(새 세션 포함) 매 턴 주입된다 (v2.2.308 — 세션 휘발 문제 해결).
|
||||||
// 턴 응답을 막지 않는다 (await 없음).
|
// 턴 응답을 막지 않는다 (await 없음).
|
||||||
if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) {
|
const isFactCorrection = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt));
|
||||||
|
const isBehaviorComplaint = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeBehaviorComplaint(prompt));
|
||||||
|
if (isFactCorrection || isBehaviorComplaint) {
|
||||||
const visible = this.chatHistory.filter(m => !m.internal);
|
const visible = this.chatHistory.filter(m => !m.internal);
|
||||||
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
||||||
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
|
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
|
||||||
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
|
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
|
||||||
if (lastAssistant && priorQuestion) {
|
if (lastAssistant && priorQuestion) {
|
||||||
void captureCorrection({
|
void captureCorrection({
|
||||||
brainPath: activeBrain.localBrainPath,
|
brainPath: activeBrain!.localBrainPath,
|
||||||
question: priorQuestion,
|
question: priorQuestion,
|
||||||
wrongAnswer: lastAssistant.content,
|
wrongAnswer: lastAssistant.content,
|
||||||
correction: prompt,
|
correction: prompt!,
|
||||||
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
|
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
|
||||||
|
behaviorHint: isBehaviorComplaint,
|
||||||
}).then(file => {
|
}).then(file => {
|
||||||
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file });
|
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file, behavior: isBehaviorComplaint });
|
||||||
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
|
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -630,7 +702,18 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
|
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
|
||||||
const actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
let actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
||||||
|
// [v2.2.309-D] 조사형 요청은 설정된 상위 모델로 라우팅 (예: "claude-code:sonnet").
|
||||||
|
// 소형 로컬 모델의 lazy tool-use 할루시네이션 대책의 마지막 층 — depth 0 에서
|
||||||
|
// 결정하고 continuation(액션 결과로 이어지는 답변)에도 같은 모델을 유지한다.
|
||||||
|
if (loopDepth === 0 && prompt && config.investigationModel
|
||||||
|
&& isLocalInvestigationPrompt(prompt)) {
|
||||||
|
this._turnCtx.investigationModelOverride = config.investigationModel;
|
||||||
|
logInfo('조사 요청 감지 — investigationModel 로 라우팅.', { model: config.investigationModel });
|
||||||
|
}
|
||||||
|
if (this._turnCtx.investigationModelOverride) {
|
||||||
|
actualModel = this._turnCtx.investigationModelOverride;
|
||||||
|
}
|
||||||
// Bound the in-memory history before building the request — shrinks bulky
|
// Bound the in-memory history before building the request — shrinks bulky
|
||||||
// older tool-result bodies and drops the oldest messages past the cap.
|
// older tool-result bodies and drops the oldest messages past the cap.
|
||||||
capChatHistory(this.chatHistory, {
|
capChatHistory(this.chatHistory, {
|
||||||
@@ -684,11 +767,17 @@ export class AgentExecutor {
|
|||||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||||
: '';
|
: '';
|
||||||
const retrievalStartMs = Date.now();
|
const retrievalStartMs = Date.now();
|
||||||
const memoryCtx = isCasualConversation
|
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
|
||||||
? ''
|
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
|
||||||
: await (async () => {
|
// (dynamicBlocks 등 turnCtx 파생물도 depth 0 것이 그대로 유지된다).
|
||||||
|
let memoryCtx: string;
|
||||||
|
if (isCasualConversation) {
|
||||||
|
memoryCtx = '';
|
||||||
|
} else if (loopDepth > 0 && this._turnCtx.memoryCtxCache !== null) {
|
||||||
|
memoryCtx = this._turnCtx.memoryCtxCache;
|
||||||
|
} else {
|
||||||
this.resetTurnContext();
|
this.resetTurnContext();
|
||||||
return buildMemoryContextFn({
|
memoryCtx = await buildMemoryContextFn({
|
||||||
currentPrompt: prompt || '',
|
currentPrompt: prompt || '',
|
||||||
activeBrain,
|
activeBrain,
|
||||||
agentSkillFile: options.agentSkillFile,
|
agentSkillFile: options.agentSkillFile,
|
||||||
@@ -699,7 +788,8 @@ export class AgentExecutor {
|
|||||||
currentTaskId: this.currentTaskId,
|
currentTaskId: this.currentTaskId,
|
||||||
turnCtx: this._turnCtx,
|
turnCtx: this._turnCtx,
|
||||||
});
|
});
|
||||||
})();
|
this._turnCtx.memoryCtxCache = memoryCtx;
|
||||||
|
}
|
||||||
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
|
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
|
||||||
recordTelemetry({
|
recordTelemetry({
|
||||||
kind: 'retrieval',
|
kind: 'retrieval',
|
||||||
@@ -735,9 +825,19 @@ export class AgentExecutor {
|
|||||||
? buildPriorTurnConclusionContext(this.chatHistory)
|
? buildPriorTurnConclusionContext(this.chatHistory)
|
||||||
: '';
|
: '';
|
||||||
// System prompt build (agent vs astra mode) → src/agent/handlePrompt/{buildAgentModeSystemPrompt,buildAstraModeSystemPrompt}.ts
|
// System prompt build (agent vs astra mode) → src/agent/handlePrompt/{buildAgentModeSystemPrompt,buildAstraModeSystemPrompt}.ts
|
||||||
const fullSystemPrompt: string = isAgentMode
|
//
|
||||||
|
// [KV 캐시 분리 v2.2.311] 기본 경로(호출자가 systemPrompt 를 넘기지 않은 경우)는
|
||||||
|
// message[0] 을 *정적 본문만* 으로 고정하고, 날짜/RAG/[CONTEXT]/동적 블록 전부를
|
||||||
|
// dynamicContextTail 로 분리해 computeBudgetedRequest 가 마지막 user 메시지 직전에
|
||||||
|
// 삽입한다. llama.cpp prompt cache 가 정적 프롬프트+과거 히스토리를 재사용하게 되어
|
||||||
|
// 매 턴 전체 재프리필(실측 13k 토큰 ≈ 90초)이 "직전 교환 + tail" 로 줄어든다.
|
||||||
|
// 커스텀 systemPrompt 호출자(멀티에이전트 등)는 종전 단일-시스템 경로 유지.
|
||||||
|
const kvSplitEnabled = getConfig().kvCachePromptSplit !== false
|
||||||
|
&& options.systemPrompt === undefined;
|
||||||
|
const builderBasePrompt = kvSplitEnabled ? '' : systemPrompt;
|
||||||
|
const builtSystemPrompt: string = isAgentMode
|
||||||
? buildAgentModeSystemPrompt({
|
? buildAgentModeSystemPrompt({
|
||||||
systemPrompt,
|
systemPrompt: builderBasePrompt,
|
||||||
agentSkillContext: options.agentSkillContext || '',
|
agentSkillContext: options.agentSkillContext || '',
|
||||||
modeBridgeCtx,
|
modeBridgeCtx,
|
||||||
priorConclusionCtx,
|
priorConclusionCtx,
|
||||||
@@ -753,7 +853,7 @@ export class AgentExecutor {
|
|||||||
})
|
})
|
||||||
: buildAstraModeSystemPrompt({
|
: buildAstraModeSystemPrompt({
|
||||||
prompt,
|
prompt,
|
||||||
systemPrompt,
|
systemPrompt: builderBasePrompt,
|
||||||
modeBridgeCtx,
|
modeBridgeCtx,
|
||||||
priorConclusionCtx,
|
priorConclusionCtx,
|
||||||
designerCtx,
|
designerCtx,
|
||||||
@@ -768,6 +868,12 @@ export class AgentExecutor {
|
|||||||
knowledgeMix: this._turnCtx.knowledgeMix,
|
knowledgeMix: this._turnCtx.knowledgeMix,
|
||||||
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
dynamicBlocks: this._turnCtx.dynamicBlocks,
|
||||||
});
|
});
|
||||||
|
// Split 모드: head = 정적 프롬프트(불변), tail = 날짜 + 빌더 산출(동적 전부).
|
||||||
|
// Legacy 모드: 종전 그대로 head 에 전부.
|
||||||
|
const fullSystemPrompt: string = kvSplitEnabled ? getStaticSystemPrompt() : builtSystemPrompt;
|
||||||
|
const dynamicContextTail: string | undefined = kvSplitEnabled
|
||||||
|
? `${getDateTimeContextBlock()}${builtSystemPrompt}`
|
||||||
|
: undefined;
|
||||||
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
// Context budget computation → src/agent/handlePrompt/computeBudgetedRequest.ts
|
||||||
const imageCount = (reqMessages as any[])
|
const imageCount = (reqMessages as any[])
|
||||||
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
.reduce((n, m) => n + (Array.isArray(m?.images) ? m.images.length : 0), 0);
|
||||||
@@ -803,7 +909,7 @@ export class AgentExecutor {
|
|||||||
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
|
const lastUserIdx = reqMessages.map((m) => m.role).lastIndexOf('user');
|
||||||
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
|
const lastUser = lastUserIdx >= 0 ? reqMessages[lastUserIdx] : undefined;
|
||||||
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
const content = typeof lastUser?.content === 'string' ? lastUser.content : '';
|
||||||
const sysTokens = estimateTokens(fullSystemPrompt) + 4;
|
const sysTokens = estimateTokens(fullSystemPrompt) + (dynamicContextTail ? estimateTokens(dynamicContextTail) : 0) + 4;
|
||||||
const mrCfg = {
|
const mrCfg = {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
triggerRatio: config.mapReduceTriggerRatio,
|
triggerRatio: config.mapReduceTriggerRatio,
|
||||||
@@ -863,6 +969,7 @@ export class AgentExecutor {
|
|||||||
|
|
||||||
const _budget = computeBudgetedRequest({
|
const _budget = computeBudgetedRequest({
|
||||||
fullSystemPrompt,
|
fullSystemPrompt,
|
||||||
|
dynamicContextTail,
|
||||||
reqMessages,
|
reqMessages,
|
||||||
actualModel,
|
actualModel,
|
||||||
config,
|
config,
|
||||||
@@ -946,10 +1053,22 @@ export class AgentExecutor {
|
|||||||
// policy enforcement) emits a final `streamReplace` so the bubble
|
// policy enforcement) emits a final `streamReplace` so the bubble
|
||||||
// ends up matching the cleaned answer regardless of what slipped
|
// ends up matching the cleaned answer regardless of what slipped
|
||||||
// through live.
|
// through live.
|
||||||
// [Clean Stream] g1nation.liveStreamTokens=false (기본) 이면 토큰을 내부에만
|
// [Clean Stream] liveStreamTokens=true(기본) 면 토큰을 실시간 표시하되,
|
||||||
// 누적하고 sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커가 잠깐
|
// LiveReasoningFilter 가 <think>/<|channel|>thought 류 추론 구간을 토큰 단위로
|
||||||
// 화면에 노출되는 누설을 원천 차단한다. true 로 두면 legacy 라이브 스트리밍.
|
// 걸러 사용자가 볼 필요 없는 생각 텍스트는 화면에 흐르지 않는다. false 면
|
||||||
|
// 내부 누적 후 sanitize 된 최종 답변만 한 번에 표시.
|
||||||
const postLiveDeltas = loopDepth === 0 && getConfig().liveStreamTokens === true;
|
const postLiveDeltas = loopDepth === 0 && getConfig().liveStreamTokens === true;
|
||||||
|
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
||||||
|
const postLiveToken = (token: string) => {
|
||||||
|
const visible = liveFilter ? liveFilter.push(token) : token;
|
||||||
|
// [v2.2.310] LM Studio LSEP 구분자 — 여는 마커 없이 흐른 추론이 화면에
|
||||||
|
// 이미 표시된 상태. streamReplace 로 걷어내고 실제 답변부터 다시.
|
||||||
|
if (liveFilter?.consumeSeparatorSignal()) {
|
||||||
|
this.webview?.postMessage({ type: 'streamReplace', value: visible });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
||||||
|
};
|
||||||
|
|
||||||
let lmStudioStats: ChatStreamStats | undefined;
|
let lmStudioStats: ChatStreamStats | undefined;
|
||||||
if (useLmStudioSdk) {
|
if (useLmStudioSdk) {
|
||||||
@@ -970,7 +1089,7 @@ export class AgentExecutor {
|
|||||||
if (this.isStaleRun(runId)) return;
|
if (this.isStaleRun(runId)) return;
|
||||||
if (token) {
|
if (token) {
|
||||||
aiResponseText += token;
|
aiResponseText += token;
|
||||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
if (postLiveDeltas) postLiveToken(token);
|
||||||
}
|
}
|
||||||
if (stopReason) finishStopReason = stopReason;
|
if (stopReason) finishStopReason = stopReason;
|
||||||
if (stats) lmStudioStats = stats;
|
if (stats) lmStudioStats = stats;
|
||||||
@@ -1045,7 +1164,7 @@ export class AgentExecutor {
|
|||||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||||
if (token) {
|
if (token) {
|
||||||
aiResponseText += token;
|
aiResponseText += token;
|
||||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
if (postLiveDeltas) postLiveToken(token);
|
||||||
}
|
}
|
||||||
const fr = engine === 'lmstudio'
|
const fr = engine === 'lmstudio'
|
||||||
? json.choices?.[0]?.finish_reason
|
? json.choices?.[0]?.finish_reason
|
||||||
@@ -1077,7 +1196,7 @@ export class AgentExecutor {
|
|||||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||||
if (token) {
|
if (token) {
|
||||||
aiResponseText += token;
|
aiResponseText += token;
|
||||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
if (postLiveDeltas) postLiveToken(token);
|
||||||
}
|
}
|
||||||
const fr = engine === 'lmstudio'
|
const fr = engine === 'lmstudio'
|
||||||
? json.choices?.[0]?.finish_reason
|
? json.choices?.[0]?.finish_reason
|
||||||
@@ -1379,6 +1498,22 @@ export class AgentExecutor {
|
|||||||
await this.context.workspaceState.update('lastActionStr', currentActionStr);
|
await this.context.workspaceState.update('lastActionStr', currentActionStr);
|
||||||
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
|
logInfo('Autonomous loop continuing after actions.', { loopDepth: loopDepth + 1, actions: report });
|
||||||
|
|
||||||
|
// [v2.2.312] 중간 라운드 본문 표시 — 액션과 *함께* 작성된 섹션이 화면에서
|
||||||
|
// 증발하던 버그 수정. 종전엔 액션이 있는 라운드는 여기서 return 하며 본문을
|
||||||
|
// 한 번도 webview 에 보내지 않았고(표시는 라이브 스트리밍뿐 — depth 0 전용),
|
||||||
|
// 최종 라운드만 streamChunk 로 붙었다. 그 결과 모델이 히스토리에서 자기 이전
|
||||||
|
// 섹션(1~3)을 보고 "## 4."부터 이어 써서, 사용자에게는 4번부터 시작하는
|
||||||
|
// 보고서가 도착했다 (실사례). 라이브로 이미 표시된 depth 0 는 중복 방지로 제외.
|
||||||
|
if (loopDepth > 0 || !postLiveDeltas) {
|
||||||
|
try {
|
||||||
|
const { stripActionTagsForDisplay } = await import('./agent/actions/stripForDisplay');
|
||||||
|
const roundVisible = stripActionTagsForDisplay(finalAssistantContent);
|
||||||
|
if (roundVisible) {
|
||||||
|
this.webview.postMessage({ type: 'streamChunk', value: `${loopDepth > 0 ? '\n\n' : ''}${roundVisible}` });
|
||||||
|
}
|
||||||
|
} catch { /* 표시 실패가 루프를 막지 않음 */ }
|
||||||
|
}
|
||||||
|
|
||||||
// Explicitly tell the AI to look at the results and continue
|
// Explicitly tell the AI to look at the results and continue
|
||||||
const continuationPrompt = `The requested local action has been executed.\nAction report:\n${report.join('\n')}\nUse the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.`;
|
const continuationPrompt = `The requested local action has been executed.\nAction report:\n${report.join('\n')}\nUse the action result messages already in the conversation to answer the user's original request directly, in the user's language. Do not say you are waiting for the next instruction.`;
|
||||||
|
|
||||||
@@ -1453,6 +1588,7 @@ export class AgentExecutor {
|
|||||||
engine,
|
engine,
|
||||||
selfCheckSources: this._turnCtx.selfCheckSources,
|
selfCheckSources: this._turnCtx.selfCheckSources,
|
||||||
confidenceSignals: this._turnCtx.confidenceSignals,
|
confidenceSignals: this._turnCtx.confidenceSignals,
|
||||||
|
actionStats: this._turnCtx.actionStats,
|
||||||
callNonStreaming: (p) => this.callNonStreaming(p),
|
callNonStreaming: (p) => this.callNonStreaming(p),
|
||||||
getAbortSignal: () => this.abortController?.signal,
|
getAbortSignal: () => this.abortController?.signal,
|
||||||
getWebview: () => this.webview,
|
getWebview: () => this.webview,
|
||||||
@@ -1462,6 +1598,24 @@ export class AgentExecutor {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||||
|
// [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
|
||||||
|
// post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
|
||||||
|
// "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.
|
||||||
|
try {
|
||||||
|
const hollowInv = detectHollowInvestigation(finalAssistantContent, this._turnCtx.actionStats);
|
||||||
|
if (hollowInv.hollow) {
|
||||||
|
this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
|
||||||
|
logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
|
||||||
|
} else {
|
||||||
|
// [v2.2.312] 반대 방향 — 파일을 읽고도 근거 인용 없는 일반론 분석 경고.
|
||||||
|
const { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } = await import('./intelligence/investigationPipeline');
|
||||||
|
const ug = detectUngroundedAnalysis(finalAssistantContent, this._turnCtx.actionStats);
|
||||||
|
if (ug.ungrounded) {
|
||||||
|
this.webview.postMessage({ type: 'streamChunk', value: formatUngroundedAnalysisFooter(ug.readCount) });
|
||||||
|
logInfo('Ungrounded Analysis 감지 (continuation).', { readCount: ug.readCount, fileRefs: ug.fileRefs });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 감지 실패가 답변을 막지 않음 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1720,6 +1874,15 @@ export class AgentExecutor {
|
|||||||
await applyCalculateActions(ctx);
|
await applyCalculateActions(ctx);
|
||||||
await applyRunCodeActions(ctx);
|
await applyRunCodeActions(ctx);
|
||||||
await applyListFilesActions(ctx);
|
await applyListFilesActions(ctx);
|
||||||
|
await applyInvestigateFilesActions(ctx);
|
||||||
|
|
||||||
|
// [v2.2.309] Hollow Investigation 감지용 액션 통계 (report 마커 기반, turn 누적).
|
||||||
|
for (const line of report) {
|
||||||
|
if (line.startsWith('📖 Read:')) this._turnCtx.actionStats.reads++;
|
||||||
|
else if (line.startsWith('📂 Listed:')) this._turnCtx.actionStats.lists++;
|
||||||
|
else if (line.startsWith('🔎 Investigated:')) this._turnCtx.actionStats.investigates++;
|
||||||
|
}
|
||||||
|
|
||||||
await applyWebFetchActions(ctx);
|
await applyWebFetchActions(ctx);
|
||||||
await applyBrainOpsActions(ctx);
|
await applyBrainOpsActions(ctx);
|
||||||
await applyCalendarActions(ctx);
|
await applyCalendarActions(ctx);
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { execFile } from 'child_process';
|
import { detectPython, execFileCapture, _resetPythonCache } from '../../lib/execUtil';
|
||||||
import type { HandlerContext } from './types';
|
import type { HandlerContext } from './types';
|
||||||
|
|
||||||
|
// [코어 수렴] 실행 래퍼·Python 탐지는 lib/execUtil 단일 구현을 사용한다.
|
||||||
|
// 기존 소비자(runCode·테스트) 호환을 위해 재수출.
|
||||||
|
export { detectPython, _resetPythonCache };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
|
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
|
||||||
*
|
*
|
||||||
@@ -24,35 +28,6 @@ const MAX_CALC_PER_TURN = 4; // 한 턴에 과도한 실행 방지
|
|||||||
const MAX_CODE_CHARS = 4_000;
|
const MAX_CODE_CHARS = 4_000;
|
||||||
const MAX_OUTPUT_CHARS = 4_000; // 모델 컨텍스트 보호
|
const MAX_OUTPUT_CHARS = 4_000; // 모델 컨텍스트 보호
|
||||||
|
|
||||||
/** 감지된 Python 실행 파일 캐시 (프로세스 생존 동안 유지, null = 미탐지/없음). */
|
|
||||||
let _pythonCmd: string | null | undefined;
|
|
||||||
|
|
||||||
function execFileP(cmd: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
|
|
||||||
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
|
||||||
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** python3 → python → py 순으로 실행 가능한 인터프리터 탐지 (OS 자동 대응, run_code 도 공유). */
|
|
||||||
export async function detectPython(): Promise<string | null> {
|
|
||||||
if (_pythonCmd !== undefined) return _pythonCmd;
|
|
||||||
for (const cmd of ['python3', 'python', 'py']) {
|
|
||||||
try {
|
|
||||||
await execFileP(cmd, ['--version'], 3_000);
|
|
||||||
_pythonCmd = cmd;
|
|
||||||
return cmd;
|
|
||||||
} catch { /* 다음 후보 */ }
|
|
||||||
}
|
|
||||||
_pythonCmd = null;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 테스트용 — 캐시 초기화. */
|
|
||||||
export function _resetPythonCache(): void { _pythonCmd = undefined; }
|
|
||||||
|
|
||||||
export async function applyCalculateActions(ctx: HandlerContext): Promise<void> {
|
export async function applyCalculateActions(ctx: HandlerContext): Promise<void> {
|
||||||
const { aiMessage, report } = ctx;
|
const { aiMessage, report } = ctx;
|
||||||
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
|
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
|
||||||
@@ -83,9 +58,20 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise<void>
|
|||||||
}
|
}
|
||||||
|
|
||||||
const label = code.replace(/\s+/g, ' ').slice(0, 60);
|
const label = code.replace(/\s+/g, ' ').slice(0, 60);
|
||||||
try {
|
const r = await execFileCapture(python, ['-I', '-c', code], { timeoutMs: CALC_TIMEOUT_MS, maxBuffer: 512 * 1024 });
|
||||||
const { stdout } = await execFileP(python, ['-I', '-c', code], CALC_TIMEOUT_MS);
|
|
||||||
const out = stdout.trim();
|
if (r.timedOut || r.code !== 0) {
|
||||||
|
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
|
||||||
|
const tail = (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
|
||||||
|
report.push(`❌ Calculate failed: ${label}`);
|
||||||
|
ctx.chatHistory.push({
|
||||||
|
role: 'system', internal: true,
|
||||||
|
content: `[Result of calculate — ERROR]\n${r.timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = r.stdout.trim();
|
||||||
if (!out) {
|
if (!out) {
|
||||||
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
||||||
report.push(`⚠️ Calculated (no output): ${label}`);
|
report.push(`⚠️ Calculated (no output): ${label}`);
|
||||||
@@ -101,15 +87,5 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise<void>
|
|||||||
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
|
||||||
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
|
|
||||||
const tail = String(err?.stderr || err?.message || err).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
|
|
||||||
const timedOut = err?.killed || /ETIMEDOUT|timed? ?out/i.test(String(err?.message || ''));
|
|
||||||
report.push(`❌ Calculate failed: ${label}`);
|
|
||||||
ctx.chatHistory.push({
|
|
||||||
role: 'system', internal: true,
|
|
||||||
content: `[Result of calculate — ERROR]\n${timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* <investigate_files path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사.
|
||||||
|
*
|
||||||
|
* list_files 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||||
|
* 읽어 파일별 추출 노트(Map)를 만들고 병합 노트를 컨텍스트에 주입한다 — 모델이
|
||||||
|
* 파일명만 보고 내용을 상상하는 헛조사(할루시네이션)를 구조적으로 차단. (v2.2.309)
|
||||||
|
* 노트는 내용 해시 캐시로 영속화되어 재조사 시 재사용된다 (온디맨드 지식화).
|
||||||
|
*/
|
||||||
|
import { HandlerContext } from './types';
|
||||||
|
import { validatePath } from '../../security';
|
||||||
|
import { getConfig } from '../../config';
|
||||||
|
import { simpleChatCompletion } from '../../intelligence/llmCall';
|
||||||
|
import { runInvestigation, formatInvestigationNotes } from '../../intelligence/investigationPipeline';
|
||||||
|
import { logInfo, logError } from '../../utils';
|
||||||
|
|
||||||
|
export async function applyInvestigateFilesActions(ctx: HandlerContext): Promise<void> {
|
||||||
|
const { aiMessage, rootPath, report } = ctx;
|
||||||
|
const regex = /<investigate_files\s+path=['"]?([^'">]+)['"]?(?:\s+focus=['"]?([^'">]*)['"]?)?\s*\/?>(?:<\/investigate_files>)?/gi;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(aiMessage)) !== null) {
|
||||||
|
const relPath = match[1].trim() || '.';
|
||||||
|
const focus = (match[2] || '').trim();
|
||||||
|
try {
|
||||||
|
validatePath(rootPath, relPath); // 경로 탈출 방지 (결과는 pipeline 이 재해석)
|
||||||
|
const cfg = getConfig();
|
||||||
|
const llm = { baseUrl: cfg.ollamaUrl, model: cfg.defaultModel };
|
||||||
|
const result = await runInvestigation({
|
||||||
|
rootPath,
|
||||||
|
relDir: relPath,
|
||||||
|
llmCall: (system, user) => simpleChatCompletion(system, user, {
|
||||||
|
...llm, temperature: 0.1, maxTokens: 600, timeoutMs: 120000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.notes.length === 0) {
|
||||||
|
report.push(`🔎 Investigate: ${relPath} — 읽을 텍스트 파일 없음 (제외 ${result.skipped.length}건)`);
|
||||||
|
ctx.chatHistory.push({
|
||||||
|
role: 'system', internal: true,
|
||||||
|
content: `[Result of investigate_files "${relPath}"] 읽을 수 있는 텍스트 파일이 없다. 제외: ${result.skipped.slice(0, 10).join(', ') || '—'}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.chatHistory.push({ role: 'system', internal: true, content: formatInvestigationNotes(result, focus) });
|
||||||
|
report.push(`🔎 Investigated: ${relPath} — 파일 ${result.notes.length}개 정독 (캐시 ${result.cacheHits} · LLM ${result.llmCalls} · 제외 ${result.skipped.length})`);
|
||||||
|
logInfo('Investigation pipeline 완료.', { relPath, files: result.notes.length, cacheHits: result.cacheHits, llmCalls: result.llmCalls });
|
||||||
|
} catch (err: any) {
|
||||||
|
report.push(`❌ Investigate failed: ${relPath} — ${err?.message ?? err}`);
|
||||||
|
logError('Investigation pipeline 실패.', { relPath, error: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { execFile } from 'child_process';
|
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { validatePath } from '../../security';
|
import { validatePath } from '../../security';
|
||||||
import { detectPython } from './calculate';
|
import { detectPython, execFileCapture } from '../../lib/execUtil';
|
||||||
import type { HandlerContext } from './types';
|
import type { HandlerContext } from './types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,19 +21,6 @@ const RUN_TIMEOUT_MS = 15_000;
|
|||||||
const MAX_RUNS_PER_TURN = 2;
|
const MAX_RUNS_PER_TURN = 2;
|
||||||
const MAX_OUTPUT_CHARS = 4_000;
|
const MAX_OUTPUT_CHARS = 4_000;
|
||||||
|
|
||||||
function execFileP(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
execFile(cmd, args, { cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err: any, stdout, stderr) => {
|
|
||||||
resolve({
|
|
||||||
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
|
|
||||||
stdout: String(stdout || ''),
|
|
||||||
stderr: String(stderr || ''),
|
|
||||||
timedOut: !!err?.killed,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const cap = (s: string) => (s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : s);
|
const cap = (s: string) => (s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : s);
|
||||||
|
|
||||||
export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
|
export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
|
||||||
@@ -87,7 +73,7 @@ export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const rel = path.relative(rootPath, absPath) || relPath;
|
const rel = path.relative(rootPath, absPath) || relPath;
|
||||||
const res = await execFileP(cmd, args, rootPath);
|
const res = await execFileCapture(cmd, args, { timeoutMs: RUN_TIMEOUT_MS, cwd: rootPath, maxBuffer: 1024 * 1024 });
|
||||||
const outBlock = [
|
const outBlock = [
|
||||||
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
|
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
|
||||||
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
|
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* [v2.2.312] 화면 표시용 액션 태그 제거.
|
||||||
|
*
|
||||||
|
* 액션 루프의 중간 라운드(액션과 함께 본문 섹션을 쓴 라운드)를 사용자 버블에
|
||||||
|
* 표시할 때, <read_file/> 같은 실행 태그 원문이 그대로 노출되지 않도록 걷어낸다.
|
||||||
|
* 실행 자체는 이 함수와 무관 — executeActions 는 원문(cleanedVisible)을 그대로
|
||||||
|
* 받는다. 여기는 *표시 전용* 경로다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACTION_TAG_NAMES = [
|
||||||
|
'read_file', 'list_files', 'investigate_files',
|
||||||
|
'create_file', 'edit_file', 'delete_file',
|
||||||
|
'run_command', 'run_code',
|
||||||
|
'read_brain', 'list_brain',
|
||||||
|
'read_sheet', 'write_sheet', 'append_sheet',
|
||||||
|
'fetch_url', 'calculate',
|
||||||
|
'create_calendar_event', 'add_task', 'update_task', 'complete_task',
|
||||||
|
// edit_file 내부 서브 태그 — 블록 제거가 실패한 잔재까지 정리.
|
||||||
|
'search', 'replace',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function stripActionTagsForDisplay(text: string): string {
|
||||||
|
let t = text || '';
|
||||||
|
for (const name of ACTION_TAG_NAMES) {
|
||||||
|
// 자기닫힘 <tag ... /> → 블록 <tag ...>...</tag> → 잔재 여는/닫는 태그 순.
|
||||||
|
t = t.replace(new RegExp(`<${name}\\b[^>]*/>`, 'gi'), '');
|
||||||
|
t = t.replace(new RegExp(`<${name}\\b[^>]*>[\\s\\S]*?</${name}>`, 'gi'), '');
|
||||||
|
t = t.replace(new RegExp(`</?${name}\\b[^>]*>`, 'gi'), '');
|
||||||
|
}
|
||||||
|
return t.replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { buildToneGuardBlock } from '../../lib/contextBuilders/toneGuard';
|
||||||
import {
|
import {
|
||||||
isProjectKnowledgeCreationRequest,
|
isProjectKnowledgeCreationRequest,
|
||||||
buildAstraStanceContext,
|
buildAstraStanceContext,
|
||||||
@@ -76,7 +77,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
|||||||
|
|
||||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
|
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
|
||||||
const casualCtx = isCasualConversation
|
const casualCtx = isCasualConversation
|
||||||
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
|
? `\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.\n\n${buildToneGuardBlock()}`
|
||||||
: '';
|
: '';
|
||||||
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
|
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
|
||||||
// evidence vs. its own general knowledge for this turn. Suppressed for casual
|
// evidence vs. its own general knowledge for this turn. Suppressed for casual
|
||||||
|
|||||||
@@ -13,6 +13,14 @@ import { buildDroppedHistorySummary } from '../../lib/contextBuilders/droppedHis
|
|||||||
|
|
||||||
export interface ComputeBudgetedRequestInput {
|
export interface ComputeBudgetedRequestInput {
|
||||||
fullSystemPrompt: string;
|
fullSystemPrompt: string;
|
||||||
|
/**
|
||||||
|
* [KV 캐시 분리 v2.2.311] 턴마다 변하는 컨텍스트(RAG/[CONTEXT]/날짜/동적 블록)를
|
||||||
|
* message[0] 이 아니라 *마지막 user 메시지 직전*의 internal system 메시지로 싣는다.
|
||||||
|
* message[0](fullSystemPrompt)은 턴 사이에 불변 → llama.cpp prompt cache 가
|
||||||
|
* "정적 프롬프트 + 과거 히스토리" 프리픽스를 재사용하고, 매 턴 프리필은
|
||||||
|
* "직전 교환 + 이 블록"만으로 줄어든다. undefined 면 종전 단일-시스템 동작.
|
||||||
|
*/
|
||||||
|
dynamicContextTail?: string;
|
||||||
/** Caller is expected to have run `capChatHistory` on this already. */
|
/** Caller is expected to have run `capChatHistory` on this already. */
|
||||||
reqMessages: ChatMessage[];
|
reqMessages: ChatMessage[];
|
||||||
actualModel: string;
|
actualModel: string;
|
||||||
@@ -126,12 +134,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
|||||||
1024,
|
1024,
|
||||||
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
|
Math.floor((ctxLimits.contextLength - ctxLimits.safetyMargin - preferredOutputReserve - imageTokenReserve) * 0.65)
|
||||||
);
|
);
|
||||||
const { prompt: budgetedSystemPrompt, truncated: systemTruncated } =
|
// Split 모드면 [CONTEXT] 는 dynamicContextTail 쪽에 있으므로 truncation 도 tail 에 적용.
|
||||||
truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
// (정적 head 에는 [CONTEXT] 마커가 없어 truncate 가 no-op — head 는 그대로 둔다.)
|
||||||
|
const splitMode = typeof input.dynamicContextTail === 'string' && input.dynamicContextTail.trim().length > 0;
|
||||||
|
let budgetedSystemPrompt = fullSystemPrompt;
|
||||||
|
let budgetedTail = splitMode ? input.dynamicContextTail! : '';
|
||||||
|
let systemTruncated = false;
|
||||||
|
if (splitMode) {
|
||||||
|
const headTokens = estimateTokens(fullSystemPrompt);
|
||||||
|
const tailCap = Math.max(512, systemCapTokens - headTokens);
|
||||||
|
const t = truncateSystemPromptContext(budgetedTail, tailCap);
|
||||||
|
budgetedTail = t.prompt;
|
||||||
|
systemTruncated = t.truncated;
|
||||||
|
} else {
|
||||||
|
const t = truncateSystemPromptContext(fullSystemPrompt, systemCapTokens);
|
||||||
|
budgetedSystemPrompt = t.prompt;
|
||||||
|
systemTruncated = t.truncated;
|
||||||
|
}
|
||||||
if (systemTruncated) {
|
if (systemTruncated) {
|
||||||
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
|
logInfo('System prompt context truncated to fit the context window.', { model: actualModel, systemCapTokens });
|
||||||
}
|
}
|
||||||
const systemTokens = estimateTokens(budgetedSystemPrompt) + 4;
|
const systemTokens = estimateTokens(budgetedSystemPrompt) + (splitMode ? estimateTokens(budgetedTail) + 4 : 0) + 4;
|
||||||
|
|
||||||
// (2) 대화 기록 압축.
|
// (2) 대화 기록 압축.
|
||||||
const historyBudget = Math.max(
|
const historyBudget = Math.max(
|
||||||
@@ -156,9 +179,27 @@ export function computeBudgetedRequest(input: ComputeBudgetedRequestInput): Comp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split 모드: 동적 컨텍스트를 마지막 user 메시지 *직전*에 삽입. 이 위치라야
|
||||||
|
// (a) 이전 요청과의 공통 프리픽스(정적 시스템 + 과거 히스토리)가 최대로 보존되고
|
||||||
|
// (b) continuation(액션 결과가 뒤에 붙는 라운드)에서도 같은 자리라 턴 내 캐시가 유지된다.
|
||||||
|
// user 메시지가 없으면(이론상) 히스토리 끝에 붙인다 — 생성 직전이므로 여전히 유효.
|
||||||
|
let historyWithTail = budgetedHistory;
|
||||||
|
if (splitMode) {
|
||||||
|
const tailMsg: ChatMessage = { role: 'system', content: budgetedTail, internal: true };
|
||||||
|
const lastUserIdx = (() => {
|
||||||
|
for (let i = budgetedHistory.length - 1; i >= 0; i--) {
|
||||||
|
if (budgetedHistory[i].role === 'user') return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
})();
|
||||||
|
historyWithTail = lastUserIdx >= 0
|
||||||
|
? [...budgetedHistory.slice(0, lastUserIdx), tailMsg, ...budgetedHistory.slice(lastUserIdx)]
|
||||||
|
: [...budgetedHistory, tailMsg];
|
||||||
|
}
|
||||||
|
|
||||||
const messagesForRequest: ChatMessage[] = [
|
const messagesForRequest: ChatMessage[] = [
|
||||||
{ role: 'system', content: budgetedSystemPrompt, internal: true },
|
{ role: 'system', content: budgetedSystemPrompt, internal: true },
|
||||||
...budgetedHistory
|
...historyWithTail
|
||||||
];
|
];
|
||||||
|
|
||||||
// (3) 동적 출력 상한.
|
// (3) 동적 출력 상한.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { LiveReasoningFilter } from '../../lib/contextBuilders/liveReasoningFilter';
|
||||||
import { logError, summarizeText } from '../../utils';
|
import { logError, summarizeText } from '../../utils';
|
||||||
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
|
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
|
||||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||||
@@ -35,9 +36,18 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
|
|||||||
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
|
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
|
||||||
let accumulated = '';
|
let accumulated = '';
|
||||||
let finishStopReason: string | undefined;
|
let finishStopReason: string | undefined;
|
||||||
|
// 라이브 표시 시 추론 구간(<think>/Harmony thought)은 토큰 단위로 차단 — agent.ts 본 스트림과 동일.
|
||||||
|
const liveFilter = new LiveReasoningFilter();
|
||||||
const post = (token: string) => {
|
const post = (token: string) => {
|
||||||
if (params.postLiveDeltas && token) {
|
if (params.postLiveDeltas && token) {
|
||||||
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
|
const visible = liveFilter.push(token);
|
||||||
|
// [v2.2.310] LM Studio LSEP 구분자 — 마커 이전에 라이브로 나간 추론 텍스트를
|
||||||
|
// streamReplace 로 화면에서 걷어내고, 마커 이후(실제 답변)부터 다시 시작.
|
||||||
|
if (liveFilter.consumeSeparatorSignal()) {
|
||||||
|
deps.getWebview()?.postMessage({ type: 'streamReplace', value: visible });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
|
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||||||
import { getConfig } from '../../config';
|
import { getConfig } from '../../config';
|
||||||
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
|
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
|
||||||
import { ErrorTranslator } from '../../core/errorHandler';
|
import { ErrorTranslator } from '../../core/errorHandler';
|
||||||
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
|
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
|
||||||
import { stripMarkdownFormatting } from '../../core/responseRecovery';
|
import { stripMarkdownFormatting } from '../../core/responseRecovery';
|
||||||
|
import { buildIntentBrief, formatIntentBrief, runReportQaLoop, IntentBrief } from '../../agents/reportQa';
|
||||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||||
|
|
||||||
export interface WorkflowDeps {
|
export interface WorkflowDeps {
|
||||||
@@ -50,6 +51,22 @@ export async function executeMultiAgentWorkflow(
|
|||||||
logError('Failed to load brain context for agents', ctxErr);
|
logError('Failed to load brain context for agents', ctxErr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── [의도 브리핑] 왜 묻는지·진짜 궁금증·답의 용도를 먼저 분석해 모든 단계
|
||||||
|
// (outline/section/polish)의 컨텍스트에 주입 — "문자적 답변" 방지의 1차 장치.
|
||||||
|
// 실패해도 파이프라인은 계속 (실패는 reportQa 모듈이 로그로 드러낸다).
|
||||||
|
let intentBrief: IntentBrief | null = null;
|
||||||
|
let intentCtx = '';
|
||||||
|
if (getConfig().reportQaEnabled) {
|
||||||
|
webview.postMessage({ type: 'workflowStage', value: { step: '⓪ 의도 분석', message: '질문의 목적을 파악하는 중...', done: false } });
|
||||||
|
const recentContext = deps.chatHistory
|
||||||
|
.filter(m => !(m as any).internal && (m.role === 'user' || m.role === 'assistant'))
|
||||||
|
.slice(-4)
|
||||||
|
.map(m => `${m.role === 'user' ? '사용자' : '아스트라'}: ${String(m.content || '').replace(/\s+/g, ' ').slice(0, 120)}`)
|
||||||
|
.join('\n');
|
||||||
|
intentBrief = await buildIntentBrief({ prompt, recentContext, model: modelName, signal });
|
||||||
|
if (intentBrief) intentCtx = `${formatIntentBrief(intentBrief)}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
const selectedAgentContext = options.agentSkillContext
|
const selectedAgentContext = options.agentSkillContext
|
||||||
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
|
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
|
||||||
: '';
|
: '';
|
||||||
@@ -64,7 +81,7 @@ export async function executeMultiAgentWorkflow(
|
|||||||
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
|
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
|
||||||
prompt,
|
prompt,
|
||||||
modelName,
|
modelName,
|
||||||
`${brainContext}${selectedAgentContext}${designerContext}`,
|
`${intentCtx}${brainContext}${selectedAgentContext}${designerContext}`,
|
||||||
signal,
|
signal,
|
||||||
(step, msg) => {
|
(step, msg) => {
|
||||||
deps.getWebview()?.postMessage({
|
deps.getWebview()?.postMessage({
|
||||||
@@ -77,12 +94,32 @@ export async function executeMultiAgentWorkflow(
|
|||||||
const wv2 = deps.getWebview();
|
const wv2 = deps.getWebview();
|
||||||
if (signal.aborted || !wv2) return;
|
if (signal.aborted || !wv2) return;
|
||||||
|
|
||||||
|
// ── [Report QA Loop] 조사·보고서형 요청은 블로그_v3 방식 품질 루프를 통과 —
|
||||||
|
// QA 규칙 레지스트리+실측치 주입으로 채점, 임계 미달이면 피드백 재작성,
|
||||||
|
// 점수가 떨어지면 재작성본 폐기(회귀 게이트). 일반 복합 질문(코드 분석 등)은
|
||||||
|
// 보고서 기준이 안 맞으므로 조사성 키워드가 있을 때만 발동.
|
||||||
|
let qaCheckedReport = rawFinalReport;
|
||||||
|
const isResearchLike = /(조사|리서치|보고서|레포트|알아봐|심층|종합\s*분석|research|report)/i.test(prompt);
|
||||||
|
if (getConfig().reportQaEnabled && isResearchLike && !signal.aborted) {
|
||||||
|
wv2.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: '보고서를 채점하는 중...', done: false } });
|
||||||
|
const qa = await runReportQaLoop({
|
||||||
|
draft: rawFinalReport,
|
||||||
|
prompt,
|
||||||
|
intentBrief,
|
||||||
|
model: modelName,
|
||||||
|
signal,
|
||||||
|
onProgress: (msg) => deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: msg, done: false } }),
|
||||||
|
});
|
||||||
|
qaCheckedReport = qa.finalDraft;
|
||||||
|
logInfo('[Workflow] Report QA 완료.', { score: qa.score, revisions: qa.revisions, trajectory: qa.trajectory.join('→') });
|
||||||
|
}
|
||||||
|
|
||||||
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
|
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
|
||||||
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
|
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
|
||||||
// 마커가 재학습되는 일을 막는다.
|
// 마커가 재학습되는 일을 막는다.
|
||||||
const finalReport = getConfig().outputFormat === 'plain'
|
const finalReport = getConfig().outputFormat === 'plain'
|
||||||
? stripMarkdownFormatting(rawFinalReport)
|
? stripMarkdownFormatting(qaCheckedReport)
|
||||||
: rawFinalReport;
|
: qaCheckedReport;
|
||||||
|
|
||||||
wv2.postMessage({ type: 'streamChunk', value: finalReport });
|
wv2.postMessage({ type: 'streamChunk', value: finalReport });
|
||||||
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { appendSuccessPattern } from '../../intelligence/skillScore';
|
|||||||
import { getConfig } from '../../config';
|
import { getConfig } from '../../config';
|
||||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
||||||
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
||||||
|
import { detectHollowInvestigation, formatHollowInvestigationFooter } from '../../intelligence/investigationPipeline';
|
||||||
|
import { logInfo } from '../../utils';
|
||||||
|
|
||||||
const devilRebuttalHook: PostAnswerHook = {
|
const devilRebuttalHook: PostAnswerHook = {
|
||||||
id: 'devil-rebuttal',
|
id: 'devil-rebuttal',
|
||||||
@@ -86,6 +88,23 @@ const termValidatorHook: PostAnswerHook = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.309] Hollow Investigation — "목록만 조회(list_files)하고 읽지는 않은 채(read/
|
||||||
|
* investigate 0회) 파일 여러 개를 서술한 답변" 감지. 소형 모델의 파일명 상상 조사를
|
||||||
|
* 사용자에게 즉시 노출한다 (결정론적, LLM 호출 없음).
|
||||||
|
*/
|
||||||
|
const hollowInvestigationHook: PostAnswerHook = {
|
||||||
|
id: 'hollow-investigation',
|
||||||
|
runAsync: false,
|
||||||
|
run(ctx: PostAnswerHookContext): void {
|
||||||
|
if (!ctx.assistantAnswer?.trim()) return;
|
||||||
|
const { hollow, fileMentions } = detectHollowInvestigation(ctx.assistantAnswer, ctx.actionStats);
|
||||||
|
if (!hollow) return;
|
||||||
|
ctx.getWebview()?.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(fileMentions) });
|
||||||
|
logInfo('Hollow Investigation 감지 — list만 하고 read 없이 파일 서술.', { files: fileMentions });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const requirementCoverageHook: PostAnswerHook = {
|
const requirementCoverageHook: PostAnswerHook = {
|
||||||
id: 'requirement-coverage',
|
id: 'requirement-coverage',
|
||||||
runAsync: false,
|
runAsync: false,
|
||||||
@@ -104,7 +123,6 @@ const confidenceEscalationHook: PostAnswerHook = {
|
|||||||
runAsync: false,
|
runAsync: false,
|
||||||
run(ctx: PostAnswerHookContext): void {
|
run(ctx: PostAnswerHookContext): void {
|
||||||
const cfg = getConfig();
|
const cfg = getConfig();
|
||||||
if (cfg.confidenceEngineEnabled === false) return;
|
|
||||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||||
|
|
||||||
// 검색이 안 돈 turn (casual 등) 은 신호 null → 보수적 기본값 (근거 0건).
|
// 검색이 안 돈 turn (casual 등) 은 신호 null → 보수적 기본값 (근거 0건).
|
||||||
@@ -118,21 +136,21 @@ const confidenceEscalationHook: PostAnswerHook = {
|
|||||||
);
|
);
|
||||||
const confidence = computeConfidence(retrievalSignals, answerSignals);
|
const confidence = computeConfidence(retrievalSignals, answerSignals);
|
||||||
|
|
||||||
// 업무 산출물 turn 에만 footer 표시 — 잡담까지 점수 붙이면 노이즈.
|
|
||||||
// 단, 확신도 '매우 낮음' 은 업무 여부와 무관하게 표시 (T4).
|
|
||||||
const isTask = coverage.ran || coverage.taskId !== undefined;
|
const isTask = coverage.ran || coverage.taskId !== undefined;
|
||||||
if (!isTask && confidence.band !== 'very-low') return;
|
|
||||||
|
|
||||||
let footer = formatConfidenceFooter(confidence);
|
|
||||||
let escalated = false;
|
|
||||||
if (cfg.escalationEnabled !== false) {
|
|
||||||
const decision = decideEscalation({
|
const decision = decideEscalation({
|
||||||
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
||||||
});
|
});
|
||||||
escalated = decision.escalate;
|
const escalated = decision.escalate;
|
||||||
footer += formatEscalationFooter(decision);
|
|
||||||
}
|
// [v2.2.300] 확신도·검토요청 footer 는 기본 *비표시* — 잡담 인사에까지
|
||||||
|
// "확신도 40/100 · 사람 확인 필요" 가 붙는 노이즈라는 사용자 피드백.
|
||||||
|
// 점수 계산과 아래 Reflection 기록은 그대로 유지 (성장 추이·Failure Pattern 의
|
||||||
|
// 원천은 화면 표시가 아니라 기록이다). 다시 보고 싶으면 설정에서 켜기 (opt-in).
|
||||||
|
if (cfg.confidenceEngineEnabled === true && (isTask || confidence.band === 'very-low')) {
|
||||||
|
let footer = formatConfidenceFooter(confidence);
|
||||||
|
if (cfg.escalationEnabled === true) footer += formatEscalationFooter(decision);
|
||||||
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reflection 기록 (Track 2-4 / 3-6) — 업무 turn 의 결정론적 회고를
|
// ── Reflection 기록 (Track 2-4 / 3-6) — 업무 turn 의 결정론적 회고를
|
||||||
// <brain>/.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천.
|
// <brain>/.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천.
|
||||||
@@ -254,6 +272,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
|||||||
devilRebuttalHook,
|
devilRebuttalHook,
|
||||||
postHocSelfCheckHook,
|
postHocSelfCheckHook,
|
||||||
termValidatorHook,
|
termValidatorHook,
|
||||||
|
hollowInvestigationHook,
|
||||||
requirementCoverageHook,
|
requirementCoverageHook,
|
||||||
confidenceEscalationHook,
|
confidenceEscalationHook,
|
||||||
criticLoopHook,
|
criticLoopHook,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface PostAnswerHookContext {
|
|||||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||||
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
|
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
|
||||||
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||||
|
/** [v2.2.309] 이번 turn 액션 실행 통계 — Hollow Investigation(헛조사) 감지용. */
|
||||||
|
actionStats?: { reads: number; lists: number; investigates: number } | null;
|
||||||
/** Devil Agent 가 호출 — non-streaming LLM. */
|
/** Devil Agent 가 호출 — non-streaming LLM. */
|
||||||
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
||||||
/** Abort signal accessor. */
|
/** Abort signal accessor. */
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
||||||
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
||||||
*/
|
*/
|
||||||
|
import { coreChat } from '../core/services';
|
||||||
|
|
||||||
|
|
||||||
export interface SelfCheckOptions {
|
export interface SelfCheckOptions {
|
||||||
ollamaUrl: string;
|
ollamaUrl: string;
|
||||||
@@ -141,53 +143,24 @@ export async function postHocSelfCheck(
|
|||||||
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
||||||
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
||||||
|
|
||||||
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
|
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 200토큰).
|
||||||
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
|
// (이 모듈은 원래 import 0개의 자립 모듈이었으나, 엔진 폴백·로깅 일원화가 더 큰 가치)
|
||||||
const controller = new AbortController();
|
|
||||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
|
||||||
let raw = '';
|
let raw = '';
|
||||||
try {
|
try {
|
||||||
const body = isOllama
|
const result = await coreChat({
|
||||||
? {
|
system, user, model: options.model,
|
||||||
model: options.model, stream: false,
|
timeoutMs: options.timeoutMs,
|
||||||
messages: [
|
temperature: 0.0,
|
||||||
{ role: 'system', content: system },
|
maxTokens: 200,
|
||||||
{ role: 'user', content: user },
|
|
||||||
],
|
|
||||||
options: { temperature: 0.0, num_predict: 200 },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: system },
|
|
||||||
{ role: 'user', content: user },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
raw = String(result.content || '');
|
||||||
const data: any = await res.json();
|
|
||||||
raw = String(
|
|
||||||
data?.message?.content ??
|
|
||||||
data?.choices?.[0]?.message?.content ??
|
|
||||||
data?.choices?.[0]?.text ??
|
|
||||||
data?.response ??
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
clearTimeout(timer);
|
|
||||||
return {
|
return {
|
||||||
...FAILURE_RESULT,
|
...FAILURE_RESULT,
|
||||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||||
durationMs: Date.now() - start,
|
durationMs: Date.now() - start,
|
||||||
rawResponse: '',
|
rawResponse: '',
|
||||||
};
|
};
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseResult(raw);
|
const parsed = parseResult(raw);
|
||||||
|
|||||||
+13
-70
@@ -1,82 +1,37 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { getConfig } from '../config';
|
import { getConfig } from '../config';
|
||||||
|
import { coreChat } from '../core/services';
|
||||||
import { AgentExecuteOptions } from '../lib/engine';
|
import { AgentExecuteOptions } from '../lib/engine';
|
||||||
|
|
||||||
export abstract class BaseAgent {
|
export abstract class BaseAgent {
|
||||||
constructor(protected readonly modelName: string) {}
|
constructor(protected readonly modelName: string) {}
|
||||||
|
|
||||||
protected async callLLM(persona: string, prompt: string, signal?: AbortSignal): Promise<string> {
|
protected async callLLM(persona: string, prompt: string, signal?: AbortSignal): Promise<string> {
|
||||||
const { ollamaUrl } = getConfig();
|
// [코어 수렴] 자체 fetch·엔진 휴리스틱을 제거하고 core/services 의 coreChat 으로 통일.
|
||||||
if (!ollamaUrl) {
|
// 이 레이어는 "시도당 45초 + 3회 재시도(지수 백오프) + Abort 즉시 중단" 정책만 담당하고,
|
||||||
throw new Error('Ollama URL이 설정되지 않았습니다. 설정을 확인해주세요.');
|
// 엔진 선택·폴백·빈응답 처리·로깅은 코어가 책임진다.
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof fetch === 'undefined') {
|
|
||||||
throw new Error('이 환경에서는 fetch 함수를 사용할 수 없습니다. Node.js 버전을 확인하거나 polyfill이 필요합니다.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = [
|
|
||||||
{ role: 'system', content: persona },
|
|
||||||
{ role: 'user', content: prompt }
|
|
||||||
];
|
|
||||||
|
|
||||||
// 엔진 자동 감지 (Ollama vs OpenAI/LM Studio)
|
|
||||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
|
||||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
|
||||||
|
|
||||||
// 컨텍스트 초과 방지를 위해 출력 토큰 상한을 항상 명시한다 (서브에이전트 중간 산출물용).
|
|
||||||
const { contextLength, maxOutputTokens } = getConfig();
|
const { contextLength, maxOutputTokens } = getConfig();
|
||||||
const numCtx = Math.max(2048, contextLength);
|
const numCtx = Math.max(2048, contextLength);
|
||||||
const outCap = Math.max(256, maxOutputTokens);
|
const outCap = Math.max(256, maxOutputTokens);
|
||||||
|
|
||||||
let lastError: any;
|
let lastError: any;
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
const controller = new AbortController();
|
|
||||||
const timeoutId = setTimeout(() => controller.abort(), 45000);
|
|
||||||
const combinedSignal = signal ? anySignal([signal, controller.signal]) : controller.signal;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (attempt > 1) await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
if (attempt > 1) await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||||
|
const result = await coreChat({
|
||||||
const response = await fetch(endpoint, {
|
system: persona,
|
||||||
method: 'POST',
|
user: prompt,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(isOllama ? {
|
|
||||||
model: this.modelName,
|
model: this.modelName,
|
||||||
messages,
|
timeoutMs: 45000,
|
||||||
stream: false,
|
signal,
|
||||||
options: { temperature: 0.3, num_ctx: numCtx, num_predict: outCap }
|
|
||||||
} : {
|
|
||||||
model: this.modelName,
|
|
||||||
messages,
|
|
||||||
stream: false,
|
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: outCap
|
maxTokens: outCap,
|
||||||
}),
|
numCtx,
|
||||||
signal: combinedSignal
|
|
||||||
});
|
});
|
||||||
|
return result.content || '';
|
||||||
clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`Agent API Error: ${response.statusText} (${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await response.json() as any;
|
|
||||||
|
|
||||||
// 강력한 응답 추출 (Multi-path parsing)
|
|
||||||
let content = '';
|
|
||||||
if (data.message?.content) content = data.message.content;
|
|
||||||
else if (data.choices?.[0]?.message?.content) content = data.choices[0].message.content;
|
|
||||||
else if (data.choices?.[0]?.text) content = data.choices[0].text;
|
|
||||||
else if (data.response) content = data.response;
|
|
||||||
else if (typeof data === 'string') content = data;
|
|
||||||
|
|
||||||
return content || '';
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
clearTimeout(timeoutId);
|
|
||||||
lastError = error;
|
lastError = error;
|
||||||
if (error.name === 'AbortError') break;
|
if (error?.name === 'AbortError') break;
|
||||||
if (attempt === 3) break;
|
if (attempt === 3) break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,18 +41,6 @@ export abstract class BaseAgent {
|
|||||||
abstract execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
|
abstract execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to combine signals (since AbortSignal.any is not always available in older Node)
|
|
||||||
function anySignal(signals: AbortSignal[]): AbortSignal {
|
|
||||||
const controller = new AbortController();
|
|
||||||
for (const signal of signals) {
|
|
||||||
if (signal.aborted) {
|
|
||||||
controller.abort();
|
|
||||||
return signal;
|
|
||||||
}
|
|
||||||
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
|
||||||
}
|
|
||||||
return controller.signal;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Section outline shape produced by ChunkedWriter in the 'outline' role.
|
* Section outline shape produced by ChunkedWriter in the 'outline' role.
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { coreChat } from '../core/services';
|
||||||
|
import { getConfig } from '../config';
|
||||||
|
import { logInfo, logWarn } from '../utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 조사·보고서 답변 품질 루프 — 블로그_v3 파이프라인의 검증 설계를 개념 이식.
|
||||||
|
*
|
||||||
|
* 이식한 개념 (원본: 블로그_v3 index.tsx — raw 복사가 아니라 보고서 도메인으로 적응):
|
||||||
|
* 1. QA 규칙 레지스트리 단일 권위 — 채점 항목은 전부 REPORT_QA_RULES 배열에서
|
||||||
|
* 생성된다. 규칙 추가/수정은 여기 한 곳만 고치면 된다.
|
||||||
|
* 2. 결정적 실측치 주입 — 분량·섹션 수·출처 인용 수를 코드로 측정해 QA 프롬프트에
|
||||||
|
* 주입한다. 모델의 눈대중 추정(들쭉날쭉)이 아니라 실측치로 판단을 안정화.
|
||||||
|
* 3. 채점→피드백 재주입→재작성 루프 + 회귀 게이트 — 점수 미달이면 피드백을 넣어
|
||||||
|
* 재작성하고 다시 채점하되, 점수가 *떨어지면* 그 재작성본은 버리고 최고점
|
||||||
|
* 초안(best draft)을 유지한다 (85→79 회귀 사고의 재발 방지 장치).
|
||||||
|
*
|
||||||
|
* 소형 로컬 모델 전제: 라운드 수 기본 2회로 제한 (호출당 수십 초 — latency 보호),
|
||||||
|
* 채점 실패(JSON 파싱 불가·타임아웃)는 루프 중단 + 현재 최고 초안 반환 (조용한
|
||||||
|
* 폴백 금지 — 로그에 남긴다).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ── QA 규칙 레지스트리 (단일 권위) ─────────────────────────────────────────
|
||||||
|
// num: 항목 번호 — 피드백이 "N번"으로 참조하므로 기존 번호는 바꾸지 말고 새 규칙은 뒤에 추가.
|
||||||
|
// critical: true 면 '중대한 결함'(임계 미달 사유) 목록에 자동 포함.
|
||||||
|
export type ReportQaRule = { num: number; text: string; critical?: boolean };
|
||||||
|
|
||||||
|
export const REPORT_QA_RULES: ReportQaRule[] = [
|
||||||
|
{ num: 1, text: '결론 선행 — 첫 문단이 질문에 대한 직접적인 답(핵심 결론 요약)으로 시작하는가. 배경 설명부터 길게 시작하면 감점', critical: true },
|
||||||
|
{ num: 2, text: '의도 부합 — 함께 제공되는 [질문자 의도 브리핑]의 목적·진짜 궁금증에 본문이 실제로 답하는가. 표면 질문에만 답하고 목적을 놓쳤으면 크게 감점', critical: true },
|
||||||
|
{ num: 3, text: '근거 명시 — 주장·사실마다 근거(제공된 자료의 문서명·출처, 또는 "일반 지식 기반" 표시)가 붙어 있는가. 출처 없는 단정이 반복되면 감점', critical: true },
|
||||||
|
{ num: 4, text: '임의 수치 금지 — 제공된 자료에 없는 깔끔한 구체 수치(통계·비율·금액·날짜)를 지어내지 않았는가. 하나라도 보이면 크게 감점하고 feedback 에 해당 수치를 인용하라', critical: true },
|
||||||
|
{ num: 5, text: '사실·추정 구분 — 확실한 사실과 추정·의견이 구분 표기되는가 ("~로 확인됨" vs "~로 추정됨"). 뒤섞여 있으면 감점' },
|
||||||
|
{ num: 6, text: '구조 — 소제목으로 논리가 전개되고 (배경→핵심 발견→분석→시사점 류), 섹션 간 내용 중복이 없는가' },
|
||||||
|
{ num: 7, text: '완결성 — 질문이 여러 하위 질문을 포함하면 전부 다뤘는가. 빠뜨린 하위 질문이 있으면 감점하고 feedback 에 명시하라', critical: true },
|
||||||
|
{ num: 8, text: '한계 명시 — 이번 조사에서 확인하지 못한 것·추가 확인이 필요한 것이 마지막에 정직하게 표기되는가. 전지적 톤으로 끝나면 감점' },
|
||||||
|
{ num: 9, text: '다음 행동 — 질문자가 이 답을 받고 취할 수 있는 다음 단계·시사점이 구체적으로 제시되는가' },
|
||||||
|
{ num: 10, text: '군더더기 금지 — "물론입니다", "~에 대해 알아보겠습니다" 류 서두·맺음 인사, 동어반복 문단이 없는가' },
|
||||||
|
{ num: 11, text: '나열 남용 금지 — 본문 대부분이 얕은 불릿 나열이면 감점. 핵심 논점은 문장·문단으로 설명되고 나열은 보조로만 쓰여야 한다' },
|
||||||
|
{ num: 12, text: '분량 적정 — 함께 제공되는 실측치(본문 글자 수)를 그대로 사용하여(직접 추정 금지), 질문의 무게 대비 지나치게 짧아 부실하거나 반복으로 부풀려져 있지 않은가' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const buildReportQaItems = (): string =>
|
||||||
|
REPORT_QA_RULES.map(r => `(${r.num}) ${r.text}`).join('\n');
|
||||||
|
export const reportQaCriticalNums = (): string =>
|
||||||
|
REPORT_QA_RULES.filter(r => r.critical).map(r => r.num).join('·');
|
||||||
|
|
||||||
|
// ── 결정적 실측치 (모델 눈대중 대신 코드 측정) ──────────────────────────────
|
||||||
|
export interface ReportMetrics {
|
||||||
|
/** 마크다운 문법 제외 본문 글자 수 (공백 포함). */
|
||||||
|
contentChars: number;
|
||||||
|
/** 소제목(##/### 또는 굵은 단독행) 개수. */
|
||||||
|
sectionCount: number;
|
||||||
|
/** 출처 인용 흔적 수 — [문서명]·「」·(출처: …)·"일반 지식" 표기. */
|
||||||
|
citationCount: number;
|
||||||
|
/** 불릿/번호 행이 전체 비어있지 않은 행에서 차지하는 비율 (0~100). */
|
||||||
|
listLineRatio: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function measureReport(text: string): ReportMetrics {
|
||||||
|
const t = text || '';
|
||||||
|
const contentChars = t
|
||||||
|
.replace(/```[\s\S]*?```/g, ' ')
|
||||||
|
.replace(/[#*_`>|-]/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim().length;
|
||||||
|
const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
|
||||||
|
const sectionCount = lines.filter(l => /^#{2,4}\s+\S/.test(l) || /^\*\*[^*]{2,40}\*\*$/.test(l)).length;
|
||||||
|
const citationCount =
|
||||||
|
(t.match(/\(출처[::][^)]*\)/g) || []).length +
|
||||||
|
(t.match(/「[^」]{2,60}」/g) || []).length +
|
||||||
|
(t.match(/\[[^\]\n]{2,60}\](?!\()/g) || []).length +
|
||||||
|
(t.match(/일반 지식/g) || []).length;
|
||||||
|
const listLines = lines.filter(l => /^([-*•]|\d+[.)])\s/.test(l)).length;
|
||||||
|
const listLineRatio = lines.length ? Math.round((listLines / lines.length) * 100) : 0;
|
||||||
|
return { contentChars, sectionCount, citationCount, listLineRatio };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 관대한 JSON 파싱 (코드펜스·앞뒤 잡담 허용) — 블로그_v3 parseLooseJson 개념 ──
|
||||||
|
export function parseLooseJson(raw: string): any {
|
||||||
|
if (!raw) return null;
|
||||||
|
const t = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
|
||||||
|
try { return JSON.parse(t); } catch { /* fall through */ }
|
||||||
|
const start = t.indexOf('{');
|
||||||
|
const end = t.lastIndexOf('}');
|
||||||
|
if (start !== -1 && end > start) {
|
||||||
|
try { return JSON.parse(t.slice(start, end + 1)); } catch { /* fall through */ }
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 질문자 의도 브리핑 (파이프라인 선두 1회 호출) ───────────────────────────
|
||||||
|
export interface IntentBrief {
|
||||||
|
/** 질문자가 왜 지금 이걸 묻는지 — 추정 목적. */
|
||||||
|
purpose: string;
|
||||||
|
/** 표면 질문 뒤의 진짜 궁금증들. */
|
||||||
|
realQuestions: string[];
|
||||||
|
/** 답을 받아 무엇을 하려는지 — 기대 산출물 형태. */
|
||||||
|
expectedUse: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatIntentBrief(brief: IntentBrief): string {
|
||||||
|
return [
|
||||||
|
'[질문자 의도 브리핑]',
|
||||||
|
`- 추정 목적: ${brief.purpose}`,
|
||||||
|
`- 진짜 궁금증: ${brief.realQuestions.join(' / ')}`,
|
||||||
|
`- 답의 용도: ${brief.expectedUse}`,
|
||||||
|
'위 브리핑에 실제로 답하는 것이 이번 답변의 성공 기준이다. 표면 질문만 문자적으로 답하지 마라.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 사용자 질문 + 최근 대화 요지로 의도 브리핑을 1회 생성. 실패 시 null (호출자가
|
||||||
|
* 브리핑 없이 진행 — 실패는 로그로 드러낸다, 조용한 폴백 금지).
|
||||||
|
*/
|
||||||
|
export async function buildIntentBrief(params: {
|
||||||
|
prompt: string;
|
||||||
|
recentContext?: string;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<IntentBrief | null> {
|
||||||
|
try {
|
||||||
|
const res = await coreChat({
|
||||||
|
system: '너는 질문 분석가다. 사용자의 요청을 읽고 왜 묻는지(목적), 표면 질문 뒤에 정말 궁금한 것, 답을 받아 무엇을 할지 분석해 JSON 으로만 응답한다. 형식: {"purpose":"...","realQuestions":["...","..."],"expectedUse":"..."} — 각 항목은 한국어 한 문장, realQuestions 는 1~3개.',
|
||||||
|
user: `${params.recentContext ? `[최근 대화 요지]\n${params.recentContext}\n\n` : ''}[사용자 요청]\n${params.prompt}`,
|
||||||
|
model: params.model,
|
||||||
|
temperature: 0.2,
|
||||||
|
maxTokens: 300,
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
const parsed = parseLooseJson(res.content);
|
||||||
|
if (parsed && typeof parsed.purpose === 'string' && Array.isArray(parsed.realQuestions)) {
|
||||||
|
return {
|
||||||
|
purpose: parsed.purpose,
|
||||||
|
realQuestions: parsed.realQuestions.map(String).filter(Boolean).slice(0, 3),
|
||||||
|
expectedUse: String(parsed.expectedUse || '명시되지 않음'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
logWarn('[ReportQA] 의도 브리핑 JSON 파싱 실패 — 브리핑 없이 진행.', { raw: res.content.slice(0, 120) });
|
||||||
|
return null;
|
||||||
|
} catch (e: any) {
|
||||||
|
logWarn('[ReportQA] 의도 브리핑 호출 실패 — 브리핑 없이 진행.', { error: e?.message || String(e) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QA 채점 + 재작성 루프 + 회귀 게이트 ────────────────────────────────────
|
||||||
|
export interface QaLoopResult {
|
||||||
|
finalDraft: string;
|
||||||
|
/** 최종 채택 초안의 점수. 채점이 한 번도 성공 못 하면 null. */
|
||||||
|
score: number | null;
|
||||||
|
/** 실행된 재작성 횟수. */
|
||||||
|
revisions: number;
|
||||||
|
/** 라운드별 점수 궤적 (로그·텔레메트리용). */
|
||||||
|
trajectory: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scoreOnce(params: {
|
||||||
|
draft: string;
|
||||||
|
prompt: string;
|
||||||
|
intentText: string;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}): Promise<{ score: number; feedback: string } | null> {
|
||||||
|
const m = measureReport(params.draft);
|
||||||
|
const metricsGuide =
|
||||||
|
`[실측치 — 그대로 사용, 직접 추정 금지] 본문 글자 수 ${m.contentChars}자, 소제목 ${m.sectionCount}개, ` +
|
||||||
|
`출처 인용 흔적 ${m.citationCount}건, 나열(불릿) 행 비율 ${m.listLineRatio}%.`;
|
||||||
|
try {
|
||||||
|
const res = await coreChat({
|
||||||
|
system: `너는 조사 보고서 품질 검수관이다. 아래 기준으로 엄격히 채점해 JSON 으로만 응답한다: {"score": 0~100, "feedback": "부족한 항목 번호와 해당 구절 인용"}.\n[채점 기준]\n${buildReportQaItems()}\n[점수 규칙] 모든 항목 충족 시 85점 이상. 사소한 미흡 1개면 80~84점. 중대 결함(${reportQaCriticalNums()}번 위반) 또는 미흡 2개 이상이면 80점 미만. 점수와 무관하게 feedback 에는 무엇이 부족한지 구체적 구절을 인용해 반드시 명시하라.`,
|
||||||
|
user: `${params.intentText}\n\n[원 질문]\n${params.prompt}\n\n${metricsGuide}\n\n[검수 대상 보고서]\n${params.draft}`,
|
||||||
|
model: params.model,
|
||||||
|
temperature: 0.0,
|
||||||
|
maxTokens: 500,
|
||||||
|
timeoutMs: 45_000,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
const parsed = parseLooseJson(res.content);
|
||||||
|
if (parsed && typeof parsed.score === 'number') {
|
||||||
|
return { score: Math.max(0, Math.min(100, parsed.score)), feedback: String(parsed.feedback || '') };
|
||||||
|
}
|
||||||
|
logWarn('[ReportQA] 채점 JSON 파싱 실패.', { raw: res.content.slice(0, 120) });
|
||||||
|
return null;
|
||||||
|
} catch (e: any) {
|
||||||
|
logWarn('[ReportQA] 채점 호출 실패.', { error: e?.message || String(e) });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 초안을 채점하고, 임계 미달이면 피드백을 재주입해 재작성 → 재채점.
|
||||||
|
* 재작성본 점수가 이전 최고점보다 낮으면 폐기(회귀 게이트).
|
||||||
|
*/
|
||||||
|
export async function runReportQaLoop(params: {
|
||||||
|
draft: string;
|
||||||
|
prompt: string;
|
||||||
|
intentBrief: IntentBrief | null;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
}): Promise<QaLoopResult> {
|
||||||
|
const cfg = getConfig();
|
||||||
|
const threshold = cfg.reportQaThreshold;
|
||||||
|
const maxRevisions = cfg.reportQaMaxRevisions;
|
||||||
|
const intentText = params.intentBrief ? formatIntentBrief(params.intentBrief) : '[질문자 의도 브리핑] (생성 실패 — 원 질문 기준으로 판단하라)';
|
||||||
|
|
||||||
|
let bestDraft = params.draft;
|
||||||
|
let bestScore: number | null = null;
|
||||||
|
const trajectory: number[] = [];
|
||||||
|
let revisions = 0;
|
||||||
|
|
||||||
|
let current = await scoreOnce({ draft: bestDraft, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
|
||||||
|
if (!current) return { finalDraft: bestDraft, score: null, revisions: 0, trajectory };
|
||||||
|
bestScore = current.score;
|
||||||
|
trajectory.push(current.score);
|
||||||
|
logInfo('[ReportQA] 초안 채점.', { score: current.score, threshold });
|
||||||
|
|
||||||
|
while (bestScore < threshold && revisions < maxRevisions) {
|
||||||
|
if (params.signal?.aborted) break;
|
||||||
|
revisions++;
|
||||||
|
params.onProgress?.(`품질 ${bestScore}점 — 보완 재작성 ${revisions}/${maxRevisions}`);
|
||||||
|
let revised: string;
|
||||||
|
try {
|
||||||
|
const res = await coreChat({
|
||||||
|
system: '너는 조사 보고서 작성자다. 아래 검수 피드백을 반영해 보고서 *전문*을 다시 작성하라. 지적받지 않은 부분의 사실·구조는 유지하고, 지적된 부분만 고친다. 보고서 본문만 출력하고 다른 말은 하지 마라.',
|
||||||
|
user: `${intentText}\n\n[원 질문]\n${params.prompt}\n\n[검수 피드백 (점수 ${bestScore}/${threshold})]\n${current.feedback}\n\n[현재 보고서]\n${bestDraft}`,
|
||||||
|
model: params.model,
|
||||||
|
temperature: 0.4,
|
||||||
|
maxTokens: getConfig().maxOutputTokens,
|
||||||
|
timeoutMs: 180_000,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
revised = (res.content || '').trim();
|
||||||
|
} catch (e: any) {
|
||||||
|
logWarn('[ReportQA] 재작성 호출 실패 — 현재 최고 초안 유지.', { error: e?.message || String(e) });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!revised || revised.length < bestDraft.length * 0.4) {
|
||||||
|
// 재작성이 비었거나 심하게 잘림 — 회귀로 간주하고 폐기.
|
||||||
|
logWarn('[ReportQA] 재작성본이 비었거나 과도하게 짧음 — 폐기.', { revisedChars: revised.length, bestChars: bestDraft.length });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rescored = await scoreOnce({ draft: revised, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
|
||||||
|
if (!rescored) break;
|
||||||
|
trajectory.push(rescored.score);
|
||||||
|
if (rescored.score >= bestScore) {
|
||||||
|
bestDraft = revised;
|
||||||
|
bestScore = rescored.score;
|
||||||
|
current = rescored;
|
||||||
|
logInfo('[ReportQA] 재작성 채택.', { round: revisions, score: rescored.score });
|
||||||
|
} else {
|
||||||
|
// 회귀 게이트 — 편집이 점수를 떨어뜨리면 그 편집본은 버린다 (블로그_v3 의 85→79 사고 방지).
|
||||||
|
logInfo('[ReportQA] 재작성본 점수 하락 — 회귀 게이트로 폐기.', { round: revisions, was: bestScore, got: rescored.score });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalDraft: bestDraft, score: bestScore, revisions, trajectory };
|
||||||
|
}
|
||||||
+49
-4
@@ -143,6 +143,16 @@ export interface IAgentConfig {
|
|||||||
* 깨끗한 답변에는 안 돈다 (latency 보호). 기본 true. (Phase 1 / Track 2-3)
|
* 깨끗한 답변에는 안 돈다 (latency 보호). 기본 true. (Phase 1 / Track 2-3)
|
||||||
*/
|
*/
|
||||||
criticLoopEnabled: boolean;
|
criticLoopEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* Report QA Loop — 조사·보고서형 워크플로우 답변에 블로그_v3 방식 품질 루프 적용:
|
||||||
|
* 의도 브리핑(왜 묻는지 분석) → 초안 → QA 채점(규칙 레지스트리+실측치 주입) →
|
||||||
|
* 피드백 재작성 → 회귀 게이트(점수 하락 시 재작성본 폐기). 기본 true. (v2.2.301)
|
||||||
|
*/
|
||||||
|
reportQaEnabled: boolean;
|
||||||
|
/** Report QA 통과 점수 임계 (이 미만이면 재작성). 기본 80. */
|
||||||
|
reportQaThreshold: number;
|
||||||
|
/** Report QA 최대 재작성 횟수 (로컬 모델 latency 보호). 기본 2. */
|
||||||
|
reportQaMaxRevisions: number;
|
||||||
/**
|
/**
|
||||||
* Reflection — 업무 turn 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를
|
* Reflection — 업무 turn 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를
|
||||||
* <brain>/.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의
|
* <brain>/.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의
|
||||||
@@ -217,6 +227,25 @@ export interface IAgentConfig {
|
|||||||
* 결과는 답변 아래 footer 한 줄로 표시.
|
* 결과는 답변 아래 footer 한 줄로 표시.
|
||||||
*/
|
*/
|
||||||
selfCheckEnabled: boolean;
|
selfCheckEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* [v2.2.309] 조사형 요청("~폴더 조사해줘") 전용 모델 오버라이드.
|
||||||
|
* 예: "claude-code:sonnet" — 소형 로컬 모델의 lazy tool-use(파일명만 보고 서술)
|
||||||
|
* 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
|
||||||
|
*/
|
||||||
|
investigationModel: string;
|
||||||
|
/**
|
||||||
|
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — message[0] 을 정적 본문으로 고정하고
|
||||||
|
* 날짜/RAG/[CONTEXT]/동적 블록을 마지막 user 메시지 직전의 system 메시지로 옮긴다.
|
||||||
|
* llama.cpp prompt cache 프리픽스 재사용으로 턴당 재프리필을 대폭 축소. 기본 true.
|
||||||
|
* 특정 모델 템플릿에서 문제 시 false 로 종전 단일-시스템 프롬프트 복귀.
|
||||||
|
*/
|
||||||
|
kvCachePromptSplit: boolean;
|
||||||
|
/**
|
||||||
|
* [v2.2.311] RAG/메모리 검색 토큰 예산 상한. 0 = 자동(컨텍스트 창의 12%,
|
||||||
|
* 2,500~6,000 클램프). 종전 자동값(창의 25%, 최대 80k)은 32k 창에서 8k 토큰을
|
||||||
|
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
|
||||||
|
*/
|
||||||
|
retrievalTokenBudget: number;
|
||||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||||
selfCheckModel: string;
|
selfCheckModel: string;
|
||||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||||
@@ -394,8 +423,8 @@ export interface IAgentConfig {
|
|||||||
// ─── Stream 표시 ───
|
// ─── Stream 표시 ───
|
||||||
/**
|
/**
|
||||||
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
|
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
|
||||||
* - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단.
|
* - true(기본): 실시간 표시. 추론 구간(<think>/Harmony thought)은 LiveReasoningFilter 가 토큰 단위로 차단.
|
||||||
* - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음.
|
* - false: 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시.
|
||||||
*/
|
*/
|
||||||
liveStreamTokens: boolean;
|
liveStreamTokens: boolean;
|
||||||
/**
|
/**
|
||||||
@@ -460,6 +489,15 @@ function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: n
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
|
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
|
||||||
|
//
|
||||||
|
// [설정 접근 규칙 — 2026-07-11 아키텍처 감사에서 명문화]
|
||||||
|
// IAgentConfig 에 이미 있는 값(엔진 URL·모델·컨텍스트 한계·두뇌 프로필 등
|
||||||
|
// "코어 계약"에 속하는 값)은 반드시 getConfig() 를 통해 읽는다 — 기본값·클램프·
|
||||||
|
// 레거시 마이그레이션이 여기 한 곳에 있기 때문. 반대로 특정 기능 전용 설정
|
||||||
|
// (예: g1nation.stocks.*, dailyBriefing.time)은 그 기능 파일에서
|
||||||
|
// vscode.workspace.getConfiguration('g1nation') 직접 접근이 허용된다 —
|
||||||
|
// 기능 하나 추가할 때마다 이 인터페이스를 비대하게 만들지 않기 위한 의도적 분리.
|
||||||
|
// 판단 기준: "둘 이상의 모듈이 읽는 값인가?" → 그렇다면 IAgentConfig 로 승격할 것.
|
||||||
export function getConfig(): IAgentConfig {
|
export function getConfig(): IAgentConfig {
|
||||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||||
|
|
||||||
@@ -535,9 +573,13 @@ export function getConfig(): IAgentConfig {
|
|||||||
requirementGraphEnabled: cfg.get<boolean>('requirementGraphEnabled', true),
|
requirementGraphEnabled: cfg.get<boolean>('requirementGraphEnabled', true),
|
||||||
requirementCoverageEnabled: cfg.get<boolean>('requirementCoverageEnabled', true),
|
requirementCoverageEnabled: cfg.get<boolean>('requirementCoverageEnabled', true),
|
||||||
epistemicGuardEnabled: cfg.get<boolean>('epistemicGuardEnabled', true),
|
epistemicGuardEnabled: cfg.get<boolean>('epistemicGuardEnabled', true),
|
||||||
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', true),
|
// v2.2.300: footer 기본 비표시 (사용자 피드백) — 계산·Reflection 은 항상 수행.
|
||||||
escalationEnabled: cfg.get<boolean>('escalationEnabled', true),
|
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', false),
|
||||||
|
escalationEnabled: cfg.get<boolean>('escalationEnabled', false),
|
||||||
criticLoopEnabled: cfg.get<boolean>('criticLoopEnabled', true),
|
criticLoopEnabled: cfg.get<boolean>('criticLoopEnabled', true),
|
||||||
|
reportQaEnabled: cfg.get<boolean>('reportQaEnabled', true),
|
||||||
|
reportQaThreshold: Math.max(50, Math.min(95, cfg.get<number>('reportQaThreshold', 80))),
|
||||||
|
reportQaMaxRevisions: Math.max(0, Math.min(5, cfg.get<number>('reportQaMaxRevisions', 2))),
|
||||||
reflectionEnabled: cfg.get<boolean>('reflectionEnabled', true),
|
reflectionEnabled: cfg.get<boolean>('reflectionEnabled', true),
|
||||||
orgMemoryEnabled: cfg.get<boolean>('orgMemoryEnabled', true),
|
orgMemoryEnabled: cfg.get<boolean>('orgMemoryEnabled', true),
|
||||||
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
||||||
@@ -553,6 +595,9 @@ export function getConfig(): IAgentConfig {
|
|||||||
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
||||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||||
|
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
|
||||||
|
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
|
||||||
|
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
|
||||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||||
|
|||||||
+3
-6
@@ -2,11 +2,8 @@ import * as vscode from 'vscode';
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { exec } from 'child_process';
|
|
||||||
import { promisify } from 'util';
|
|
||||||
import { getConfig } from '../config';
|
import { getConfig } from '../config';
|
||||||
|
import { execShell } from '../lib/execUtil';
|
||||||
const execAsync = promisify(exec);
|
|
||||||
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
||||||
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
||||||
|
|
||||||
@@ -111,10 +108,10 @@ export class HealthCheckMonitor {
|
|||||||
try {
|
try {
|
||||||
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
||||||
try {
|
try {
|
||||||
await execAsync('git push --dry-run', {
|
await execShell('git push --dry-run', {
|
||||||
cwd: brain.localBrainPath,
|
cwd: brain.localBrainPath,
|
||||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||||
timeout: 5000,
|
timeoutMs: 5000,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const msg = String(e?.stderr || e?.message || '');
|
const msg = String(e?.stderr || e?.message || '');
|
||||||
|
|||||||
+44
-2
@@ -26,6 +26,12 @@ export interface AIChatRequest {
|
|||||||
model?: string;
|
model?: string;
|
||||||
/** Optional override (default = config.timeout). */
|
/** Optional override (default = config.timeout). */
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
|
/** 샘플링 온도 (기본 0.7). 판정·재순위 등 결정적 작업은 0.0~0.2 권장. */
|
||||||
|
temperature?: number;
|
||||||
|
/** 출력 토큰 상한 (ollama num_predict / lmstudio max_tokens). 미지정 시 서버 기본. */
|
||||||
|
maxTokens?: number;
|
||||||
|
/** [ollama 전용] 컨텍스트 창 크기(num_ctx). */
|
||||||
|
numCtx?: number;
|
||||||
/**
|
/**
|
||||||
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
|
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
|
||||||
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
|
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
|
||||||
@@ -37,7 +43,7 @@ export interface AIChatRequest {
|
|||||||
export interface AIChatResult {
|
export interface AIChatResult {
|
||||||
content: string;
|
content: string;
|
||||||
/** Engine that actually returned the content. */
|
/** Engine that actually returned the content. */
|
||||||
engine: 'lmstudio' | 'ollama';
|
engine: 'lmstudio' | 'ollama' | 'claude-code';
|
||||||
model: string;
|
model: string;
|
||||||
/** True iff content came back empty after all retries. Caller decides UX. */
|
/** True iff content came back empty after all retries. Caller decides UX. */
|
||||||
empty: boolean;
|
empty: boolean;
|
||||||
@@ -73,6 +79,23 @@ export class AIService implements IAIService {
|
|||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
|
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
|
||||||
const timeoutMs = req.timeoutMs ?? config.timeout;
|
const timeoutMs = req.timeoutMs ?? config.timeout;
|
||||||
|
|
||||||
|
// [v2.2.305] Claude 구독 모델 ('claude:sonnet' 등) — 로컬 엔진 대신 Claude Code CLI 위임.
|
||||||
|
// 워크플로우·Report QA·리랭크 등 coreChat 소비자 전부가 구독 모델로 동작 가능해진다.
|
||||||
|
// (다른 클라우드 prefix 는 스트리밍 전용 경로만 지원 — 여기 오면 아래 로컬 시도가 실패로 드러남)
|
||||||
|
if (model.startsWith('claude:')) {
|
||||||
|
const { runClaudeCode } = await import('../features/providers/claudeCode');
|
||||||
|
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
|
||||||
|
if (req.system && req.system.trim()) messages.push({ role: 'system', content: req.system });
|
||||||
|
messages.push({ role: 'user', content: req.user });
|
||||||
|
const r = await runClaudeCode({
|
||||||
|
messages,
|
||||||
|
model: model.slice('claude:'.length),
|
||||||
|
signal: req.signal,
|
||||||
|
timeoutMs: Math.max(timeoutMs, 120_000),
|
||||||
|
});
|
||||||
|
return { content: r.text, engine: 'claude-code', model, empty: !r.text.trim() };
|
||||||
|
}
|
||||||
const primaryEngine = resolveEngine(config.ollamaUrl);
|
const primaryEngine = resolveEngine(config.ollamaUrl);
|
||||||
const engines = primaryEngine === 'lmstudio'
|
const engines = primaryEngine === 'lmstudio'
|
||||||
? ['lmstudio', 'ollama'] as const
|
? ['lmstudio', 'ollama'] as const
|
||||||
@@ -89,11 +112,21 @@ export class AIService implements IAIService {
|
|||||||
|
|
||||||
for (const engine of engines) {
|
for (const engine of engines) {
|
||||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||||
|
const temperature = req.temperature ?? 0.7;
|
||||||
const payload = {
|
const payload = {
|
||||||
model,
|
model,
|
||||||
messages,
|
messages,
|
||||||
stream: false,
|
stream: false,
|
||||||
...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }),
|
...(engine === 'ollama'
|
||||||
|
? { options: {
|
||||||
|
temperature,
|
||||||
|
...(req.maxTokens != null ? { num_predict: req.maxTokens } : {}),
|
||||||
|
...(req.numCtx != null ? { num_ctx: req.numCtx } : {}),
|
||||||
|
} }
|
||||||
|
: {
|
||||||
|
temperature,
|
||||||
|
...(req.maxTokens != null ? { max_tokens: req.maxTokens } : {}),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -151,6 +184,15 @@ export class AIService implements IAIService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [코어 수렴] 인스턴스 없이 코어 LLM 경로를 쓰는 모듈 함수용 헬퍼.
|
||||||
|
* AIService 는 무상태(설정을 매 호출 읽음)라 안전하다. 엔진 폴백·타임아웃·abort·
|
||||||
|
* 로깅·빈응답 소프트실패가 모두 이 한 경로로 통일된다 — 자체 fetch 금지.
|
||||||
|
*/
|
||||||
|
export function coreChat(req: AIChatRequest): Promise<AIChatResult> {
|
||||||
|
return new AIService().chat(req);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체
|
* BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체
|
||||||
*/
|
*/
|
||||||
|
|||||||
+15
-1
@@ -5,6 +5,7 @@ import * as path from 'path';
|
|||||||
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
|
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
|
||||||
import './features/teamops/handlers';
|
import './features/teamops/handlers';
|
||||||
import './features/system/handlers';
|
import './features/system/handlers';
|
||||||
|
import './features/claude/claudeLauncher'; // /claude — Claude Code CLI 위임 (v2.2.303)
|
||||||
import './features/datacollect/handlers';
|
import './features/datacollect/handlers';
|
||||||
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
|
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
|
||||||
// axios removed in favor of native fetch
|
// axios removed in favor of native fetch
|
||||||
@@ -39,7 +40,7 @@ import { getBrainTokenIndex, clearBrainTokenIndex } from './retrieval';
|
|||||||
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
|
import { lessonTemplate, lessonSlug, parseLessonFrontmatter, normalizeLessonTitle, bumpLessonOccurrences } from './retrieval/lessonHelpers';
|
||||||
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup';
|
import { runConnectGoogleCalendarIcal, runConnectGoogleCalendarOAuth } from './extension/calendarSetup';
|
||||||
import { runInitialSetup } from './extension/initialSetup';
|
import { runInitialSetup } from './extension/initialSetup';
|
||||||
import { startStocksWatcher } from './features/stocks';
|
import { startStocksWatcher, registerStocksAutoBackup } from './features/stocks';
|
||||||
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
import { startDailyBriefingWatcher } from './features/briefing/dailyBriefing';
|
||||||
import { startEmailWatcher } from './features/email/emailWatcher';
|
import { startEmailWatcher } from './features/email/emailWatcher';
|
||||||
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
import { ensureDefaultBrainConfigured } from './extension/brainBootstrap';
|
||||||
@@ -85,6 +86,17 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
|
// 두뇌에 기록. 자기 평가가 구식 스냅샷 대신 항상 현행 소스를 근거로 하게 한다.
|
||||||
void ensureFeatureInventory(context);
|
void ensureFeatureInventory(context);
|
||||||
|
|
||||||
|
// [v2.2.311] 제2뇌 캐시 워밍 — 활성화 직후 백그라운드로 파일 목록 + 토큰 인덱스
|
||||||
|
// (수십 MB JSON)를 메모리에 올려, 세션 첫 질의가 그 비용(수 초)을 뒤집어쓰지
|
||||||
|
// 않게 한다. 워처가 같이 걸려 이후 턴은 변경 없으면 워크/stat 을 통째로 생략.
|
||||||
|
try {
|
||||||
|
const { getActiveBrainProfile } = require('./utils') as typeof import('./utils');
|
||||||
|
const { warmBrainCache } = require('./retrieval/brainWatch') as typeof import('./retrieval/brainWatch');
|
||||||
|
const { getBrainTokenIndex } = require('./retrieval/brainIndex') as typeof import('./retrieval/brainIndex');
|
||||||
|
const brainPath = getActiveBrainProfile()?.localBrainPath;
|
||||||
|
if (brainPath) warmBrainCache(brainPath, (files) => { getBrainTokenIndex(brainPath, files); });
|
||||||
|
} catch { /* 워밍 실패는 비치명 — 첫 질의가 종전 경로로 처리 */ }
|
||||||
|
|
||||||
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
|
// Initialize Astra Path Resolver (.astra → ConnectAI/.astra/)
|
||||||
initAstraPathResolver(context);
|
initAstraPathResolver(context);
|
||||||
|
|
||||||
@@ -327,6 +339,8 @@ export async function activate(context: vscode.ExtensionContext) {
|
|||||||
// Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거.
|
// Stocks watcher — VS Code 시작 시 자동 활성화. KST 09:00 / 15:00 자동 트리거.
|
||||||
// disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup.
|
// disposable 은 subscriptions 에 푸시해 종료 시 timer cleanup.
|
||||||
context.subscriptions.push(startStocksWatcher(context));
|
context.subscriptions.push(startStocksWatcher(context));
|
||||||
|
// Stocks 자동 백업 — stocks.json 변경 시 Sheets `_backup` 탭으로 (v2.2.297).
|
||||||
|
context.subscriptions.push(registerStocksAutoBackup(context));
|
||||||
|
|
||||||
// Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송.
|
// Daily briefing — 평일 KST 09:30 오늘의 일정·할일을 텔레그램으로 발송.
|
||||||
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
|
// 텔레그램/캘린더 미연결이면 fire 시점에 조용히 skip (로그만).
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, for
|
|||||||
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
|
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
|
||||||
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
|
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
|
||||||
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
|
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
|
||||||
|
import { loadStandingRules, buildStandingRulesBlock, STANDING_RULES_REL_PATH } from '../intelligence/correctionLoop';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검색 평가 명령 묶음 (Phase 1-나).
|
* 검색 평가 명령 묶음 (Phase 1-나).
|
||||||
@@ -43,6 +44,7 @@ export function registerEvalCommands(): vscode.Disposable[] {
|
|||||||
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
||||||
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
|
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
|
||||||
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
|
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
|
||||||
|
vscode.commands.registerCommand('g1nation.growth.standingRules', standingRulesCommand),
|
||||||
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
|
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
|
||||||
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
|
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
|
||||||
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
|
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
|
||||||
@@ -307,6 +309,57 @@ async function runTaskEvalCommand(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
|
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
|
||||||
|
/**
|
||||||
|
* Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
|
||||||
|
* 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
|
||||||
|
* 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
|
||||||
|
*/
|
||||||
|
async function standingRulesCommand(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const brain = getActiveBrainProfile();
|
||||||
|
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
|
||||||
|
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rules = loadStandingRules(brain.localBrainPath);
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
const retired = rules.filter(r => r.status !== 'active');
|
||||||
|
const block = buildStandingRulesBlock(rules);
|
||||||
|
const md = [
|
||||||
|
'# 상시 행동 규칙 (Standing Rules)',
|
||||||
|
'',
|
||||||
|
`사용자 지적("또 ~하네", "하지 말라고 했잖아")이 명령형 규칙으로 정규화되어 여기 쌓이고,`,
|
||||||
|
`**매 턴 시스템 프롬프트 보호 구역에 주입**됩니다 — 세션을 새로 열어도 유지됩니다.`,
|
||||||
|
`수정/삭제: \`${STANDING_RULES_REL_PATH}\` 파일을 직접 편집하세요.`,
|
||||||
|
'',
|
||||||
|
`## 활성 규칙 ${active.length}개`,
|
||||||
|
'',
|
||||||
|
...(active.length
|
||||||
|
? active.sort((a, b) => b.hits - a.hits).map(r =>
|
||||||
|
`- **${r.rule}** — 지적 ${r.hits}회, 최근 ${r.lastHitAt.slice(0, 10)}\n - 원 발화: "${r.origin}"`)
|
||||||
|
: ['(아직 없음 — 행동/형식을 지적하면 자동으로 쌓입니다)']),
|
||||||
|
'',
|
||||||
|
`## 은퇴 규칙 ${retired.length}개`,
|
||||||
|
...(retired.length ? retired.map(r => `- ${r.rule} (지적 ${r.hits}회)`) : ['(없음)']),
|
||||||
|
'',
|
||||||
|
'## 실제 주입 블록 (도달 증거)',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
block || '(활성 규칙이 없어 이번 턴에는 주입되지 않음)',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const reportPath = path.join(brain.localBrainPath, '.astra', 'growth', 'standing-rules-report.md');
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(reportPath, md, 'utf8');
|
||||||
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
|
||||||
|
await vscode.window.showTextDocument(doc, { preview: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
logError('Standing rules command failed.', { error: err?.message || String(err) });
|
||||||
|
vscode.window.showErrorMessage(`상시 규칙 열람 실패: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function growthReportCommand(): Promise<void> {
|
async function growthReportCommand(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const brain = getActiveBrainProfile();
|
const brain = getActiveBrainProfile();
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
|
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
|
||||||
*/
|
*/
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||||
import { logError, logInfo } from '../../utils';
|
import { logError, logInfo } from '../../utils';
|
||||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||||
@@ -22,25 +23,8 @@ let _timer: NodeJS.Timeout | undefined;
|
|||||||
let _disposed = false;
|
let _disposed = false;
|
||||||
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
|
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
|
||||||
|
|
||||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
|
||||||
const now = new Date();
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).formatToParts(now);
|
|
||||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
|
||||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
|
||||||
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일 (0=일).
|
|
||||||
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
|
||||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
|
|
||||||
}
|
|
||||||
|
|
||||||
function briefingTime(): { hour: number; minute: number } {
|
function briefingTime(): { hour: number; minute: number } {
|
||||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
|
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30'), 9, 30);
|
||||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
|
||||||
if (!m) return { hour: 9, minute: 30 };
|
|
||||||
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 다음 발송까지 ms — 평일 스킵 포함. */
|
/** 다음 발송까지 ms — 평일 스킵 포함. */
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { registerSlashCommand, chunk, type Webview } from '../datacollect/slashRouter';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /claude — 로컬에 설치된 Claude Code CLI 를 VS Code 통합 터미널에서 실행.
|
||||||
|
*
|
||||||
|
* 배경 (v2.2.303): 벤치마킹→개발 파이프라인의 마지막 구간. 로컬 소형 모델은
|
||||||
|
* 분석·명세(핸드오프 문서)까지 담당하고, 실제 코드 생성은 클라우드 모델에
|
||||||
|
* 위임한다. Anthropic 구독(Pro/Max) 인증은 API 키가 아니라 Claude Code 로그인
|
||||||
|
* 방식이므로, Astra 가 API 를 직접 호출하는 대신 **사용자가 이미 로그인해 둔
|
||||||
|
* `claude` CLI 를 실행**한다 — 추가 과금 없이 구독 한도 안에서 동작.
|
||||||
|
*
|
||||||
|
* 설계 (bridgeLauncher 의 터미널 패턴 재사용):
|
||||||
|
* - 통합 터미널("Claude Code (Astra)")에서 실행 → 셸/OS 차이를 VS Code 가 흡수,
|
||||||
|
* Claude Code 의 권한 승인·진행 상황을 사용자가 눈으로 보며 제어.
|
||||||
|
* - cwd = 첫 워크스페이스 폴더 (Claude Code 가 그 프로젝트 컨텍스트로 동작).
|
||||||
|
* - 지시문은 첫 인자로 전달 — 따옴표는 셸 호환을 위해 정규화.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TERMINAL_NAME = 'Claude Code (Astra)';
|
||||||
|
|
||||||
|
/** 셸(zsh/bash/PowerShell) 공통으로 안전한 한 줄 인자로 정규화 (순수 — 테스트 대상). */
|
||||||
|
export function buildClaudeCommand(instruction: string): string {
|
||||||
|
const t = (instruction || '').trim();
|
||||||
|
if (!t) return 'claude';
|
||||||
|
// 큰따옴표로 감싸므로 내부의 "는 ', POSIX 확장 문자와 PowerShell 백틱은 공백으로 완화.
|
||||||
|
// 지시문은 터미널에서 사용자가 이어서 수정할 수 있으니 완벽 이스케이프보다 안전 우선.
|
||||||
|
const safe = t.replace(/"/g, "'").replace(/[`$\\]/g, ' ').replace(/\s+/g, ' ').trim();
|
||||||
|
return `claude "${safe}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClaude(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||||
|
const instruction = (arg || '').trim();
|
||||||
|
|
||||||
|
const ws = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file');
|
||||||
|
const cwd = ws?.uri.fsPath;
|
||||||
|
|
||||||
|
// 기존 우리 터미널이 있으면 재사용하지 않고 정리 후 새로 연다 (죽은 셸 방지 — bridgeLauncher 동일).
|
||||||
|
vscode.window.terminals.find(t => t.name === TERMINAL_NAME)?.dispose();
|
||||||
|
const terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd });
|
||||||
|
terminal.show(true);
|
||||||
|
terminal.sendText(buildClaudeCommand(instruction));
|
||||||
|
|
||||||
|
chunk(view, [
|
||||||
|
`🚀 **Claude Code 실행** — "${TERMINAL_NAME}" 터미널에서 진행됩니다.`,
|
||||||
|
instruction ? `- 전달한 지시: ${instruction.slice(0, 200)}${instruction.length > 200 ? '…' : ''}` : '- 지시 없이 대화형으로 시작했습니다.',
|
||||||
|
cwd ? `- 작업 폴더: \`${cwd}\`` : '- ⚠️ 열린 워크스페이스가 없어 홈 폴더에서 시작합니다.',
|
||||||
|
'- 파일 수정·명령 실행 승인은 터미널에서 직접 하시면 됩니다 (구독 인증 그대로 사용).',
|
||||||
|
'',
|
||||||
|
'💡 `command not found: claude` 가 뜨면: 터미널에서 `npm install -g @anthropic-ai/claude-code` 설치 후 `claude` 를 실행해 로그인하세요.',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerSlashCommand({
|
||||||
|
name: '/claude',
|
||||||
|
description: 'Claude Code(클라우드·구독 인증)를 터미널로 실행 — 핸드오프 문서 기반 개발 위임. 예: /claude <파일경로> 읽고 명세대로 개발해줘',
|
||||||
|
handler: runClaude,
|
||||||
|
});
|
||||||
@@ -161,6 +161,12 @@ export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
|
|||||||
export interface DispatcherDeps {
|
export interface DispatcherDeps {
|
||||||
context: vscode.ExtensionContext;
|
context: vscode.ExtensionContext;
|
||||||
ai: IAIService;
|
ai: IAIService;
|
||||||
|
/**
|
||||||
|
* [코어 수렴] 두뇌 컨텍스트 검색 thunk — 있으면 메인 RetrievalOrchestrator 의
|
||||||
|
* 전체 검색 경로(임베딩 하이브리드·청크)를 사용한다. 없으면(텔레그램 등 경량
|
||||||
|
* 환경) 기존 scopedBrainRetriever 로 폴백. 반환 포맷은 buildContextBlock 동일.
|
||||||
|
*/
|
||||||
|
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||||
/** Default model to fall back to when an agent has no override. */
|
/** Default model to fall back to when an agent has no override. */
|
||||||
defaultModel: string;
|
defaultModel: string;
|
||||||
/**
|
/**
|
||||||
@@ -667,11 +673,17 @@ async function _dispatchOne(
|
|||||||
// the whole brain so a missing mapping doesn't starve the
|
// the whole brain so a missing mapping doesn't starve the
|
||||||
// dispatcher.
|
// dispatcher.
|
||||||
const scope = resolveScopeForAgent(agentId, brainRoot);
|
const scope = resolveScopeForAgent(agentId, brainRoot);
|
||||||
|
if (deps.retrieveBrainContext) {
|
||||||
|
// 메인 오케스트레이터 경로 — 임베딩 하이브리드·청크·개선 사항을 그대로 수혜.
|
||||||
|
brainContext = await deps.retrieveBrainContext(task, scope.folders, brainFileLimit);
|
||||||
|
} else {
|
||||||
|
// 경량 폴백 (텔레그램 등 오케스트레이터가 없는 소비자).
|
||||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||||
maxResults: brainFileLimit,
|
maxResults: brainFileLimit,
|
||||||
});
|
});
|
||||||
brainContext = buildContextBlock(retrieval);
|
brainContext = buildContextBlock(retrieval);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
||||||
agentId, error: e?.message ?? String(e),
|
agentId, error: e?.message ?? String(e),
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { exec } from 'child_process';
|
import { execShell } from '../../lib/execUtil';
|
||||||
import { promisify } from 'util';
|
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
|
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
|
||||||
@@ -133,7 +130,7 @@ export async function stopNotebookLmBridge(): Promise<void> {
|
|||||||
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
|
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
|
||||||
if (await isBridgeAlive()) {
|
if (await isBridgeAlive()) {
|
||||||
try {
|
try {
|
||||||
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
|
await execShell(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeoutMs: 8000 });
|
||||||
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
|
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ import { promises as fsp } from 'fs';
|
|||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
||||||
import { callLmSynthesis } from './llm';
|
import { callLmSynthesis } from './llm';
|
||||||
|
import { getConfig } from '../../config';
|
||||||
|
import { logInfo } from '../../utils';
|
||||||
|
import { runReportQaLoop } from '../../agents/reportQa';
|
||||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||||
import { collectSourceFiles } from './reviewFiles';
|
import { collectSourceFiles } from './reviewFiles';
|
||||||
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
||||||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||||||
|
import { buildHandoffDoc } from './prompts/handoffDoc';
|
||||||
|
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
|
||||||
import {
|
import {
|
||||||
type YoutubeAnalysisMode,
|
type YoutubeAnalysisMode,
|
||||||
formatHms,
|
formatHms,
|
||||||
@@ -49,28 +54,64 @@ import {
|
|||||||
|
|
||||||
// ───────────────────────────── /benchmark ─────────────────────────────
|
// ───────────────────────────── /benchmark ─────────────────────────────
|
||||||
|
|
||||||
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
/**
|
||||||
|
* /benchmark 모드 → 실행할 합성 파트 목록.
|
||||||
|
* - full(기본): 디자인 3파트 + 비즈니스 렌즈 (1~4)
|
||||||
|
* - design: 기존 3파트만 (원본 복원 시방서)
|
||||||
|
* - biz: 비즈니스 렌즈만 (빠름 — 포지셔닝·가격·운영)
|
||||||
|
* - build: 풀코스 + 스텝별 빌드 프롬프트 (1~5)
|
||||||
|
*/
|
||||||
|
export type BenchmarkMode = 'full' | 'design' | 'biz' | 'build' | 'handoff';
|
||||||
|
export const BENCHMARK_MODE_PARTS: Record<BenchmarkMode, ReadonlyArray<1 | 2 | 3 | 4 | 5>> = {
|
||||||
|
full: [1, 2, 3, 4],
|
||||||
|
design: [1, 2, 3],
|
||||||
|
biz: [4],
|
||||||
|
build: [1, 2, 3, 4, 5],
|
||||||
|
handoff: [], // LLM 합성 없음 — 스캔 원시 데이터로 인수인계 문서를 결정론 생성
|
||||||
|
};
|
||||||
|
|
||||||
|
/** /benchmark 인자 파싱 — 순수 함수 (테스트 대상). */
|
||||||
|
export function parseBenchmarkArgs(arg: string): {
|
||||||
|
url: string; depth?: number; pages?: number; mode: BenchmarkMode; userContent: string;
|
||||||
|
} {
|
||||||
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
const tokens = arg.trim().split(/\s+/).filter(Boolean);
|
||||||
let url = '';
|
let url = '';
|
||||||
let depthArg: number | undefined;
|
let depth: number | undefined;
|
||||||
let pagesArg: number | undefined;
|
let pages: number | undefined;
|
||||||
|
let mode: BenchmarkMode = 'full';
|
||||||
const restParts: string[] = [];
|
const restParts: string[] = [];
|
||||||
|
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
|
||||||
for (const t of tokens) {
|
for (const t of tokens) {
|
||||||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||||||
|
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
|
||||||
if (m) {
|
if (m) {
|
||||||
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
|
if (m[1].toLowerCase() === 'depth') depth = Number(m[2]);
|
||||||
else pagesArg = Number(m[2]);
|
else pages = Number(m[2]);
|
||||||
|
} else if (modeM) {
|
||||||
|
mode = modeM[1].toLowerCase() as BenchmarkMode;
|
||||||
|
} else if (BARE_MODES.has(t.toLowerCase() as BenchmarkMode)) {
|
||||||
|
mode = t.toLowerCase() as BenchmarkMode;
|
||||||
} else if (!url) {
|
} else if (!url) {
|
||||||
url = t;
|
url = t;
|
||||||
} else {
|
} else {
|
||||||
restParts.push(t);
|
restParts.push(t);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return { url, depth, pages, mode, userContent: restParts.join(' ') };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
|
||||||
|
const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
|
chunk(view, [
|
||||||
|
'사용법: `/benchmark <url> [mode] [depth=N] [pages=N] [보조 설명]`',
|
||||||
|
'- mode: `full`(기본 — 디자인+비즈니스) · `design`(복원 시방서만) · `biz`(사업 분석만) · `build`(풀코스+빌드 프롬프트) · `handoff`(클라우드 개발용 인수인계 문서 — LLM 없이 즉시)',
|
||||||
|
'- 가격·수익 분석이 목적이면 `depth=2` 로 pricing 페이지까지 크롤 권장',
|
||||||
|
'예: `/benchmark https://interiorai.com build depth=2 pages=12 나는 부동산 쪽으로 변형하고 싶어`',
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
const userContent = restParts.join(' ');
|
|
||||||
|
|
||||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||||
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
|
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
|
||||||
@@ -126,23 +167,84 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
|||||||
'```',
|
'```',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
|
// ── handoff 모드: LLM 합성을 건너뛰고 스캔 원시 데이터로 인수인계 문서를 즉시 생성.
|
||||||
|
// 소형 모델의 요약 병목을 통과시키지 않는다 (측정치 무손실 — handoffDoc.ts 참조).
|
||||||
|
if (mode === 'handoff') {
|
||||||
|
const handoffDoc = buildHandoffDoc(s, url, { crawlDepth, maxPages, userContent });
|
||||||
|
chunk(view, `📦 **개발 인수인계 문서 생성** (LLM 미개입 — 측정치 원본)\n\n`);
|
||||||
|
const hToday = new Date().toISOString().slice(0, 10);
|
||||||
|
let hHost = url;
|
||||||
|
try { hHost = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw */ }
|
||||||
|
const hTitle = `웹벤치마크 핸드오프 ${hHost} ${hToday}`;
|
||||||
|
const savedH = await persistWikiDoc(hTitle, handoffDoc, { docType: 'benchmark-handoff', sources: [url] });
|
||||||
|
chunk(view, describeSave(savedH, '인수인계 문서'));
|
||||||
|
if (savedH.ok && savedH.path) {
|
||||||
|
chunk(view, [
|
||||||
|
'',
|
||||||
|
'**다음 단계 — 클라우드 모델로 개발:**',
|
||||||
|
`1. 파일을 열어 §1 "내 요구사항" 을 채우세요 (무엇으로 변형할지).`,
|
||||||
|
`2. Claude Code(구독 인증)로 바로 개발: \`/claude ${savedH.path} 파일을 읽고 명세대로 개발해줘\``,
|
||||||
|
` (또는 Antigravity 에 파일 내용을 붙여넣어도 됩니다)`,
|
||||||
|
'',
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
let finalReport: string;
|
let finalReport: string;
|
||||||
if (looksEmpty) {
|
if (looksEmpty) {
|
||||||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||||||
finalReport = rawReport;
|
finalReport = rawReport;
|
||||||
} else {
|
} else {
|
||||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||||
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
const partList = BENCHMARK_MODE_PARTS[mode];
|
||||||
|
const PART_LABELS: Record<number, string> = {
|
||||||
|
1: '4-렌즈 디자인', 2: 'IA·토큰', 3: '재구축 명세', 4: '비즈니스 렌즈', 5: '빌드 프롬프트',
|
||||||
|
};
|
||||||
|
chunk(view, `🧪 **LLM 합성** (mode \`${mode}\` · ${partList.length}단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||||
try {
|
try {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
for (const part of [1, 2, 3] as const) {
|
let okParts = 0;
|
||||||
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
|
for (let i = 0; i < partList.length; i++) {
|
||||||
|
const part = partList[i];
|
||||||
|
chunk(view, `\n · ${i + 1}/${partList.length} ${PART_LABELS[part]} 합성 중…`);
|
||||||
const partT0 = Date.now();
|
const partT0 = Date.now();
|
||||||
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
|
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
|
||||||
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
|
// (실사례: 파트 2 타임아웃으로 105초 걸린 파트 1까지 폐기). 타임아웃도 300초로 —
|
||||||
|
// 입력 JSON 이 큰 파트는 e4b 급에서 120초를 넘긴다.
|
||||||
|
let out: string;
|
||||||
|
try {
|
||||||
|
out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part), undefined, { timeoutMs: 300_000 });
|
||||||
|
} catch (partErr: any) {
|
||||||
|
chunk(view, ` ✗ (${Math.round((Date.now() - partT0) / 1000)}s — ${partErr?.message || partErr})`);
|
||||||
|
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: ${partErr?.message || String(partErr)} — 나머지 파트는 계속 진행됨.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!out) {
|
||||||
|
chunk(view, ` ✗ (빈 응답)`);
|
||||||
|
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: 빈 응답 — 나머지 파트는 계속 진행됨.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
okParts++;
|
||||||
|
// [Report QA] 비즈니스 렌즈는 날조 위험(임의 가격·매출)이 가장 큰 파트 —
|
||||||
|
// 채점→피드백 재작성→회귀 게이트를 통과시킨다. prompt 로 합성 프롬프트
|
||||||
|
// 자체(스캔 JSON 포함)를 넘겨 재작성이 원본 근거를 보게 한다.
|
||||||
|
if (part === 4 && getConfig().reportQaEnabled) {
|
||||||
|
chunk(view, ` (품질 검증 중…)`);
|
||||||
|
const qa = await runReportQaLoop({
|
||||||
|
draft: out,
|
||||||
|
prompt: buildSynthesisPrompt(s, userContent, 4),
|
||||||
|
intentBrief: null,
|
||||||
|
model,
|
||||||
|
onProgress: (msg: string) => chunk(view, ` · ${msg}`),
|
||||||
|
});
|
||||||
|
out = qa.finalDraft;
|
||||||
|
logInfo('[Benchmark] 비즈니스 렌즈 QA 완료.', { score: qa.score, revisions: qa.revisions });
|
||||||
|
}
|
||||||
parts.push(out);
|
parts.push(out);
|
||||||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||||||
}
|
}
|
||||||
|
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
|
||||||
finalReport = parts.join('\n\n---\n\n');
|
finalReport = parts.join('\n\n---\n\n');
|
||||||
chunk(view, `\n\n`);
|
chunk(view, `\n\n`);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -151,6 +253,10 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [v2.2.307] 레이아웃 골격 (결정론) — 합성 성공/실패와 무관하게 항상 포함.
|
||||||
|
// "배너/캐러셀이 있는가, 각 페이지가 어떤 블록 순서로 구성되는가"에 즉답.
|
||||||
|
finalReport = `${buildLayoutSkeleton(s)}\n\n---\n\n${finalReport}`;
|
||||||
|
|
||||||
chunk(view, finalReport + '\n\n');
|
chunk(view, finalReport + '\n\n');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1129,7 +1235,7 @@ async function reduceReviewNotes(
|
|||||||
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
||||||
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
||||||
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
||||||
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
|
registerSlashCommand({ name: '/benchmark', description: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', handler: runBenchmark });
|
||||||
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
||||||
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
||||||
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import * as vscode from 'vscode';
|
|||||||
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
|
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
|
||||||
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw.
|
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw.
|
||||||
*/
|
*/
|
||||||
|
// [코어 수렴 예외 — 의도적] 이 모듈은 core/services.coreChat 으로 수렴하지 않는다:
|
||||||
|
// finish_reason 기반 이어쓰기(continuation), repeat_penalty/top_k 커스텀 샘플링,
|
||||||
|
// degeneration 감지·재시도 등 '장문 생성 엔진' 요구가 코어 채팅 API 범위를 벗어난다.
|
||||||
|
// 단순 단발 완성이 필요한 새 코드는 이 파일이 아니라 coreChat 을 사용할 것.
|
||||||
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
|
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
@@ -70,6 +74,9 @@ interface LmOpts {
|
|||||||
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
||||||
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
||||||
maxContinuations?: number;
|
maxContinuations?: number;
|
||||||
|
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
|
||||||
|
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
||||||
@@ -82,6 +89,7 @@ async function callLmOnce(
|
|||||||
lmUrl: string, model: string, sys: string, prompt: string,
|
lmUrl: string, model: string, sys: string, prompt: string,
|
||||||
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
||||||
maxTokens?: number,
|
maxTokens?: number,
|
||||||
|
timeoutMs = 120_000,
|
||||||
): Promise<LmResult> {
|
): Promise<LmResult> {
|
||||||
const payload: Record<string, unknown> = {
|
const payload: Record<string, unknown> = {
|
||||||
model,
|
model,
|
||||||
@@ -96,7 +104,7 @@ async function callLmOnce(
|
|||||||
};
|
};
|
||||||
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
||||||
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
|
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
|
||||||
const res = await lmChat(lmUrl, payload, 120_000);
|
const res = await lmChat(lmUrl, payload, timeoutMs);
|
||||||
const choice = res?.choices?.[0];
|
const choice = res?.choices?.[0];
|
||||||
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
||||||
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
|
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
|
||||||
@@ -146,7 +154,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
|||||||
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
|
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||||
let out = first.text;
|
let out = first.text;
|
||||||
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
||||||
if (looksDegenerate(out)) {
|
if (looksDegenerate(out)) {
|
||||||
@@ -159,7 +167,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
|
|||||||
for (let c = 0; c < maxCont && finish === 'length'; c++) {
|
for (let c = 0; c < maxCont && finish === 'length'; c++) {
|
||||||
let next: LmResult;
|
let next: LmResult;
|
||||||
try {
|
try {
|
||||||
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
|
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens, opts?.timeoutMs);
|
||||||
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
||||||
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
||||||
out = `${out}\n${next.text}`.trim();
|
out = `${out}\n${next.text}`.trim();
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* /benchmark handoff — 클라우드 모델(Claude Code·Antigravity 등) 개발 인수인계 문서.
|
||||||
|
*
|
||||||
|
* 설계 원칙 (v2.2.303): 로컬 소형 모델의 *작문*을 통과시키지 않는다.
|
||||||
|
* 4b 모델이 큰 모델에게 줄 지시문을 요약하게 하면 스캔이 정확히 측정한 값
|
||||||
|
* (rgb·px·카피 원문)이 "적절한 색상" 같은 모호어로 퇴화한다. 클라우드 모델은
|
||||||
|
* 원시 구조화 데이터를 그대로 소화하는 걸 가장 잘하므로, 이 문서는 스캔 JSON 을
|
||||||
|
* 결정론적으로 추려 옮기는 **순수 함수**다 — LLM 호출 0회, 손실 0.
|
||||||
|
*
|
||||||
|
* 산출물 구성: ①클라우드 모델용 지시문 ②내 요구사항 슬롯(사용자 변형 지시)
|
||||||
|
* ③디자인 토큰 원본 ④마이크로카피 원문 ⑤IA/페이지 인벤토리 ⑥원시 JSON 부록.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const j = (v: unknown): string => JSON.stringify(v, null, 2);
|
||||||
|
|
||||||
|
/** 값이 있으면 "- **라벨**: 값" 한 줄, 없으면 빈 문자열. */
|
||||||
|
function line(label: string, v: unknown): string {
|
||||||
|
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) return '';
|
||||||
|
const s = Array.isArray(v) ? v.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v));
|
||||||
|
return `- **${label}**: ${s}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title: string, lines: string[]): string {
|
||||||
|
const body = lines.filter(Boolean).join('\n');
|
||||||
|
return body ? `### ${title}\n${body}` : `### ${title}\n- (스캔 데이터 없음)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildHandoffDoc(scan: any, url: string, opts: { crawlDepth: number; maxPages: number; userContent: string }): string {
|
||||||
|
const s = scan || {};
|
||||||
|
const colors = s.design?.colors || {};
|
||||||
|
const typo = s.design?.typography || {};
|
||||||
|
const layout = s.design?.layout || {};
|
||||||
|
const inter = s.interactions || {};
|
||||||
|
const micro = s.microcopy || {};
|
||||||
|
const pages: any[] = s.sitemap?.pages || [];
|
||||||
|
|
||||||
|
const pageRows = pages.map((p: any) => {
|
||||||
|
const forms = (p.formFields || []).slice(0, 4).map((f: any) => f.name || f.label || f.type).filter(Boolean).join('·') || '-';
|
||||||
|
const ctas = (p.ctaSamples || []).slice(0, 3).join(' / ') || '-';
|
||||||
|
return `| ${p.url || '-'} | ${p.role || '-'} | ${(p.h1 || p.title || '-').slice(0, 40)} | ${p.primaryContentType || '-'} | ${p.imageCount ?? '-'} | ${forms} | ${ctas} |`;
|
||||||
|
}).join('\n');
|
||||||
|
|
||||||
|
const requirements = opts.userContent.trim()
|
||||||
|
? `사용자가 지정한 변형 방향:\n> ${opts.userContent.trim()}\n\n위 방향에 맞춰 아래 원본 명세를 변형하되, 디자인 토큰·레이아웃 골격은 유지하라.`
|
||||||
|
: [
|
||||||
|
'아직 지정되지 않음 — 개발 시작 전에 아래를 채워 넣을 것:',
|
||||||
|
'- [ ] 서비스 이름 / 도메인:',
|
||||||
|
'- [ ] 헤드라인을 무엇으로 바꿀지 (원본의 포지셔닝 공식은 유지):',
|
||||||
|
'- [ ] 콘텐츠·이미지는 무엇으로 대체할지:',
|
||||||
|
'- [ ] 뺄 기능 (결제·로그인 등 MVP 에서 제외할 것):',
|
||||||
|
'- [ ] 핵심 생성/처리 엔진 (사용할 API 또는 로컬 모델):',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
return `# 개발 인수인계 — ${s.meta?.title || url}
|
||||||
|
|
||||||
|
> 이 문서는 Playwright 스캔이 **측정한 원시 값**을 그대로 옮긴 개발 명세입니다.
|
||||||
|
> 생성: Astra /benchmark handoff · 원본 ${url} · 스캔 ${s.sitemap?.totalPages ?? 1}페이지 (depth ${opts.crawlDepth})
|
||||||
|
|
||||||
|
## 0. 클라우드 모델용 지시문 (이 문서를 받은 AI 에게)
|
||||||
|
|
||||||
|
너는 아래 명세를 근거로 원본 사이트와 매우 유사한 사이트를 처음부터 구현한다.
|
||||||
|
1. **모든 색상·폰트·여백·radius 값은 이 문서의 측정치를 그대로 사용하라** — "비슷한 색", "모던한 폰트" 같은 임의 대체 금지.
|
||||||
|
2. §1 "내 요구사항"의 변경 지시가 원본 명세와 충돌하면 요구사항이 우선한다. 그 외에는 원본 재현이 기본.
|
||||||
|
3. 페이지 구조는 §4 인벤토리의 역할(role)·섹션 순서를 따른다.
|
||||||
|
4. 기술 스택은 특별한 지시가 없으면 정적 웹 + 최소 백엔드(Next.js 또는 Node/Express)로 단순하게.
|
||||||
|
5. 명세에 없는 정보(백엔드 로직·CMS·결제 연동)는 추측하지 말고 구현 전에 질문하라.
|
||||||
|
|
||||||
|
## 1. 내 요구사항 (변형 지시)
|
||||||
|
|
||||||
|
${requirements}
|
||||||
|
|
||||||
|
## 2. 디자인 토큰 (측정 원본)
|
||||||
|
|
||||||
|
${section('컬러', [
|
||||||
|
line('팔레트 (사용 빈도순)', (colors.palette || []).slice(0, 8).map((p: any) => `${p.value}(×${p.count})`)),
|
||||||
|
line('배경', colors.background),
|
||||||
|
line('본문 텍스트', colors.primaryText),
|
||||||
|
line('링크', colors.linkColor),
|
||||||
|
line('버튼 배경', colors.buttonBackground),
|
||||||
|
line('버튼 텍스트', colors.buttonText),
|
||||||
|
line('구성 비율', colors.composition?.ratioLabel),
|
||||||
|
line('다크모드 신호', colors.darkModeHints),
|
||||||
|
])}
|
||||||
|
|
||||||
|
${section('타이포그래피', [
|
||||||
|
line('주 폰트', typo.primaryFont),
|
||||||
|
line('폰트 스택', (typo.fontStack || []).slice(0, 3)),
|
||||||
|
line('h1', typo.h1),
|
||||||
|
line('h2', typo.h2),
|
||||||
|
line('h3', typo.h3),
|
||||||
|
line('본문', typo.body),
|
||||||
|
line('버튼', typo.button),
|
||||||
|
line('상위 크기', (typo.topFontSizes || []).slice(0, 6)),
|
||||||
|
line('상위 굵기', (typo.topFontWeights || []).slice(0, 5)),
|
||||||
|
])}
|
||||||
|
|
||||||
|
${section('레이아웃 & 여백', [
|
||||||
|
line('본문 최대 폭', layout.bodyMaxWidth),
|
||||||
|
line('섹션 간 여백', layout.sectionSpacing),
|
||||||
|
line('카드 간격', layout.cardSpacing),
|
||||||
|
line('radius 스케일', layout.borderRadiusScale),
|
||||||
|
line('그리드', layout.grids),
|
||||||
|
line('컨테이너', layout.containerSystem),
|
||||||
|
line('반응형 신호', layout.responsiveHints),
|
||||||
|
])}
|
||||||
|
|
||||||
|
${section('인터랙션', [
|
||||||
|
line('hover 규칙', (inter.hoverRules || []).slice(0, 6)),
|
||||||
|
line('focus 규칙', (inter.focusRules || []).slice(0, 3)),
|
||||||
|
line('transition 분포', inter.transitionDistribution),
|
||||||
|
line('CSS 변수', inter.cssVars),
|
||||||
|
])}
|
||||||
|
|
||||||
|
## 3. 마이크로카피 원문 (톤앤매너의 근거 — 변형 시에도 이 공식·리듬 유지)
|
||||||
|
|
||||||
|
${[
|
||||||
|
line('헤드라인', micro.headline),
|
||||||
|
line('서브헤드라인', micro.subheadline || (micro.subheadlines || []).slice(0, 4)),
|
||||||
|
line('CTA 샘플', (micro.ctaSamples || []).slice(0, 8)),
|
||||||
|
line('placeholder', micro.placeholders),
|
||||||
|
line('상태 메시지', micro.stateMessages),
|
||||||
|
line('본문 샘플', typeof micro.bodySample === 'string' ? micro.bodySample.slice(0, 300) : micro.bodySample),
|
||||||
|
].filter(Boolean).join('\n') || '- (스캔 데이터 없음)'}
|
||||||
|
|
||||||
|
## 4. 페이지 인벤토리 (IA)
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
${String(s.sitemap?.ascii || '(단일 페이지)').trimEnd()}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
| URL | 역할 | 제목/H1 | 콘텐츠 유형 | 이미지 수 | 폼 필드 | CTA |
|
||||||
|
|---|---|---|---|---|---|---|
|
||||||
|
${pageRows || '| (단일 페이지 스캔) | - | - | - | - | - | - |'}
|
||||||
|
|
||||||
|
## 5. 원시 스캔 데이터 부록 (기계 판독용 — 위 요약에 없는 세부값은 여기서 인용)
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
${j({
|
||||||
|
meta: s.meta,
|
||||||
|
design: s.design,
|
||||||
|
interactions: s.interactions,
|
||||||
|
microcopy: s.microcopy,
|
||||||
|
structure: s.structure,
|
||||||
|
}).slice(0, 60_000)}
|
||||||
|
\`\`\`
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
/**
|
||||||
|
* 레이아웃 골격 리포트 — 스캔 데이터에서 **결정론으로** 페이지별 위→아래 구성을 그린다.
|
||||||
|
*
|
||||||
|
* 배경 (v2.2.307): /benchmark 결과가 "색·폰트" 수준에 머물고, LLM 합성이 죽으면
|
||||||
|
* 레이아웃 정보가 아예 사라졌다 (실사례: koritips 스캔 — 파트 2 타임아웃 → 원시
|
||||||
|
* 요약만 남음). 골격(어떤 섹션이 어떤 순서로, 배너/캐러셀/폼 위젯이 어디 있는지)은
|
||||||
|
* 스캔의 sectionRoles(태그 + class/id 힌트 + 미리보기)에서 LLM 없이 뽑을 수 있다.
|
||||||
|
* → 이 모듈이 항상 보고서에 포함되므로 합성이 실패해도 골격 분석은 남는다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** class/id 힌트 → 위젯 종류 추정 (단일 권위 — 새 신호는 여기만 추가). */
|
||||||
|
export const WIDGET_SIGNALS: ReadonlyArray<{ re: RegExp; label: string }> = [
|
||||||
|
{ re: /carousel|swiper|slick|slider|slideshow|glide|splide/i, label: '🎠 캐러셀/롤링 배너' },
|
||||||
|
{ re: /hero|jumbotron|masthead|banner|billboard/i, label: '🖼️ 히어로/배너' },
|
||||||
|
{ re: /nav|menu|gnb|header/i, label: '🧭 내비게이션/헤더' },
|
||||||
|
{ re: /footer/i, label: '🦶 푸터' },
|
||||||
|
{ re: /sidebar|aside|widget-area/i, label: '📌 사이드바' },
|
||||||
|
{ re: /search/i, label: '🔍 검색' },
|
||||||
|
{ re: /subscribe|newsletter|cta|signup/i, label: '📮 구독/CTA' },
|
||||||
|
{ re: /grid|card|list|archive|loop|posts/i, label: '🗂️ 카드/리스트 그리드' },
|
||||||
|
{ re: /comment/i, label: '💬 댓글' },
|
||||||
|
{ re: /related|recommend/i, label: '🔗 관련 글' },
|
||||||
|
{ re: /breadcrumb/i, label: '🧵 브레드크럼' },
|
||||||
|
{ re: /pagination|paging|pager/i, label: '📄 페이지네이션' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function classifyWidget(hint: string): string | null {
|
||||||
|
const h = (hint || '').trim();
|
||||||
|
if (!h) return null;
|
||||||
|
for (const { re, label } of WIDGET_SIGNALS) if (re.test(h)) return label;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionLine(sec: any, idx: number): string {
|
||||||
|
const tag = sec.tag || sec.role || 'section';
|
||||||
|
const hint = String(sec.hint || '').slice(0, 40);
|
||||||
|
const widget = classifyWidget(hint);
|
||||||
|
const preview = String(sec.preview || sec.text || sec.textPreview || '').replace(/\s+/g, ' ').slice(0, 60);
|
||||||
|
const counts = [
|
||||||
|
sec.btns != null || sec.buttonCount != null ? `버튼 ${sec.btns ?? sec.buttonCount}` : '',
|
||||||
|
sec.imgs != null || sec.imgCount != null ? `이미지 ${sec.imgs ?? sec.imgCount}` : '',
|
||||||
|
sec.links != null || sec.linkCount != null ? `링크 ${sec.links ?? sec.linkCount}` : '',
|
||||||
|
].filter(Boolean).join(' · ');
|
||||||
|
return [
|
||||||
|
`${idx + 1}. \`<${tag}>\``,
|
||||||
|
widget ? widget : '',
|
||||||
|
hint ? `\`${hint}\`` : '',
|
||||||
|
preview ? `— "${preview}"` : '',
|
||||||
|
counts ? `(${counts})` : '',
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지별 위→아래 골격 마크다운. 스캔이 비어도 깨지지 않는다.
|
||||||
|
* LLM 합성 성공/실패와 무관하게 보고서에 항상 포함할 것.
|
||||||
|
*/
|
||||||
|
export function buildLayoutSkeleton(scan: any): string {
|
||||||
|
const s = scan || {};
|
||||||
|
const out: string[] = ['## 레이아웃 골격 (스캔 실측 — LLM 미개입)', ''];
|
||||||
|
|
||||||
|
// 홈(루트) — structure.sections 가 가장 상세.
|
||||||
|
const rootSections: any[] = s.structure?.sections || [];
|
||||||
|
if (rootSections.length) {
|
||||||
|
out.push(`### 홈 \`/\` — 위→아래 블록 구성`);
|
||||||
|
rootSections.slice(0, 12).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||||
|
out.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 하위 페이지 — sitemap.pages[].sectionRoles.
|
||||||
|
const pages: any[] = (s.sitemap?.pages || []).filter((p: any) => !p?.error);
|
||||||
|
for (const p of pages) {
|
||||||
|
const roles: any[] = p.sectionRoles || [];
|
||||||
|
const pathLabel = (() => { try { return new URL(p.url, 'https://x').pathname; } catch { return p.url || '?'; } })();
|
||||||
|
if (pathLabel === '/' && rootSections.length) continue; // 홈은 위에서 상세 처리
|
||||||
|
if (!roles.length) continue;
|
||||||
|
out.push(`### \`${decodeURIComponent(pathLabel)}\` (${p.role || '?'} · ${p.primaryContentType || '?'})`);
|
||||||
|
roles.slice(0, 10).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
|
||||||
|
out.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 감지된 위젯 총괄 — "롤링 배너가 있다/없다"에 바로 답하는 요약.
|
||||||
|
const allHints: string[] = [
|
||||||
|
...rootSections.map((x: any) => String(x.hint || '')),
|
||||||
|
...pages.flatMap((p: any) => (p.sectionRoles || []).map((x: any) => String(x.hint || ''))),
|
||||||
|
];
|
||||||
|
const found = new Set<string>();
|
||||||
|
for (const h of allHints) { const w = classifyWidget(h); if (w) found.add(w); }
|
||||||
|
const carousel = [...found].some(w => w.includes('캐러셀'));
|
||||||
|
out.push('### 위젯 감지 요약');
|
||||||
|
out.push(`- 감지됨: ${found.size ? [...found].join(', ') : '(없음)'}`);
|
||||||
|
out.push(`- 롤링 배너/캐러셀: ${carousel ? '**있음** (class/id 신호 기준)' : '신호 없음 — 스캔 힌트에 캐러셀 계열 class 가 없었음 (JS 렌더 위젯일 수 있어 실사이트 확인 권장)'}`);
|
||||||
|
|
||||||
|
if (!rootSections.length && !pages.some((p: any) => (p.sectionRoles || []).length)) {
|
||||||
|
return '## 레이아웃 골격 (스캔 실측)\n- 섹션 데이터가 스캔에 없습니다 (depth/pages 를 올려 재스캔 권장).';
|
||||||
|
}
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
|
/**
|
||||||
export type SynthesisPart = 1 | 2 | 3;
|
* /benchmark 보고서의 파트 분할.
|
||||||
|
* 1: 4-렌즈 디자인 분석 / 2: IA + 디자인 토큰 / 3: 원본 재구축 명세 (재해석 금지)
|
||||||
|
* 4: 비즈니스 벤치마킹 렌즈 (v2.2.302 — 포지셔닝·가격·수익구조·운영 방향성.
|
||||||
|
* 피터 레벨스 류 1인 AI 기업 분석 프레임 이식. 여기서만 사용자 컨텍스트로 변형 제안 허용)
|
||||||
|
* 5: 단계별 빌드 프롬프트 (v2.2.302 — 재구축 명세를 바이브코딩 IDE 에 바로
|
||||||
|
* 붙여넣는 스텝별 프롬프트 교재로 변환)
|
||||||
|
*/
|
||||||
|
export type SynthesisPart = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
|
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
|
||||||
@@ -149,19 +156,24 @@ ${commonHeader}
|
|||||||
### 1-2. 타이포그래피 (Typography)
|
### 1-2. 타이포그래피 (Typography)
|
||||||
|
|
||||||
## 2. 레이아웃 및 여백 (Layout & Whitespace)
|
## 2. 레이아웃 및 여백 (Layout & Whitespace)
|
||||||
### 2-1. 그리드 시스템 (Grid System)
|
### 2-1. 페이지 골격 서술 (Page Skeleton — 위→아래)
|
||||||
### 2-2. 섹션 간 여백 (Section Spacing)
|
- \`structure.sections\` 와 \`sitemap.pages[].sectionRoles\` 를 근거로, 홈과 대표 페이지의 블록 구성을 **위→아래 순서 문장**으로 서술하라 (예: "헤더/GNB → 히어로 배너 → 최신 글 카드 그리드(이미지 40장) → 구독 폼 → 푸터").
|
||||||
### 2-3. 카드/카드 그리드 (Card Spacing)
|
- 각 섹션의 \`hint\`(class/id) 신호로 위젯을 판별하라: carousel/swiper/slick/slider = 롤링 배너, hero/banner = 정적 배너, grid/card/list = 카드 그리드 등. **배너가 롤링(캐러셀)인지 정적인지 반드시 판정**하고, 신호가 없으면 "캐러셀 class 신호 없음 — 정적으로 추정"이라고 명시하라 (지어내기 금지).
|
||||||
### 2-4. Border Radius / 컨테이너
|
### 2-2. 그리드 시스템 (Grid System)
|
||||||
|
### 2-3. 섹션 간 여백 (Section Spacing)
|
||||||
|
### 2-4. 카드/카드 그리드 (Card Spacing)
|
||||||
|
### 2-5. Border Radius / 컨테이너
|
||||||
|
|
||||||
## 3. 마이크로 인터랙션 (Micro Interaction)
|
## 3. 마이크로 인터랙션 (Micro Interaction)
|
||||||
### 3-1. Hover / Focus 효과
|
### 3-1. Hover / Focus 효과
|
||||||
### 3-2. Transition 패턴
|
### 3-2. Transition 패턴
|
||||||
### 3-3. 레이어링 (z-index / position)
|
### 3-3. 레이어링 (z-index / position)
|
||||||
|
|
||||||
## 4. 라이팅 톤앤매너 (Microcopy & Voice)
|
## 4. 톤앤매너 (Tone & Manner)
|
||||||
### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피
|
### 4-1. 시각적 톤앤매너 한 문단 (Visual Personality)
|
||||||
### 4-2. Placeholder 및 보이스 신호`
|
- 컬러 비율·폰트·radius·여백 수치를 종합해 이 사이트의 시각적 성격을 한 문단으로 규정하라 (예: "무채색 80% + 하늘색 포인트, 넉넉한 여백, 산세리프 — 정보 전달 중심의 차분한 블로그 톤"). 수치를 인용하되 성격 규정은 문장으로.
|
||||||
|
### 4-2. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||||
|
### 4-3. Placeholder 및 보이스 신호`
|
||||||
: part === 2
|
: part === 2
|
||||||
? `
|
? `
|
||||||
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
|
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
|
||||||
@@ -199,7 +211,7 @@ ${commonHeader}
|
|||||||
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
|
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
|
||||||
|
|
||||||
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
|
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
|
||||||
: `
|
: part === 3 ? `
|
||||||
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
|
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
|
||||||
|
|
||||||
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
|
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
|
||||||
@@ -230,13 +242,70 @@ ${commonHeader}
|
|||||||
|
|
||||||
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
|
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
|
||||||
|
|
||||||
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`;
|
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`
|
||||||
|
: part === 4 ? `
|
||||||
|
## 10. 서비스 정의 & 포지셔닝 (What Are They Selling)
|
||||||
|
### 10-1. 한 줄 포지셔닝 분석
|
||||||
|
- 헤드라인(\`microcopy.headline\`)이 어떤 공식인지 분석하라 (예: "Fire your photographer" = <대체 대상> + <도발적 동사> 공식). 카피 원문을 인용할 것.
|
||||||
|
### 10-2. 타깃 사용자와 핵심 가치 제안
|
||||||
|
- CTA·서브헤드라인·폼 필드에서 역추적한 타깃 고객과, 이 서비스가 대체하는 기존 비용(사람/시간/돈).
|
||||||
|
### 10-3. 서비스 유형 분류
|
||||||
|
- 생성형 AI 도구 / SaaS / 콘텐츠 / 커머스 중 무엇이며, 핵심 엔진(이미지 생성·텍스트 생성 등)은 무엇으로 추정되는지. 근거 문구 인용.
|
||||||
|
|
||||||
|
## 11. 가격 & 수익 구조 (Pricing & Unit Economics)
|
||||||
|
### 11-1. 가격 티어 추출
|
||||||
|
- 스캔 데이터(h2List·ctaSamples·페이지 텍스트)에서 발견한 가격·크레딧·구독 문구를 **원문 그대로** 표로 정리. 가격 정보가 스캔에 없으면 "스캔 데이터 부족 — \`depth=2\` 로 pricing 페이지 포함 재스캔 권장"이라고 쓰고 이 섹션을 추정으로 채우지 마라.
|
||||||
|
### 11-2. AI 원가 추정 (표)
|
||||||
|
- 이 서비스의 핵심 생성 1회당 원가를 시나리오 표로: (a) 상용 API 사용 시 (일반적으로 알려진 단가 범위 — 반드시 "추정"으로 표기), (b) 오픈소스 모델 셀프호스팅 시 (GPU 서버 고정비 방식). 두 방식의 손익분기 조건을 "사용자 N명 × 월 생성 M회" 형태의 부등식으로 제시.
|
||||||
|
### 11-3. 마진 시뮬레이션
|
||||||
|
- 판매가 − AI 원가 − 결제 수수료(~3.3%) 산술을 표로. 스캔에서 가격을 못 찾았으면 가상의 판매가라고 명시하고 계산 구조만 보여라.
|
||||||
|
|
||||||
|
## 12. 기능 인벤토리 & 사용자 여정 (Feature Inventory)
|
||||||
|
### 12-1. 사용자 여정 지도
|
||||||
|
- 폼 필드·CTA·페이지 흐름에서 역추적: 방문 → (예: 업로드 → 스타일 선택 → 생성 → 결과 → 결제/구독) 단계별로. 각 단계의 근거 데이터를 인용.
|
||||||
|
### 12-2. 백엔드 요구 기능
|
||||||
|
- 인증 / 결제(어떤 PG 흔적이 있는지) / 생성 큐 / 스토리지 / 크레딧 차감 등 프론트 뒤에 있어야 할 기능 목록.
|
||||||
|
### 12-3. MVP 컷 (2~3일 버전)
|
||||||
|
- "빠르게 만들어 시장 반응을 본다" 원칙으로, 위 기능 중 첫 배포에 꼭 필요한 것만 남긴 최소 범위. 뺄 것을 명시하는 것이 핵심.
|
||||||
|
|
||||||
|
## 13. 운영 & 방향성 제안 (Operations & Direction)
|
||||||
|
> 이 섹션에서만 [사용자 보조 컨텍스트]를 적극 결합해 변형·확장을 제안하라 (part 3의 원본 복원 원칙과 분리된 목적).
|
||||||
|
### 13-1. 시리즈화 아이디어
|
||||||
|
- 이 서비스의 공식(포지셔닝+엔진)을 유지한 채 겉만 재포장할 수 있는 인접 서비스 3개 (예: "Fire your X" 시리즈).
|
||||||
|
### 13-2. 광고비 0원 마케팅 채널
|
||||||
|
- 이 유형의 서비스가 초기 사용자를 모으는 현실적 채널 2~3개와 각 채널에 맞는 첫 게시물 아이디어.
|
||||||
|
### 13-3. 출시 후 검증 지표
|
||||||
|
- 시장 반응을 판단할 최소 지표 (방문→업로드 전환율, 생성→결제 전환율 등)와 "접을 기준"까지.
|
||||||
|
### 13-4. 사용자 컨텍스트 결합 변형
|
||||||
|
- [사용자 보조 컨텍스트]가 있으면 그 방향으로 이 모델을 변형한 구체안 1개. 없으면 "(컨텍스트 미입력)" 한 줄만.`
|
||||||
|
: `
|
||||||
|
## 14. 단계별 빌드 프롬프트 (Step-by-Step Build Prompts)
|
||||||
|
|
||||||
|
> **미션**: 아래 스텝 프롬프트들을 바이브코딩 IDE 에 순서대로 붙여넣기만 하면 원본과 매우 유사한 서비스가 완성되어야 한다.
|
||||||
|
> - 각 스텝은 \`### STEP N: <제목>\` 헤딩 + 프롬프트 전문을 담은 코드블록 1개.
|
||||||
|
> - 프롬프트 안에 스캔에서 추출한 **실제 디자인 토큰 값**(색상 rgb/hex·폰트·radius·spacing)과 헤드라인·CTA 원문을 그대로 박아 넣어라 — "적절한 색"·"모던한 폰트" 같은 모호어 금지.
|
||||||
|
> - 각 프롬프트는 자기완결적: 이전 스텝의 산출물을 전제하되, 참조하는 값은 다시 명시한다.
|
||||||
|
> - 기술 스택은 특별한 근거가 없으면 정적 웹 + 최소 백엔드(Node/Express 또는 Next.js) 로 단순하게.
|
||||||
|
|
||||||
|
### STEP 1: 랜딩 페이지 (헤드라인·히어로·가격 섹션 — 디자인 토큰 포함)
|
||||||
|
### STEP 2: 핵심 도구 UI (업로드/입력 → 옵션 선택 → 결과 표시 골격, API 미연결 상태)
|
||||||
|
### STEP 3: 생성 API 연동 (.env 키 보호, 로딩·에러 상태, 결과 다운로드)
|
||||||
|
### STEP 4: 결제/크레딧 최소 구현 (또는 대기자 명단 폼 — MVP 컷 기준)
|
||||||
|
|
||||||
|
각 STEP 아래에 "확인 방법" 한 줄(브라우저에서 무엇이 보이면 성공인지)을 덧붙여라.
|
||||||
|
|
||||||
|
## 15. 빌드 순서 요약 (Build Order)
|
||||||
|
- STEP 1→4 를 표로: | 스텝 | 산출물 | 소요 추정 | 확인 방법 |.`;
|
||||||
|
|
||||||
const partGoal = part === 1
|
const partGoal = part === 1
|
||||||
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
? '이번 단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||||
: part === 2
|
: part === 2
|
||||||
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
? '이번 단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||||
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
|
: part === 3
|
||||||
|
? '이번 단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'
|
||||||
|
: part === 4
|
||||||
|
? '이번 단계: 이 사이트를 하나의 *사업*으로 분석한다 — 무엇을 팔고, 어떻게 돈을 벌고, 내가 비슷한 서비스를 만든다면 어떻게 운영할지. 스캔 데이터에 있는 문구·가격·CTA 를 근거로 하되, 스캔에 없는 가격·매출은 지어내지 말고 "스캔 데이터 부족"으로 표기한다.'
|
||||||
|
: '이번 단계: 앞선 분석을 바탕으로, 바이브코딩 IDE(Antigravity 등)에 순서대로 붙여넣기만 하면 이 사이트와 매우 유사한 서비스가 만들어지는 **스텝별 빌드 프롬프트**를 작성한다. 각 프롬프트는 자기완결적이어야 한다 (앞 스텝 결과를 전제하되 필요한 값은 다시 명시).';
|
||||||
|
|
||||||
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
|
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
|
||||||
${sharedRules}
|
${sharedRules}
|
||||||
|
|||||||
Binary file not shown.
@@ -18,6 +18,7 @@
|
|||||||
* 수동 명령과 완전 호환.
|
* 수동 명령과 완전 호환.
|
||||||
*/
|
*/
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { getConfig } from '../../config';
|
import { getConfig } from '../../config';
|
||||||
@@ -47,23 +48,11 @@ let _timer: NodeJS.Timeout | undefined;
|
|||||||
let _disposed = false;
|
let _disposed = false;
|
||||||
let _lastFiredYmd = '';
|
let _lastFiredYmd = '';
|
||||||
|
|
||||||
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
|
||||||
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
|
||||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday: new Date(`${ymd}T00:00:00Z`).getUTCDay() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function cycleSchedule(): { day: number; hour: number; minute: number } {
|
function cycleSchedule(): { day: number; hour: number; minute: number } {
|
||||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||||
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
|
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
|
||||||
const raw = (cfg.get<string>('growthCycle.time', '20:00') || '20:00').trim();
|
const t = parseHhMm(cfg.get<string>('growthCycle.time', '20:00'), 20, 0);
|
||||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
return { day, hour: t.hour, minute: t.minute };
|
||||||
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function msUntilNextFire(): number {
|
function msUntilNextFire(): number {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
||||||
*/
|
*/
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { getConfig } from '../../config';
|
import { getConfig } from '../../config';
|
||||||
@@ -213,19 +214,8 @@ export async function runSleepDigestOnce(): Promise<string> {
|
|||||||
|
|
||||||
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
|
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
|
||||||
|
|
||||||
function nowInKst(): { hour: number; minute: number; ymd: string } {
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).formatToParts(new Date());
|
|
||||||
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
|
||||||
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
function digestTime(): { hour: number; minute: number } {
|
function digestTime(): { hour: number; minute: number } {
|
||||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
|
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00'), 3, 0);
|
||||||
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
|
||||||
return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function msUntilNextFire(): number {
|
function msUntilNextFire(): number {
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
/**
|
||||||
|
* Claude Code CLI adapter — **구독(Pro/Max) 인증으로 Claude 를 Astra 엔진으로**.
|
||||||
|
*
|
||||||
|
* 배경 (v2.2.305): Anthropic 구독은 API 키가 아니라 Claude Code 로그인 방식이라
|
||||||
|
* `anthropic:` 프로바이더(API 키·종량제)로는 쓸 수 없다. 이 어댑터는 사용자가
|
||||||
|
* 이미 로그인해 둔 로컬 `claude` CLI 를 headless(-p) 로 실행해 추론만 위임한다 —
|
||||||
|
* 추가 과금 없이 구독 한도 안에서 동작.
|
||||||
|
*
|
||||||
|
* 통합 방식: 다른 클라우드 프로바이더와 동일하게 *OpenAI 호환 SSE* Response 를
|
||||||
|
* 반환한다 → agent.ts 의 기존 SSE 파서가 그대로 소비 (모델 드롭다운에
|
||||||
|
* "Claude 구독 · sonnet" 으로 노출, 선택만 하면 됨).
|
||||||
|
*
|
||||||
|
* 설계 결정:
|
||||||
|
* - 프롬프트는 argv 가 아니라 **stdin** 으로 전달 — Astra 의 시스템 프롬프트+
|
||||||
|
* 컨텍스트는 수십 KB 라 ARG_MAX(맥 256KB) 위험이 있다.
|
||||||
|
* - `--output-format stream-json` 을 파싱하되, 버전 간 이벤트 차이를 피하려고
|
||||||
|
* turn 단위(assistant 메시지)로 수집해 SSE 로 흘린다 (부분 델타 플래그는
|
||||||
|
* 구버전 CLI 에 없어 사용하지 않음).
|
||||||
|
* - 실패는 명확한 에러로 — CLI 미설치/미로그인 시 설치·로그인 안내를 에러
|
||||||
|
* 본문에 담는다 (조용한 폴백 금지).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { StreamParams, ChatMessage } from './types';
|
||||||
|
import { execFileCapture } from '../../lib/execUtil';
|
||||||
|
import { logInfo, logWarn } from '../../utils';
|
||||||
|
|
||||||
|
/** 드롭다운에 노출할 구독 모델 별칭 — CLI 의 --model 이 그대로 받는 값. */
|
||||||
|
export function listClaudeCodeModels(): string[] {
|
||||||
|
return ['sonnet', 'opus', 'haiku'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI 경로 탐지 (프로세스 생존 동안 캐시) ─────────────────────────────────
|
||||||
|
let _cliPath: string | null | undefined;
|
||||||
|
|
||||||
|
function candidatePaths(): string[] {
|
||||||
|
const cfg = vscode.workspace.getConfiguration('g1nation').get<string>('claude.cliPath', '')?.trim();
|
||||||
|
const home = os.homedir();
|
||||||
|
const cands = [
|
||||||
|
...(cfg ? [cfg] : []),
|
||||||
|
'claude',
|
||||||
|
'/usr/local/bin/claude',
|
||||||
|
'/opt/homebrew/bin/claude',
|
||||||
|
`${home}/.claude/local/claude`,
|
||||||
|
`${home}/.npm-global/bin/claude`,
|
||||||
|
...(process.platform === 'win32' ? ['claude.cmd', 'claude.exe'] : []),
|
||||||
|
];
|
||||||
|
return [...new Set(cands)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 로그인된 Claude Code CLI 실행 파일을 찾는다. null = 미설치 (설치 안내는 호출자). */
|
||||||
|
export async function resolveClaudeCli(): Promise<string | null> {
|
||||||
|
if (_cliPath !== undefined) return _cliPath;
|
||||||
|
for (const cand of candidatePaths()) {
|
||||||
|
// 절대 경로 후보는 존재 확인으로 빠르게 거르고, PATH 후보는 --version 실행으로 검증.
|
||||||
|
if (cand.includes('/') || cand.includes('\\')) {
|
||||||
|
try { if (!fs.existsSync(cand)) continue; } catch { continue; }
|
||||||
|
}
|
||||||
|
const r = await execFileCapture(cand, ['--version'], { timeoutMs: 8_000 });
|
||||||
|
if (r.code === 0) {
|
||||||
|
_cliPath = cand;
|
||||||
|
logInfo('[ClaudeCode] CLI 확인.', { path: cand, version: r.stdout.trim().slice(0, 40) });
|
||||||
|
return cand;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_cliPath = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테스트/설정 변경 시 캐시 초기화. */
|
||||||
|
export function _resetClaudeCliCache(): void { _cliPath = undefined; }
|
||||||
|
|
||||||
|
// ── 프롬프트 조립 (순수 — 테스트 대상) ──────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Astra 의 messages 배열을 claude -p 가 받는 단일 프롬프트로 평탄화.
|
||||||
|
* 시스템 프롬프트는 명시적 블록으로, 대화 이력은 화자 라벨로 보존한다.
|
||||||
|
*/
|
||||||
|
export function flattenClaudePrompt(messages: ChatMessage[]): string {
|
||||||
|
const sys: string[] = [];
|
||||||
|
const turns: string[] = [];
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === 'system') sys.push(m.content);
|
||||||
|
else turns.push(`${m.role === 'user' ? '[사용자]' : '[아스트라]'}\n${m.content}`);
|
||||||
|
}
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (sys.length) {
|
||||||
|
parts.push(`<system_instructions>\n${sys.join('\n\n')}\n</system_instructions>`);
|
||||||
|
parts.push('위 system_instructions 를 너의 시스템 지침으로 따르라. 도구를 사용하지 말고, 마지막 [사용자] 메시지에 대한 답변 본문만 출력하라.');
|
||||||
|
}
|
||||||
|
if (turns.length) parts.push(turns.join('\n\n'));
|
||||||
|
return parts.join('\n\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CLI 인자 (순수 — 테스트 대상). 프롬프트는 stdin 으로 가므로 여기 없음. */
|
||||||
|
export function buildClaudeCliArgs(model: string): string[] {
|
||||||
|
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
|
||||||
|
const m = (model || '').trim();
|
||||||
|
if (m) args.push('--model', m);
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** stream-json 라인들에서 답변 텍스트/에러 추출 (순수 — 테스트 대상). */
|
||||||
|
export function extractFromStreamJson(lines: string[]): { text: string; error: string } {
|
||||||
|
let text = '';
|
||||||
|
let error = '';
|
||||||
|
for (const line of lines) {
|
||||||
|
const t = line.trim();
|
||||||
|
if (!t.startsWith('{')) continue;
|
||||||
|
let j: any;
|
||||||
|
try { j = JSON.parse(t); } catch { continue; }
|
||||||
|
if (j.type === 'assistant' && Array.isArray(j.message?.content)) {
|
||||||
|
for (const block of j.message.content) {
|
||||||
|
if (block?.type === 'text' && typeof block.text === 'string') text += block.text;
|
||||||
|
}
|
||||||
|
} else if (j.type === 'result') {
|
||||||
|
if (j.is_error || (j.subtype && j.subtype !== 'success')) {
|
||||||
|
error = String(j.result || j.error || j.subtype || 'unknown error');
|
||||||
|
} else if (!text && typeof j.result === 'string') {
|
||||||
|
text = j.result; // 일부 버전은 assistant 이벤트 없이 result 에만 본문
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { text, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 실행기 ──────────────────────────────────────────────────────────────────
|
||||||
|
const LOGIN_HINT = 'Claude Code CLI 확인이 필요합니다 — 터미널에서 `claude` 실행 후 구독 계정으로 로그인하세요. 미설치라면 `npm install -g @anthropic-ai/claude-code`. (경로가 특이하면 설정 g1nation.claude.cliPath 지정)';
|
||||||
|
|
||||||
|
export interface ClaudeCodeResult { text: string }
|
||||||
|
|
||||||
|
/** claude -p 1회 실행 — 성공 시 답변 텍스트, 실패 시 throw(안내 포함 메시지). */
|
||||||
|
export async function runClaudeCode(params: {
|
||||||
|
messages: ChatMessage[];
|
||||||
|
model: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
timeoutMs?: number;
|
||||||
|
}): Promise<ClaudeCodeResult> {
|
||||||
|
const cli = await resolveClaudeCli();
|
||||||
|
if (!cli) throw new Error(LOGIN_HINT);
|
||||||
|
|
||||||
|
const prompt = flattenClaudePrompt(params.messages);
|
||||||
|
const args = buildClaudeCliArgs(params.model);
|
||||||
|
const cwd = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file')?.uri.fsPath || os.homedir();
|
||||||
|
const timeoutMs = params.timeoutMs ?? 300_000; // 클라우드 대형 모델 — 여유 있게
|
||||||
|
|
||||||
|
return new Promise<ClaudeCodeResult>((resolve, reject) => {
|
||||||
|
const child = spawn(cli, args, { cwd, windowsHide: true });
|
||||||
|
let out = '';
|
||||||
|
let err = '';
|
||||||
|
let settled = false;
|
||||||
|
const finish = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timer); fn(); } };
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
try { child.kill(); } catch { /* already dead */ }
|
||||||
|
finish(() => reject(new Error(`Claude 응답이 ${Math.round(timeoutMs / 1000)}초 안에 오지 않아 중단했습니다.`)));
|
||||||
|
}, timeoutMs);
|
||||||
|
if (params.signal) {
|
||||||
|
const onAbort = () => { try { child.kill(); } catch { /* noop */ } finish(() => reject(new Error('AbortError'))); };
|
||||||
|
if (params.signal.aborted) onAbort();
|
||||||
|
else params.signal.addEventListener('abort', onAbort, { once: true });
|
||||||
|
}
|
||||||
|
child.stdout.on('data', (d) => { out += String(d); });
|
||||||
|
child.stderr.on('data', (d) => { err += String(d); });
|
||||||
|
child.on('error', (e) => finish(() => reject(new Error(`Claude CLI 실행 실패(${e.message}). ${LOGIN_HINT}`))));
|
||||||
|
child.on('close', (code) => finish(() => {
|
||||||
|
const { text, error } = extractFromStreamJson(out.split('\n'));
|
||||||
|
if (text.trim()) return resolve({ text: text.trim() });
|
||||||
|
const detail = (error || err || `exit ${code}`).trim().split('\n').slice(-4).join(' ').slice(0, 400);
|
||||||
|
logWarn('[ClaudeCode] 빈 응답/실패.', { code, detail });
|
||||||
|
reject(new Error(`Claude 응답 실패: ${detail}\n${LOGIN_HINT}`));
|
||||||
|
}));
|
||||||
|
child.stdin.write(prompt);
|
||||||
|
child.stdin.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 스트리밍 어댑터 (OpenAI 호환 SSE Response) ─────────────────────────────
|
||||||
|
export async function streamClaudeCode(
|
||||||
|
_context: vscode.ExtensionContext,
|
||||||
|
params: StreamParams,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { text } = await runClaudeCode({
|
||||||
|
messages: params.messages,
|
||||||
|
model: params.model,
|
||||||
|
signal: params.signal,
|
||||||
|
});
|
||||||
|
const sse = [
|
||||||
|
`data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`,
|
||||||
|
'',
|
||||||
|
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}`,
|
||||||
|
'',
|
||||||
|
'data: [DONE]',
|
||||||
|
'', '',
|
||||||
|
].join('\n');
|
||||||
|
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.message === 'AbortError') throw e;
|
||||||
|
return new Response(JSON.stringify({ error: { message: e?.message || String(e) } }), {
|
||||||
|
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { ProviderId, StreamParams, ChatMessage, parseModelPrefix, makeModelId, p
|
|||||||
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
|
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
|
||||||
import { streamAnthropic, listAnthropicModels } from './anthropic';
|
import { streamAnthropic, listAnthropicModels } from './anthropic';
|
||||||
import { streamGemini, listGeminiModels } from './gemini';
|
import { streamGemini, listGeminiModels } from './gemini';
|
||||||
|
import { streamClaudeCode, listClaudeCodeModels, resolveClaudeCli } from './claudeCode';
|
||||||
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
||||||
|
|
||||||
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
||||||
@@ -31,6 +32,7 @@ export async function streamCloudCompletion(
|
|||||||
case 'openrouter': return streamOpenRouter(context, fullParams);
|
case 'openrouter': return streamOpenRouter(context, fullParams);
|
||||||
case 'anthropic': return streamAnthropic(context, fullParams);
|
case 'anthropic': return streamAnthropic(context, fullParams);
|
||||||
case 'gemini': return streamGemini(context, fullParams);
|
case 'gemini': return streamGemini(context, fullParams);
|
||||||
|
case 'claude-code': return streamClaudeCode(context, fullParams);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,13 @@ export async function listAllCloudModels(context: vscode.ExtensionContext): Prom
|
|||||||
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
|
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
// Claude 구독 — API 키 불필요: 로그인된 Claude Code CLI 가 있으면 곧 활성.
|
||||||
|
tasks.push(resolveClaudeCli().then((cli) => {
|
||||||
|
if (!cli) return;
|
||||||
|
for (const id of listClaudeCodeModels()) {
|
||||||
|
out.push({ id: makeModelId('claude-code', id), provider: 'claude-code', label: `Claude 구독 · ${id}` });
|
||||||
|
}
|
||||||
|
}).catch(() => { /* 탐지 실패 = 미노출 */ }));
|
||||||
await Promise.all(tasks);
|
await Promise.all(tasks);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
|
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini' | 'claude-code';
|
||||||
|
|
||||||
export interface ChatMessage {
|
export interface ChatMessage {
|
||||||
role: 'system' | 'user' | 'assistant';
|
role: 'system' | 'user' | 'assistant';
|
||||||
@@ -32,6 +32,8 @@ const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
|
|||||||
{ prefix: 'openrouter:', id: 'openrouter' },
|
{ prefix: 'openrouter:', id: 'openrouter' },
|
||||||
{ prefix: 'anthropic:', id: 'anthropic' },
|
{ prefix: 'anthropic:', id: 'anthropic' },
|
||||||
{ prefix: 'gemini:', id: 'gemini' },
|
{ prefix: 'gemini:', id: 'gemini' },
|
||||||
|
// Claude 구독(Pro/Max) — API 키가 아니라 로컬 Claude Code CLI 로그인에 위임 (v2.2.305).
|
||||||
|
{ prefix: 'claude:', id: 'claude-code' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,14 +50,18 @@ export function parseModelPrefix(modelId: string): { provider: ProviderId; model
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용. */
|
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용.
|
||||||
|
* 주의: prefix 는 PROVIDER_PREFIXES 가 단일 권위 — provider id 와 다를 수 있다
|
||||||
|
* (claude-code 의 prefix 는 사용자 친화적으로 'claude:'). */
|
||||||
export function makeModelId(provider: ProviderId, model: string): string {
|
export function makeModelId(provider: ProviderId, model: string): string {
|
||||||
return `${provider}:${model}`;
|
const hit = PROVIDER_PREFIXES.find(p => p.id === provider);
|
||||||
|
return `${hit ? hit.prefix : provider + ':'}${model}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** UI 표시용 짧은 라벨. */
|
/** UI 표시용 짧은 라벨. */
|
||||||
export function providerLabel(p: ProviderId): string {
|
export function providerLabel(p: ProviderId): string {
|
||||||
switch (p) {
|
switch (p) {
|
||||||
|
case 'claude-code': return 'Claude 구독';
|
||||||
case 'openrouter': return 'OpenRouter';
|
case 'openrouter': return 'OpenRouter';
|
||||||
case 'anthropic': return 'Anthropic';
|
case 'anthropic': return 'Anthropic';
|
||||||
case 'gemini': return 'Gemini';
|
case 'gemini': return 'Gemini';
|
||||||
|
|||||||
@@ -21,7 +21,8 @@
|
|||||||
* - 워크스페이스 외부 경로 무시
|
* - 워크스페이스 외부 경로 무시
|
||||||
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
|
||||||
*/
|
*/
|
||||||
import { spawn, spawnSync } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
|
import { detectPythonSync, PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import { logError, logInfo } from '../../utils';
|
import { logError, logInfo } from '../../utils';
|
||||||
@@ -86,23 +87,10 @@ function _firstNonEmptyLine(s: string): string {
|
|||||||
* Returns the platform-preferred default when neither is found so the caller
|
* Returns the platform-preferred default when neither is found so the caller
|
||||||
* still produces the usual "미설치" warning via the spawn-failure path.
|
* still produces the usual "미설치" warning via the spawn-failure path.
|
||||||
*/
|
*/
|
||||||
let _pythonCmdCache: string | undefined;
|
// [코어 수렴] Python 탐지는 lib/execUtil 의 단일 구현·공유 캐시를 쓴다.
|
||||||
|
// 미설치 시에도 플랫폼 기본 후보를 돌려줘 spawn-실패 경로가 기존처럼 "미설치" 경고를 만든다.
|
||||||
function _resolvePythonCmd(): string {
|
function _resolvePythonCmd(): string {
|
||||||
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
|
return detectPythonSync() ?? PYTHON_CANDIDATES[0];
|
||||||
const candidates = process.platform === 'win32'
|
|
||||||
? ['python', 'python3']
|
|
||||||
: ['python3', 'python'];
|
|
||||||
for (const cmd of candidates) {
|
|
||||||
try {
|
|
||||||
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
|
||||||
if (!r.error && (r.status === 0 || r.status === null)) {
|
|
||||||
_pythonCmdCache = cmd;
|
|
||||||
return cmd;
|
|
||||||
}
|
|
||||||
} catch { /* try next candidate */ }
|
|
||||||
}
|
|
||||||
_pythonCmdCache = candidates[0];
|
|
||||||
return _pythonCmdCache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
|
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
|
||||||
*/
|
*/
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { logInfo, logError } from '../../utils';
|
import { logInfo, logError } from '../../utils';
|
||||||
|
|
||||||
@@ -44,9 +45,9 @@ export async function probePythonEnv(): Promise<PythonProbe> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
|
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
|
||||||
const candidates = process.platform === 'win32'
|
// [코어 수렴] 후보 순서는 lib/execUtil 과 공유 (이 probe 는 Python3 버전·패키지 검증까지
|
||||||
? ['python', 'py', 'python3']
|
// 수행하는 특수 경로라 탐지 로직 자체는 유지한다).
|
||||||
: ['python3', 'python'];
|
const candidates = PYTHON_CANDIDATES;
|
||||||
|
|
||||||
for (const cmd of candidates) {
|
for (const cmd of candidates) {
|
||||||
const ver = await _capture(cmd, ['--version'], 5_000);
|
const ver = await _capture(cmd, ['--version'], 5_000);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export {
|
|||||||
readSheetRange,
|
readSheetRange,
|
||||||
writeSheetRange,
|
writeSheetRange,
|
||||||
appendSheetRows,
|
appendSheetRows,
|
||||||
|
clearSheetRange,
|
||||||
|
ensureSheetTab,
|
||||||
parseTsvBody,
|
parseTsvBody,
|
||||||
valuesToMarkdownTable,
|
valuesToMarkdownTable,
|
||||||
SheetCell,
|
SheetCell,
|
||||||
|
|||||||
@@ -127,6 +127,75 @@ export async function appendSheetRows(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* range 의 값 지우기 — 셀 서식은 유지, 값만 제거.
|
||||||
|
* 백업 탭 덮어쓰기 전 잔여 행 제거용 (목록이 줄었을 때 아래 행이 남지 않게).
|
||||||
|
*/
|
||||||
|
export async function clearSheetRange(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
spreadsheetId: string,
|
||||||
|
range: string,
|
||||||
|
): Promise<{ ok: true } | ApiFailure> {
|
||||||
|
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||||
|
if (!range.trim()) return { ok: false, error: 'range 비어있음' };
|
||||||
|
const tok = await getFreshAccessToken(context);
|
||||||
|
if (!tok.ok) return { ok: false, error: tok.error };
|
||||||
|
const url = `${API_BASE}/${encodeURIComponent(spreadsheetId)}/values/${encodeURIComponent(range)}:clear`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const json: any = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) return { ok: false, error: json?.error?.message || `HTTP ${res.status}` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* title 이름의 탭이 spreadsheet 에 없으면 batchUpdate(addSheet) 로 생성.
|
||||||
|
* 이미 있으면 no-op. 백업 탭처럼 사용자가 미리 만들어 두지 않는 탭에 쓰기 전 호출.
|
||||||
|
*/
|
||||||
|
export async function ensureSheetTab(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
spreadsheetId: string,
|
||||||
|
title: string,
|
||||||
|
): Promise<{ ok: true; created: boolean } | ApiFailure> {
|
||||||
|
if (!spreadsheetId.trim()) return { ok: false, error: 'spreadsheet_id 비어있음' };
|
||||||
|
if (!title.trim()) return { ok: false, error: 'sheet title 비어있음' };
|
||||||
|
const tok = await getFreshAccessToken(context);
|
||||||
|
if (!tok.ok) return { ok: false, error: tok.error };
|
||||||
|
try {
|
||||||
|
const metaUrl = `${API_BASE}/${encodeURIComponent(spreadsheetId)}?fields=sheets.properties.title`;
|
||||||
|
const metaRes = await fetch(metaUrl, {
|
||||||
|
headers: { Authorization: `Bearer ${tok.accessToken}` },
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const meta: any = await metaRes.json().catch(() => ({}));
|
||||||
|
if (!metaRes.ok) return { ok: false, error: meta?.error?.message || `HTTP ${metaRes.status}` };
|
||||||
|
const titles: string[] = (meta.sheets ?? []).map((s: any) => s?.properties?.title).filter(Boolean);
|
||||||
|
if (titles.includes(title)) return { ok: true, created: false };
|
||||||
|
|
||||||
|
const updRes = await fetch(`${API_BASE}/${encodeURIComponent(spreadsheetId)}:batchUpdate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${tok.accessToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ requests: [{ addSheet: { properties: { title } } }] }),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
const upd: any = await updRes.json().catch(() => ({}));
|
||||||
|
if (!updRes.ok) return { ok: false, error: upd?.error?.message || `HTTP ${updRes.status}` };
|
||||||
|
return { ok: true, created: true };
|
||||||
|
} catch (e: any) {
|
||||||
|
return { ok: false, error: e?.message ?? String(e) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
// ────────────── 파싱 헬퍼 — action tag 본문 TSV 해석 ──────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import * as vscode from 'vscode';
|
||||||
|
import { clearSheetRange, ensureSheetTab, readSheetRange, writeSheetRange, type SheetValues } from '../sheets';
|
||||||
|
import { logError, logInfo } from '../../utils';
|
||||||
|
import { onStocksStoreChanged, readStocksStore, writeStocksStore } from './stocksStore';
|
||||||
|
import type { Stock } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* stocks.json 원본 클라우드 백업 — Google Sheets `_backup` 탭 (v2.2.297).
|
||||||
|
*
|
||||||
|
* 문제: 목록의 source of truth 가 로컬 `.astra/stocks.json` 뿐이라 컴퓨터를 바꾸면
|
||||||
|
* 데이터가 사라진다. Sheet1~3 sync 는 분류 *표시용* 이라 복원 불가.
|
||||||
|
*
|
||||||
|
* 해결: 같은 spreadsheet(`g1nation.stocks.spreadsheetId`)에 `_backup` 탭을 자동
|
||||||
|
* 생성하고, 종목당 한 행으로 **원본 JSON 그대로** 백업. 새 컴퓨터에서는
|
||||||
|
* `/stocks restore` 한 번으로 복원.
|
||||||
|
*
|
||||||
|
* 백업 시점: writeStocksStore 성공 → onStocksStoreChanged → 디바운스 30초 후 1회.
|
||||||
|
* (add/remove/check/discover 자동편입/워처 가격갱신 전부 이 경로를 지남.)
|
||||||
|
*
|
||||||
|
* 레이아웃:
|
||||||
|
* A1: BACKUP_MARKER B1: ISO timestamp C1: 종목 수
|
||||||
|
* A2~: 심볼 B2~: 이름 C2~: JSON.stringify(stock)
|
||||||
|
* 심볼/이름 컬럼은 사람이 시트에서 훑어보기용 — 복원은 C열 JSON 만 사용.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BACKUP_TAB = '_backup';
|
||||||
|
const BACKUP_MARKER = 'ASTRA_STOCKS_BACKUP_V1';
|
||||||
|
const DEBOUNCE_MS = 30_000;
|
||||||
|
|
||||||
|
function getSpreadsheetId(): string {
|
||||||
|
return (vscode.workspace.getConfiguration('g1nation').get<string>('stocks.spreadsheetId') || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoBackupEnabled(): boolean {
|
||||||
|
return vscode.workspace.getConfiguration('g1nation').get<boolean>('stocks.autoBackup', true) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 지금 즉시 백업 1회. spreadsheetId 미설정이면 skip (ok:false + reason). */
|
||||||
|
export async function backupToSheet(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; count?: number }> {
|
||||||
|
const spreadsheetId = getSpreadsheetId();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
||||||
|
}
|
||||||
|
const store = readStocksStore();
|
||||||
|
|
||||||
|
const ensured = await ensureSheetTab(context, spreadsheetId, BACKUP_TAB);
|
||||||
|
if (!ensured.ok) return { ok: false, reason: `백업 탭 확인 실패: ${ensured.error}` };
|
||||||
|
if (ensured.created) logInfo(`Stocks backup: '${BACKUP_TAB}' 탭 생성.`);
|
||||||
|
|
||||||
|
// 목록이 줄었을 때 잔여 행이 남지 않게 먼저 클리어. 클리어 실패는 치명적이지
|
||||||
|
// 않으므로 (덮어쓰기는 어차피 진행) 로그만 남기고 계속.
|
||||||
|
const cleared = await clearSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A:C`);
|
||||||
|
if (!cleared.ok) logError('Stocks backup: 기존 행 클리어 실패 — 덮어쓰기로 계속.', { error: cleared.error });
|
||||||
|
|
||||||
|
const rows: SheetValues = [[BACKUP_MARKER, new Date().toISOString(), store.length]];
|
||||||
|
for (const s of store) {
|
||||||
|
rows.push([s.심볼, s.이름, JSON.stringify(s)]);
|
||||||
|
}
|
||||||
|
const wrote = await writeSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1`, rows);
|
||||||
|
if (!wrote.ok) return { ok: false, reason: `백업 쓰기 실패: ${wrote.error}` };
|
||||||
|
|
||||||
|
logInfo('Stocks backup: 완료.', { count: store.length });
|
||||||
|
return { ok: true, count: store.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `_backup` 탭에서 목록 복원 — 로컬 stocks.json 을 백업 내용으로 **교체**.
|
||||||
|
* silent write — 복원 직후 같은 내용을 되백업하는 루프 방지.
|
||||||
|
*/
|
||||||
|
export async function restoreFromSheet(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
): Promise<{ ok: boolean; reason?: string; count?: number; backedUpAt?: string; previousCount?: number }> {
|
||||||
|
const spreadsheetId = getSpreadsheetId();
|
||||||
|
if (!spreadsheetId) {
|
||||||
|
return { ok: false, reason: 'g1nation.stocks.spreadsheetId 미설정 — Settings 에서 spreadsheet ID 를 넣어주세요.' };
|
||||||
|
}
|
||||||
|
const read = await readSheetRange(context, spreadsheetId, `${BACKUP_TAB}!A1:C`);
|
||||||
|
if (!read.ok) return { ok: false, reason: `백업 탭 읽기 실패: ${read.error} — 아직 백업한 적이 없다면 /stocks backup 먼저.` };
|
||||||
|
|
||||||
|
const values = read.values;
|
||||||
|
const header = values[0] ?? [];
|
||||||
|
if (String(header[0] ?? '') !== BACKUP_MARKER) {
|
||||||
|
return { ok: false, reason: `'${BACKUP_TAB}' 탭이 백업 형식이 아닙니다 (헤더 마커 불일치).` };
|
||||||
|
}
|
||||||
|
const backedUpAt = String(header[1] ?? '');
|
||||||
|
|
||||||
|
const restored: Stock[] = [];
|
||||||
|
for (const row of values.slice(1)) {
|
||||||
|
const raw = String(row[2] ?? '').trim();
|
||||||
|
if (!raw) continue;
|
||||||
|
try {
|
||||||
|
const s = JSON.parse(raw);
|
||||||
|
if (s && typeof s === 'object' && typeof s.심볼 === 'string' && typeof s.이름 === 'string') {
|
||||||
|
restored.push(s as Stock);
|
||||||
|
}
|
||||||
|
} catch { /* 손상 행은 skip — 나머지는 살린다 */ }
|
||||||
|
}
|
||||||
|
if (restored.length === 0) {
|
||||||
|
return { ok: false, reason: '백업에 유효한 종목이 없습니다 — 로컬 목록을 건드리지 않았습니다.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousCount = readStocksStore().length;
|
||||||
|
if (!writeStocksStore(restored, { silent: true })) {
|
||||||
|
return { ok: false, reason: 'stocks.json 쓰기 실패 (워크스페이스 확인).' };
|
||||||
|
}
|
||||||
|
logInfo('Stocks restore: 완료.', { count: restored.length, backedUpAt, previousCount });
|
||||||
|
return { ok: true, count: restored.length, backedUpAt, previousCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 자동 백업 배선 — extension activate 에서 1회 호출.
|
||||||
|
* store 변경 → 30초 디바운스 → backupToSheet. spreadsheetId 미설정/설정 OFF 면 조용히 skip.
|
||||||
|
*/
|
||||||
|
export function registerStocksAutoBackup(context: vscode.ExtensionContext): vscode.Disposable {
|
||||||
|
let timer: NodeJS.Timeout | undefined;
|
||||||
|
const sub = onStocksStoreChanged(() => {
|
||||||
|
if (!autoBackupEnabled() || !getSpreadsheetId()) return;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(async () => {
|
||||||
|
timer = undefined;
|
||||||
|
const r = await backupToSheet(context);
|
||||||
|
if (!r.ok) logError('Stocks 자동 백업 실패.', { reason: r.reason });
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
dispose: () => {
|
||||||
|
sub.dispose();
|
||||||
|
if (timer) { clearTimeout(timer); timer = undefined; }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
|
export { startStocksWatcher, runOnceNow } from './stocksWatcher';
|
||||||
export { handleStocksCommand } from './slashStocks';
|
export { handleStocksCommand } from './slashStocks';
|
||||||
|
export { registerStocksAutoBackup } from './backupSync';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AIService } from '../../core/services';
|
|||||||
import { classifyAll } from './signalClassifier';
|
import { classifyAll } from './signalClassifier';
|
||||||
import { writeStocksStore } from './stocksStore';
|
import { writeStocksStore } from './stocksStore';
|
||||||
import { syncToSheets } from './sheetsSync';
|
import { syncToSheets } from './sheetsSync';
|
||||||
|
import { backupToSheet, restoreFromSheet } from './backupSync';
|
||||||
import { judgeStock } from './llmJudge';
|
import { judgeStock } from './llmJudge';
|
||||||
import { sendStocksReport, buildReportText } from './telegramReport';
|
import { sendStocksReport, buildReportText } from './telegramReport';
|
||||||
import { runOnceNow } from './stocksWatcher';
|
import { runOnceNow } from './stocksWatcher';
|
||||||
@@ -27,6 +28,8 @@ import type { ClassifiedStock, Stock } from './types';
|
|||||||
* /stocks check — 현재가 갱신 (Yahoo)
|
* /stocks check — 현재가 갱신 (Yahoo)
|
||||||
* /stocks signal — 매수사정권 종목만
|
* /stocks signal — 매수사정권 종목만
|
||||||
* /stocks sync — Google Sheets 동기화
|
* /stocks sync — Google Sheets 동기화
|
||||||
|
* /stocks backup — 원본 목록을 Sheets `_backup` 탭에 백업
|
||||||
|
* /stocks restore — `_backup` 탭에서 목록 복원 (기기 이전용)
|
||||||
* /stocks add <심볼> <이름> [투자성향]
|
* /stocks add <심볼> <이름> [투자성향]
|
||||||
* /stocks remove <심볼>
|
* /stocks remove <심볼>
|
||||||
* /stocks judge <심볼> — LLM 4-criteria 평가
|
* /stocks judge <심볼> — LLM 4-criteria 평가
|
||||||
@@ -116,6 +119,25 @@ async function cmdSync(view: Webview | undefined, context: vscode.ExtensionConte
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cmdBackup(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
chunk(view, '\n☁️ Google Sheets `_backup` 탭으로 백업 중...\n');
|
||||||
|
const r = await backupToSheet(context);
|
||||||
|
chunk(view, r.ok
|
||||||
|
? `\n✅ 백업 완료: ${r.count}개 종목. 새 컴퓨터에서는 \`/stocks restore\` 로 복원하세요.\n`
|
||||||
|
: `\n❌ 백업 실패: ${r.reason}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cmdRestore(view: Webview | undefined, context: vscode.ExtensionContext): Promise<void> {
|
||||||
|
chunk(view, '\n☁️ Google Sheets `_backup` 탭에서 복원 중...\n');
|
||||||
|
const r = await restoreFromSheet(context);
|
||||||
|
if (!r.ok) {
|
||||||
|
chunk(view, `\n❌ 복원 실패: ${r.reason}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const when = r.backedUpAt ? ` (백업 시각: ${r.backedUpAt})` : '';
|
||||||
|
chunk(view, `\n✅ 복원 완료: ${r.count}개 종목${when} — 기존 로컬 ${r.previousCount}개를 교체했습니다.\n`);
|
||||||
|
}
|
||||||
|
|
||||||
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
async function cmdAdd(arg: string, view: Webview | undefined): Promise<void> {
|
||||||
const parts = arg.split(/\s+/);
|
const parts = arg.split(/\s+/);
|
||||||
if (parts.length < 2) {
|
if (parts.length < 2) {
|
||||||
@@ -645,6 +667,8 @@ function cmdHelp(view: Webview | undefined): void {
|
|||||||
' `/stocks check` — 현재가 갱신',
|
' `/stocks check` — 현재가 갱신',
|
||||||
' `/stocks signal` — 매수사정권 종목만',
|
' `/stocks signal` — 매수사정권 종목만',
|
||||||
' `/stocks sync` — Google Sheets 동기화',
|
' `/stocks sync` — Google Sheets 동기화',
|
||||||
|
' `/stocks backup` — 목록 원본을 Sheets `_backup` 탭에 백업 (변경 시 자동 백업도 됨)',
|
||||||
|
' `/stocks restore` — `_backup` 탭에서 목록 복원 (새 컴퓨터 이전용)',
|
||||||
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
|
' `/stocks add <심볼> <이름>` — 종목 추가 (예: `add 005930 삼성전자` / `add AAPL Apple`)',
|
||||||
' `/stocks remove <심볼>` — 종목 제거',
|
' `/stocks remove <심볼>` — 종목 제거',
|
||||||
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
' `/stocks judge <심볼>` — LLM 4-criteria 평가 (stocks.json 등록 종목)',
|
||||||
@@ -683,6 +707,12 @@ export async function handleStocksCommand(
|
|||||||
case 'sync':
|
case 'sync':
|
||||||
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (sync 불가).\n'); return true; }
|
||||||
await cmdSync(view, context); return true;
|
await cmdSync(view, context); return true;
|
||||||
|
case 'backup':
|
||||||
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (backup 불가).\n'); return true; }
|
||||||
|
await cmdBackup(view, context); return true;
|
||||||
|
case 'restore':
|
||||||
|
if (!context) { chunk(view, '\n❌ ExtensionContext 없음 (restore 불가).\n'); return true; }
|
||||||
|
await cmdRestore(view, context); return true;
|
||||||
case 'add': await cmdAdd(rest, view); return true;
|
case 'add': await cmdAdd(rest, view); return true;
|
||||||
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
case 'remove': case 'rm': await cmdRemove(rest, view); return true;
|
||||||
case 'judge': await cmdJudge(rest, view); return true;
|
case 'judge': await cmdJudge(rest, view); return true;
|
||||||
|
|||||||
@@ -17,6 +17,28 @@ import type { Stock, StocksStore } from './types';
|
|||||||
|
|
||||||
const STORE_REL_PATH = '.astra/stocks.json';
|
const STORE_REL_PATH = '.astra/stocks.json';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 성공 시 알림 리스너 (v2.2.297) — 모든 변경 경로(add/remove/update/check/
|
||||||
|
* discover 자동편입/워처 가격갱신)가 writeStocksStore 한 곳을 지나므로, 여기서
|
||||||
|
* fire 하면 Sheets 자동 백업이 변경을 빠짐없이 따라간다.
|
||||||
|
* 리스너 예외는 삼킨다 — 백업 실패가 저장 자체를 깨면 안 됨.
|
||||||
|
*/
|
||||||
|
type StoreChangeListener = () => void;
|
||||||
|
const changeListeners = new Set<StoreChangeListener>();
|
||||||
|
|
||||||
|
export function onStocksStoreChanged(listener: StoreChangeListener): { dispose(): void } {
|
||||||
|
changeListeners.add(listener);
|
||||||
|
return { dispose: () => { changeListeners.delete(listener); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fireStoreChanged(): void {
|
||||||
|
for (const l of changeListeners) {
|
||||||
|
try { l(); } catch (e: any) {
|
||||||
|
logError('stocks store 변경 리스너 예외.', { error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function getStocksFilePath(): string | null {
|
export function getStocksFilePath(): string | null {
|
||||||
const folders = vscode.workspace.workspaceFolders;
|
const folders = vscode.workspace.workspaceFolders;
|
||||||
if (!folders || folders.length === 0) return null;
|
if (!folders || folders.length === 0) return null;
|
||||||
@@ -41,8 +63,12 @@ export function readStocksStore(): StocksStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내). */
|
/**
|
||||||
export function writeStocksStore(store: StocksStore): boolean {
|
* Atomic write — tmp + rename. 워크스페이스 없으면 false 반환 (caller 가 안내).
|
||||||
|
* opts.silent: 변경 리스너 미발화 — restore 처럼 백업에서 방금 읽어온 내용을
|
||||||
|
* 다시 백업하는 루프를 끊을 때만 사용.
|
||||||
|
*/
|
||||||
|
export function writeStocksStore(store: StocksStore, opts?: { silent?: boolean }): boolean {
|
||||||
const filePath = getStocksFilePath();
|
const filePath = getStocksFilePath();
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
logError('워크스페이스 폴더가 없어 stocks.json 쓰기 불가.');
|
||||||
@@ -54,6 +80,7 @@ export function writeStocksStore(store: StocksStore): boolean {
|
|||||||
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||||
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
fs.writeFileSync(tmp, JSON.stringify(store, null, 2), 'utf-8');
|
||||||
fs.renameSync(tmp, filePath);
|
fs.renameSync(tmp, filePath);
|
||||||
|
if (!opts?.silent) fireStoreChanged();
|
||||||
return true;
|
return true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
logError('stocks.json 쓰기 실패.', { filePath, error: e?.message ?? String(e) });
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { nowInKst } from '../../lib/kstSchedule';
|
||||||
import { logError, logInfo } from '../../utils';
|
import { logError, logInfo } from '../../utils';
|
||||||
import { readStocksStore, writeStocksStore } from './stocksStore';
|
import { readStocksStore, writeStocksStore } from './stocksStore';
|
||||||
import { fetchAllPrices } from './yahooClient';
|
import { fetchAllPrices } from './yahooClient';
|
||||||
@@ -32,23 +33,6 @@ function scheduleTimesKst(): { hour: number; minute: number }[] {
|
|||||||
let _timer: NodeJS.Timeout | undefined;
|
let _timer: NodeJS.Timeout | undefined;
|
||||||
let _disposed = false;
|
let _disposed = false;
|
||||||
|
|
||||||
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
|
|
||||||
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
|
|
||||||
const now = new Date();
|
|
||||||
const parts = new Intl.DateTimeFormat('en-US', {
|
|
||||||
timeZone: 'Asia/Seoul',
|
|
||||||
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
||||||
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
||||||
}).formatToParts(now);
|
|
||||||
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
|
|
||||||
return {
|
|
||||||
date: now,
|
|
||||||
hour: get('hour'),
|
|
||||||
minute: get('minute'),
|
|
||||||
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지금부터 다음 firing 까지 milliseconds 계산.
|
* 지금부터 다음 firing 까지 milliseconds 계산.
|
||||||
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
||||||
|
|||||||
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
|
|||||||
wrongAnswer: string;
|
wrongAnswer: string;
|
||||||
correction: string;
|
correction: string;
|
||||||
llm: { baseUrl: string; model: string };
|
llm: { baseUrl: string; model: string };
|
||||||
|
/** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
|
||||||
|
behaviorHint?: boolean;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
|
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
|
||||||
|
|
||||||
|
// [④ Standing Rules] 행동/스타일 교정은 명령형 규칙으로 정규화해 즉시 영속화 —
|
||||||
|
// 다음 턴부터(그리고 새 세션에서도) 매 턴 주입된다. 태그가 형식오류/지시불이행
|
||||||
|
// 이거나 감지 단계에서 행동 지적 패턴(behaviorHint)에 걸린 경우만.
|
||||||
|
if (opts.behaviorHint || tag === '형식오류' || tag === '지시불이행') {
|
||||||
|
try {
|
||||||
|
const rule = await deriveStandingRule(opts.wrongAnswer, opts.correction, opts.llm);
|
||||||
|
if (rule) upsertStandingRule(opts.brainPath, rule, opts.correction);
|
||||||
|
} catch { /* 규칙 파생 실패가 레슨 캡처를 막지 않음 */ }
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const c: CorrectionCase = {
|
const c: CorrectionCase = {
|
||||||
ts: now.toISOString(),
|
ts: now.toISOString(),
|
||||||
@@ -380,3 +393,165 @@ export function formatRegressionReport(results: RegressionResult[], meta: { date
|
|||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ④ Standing Rules — 세션을 넘는 상시 행동 규칙 ────────────────────────────
|
||||||
|
//
|
||||||
|
// 배경: 기존 파이프라인(①~③)은 *사실 정정*을 영속화하지만, "결론 수정 문구 쓰지 마"
|
||||||
|
// 같은 *행동/스타일 교정*은 (a) 감지 패턴에 안 걸리고 (b) 레슨은 유사도 검색이라
|
||||||
|
// 질문 내용과 무관한 상시 규칙이 주입되지 않아 새 세션에서 증발했다 (v2.2.308).
|
||||||
|
//
|
||||||
|
// 해법: 행동 교정을 명령형 1줄 규칙으로 정규화해 파일에 영속화하고, 검색과 무관하게
|
||||||
|
// 매 턴 behavior-constraints 보호 구역에 주입한다. 상한(10개)과 재지적 카운트로
|
||||||
|
// 프롬프트 오염을 막고, 파일은 사람이 열어 수정/삭제 가능 (Permission Based Learning).
|
||||||
|
|
||||||
|
export const STANDING_RULES_REL_PATH = path.join('.astra', 'growth', 'standing-rules.json');
|
||||||
|
export const MAX_ACTIVE_STANDING_RULES = 10;
|
||||||
|
|
||||||
|
export interface StandingRule {
|
||||||
|
id: string; // 정규화된 규칙의 해시
|
||||||
|
rule: string; // 명령형 1줄 (모든 답변에 적용)
|
||||||
|
origin: string; // 규칙을 만든 원 발화 (사용자 지적)
|
||||||
|
createdAt: string; // ISO
|
||||||
|
lastHitAt: string; // 마지막 재지적 ISO
|
||||||
|
hits: number; // 지적 누계 (1 = 최초) — 클수록 은퇴 대상에서 멂
|
||||||
|
status: 'active' | 'retired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행동/스타일 지적 감지 — 사실 정정(looksLikeCorrection)과 별개의 그물.
|
||||||
|
// "또 ~하네", "하지 말라고 했잖아", "몇 번을 말해" 같은 반복 지적이 핵심 신호다.
|
||||||
|
const BEHAVIOR_COMPLAINT_RE = [
|
||||||
|
/^\s*또\s+.{1,80}(하네|하잖|이네|했네|라고\s*(썼|하)|나왔|그러)/, // "또 결론 수정이라고 썼네"
|
||||||
|
/(하지\s*말라(고|니까)|말라고\s*했(잖|는데)|말랬잖)/, // "하지 말라고 했잖아"
|
||||||
|
/(몇\s*번(을|이나)?\s*(말|얘기|이야기)|매번\s*똑같)/, // "몇 번을 말해"
|
||||||
|
/(왜\s*자꾸|자꾸\s*왜|계속\s*(그러|반복)|또\s*반복)/, // "왜 자꾸 그래"
|
||||||
|
/(붙이지\s*마|쓰지\s*마|넣지\s*마|빼\s*달라고|빼라고)/, // 출력 형식 지시
|
||||||
|
];
|
||||||
|
|
||||||
|
export function looksLikeBehaviorComplaint(prompt: string): boolean {
|
||||||
|
const t = (prompt || '').trim();
|
||||||
|
if (t.length < 4 || t.length > 1500) return false;
|
||||||
|
return BEHAVIOR_COMPLAINT_RE.some(re => re.test(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM 으로 지적 → 명령형 규칙 정규화. 실패 시 휴리스틱 fallback.
|
||||||
|
const RULE_SYSTEM = [
|
||||||
|
'너는 AI 행동 규칙 작성기다. 사용자가 AI 답변의 습관/형식/태도를 지적했다.',
|
||||||
|
'이 지적을 앞으로 모든 답변에 적용할 명령형 규칙 한 줄(한국어, 70자 이내)로 요약하라.',
|
||||||
|
'내용 지식(사실)이 아니라 행동 규칙만 만든다. 행동 규칙으로 만들 수 없으면 빈 문자열.',
|
||||||
|
'반드시 JSON 한 줄만 출력: {"rule":"<규칙 또는 빈 문자열>"}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
export async function deriveStandingRule(
|
||||||
|
wrongAnswer: string,
|
||||||
|
correction: string,
|
||||||
|
llm: { baseUrl: string; model: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const fallback = (): string => {
|
||||||
|
// "~하지 마/말라" 문장이 있으면 그 문장 자체가 규칙에 가장 가깝다.
|
||||||
|
const m = correction.match(/[^.!?\n]*(?:하지\s*마|말라|쓰지\s*마|붙이지\s*마|넣지\s*마)[^.!?\n]*/);
|
||||||
|
const base = (m ? m[0] : correction).trim().replace(/\s+/g, ' ').slice(0, 70);
|
||||||
|
return base ? `${base} — 이 지적을 반복하게 하지 않는다` : '';
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const user = [
|
||||||
|
`[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`,
|
||||||
|
`[사용자 지적] ${correction.slice(0, MAX_FIELD_CHARS)}`,
|
||||||
|
].join('\n');
|
||||||
|
const raw = await simpleChatCompletion(RULE_SYSTEM, user, {
|
||||||
|
baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000,
|
||||||
|
});
|
||||||
|
const m = raw.match(/\{[\s\S]*?\}/);
|
||||||
|
if (!m) return fallback();
|
||||||
|
const rule = String(JSON.parse(m[0])?.rule || '').trim().replace(/\s+/g, ' ').slice(0, 90);
|
||||||
|
return rule || fallback();
|
||||||
|
} catch {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleText(rule: string): string {
|
||||||
|
return rule.toLowerCase().replace(/[^a-z0-9가-힣]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleId(rule: string): string {
|
||||||
|
// 의존성 없는 짧은 해시 (djb2) — 파일에서 사람이 규칙을 찾을 때 쓰는 식별자.
|
||||||
|
const s = normalizeRuleText(rule);
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
||||||
|
return 'sr-' + h.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 규칙인지 — 토큰 자카드 유사도 (LLM 없이, 재지적 시 hits 만 올리기 위함).
|
||||||
|
export function isSameRule(a: string, b: string): boolean {
|
||||||
|
const ta = new Set(normalizeRuleText(a).split(' ').filter(w => w.length >= 2));
|
||||||
|
const tb = new Set(normalizeRuleText(b).split(' ').filter(w => w.length >= 2));
|
||||||
|
if (ta.size === 0 || tb.size === 0) return false;
|
||||||
|
let inter = 0;
|
||||||
|
for (const w of ta) if (tb.has(w)) inter++;
|
||||||
|
return inter / (ta.size + tb.size - inter) >= 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStandingRules(brainPath: string): StandingRule[] {
|
||||||
|
try {
|
||||||
|
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||||
|
if (!fs.existsSync(file)) return [];
|
||||||
|
const o = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return Array.isArray(o) ? o.filter(r => r && typeof r.rule === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStandingRules(brainPath: string, rules: StandingRule[]): boolean {
|
||||||
|
try {
|
||||||
|
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(rules, null, 2) + '\n', 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 추가/재지적 반영. 중복이면 hits++ (은퇴했던 규칙도 부활), 신규면 추가.
|
||||||
|
* active 가 상한을 넘으면 hits 낮고 오래된 것부터 은퇴. 저장까지 수행.
|
||||||
|
*/
|
||||||
|
export function upsertStandingRule(brainPath: string, rule: string, origin: string, nowIso?: string): StandingRule[] {
|
||||||
|
const now = nowIso || new Date().toISOString();
|
||||||
|
const rules = loadStandingRules(brainPath);
|
||||||
|
const existing = rules.find(r => isSameRule(r.rule, rule));
|
||||||
|
if (existing) {
|
||||||
|
existing.hits += 1;
|
||||||
|
existing.lastHitAt = now;
|
||||||
|
existing.status = 'active'; // 재지적 = 아직 필요한 규칙
|
||||||
|
} else {
|
||||||
|
rules.push({
|
||||||
|
id: ruleId(rule), rule: rule.trim(), origin: origin.slice(0, 200),
|
||||||
|
createdAt: now, lastHitAt: now, hits: 1, status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 상한 초과 시 은퇴: hits 오름차순 → lastHitAt 오래된 순.
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
if (active.length > MAX_ACTIVE_STANDING_RULES) {
|
||||||
|
const toRetire = active
|
||||||
|
.sort((a, b) => (a.hits - b.hits) || a.lastHitAt.localeCompare(b.lastHitAt))
|
||||||
|
.slice(0, active.length - MAX_ACTIVE_STANDING_RULES);
|
||||||
|
for (const r of toRetire) r.status = 'retired';
|
||||||
|
}
|
||||||
|
saveStandingRules(brainPath, rules);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매 턴 주입 블록 — 검색과 무관한 상시 규칙. active 없으면 ''. */
|
||||||
|
export function buildStandingRulesBlock(rules: StandingRule[]): string {
|
||||||
|
const active = rules.filter(r => r.status === 'active')
|
||||||
|
.sort((a, b) => (b.hits - a.hits) || b.lastHitAt.localeCompare(a.lastHitAt))
|
||||||
|
.slice(0, MAX_ACTIVE_STANDING_RULES);
|
||||||
|
if (active.length === 0) return '';
|
||||||
|
const lines = ['[상시 행동 규칙 — 사용자 지적으로 축적됨. 모든 답변에서 반드시 지켜라]'];
|
||||||
|
for (const r of active) {
|
||||||
|
lines.push(`- ${r.rule}${r.hits >= 2 ? ` (지적 ${r.hits}회)` : ''}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
/**
|
||||||
|
* Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
|
||||||
|
*
|
||||||
|
* 문제: 소형 로컬 모델은 <list_files> 로 파일명 목록을 받으면 <read_file> 을 부르지
|
||||||
|
* 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
|
||||||
|
*
|
||||||
|
* 해법 3중:
|
||||||
|
* ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
|
||||||
|
* (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
|
||||||
|
* ② Map-Reduce — <investigate_files> 액션이 폴더의 파일을 *코드가 하나씩 강제로
|
||||||
|
* 읽어* 파일별 추출 노트 생성(Map) 후 병합 노트를 컨텍스트에 주입(Reduce 는
|
||||||
|
* 본 턴 모델이 노트 기반으로 수행). 모델에게 "읽을지" 선택권 자체가 없다.
|
||||||
|
* ③ 온디맨드 지식화 캐시 — 파일 내용 해시를 키로 노트를 .astra/cache/investigate 에
|
||||||
|
* 저장. 같은 내용은 재조사 시 LLM 호출 없이 재사용, 파일이 바뀐 것만 갱신.
|
||||||
|
* 전량 위키화 없이 조사가 닿는 곳만 점진적으로 지식화된다.
|
||||||
|
*
|
||||||
|
* 노트는 질문 비의존(파일의 목적/핵심 사실/관계)으로 뽑는다 — 캐시 재사용률을
|
||||||
|
* 최대화하기 위함 (질문 특화 해석은 Reduce 단계의 본 턴 모델 몫).
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { EXCLUDED_DIRS } from '../config';
|
||||||
|
|
||||||
|
// ── 상수 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const INVESTIGATE_CACHE_REL_DIR = path.join('.astra', 'cache', 'investigate');
|
||||||
|
export const MAX_INVESTIGATION_FILES = 40; // 초과분은 skipped 로 보고 (조용한 누락 금지)
|
||||||
|
export const MAX_CHARS_PER_FILE = 20_000; // 파일당 읽기 상한 (소형 모델 충실 구간)
|
||||||
|
|
||||||
|
const TEXT_EXTS = new Set([
|
||||||
|
'.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonl',
|
||||||
|
'.html', '.css', '.py', '.yml', '.yaml', '.csv', '.xml', '.sh', '.bat', '.ps1', '.sql', '.env',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface InvestigationFileNote {
|
||||||
|
relPath: string;
|
||||||
|
note: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestigationResult {
|
||||||
|
dir: string;
|
||||||
|
notes: InvestigationFileNote[];
|
||||||
|
/** 상한·확장자·읽기실패로 제외된 파일 (조용한 누락 방지용 보고). */
|
||||||
|
skipped: string[];
|
||||||
|
cacheHits: number;
|
||||||
|
llmCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 조사형 요청 감지 + 증거 게이트 블록 ───────────────────────────────────
|
||||||
|
|
||||||
|
const INVESTIGATE_VERB_RE = /(조사|분석|검토|파악|리뷰|정리)\s*(해|을|를|좀|부탁|해줘|해주|하고|해서)/;
|
||||||
|
const LOCAL_TARGET_RE = /(폴더|파일|디렉토리|프로젝트|코드|문서|리소스|소스)|([A-Za-z]:\\|\.{0,2}\/[\w가-힣])/;
|
||||||
|
|
||||||
|
/** 로컬 자료를 대상으로 한 조사형 요청인지 (증거 게이트·모델 라우팅 공용 감지기). */
|
||||||
|
export function isLocalInvestigationPrompt(prompt: string): boolean {
|
||||||
|
const t = (prompt || '').trim();
|
||||||
|
if (t.length < 6) return false;
|
||||||
|
return INVESTIGATE_VERB_RE.test(t) && LOCAL_TARGET_RE.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조사형 요청일 때만 행동 제약 블록 반환, 아니면 ''. */
|
||||||
|
export function buildInvestigationGateBlock(prompt: string): string {
|
||||||
|
if (!isLocalInvestigationPrompt(prompt)) return '';
|
||||||
|
return [
|
||||||
|
'[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
|
||||||
|
'- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
|
||||||
|
'- 폴더 단위 조사는 <investigate_files path="상대경로" focus="조사 목적"/> 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 <read_file>.',
|
||||||
|
'- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
|
||||||
|
'- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [src/agent.ts]). 근거 없는 주장은 쓰지 않는다.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ②③ 파일 수집 → 노트 추출(캐시) → 병합 ──────────────────────────────────
|
||||||
|
|
||||||
|
export function collectInvestigationFiles(
|
||||||
|
rootPath: string,
|
||||||
|
relDir: string,
|
||||||
|
maxFiles = MAX_INVESTIGATION_FILES,
|
||||||
|
): { files: Array<{ relPath: string; absPath: string }>; skipped: string[] } {
|
||||||
|
const files: Array<{ relPath: string; absPath: string }> = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const baseAbs = path.resolve(rootPath, relDir || '.');
|
||||||
|
|
||||||
|
const walk = (abs: string, rel: string): void => {
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { skipped.push(rel + '/ (읽기 실패)'); return; }
|
||||||
|
// 결정적 순서 (테스트·캐시 재현성)
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.') || EXCLUDED_DIRS.has(e.name)) continue;
|
||||||
|
const childAbs = path.join(abs, e.name);
|
||||||
|
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) { walk(childAbs, childRel); continue; }
|
||||||
|
if (!TEXT_EXTS.has(path.extname(e.name).toLowerCase())) { skipped.push(childRel + ' (비텍스트)'); continue; }
|
||||||
|
if (files.length >= maxFiles) { skipped.push(childRel + ' (상한 초과)'); continue; }
|
||||||
|
files.push({ relPath: childRel, absPath: childAbs });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const st = fs.existsSync(baseAbs) ? fs.statSync(baseAbs) : null;
|
||||||
|
if (st?.isFile()) {
|
||||||
|
files.push({ relPath: relDir, absPath: baseAbs });
|
||||||
|
} else if (st?.isDirectory()) {
|
||||||
|
walk(baseAbs, '');
|
||||||
|
}
|
||||||
|
return { files, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTRACT_SYSTEM = [
|
||||||
|
'너는 파일 내용 분석기다. 제공된 파일 *내용*만 근거로 사실을 추출하라. 내용에 없는 것을 지어내지 말 것.',
|
||||||
|
'출력 형식 (개조식, 명사형 종결):',
|
||||||
|
'목적: <이 파일이 무엇인지 한 줄>',
|
||||||
|
'핵심:',
|
||||||
|
'- <핵심 사실/설정/결정 3~8개 — 수치·이름·경로 포함>',
|
||||||
|
'관계: <다른 파일/모듈과의 연결이 내용에 명시되어 있으면, 없으면 "—">',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function cacheKey(content: string): string {
|
||||||
|
return crypto.createHash('sha1').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePathFor(rootPath: string, key: string): string {
|
||||||
|
return path.join(rootPath, INVESTIGATE_CACHE_REL_DIR, `${key}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더(또는 단일 파일)를 강제로 읽어 파일별 노트 생성. llmCall 은 주입식(테스트 용이).
|
||||||
|
* 캐시: sha1(내용) → 노트. 내용이 같으면 질문이 달라도 재사용 (질문 비의존 노트).
|
||||||
|
*/
|
||||||
|
export async function runInvestigation(opts: {
|
||||||
|
rootPath: string;
|
||||||
|
relDir: string;
|
||||||
|
llmCall: (system: string, user: string) => Promise<string>;
|
||||||
|
maxFiles?: number;
|
||||||
|
onProgress?: (done: number, total: number, relPath: string) => void;
|
||||||
|
}): Promise<InvestigationResult> {
|
||||||
|
const { files, skipped } = collectInvestigationFiles(opts.rootPath, opts.relDir, opts.maxFiles ?? MAX_INVESTIGATION_FILES);
|
||||||
|
const result: InvestigationResult = { dir: opts.relDir, notes: [], skipped, cacheHits: 0, llmCalls: 0 };
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
let content: string;
|
||||||
|
try { content = fs.readFileSync(f.absPath, 'utf8').slice(0, MAX_CHARS_PER_FILE); }
|
||||||
|
catch { result.skipped.push(f.relPath + ' (읽기 실패)'); continue; }
|
||||||
|
if (!content.trim()) { result.skipped.push(f.relPath + ' (빈 파일)'); continue; }
|
||||||
|
|
||||||
|
const key = cacheKey(content);
|
||||||
|
const cacheFile = cachePathFor(opts.rootPath, key);
|
||||||
|
let note: string | null = null;
|
||||||
|
let fromCache = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||||
|
if (cached && typeof cached.note === 'string' && cached.note.trim()) {
|
||||||
|
note = cached.note;
|
||||||
|
fromCache = true;
|
||||||
|
result.cacheHits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 캐시 손상 → 재추출 */ }
|
||||||
|
|
||||||
|
if (note === null) {
|
||||||
|
try {
|
||||||
|
note = (await opts.llmCall(EXTRACT_SYSTEM, `[파일] ${f.relPath}\n\n${content}`)).trim();
|
||||||
|
result.llmCalls++;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||||
|
fs.writeFileSync(cacheFile, JSON.stringify({ relPath: f.relPath, note, ts: new Date().toISOString() }, null, 2), 'utf8');
|
||||||
|
} catch { /* 캐시 저장 실패는 무해 */ }
|
||||||
|
} catch (e: any) {
|
||||||
|
result.skipped.push(f.relPath + ` (추출 실패: ${e?.message ?? e})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.notes.push({ relPath: f.relPath, note, fromCache });
|
||||||
|
opts.onProgress?.(i + 1, files.length, f.relPath);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 병합 노트 — 액션이 chatHistory 에 internal system 메시지로 주입하는 본문. */
|
||||||
|
export function formatInvestigationNotes(result: InvestigationResult, focus?: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`[Result of investigate_files "${result.dir}"] — 아래는 각 파일을 *실제로 읽고* 추출한 노트다 (${result.notes.length}개 파일, 캐시 재사용 ${result.cacheHits}건).`);
|
||||||
|
if (focus?.trim()) lines.push(`조사 목적: ${focus.trim()}`);
|
||||||
|
lines.push('이 노트를 근거로 답하되, 각 주장 끝에 근거 파일 경로를 붙여라. 노트에 없는 내용은 "노트에 근거 없음"으로 표기.');
|
||||||
|
lines.push('');
|
||||||
|
for (const n of result.notes) {
|
||||||
|
lines.push(`── [${n.relPath}]`);
|
||||||
|
lines.push(n.note);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (result.skipped.length) {
|
||||||
|
lines.push(`(미확인 파일 ${result.skipped.length}건 — 필요 시 <read_file> 로 개별 확인: ${result.skipped.slice(0, 10).join(', ')}${result.skipped.length > 10 ? ' 외' : ''})`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hollow Investigation 감지 (postAnswerHook 용 순수 함수) ──────────────────
|
||||||
|
|
||||||
|
export interface TurnActionStats { reads: number; lists: number; investigates: number }
|
||||||
|
|
||||||
|
const FILE_MENTION_RE = /[\w가-힣.\-]{2,}\.(?:md|txt|ts|tsx|js|jsx|json|html|css|py|pdf|docx?|xlsx?|pptx?|ya?ml|csv)\b/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "목록만 보고(list) 읽지는 않았는데(read/investigate 0) 파일들을 서술" = 헛조사.
|
||||||
|
* 파일명 언급 2개 이상일 때만 (1개는 단순 지칭일 수 있음 — 오탐 방지).
|
||||||
|
*/
|
||||||
|
export function detectHollowInvestigation(
|
||||||
|
answer: string,
|
||||||
|
stats: TurnActionStats | null | undefined,
|
||||||
|
): { hollow: boolean; fileMentions: string[] } {
|
||||||
|
if (!stats || stats.lists === 0) return { hollow: false, fileMentions: [] };
|
||||||
|
if (stats.reads > 0 || stats.investigates > 0) return { hollow: false, fileMentions: [] };
|
||||||
|
const mentions = [...new Set(Array.from((answer || '').matchAll(FILE_MENTION_RE), m => m[0]))];
|
||||||
|
return { hollow: mentions.length >= 2, fileMentions: mentions.slice(0, 8) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHollowInvestigationFooter(fileMentions: string[]): string {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
`⚠️ **헛조사 감지** — 이 답변은 파일 목록만 조회하고 실제 내용은 읽지 않은 채 파일 ${fileMentions.length}건(${fileMentions.slice(0, 4).join(', ')}${fileMentions.length > 4 ? ' 외' : ''})을 서술했습니다.`,
|
||||||
|
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.312] 헛조사의 반대 방향 실패 — 파일을 *읽고도* 답변에 파일 근거를 하나도
|
||||||
|
* 인용하지 않은 "일반론 분석" 감지. 실사례: 프로젝트 분석 보고가 7줄짜리
|
||||||
|
* 프로젝트명만 바꿔도 성립하는 총평("~가 인상적입니다")으로 끝남.
|
||||||
|
* 조건: read/investigate 를 1회 이상 했고, 답변이 실질 분량(300자+)인데
|
||||||
|
* 파일 참조가 2개 미만 → ungrounded. (짧은 확인형 응답은 오탐 방지로 제외.)
|
||||||
|
*/
|
||||||
|
export function detectUngroundedAnalysis(
|
||||||
|
answer: string,
|
||||||
|
stats: TurnActionStats | null | undefined,
|
||||||
|
): { ungrounded: boolean; readCount: number; fileRefs: number } {
|
||||||
|
const readCount = (stats?.reads ?? 0) + (stats?.investigates ?? 0);
|
||||||
|
if (!stats || readCount === 0) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||||
|
const body = (answer || '').trim();
|
||||||
|
if (body.length < 300) return { ungrounded: false, readCount, fileRefs: 0 };
|
||||||
|
const refs = new Set(Array.from(body.matchAll(FILE_MENTION_RE), m => m[0]));
|
||||||
|
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatUngroundedAnalysisFooter(readCount: number): string {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
`⚠️ **근거 인용 없음** — 파일을 ${readCount}회 읽고도 답변에 실제 파일·코드 근거가 인용되지 않았습니다. 위 내용은 일반론일 수 있습니다.`,
|
||||||
|
'구체 근거가 필요하면 "실제 읽은 파일명을 인용해서 다시 정리해줘"라고 요청해 주세요.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
+12
-29
@@ -1,3 +1,4 @@
|
|||||||
|
import { coreChat } from '../core/services';
|
||||||
/**
|
/**
|
||||||
* 단순 non-streaming LLM 호출 — Ollama / LM Studio(OpenAI 호환) 듀얼 엔드포인트.
|
* 단순 non-streaming LLM 호출 — Ollama / LM Studio(OpenAI 호환) 듀얼 엔드포인트.
|
||||||
*
|
*
|
||||||
@@ -19,34 +20,16 @@ export async function simpleChatCompletion(
|
|||||||
user: string,
|
user: string,
|
||||||
options: SimpleChatOptions,
|
options: SimpleChatOptions,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const isOllama = options.baseUrl.includes(':11434') || options.baseUrl.includes('ollama');
|
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — core/services.coreChat 으로 통일.
|
||||||
const endpoint = isOllama ? `${options.baseUrl}/api/chat` : `${options.baseUrl}/v1/chat/completions`;
|
// options.baseUrl 은 하위 호환용으로 남지만 무시된다 (코어가 설정의 ollamaUrl 사용 —
|
||||||
const controller = new AbortController();
|
// 기존 호출자들도 전부 config.ollamaUrl 을 넘기고 있었으므로 동작 동일).
|
||||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 120000);
|
const result = await coreChat({
|
||||||
try {
|
system,
|
||||||
const messages = [
|
user,
|
||||||
{ role: 'system', content: system },
|
model: options.model,
|
||||||
{ role: 'user', content: user },
|
timeoutMs: options.timeoutMs ?? 120000,
|
||||||
];
|
temperature: options.temperature ?? 0.2,
|
||||||
const body = isOllama
|
maxTokens: options.maxTokens ?? 1200,
|
||||||
? { model: options.model, stream: false, messages, options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 1200 } }
|
|
||||||
: { model: options.model, stream: false, temperature: options.temperature ?? 0.2, max_tokens: options.maxTokens ?? 1200, messages };
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
return result.content;
|
||||||
const data: any = await res.json();
|
|
||||||
return String(
|
|
||||||
data?.message?.content ??
|
|
||||||
data?.choices?.[0]?.message?.content ??
|
|
||||||
data?.choices?.[0]?.text ??
|
|
||||||
data?.response ??
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -154,6 +154,42 @@ export const DEFAULT_TASK_REQUIREMENTS: TaskRequirement[] = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// [v2.2.312] 실사례 대응 — "E:\...프로젝트를 분석하고 어떻게 되어 있는지 보고해줘"가
|
||||||
|
// 어떤 유형에도 안 걸려 7줄짜리 일반론 총평으로 끝났다. '보고해줘/현황 보고' 류를
|
||||||
|
// 잡아 파일 근거 인용을 필수 요소로 강제한다.
|
||||||
|
id: 'analysis-report',
|
||||||
|
label: '분석 보고',
|
||||||
|
detectKeywords: ['분석[^\\n]{0,20}보고', '보고해\\s*줘?', '현황\\s*(보고|정리)', '어떻게\\s*되어\\s*있는지', '검토[^\\n]{0,10}보고'],
|
||||||
|
coverageCheck: true,
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
id: 'overview', label: '보고 개요',
|
||||||
|
hint: '첫 줄에 "보고 개요: (분석 대상) — (확인한 근거: 실제 읽은 파일 수) — (한 줄 결론)" 형식으로 시작. 요청자가 첫 줄만 읽어도 무엇을 어떤 근거로 판단했는지 알 수 있게.',
|
||||||
|
detectPatterns: ['보고 ?개요', '분석 ?대상', '개요'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-evidence', label: '파일 근거',
|
||||||
|
hint: '모든 주요 주장에 이번 대화에서 실제로 읽은 파일명·경로를 인용. 프로젝트 이름을 바꿔도 성립하는 일반론("~가 인상적입니다", "~할 가능성이 높습니다")은 금지 — 근거 없는 문장은 쓰지 말 것.',
|
||||||
|
detectPatterns: ['\\.(ts|tsx|js|mjs|cjs|py|md|json|html|css|java|cs)\\b', 'src[\\\\/]', 'package\\.json'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'structure', label: '구조·현황',
|
||||||
|
hint: '프로젝트/대상의 실제 구성 — 핵심 모듈, 진행 상태, 확인된 사실. STATUS.md 등 현재 상태 기록이 있으면 그것을 기준으로.',
|
||||||
|
detectPatterns: ['구조', '구성', '모듈', '아키텍처', '현황', '상태'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'findings', label: '핵심 발견·이슈',
|
||||||
|
hint: '분석에서 드러난 강점·문제·이슈. 각 항목에 근거 파일 연결.',
|
||||||
|
detectPatterns: ['발견', '강점', '문제', '이슈', '리스크', '과제'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'next-steps', label: '다음 단계',
|
||||||
|
hint: '이 보고를 받은 사람이 취할 수 있는 구체적 다음 행동.',
|
||||||
|
detectPatterns: ['다음 ?단계', '권장', '제안', '후속'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'work-research',
|
id: 'work-research',
|
||||||
label: '업무조사',
|
label: '업무조사',
|
||||||
@@ -233,6 +269,9 @@ export function buildRequirementGraphBlock(
|
|||||||
}
|
}
|
||||||
lines.push('');
|
lines.push('');
|
||||||
lines.push('제출 전 위 체크리스트를 스스로 점검하고, 누락 요소가 있으면 보완 후 답변할 것.');
|
lines.push('제출 전 위 체크리스트를 스스로 점검하고, 누락 요소가 있으면 보완 후 답변할 것.');
|
||||||
|
// [v2.2.312] 업무 유형이 감지된 답변은 일반 "최대 3섹션" 규칙보다 필수 요소 커버가
|
||||||
|
// 우선 — 보고서가 3섹션 제한 때문에 얕아지는 상충 제거.
|
||||||
|
lines.push('이 업무 유형이 감지된 답변에서는 "최대 3섹션" 일반 규칙보다 필수 요소 커버가 우선한다 — 요소별로 섹션을 나눠도 된다.');
|
||||||
lines.push('[/TASK REQUIREMENTS]');
|
lines.push('[/TASK REQUIREMENTS]');
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,245 @@
|
|||||||
|
/**
|
||||||
|
* LiveReasoningFilter — 스트리밍 *도중* 모델의 내부 추론을 화면에서 차단하는
|
||||||
|
* 상태 기계 (per-generation 1 인스턴스, stateful).
|
||||||
|
*
|
||||||
|
* 배경: liveStreamTokens=true(기본) 라이브 스트리밍에서 `<|channel|>thought …` /
|
||||||
|
* `<think>…</think>` 같은 추론 구간이 생성 내내 그대로 노출됐다가 완료 시
|
||||||
|
* streamReplace 로만 사라졌다. 사용자가 볼 필요 없는 텍스트가 답변 대기 내내
|
||||||
|
* 화면을 채우는 문제 — 이 필터가 토큰 단위로 추론 구간을 걸러 "보여도 되는"
|
||||||
|
* 델타만 통과시킨다. 최종 정리는 여전히 outputSanitization(streamReplace)이
|
||||||
|
* 책임지므로, 여기서 놓친 마커는 완료 시 반드시 정리된다 (이중 방어).
|
||||||
|
*
|
||||||
|
* 처리하는 마커 (outputSanitization.sanitizeAssistantContent 와 동일 계열):
|
||||||
|
* - `<think>…</think>` / `<thinking>…</thinking>` / `<analysis>…</analysis>`
|
||||||
|
* - Harmony 채널: `<|channel|>thought|analysis|commentary|reasoning …` 는 숨기고,
|
||||||
|
* `<|channel|>final` (+ 뒤따르는 `<|message|>`) 마커는 제거 후 본문 통과.
|
||||||
|
* `<|end|>` / `<|return|>` 는 숨김 종료.
|
||||||
|
*
|
||||||
|
* 토큰 경계 대응: 마커가 토큰 두 개에 걸쳐 쪼개져 와도 (`<thi` + `nk>`) 잡히도록,
|
||||||
|
* 열림 마커의 접두사가 될 수 있는 꼬리는 emit 을 보류(holdback)한다.
|
||||||
|
* "Thinking Process:" 류 평문 휴리스틱은 라이브에서는 오탐 위험이 커서 다루지
|
||||||
|
* 않는다 — 완료 시 streamReplace 가 정리.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
|
||||||
|
{ literal: '<think>', closer: /<\/think(?:ing)?>/i },
|
||||||
|
{ literal: '<thinking>', closer: /<\/think(?:ing)?>/i },
|
||||||
|
{ literal: '<analysis>', closer: /<\/analysis>/i },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
|
||||||
|
* 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
|
||||||
|
* 경계에 이 합성 마커를 삽입한다:
|
||||||
|
* `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__<답변>`
|
||||||
|
* 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 END 마커를 만나는 순간
|
||||||
|
* `separatorSeen` 신호를 세워, 호출자(스트림 루프)가 streamReplace 로 그때까지
|
||||||
|
* 표시된 추론을 화면에서 걷어내게 한다. START 변형이 오면 일반 opener 처럼 숨긴다.
|
||||||
|
*/
|
||||||
|
const LSEP_CONST_PREFIX = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_';
|
||||||
|
const LSEP_END_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/i;
|
||||||
|
const LSEP_START_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_[0-9a-f]{6,64}__/i;
|
||||||
|
|
||||||
|
/** tail 이 LSEP 마커의 접두사가 될 가능성 (emit 보류 판단, '<' 계열과 별도). */
|
||||||
|
function couldBeLsepPrefix(tail: string): boolean {
|
||||||
|
if (!tail.startsWith('_')) return false;
|
||||||
|
if (tail.length <= LSEP_CONST_PREFIX.length) return LSEP_CONST_PREFIX.startsWith(tail);
|
||||||
|
if (!tail.startsWith(LSEP_CONST_PREFIX)) return false;
|
||||||
|
// 상수부 이후: (START|END 일부) + '_' + hex + 닫는 '__' 진행 중이면 보류
|
||||||
|
return /^[A-Z]{0,5}_?[0-9a-f]{0,64}_{0,2}$/i.test(tail.slice(LSEP_CONST_PREFIX.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
|
||||||
|
const CHANNEL_MARKER = /<\|?channel\|?>/i;
|
||||||
|
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
|
||||||
|
const FINAL_CHANNEL_WORD = /^final\b/i;
|
||||||
|
/** 채널 이름 판별에 필요한 최대 대기 글자수 — 마커 뒤 공백+가장 긴 채널명이면 충분. */
|
||||||
|
const CHANNEL_PEEK_CHARS = 16;
|
||||||
|
const END_MARKER = /<\|?(?:end|return)\|?>/i;
|
||||||
|
const MESSAGE_MARKER = /^\s*<\|?message\|?>/i;
|
||||||
|
|
||||||
|
/** tail 이 열림 마커들 중 하나의 접두사가 될 *가능성* 이 있으면 true (emit 보류 판단). */
|
||||||
|
function couldBeOpenerPrefix(tail: string): boolean {
|
||||||
|
if (!tail.startsWith('<')) return false;
|
||||||
|
const lower = tail.toLowerCase();
|
||||||
|
for (const { literal } of TAG_OPENERS) {
|
||||||
|
if (literal.startsWith(lower)) return true;
|
||||||
|
}
|
||||||
|
// 채널 마커 변형들의 접두사 여부
|
||||||
|
for (const marker of ['<|channel|>', '<channel|>', '<|channel>']) {
|
||||||
|
if (marker.startsWith(lower)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Mode = 'visible' | 'hiddenTag' | 'hiddenChannel' | 'channelPending';
|
||||||
|
|
||||||
|
export class LiveReasoningFilter {
|
||||||
|
private mode: Mode = 'visible';
|
||||||
|
/** 아직 판정/방출하지 않은 원본 꼬리. */
|
||||||
|
private pending = '';
|
||||||
|
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
|
||||||
|
private closer: RegExp | null = null;
|
||||||
|
/** [v2.2.310] LSEP END 마커 감지 신호 — 호출자가 streamReplace 로 화면 리셋. */
|
||||||
|
private separatorSeen = false;
|
||||||
|
|
||||||
|
/** LSEP 구분자를 만났는지 (1회성 소비 — 읽으면 리셋). */
|
||||||
|
consumeSeparatorSignal(): boolean {
|
||||||
|
const v = this.separatorSeen;
|
||||||
|
this.separatorSeen = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
||||||
|
* 반환값이 빈 문자열이면 이번 토큰은 전부 (아직) 숨김.
|
||||||
|
*/
|
||||||
|
push(token: string): string {
|
||||||
|
this.pending += token;
|
||||||
|
let out = '';
|
||||||
|
|
||||||
|
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
||||||
|
for (;;) {
|
||||||
|
if (this.mode === 'visible') {
|
||||||
|
// [v2.2.310] LSEP 마커 — START 가 END 보다 먼저면 정상 블록(숨김),
|
||||||
|
// END 만 단독 등장(여는 마커 없이 추론이 먼저 흐른 케이스)이면 리셋 신호.
|
||||||
|
const sepStart = this.pending.match(LSEP_START_RE);
|
||||||
|
const sepEnd = this.pending.match(LSEP_END_RE);
|
||||||
|
const startIdx = sepStart?.index ?? -1;
|
||||||
|
const endIdx = sepEnd?.index ?? -1;
|
||||||
|
if (startIdx >= 0 && (endIdx < 0 || startIdx < endIdx)) {
|
||||||
|
// START 변형 — 일반 opener 처럼 END 까지 숨김.
|
||||||
|
out += this.pending.slice(0, startIdx);
|
||||||
|
this.pending = this.pending.slice(startIdx + sepStart![0].length);
|
||||||
|
this.mode = 'hiddenTag';
|
||||||
|
this.closer = LSEP_END_RE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (endIdx >= 0) {
|
||||||
|
// 여는 마커 없이 흘러온 추론의 끝 — 이미 화면에 나간 앞부분은 되돌릴
|
||||||
|
// 수 없으니 신호만 세우고(호출자가 streamReplace), 잔여 추론+마커 폐기.
|
||||||
|
this.pending = this.pending.slice(endIdx + sepEnd![0].length);
|
||||||
|
this.separatorSeen = true;
|
||||||
|
out = ''; // 마커 앞 잔여분(추론)은 방출 취소
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hit = this.findEarliestOpener(this.pending);
|
||||||
|
if (hit) {
|
||||||
|
out += this.pending.slice(0, hit.index);
|
||||||
|
this.pending = this.pending.slice(hit.index + hit.length);
|
||||||
|
if (hit.kind === 'tag') {
|
||||||
|
this.mode = 'hiddenTag';
|
||||||
|
this.closer = hit.closer!;
|
||||||
|
} else {
|
||||||
|
this.mode = 'channelPending';
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 열림 마커 없음 — 꼬리가 마커의 접두사일 수 있으면 그만큼 보류하고 방출.
|
||||||
|
const hold = this.holdbackLen(this.pending);
|
||||||
|
out += this.pending.slice(0, this.pending.length - hold);
|
||||||
|
this.pending = hold ? this.pending.slice(-hold) : '';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'channelPending') {
|
||||||
|
// 채널 이름이 판별될 만큼 모일 때까지 대기.
|
||||||
|
const head = this.pending.replace(/^\s+/, '');
|
||||||
|
if (FINAL_CHANNEL_WORD.test(head)) {
|
||||||
|
// final 채널 — 마커(+뒤따르는 <|message|>)만 제거하고 본문은 통과.
|
||||||
|
let rest = head.replace(FINAL_CHANNEL_WORD, '');
|
||||||
|
const m = rest.match(MESSAGE_MARKER);
|
||||||
|
if (m) rest = rest.slice(m[0].length);
|
||||||
|
else if (/^\s*<?\|?m?e?s?s?a?g?e?\|?>?$/i.test(rest) && rest.length < 12) {
|
||||||
|
// <|message|> 가 아직 다 안 온 것일 수 있음 — 더 기다린다.
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
this.pending = rest;
|
||||||
|
this.mode = 'visible';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (HIDDEN_CHANNEL_WORD.test(head)) {
|
||||||
|
this.pending = head;
|
||||||
|
this.mode = 'hiddenChannel';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (this.pending.length >= CHANNEL_PEEK_CHARS) {
|
||||||
|
// 아는 채널명이 아님 — 보수적으로 숨김 (완료 시 streamReplace 가 정리).
|
||||||
|
this.mode = 'hiddenChannel';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return out; // 더 모일 때까지 대기
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.mode === 'hiddenTag') {
|
||||||
|
const m = this.pending.match(this.closer!);
|
||||||
|
if (m && m.index !== undefined) {
|
||||||
|
this.pending = this.pending.slice(m.index + m[0].length);
|
||||||
|
this.mode = 'visible';
|
||||||
|
this.closer = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
||||||
|
// (LSEP END 마커는 ~85자라 넉넉히 보존 — v2.2.310)
|
||||||
|
this.pending = this.pending.slice(-100);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// hiddenChannel: 다음 채널 마커(재판정) 또는 end/return(숨김 종료)까지 폐기.
|
||||||
|
const ch = this.pending.match(CHANNEL_MARKER);
|
||||||
|
const end = this.pending.match(END_MARKER);
|
||||||
|
const chIdx = ch?.index ?? -1;
|
||||||
|
const endIdx = end?.index ?? -1;
|
||||||
|
if (chIdx >= 0 && (endIdx < 0 || chIdx < endIdx)) {
|
||||||
|
this.pending = this.pending.slice(chIdx + ch![0].length);
|
||||||
|
this.mode = 'channelPending';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (endIdx >= 0) {
|
||||||
|
this.pending = this.pending.slice(endIdx + end![0].length);
|
||||||
|
this.mode = 'visible';
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.pending = this.pending.slice(-24);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 스트림 종료 시 보류 중이던 visible 꼬리를 회수 (숨김 모드였다면 버린다). */
|
||||||
|
flush(): string {
|
||||||
|
const tail = this.mode === 'visible' ? this.pending : '';
|
||||||
|
this.pending = '';
|
||||||
|
return tail;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findEarliestOpener(s: string): { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null {
|
||||||
|
const lower = s.toLowerCase();
|
||||||
|
let best: { index: number; length: number; kind: 'tag' | 'channel'; closer?: RegExp } | null = null;
|
||||||
|
for (const { literal, closer } of TAG_OPENERS) {
|
||||||
|
const i = lower.indexOf(literal);
|
||||||
|
if (i >= 0 && (!best || i < best.index)) best = { index: i, length: literal.length, kind: 'tag', closer };
|
||||||
|
}
|
||||||
|
const m = s.match(CHANNEL_MARKER);
|
||||||
|
if (m && m.index !== undefined && (!best || m.index < best.index)) {
|
||||||
|
best = { index: m.index, length: m[0].length, kind: 'channel' };
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
private holdbackLen(s: string): number {
|
||||||
|
// 뒤에서부터 '<' 를 찾아, 거기부터 끝까지가 열림 마커의 접두사일 수 있으면 보류.
|
||||||
|
const maxHold = Math.min(s.length, 12);
|
||||||
|
for (let k = 1; k <= maxHold; k++) {
|
||||||
|
const tail = s.slice(s.length - k);
|
||||||
|
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
|
||||||
|
}
|
||||||
|
// [v2.2.310] LSEP 마커('_' 시작, 최대 ~110자) 접두사 보류 — 토큰 경계 분절 대응.
|
||||||
|
const maxLsep = Math.min(s.length, 110);
|
||||||
|
for (let k = 1; k <= maxLsep; k++) {
|
||||||
|
const tail = s.slice(s.length - k);
|
||||||
|
if (tail.startsWith('_') && couldBeLsepPrefix(tail)) return k;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -140,6 +140,7 @@ export function buildLocalProjectIntentGuidance(intent: LocalProjectIntent): str
|
|||||||
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
|
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
|
||||||
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
|
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
|
||||||
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
|
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
|
||||||
|
'- If STATUS.md exists, read it before forming any claim about current phase/progress. Never describe a project\'s state using only design docs or docs/plans/* — those capture intent at write-time and go stale; STATUS.md (updated per work unit) is the current-state source of truth. If STATUS.md shows a later phase or a different chosen approach than a design doc implies, report the STATUS.md version as current and note that the design doc is superseded.',
|
||||||
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
|
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
|
||||||
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
|
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ export function listProjectTree(root: string, current: string, depth: number, ma
|
|||||||
export function findPriorityProjectFiles(root: string): string[] {
|
export function findPriorityProjectFiles(root: string): string[] {
|
||||||
const exactNames = new Set([
|
const exactNames = new Set([
|
||||||
'package.json',
|
'package.json',
|
||||||
|
'STATUS.md',
|
||||||
|
'status.md',
|
||||||
'README.md',
|
'README.md',
|
||||||
'readme.md',
|
'readme.md',
|
||||||
'tsconfig.json',
|
'tsconfig.json',
|
||||||
@@ -173,15 +175,16 @@ export function findPriorityProjectFiles(root: string): string[] {
|
|||||||
const rank = (file: string) => {
|
const rank = (file: string) => {
|
||||||
const relative = path.relative(root, file);
|
const relative = path.relative(root, file);
|
||||||
if (path.basename(file) === 'package.json') return 0;
|
if (path.basename(file) === 'package.json') return 0;
|
||||||
if (/readme\.md$/i.test(file)) return 1;
|
if (/^status\.md$/i.test(relative)) return 1;
|
||||||
if (/^src[\\/]App\.tsx$/i.test(relative)) return 2;
|
if (/readme\.md$/i.test(file)) return 2;
|
||||||
if (/^src[\\/]main\.tsx$/i.test(relative)) return 3;
|
if (/^src[\\/]App\.tsx$/i.test(relative)) return 3;
|
||||||
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4;
|
if (/^src[\\/]main\.tsx$/i.test(relative)) return 4;
|
||||||
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5;
|
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 5;
|
||||||
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6;
|
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 6;
|
||||||
if (/^src[\\/]/i.test(relative)) return 7;
|
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 7;
|
||||||
if (/^docs[\\/]|\.md$/i.test(relative)) return 8;
|
if (/^src[\\/]/i.test(relative)) return 8;
|
||||||
return 9;
|
if (/^docs[\\/]|\.md$/i.test(relative)) return 9;
|
||||||
|
return 10;
|
||||||
};
|
};
|
||||||
return rank(a) - rank(b) || a.localeCompare(b);
|
return rank(a) - rank(b) || a.localeCompare(b);
|
||||||
});
|
});
|
||||||
@@ -276,6 +279,7 @@ export function buildLocalProjectPathContext(prompt: string, rootPath: string):
|
|||||||
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
|
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
|
||||||
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
|
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
|
||||||
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
|
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
|
||||||
|
'If a STATUS.md is present in the priority previews or scanned tree, it is the canonical current-state record for this project (updated per work unit) — read it FIRST and let it anchor your understanding of "current" phase/status. Design docs and plans under docs/ (e.g. dated design specs, docs/plans/*) describe intent at the time they were written and may be stale; if their content conflicts with STATUS.md (different phase, different chosen approach), STATUS.md wins and you should say so explicitly rather than silently presenting the older doc as current.',
|
||||||
'If access failed, explain the concrete failure.',
|
'If access failed, explain the concrete failure.',
|
||||||
'If access succeeded and priority file previews are present, do not say that code was not provided.',
|
'If access succeeded and priority file previews are present, do not say that code was not provided.',
|
||||||
'Treat the Local project intent line as the routing decision for this response.',
|
'Treat the Local project intent line as the routing decision for this response.',
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { RetrievalOrchestrator } from '../../retrieval';
|
|||||||
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
||||||
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
||||||
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
||||||
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop';
|
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap, loadStandingRules, buildStandingRulesBlock } from '../../intelligence/correctionLoop';
|
||||||
|
import { buildInvestigationGateBlock } from '../../intelligence/investigationPipeline';
|
||||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||||
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
|
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
|
||||||
import {
|
import {
|
||||||
@@ -165,11 +166,13 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
|||||||
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
|
if (domainScope.folders.length > 0) effectiveScopeFolders = domainScope.folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
|
// Context 윈도우 비례 retrieval 예산.
|
||||||
const scaledTotalBudget = Math.min(
|
// [v2.2.311] 종전 자동값(창의 25%, 8k~80k)은 32K 창에서 검색 결과에만 8k 토큰을 태워
|
||||||
80000,
|
// 로컬 프리필 수십 초의 주범이었다. 자동값을 창의 12%(2.5k~6k 클램프)로 낮추고,
|
||||||
Math.max(8000, Math.floor(config.contextLength * 0.25)),
|
// 더 풍부한 근거가 필요하면 g1nation.retrievalTokenBudget 으로 명시 상향.
|
||||||
);
|
const scaledTotalBudget = config.retrievalTokenBudget > 0
|
||||||
|
? config.retrievalTokenBudget
|
||||||
|
: Math.min(6000, Math.max(2500, Math.floor(config.contextLength * 0.12)));
|
||||||
|
|
||||||
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
|
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
|
||||||
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
|
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
|
||||||
@@ -418,6 +421,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
|||||||
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
||||||
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
||||||
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
||||||
|
// [Correction Loop ④] Standing Rules — 행동/스타일 교정의 영속 규칙. 레슨(유사도
|
||||||
|
// 검색)과 달리 질문 내용과 무관하게 *매 턴* 주입된다. 세션을 새로 열어도 유지 —
|
||||||
|
// "self-reflection 이 단기 기억에 갇힌다" 문제의 구조적 해결 (v2.2.308).
|
||||||
|
const standingRulesBlock = buildStandingRulesBlock(loadStandingRules(deps.activeBrain.localBrainPath));
|
||||||
|
|
||||||
|
// [조사 증거 게이트 v2.2.309] 로컬 자료 조사형 요청이면 "읽지 않은 파일 서술 금지 +
|
||||||
|
// investigate_files 위임" 제약 주입 — 파일명만 보고 상상하는 헛조사 차단의 1차 방어선.
|
||||||
|
const investigationGateBlock = buildInvestigationGateBlock(deps.currentPrompt);
|
||||||
|
|
||||||
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
||||||
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
||||||
@@ -428,7 +439,17 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
|||||||
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
|
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
|
||||||
const routingHint = buildDomainRoutingHint(classifyKnowledgeDomain(deps.currentPrompt));
|
const routingHint = buildDomainRoutingHint(classifyKnowledgeDomain(deps.currentPrompt));
|
||||||
|
|
||||||
const constraintBlock = [routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
// [답변 전 이해 원칙] 문자적 해석 방지 — 모든 실질 질문 턴에 주입되는 행동 제약
|
||||||
|
// (LLM 호출 없음). 조사·보고서 워크플로우의 '의도 브리핑'(LLM 1회)의 경량판으로,
|
||||||
|
// 일반 단발 질문에서도 "왜 묻는지"를 먼저 판단하고 답하게 한다. (v2.2.301)
|
||||||
|
const intentPrinciple = [
|
||||||
|
'[답변 전 이해 원칙]',
|
||||||
|
'- 이번 메시지를 문자 그대로만 읽지 말고, 최근 대화 흐름·직전 결론과 함께 해석하라.',
|
||||||
|
'- 답하기 전에 판단하라: 이 사람이 왜 지금 이걸 묻는가(목적), 표면 질문 뒤에 정말 궁금한 것은 무엇인가, 답을 받아 무엇을 하려는가.',
|
||||||
|
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const constraintBlock = [intentPrinciple, standingRulesBlock, investigationGateBlock, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||||
return memoryBlock;
|
return memoryBlock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
|
|||||||
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
|
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
|
||||||
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
|
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
|
||||||
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
|
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
|
||||||
|
// [v2.2.301] 명시적 조사·보고서 요청은 절대 임계값 게이트보다 우선 발동 —
|
||||||
|
// 짧은 "X 조사해줘"도 Report QA 파이프라인(의도 브리핑→채점→회귀 게이트)을
|
||||||
|
// 타야 하기 때문. '요약/리뷰' 류 일반 키워드는 기존 결정대로 게이트 아래 유지.
|
||||||
|
if (/(조사|리서치|보고서|레포트)/.test(prompt) || /\b(research|report)\b/i.test(prompt)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const promptTokensForGate = estimateTokens(prompt);
|
const promptTokensForGate = estimateTokens(prompt);
|
||||||
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
|
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
|
||||||
|
|||||||
@@ -22,7 +22,16 @@
|
|||||||
* `<|return|>`) 보수적으로 매칭.
|
* `<|return|>`) 보수적으로 매칭.
|
||||||
*/
|
*/
|
||||||
export function sanitizeAssistantContent(text: string): string {
|
export function sanitizeAssistantContent(text: string): string {
|
||||||
const stripped = text
|
// [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가
|
||||||
|
// *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다.
|
||||||
|
// 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거.
|
||||||
|
let t = text;
|
||||||
|
const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
|
||||||
|
const lsepParts = t.split(lsepEnd);
|
||||||
|
if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1];
|
||||||
|
t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, '');
|
||||||
|
|
||||||
|
const stripped = t
|
||||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* Tone Guard — AI 상투어(클리셰) 금지 레지스트리 (v2.2.306).
|
||||||
|
*
|
||||||
|
* 배경: 잡담 턴에서 "힘든 감정을 느끼고 계시군요. 괜찮으시다면 … 저는 언제든
|
||||||
|
* 여기에 있습니다." 같은 상담사 화법이 그대로 나왔다 (실사례). 작업 턴에는
|
||||||
|
* 문체 규칙(persona R1~R7)이 있지만 casual 모드는 무방비였다.
|
||||||
|
*
|
||||||
|
* 설계: 블로그_v3 QA 레지스트리와 같은 철학 — 금지 표현은 이 배열이 단일 권위.
|
||||||
|
* 소형 모델은 "자연스럽게 해" 같은 추상 지시보다 **구체적 금지 목록 + 대조
|
||||||
|
* 예시**에 훨씬 잘 반응한다. 새 클리셰 발견 시 여기 한 곳만 추가하면 된다.
|
||||||
|
* (순수 모듈 — import 없음: utils/persona 와 contextBuilders 양쪽에서 안전하게 사용)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 금지 표현 패턴 — 프롬프트에 나열되는 문구. 발견 즉시 다른 말로 바꿔야 한다. */
|
||||||
|
export const AI_CLICHE_PATTERNS: readonly string[] = [
|
||||||
|
'"~하시군요/~계시군요" 감정 미러링 (예: "슬픈 감정을 느끼고 계시군요")',
|
||||||
|
'"괜찮으시다면/원하신다면 ~해 드릴 수 있습니다" 허락 구걸형 제안',
|
||||||
|
'"저는 언제든 여기에 있습니다" / "언제든 말씀해 주세요" 대기 선언',
|
||||||
|
'"무엇을 도와드릴까요?" 로 대화 던지기 (사용자가 화제를 닫기 전에는 금지)',
|
||||||
|
'"함께 ~해 보아요" / "함께 검토해 드릴 수 있습니다" 류 유도 문구',
|
||||||
|
'"물론입니다!" / "당연하죠!" 과잉 맞장구 서두',
|
||||||
|
'"도움이 되었기를 바랍니다" 류 맺음 인사',
|
||||||
|
'"충분히 그러실 수 있습니다" / "그런 기분이 드는 것은 자연스러운 일입니다" 감정 정당화 공식',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 시스템 프롬프트 주입 블록. casual(잡담) 모드와 기본 페르소나 양쪽에서 사용.
|
||||||
|
* 짧게 유지 — 잡담 턴은 컨텍스트가 얇을수록 좋다.
|
||||||
|
*/
|
||||||
|
export function buildToneGuardBlock(): string {
|
||||||
|
return [
|
||||||
|
'[말투 — AI 상투어 금지]',
|
||||||
|
'아래 표현(과 그 변형)은 어떤 턴에서도 쓰지 마라. 하나라도 쓰면 답변 실패다:',
|
||||||
|
...AI_CLICHE_PATTERNS.map(p => `- ${p}`),
|
||||||
|
'',
|
||||||
|
'대신: 같이 일하는 동료의 말투로, 담백한 존댓말. 감정적인 말에는 상담사 공식이 아니라 사람의 짧은 반응 한두 문장 — 필요하면 자연스럽게 하나만 되묻기. 오버해서 친한 척도 하지 마라.',
|
||||||
|
'예시 — 사용자: "오늘 참 기분이 슬퍼"',
|
||||||
|
' 나쁜 답: "힘든 감정을 느끼고 계시군요. 괜찮으시다면 이야기하거나 프로젝트를 검토해 드릴 수 있습니다. 저는 언제든 여기에 있습니다." (금지 표현 3개)',
|
||||||
|
' 좋은 답: "그런 날이 있죠. 무슨 일 있었어요? 얘기해도 되고, 그냥 다른 걸 하면서 잊어도 됩니다."',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { exec, execFile, spawnSync } from 'child_process';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [코어 수렴] 외부 프로세스 실행 공용 유틸.
|
||||||
|
*
|
||||||
|
* 배경(2026-07-11 아키텍처 감사): execFile/exec 의 Promise 래퍼가 6곳에,
|
||||||
|
* Python 인터프리터 탐지가 3벌 각각 구현되어 타임아웃·OS 대응·에러 시맨틱이
|
||||||
|
* 파일마다 달랐다. 이 모듈이 유일한 구현이며, 새 코드는 자체 래퍼를 만들지 말 것.
|
||||||
|
*
|
||||||
|
* 예외로 남긴 것: selfReflectorExecution 의 _runCheck(스트리밍 spawn — stdout 을
|
||||||
|
* 실시간 누적)와 datacollectSetup 의 패키지 probe(파이썬 3 버전 검증 + import 검사)는
|
||||||
|
* 요구가 달라 유지하되, 인터프리터 후보/탐지는 이 모듈을 공유한다.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ExecCapture {
|
||||||
|
/** 종료 코드. 실행 자체가 실패(spawn 불가)면 -1. */
|
||||||
|
code: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
/** 타임아웃으로 강제 종료됨. */
|
||||||
|
timedOut: boolean;
|
||||||
|
/** 명령을 찾지 못하는 등 spawn 자체가 실패. */
|
||||||
|
spawnFailed?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** execFile 기반 캡처 실행 — 절대 reject 하지 않고 항상 ExecCapture 를 돌려준다. */
|
||||||
|
export function execFileCapture(
|
||||||
|
cmd: string,
|
||||||
|
args: string[],
|
||||||
|
opts: { timeoutMs?: number; cwd?: string; maxBuffer?: number } = {},
|
||||||
|
): Promise<ExecCapture> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(cmd, args, {
|
||||||
|
timeout: opts.timeoutMs ?? 15_000,
|
||||||
|
cwd: opts.cwd,
|
||||||
|
maxBuffer: opts.maxBuffer ?? 1024 * 1024,
|
||||||
|
windowsHide: true,
|
||||||
|
}, (err: any, stdout, stderr) => {
|
||||||
|
resolve({
|
||||||
|
code: err ? (typeof err.code === 'number' ? err.code : -1) : 0,
|
||||||
|
stdout: String(stdout || ''),
|
||||||
|
stderr: String(stderr || ''),
|
||||||
|
timedOut: !!err?.killed,
|
||||||
|
spawnFailed: err?.code === 'ENOENT' || undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 셸 명령 실행 (exec 기반). 실패 시 reject — err.stdout/err.stderr 가 붙는
|
||||||
|
* node 표준 시맨틱 그대로 (health check 의 git 자격증명 판정 등이 의존).
|
||||||
|
*/
|
||||||
|
export function execShell(
|
||||||
|
command: string,
|
||||||
|
opts: { timeoutMs?: number; cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec(command, {
|
||||||
|
timeout: opts.timeoutMs ?? 15_000,
|
||||||
|
cwd: opts.cwd,
|
||||||
|
env: opts.env,
|
||||||
|
windowsHide: true,
|
||||||
|
}, (err, stdout, stderr) => {
|
||||||
|
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
||||||
|
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Python 인터프리터 탐지 (단일 구현·공유 캐시) ────────────────────────────
|
||||||
|
// OS별 후보 순서: 윈도우는 python/py 가 표준, 그 외는 python3 가 안전.
|
||||||
|
export const PYTHON_CANDIDATES: readonly string[] = process.platform === 'win32'
|
||||||
|
? ['python', 'py', 'python3']
|
||||||
|
: ['python3', 'python'];
|
||||||
|
|
||||||
|
let _pythonCmd: string | null | undefined;
|
||||||
|
|
||||||
|
/** 비동기 탐지 — 프로세스 생존 동안 캐시. null = 미설치. */
|
||||||
|
export async function detectPython(): Promise<string | null> {
|
||||||
|
if (_pythonCmd !== undefined) return _pythonCmd;
|
||||||
|
for (const cmd of PYTHON_CANDIDATES) {
|
||||||
|
const r = await execFileCapture(cmd, ['--version'], { timeoutMs: 3_000 });
|
||||||
|
if (r.code === 0) { _pythonCmd = cmd; return cmd; }
|
||||||
|
}
|
||||||
|
_pythonCmd = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 동기 탐지 (spawnSync) — 동기 컨텍스트(selfReflector _pickTool 등)용. 같은 캐시 공유. */
|
||||||
|
export function detectPythonSync(): string | null {
|
||||||
|
if (_pythonCmd !== undefined) return _pythonCmd;
|
||||||
|
for (const cmd of PYTHON_CANDIDATES) {
|
||||||
|
try {
|
||||||
|
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
|
||||||
|
if (!r.error && (r.status === 0 || r.status === null)) { _pythonCmd = cmd; return cmd; }
|
||||||
|
} catch { /* 다음 후보 */ }
|
||||||
|
}
|
||||||
|
_pythonCmd = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테스트용 — 캐시 초기화. */
|
||||||
|
export function _resetPythonCache(): void { _pythonCmd = undefined; }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* [코어 수렴] KST(Asia/Seoul) 스케줄 공용 유틸.
|
||||||
|
*
|
||||||
|
* 배경(2026-07-11 아키텍처 감사): stocksWatcher·dailyBriefing·sleepDigest·
|
||||||
|
* growthCycleWatcher 네 워처가 각각 nowInKst()/HH:MM 파싱을 복제하고 있었다.
|
||||||
|
* "다음 발사까지 ms" 계산은 정책이 제각각(복수 시각/평일만/매일/주간)이라
|
||||||
|
* 각 워처에 남기고, 여기서는 그 계산의 공통 원료만 단일화한다.
|
||||||
|
*
|
||||||
|
* 시간대는 항상 Asia/Seoul 강제 — 사용자 OS timezone 과 무관하게 같은 시각에 동작.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface KstNow {
|
||||||
|
hour: number;
|
||||||
|
minute: number;
|
||||||
|
/** 'YYYY-MM-DD' — 하루 1회 발사 dedupe 키로 쓰인다. */
|
||||||
|
ymd: string;
|
||||||
|
/** 요일 (0=일 … 6=토). */
|
||||||
|
weekday: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Asia/Seoul 기준 *지금* 의 시각/날짜/요일. */
|
||||||
|
export function nowInKst(): KstNow {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: 'Asia/Seoul',
|
||||||
|
year: 'numeric', month: '2-digit', day: '2-digit',
|
||||||
|
hour: '2-digit', minute: '2-digit', hour12: false,
|
||||||
|
}).formatToParts(new Date());
|
||||||
|
const get = (t: string) => parts.find(p => p.type === t)?.value || '00';
|
||||||
|
const ymd = `${get('year')}-${get('month')}-${get('day')}`;
|
||||||
|
// 'YYYY-MM-DD' → UTC midnight Date — getUTCDay 가 그 날짜의 요일.
|
||||||
|
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
|
||||||
|
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 'HH:MM' 설정 문자열 파싱 — 형식이 틀리면 기본값, 범위는 23:59 로 클램프. */
|
||||||
|
export function parseHhMm(raw: string | undefined, defHour: number, defMinute: number): { hour: number; minute: number } {
|
||||||
|
const m = (raw || '').trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||||
|
if (!m) return { hour: defHour, minute: defMinute };
|
||||||
|
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { tokenize, countConflictIndicators } from './scoring';
|
|||||||
import { detectLessonKind } from './lessonHelpers';
|
import { detectLessonKind } from './lessonHelpers';
|
||||||
import { splitIntoSections } from './chunker';
|
import { splitIntoSections } from './chunker';
|
||||||
import { logInfo } from '../utils';
|
import { logInfo } from '../utils';
|
||||||
|
import { ensureBrainWatcher, getBrainGeneration } from './brainWatch';
|
||||||
|
|
||||||
// v5 adds optional per-file `chunks` (section-level index, Phase 1-가) alongside the
|
// 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
|
// v4 per-file `embedding`. Older indexes are auto-rebuilt on first load — no migration
|
||||||
@@ -109,6 +110,16 @@ interface BrainState {
|
|||||||
diskPath: string | null; // null if we can't determine a writable path
|
diskPath: string | null; // null if we can't determine a writable path
|
||||||
writeTimer?: ReturnType<typeof setTimeout>;
|
writeTimer?: ReturnType<typeof setTimeout>;
|
||||||
evictTimer?: ReturnType<typeof setTimeout>;
|
evictTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
/**
|
||||||
|
* [v2.2.311] statSync 생략 최적화의 장부. statValidatedGen 세대에서 실제로
|
||||||
|
* stat(또는 재색인)을 거친 파일만 statValidated 에 들어간다 — 세대가 그대로면
|
||||||
|
* 그 파일들은 재-stat 없이 캐시를 신뢰한다. 세대가 바뀌면(파일시스템 변경)
|
||||||
|
* 집합을 비워 전 파일이 다시 한 번 stat 을 거친다. 파일 *단위* 로 기록하는
|
||||||
|
* 이유: 스코프 검색(부분 목록) 뒤 전체 검색이 와도, 검증 안 된 파일이
|
||||||
|
* 신뢰 경로로 새지 않게 하기 위함.
|
||||||
|
*/
|
||||||
|
statValidatedGen?: number;
|
||||||
|
statValidated?: Set<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const _states = new Map<string, BrainState>();
|
const _states = new Map<string, BrainState>();
|
||||||
@@ -117,7 +128,16 @@ const _states = new Map<string, BrainState>();
|
|||||||
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
|
// 확장 호스트 메모리에 세션 내내 상주했다. 인덱스는 mtime 키 디스크 캐시에서 언제든 싸게
|
||||||
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
|
// 재로드되므로, 마지막 사용 후 IDLE_EVICT_MS 가 지나면 dirty 를 flush 하고 메모리에서 내린다.
|
||||||
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
|
// 상시 인터벌 없이 접근할 때마다 재예약되는 one-shot unref 타이머라 그 자체의 발열 비용은 없다.
|
||||||
const IDLE_EVICT_MS = 30 * 60_000;
|
// [v2.2.311] 30분 → 2시간: 점심/회의 뒤 첫 질의가 대형 인덱스(수십 MB JSON) 재파싱을
|
||||||
|
// 뒤집어쓰는 빈도를 줄인다. 메모리 절약보다 응답 지연이 더 아픈 로컬-퍼스트 워크로드.
|
||||||
|
const IDLE_EVICT_MS = 120 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.311] stat 생략 신뢰 명부에 올리지 않는 "갓 수정된 파일" 창. fs.watch 이벤트는
|
||||||
|
* 비동기(+디바운스)라, 수정 직후 같은 틱에 들어온 질의는 세대 bump 를 아직 못 본다 —
|
||||||
|
* 그 창 안의 파일은 명부에서 제외해 다음 호출에서도 stat 을 거치게 한다.
|
||||||
|
*/
|
||||||
|
const RECENT_MTIME_GUARD_MS = 3000;
|
||||||
|
|
||||||
function touchEviction(st: BrainState, brainPath: string): void {
|
function touchEviction(st: BrainState, brainPath: string): void {
|
||||||
if (st.evictTimer) clearTimeout(st.evictTimer);
|
if (st.evictTimer) clearTimeout(st.evictTimer);
|
||||||
@@ -206,8 +226,35 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
let reindexed = 0;
|
let reindexed = 0;
|
||||||
|
|
||||||
|
// [v2.2.311] 워처 세대가 마지막 stat 검증 이후 그대로면 — 파일시스템 변경 없음 —
|
||||||
|
// 그 세대에서 이미 stat 을 거친 파일의 statSync 를 생략하고 캐시 항목을 신뢰.
|
||||||
|
// 워처가 없으면(gen === -1) 종전대로 매번 stat — 정확성 저하 없음.
|
||||||
|
ensureBrainWatcher(brainPath);
|
||||||
|
const watchGen = getBrainGeneration(brainPath);
|
||||||
|
if (watchGen >= 0 && st.statValidatedGen !== watchGen) {
|
||||||
|
st.statValidatedGen = watchGen;
|
||||||
|
st.statValidated = new Set();
|
||||||
|
}
|
||||||
|
const validated = watchGen >= 0 ? (st.statValidated ?? (st.statValidated = new Set())) : null;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
seen.add(file);
|
seen.add(file);
|
||||||
|
const cachedFast = validated && validated.has(file) ? st.index.entries[file] : undefined;
|
||||||
|
if (cachedFast
|
||||||
|
&& Array.isArray(cachedFast.tokens)
|
||||||
|
&& Array.isArray(cachedFast.titleTokens)) {
|
||||||
|
out.push({
|
||||||
|
filePath: file,
|
||||||
|
relativePath: cachedFast.relativePath,
|
||||||
|
title: cachedFast.title,
|
||||||
|
tokens: cachedFast.tokens,
|
||||||
|
titleTokens: cachedFast.titleTokens,
|
||||||
|
conflictCount: cachedFast.conflictCount || 0,
|
||||||
|
mtimeMs: cachedFast.mtimeMs,
|
||||||
|
kind: cachedFast.kind || '',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let stat: fs.Stats;
|
let stat: fs.Stats;
|
||||||
try {
|
try {
|
||||||
stat = fs.statSync(file);
|
stat = fs.statSync(file);
|
||||||
@@ -220,6 +267,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
|||||||
&& cached.size === stat.size
|
&& cached.size === stat.size
|
||||||
&& Array.isArray(cached.tokens)
|
&& Array.isArray(cached.tokens)
|
||||||
&& Array.isArray(cached.titleTokens)) {
|
&& Array.isArray(cached.titleTokens)) {
|
||||||
|
if (Date.now() - stat.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||||
out.push({
|
out.push({
|
||||||
filePath: file,
|
filePath: file,
|
||||||
relativePath: cached.relativePath,
|
relativePath: cached.relativePath,
|
||||||
@@ -255,6 +303,7 @@ export function getBrainTokenIndex(brainPath: string, files: string[]): IndexedB
|
|||||||
st.index.entries[file] = entry;
|
st.index.entries[file] = entry;
|
||||||
st.dirty = true;
|
st.dirty = true;
|
||||||
reindexed++;
|
reindexed++;
|
||||||
|
if (Date.now() - entry.mtimeMs > RECENT_MTIME_GUARD_MS) validated?.add(file);
|
||||||
out.push({
|
out.push({
|
||||||
filePath: file,
|
filePath: file,
|
||||||
relativePath,
|
relativePath,
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* ============================================================
|
||||||
|
* Brain Watch — 제2뇌 파일시스템 워처 + 상주 파일 목록 캐시 (v2.2.311)
|
||||||
|
*
|
||||||
|
* 문제: 검색은 이미 mtime 키 토큰 인덱스(brain-index.json)로 파일 *내용* 재파싱을
|
||||||
|
* 피하지만, 매 질의마다 (a) 전체 디렉터리 재귀 워크(수천 파일)와 (b) 파일별
|
||||||
|
* fs.statSync 수천 회를 다시 수행했다 — 인덱스가 신선한지 확인할 방법이 "전부
|
||||||
|
* 다시 stat" 뿐이었기 때문. 브레인 6,500 파일 기준 턴당 0.5~1.5초.
|
||||||
|
*
|
||||||
|
* 해결: 브레인 루트에 재귀 fs.watch 를 하나 걸고 변경 시 세대(generation) 카운터만
|
||||||
|
* 올린다. 세대가 그대로면 — 파일 목록도, mtime 도 안 변한 것 — 워크/stat 을 통째로
|
||||||
|
* 생략하고 메모리의 목록·인덱스를 그대로 신뢰한다. 이것이 "제2뇌를 항상 메모리에
|
||||||
|
* 올려놓기"의 안전한 형태다: 원본 파일을 통째로 상주시키는 대신(수백 MB), 검색에
|
||||||
|
* 실제로 쓰는 파생물(토큰 인덱스 + 파일 목록)을 상주시키고 워처가 무효화를 맡는다.
|
||||||
|
*
|
||||||
|
* 워처를 못 여는 환경(권한/네트워크 드라이브 등)에서는 generation 이 -1 로 고정되고
|
||||||
|
* 모든 호출자는 종전 동작(매번 stat)으로 폴백한다 — 정확성은 워처 가용성과 무관.
|
||||||
|
* ============================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { findBrainFiles, invalidateBrainFilesCache, logInfo } from '../utils';
|
||||||
|
|
||||||
|
interface WatchState {
|
||||||
|
watcher: fs.FSWatcher | null;
|
||||||
|
/** 파일시스템 변경마다 증가. 워처 없으면 상태 자체가 failed=true. */
|
||||||
|
generation: number;
|
||||||
|
failed: boolean;
|
||||||
|
/** generation 기준 파일 목록 캐시. */
|
||||||
|
listGeneration: number;
|
||||||
|
list: string[] | null;
|
||||||
|
/** 이벤트 폭주(대량 sync) 시 bump 를 묶는 디바운스 타이머. */
|
||||||
|
bumpTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const _watches = new Map<string, WatchState>();
|
||||||
|
|
||||||
|
/** 인덱스 캐시 등 우리 자신의 산출물 경로 — 이 아래 변경은 무효화 사유가 아니다. */
|
||||||
|
function isSelfNoise(relPath: string): boolean {
|
||||||
|
// .astra (brain-index.json, eval, growth …) 와 .git 은 검색 대상도 아니고
|
||||||
|
// 턴마다 우리가 직접 쓰는 곳이라, 여기서 세대를 올리면 캐시가 영원히 못 산다.
|
||||||
|
return /(^|[\\/])(\.astra|\.git)([\\/]|$)/.test(relPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bump(st: WatchState, brainPath: string): void {
|
||||||
|
if (st.bumpTimer) return; // 이미 예약됨 — 몰아서 1회
|
||||||
|
st.bumpTimer = setTimeout(() => {
|
||||||
|
st.bumpTimer = null;
|
||||||
|
st.generation++;
|
||||||
|
st.list = null;
|
||||||
|
invalidateBrainFilesCache(brainPath);
|
||||||
|
}, 300);
|
||||||
|
if (typeof (st.bumpTimer as any).unref === 'function') (st.bumpTimer as any).unref();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 브레인 루트에 워처를 보장한다 (idempotent). 실패해도 throw 하지 않는다 —
|
||||||
|
* 이후 getBrainGeneration() 이 -1 을 반환해 호출자들이 stat 경로로 폴백.
|
||||||
|
*/
|
||||||
|
export function ensureBrainWatcher(brainPath: string): void {
|
||||||
|
if (!brainPath) return;
|
||||||
|
if (_watches.has(brainPath)) return;
|
||||||
|
const st: WatchState = { watcher: null, generation: 0, failed: false, listGeneration: -1, list: null, bumpTimer: null };
|
||||||
|
_watches.set(brainPath, st);
|
||||||
|
try {
|
||||||
|
// recursive fs.watch 는 Windows/macOS 에서 네이티브 지원. (Linux 는 Node 20+.)
|
||||||
|
st.watcher = fs.watch(brainPath, { recursive: true }, (_event, filename) => {
|
||||||
|
const rel = filename ? String(filename) : '';
|
||||||
|
if (rel && isSelfNoise(rel)) return;
|
||||||
|
bump(st, brainPath);
|
||||||
|
});
|
||||||
|
st.watcher.on('error', () => {
|
||||||
|
st.failed = true;
|
||||||
|
try { st.watcher?.close(); } catch { /* already closed */ }
|
||||||
|
st.watcher = null;
|
||||||
|
logInfo('Brain watcher failed — falling back to per-query stat.', { brainPath });
|
||||||
|
});
|
||||||
|
logInfo('Brain watcher started — file list & stat checks now cached until change.', { brainPath });
|
||||||
|
} catch (e: any) {
|
||||||
|
st.failed = true;
|
||||||
|
st.watcher = null;
|
||||||
|
logInfo('Brain watcher unavailable — falling back to per-query stat.', { brainPath, error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 세대 번호. 워처가 없거나 죽었으면 -1 — 호출자는 캐시 신뢰 최적화를 꺼야 한다.
|
||||||
|
*/
|
||||||
|
export function getBrainGeneration(brainPath: string): number {
|
||||||
|
const st = _watches.get(brainPath);
|
||||||
|
if (!st || st.failed || !st.watcher) return -1;
|
||||||
|
return st.generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 세대 기반 파일 목록 — 세대가 그대로면 디렉터리 워크 없이 메모리 목록을 반환.
|
||||||
|
* 워처가 없으면 findBrainFiles(5초 TTL)로 폴백해 종전과 동일하게 동작.
|
||||||
|
*/
|
||||||
|
export function listBrainFilesCached(brainPath: string): string[] {
|
||||||
|
if (!brainPath) return [];
|
||||||
|
ensureBrainWatcher(brainPath);
|
||||||
|
const st = _watches.get(brainPath)!;
|
||||||
|
if (st.failed || !st.watcher) return findBrainFiles(brainPath);
|
||||||
|
if (st.list !== null && st.listGeneration === st.generation) return st.list.slice();
|
||||||
|
const files = findBrainFiles(brainPath);
|
||||||
|
st.list = files.slice();
|
||||||
|
st.listGeneration = st.generation;
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화 직후 백그라운드 워밍 — 첫 질의가 78MB 인덱스 파싱 + 전체 워크를
|
||||||
|
* 뒤집어쓰지 않도록 미리 올려 둔다. 실패는 조용히 무시 (다음 질의가 정상 경로).
|
||||||
|
*/
|
||||||
|
export function warmBrainCache(brainPath: string, warmTokenIndex: (files: string[]) => void): void {
|
||||||
|
if (!brainPath) return;
|
||||||
|
setTimeout(() => {
|
||||||
|
try {
|
||||||
|
const t0 = Date.now();
|
||||||
|
const files = listBrainFilesCached(brainPath);
|
||||||
|
warmTokenIndex(files);
|
||||||
|
logInfo('Brain cache warmed.', { brainPath, files: files.length, ms: Date.now() - t0 });
|
||||||
|
} catch (e: any) {
|
||||||
|
logInfo('Brain cache warm failed (non-fatal).', { brainPath, error: e?.message ?? String(e) });
|
||||||
|
}
|
||||||
|
}, 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 테스트/브레인 전환용 — 워처 해제. */
|
||||||
|
export function disposeBrainWatcher(brainPath?: string): void {
|
||||||
|
const targets = brainPath ? [brainPath] : Array.from(_watches.keys());
|
||||||
|
for (const key of targets) {
|
||||||
|
const st = _watches.get(key);
|
||||||
|
if (!st) continue;
|
||||||
|
if (st.bumpTimer) { clearTimeout(st.bumpTimer); st.bumpTimer = null; }
|
||||||
|
try { st.watcher?.close(); } catch { /* noop */ }
|
||||||
|
_watches.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,22 +16,12 @@ const DEFAULT_BUDGET: ContextBudgetConfig = {
|
|||||||
maxChunks: 12
|
maxChunks: 12
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
// [코어 수렴] 토큰 추정은 lib/contextManager 의 단일 구현을 쓴다 (CJK 전반 커버·
|
||||||
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
|
// 보수적 계수). 예전 이 파일의 자체 추정(한글만 1.5/0.25)은 제거 — 기존 소비자
|
||||||
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
|
// 호환을 위해 같은 이름으로 재수출.
|
||||||
*/
|
import { estimateTokens } from '../lib/contextManager';
|
||||||
export function estimateTokens(text: string): number {
|
export { estimateTokens };
|
||||||
// 한국어 비율 추정
|
|
||||||
const koreanChars = (text.match(/[가-힣]/g) || []).length;
|
|
||||||
const totalChars = text.length;
|
|
||||||
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
|
|
||||||
|
|
||||||
// 한국어는 글자당 ~1.5 토큰, 영어는 ~0.25 토큰
|
|
||||||
const koreanTokens = koreanChars * 1.5;
|
|
||||||
const otherTokens = (totalChars - koreanChars) * 0.25;
|
|
||||||
|
|
||||||
return Math.ceil(koreanTokens + otherTokens);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
|
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { getConfig } from '../config';
|
import { getConfig } from '../config';
|
||||||
|
import { logInfo } from '../utils';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 단계별 지식 스코프 라우터 — 소형 모델 정밀 검색의 핵심.
|
* 단계별 지식 스코프 라우터 — 소형 모델 정밀 검색의 핵심.
|
||||||
@@ -147,8 +148,12 @@ export function resolveDomainScope(query: string, brainRoot: string): DomainScop
|
|||||||
try { if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) valid.push(abs); } catch { /* skip */ }
|
try { if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) valid.push(abs); } catch { /* skip */ }
|
||||||
}
|
}
|
||||||
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
|
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
|
||||||
|
// [조용한 폴백 금지] 설정은 돼 있는데 폴더가 없으면(예: 지식 재편 후 옛 경로) 로그로 드러낸다.
|
||||||
const specialtyAbs = path.resolve(rootAbs, relFolders[relFolders.length - 1]);
|
const specialtyAbs = path.resolve(rootAbs, relFolders[relFolders.length - 1]);
|
||||||
if (!valid.includes(specialtyAbs)) return none;
|
if (!valid.includes(specialtyAbs)) {
|
||||||
|
logInfo('domain-knowledge: Specialty 폴더가 존재하지 않아 전체 두뇌로 폴백합니다. 설정 경로를 확인하세요.', { domain, folder: relFolders[relFolders.length - 1] });
|
||||||
|
return none;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
domain,
|
domain,
|
||||||
|
|||||||
+40
-2
@@ -15,7 +15,8 @@
|
|||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { BrainProfile } from '../config';
|
import { BrainProfile } from '../config';
|
||||||
import { findBrainFiles, summarizeText } from '../utils';
|
import { summarizeText } from '../utils';
|
||||||
|
import { listBrainFilesCached } from './brainWatch';
|
||||||
import { isInside } from '../lib/paths';
|
import { isInside } from '../lib/paths';
|
||||||
import { MemoryManager } from '../memory';
|
import { MemoryManager } from '../memory';
|
||||||
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
import { RetrievalChunk, RetrievalResult, ContextBudgetConfig } from './types';
|
||||||
@@ -228,6 +229,42 @@ export class RetrievalOrchestrator {
|
|||||||
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
||||||
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
|
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* [코어 수렴] 스코프 지정 두뇌 검색 — 기업 모드(dispatcher) 등 "메모리 계층 없이
|
||||||
|
* 두뇌만" 필요한 소비자용 공개 경로. 프로덕션 retrieve() 와 동일한 searchBrainFiles
|
||||||
|
* (임베딩 하이브리드·actionability·청크 검색 포함)를 태우므로, 경량 scopedBrainRetriever
|
||||||
|
* 와 달리 오케스트레이터의 검색 개선을 그대로 받는다.
|
||||||
|
*/
|
||||||
|
public retrieveBrainChunksScoped(
|
||||||
|
query: string,
|
||||||
|
brain: BrainProfile,
|
||||||
|
opts: {
|
||||||
|
limit?: number;
|
||||||
|
scopeFolders?: string[];
|
||||||
|
includeRawConversations?: boolean;
|
||||||
|
queryEmbedding?: number[];
|
||||||
|
embeddingModel?: string;
|
||||||
|
embeddingBlendAlpha?: number;
|
||||||
|
chunkLevelRetrieval?: boolean;
|
||||||
|
chunkTargetChars?: number;
|
||||||
|
} = {},
|
||||||
|
): RetrievalChunk[] {
|
||||||
|
const expandedTokens = expandQuery(tokenize(query));
|
||||||
|
return this.searchBrainFiles(
|
||||||
|
query,
|
||||||
|
expandedTokens,
|
||||||
|
brain,
|
||||||
|
opts.limit ?? 6,
|
||||||
|
opts.includeRawConversations ?? false,
|
||||||
|
opts.scopeFolders ?? [],
|
||||||
|
opts.queryEmbedding,
|
||||||
|
opts.embeddingModel,
|
||||||
|
opts.embeddingBlendAlpha,
|
||||||
|
opts.chunkLevelRetrieval || false,
|
||||||
|
opts.chunkTargetChars ?? 1200,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public rankBrainForEval(
|
public rankBrainForEval(
|
||||||
query: string,
|
query: string,
|
||||||
brain: BrainProfile,
|
brain: BrainProfile,
|
||||||
@@ -293,7 +330,8 @@ export class RetrievalOrchestrator {
|
|||||||
try {
|
try {
|
||||||
const scoped = (file: string) => scopeFolders.length === 0
|
const scoped = (file: string) => scopeFolders.length === 0
|
||||||
|| scopeFolders.some((folder) => isInside(folder, file));
|
|| scopeFolders.some((folder) => isInside(folder, file));
|
||||||
const allFiles = findBrainFiles(brain.localBrainPath)
|
// [v2.2.311] 워처 세대 기반 상주 목록 — 변경 없으면 디렉터리 재귀 워크 생략.
|
||||||
|
const allFiles = listBrainFilesCached(brain.localBrainPath)
|
||||||
.filter(scoped)
|
.filter(scoped)
|
||||||
.filter((file) => {
|
.filter((file) => {
|
||||||
const rel = path.relative(brain.localBrainPath, file);
|
const rel = path.relative(brain.localBrainPath, file);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { RetrievalChunk } from './types';
|
import { RetrievalChunk } from './types';
|
||||||
|
import { coreChat } from '../core/services';
|
||||||
|
|
||||||
export interface SemanticRerankOptions {
|
export interface SemanticRerankOptions {
|
||||||
ollamaUrl: string;
|
ollamaUrl: string;
|
||||||
@@ -94,46 +95,15 @@ async function callLlmForRerank(
|
|||||||
user: string,
|
user: string,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 256토큰).
|
||||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
const result = await coreChat({
|
||||||
const controller = new AbortController();
|
system, user, model,
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
timeoutMs,
|
||||||
try {
|
temperature: 0.0,
|
||||||
const body = isOllama
|
maxTokens: 256,
|
||||||
? {
|
|
||||||
model, stream: false,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: system },
|
|
||||||
{ role: 'user', content: user },
|
|
||||||
],
|
|
||||||
options: { temperature: 0.0, num_predict: 256 },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
model,
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: system },
|
|
||||||
{ role: 'user', content: user },
|
|
||||||
],
|
|
||||||
stream: false, temperature: 0.0, max_tokens: 256,
|
|
||||||
};
|
|
||||||
const res = await fetch(endpoint, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
signal: controller.signal,
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
return String(result.content || '');
|
||||||
const data: any = await res.json();
|
|
||||||
const content =
|
|
||||||
data?.message?.content ??
|
|
||||||
data?.choices?.[0]?.message?.content ??
|
|
||||||
data?.choices?.[0]?.text ??
|
|
||||||
data?.response ??
|
|
||||||
'';
|
|
||||||
return String(content || '');
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { getConfig } from '../../config';
|
|||||||
export interface DispatcherDepsInputs {
|
export interface DispatcherDepsInputs {
|
||||||
context: vscode.ExtensionContext;
|
context: vscode.ExtensionContext;
|
||||||
ai: AIService;
|
ai: AIService;
|
||||||
|
/** [코어 수렴] 오케스트레이터 기반 두뇌 검색 thunk (AgentExecutor.retrieveBrainBlockForCompany). */
|
||||||
|
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
onEvent: (event: CompanyTurnEvent) => void;
|
onEvent: (event: CompanyTurnEvent) => void;
|
||||||
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
|
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
|
||||||
@@ -44,6 +46,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
|
|||||||
return {
|
return {
|
||||||
context: inputs.context,
|
context: inputs.context,
|
||||||
ai: inputs.ai,
|
ai: inputs.ai,
|
||||||
|
retrieveBrainContext: inputs.retrieveBrainContext,
|
||||||
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
||||||
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
|
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
|
||||||
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
|
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
|
||||||
|
|||||||
@@ -1868,6 +1868,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
|||||||
const deps = buildDispatcherDeps({
|
const deps = buildDispatcherDeps({
|
||||||
context: this._context,
|
context: this._context,
|
||||||
ai,
|
ai,
|
||||||
|
// [코어 수렴] 기업 모드 검색을 메인 오케스트레이터 경로로 승격.
|
||||||
|
retrieveBrainContext: (q, folders, limit) => this._agent.retrieveBrainBlockForCompany(q, folders, limit),
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
onEvent: emitter.emit,
|
onEvent: emitter.emit,
|
||||||
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
|
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
|
||||||
|
|||||||
+30
-5
@@ -1,4 +1,5 @@
|
|||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { buildToneGuardBlock } from './lib/contextBuilders/toneGuard';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
@@ -282,6 +283,8 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
|
|||||||
|
|
||||||
사용자가 명시적으로 화제를 닫지 않는 한 "다음에 무엇을 도와드릴까요?" 류 마무리 금지.
|
사용자가 명시적으로 화제를 닫지 않는 한 "다음에 무엇을 도와드릴까요?" 류 마무리 금지.
|
||||||
|
|
||||||
|
${buildToneGuardBlock()}
|
||||||
|
|
||||||
[ENGINEERING STANCE]
|
[ENGINEERING STANCE]
|
||||||
- Be a direct engineering partner. Technical precision over polite filler.
|
- Be a direct engineering partner. Technical precision over polite filler.
|
||||||
- Collapse checklists into: verdict → reason → risk → next move. (R1 already requires the verdict to be sentence 1.)
|
- Collapse checklists into: verdict → reason → risk → next move. (R1 already requires the verdict to be sentence 1.)
|
||||||
@@ -491,14 +494,19 @@ function getEnvironmentBlock(): string {
|
|||||||
- Canonical <run_command> example for this OS: ${example}`;
|
- Canonical <run_command> example for this OS: ${example}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSystemPrompt(): string {
|
/**
|
||||||
const now = new Date();
|
* [KV 캐시 분리 v2.2.311] 시스템 프롬프트의 *턴 사이에 변하지 않는* 부분만 반환.
|
||||||
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
* 여기에 분 단위 시각 같은 가변 요소가 섞이면 LM Studio(llama.cpp)의 prompt cache 가
|
||||||
const isoDate = now.toISOString().split('T')[0];
|
* 요청 첫 토큰부터 무효화되어 매 턴 1만+ 토큰 전체를 다시 프리필한다 — 실측 94초/턴의
|
||||||
|
* 주범. 날짜/시각은 `getDateTimeContextBlock()` 으로 분리해 요청 후미(동적 컨텍스트
|
||||||
|
* 메시지)에 싣는다.
|
||||||
|
*/
|
||||||
|
export function getStaticSystemPrompt(): string {
|
||||||
const envBlock = getEnvironmentBlock();
|
const envBlock = getEnvironmentBlock();
|
||||||
const base = `${BASE_SYSTEM_PROMPT}\n\n[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
const base = `${BASE_SYSTEM_PROMPT}\n\n[출력 위생 규칙 — 반드시 준수]\n- 자연스러운 한국어로 작성하고, 한 단어 안에 한글과 영문 알파벳을 섞지 마시오 ("결ently", "인orp" 같은 깨진 합성 표기 절대 금지).\n- 외래어·기술 용어는 완전한 한글 표기 또는 완전한 영문 단어 중 하나로 일관되게 쓰시오.\n- 내부 검증·체크 로그(Consistency/Completeness/Accuracy 등) 블록을 사용자 출력에 포함하지 마시오.${envBlock}`;
|
||||||
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
// Self-Reflector Phase A — 사용자 설정이 켜져 있으면 답변 끝에 자기검증
|
||||||
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
// 블록을 강제하는 룰을 prepend. require로 동적 로드해 순환 import 회피.
|
||||||
|
// (설정은 세션 중 바뀔 수 있지만 드물다 — 바뀌면 그 턴만 캐시 미스, 문제 없음.)
|
||||||
try {
|
try {
|
||||||
const { getConfig } = require('./config') as typeof import('./config');
|
const { getConfig } = require('./config') as typeof import('./config');
|
||||||
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
const { appendSelfReflectorRule } = require('./features/selfReflector/selfReflectorPrompt') as typeof import('./features/selfReflector/selfReflectorPrompt');
|
||||||
@@ -510,4 +518,21 @@ export function getSystemPrompt(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [CURRENT DATE/TIME] 블록 — 가변이므로 정적 프롬프트가 아니라 동적 컨텍스트에 주입. */
|
||||||
|
export function getDateTimeContextBlock(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const dateTimeStr = now.toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'long', hour: '2-digit', minute: '2-digit' });
|
||||||
|
const isoDate = now.toISOString().split('T')[0];
|
||||||
|
return `[CURRENT DATE/TIME]\nToday: ${isoDate} (${dateTimeStr})\nUse this date as the absolute reference for any date-related calculations (e.g., "this week", "today", "yesterday").`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSystemPrompt(): string {
|
||||||
|
// 기존 호출부 호환 — 정적 본문 + 날짜 블록을 합친 종전 형태 그대로.
|
||||||
|
const staticPart = getStaticSystemPrompt();
|
||||||
|
const dateBlock = getDateTimeContextBlock();
|
||||||
|
// BASE 직후·위생 규칙 앞이던 종전 위치와 달리 끝에 붙지만, 단독 사용처(브리핑·평가
|
||||||
|
// 등 1회성 호출)에는 위치가 의미 없다. 채팅 본류는 split 경로를 쓴다.
|
||||||
|
return `${staticPart}\n\n${dateBlock}`;
|
||||||
|
}
|
||||||
|
|
||||||
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
export const SYSTEM_PROMPT = BASE_SYSTEM_PROMPT;
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { buildSynthesisPrompt } from '../src/features/datacollect/prompts/synthesisPrompt';
|
||||||
|
|
||||||
|
// handlers.ts 는 vscode·bridge 의존이 커서 순수 함수만 골라 검증한다
|
||||||
|
// (vscode 는 전역 moduleNameMapper mock 이 처리 — 로컬 jest.mock 금지).
|
||||||
|
import { parseBenchmarkArgs, BENCHMARK_MODE_PARTS } from '../src/features/datacollect/handlers';
|
||||||
|
|
||||||
|
const SCAN = {
|
||||||
|
url: 'https://interiorai.com',
|
||||||
|
meta: { title: 'InteriorAI', description: 'Fire your interior designer', lang: 'en' },
|
||||||
|
design: { colors: { palette: [{ value: 'rgb(10,10,10)', count: 40 }] }, typography: { primaryFont: 'Inter' } },
|
||||||
|
microcopy: { headline: 'Fire your interior designer', ctaSamples: ['Get started $29/mo'] },
|
||||||
|
structure: { h1: 'Fire your interior designer' },
|
||||||
|
sitemap: { totalPages: 3, crawlDepth: 1, ascii: '/', pages: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('parseBenchmarkArgs — 모드 파싱', () => {
|
||||||
|
test('기본 모드는 full (디자인+비즈니스)', () => {
|
||||||
|
const r = parseBenchmarkArgs('https://a.com depth=2 pages=10 부동산 쪽 변형');
|
||||||
|
expect(r).toMatchObject({ url: 'https://a.com', depth: 2, pages: 10, mode: 'full', userContent: '부동산 쪽 변형' });
|
||||||
|
});
|
||||||
|
test('bare 키워드와 mode= 표기를 모두 받는다', () => {
|
||||||
|
expect(parseBenchmarkArgs('https://a.com build').mode).toBe('build');
|
||||||
|
expect(parseBenchmarkArgs('https://a.com mode=biz').mode).toBe('biz');
|
||||||
|
expect(parseBenchmarkArgs('https://a.com mode:design').mode).toBe('design');
|
||||||
|
});
|
||||||
|
test('모드 키워드는 보조 설명에 섞이지 않는다', () => {
|
||||||
|
const r = parseBenchmarkArgs('https://a.com biz 인테리어 시장 분석');
|
||||||
|
expect(r.mode).toBe('biz');
|
||||||
|
expect(r.userContent).toBe('인테리어 시장 분석');
|
||||||
|
});
|
||||||
|
test('URL 없으면 url 빈 문자열', () => {
|
||||||
|
expect(parseBenchmarkArgs('design').url).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('BENCHMARK_MODE_PARTS — 모드별 파트 구성', () => {
|
||||||
|
test('full=1~4, design=1~3, biz=4만, build=1~5', () => {
|
||||||
|
expect(BENCHMARK_MODE_PARTS.full).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(BENCHMARK_MODE_PARTS.design).toEqual([1, 2, 3]);
|
||||||
|
expect(BENCHMARK_MODE_PARTS.biz).toEqual([4]);
|
||||||
|
expect(BENCHMARK_MODE_PARTS.build).toEqual([1, 2, 3, 4, 5]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSynthesisPrompt — 파트 4·5 템플릿', () => {
|
||||||
|
test('파트 4는 비즈니스 렌즈 섹션(포지셔닝·가격·MVP·운영)을 요구한다', () => {
|
||||||
|
const p = buildSynthesisPrompt(SCAN, '', 4);
|
||||||
|
expect(p).toContain('10. 서비스 정의 & 포지셔닝');
|
||||||
|
expect(p).toContain('11. 가격 & 수익 구조');
|
||||||
|
expect(p).toContain('12. 기능 인벤토리');
|
||||||
|
expect(p).toContain('13. 운영 & 방향성 제안');
|
||||||
|
expect(p).toContain('MVP 컷');
|
||||||
|
expect(p).toContain('손익분기');
|
||||||
|
// 날조 금지 — 스캔에 없는 가격을 지어내지 않도록.
|
||||||
|
expect(p).toContain('스캔 데이터 부족');
|
||||||
|
});
|
||||||
|
test('파트 4에서만 사용자 컨텍스트 결합 변형을 지시한다', () => {
|
||||||
|
const p4 = buildSynthesisPrompt(SCAN, '부동산 중개용으로 변형', 4);
|
||||||
|
expect(p4).toContain('사용자 컨텍스트 결합 변형');
|
||||||
|
expect(p4).toContain('부동산 중개용으로 변형');
|
||||||
|
const p3 = buildSynthesisPrompt(SCAN, '부동산 중개용으로 변형', 3);
|
||||||
|
expect(p3).toContain('재해석·확장·전환하지 말 것'); // 원본 복원 원칙은 그대로
|
||||||
|
});
|
||||||
|
test('파트 5는 자기완결적 스텝 프롬프트와 실측 토큰 명시를 요구한다', () => {
|
||||||
|
const p = buildSynthesisPrompt(SCAN, '', 5);
|
||||||
|
expect(p).toContain('단계별 빌드 프롬프트');
|
||||||
|
expect(p).toContain('STEP 1');
|
||||||
|
expect(p).toContain('STEP 4');
|
||||||
|
expect(p).toContain('자기완결적');
|
||||||
|
expect(p).toContain('빌드 순서 요약');
|
||||||
|
});
|
||||||
|
test('파트 1~3 기존 템플릿은 변하지 않았다', () => {
|
||||||
|
expect(buildSynthesisPrompt(SCAN, '', 1)).toContain('1. 시각적 정체성');
|
||||||
|
expect(buildSynthesisPrompt(SCAN, '', 2)).toContain('8. 페이지 템플릿 맵');
|
||||||
|
expect(buildSynthesisPrompt(SCAN, '', 3)).toContain('9. 원본 사이트 재구축 명세');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* [v2.2.311] 제2뇌 상주 캐시 계층 — 워처 세대 기반 stat 생략 + 파일 목록 캐시.
|
||||||
|
*
|
||||||
|
* 핵심 계약:
|
||||||
|
* 1. 워처 세대가 그대로면 getBrainTokenIndex 는 파일별 statSync 를 생략한다.
|
||||||
|
* 2. 세대가 바뀌면(파일시스템 변경) 다시 stat 을 거쳐 변경분을 재색인한다.
|
||||||
|
* 3. 부분(스코프) 목록으로 검증된 상태에서 전체 목록 질의가 와도, 검증 안 된
|
||||||
|
* 파일은 신뢰 경로로 새지 않는다 (파일 단위 검증 장부).
|
||||||
|
* 4. 워처 불가 환경(gen === -1)에서는 종전과 동일하게 매번 stat.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
// brainWatch 를 결정적으로 제어 — 실제 fs.watch 이벤트 타이밍에 의존하지 않는다.
|
||||||
|
let mockGen = 0;
|
||||||
|
jest.mock('../src/retrieval/brainWatch', () => ({
|
||||||
|
ensureBrainWatcher: jest.fn(),
|
||||||
|
getBrainGeneration: jest.fn(() => mockGen),
|
||||||
|
listBrainFilesCached: jest.fn(),
|
||||||
|
warmBrainCache: jest.fn(),
|
||||||
|
disposeBrainWatcher: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getBrainTokenIndex } from '../src/retrieval/brainIndex';
|
||||||
|
|
||||||
|
function makeBrain(files: Record<string, string>): { root: string; paths: string[] } {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'astra-brain-'));
|
||||||
|
const paths: string[] = [];
|
||||||
|
for (const [name, content] of Object.entries(files)) {
|
||||||
|
const p = path.join(root, name);
|
||||||
|
fs.writeFileSync(p, content, 'utf8');
|
||||||
|
// RECENT_MTIME_GUARD(3초): 갓 수정된 파일은 신뢰 명부에 오르지 않으므로,
|
||||||
|
// "변경 없음" 시나리오를 테스트하려면 mtime 을 과거로 되돌린다.
|
||||||
|
const past = new Date(Date.now() - 60_000);
|
||||||
|
fs.utimesSync(p, past, past);
|
||||||
|
paths.push(p);
|
||||||
|
}
|
||||||
|
return { root, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
function countStatsFor(paths: string[], spy: jest.SpyInstance): number {
|
||||||
|
return spy.mock.calls.filter((c) => paths.includes(String(c[0]))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('getBrainTokenIndex — 워처 세대 기반 stat 생략', () => {
|
||||||
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
|
||||||
|
test('같은 세대의 2번째 호출은 statSync 0회 (캐시 신뢰)', () => {
|
||||||
|
mockGen = 0;
|
||||||
|
const { root, paths } = makeBrain({ 'a.md': '알파 내용', 'b.md': '베타 내용' });
|
||||||
|
expect(getBrainTokenIndex(root, paths)).toHaveLength(2);
|
||||||
|
|
||||||
|
const spy = jest.spyOn(fs, 'statSync');
|
||||||
|
const r2 = getBrainTokenIndex(root, paths);
|
||||||
|
expect(r2).toHaveLength(2);
|
||||||
|
expect(countStatsFor(paths, spy)).toBe(0);
|
||||||
|
expect(r2.map((d) => d.title).sort()).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('세대가 바뀌면 재-stat 후 변경 내용을 재색인한다', () => {
|
||||||
|
mockGen = 0;
|
||||||
|
const { root, paths } = makeBrain({ 'a.md': '원본 토큰들' });
|
||||||
|
getBrainTokenIndex(root, paths);
|
||||||
|
|
||||||
|
// 파일 변경 + 세대 bump (mtime 차이를 보장하기 위해 utimesSync 로 강제)
|
||||||
|
fs.writeFileSync(paths[0], '완전히 새로운 내용입니다', 'utf8');
|
||||||
|
const future = new Date(Date.now() + 5000);
|
||||||
|
fs.utimesSync(paths[0], future, future);
|
||||||
|
mockGen = 1;
|
||||||
|
|
||||||
|
const spy = jest.spyOn(fs, 'statSync');
|
||||||
|
const r = getBrainTokenIndex(root, paths);
|
||||||
|
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||||
|
expect(r[0].tokens).toEqual(expect.arrayContaining(['새로운']));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('스코프 검증 후 전체 질의 — 미검증 파일은 신뢰 경로로 새지 않는다', () => {
|
||||||
|
mockGen = 0;
|
||||||
|
const { root, paths } = makeBrain({ 'in-scope.md': '스코프 안', 'out-scope.md': '스코프 밖' });
|
||||||
|
const [inScope, outScope] = paths;
|
||||||
|
getBrainTokenIndex(root, [inScope]); // 부분 목록만 검증됨
|
||||||
|
|
||||||
|
const spy = jest.spyOn(fs, 'statSync');
|
||||||
|
getBrainTokenIndex(root, paths); // 전체 질의
|
||||||
|
expect(countStatsFor([inScope], spy)).toBe(0); // 검증된 파일은 생략
|
||||||
|
expect(countStatsFor([outScope], spy)).toBeGreaterThan(0); // 미검증 파일은 stat
|
||||||
|
});
|
||||||
|
|
||||||
|
test('워처 불가(gen=-1) 환경은 매번 stat (종전 동작)', () => {
|
||||||
|
mockGen = -1;
|
||||||
|
const { root, paths } = makeBrain({ 'a.md': '내용' });
|
||||||
|
getBrainTokenIndex(root, paths);
|
||||||
|
const spy = jest.spyOn(fs, 'statSync');
|
||||||
|
getBrainTokenIndex(root, paths);
|
||||||
|
expect(countStatsFor(paths, spy)).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { flattenClaudePrompt, buildClaudeCliArgs, extractFromStreamJson, listClaudeCodeModels } from '../src/features/providers/claudeCode';
|
||||||
|
import { parseModelPrefix, makeModelId } from '../src/features/providers/types';
|
||||||
|
|
||||||
|
describe('claude: 프리픽스 라우팅', () => {
|
||||||
|
test('claude:sonnet → claude-code 프로바이더로 라우팅', () => {
|
||||||
|
expect(parseModelPrefix('claude:sonnet')).toEqual({ provider: 'claude-code', model: 'sonnet' });
|
||||||
|
});
|
||||||
|
test('makeModelId 는 프리픽스 테이블을 따른다 (claude-code → claude:)', () => {
|
||||||
|
expect(makeModelId('claude-code', 'sonnet')).toBe('claude:sonnet');
|
||||||
|
expect(makeModelId('anthropic', 'claude-opus-4-8')).toBe('anthropic:claude-opus-4-8');
|
||||||
|
});
|
||||||
|
test('로컬 모델은 여전히 null (로컬 엔진 경로)', () => {
|
||||||
|
expect(parseModelPrefix('gemma4:e2b')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('flattenClaudePrompt — 메시지 평탄화', () => {
|
||||||
|
test('시스템 블록·화자 라벨·도구 금지 지시를 포함한다', () => {
|
||||||
|
const p = flattenClaudePrompt([
|
||||||
|
{ role: 'system', content: '너는 아스트라다.' },
|
||||||
|
{ role: 'user', content: '안녕' },
|
||||||
|
{ role: 'assistant', content: '반갑습니다' },
|
||||||
|
{ role: 'user', content: '이어서 질문' },
|
||||||
|
]);
|
||||||
|
expect(p).toContain('<system_instructions>\n너는 아스트라다.\n</system_instructions>');
|
||||||
|
expect(p).toContain('도구를 사용하지 말고');
|
||||||
|
expect(p).toContain('[사용자]\n안녕');
|
||||||
|
expect(p).toContain('[아스트라]\n반갑습니다');
|
||||||
|
expect(p.indexOf('[사용자]\n안녕')).toBeLessThan(p.indexOf('[사용자]\n이어서 질문'));
|
||||||
|
});
|
||||||
|
test('시스템 없는 단일 user 도 동작', () => {
|
||||||
|
expect(flattenClaudePrompt([{ role: 'user', content: 'hi' }])).toBe('[사용자]\nhi');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildClaudeCliArgs', () => {
|
||||||
|
test('headless stream-json + 모델 지정', () => {
|
||||||
|
expect(buildClaudeCliArgs('sonnet')).toEqual(['-p', '--output-format', 'stream-json', '--verbose', '--model', 'sonnet']);
|
||||||
|
});
|
||||||
|
test('모델 비면 --model 생략 (CLI 기본 모델)', () => {
|
||||||
|
expect(buildClaudeCliArgs('')).toEqual(['-p', '--output-format', 'stream-json', '--verbose']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('extractFromStreamJson — CLI 출력 파싱', () => {
|
||||||
|
test('assistant 이벤트의 text 블록을 이어붙인다', () => {
|
||||||
|
const lines = [
|
||||||
|
'{"type":"system","subtype":"init"}',
|
||||||
|
'{"type":"assistant","message":{"content":[{"type":"text","text":"안녕"},{"type":"text","text":"하세요"}]}}',
|
||||||
|
'{"type":"result","subtype":"success","result":"안녕하세요"}',
|
||||||
|
];
|
||||||
|
expect(extractFromStreamJson(lines)).toEqual({ text: '안녕하세요', error: '' });
|
||||||
|
});
|
||||||
|
test('assistant 이벤트가 없으면 result 본문을 사용 (버전 차이 대응)', () => {
|
||||||
|
const lines = ['{"type":"result","subtype":"success","result":"본문"}'];
|
||||||
|
expect(extractFromStreamJson(lines).text).toBe('본문');
|
||||||
|
});
|
||||||
|
test('에러 result 는 error 로 추출', () => {
|
||||||
|
const lines = ['{"type":"result","subtype":"error_during_execution","is_error":true,"result":"Invalid API key"}'];
|
||||||
|
const r = extractFromStreamJson(lines);
|
||||||
|
expect(r.text).toBe('');
|
||||||
|
expect(r.error).toContain('Invalid API key');
|
||||||
|
});
|
||||||
|
test('JSON 아닌 라인·깨진 라인은 무시', () => {
|
||||||
|
expect(extractFromStreamJson(['잡음', '{broken', ''])).toEqual({ text: '', error: '' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listClaudeCodeModels', () => {
|
||||||
|
test('구독 별칭 3종', () => {
|
||||||
|
expect(listClaudeCodeModels()).toEqual(['sonnet', 'opus', 'haiku']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -56,3 +56,92 @@ describe('computeBudgetedRequest — real-window alignment', () => {
|
|||||||
expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768);
|
expect(run({ actualContextLength: NaN }).effectiveContextLength).toBe(32768);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// [v2.2.311] KV 캐시 분리 — dynamicContextTail 삽입 계약.
|
||||||
|
// message[0] 은 불변 정적 프롬프트로 남고, 동적 컨텍스트는 마지막 user 메시지
|
||||||
|
// 직전의 internal system 메시지로 들어가야 프롬프트 프리픽스 캐시가 산다.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
describe('computeBudgetedRequest — dynamicContextTail (KV cache split)', () => {
|
||||||
|
const HEAD = 'STATIC HEAD PROMPT';
|
||||||
|
const TAIL = '[CURRENT DATE/TIME]\nToday: 2026-07-20\n\n[CONTEXT]\nRAG chunk A\n[/CONTEXT]';
|
||||||
|
|
||||||
|
// 주의: default parameter 는 명시적 undefined 에도 적용되므로 쓰지 않는다 —
|
||||||
|
// "tail 없음" 케이스를 정확히 표현하기 위해 항상 명시적으로 넘긴다.
|
||||||
|
function runSplit(reqMessages: ChatMessage[], tail: string | undefined) {
|
||||||
|
return computeBudgetedRequest({
|
||||||
|
fullSystemPrompt: HEAD,
|
||||||
|
dynamicContextTail: tail,
|
||||||
|
reqMessages,
|
||||||
|
actualModel: 'some-13b-model',
|
||||||
|
config: baseConfig,
|
||||||
|
imageCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('tail 은 마지막 user 메시지 직전에 internal system 으로 삽입된다', () => {
|
||||||
|
const msgs: ChatMessage[] = [
|
||||||
|
{ role: 'user', content: '첫 질문' },
|
||||||
|
{ role: 'assistant', content: '첫 답' },
|
||||||
|
{ role: 'user', content: '두번째 질문' },
|
||||||
|
];
|
||||||
|
const r = runSplit(msgs, TAIL);
|
||||||
|
expect(r.messagesForRequest[0]).toMatchObject({ role: 'system', content: HEAD });
|
||||||
|
const idx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||||
|
expect(idx).toBeGreaterThan(0);
|
||||||
|
expect(r.messagesForRequest[idx].role).toBe('system');
|
||||||
|
expect(r.messagesForRequest[idx + 1]).toMatchObject({ role: 'user', content: '두번째 질문' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('continuation(마지막 user 뒤 액션 결과 system)이 있어도 tail 은 user 앞', () => {
|
||||||
|
const msgs: ChatMessage[] = [
|
||||||
|
{ role: 'user', content: '조사해줘' },
|
||||||
|
{ role: 'system', content: '[Result of read_file] ...', internal: true },
|
||||||
|
];
|
||||||
|
const r = runSplit(msgs, TAIL);
|
||||||
|
const tailIdx = r.messagesForRequest.findIndex((m) => m.content === TAIL);
|
||||||
|
const userIdx = r.messagesForRequest.findIndex((m) => m.role === 'user');
|
||||||
|
const actionIdx = r.messagesForRequest.findIndex((m) => String(m.content).startsWith('[Result of'));
|
||||||
|
expect(tailIdx).toBeLessThan(userIdx);
|
||||||
|
expect(actionIdx).toBeGreaterThan(userIdx);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user 메시지가 없으면 tail 을 히스토리 끝에 붙인다', () => {
|
||||||
|
const msgs: ChatMessage[] = [{ role: 'system', content: 'internal only', internal: true }];
|
||||||
|
const r = runSplit(msgs, TAIL);
|
||||||
|
const last = r.messagesForRequest[r.messagesForRequest.length - 1];
|
||||||
|
expect(last.content).toBe(TAIL);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('tail 미지정이면 종전 단일-시스템 동작 그대로', () => {
|
||||||
|
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||||
|
const r = runSplit(msgs, undefined);
|
||||||
|
expect(r.messagesForRequest).toHaveLength(2);
|
||||||
|
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('예산 초과 시 [CONTEXT] truncation 은 tail 에 적용되고 head 는 보존', () => {
|
||||||
|
const hugeTail = `[CONTEXT]\n${'긴 배경 지식 청크. '.repeat(20000)}\n[/CONTEXT]`;
|
||||||
|
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||||
|
const r = computeBudgetedRequest({
|
||||||
|
fullSystemPrompt: HEAD,
|
||||||
|
dynamicContextTail: hugeTail,
|
||||||
|
reqMessages: msgs,
|
||||||
|
actualModel: 'some-13b-model',
|
||||||
|
config: { ...baseConfig, contextLength: 8192 },
|
||||||
|
imageCount: 0,
|
||||||
|
});
|
||||||
|
expect(r.systemTruncated).toBe(true);
|
||||||
|
expect(r.messagesForRequest[0].content).toBe(HEAD);
|
||||||
|
const tailMsg = r.messagesForRequest.find((m) => String(m.content).includes('[CONTEXT]'));
|
||||||
|
expect(tailMsg).toBeDefined();
|
||||||
|
expect(String(tailMsg!.content).length).toBeLessThan(hugeTail.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('systemTokens 는 head+tail 합산으로 보고된다', () => {
|
||||||
|
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
|
||||||
|
const withTail = runSplit(msgs, TAIL);
|
||||||
|
const withoutTail = runSplit(msgs, undefined);
|
||||||
|
expect(withTail.systemTokens).toBeGreaterThan(withoutTail.systemTokens);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { classifyKnowledgeDomain, combineDomainFolders, buildDomainRoutingHint } from '../src/retrieval/domainRouter';
|
import { classifyKnowledgeDomain, combineDomainFolders, buildDomainRoutingHint } from '../src/retrieval/domainRouter';
|
||||||
|
|
||||||
jest.mock('vscode', () => ({ workspace: { getConfiguration: jest.fn() } }), { virtual: true });
|
// vscode 는 jest.config.js 의 moduleNameMapper(tests/mocks/vscode.js)가 전역 mock 으로 처리한다.
|
||||||
|
|
||||||
describe('classifyKnowledgeDomain', () => {
|
describe('classifyKnowledgeDomain', () => {
|
||||||
it('코드 펜스가 있으면 coding 확정', () => {
|
it('코드 펜스가 있으면 coding 확정', () => {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { buildHandoffDoc } from '../src/features/datacollect/prompts/handoffDoc';
|
||||||
|
import { buildClaudeCommand } from '../src/features/claude/claudeLauncher';
|
||||||
|
import { parseBenchmarkArgs, BENCHMARK_MODE_PARTS } from '../src/features/datacollect/handlers';
|
||||||
|
|
||||||
|
const SCAN = {
|
||||||
|
meta: { title: 'InteriorAI', description: 'Fire your interior designer', lang: 'en' },
|
||||||
|
design: {
|
||||||
|
colors: {
|
||||||
|
palette: [{ value: 'rgb(10,10,10)', count: 40 }, { value: 'rgb(255,87,51)', count: 12 }],
|
||||||
|
background: 'rgb(255,255,255)', primaryText: 'rgb(10,10,10)',
|
||||||
|
buttonBackground: 'rgb(255,87,51)', buttonText: 'rgb(255,255,255)',
|
||||||
|
},
|
||||||
|
typography: { primaryFont: 'Inter', h1: { size: '48px', weight: 700 }, body: { size: '16px' } },
|
||||||
|
layout: { bodyMaxWidth: '1200px', sectionSpacing: '96px', borderRadiusScale: ['8px', '16px'] },
|
||||||
|
},
|
||||||
|
interactions: { hoverRules: ['button: background 0.2s ease'] },
|
||||||
|
microcopy: { headline: 'Fire your interior designer', ctaSamples: ['Get started $29/mo'] },
|
||||||
|
structure: { h1: 'Fire your interior designer' },
|
||||||
|
sitemap: {
|
||||||
|
totalPages: 2, crawlDepth: 1, ascii: '/\n└── /pricing',
|
||||||
|
pages: [
|
||||||
|
{ url: '/', role: 'landing', h1: 'Fire your interior designer', primaryContentType: 'hero', imageCount: 6, ctaSamples: ['Get started'] },
|
||||||
|
{ url: '/pricing', role: 'pricing', title: 'Pricing', primaryContentType: 'table', imageCount: 0, formFields: [{ name: 'email', type: 'email' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('buildHandoffDoc — 측정치 무손실 인수인계 문서', () => {
|
||||||
|
const doc = buildHandoffDoc(SCAN, 'https://interiorai.com', { crawlDepth: 1, maxPages: 8, userContent: '' });
|
||||||
|
|
||||||
|
test('측정된 원시 값이 그대로 들어간다 (rgb·px·카피 원문)', () => {
|
||||||
|
expect(doc).toContain('rgb(255,87,51)(×12)');
|
||||||
|
expect(doc).toContain('1200px');
|
||||||
|
expect(doc).toContain('Fire your interior designer');
|
||||||
|
expect(doc).toContain('Get started $29/mo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('클라우드 모델용 지시문과 임의 대체 금지 규칙을 포함한다', () => {
|
||||||
|
expect(doc).toContain('클라우드 모델용 지시문');
|
||||||
|
expect(doc).toContain('측정치를 그대로 사용하라');
|
||||||
|
expect(doc).toContain('추측하지 말고');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('요구사항 미입력 시 채울 체크리스트를 제공한다', () => {
|
||||||
|
expect(doc).toContain('- [ ] 서비스 이름');
|
||||||
|
expect(doc).toContain('- [ ] 뺄 기능');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('사용자 변형 지시가 있으면 요구사항 우선 원칙과 함께 삽입된다', () => {
|
||||||
|
const d2 = buildHandoffDoc(SCAN, 'https://interiorai.com', { crawlDepth: 1, maxPages: 8, userContent: '부동산 중개용으로 변형' });
|
||||||
|
expect(d2).toContain('부동산 중개용으로 변형');
|
||||||
|
expect(d2).toContain('요구사항이 우선한다');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('페이지 인벤토리 표와 원시 JSON 부록을 포함한다', () => {
|
||||||
|
expect(doc).toContain('| /pricing | pricing |');
|
||||||
|
expect(doc).toContain('원시 스캔 데이터 부록');
|
||||||
|
expect(doc).toContain('"primaryFont": "Inter"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빈 스캔에도 문서가 깨지지 않는다', () => {
|
||||||
|
const d = buildHandoffDoc({}, 'https://x.com', { crawlDepth: 0, maxPages: 1, userContent: '' });
|
||||||
|
expect(d).toContain('(스캔 데이터 없음)');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('handoff 모드 파싱·구성', () => {
|
||||||
|
test('handoff 는 bare/mode= 모두 인식되고 합성 파트가 없다', () => {
|
||||||
|
expect(parseBenchmarkArgs('https://a.com handoff').mode).toBe('handoff');
|
||||||
|
expect(parseBenchmarkArgs('https://a.com mode=handoff').mode).toBe('handoff');
|
||||||
|
expect(BENCHMARK_MODE_PARTS.handoff).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildClaudeCommand — 셸 안전 정규화', () => {
|
||||||
|
test('지시문을 큰따옴표 한 줄 인자로 만든다', () => {
|
||||||
|
expect(buildClaudeCommand('파일 읽고 개발해줘')).toBe('claude "파일 읽고 개발해줘"');
|
||||||
|
});
|
||||||
|
test('따옴표·POSIX 확장 문자를 무해화한다', () => {
|
||||||
|
expect(buildClaudeCommand('say "hi" and $HOME `ls`')).toBe(`claude "say 'hi' and HOME ls"`);
|
||||||
|
});
|
||||||
|
test('빈 지시문이면 대화형 실행', () => {
|
||||||
|
expect(buildClaudeCommand(' ')).toBe('claude');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309)
|
||||||
|
* ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
isLocalInvestigationPrompt,
|
||||||
|
buildInvestigationGateBlock,
|
||||||
|
collectInvestigationFiles,
|
||||||
|
runInvestigation,
|
||||||
|
formatInvestigationNotes,
|
||||||
|
detectHollowInvestigation,
|
||||||
|
formatHollowInvestigationFooter,
|
||||||
|
INVESTIGATE_CACHE_REL_DIR,
|
||||||
|
MAX_INVESTIGATION_FILES,
|
||||||
|
} from '../src/intelligence/investigationPipeline';
|
||||||
|
|
||||||
|
function tmpRoot(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => {
|
||||||
|
test('폴더/프로젝트 조사 요청 감지', () => {
|
||||||
|
expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true);
|
||||||
|
expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true);
|
||||||
|
expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true);
|
||||||
|
});
|
||||||
|
test('로컬 대상이 없는 일반 요청은 미감지', () => {
|
||||||
|
expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false);
|
||||||
|
expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님
|
||||||
|
});
|
||||||
|
test('증거 게이트 블록은 감지 시에만 생성', () => {
|
||||||
|
expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files');
|
||||||
|
expect(buildInvestigationGateBlock('안녕')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collectInvestigationFiles — 파일 수집', () => {
|
||||||
|
test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'docs'));
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B');
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A');
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary');
|
||||||
|
const { files, skipped } = collectInvestigationFiles(root, 'docs');
|
||||||
|
expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']);
|
||||||
|
expect(skipped.some(s => s.includes('img.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'many'));
|
||||||
|
for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) {
|
||||||
|
fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i);
|
||||||
|
}
|
||||||
|
const { files, skipped } = collectInvestigationFiles(root, 'many');
|
||||||
|
expect(files).toHaveLength(MAX_INVESTIGATION_FILES);
|
||||||
|
expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5);
|
||||||
|
});
|
||||||
|
test('단일 파일 경로도 지원', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.writeFileSync(path.join(root, 'one.md'), '단일');
|
||||||
|
const { files } = collectInvestigationFiles(root, 'one.md');
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runInvestigation — 강제 정독 + 캐시', () => {
|
||||||
|
test('모든 파일에 LLM 추출 실행, 노트 생성', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용');
|
||||||
|
const calls: string[] = [];
|
||||||
|
const r = await runInvestigation({
|
||||||
|
rootPath: root, relDir: 'p',
|
||||||
|
llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; },
|
||||||
|
});
|
||||||
|
expect(r.notes).toHaveLength(2);
|
||||||
|
expect(r.llmCalls).toBe(2);
|
||||||
|
expect(r.cacheHits).toBe(0);
|
||||||
|
// LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라)
|
||||||
|
expect(calls[0]).toContain('X 파일 내용');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용');
|
||||||
|
const llm = jest.fn(async () => '목적: v1');
|
||||||
|
await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 2차: 동일 내용 → 캐시
|
||||||
|
const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음
|
||||||
|
expect(r2.cacheHits).toBe(1);
|
||||||
|
expect(r2.notes[0].fromCache).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true);
|
||||||
|
|
||||||
|
// 3차: 내용 변경 → 재추출
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용');
|
||||||
|
const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(2);
|
||||||
|
expect(r3.cacheHits).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추출 실패 파일은 skipped 로 계속 진행', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상');
|
||||||
|
const r = await runInvestigation({
|
||||||
|
rootPath: root, relDir: 'p',
|
||||||
|
llmCall: async (_s, u) => {
|
||||||
|
if (u.includes('실패 대상')) throw new Error('LLM down');
|
||||||
|
return '목적: ok';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.notes).toHaveLength(1);
|
||||||
|
expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('병합 노트에 파일 경로·미확인 목록 포함', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x');
|
||||||
|
const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' });
|
||||||
|
const notes = formatInvestigationNotes(r, '구조 파악');
|
||||||
|
expect(notes).toContain('[a.md]');
|
||||||
|
expect(notes).toContain('조사 목적: 구조 파악');
|
||||||
|
expect(notes).toContain('미확인 파일');
|
||||||
|
expect(notes).toContain('실제로 읽고');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectHollowInvestigation — 헛조사 감지', () => {
|
||||||
|
const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.';
|
||||||
|
|
||||||
|
test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => {
|
||||||
|
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||||
|
expect(r.hollow).toBe(true);
|
||||||
|
expect(r.fileMentions.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
test('실제로 읽었으면(read>0) 정상', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('investigate_files 를 썼으면 정상', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('list 자체가 없으면 판정 대상 아님', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false);
|
||||||
|
expect(detectHollowInvestigation(answer, null).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('파일 언급 1개는 오탐 방지 위해 통과', () => {
|
||||||
|
const one = 'README.md 를 확인해보세요.';
|
||||||
|
expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('경고 footer 에 파일명 포함', () => {
|
||||||
|
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||||
|
const footer = formatHollowInvestigationFooter(r.fileMentions);
|
||||||
|
expect(footer).toContain('헛조사 감지');
|
||||||
|
expect(footer).toContain('investigate_files');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// [v2.2.312] 근거 없는 분석 감지 — 파일을 읽고도 인용이 없는 일반론 보고.
|
||||||
|
import { detectUngroundedAnalysis, formatUngroundedAnalysisFooter } from '../src/intelligence/investigationPipeline';
|
||||||
|
|
||||||
|
describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
|
||||||
|
const generic = '이 프로젝트는 로컬 LLM과 RAG 기술의 결합 시도가 매우 인상적입니다. '.repeat(10);
|
||||||
|
|
||||||
|
test('read 를 하고도 파일 인용 없는 장문 = ungrounded', () => {
|
||||||
|
const r = detectUngroundedAnalysis(generic, { reads: 3, lists: 1, investigates: 0 });
|
||||||
|
expect(r.ungrounded).toBe(true);
|
||||||
|
expect(r.readCount).toBe(3);
|
||||||
|
});
|
||||||
|
test('파일 근거를 2개 이상 인용하면 정상', () => {
|
||||||
|
const grounded = `${generic} 근거: agent.ts 의 handlePrompt 와 memoryContext.ts 의 buildMemoryContext 를 확인함.`;
|
||||||
|
expect(detectUngroundedAnalysis(grounded, { reads: 3, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||||
|
});
|
||||||
|
test('read 가 없으면 판정 대상 아님 (hollow 감지의 영역)', () => {
|
||||||
|
expect(detectUngroundedAnalysis(generic, { reads: 0, lists: 1, investigates: 0 }).ungrounded).toBe(false);
|
||||||
|
});
|
||||||
|
test('짧은 확인형 응답은 오탐 방지로 제외', () => {
|
||||||
|
expect(detectUngroundedAnalysis('네, 서버 정상입니다.', { reads: 1, lists: 0, investigates: 0 }).ungrounded).toBe(false);
|
||||||
|
});
|
||||||
|
test('footer 에 읽은 횟수 포함', () => {
|
||||||
|
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { buildLayoutSkeleton, classifyWidget } from '../src/features/datacollect/prompts/layoutSkeleton';
|
||||||
|
|
||||||
|
const SCAN = {
|
||||||
|
structure: {
|
||||||
|
sections: [
|
||||||
|
{ role: 'header', depth: 0, text: '메뉴', btns: 1, links: 8, imgs: 1, hint: 'site-header' },
|
||||||
|
{ role: 'section', depth: 1, text: '최신 글', btns: 0, links: 12, imgs: 10, hint: 'swiper-container hero-slider' },
|
||||||
|
{ role: 'section', depth: 1, text: '카테고리 글 목록', btns: 2, links: 20, imgs: 30, hint: 'post-grid archive' },
|
||||||
|
{ role: 'footer', depth: 0, text: '저작권', btns: 0, links: 4, imgs: 0, hint: 'site-footer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
sitemap: {
|
||||||
|
pages: [
|
||||||
|
{ url: 'https://koritips.com/', role: 'other', primaryContentType: 'list-article', sectionRoles: [] },
|
||||||
|
{
|
||||||
|
url: 'https://koritips.com/about', role: 'about', primaryContentType: 'list-card',
|
||||||
|
sectionRoles: [
|
||||||
|
{ tag: 'header', hint: 'gnb', preview: '메뉴' },
|
||||||
|
{ tag: 'main', hint: 'about-content', preview: '소개 본문' },
|
||||||
|
{ tag: 'form', hint: 'newsletter-signup', preview: '구독하기' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('classifyWidget — class/id 신호 위젯 판별', () => {
|
||||||
|
test('캐러셀 계열 class 를 롤링 배너로 감지', () => {
|
||||||
|
expect(classifyWidget('swiper-container hero-slider')).toContain('캐러셀');
|
||||||
|
expect(classifyWidget('slick-track')).toContain('캐러셀');
|
||||||
|
});
|
||||||
|
test('내비/푸터/구독 신호 판별', () => {
|
||||||
|
expect(classifyWidget('site-header gnb')).toContain('내비게이션');
|
||||||
|
expect(classifyWidget('newsletter-signup')).toContain('구독');
|
||||||
|
});
|
||||||
|
test('신호 없으면 null', () => {
|
||||||
|
expect(classifyWidget('random-xyz')).toBeNull();
|
||||||
|
expect(classifyWidget('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildLayoutSkeleton — 결정론 골격 리포트', () => {
|
||||||
|
const md = buildLayoutSkeleton(SCAN);
|
||||||
|
|
||||||
|
test('홈의 위→아래 블록 순서를 그린다 (헤더→캐러셀→그리드→푸터)', () => {
|
||||||
|
const iHeader = md.indexOf('내비게이션');
|
||||||
|
const iCarousel = md.indexOf('캐러셀');
|
||||||
|
const iGrid = md.indexOf('카드/리스트 그리드');
|
||||||
|
const iFooter = md.indexOf('푸터');
|
||||||
|
expect(iHeader).toBeGreaterThan(-1);
|
||||||
|
expect(iHeader).toBeLessThan(iCarousel);
|
||||||
|
expect(iCarousel).toBeLessThan(iGrid);
|
||||||
|
expect(iGrid).toBeLessThan(iFooter);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('롤링 배너 유무에 즉답한다', () => {
|
||||||
|
expect(md).toContain('롤링 배너/캐러셀: **있음**');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('하위 페이지 골격도 포함한다', () => {
|
||||||
|
expect(md).toContain('`/about`');
|
||||||
|
expect(md).toContain('구독/CTA');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('버튼/이미지/링크 수를 표기한다', () => {
|
||||||
|
expect(md).toContain('이미지 10');
|
||||||
|
expect(md).toContain('링크 8');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('캐러셀 신호가 없으면 "신호 없음 + 실사이트 확인 권장"으로 정직하게', () => {
|
||||||
|
const noCarousel = buildLayoutSkeleton({
|
||||||
|
structure: { sections: [{ role: 'header', hint: 'site-header', text: '' }] },
|
||||||
|
sitemap: { pages: [] },
|
||||||
|
});
|
||||||
|
expect(noCarousel).toContain('신호 없음');
|
||||||
|
expect(noCarousel).toContain('실사이트 확인 권장');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빈 스캔에도 깨지지 않는다', () => {
|
||||||
|
expect(buildLayoutSkeleton({})).toContain('섹션 데이터가 스캔에 없습니다');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import { LiveReasoningFilter } from '../src/lib/contextBuilders/liveReasoningFilter';
|
||||||
|
|
||||||
|
/** 토큰 배열을 순서대로 밀어넣고 화면에 방출된 텍스트를 합쳐 반환. */
|
||||||
|
function run(tokens: string[]): string {
|
||||||
|
const f = new LiveReasoningFilter();
|
||||||
|
let out = '';
|
||||||
|
for (const t of tokens) out += f.push(t);
|
||||||
|
out += f.flush();
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차단', () => {
|
||||||
|
test('일반 텍스트는 그대로 통과한다', () => {
|
||||||
|
expect(run(['안녕', '하세요. ', '무엇을 도와드릴까요?'])).toBe('안녕하세요. 무엇을 도와드릴까요?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<think> 블록은 통째로 숨긴다', () => {
|
||||||
|
expect(run(['<think>사용자가 인사했다</think>', '반갑습니다.'])).toBe('반갑습니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('토큰 경계에 걸쳐 쪼개진 <think> 도 잡는다', () => {
|
||||||
|
expect(run(['<th', 'ink>내부 ', '추론</thi', 'nk>답', '변입니다'])).toBe('답변입니다');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('<thinking> 과 <analysis> 변형도 처리한다', () => {
|
||||||
|
expect(run(['<thinking>...</thinking>', 'A'])).toBe('A');
|
||||||
|
expect(run(['<analysis>...</analysis>', 'B'])).toBe('B');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('본문 중간의 think 블록도 앞뒤 본문을 보존한다', () => {
|
||||||
|
expect(run(['결론은 X입니다. ', '<think>검산</think>', '근거는 Y입니다.']))
|
||||||
|
.toBe('결론은 X입니다. 근거는 Y입니다.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Harmony thought 채널은 숨기고 final 채널 본문만 통과한다', () => {
|
||||||
|
const tokens = [
|
||||||
|
'<|channel|>thought The user said "안녕". This is a greeting.',
|
||||||
|
'<|channel|>final<|message|>',
|
||||||
|
'반갑습니다. 무엇을 도와드릴까요?',
|
||||||
|
];
|
||||||
|
expect(run(tokens)).toBe('반갑습니다. 무엇을 도와드릴까요?');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('채널 마커가 토큰 경계에 걸쳐도 잡는다', () => {
|
||||||
|
const tokens = ['<|chan', 'nel|>thought 내부 추론 텍스트 ', '<|channel|>fin', 'al<|mess', 'age|>답변'];
|
||||||
|
expect(run(tokens)).toBe('답변');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thought 채널이 <|end|> 로 끝나면 이후는 visible', () => {
|
||||||
|
expect(run(['<|channel|>thought 추론<|end|>', '본문'])).toBe('본문');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('닫히지 않은 think 는 아무것도 방출하지 않는다 (최종 streamReplace 가 정리)', () => {
|
||||||
|
expect(run(['<think>끝나지 않는 추론...'])).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마커가 아닌 < 문자는 보류 후 정상 방출된다', () => {
|
||||||
|
expect(run(['a < b 이고 ', 'c > d 입니다'])).toBe('a < b 이고 c > d 입니다');
|
||||||
|
expect(run(['제네릭 Array<string> 타입'])).toBe('제네릭 Array<string> 타입');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('스트림이 잠재 마커 접두사로 끝나면 flush 로 회수된다', () => {
|
||||||
|
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => {
|
||||||
|
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||||
|
|
||||||
|
/** run + separator 신호 관찰 버전. */
|
||||||
|
function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } {
|
||||||
|
const f = new LiveReasoningFilter();
|
||||||
|
let out = '';
|
||||||
|
const replacedAt: number[] = [];
|
||||||
|
tokens.forEach((t, i) => {
|
||||||
|
const visible = f.push(t);
|
||||||
|
if (f.consumeSeparatorSignal()) {
|
||||||
|
out = visible; // streamReplace 시뮬레이션 — 화면 리셋
|
||||||
|
replacedAt.push(i);
|
||||||
|
} else {
|
||||||
|
out += visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out += f.flush();
|
||||||
|
return { out, replacedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => {
|
||||||
|
const { out, replacedAt } = runWithSignal([
|
||||||
|
'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...',
|
||||||
|
MARKER + '잘 지내고 있습니다. 별일 없으셨나요?',
|
||||||
|
]);
|
||||||
|
expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||||
|
expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => {
|
||||||
|
const { out } = runWithSignal([
|
||||||
|
'추론 텍스트 어쩌고 ',
|
||||||
|
'__LM_STUDIO_INTERNAL_LSEP',
|
||||||
|
'_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c',
|
||||||
|
'9e5f3a7b8c1d2e6a__',
|
||||||
|
'실제 답변',
|
||||||
|
]);
|
||||||
|
expect(out).toBe('실제 답변');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('START 변형이 오면 END 까지 통째로 숨긴다', () => {
|
||||||
|
const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__';
|
||||||
|
const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']);
|
||||||
|
expect(out).toBe('답변 앞부분 답변 뒷부분');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => {
|
||||||
|
const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']);
|
||||||
|
expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다');
|
||||||
|
expect(replacedAt).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => {
|
||||||
|
const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization');
|
||||||
|
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||||
|
|
||||||
|
test('마커 앞 추론 전체 제거, 답변만 남긴다', () => {
|
||||||
|
const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?';
|
||||||
|
expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||||
|
});
|
||||||
|
test('마커가 여러 번이면 마지막 마커 뒤만', () => {
|
||||||
|
const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변';
|
||||||
|
expect(sanitizeAssistantContent(raw)).toBe('최종 답변');
|
||||||
|
});
|
||||||
|
test('마커 없으면 기존 동작 그대로', () => {
|
||||||
|
expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* [v2.2.311] KV 캐시 친화 프롬프트 분리 — 정적/동적 경계 계약.
|
||||||
|
*
|
||||||
|
* 정적 프롬프트(getStaticSystemPrompt)에 분 단위 시각 같은 가변 요소가 섞이는 순간
|
||||||
|
* llama.cpp prompt cache 는 요청 첫 토큰부터 무효화된다 (실측: 13k 토큰 전체 재프리필
|
||||||
|
* ≈ 90초/턴). 이 테스트는 그 회귀를 막는 가드레일이다.
|
||||||
|
*/
|
||||||
|
import { getStaticSystemPrompt, getDateTimeContextBlock, getSystemPrompt } from '../src/utils';
|
||||||
|
import { buildAstraModeSystemPrompt } from '../src/agent/handlePrompt/buildAstraModeSystemPrompt';
|
||||||
|
|
||||||
|
describe('정적/동적 시스템 프롬프트 분리', () => {
|
||||||
|
test('정적 프롬프트에는 날짜/시각이 없다 (캐시 무효화 가드)', () => {
|
||||||
|
const s = getStaticSystemPrompt();
|
||||||
|
expect(s).not.toContain('[CURRENT DATE/TIME]');
|
||||||
|
// 오늘 날짜 문자열이 어떤 형태로도 들어있으면 안 된다.
|
||||||
|
const iso = new Date().toISOString().split('T')[0];
|
||||||
|
expect(s).not.toContain(iso);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('정적 프롬프트는 연속 호출에서 동일하다 (프리픽스 불변)', () => {
|
||||||
|
expect(getStaticSystemPrompt()).toBe(getStaticSystemPrompt());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('날짜 블록은 오늘 날짜를 포함한다', () => {
|
||||||
|
const block = getDateTimeContextBlock();
|
||||||
|
expect(block).toContain('[CURRENT DATE/TIME]');
|
||||||
|
expect(block).toContain(new Date().toISOString().split('T')[0]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('레거시 getSystemPrompt 는 정적 본문 + 날짜 블록을 모두 포함 (호환)', () => {
|
||||||
|
const s = getSystemPrompt();
|
||||||
|
expect(s).toContain('[CURRENT DATE/TIME]');
|
||||||
|
expect(s).toContain('[출력 위생 규칙 — 반드시 준수]');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('빌더에 systemPrompt="" 를 넘겨도 [CONTEXT] 골격은 유지된다 (tail 생성 경로)', () => {
|
||||||
|
const tail = buildAstraModeSystemPrompt({
|
||||||
|
prompt: '질문',
|
||||||
|
systemPrompt: '',
|
||||||
|
modeBridgeCtx: '',
|
||||||
|
priorConclusionCtx: '',
|
||||||
|
designerCtx: '',
|
||||||
|
projectArchitectureCtx: '',
|
||||||
|
secondBrainTraceCtx: '',
|
||||||
|
memoryCtx: 'RAG 본문',
|
||||||
|
knowledgeContextForPrompt: '',
|
||||||
|
contextBlock: '열린 파일 내용',
|
||||||
|
negativeCtx: '',
|
||||||
|
isCasualConversation: false,
|
||||||
|
localPathContext: '',
|
||||||
|
knowledgeMix: null,
|
||||||
|
dynamicBlocks: new Map([['behavior-constraints', '[GROUNDING] 근거 강도: 보통']]),
|
||||||
|
});
|
||||||
|
expect(tail).toContain('[CONTEXT]');
|
||||||
|
expect(tail).toContain('RAG 본문');
|
||||||
|
expect(tail).toContain('열린 파일 내용');
|
||||||
|
expect(tail).toContain('[GROUNDING]');
|
||||||
|
expect(tail.startsWith('You are Astra')).toBe(false); // 정적 본문이 섞이면 안 됨
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { measureReport, parseLooseJson, runReportQaLoop, REPORT_QA_RULES, buildReportQaItems, reportQaCriticalNums, formatIntentBrief } from '../src/agents/reportQa';
|
||||||
|
|
||||||
|
jest.mock('../src/core/services', () => ({
|
||||||
|
coreChat: jest.fn(),
|
||||||
|
}));
|
||||||
|
const { coreChat } = require('../src/core/services');
|
||||||
|
|
||||||
|
describe('measureReport — 결정적 실측치', () => {
|
||||||
|
test('본문 글자 수는 마크다운 문법을 제외하고 센다', () => {
|
||||||
|
const m = measureReport('## 제목\n\n본문 다섯글자\n\n```js\ncode();\n```');
|
||||||
|
expect(m.contentChars).toBe('제목 본문 다섯글자'.length);
|
||||||
|
expect(m.sectionCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('출처 인용 흔적과 나열 비율을 측정한다', () => {
|
||||||
|
const text = [
|
||||||
|
'## 발견', '결론입니다 (출처: 문서A)', '「위키 문서B」에 따르면 그렇다.',
|
||||||
|
'- 항목 1', '- 항목 2', '일반 지식 기반의 추정이다.',
|
||||||
|
].join('\n');
|
||||||
|
const m = measureReport(text);
|
||||||
|
expect(m.citationCount).toBeGreaterThanOrEqual(3);
|
||||||
|
expect(m.listLineRatio).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseLooseJson — 관대한 JSON 파싱', () => {
|
||||||
|
test('코드펜스에 싸인 JSON 을 파싱한다', () => {
|
||||||
|
expect(parseLooseJson('```json\n{"score": 85, "feedback": "좋음"}\n```')).toEqual({ score: 85, feedback: '좋음' });
|
||||||
|
});
|
||||||
|
test('앞뒤 잡담이 섞여도 첫 { ~ 마지막 } 로 복구한다', () => {
|
||||||
|
expect(parseLooseJson('알겠습니다. {"score": 70} 입니다.')).toEqual({ score: 70 });
|
||||||
|
});
|
||||||
|
test('복구 불가면 null', () => {
|
||||||
|
expect(parseLooseJson('점수는 85점입니다')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('QA 규칙 레지스트리', () => {
|
||||||
|
test('번호가 고유하고 채점 항목 문자열이 전 규칙을 포함한다', () => {
|
||||||
|
const nums = REPORT_QA_RULES.map(r => r.num);
|
||||||
|
expect(new Set(nums).size).toBe(nums.length);
|
||||||
|
const items = buildReportQaItems();
|
||||||
|
for (const r of REPORT_QA_RULES) expect(items).toContain(`(${r.num})`);
|
||||||
|
});
|
||||||
|
test('중대 결함 번호 목록은 critical 규칙에서 자동 생성된다', () => {
|
||||||
|
const criticals = REPORT_QA_RULES.filter(r => r.critical).map(r => r.num);
|
||||||
|
expect(reportQaCriticalNums()).toBe(criticals.join('·'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runReportQaLoop — 채점·재작성·회귀 게이트', () => {
|
||||||
|
beforeEach(() => (coreChat as jest.Mock).mockReset());
|
||||||
|
const brief = { purpose: 'p', realQuestions: ['q'], expectedUse: 'u' };
|
||||||
|
const longDraft = '초안 본문 '.repeat(50);
|
||||||
|
|
||||||
|
test('임계 이상이면 재작성 없이 그대로 통과', async () => {
|
||||||
|
(coreChat as jest.Mock).mockResolvedValueOnce({ content: '{"score": 88, "feedback": "통과"}', empty: false });
|
||||||
|
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
|
||||||
|
expect(r.finalDraft).toBe(longDraft);
|
||||||
|
expect(r.score).toBe(88);
|
||||||
|
expect(r.revisions).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('임계 미달 → 재작성 → 점수 상승 시 채택', async () => {
|
||||||
|
const revised = '개선된 보고서 본문 '.repeat(40);
|
||||||
|
(coreChat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 70, "feedback": "3번 근거 부족"}', empty: false }) // 초안 채점
|
||||||
|
.mockResolvedValueOnce({ content: revised, empty: false }) // 재작성
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 86, "feedback": "통과"}', empty: false }); // 재채점
|
||||||
|
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
|
||||||
|
expect(r.finalDraft).toBe(revised.trim());
|
||||||
|
expect(r.score).toBe(86);
|
||||||
|
expect(r.revisions).toBe(1);
|
||||||
|
expect(r.trajectory).toEqual([70, 86]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[회귀 게이트] 재작성본 점수가 떨어지면 폐기하고 최고 초안 유지', async () => {
|
||||||
|
const worse = '나빠진 보고서 본문 '.repeat(40);
|
||||||
|
(coreChat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 75, "feedback": "1번 미흡"}', empty: false })
|
||||||
|
.mockResolvedValueOnce({ content: worse, empty: false })
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 68, "feedback": "더 나빠짐"}', empty: false })
|
||||||
|
.mockResolvedValueOnce({ content: worse, empty: false })
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 60, "feedback": "악화"}', empty: false });
|
||||||
|
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
|
||||||
|
expect(r.finalDraft).toBe(longDraft); // 원본 유지
|
||||||
|
expect(r.score).toBe(75);
|
||||||
|
expect(r.trajectory).toEqual([75, 68, 60]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('채점 자체가 실패(JSON 파싱 불가)하면 초안을 그대로 반환한다', async () => {
|
||||||
|
(coreChat as jest.Mock).mockResolvedValueOnce({ content: '점수를 매길 수 없습니다', empty: false });
|
||||||
|
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: null });
|
||||||
|
expect(r.finalDraft).toBe(longDraft);
|
||||||
|
expect(r.score).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('재작성본이 과도하게 짧으면(잘림) 회귀로 간주해 폐기한다', async () => {
|
||||||
|
(coreChat as jest.Mock)
|
||||||
|
.mockResolvedValueOnce({ content: '{"score": 60, "feedback": "부족"}', empty: false })
|
||||||
|
.mockResolvedValueOnce({ content: '짧음', empty: false }) // 심하게 잘린 재작성
|
||||||
|
.mockResolvedValueOnce({ content: '짧음', empty: false })
|
||||||
|
const r = await runReportQaLoop({ draft: longDraft, prompt: 'X 조사해줘', intentBrief: brief });
|
||||||
|
expect(r.finalDraft).toBe(longDraft);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatIntentBrief', () => {
|
||||||
|
test('브리핑을 성공 기준 지시와 함께 포맷한다', () => {
|
||||||
|
const out = formatIntentBrief({ purpose: '시장 진입 판단', realQuestions: ['경쟁 강도', '규제'], expectedUse: '투자 결정' });
|
||||||
|
expect(out).toContain('시장 진입 판단');
|
||||||
|
expect(out).toContain('경쟁 강도 / 규제');
|
||||||
|
expect(out).toContain('표면 질문만 문자적으로 답하지 마라');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -124,3 +124,23 @@ describe('DEFAULT_TASK_REQUIREMENTS 무결성', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// [v2.2.312] 분석 보고 유형 — "프로젝트 분석하고 보고해줘" 실사례가 유형 미감지로
|
||||||
|
// 일반론 총평이 되던 갭을 막는다.
|
||||||
|
describe('detectTaskType — analysis-report (v2.2.312)', () => {
|
||||||
|
test('실사례 문장을 정확히 감지한다', () => {
|
||||||
|
expect(detectTaskType('E:\Wiki\connectai 프로젝트를 분석하고 어떻게 되어 있는지 보고해줘')?.id).toBe('analysis-report');
|
||||||
|
expect(detectTaskType('진행 현황 보고 부탁해')?.id).toBe('analysis-report');
|
||||||
|
expect(detectTaskType('이 코드 검토해서 보고해줘')?.id).toBe('analysis-report');
|
||||||
|
});
|
||||||
|
test('더 구체적인 유형이 우선한다 (회의록·시장조사)', () => {
|
||||||
|
expect(detectTaskType('회의록으로 정리해서 보고해줘')?.id).toBe('meeting-minutes');
|
||||||
|
expect(detectTaskType('시장조사 결과 보고해줘')?.id).toBe('market-research');
|
||||||
|
});
|
||||||
|
test('블록에 파일 근거 요소와 섹션 규칙 오버라이드가 포함된다', () => {
|
||||||
|
const block = buildRequirementGraphBlock('프로젝트 분석하고 보고해줘');
|
||||||
|
expect(block).toContain('파일 근거');
|
||||||
|
expect(block).toContain('보고 개요');
|
||||||
|
expect(block).toContain('최대 3섹션');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308)
|
||||||
|
*
|
||||||
|
* 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제.
|
||||||
|
* 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
looksLikeBehaviorComplaint,
|
||||||
|
loadStandingRules,
|
||||||
|
saveStandingRules,
|
||||||
|
upsertStandingRule,
|
||||||
|
buildStandingRulesBlock,
|
||||||
|
isSameRule,
|
||||||
|
MAX_ACTIVE_STANDING_RULES,
|
||||||
|
STANDING_RULES_REL_PATH,
|
||||||
|
StandingRule,
|
||||||
|
} from '../src/intelligence/correctionLoop';
|
||||||
|
|
||||||
|
function tmpBrain(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => {
|
||||||
|
test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"하지 말라고 했잖아"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"몇 번을 말해"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"왜 자꾸 ~"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true);
|
||||||
|
});
|
||||||
|
test('출력 형식 지시: "붙이지 마"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true);
|
||||||
|
});
|
||||||
|
test('일반 질문은 오탐하지 않음', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false);
|
||||||
|
expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false);
|
||||||
|
// "또"로 시작하지만 지적이 아닌 추가 요청
|
||||||
|
expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false);
|
||||||
|
});
|
||||||
|
test('빈/초장문 입력 방어', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('')).toBe(false);
|
||||||
|
expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSameRule — 재지적 판정 (토큰 자카드)', () => {
|
||||||
|
test('같은 규칙의 표현 변형은 동일 판정', () => {
|
||||||
|
expect(isSameRule(
|
||||||
|
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||||
|
'답변에 결론 수정 문구를 절대 붙이지 않는다',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
test('다른 규칙은 구분', () => {
|
||||||
|
expect(isSameRule(
|
||||||
|
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||||
|
'모든 답변은 한국어로 작성한다',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertStandingRule — 저장/중복/상한', () => {
|
||||||
|
test('신규 규칙 저장 → 파일 생성 + active', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네');
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].status).toBe('active');
|
||||||
|
expect(rules[0].hits).toBe(1);
|
||||||
|
expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true);
|
||||||
|
// 라운드트립
|
||||||
|
const loaded = loadStandingRules(brain);
|
||||||
|
expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1');
|
||||||
|
const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2');
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].hits).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('은퇴한 규칙이 재지적되면 부활', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
const retired: StandingRule[] = [{
|
||||||
|
id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired',
|
||||||
|
}];
|
||||||
|
saveStandingRules(brain, retired);
|
||||||
|
const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네');
|
||||||
|
expect(rules[0].status).toBe('active');
|
||||||
|
expect(rules[0].hits).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
// 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개
|
||||||
|
const distinct = [
|
||||||
|
'답변은 항상 한국어로 작성한다',
|
||||||
|
'수치를 말할 때 근거 문서를 인용한다',
|
||||||
|
'표를 남용하지 않는다',
|
||||||
|
'이모지 사용을 최소화한다',
|
||||||
|
'결론부터 먼저 말한다',
|
||||||
|
'추측일 때는 추정임을 명시한다',
|
||||||
|
'링크는 마크다운 형식으로 쓴다',
|
||||||
|
'제목에 물음표를 넣지 않는다',
|
||||||
|
'인사말을 매번 반복하지 않는다',
|
||||||
|
'코드 예시에는 설명 주석을 단다',
|
||||||
|
];
|
||||||
|
distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`));
|
||||||
|
expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||||
|
// 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게)
|
||||||
|
upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z');
|
||||||
|
// 상한+1 번째 신규 규칙
|
||||||
|
const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z');
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||||
|
// hits 2인 규칙은 살아남고, 방금 들어온 신규도 active
|
||||||
|
expect(active.some(r => r.rule.includes('이모지'))).toBe(true);
|
||||||
|
expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true);
|
||||||
|
expect(rules.some(r => r.status === 'retired')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildStandingRulesBlock — 매 턴 주입 블록', () => {
|
||||||
|
test('active 규칙을 hits 내림차순으로 포함', () => {
|
||||||
|
const rules: StandingRule[] = [
|
||||||
|
{ id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' },
|
||||||
|
{ id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' },
|
||||||
|
{ id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' },
|
||||||
|
];
|
||||||
|
const block = buildStandingRulesBlock(rules);
|
||||||
|
expect(block).toContain('[상시 행동 규칙');
|
||||||
|
expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순
|
||||||
|
expect(block).toContain('(지적 3회)');
|
||||||
|
expect(block).not.toContain('규칙C'); // retired 제외
|
||||||
|
});
|
||||||
|
test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => {
|
||||||
|
expect(buildStandingRulesBlock([])).toBe('');
|
||||||
|
expect(buildStandingRulesBlock([
|
||||||
|
{ id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' },
|
||||||
|
])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* [v2.2.312] 중간 라운드 표시용 액션 태그 제거 — "## 4.부터 시작하는 보고서" 버그의
|
||||||
|
* 표시 경로 헬퍼. 태그는 걷어내되 본문 섹션은 보존해야 한다.
|
||||||
|
*/
|
||||||
|
import { stripActionTagsForDisplay } from '../src/agent/actions/stripForDisplay';
|
||||||
|
|
||||||
|
describe('stripActionTagsForDisplay', () => {
|
||||||
|
test('자기닫힘 태그 제거 + 본문 보존', () => {
|
||||||
|
const input = '## 1. 프로젝트 개요\n구조를 확인한다.\n<read_file path="src/agent.ts"/>\n<list_files path="src"/>';
|
||||||
|
const out = stripActionTagsForDisplay(input);
|
||||||
|
expect(out).toContain('## 1. 프로젝트 개요');
|
||||||
|
expect(out).not.toContain('<read_file');
|
||||||
|
expect(out).not.toContain('<list_files');
|
||||||
|
});
|
||||||
|
test('블록 태그(내용 포함) 제거', () => {
|
||||||
|
const input = '분석 시작.\n<run_command>cd x; npm test</run_command>\n<create_file path="a.md">본문</create_file>\n끝.';
|
||||||
|
const out = stripActionTagsForDisplay(input);
|
||||||
|
expect(out).toContain('분석 시작.');
|
||||||
|
expect(out).toContain('끝.');
|
||||||
|
expect(out).not.toContain('npm test');
|
||||||
|
expect(out).not.toContain('<create_file');
|
||||||
|
});
|
||||||
|
test('잔재 여는/닫는 태그도 정리', () => {
|
||||||
|
const out = stripActionTagsForDisplay('앞 <edit_file path="x.ts"> 뒤');
|
||||||
|
expect(out).not.toContain('<edit_file');
|
||||||
|
expect(out).toContain('앞');
|
||||||
|
expect(out).toContain('뒤');
|
||||||
|
});
|
||||||
|
test('태그 없는 본문은 그대로', () => {
|
||||||
|
expect(stripActionTagsForDisplay('## 2. 구조\n- src/agent.ts')).toBe('## 2. 구조\n- src/agent.ts');
|
||||||
|
});
|
||||||
|
test('빈 입력 안전', () => {
|
||||||
|
expect(stripActionTagsForDisplay('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { buildToneGuardBlock, AI_CLICHE_PATTERNS } from '../src/lib/contextBuilders/toneGuard';
|
||||||
|
import { getSystemPrompt } from '../src/utils';
|
||||||
|
|
||||||
|
describe('Tone Guard — AI 상투어 금지', () => {
|
||||||
|
const block = buildToneGuardBlock();
|
||||||
|
|
||||||
|
test('실사례 클리셰(상담사 화법·대기 선언·허락 구걸)를 전부 금지한다', () => {
|
||||||
|
expect(block).toContain('계시군요');
|
||||||
|
expect(block).toContain('저는 언제든 여기에 있습니다');
|
||||||
|
expect(block).toContain('괜찮으시다면');
|
||||||
|
expect(block).toContain('무엇을 도와드릴까요');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('레지스트리의 모든 패턴이 블록에 나열된다 (단일 권위)', () => {
|
||||||
|
for (const p of AI_CLICHE_PATTERNS) expect(block).toContain(p);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('대조 예시(나쁜 답 vs 좋은 답)와 동료 톤 지침을 포함한다', () => {
|
||||||
|
expect(block).toContain('나쁜 답');
|
||||||
|
expect(block).toContain('좋은 답');
|
||||||
|
expect(block).toContain('오버해서 친한 척도 하지 마라');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('기본 페르소나(작업 턴)에도 주입된다', () => {
|
||||||
|
expect(getSystemPrompt()).toContain('[말투 — AI 상투어 금지]');
|
||||||
|
});
|
||||||
|
});
|
||||||
+15
-3
@@ -42,9 +42,21 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
|
|||||||
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki'));
|
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('상대경로 설정은 무시하고 다음 후보로 (경로 오염 방지)', () => {
|
test('[v2.2.304] 상대경로 설정은 두뇌 루트 기준으로 해석 (이식성)', () => {
|
||||||
const r = pickWikiDir('상대/경로', 'E:/brain', null);
|
const r = pickWikiDir('상대/경로', '/brain', null);
|
||||||
expect(r!.label).toBe('두뇌');
|
expect(r!.label).toBe('설정 폴더(두뇌 기준)');
|
||||||
|
expect(r!.dir.replace(/\\/g, '/')).toContain('/brain/상대/경로');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
|
||||||
|
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
|
||||||
|
// Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증
|
||||||
|
expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
||||||
|
const r = pickWikiDir('상대/경로', '', '/ws');
|
||||||
|
expect(r!.label).toBe('워크스페이스');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('④ 전부 없으면 null', () => {
|
test('④ 전부 없으면 null', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user