v2.2.299~307: 아키텍처 수렴 + 채팅 정리 + 벤치마킹 강화 + Claude 구독 엔진
- v2.2.299 아키텍처 수렴: coreChat 통일(엔진 휴리스틱 3벌 제거), 기업 모드 검색 오케스트레이터 승격, lib/execUtil(실행 래퍼 6곳·Python 탐지 3벌 단일화), lib/kstSchedule(워처 4개 nowInKst 통합), estimateTokens 통합, 설정 접근 규칙 명문화 - v2.2.300 채팅 화면 정리: LiveReasoningFilter(스트리밍 중 <think>/Harmony 추론 토큰 단위 차단), 확신도·검토요청 footer 기본 숨김(계산·Reflection 은 유지) - v2.2.301 문맥·의도 이해: [답변 전 이해 원칙] 상시 주입, 워크플로우 의도 브리핑, Report QA 루프(규칙 레지스트리+실측치+회귀 게이트, 블로그_v3 개념 이식) - v2.2.302 /benchmark 비즈니스 렌즈(가격·수익·운영)+빌드 프롬프트 모드+QA 연계 - v2.2.303 handoff 모드(측정치 무손실 인수인계 문서)+/claude(Claude Code 터미널 위임) - v2.2.304 이식성: 지식 경로 두뇌-상대 규약(pickWikiDir 상대 해석), 이사 체크리스트 - v2.2.305 Claude 구독 엔진: claude: 프로바이더(CLI 위임, 모델 드롭다운 자동 노출, coreChat 지원 — 워크플로우·QA도 구독 모델 가능) - v2.2.306 Tone Guard: AI 상투어 금지 레지스트리(상담사 화법 실사례 8종+대조 예시) - v2.2.307 /benchmark 레이아웃 골격(sectionRoles 결정론 분석, 롤링 배너 즉답), 파트별 실패 격리, 합성 타임아웃 120→300초 검증: tsc 무오류 + jest 888 통과 + esbuild 정상 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1783259143996,
|
||||
"createdAt": 1783757602271,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-07-05T13:45:43.997Z\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) (12ms)\n",
|
||||
"createdAt": 1783259143997,
|
||||
"result": "---\nid: wiki_on\ndate: 2026-07-11T08:13:22.272Z\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) (12ms)\n",
|
||||
"createdAt": 1783757602272,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-05T13:45:43.984Z",
|
||||
"startTime": "2026-07-11T08:13:22.259Z",
|
||||
"totalElapsedMs": 13,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
@@ -14,14 +14,14 @@
|
||||
"to": "direct",
|
||||
"durationMs": 12,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-07-05T13:45:43.996Z"
|
||||
"ts": "2026-07-11T08:13:22.271Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 1,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-05T13:45:43.997Z"
|
||||
"ts": "2026-07-11T08:13:22.272Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1783259150864,
|
||||
"createdAt": 1783757609132,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1783259150864,
|
||||
"createdAt": 1783757609132,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1783259150863,
|
||||
"createdAt": 1783757609131,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1783259150863,
|
||||
"createdAt": 1783757609131,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+8
-8
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1783259150850",
|
||||
"missionId": "stress_conflict_1783757609119",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-05T13:45:50.850Z",
|
||||
"totalElapsedMs": 14,
|
||||
"startTime": "2026-07-11T08:13:29.119Z",
|
||||
"totalElapsedMs": 13,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 12,
|
||||
"durationMs": 11,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-07-05T13:45:50.862Z"
|
||||
"ts": "2026-07-11T08:13:29.130Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 1,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-07-05T13:45:50.863Z"
|
||||
"ts": "2026-07-11T08:13:29.131Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 0,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-07-05T13:45:50.863Z"
|
||||
"ts": "2026-07-11T08:13:29.131Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 1,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-05T13:45:50.864Z"
|
||||
"ts": "2026-07-11T08:13:29.132Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,95 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## 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 는 분류 표시용이라 복원이 불가능했다.
|
||||
|
||||
@@ -182,32 +182,32 @@
|
||||
|
||||
<section class="section" data-section="domainKnowledge" data-tab="knowledge">
|
||||
<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">
|
||||
<label for="dkGeneral">General Knowledge 폴더 (모든 단계 공통)</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dkMath">수학·논리 Specialty 폴더</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dkCoding">코딩·디버깅 Specialty 폴더</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label for="dkFacts">일반 상식·팩트 Specialty 폴더</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+29
-9
@@ -2,7 +2,7 @@
|
||||
"name": "astra",
|
||||
"displayName": "Astra",
|
||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||
"version": "2.2.297",
|
||||
"version": "2.2.307",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -267,22 +267,22 @@
|
||||
"g1nation.domainKnowledge.generalFolder": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[단계별 지식] 모든 단계가 공통으로 참조하는 General Knowledge 폴더 (활성 두뇌 기준 상대 경로, 예: `General`). 질문이 수학/코딩/팩트로 분류될 때 해당 Specialty 폴더와 함께 검색됩니다."
|
||||
"markdownDescription": "[단계별 지식] 모든 단계가 공통으로 참조하는 General Knowledge 폴더 (두뇌 기준 상대 경로, 예: `10_Wiki/Topics/_Common`). 질문이 수학/코딩/팩트로 분류될 때 해당 Specialty 폴더와 함께 검색됩니다."
|
||||
},
|
||||
"g1nation.domainKnowledge.mathFolder": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[단계별 지식] 수학·논리 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `Specialty_Math`). 비워두면 수학 질문도 전체 두뇌를 검색합니다."
|
||||
"markdownDescription": "[단계별 지식] 수학·논리 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/_Common/Math`). 비워두면 수학 질문도 전체 두뇌를 검색합니다."
|
||||
},
|
||||
"g1nation.domainKnowledge.codingFolder": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[단계별 지식] 코딩·디버깅 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topic_Programming`). 비워두면 코딩 질문도 전체 두뇌를 검색합니다."
|
||||
"markdownDescription": "[단계별 지식] 코딩·디버깅 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/Domain_Programming`). 비워두면 코딩 질문도 전체 두뇌를 검색합니다."
|
||||
},
|
||||
"g1nation.domainKnowledge.factsFolder": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "[단계별 지식] 일반 상식·팩트 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `Specialty_Facts`). 비워두면 팩트 질문도 전체 두뇌를 검색합니다."
|
||||
"markdownDescription": "[단계별 지식] 일반 상식·팩트 질문 전용 Specialty 폴더 (두뇌 상대 경로, 예: `10_Wiki/Topics/Domain_General`). 비워두면 팩트 질문도 전체 두뇌를 검색합니다."
|
||||
},
|
||||
"g1nation.datacollectLocalProjectPath": {
|
||||
"type": "string",
|
||||
@@ -292,7 +292,7 @@
|
||||
"g1nation.datacollectSavePath": {
|
||||
"type": "string",
|
||||
"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": {
|
||||
"type": "number",
|
||||
@@ -861,13 +861,28 @@
|
||||
},
|
||||
"g1nation.confidenceEngineEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"description": "Confidence Engine — 답변 확신도 0~100 을 검색 그라운딩·출처 인용·충돌·커버리지 신호로 결정론적 산출, 업무 답변 아래 footer 표시. LLM 호출 없음. 기본 켜짐."
|
||||
"default": false,
|
||||
"description": "확신도 footer 표시 — 답변 아래에 확신도 0~100 배지를 표시. 점수 계산과 Reflection 성장 기록은 이 설정과 무관하게 항상 수행됩니다. 기본 꺼짐 (v2.2.300, 채팅 노이즈 방지)."
|
||||
},
|
||||
"g1nation.escalationEnabled": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "검토 요청 footer 표시 — 확신도 낮음/출처 충돌 시 답변 아래에 사람 검토 요청 카드를 표시. confidenceEngineEnabled 켜짐에 종속. 기본 꺼짐 (v2.2.300)."
|
||||
},
|
||||
"g1nation.reportQaEnabled": {
|
||||
"type": "boolean",
|
||||
"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": {
|
||||
"type": "boolean",
|
||||
@@ -1060,6 +1075,11 @@
|
||||
"editPresentation": "multilineText",
|
||||
"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": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
|
||||
+52
-6
@@ -41,6 +41,7 @@ import {
|
||||
} from './lib/contextBuilders/promptDetection';
|
||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
|
||||
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
|
||||
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
|
||||
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './lib/contextBuilders/projectEvidence';
|
||||
@@ -102,6 +103,7 @@ import {
|
||||
} from './features/secondBrainTrace';
|
||||
import { MemoryManager } from './memory';
|
||||
import { RetrievalOrchestrator } from './retrieval';
|
||||
import { embedQuery } from './retrieval/embeddings';
|
||||
import { isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
|
||||
import { buildKnowledgeMixPolicy, ResolvedKnowledgeMix } from './retrieval/knowledgeMix';
|
||||
import {
|
||||
@@ -358,6 +360,44 @@ export class AgentExecutor {
|
||||
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() {
|
||||
return restoreLastSessionFn({
|
||||
sessionManager: this.sessionManager,
|
||||
@@ -946,10 +986,16 @@ export class AgentExecutor {
|
||||
// policy enforcement) emits a final `streamReplace` so the bubble
|
||||
// ends up matching the cleaned answer regardless of what slipped
|
||||
// through live.
|
||||
// [Clean Stream] g1nation.liveStreamTokens=false (기본) 이면 토큰을 내부에만
|
||||
// 누적하고 sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커가 잠깐
|
||||
// 화면에 노출되는 누설을 원천 차단한다. true 로 두면 legacy 라이브 스트리밍.
|
||||
// [Clean Stream] liveStreamTokens=true(기본) 면 토큰을 실시간 표시하되,
|
||||
// LiveReasoningFilter 가 <think>/<|channel|>thought 류 추론 구간을 토큰 단위로
|
||||
// 걸러 사용자가 볼 필요 없는 생각 텍스트는 화면에 흐르지 않는다. false 면
|
||||
// 내부 누적 후 sanitize 된 최종 답변만 한 번에 표시.
|
||||
const postLiveDeltas = loopDepth === 0 && getConfig().liveStreamTokens === true;
|
||||
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
||||
const postLiveToken = (token: string) => {
|
||||
const visible = liveFilter ? liveFilter.push(token) : token;
|
||||
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
||||
};
|
||||
|
||||
let lmStudioStats: ChatStreamStats | undefined;
|
||||
if (useLmStudioSdk) {
|
||||
@@ -970,7 +1016,7 @@ export class AgentExecutor {
|
||||
if (this.isStaleRun(runId)) return;
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
if (stopReason) finishStopReason = stopReason;
|
||||
if (stats) lmStudioStats = stats;
|
||||
@@ -1045,7 +1091,7 @@ export class AgentExecutor {
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
const fr = engine === 'lmstudio'
|
||||
? json.choices?.[0]?.finish_reason
|
||||
@@ -1077,7 +1123,7 @@ export class AgentExecutor {
|
||||
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
|
||||
if (token) {
|
||||
aiResponseText += token;
|
||||
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
|
||||
if (postLiveDeltas) postLiveToken(token);
|
||||
}
|
||||
const fr = engine === 'lmstudio'
|
||||
? json.choices?.[0]?.finish_reason
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { execFile } from 'child_process';
|
||||
import { detectPython, execFileCapture, _resetPythonCache } from '../../lib/execUtil';
|
||||
import type { HandlerContext } from './types';
|
||||
|
||||
// [코어 수렴] 실행 래퍼·Python 탐지는 lib/execUtil 단일 구현을 사용한다.
|
||||
// 기존 소비자(runCode·테스트) 호환을 위해 재수출.
|
||||
export { detectPython, _resetPythonCache };
|
||||
|
||||
/**
|
||||
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
|
||||
*
|
||||
@@ -24,35 +28,6 @@ const MAX_CALC_PER_TURN = 4; // 한 턴에 과도한 실행 방지
|
||||
const MAX_CODE_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> {
|
||||
const { aiMessage, report } = ctx;
|
||||
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
|
||||
@@ -83,32 +58,33 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise<void>
|
||||
}
|
||||
|
||||
const label = code.replace(/\s+/g, ' ').slice(0, 60);
|
||||
try {
|
||||
const { stdout } = await execFileP(python, ['-I', '-c', code], CALC_TIMEOUT_MS);
|
||||
const out = stdout.trim();
|
||||
if (!out) {
|
||||
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
||||
report.push(`⚠️ Calculated (no output): ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
|
||||
});
|
||||
} else {
|
||||
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
|
||||
report.push(`🧮 Calculated: ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
const r = await execFileCapture(python, ['-I', '-c', code], { timeoutMs: CALC_TIMEOUT_MS, maxBuffer: 512 * 1024 });
|
||||
|
||||
if (r.timedOut || r.code !== 0) {
|
||||
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
|
||||
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 || ''));
|
||||
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${timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
|
||||
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) {
|
||||
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
|
||||
report.push(`⚠️ Calculated (no output): ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
|
||||
});
|
||||
} else {
|
||||
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
|
||||
report.push(`🧮 Calculated: ${label}`);
|
||||
ctx.chatHistory.push({
|
||||
role: 'system', internal: true,
|
||||
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { validatePath } from '../../security';
|
||||
import { detectPython } from './calculate';
|
||||
import { detectPython, execFileCapture } from '../../lib/execUtil';
|
||||
import type { HandlerContext } from './types';
|
||||
|
||||
/**
|
||||
@@ -22,19 +21,6 @@ const RUN_TIMEOUT_MS = 15_000;
|
||||
const MAX_RUNS_PER_TURN = 2;
|
||||
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);
|
||||
|
||||
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 res = await execFileP(cmd, args, rootPath);
|
||||
const res = await execFileCapture(cmd, args, { timeoutMs: RUN_TIMEOUT_MS, cwd: rootPath, maxBuffer: 1024 * 1024 });
|
||||
const outBlock = [
|
||||
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
|
||||
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { buildToneGuardBlock } from '../../lib/contextBuilders/toneGuard';
|
||||
import {
|
||||
isProjectKnowledgeCreationRequest,
|
||||
buildAstraStanceContext,
|
||||
@@ -76,7 +77,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
|
||||
|
||||
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
|
||||
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
|
||||
// evidence vs. its own general knowledge for this turn. Suppressed for casual
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { LiveReasoningFilter } from '../../lib/contextBuilders/liveReasoningFilter';
|
||||
import { logError, summarizeText } from '../../utils';
|
||||
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
|
||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||
@@ -35,9 +36,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
|
||||
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
|
||||
let accumulated = '';
|
||||
let finishStopReason: string | undefined;
|
||||
// 라이브 표시 시 추론 구간(<think>/Harmony thought)은 토큰 단위로 차단 — agent.ts 본 스트림과 동일.
|
||||
const liveFilter = new LiveReasoningFilter();
|
||||
const post = (token: string) => {
|
||||
if (params.postLiveDeltas && token) {
|
||||
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
|
||||
const visible = liveFilter.push(token);
|
||||
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
|
||||
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
|
||||
import { getConfig } from '../../config';
|
||||
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
|
||||
import { ErrorTranslator } from '../../core/errorHandler';
|
||||
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
|
||||
import { stripMarkdownFormatting } from '../../core/responseRecovery';
|
||||
import { buildIntentBrief, formatIntentBrief, runReportQaLoop, IntentBrief } from '../../agents/reportQa';
|
||||
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
|
||||
|
||||
export interface WorkflowDeps {
|
||||
@@ -50,6 +51,22 @@ export async function executeMultiAgentWorkflow(
|
||||
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
|
||||
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
|
||||
: '';
|
||||
@@ -64,7 +81,7 @@ export async function executeMultiAgentWorkflow(
|
||||
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
|
||||
prompt,
|
||||
modelName,
|
||||
`${brainContext}${selectedAgentContext}${designerContext}`,
|
||||
`${intentCtx}${brainContext}${selectedAgentContext}${designerContext}`,
|
||||
signal,
|
||||
(step, msg) => {
|
||||
deps.getWebview()?.postMessage({
|
||||
@@ -77,12 +94,32 @@ export async function executeMultiAgentWorkflow(
|
||||
const wv2 = deps.getWebview();
|
||||
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가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
|
||||
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
|
||||
// 마커가 재학습되는 일을 막는다.
|
||||
const finalReport = getConfig().outputFormat === 'plain'
|
||||
? stripMarkdownFormatting(rawFinalReport)
|
||||
: rawFinalReport;
|
||||
? stripMarkdownFormatting(qaCheckedReport)
|
||||
: qaCheckedReport;
|
||||
|
||||
wv2.postMessage({ type: 'streamChunk', value: finalReport });
|
||||
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
|
||||
|
||||
@@ -104,7 +104,6 @@ const confidenceEscalationHook: PostAnswerHook = {
|
||||
runAsync: false,
|
||||
run(ctx: PostAnswerHookContext): void {
|
||||
const cfg = getConfig();
|
||||
if (cfg.confidenceEngineEnabled === false) return;
|
||||
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
|
||||
|
||||
// 검색이 안 돈 turn (casual 등) 은 신호 null → 보수적 기본값 (근거 0건).
|
||||
@@ -118,21 +117,21 @@ const confidenceEscalationHook: PostAnswerHook = {
|
||||
);
|
||||
const confidence = computeConfidence(retrievalSignals, answerSignals);
|
||||
|
||||
// 업무 산출물 turn 에만 footer 표시 — 잡담까지 점수 붙이면 노이즈.
|
||||
// 단, 확신도 '매우 낮음' 은 업무 여부와 무관하게 표시 (T4).
|
||||
const isTask = coverage.ran || coverage.taskId !== undefined;
|
||||
if (!isTask && confidence.band !== 'very-low') return;
|
||||
const decision = decideEscalation({
|
||||
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
||||
});
|
||||
const escalated = decision.escalate;
|
||||
|
||||
let footer = formatConfidenceFooter(confidence);
|
||||
let escalated = false;
|
||||
if (cfg.escalationEnabled !== false) {
|
||||
const decision = decideEscalation({
|
||||
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
|
||||
});
|
||||
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 의 결정론적 회고를
|
||||
// <brain>/.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천.
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
|
||||
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
|
||||
*/
|
||||
import { coreChat } from '../core/services';
|
||||
|
||||
|
||||
export interface SelfCheckOptions {
|
||||
ollamaUrl: string;
|
||||
@@ -141,53 +143,24 @@ export async function postHocSelfCheck(
|
||||
const sourcesCap = (sources || []).slice(0, options.maxSources);
|
||||
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
|
||||
|
||||
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 200토큰).
|
||||
// (이 모듈은 원래 import 0개의 자립 모듈이었으나, 엔진 폴백·로깅 일원화가 더 큰 가치)
|
||||
let raw = '';
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model: options.model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 200 },
|
||||
}
|
||||
: {
|
||||
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
const result = await coreChat({
|
||||
system, user, model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
temperature: 0.0,
|
||||
maxTokens: 200,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
raw = String(
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'',
|
||||
);
|
||||
raw = String(result.content || '');
|
||||
} catch (e: any) {
|
||||
clearTimeout(timer);
|
||||
return {
|
||||
...FAILURE_RESULT,
|
||||
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
|
||||
durationMs: Date.now() - start,
|
||||
rawResponse: '',
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
const parsed = parseResult(raw);
|
||||
|
||||
+14
-71
@@ -1,82 +1,37 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../config';
|
||||
import { coreChat } from '../core/services';
|
||||
import { AgentExecuteOptions } from '../lib/engine';
|
||||
|
||||
export abstract class BaseAgent {
|
||||
constructor(protected readonly modelName: string) {}
|
||||
|
||||
protected async callLLM(persona: string, prompt: string, signal?: AbortSignal): Promise<string> {
|
||||
const { ollamaUrl } = getConfig();
|
||||
if (!ollamaUrl) {
|
||||
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`;
|
||||
|
||||
// 컨텍스트 초과 방지를 위해 출력 토큰 상한을 항상 명시한다 (서브에이전트 중간 산출물용).
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱을 제거하고 core/services 의 coreChat 으로 통일.
|
||||
// 이 레이어는 "시도당 45초 + 3회 재시도(지수 백오프) + Abort 즉시 중단" 정책만 담당하고,
|
||||
// 엔진 선택·폴백·빈응답 처리·로깅은 코어가 책임진다.
|
||||
const { contextLength, maxOutputTokens } = getConfig();
|
||||
const numCtx = Math.max(2048, contextLength);
|
||||
const outCap = Math.max(256, maxOutputTokens);
|
||||
|
||||
let lastError: any;
|
||||
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 {
|
||||
if (attempt > 1) await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(isOllama ? {
|
||||
const result = await coreChat({
|
||||
system: persona,
|
||||
user: prompt,
|
||||
model: this.modelName,
|
||||
messages,
|
||||
stream: false,
|
||||
options: { temperature: 0.3, num_ctx: numCtx, num_predict: outCap }
|
||||
} : {
|
||||
model: this.modelName,
|
||||
messages,
|
||||
stream: false,
|
||||
timeoutMs: 45000,
|
||||
signal,
|
||||
temperature: 0.3,
|
||||
max_tokens: outCap
|
||||
}),
|
||||
signal: combinedSignal
|
||||
});
|
||||
|
||||
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 || '';
|
||||
maxTokens: outCap,
|
||||
numCtx,
|
||||
});
|
||||
return result.content || '';
|
||||
} catch (error: any) {
|
||||
clearTimeout(timeoutId);
|
||||
lastError = error;
|
||||
if (error.name === 'AbortError') break;
|
||||
if (error?.name === 'AbortError') 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>;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
+27
-4
@@ -143,6 +143,16 @@ export interface IAgentConfig {
|
||||
* 깨끗한 답변에는 안 돈다 (latency 보호). 기본 true. (Phase 1 / Track 2-3)
|
||||
*/
|
||||
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 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를
|
||||
* <brain>/.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의
|
||||
@@ -394,8 +404,8 @@ export interface IAgentConfig {
|
||||
// ─── Stream 표시 ───
|
||||
/**
|
||||
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
|
||||
* - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단.
|
||||
* - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음.
|
||||
* - true(기본): 실시간 표시. 추론 구간(<think>/Harmony thought)은 LiveReasoningFilter 가 토큰 단위로 차단.
|
||||
* - false: 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시.
|
||||
*/
|
||||
liveStreamTokens: boolean;
|
||||
/**
|
||||
@@ -460,6 +470,15 @@ function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: n
|
||||
}
|
||||
|
||||
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
|
||||
//
|
||||
// [설정 접근 규칙 — 2026-07-11 아키텍처 감사에서 명문화]
|
||||
// IAgentConfig 에 이미 있는 값(엔진 URL·모델·컨텍스트 한계·두뇌 프로필 등
|
||||
// "코어 계약"에 속하는 값)은 반드시 getConfig() 를 통해 읽는다 — 기본값·클램프·
|
||||
// 레거시 마이그레이션이 여기 한 곳에 있기 때문. 반대로 특정 기능 전용 설정
|
||||
// (예: g1nation.stocks.*, dailyBriefing.time)은 그 기능 파일에서
|
||||
// vscode.workspace.getConfiguration('g1nation') 직접 접근이 허용된다 —
|
||||
// 기능 하나 추가할 때마다 이 인터페이스를 비대하게 만들지 않기 위한 의도적 분리.
|
||||
// 판단 기준: "둘 이상의 모듈이 읽는 값인가?" → 그렇다면 IAgentConfig 로 승격할 것.
|
||||
export function getConfig(): IAgentConfig {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
|
||||
@@ -535,9 +554,13 @@ export function getConfig(): IAgentConfig {
|
||||
requirementGraphEnabled: cfg.get<boolean>('requirementGraphEnabled', true),
|
||||
requirementCoverageEnabled: cfg.get<boolean>('requirementCoverageEnabled', true),
|
||||
epistemicGuardEnabled: cfg.get<boolean>('epistemicGuardEnabled', true),
|
||||
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', true),
|
||||
escalationEnabled: cfg.get<boolean>('escalationEnabled', true),
|
||||
// v2.2.300: footer 기본 비표시 (사용자 피드백) — 계산·Reflection 은 항상 수행.
|
||||
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', false),
|
||||
escalationEnabled: cfg.get<boolean>('escalationEnabled', false),
|
||||
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),
|
||||
orgMemoryEnabled: cfg.get<boolean>('orgMemoryEnabled', true),
|
||||
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
|
||||
|
||||
+3
-6
@@ -2,11 +2,8 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { getConfig } from '../config';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { execShell } from '../lib/execUtil';
|
||||
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
|
||||
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
|
||||
|
||||
@@ -111,10 +108,10 @@ export class HealthCheckMonitor {
|
||||
try {
|
||||
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
|
||||
try {
|
||||
await execAsync('git push --dry-run', {
|
||||
await execShell('git push --dry-run', {
|
||||
cwd: brain.localBrainPath,
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
||||
timeout: 5000,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
const msg = String(e?.stderr || e?.message || '');
|
||||
|
||||
+44
-2
@@ -26,6 +26,12 @@ export interface AIChatRequest {
|
||||
model?: string;
|
||||
/** Optional override (default = config.timeout). */
|
||||
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 로 결합되어, 사용자가 회사 모드
|
||||
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
|
||||
@@ -37,7 +43,7 @@ export interface AIChatRequest {
|
||||
export interface AIChatResult {
|
||||
content: string;
|
||||
/** Engine that actually returned the content. */
|
||||
engine: 'lmstudio' | 'ollama';
|
||||
engine: 'lmstudio' | 'ollama' | 'claude-code';
|
||||
model: string;
|
||||
/** True iff content came back empty after all retries. Caller decides UX. */
|
||||
empty: boolean;
|
||||
@@ -73,6 +79,23 @@ export class AIService implements IAIService {
|
||||
const config = getConfig();
|
||||
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
|
||||
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 engines = primaryEngine === 'lmstudio'
|
||||
? ['lmstudio', 'ollama'] as const
|
||||
@@ -89,11 +112,21 @@ export class AIService implements IAIService {
|
||||
|
||||
for (const engine of engines) {
|
||||
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
|
||||
const temperature = req.temperature ?? 0.7;
|
||||
const payload = {
|
||||
model,
|
||||
messages,
|
||||
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 {
|
||||
@@ -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: 지식 베이스 파일 시스템 저장 및 관리 구현체
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as path from 'path';
|
||||
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
|
||||
import './features/teamops/handlers';
|
||||
import './features/system/handlers';
|
||||
import './features/claude/claudeLauncher'; // /claude — Claude Code CLI 위임 (v2.2.303)
|
||||
import './features/datacollect/handlers';
|
||||
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
|
||||
// axios removed in favor of native fetch
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
|
||||
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
|
||||
@@ -22,25 +23,8 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
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 } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
|
||||
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])) };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30'), 9, 30);
|
||||
}
|
||||
|
||||
/** 다음 발송까지 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 {
|
||||
context: vscode.ExtensionContext;
|
||||
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. */
|
||||
defaultModel: string;
|
||||
/**
|
||||
@@ -667,10 +673,16 @@ async function _dispatchOne(
|
||||
// the whole brain so a missing mapping doesn't starve the
|
||||
// dispatcher.
|
||||
const scope = resolveScopeForAgent(agentId, brainRoot);
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
if (deps.retrieveBrainContext) {
|
||||
// 메인 오케스트레이터 경로 — 임베딩 하이브리드·청크·개선 사항을 그대로 수혜.
|
||||
brainContext = await deps.retrieveBrainContext(task, scope.folders, brainFileLimit);
|
||||
} else {
|
||||
// 경량 폴백 (텔레그램 등 오케스트레이터가 없는 소비자).
|
||||
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
|
||||
maxResults: brainFileLimit,
|
||||
});
|
||||
brainContext = buildContextBlock(retrieval);
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
import { execShell } from '../../lib/execUtil';
|
||||
|
||||
/**
|
||||
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
|
||||
@@ -133,7 +130,7 @@ export async function stopNotebookLmBridge(): Promise<void> {
|
||||
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
|
||||
if (await isBridgeAlive()) {
|
||||
try {
|
||||
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
|
||||
await execShell(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeoutMs: 8000 });
|
||||
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ import { promises as fsp } from 'fs';
|
||||
import * as path from 'path';
|
||||
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
|
||||
import { callLmSynthesis } from './llm';
|
||||
import { getConfig } from '../../config';
|
||||
import { logInfo } from '../../utils';
|
||||
import { runReportQaLoop } from '../../agents/reportQa';
|
||||
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
|
||||
import { collectSourceFiles } from './reviewFiles';
|
||||
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
|
||||
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
|
||||
import { buildHandoffDoc } from './prompts/handoffDoc';
|
||||
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
|
||||
import {
|
||||
type YoutubeAnalysisMode,
|
||||
formatHms,
|
||||
@@ -49,28 +54,64 @@ import {
|
||||
|
||||
// ───────────────────────────── /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);
|
||||
let url = '';
|
||||
let depthArg: number | undefined;
|
||||
let pagesArg: number | undefined;
|
||||
let depth: number | undefined;
|
||||
let pages: number | undefined;
|
||||
let mode: BenchmarkMode = 'full';
|
||||
const restParts: string[] = [];
|
||||
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
|
||||
for (const t of tokens) {
|
||||
const m = /^(depth|pages)=(\d+)$/i.exec(t);
|
||||
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
|
||||
if (m) {
|
||||
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
|
||||
else pagesArg = Number(m[2]);
|
||||
if (m[1].toLowerCase() === 'depth') depth = 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) {
|
||||
url = t;
|
||||
} else {
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
const userContent = restParts.join(' ');
|
||||
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
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');
|
||||
|
||||
// ── 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;
|
||||
if (looksEmpty) {
|
||||
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
|
||||
finalReport = rawReport;
|
||||
} else {
|
||||
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
|
||||
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
|
||||
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 {
|
||||
const parts: string[] = [];
|
||||
for (const part of [1, 2, 3] as const) {
|
||||
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
|
||||
let okParts = 0;
|
||||
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 out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
|
||||
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
|
||||
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
|
||||
// (실사례: 파트 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);
|
||||
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
|
||||
}
|
||||
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
|
||||
finalReport = parts.join('\n\n---\n\n');
|
||||
chunk(view, `\n\n`);
|
||||
} 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');
|
||||
|
||||
try {
|
||||
@@ -1129,7 +1235,7 @@ async function reduceReviewNotes(
|
||||
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
|
||||
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
|
||||
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
|
||||
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/benchmark', description: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', handler: runBenchmark });
|
||||
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
|
||||
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
|
||||
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
|
||||
|
||||
@@ -21,6 +21,10 @@ import * as vscode from 'vscode';
|
||||
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
|
||||
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 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> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
@@ -70,6 +74,9 @@ interface LmOpts {
|
||||
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
|
||||
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
|
||||
maxContinuations?: number;
|
||||
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
|
||||
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
|
||||
@@ -82,6 +89,7 @@ async function callLmOnce(
|
||||
lmUrl: string, model: string, sys: string, prompt: string,
|
||||
sampling: { temperature: number; repeat_penalty: number; top_k: number },
|
||||
maxTokens?: number,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<LmResult> {
|
||||
const payload: Record<string, unknown> = {
|
||||
model,
|
||||
@@ -96,7 +104,7 @@ async function callLmOnce(
|
||||
};
|
||||
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
|
||||
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 content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
|
||||
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
|
||||
};
|
||||
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;
|
||||
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
|
||||
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++) {
|
||||
let next: LmResult;
|
||||
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; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
|
||||
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
|
||||
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)의
|
||||
@@ -149,19 +156,24 @@ ${commonHeader}
|
||||
### 1-2. 타이포그래피 (Typography)
|
||||
|
||||
## 2. 레이아웃 및 여백 (Layout & Whitespace)
|
||||
### 2-1. 그리드 시스템 (Grid System)
|
||||
### 2-2. 섹션 간 여백 (Section Spacing)
|
||||
### 2-3. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-4. Border Radius / 컨테이너
|
||||
### 2-1. 페이지 골격 서술 (Page Skeleton — 위→아래)
|
||||
- \`structure.sections\` 와 \`sitemap.pages[].sectionRoles\` 를 근거로, 홈과 대표 페이지의 블록 구성을 **위→아래 순서 문장**으로 서술하라 (예: "헤더/GNB → 히어로 배너 → 최신 글 카드 그리드(이미지 40장) → 구독 폼 → 푸터").
|
||||
- 각 섹션의 \`hint\`(class/id) 신호로 위젯을 판별하라: carousel/swiper/slick/slider = 롤링 배너, hero/banner = 정적 배너, grid/card/list = 카드 그리드 등. **배너가 롤링(캐러셀)인지 정적인지 반드시 판정**하고, 신호가 없으면 "캐러셀 class 신호 없음 — 정적으로 추정"이라고 명시하라 (지어내기 금지).
|
||||
### 2-2. 그리드 시스템 (Grid System)
|
||||
### 2-3. 섹션 간 여백 (Section Spacing)
|
||||
### 2-4. 카드/카드 그리드 (Card Spacing)
|
||||
### 2-5. Border Radius / 컨테이너
|
||||
|
||||
## 3. 마이크로 인터랙션 (Micro Interaction)
|
||||
### 3-1. Hover / Focus 효과
|
||||
### 3-2. Transition 패턴
|
||||
### 3-3. 레이어링 (z-index / position)
|
||||
|
||||
## 4. 라이팅 톤앤매너 (Microcopy & Voice)
|
||||
### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-2. Placeholder 및 보이스 신호`
|
||||
## 4. 톤앤매너 (Tone & Manner)
|
||||
### 4-1. 시각적 톤앤매너 한 문단 (Visual Personality)
|
||||
- 컬러 비율·폰트·radius·여백 수치를 종합해 이 사이트의 시각적 성격을 한 문단으로 규정하라 (예: "무채색 80% + 하늘색 포인트, 넉넉한 여백, 산세리프 — 정보 전달 중심의 차분한 블로그 톤"). 수치를 인용하되 성격 규정은 문장으로.
|
||||
### 4-2. 헤드라인 / 서브헤드라인 / CTA 카피
|
||||
### 4-3. Placeholder 및 보이스 신호`
|
||||
: part === 2
|
||||
? `
|
||||
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
|
||||
@@ -199,7 +211,7 @@ ${commonHeader}
|
||||
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
|
||||
|
||||
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
|
||||
: `
|
||||
: part === 3 ? `
|
||||
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
|
||||
|
||||
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
|
||||
@@ -230,13 +242,70 @@ ${commonHeader}
|
||||
|
||||
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·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
|
||||
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
? '이번 단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
|
||||
: part === 2
|
||||
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
|
||||
? '이번 단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
|
||||
: part === 3
|
||||
? '이번 단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'
|
||||
: part === 4
|
||||
? '이번 단계: 이 사이트를 하나의 *사업*으로 분석한다 — 무엇을 팔고, 어떻게 돈을 벌고, 내가 비슷한 서비스를 만든다면 어떻게 운영할지. 스캔 데이터에 있는 문구·가격·CTA 를 근거로 하되, 스캔에 없는 가격·매출은 지어내지 말고 "스캔 데이터 부족"으로 표기한다.'
|
||||
: '이번 단계: 앞선 분석을 바탕으로, 바이브코딩 IDE(Antigravity 등)에 순서대로 붙여넣기만 하면 이 사이트와 매우 유사한 서비스가 만들어지는 **스텝별 빌드 프롬프트**를 작성한다. 각 프롬프트는 자기완결적이어야 한다 (앞 스텝 결과를 전제하되 필요한 값은 다시 명시).';
|
||||
|
||||
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
|
||||
${sharedRules}
|
||||
|
||||
Binary file not shown.
@@ -18,6 +18,7 @@
|
||||
* 수동 명령과 완전 호환.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -47,23 +48,11 @@ let _timer: NodeJS.Timeout | undefined;
|
||||
let _disposed = false;
|
||||
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 } {
|
||||
const cfg = vscode.workspace.getConfiguration('g1nation');
|
||||
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 m = raw.match(/^(\d{1,2}):(\d{2})$/);
|
||||
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
|
||||
const t = parseHhMm(cfg.get<string>('growthCycle.time', '20:00'), 20, 0);
|
||||
return { day, hour: t.hour, minute: t.minute };
|
||||
}
|
||||
|
||||
function msUntilNextFire(): number {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import { getConfig } from '../../config';
|
||||
@@ -213,19 +214,8 @@ export async function runSleepDigestOnce(): Promise<string> {
|
||||
|
||||
// ── 일일 스케줄러 (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 } {
|
||||
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
|
||||
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 };
|
||||
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00'), 3, 0);
|
||||
}
|
||||
|
||||
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 { streamAnthropic, listAnthropicModels } from './anthropic';
|
||||
import { streamGemini, listGeminiModels } from './gemini';
|
||||
import { streamClaudeCode, listClaudeCodeModels, resolveClaudeCli } from './claudeCode';
|
||||
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
|
||||
|
||||
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
|
||||
@@ -31,6 +32,7 @@ export async function streamCloudCompletion(
|
||||
case 'openrouter': return streamOpenRouter(context, fullParams);
|
||||
case 'anthropic': return streamAnthropic(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}` });
|
||||
}));
|
||||
}
|
||||
// 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);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
|
||||
*/
|
||||
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
|
||||
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini' | 'claude-code';
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
@@ -32,6 +32,8 @@ const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
|
||||
{ prefix: 'openrouter:', id: 'openrouter' },
|
||||
{ prefix: 'anthropic:', id: 'anthropic' },
|
||||
{ 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;
|
||||
}
|
||||
|
||||
/** 역변환 — 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 {
|
||||
return `${provider}:${model}`;
|
||||
const hit = PROVIDER_PREFIXES.find(p => p.id === provider);
|
||||
return `${hit ? hit.prefix : provider + ':'}${model}`;
|
||||
}
|
||||
|
||||
/** UI 표시용 짧은 라벨. */
|
||||
export function providerLabel(p: ProviderId): string {
|
||||
switch (p) {
|
||||
case 'claude-code': return 'Claude 구독';
|
||||
case 'openrouter': return 'OpenRouter';
|
||||
case 'anthropic': return 'Anthropic';
|
||||
case 'gemini': return 'Gemini';
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
* - 워크스페이스 외부 경로 무시
|
||||
* - 사용자가 `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 fs from 'fs';
|
||||
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
|
||||
* still produces the usual "미설치" warning via the spawn-failure path.
|
||||
*/
|
||||
let _pythonCmdCache: string | undefined;
|
||||
// [코어 수렴] Python 탐지는 lib/execUtil 의 단일 구현·공유 캐시를 쓴다.
|
||||
// 미설치 시에도 플랫폼 기본 후보를 돌려줘 spawn-실패 경로가 기존처럼 "미설치" 경고를 만든다.
|
||||
function _resolvePythonCmd(): string {
|
||||
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
|
||||
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;
|
||||
return detectPythonSync() ?? PYTHON_CANDIDATES[0];
|
||||
}
|
||||
|
||||
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
|
||||
*/
|
||||
import * as vscode from 'vscode';
|
||||
import { PYTHON_CANDIDATES } from '../../lib/execUtil';
|
||||
import { spawn } from 'child_process';
|
||||
import { logInfo, logError } from '../../utils';
|
||||
|
||||
@@ -44,9 +45,9 @@ export async function probePythonEnv(): Promise<PythonProbe> {
|
||||
};
|
||||
|
||||
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
|
||||
const candidates = process.platform === 'win32'
|
||||
? ['python', 'py', 'python3']
|
||||
: ['python3', 'python'];
|
||||
// [코어 수렴] 후보 순서는 lib/execUtil 과 공유 (이 probe 는 Python3 버전·패키지 검증까지
|
||||
// 수행하는 특수 경로라 탐지 로직 자체는 유지한다).
|
||||
const candidates = PYTHON_CANDIDATES;
|
||||
|
||||
for (const cmd of candidates) {
|
||||
const ver = await _capture(cmd, ['--version'], 5_000);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { nowInKst } from '../../lib/kstSchedule';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
import { readStocksStore, writeStocksStore } from './stocksStore';
|
||||
import { fetchAllPrices } from './yahooClient';
|
||||
@@ -32,23 +33,6 @@ function scheduleTimesKst(): { hour: number; minute: number }[] {
|
||||
let _timer: NodeJS.Timeout | undefined;
|
||||
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 계산.
|
||||
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
|
||||
|
||||
+13
-30
@@ -1,3 +1,4 @@
|
||||
import { coreChat } from '../core/services';
|
||||
/**
|
||||
* 단순 non-streaming LLM 호출 — Ollama / LM Studio(OpenAI 호환) 듀얼 엔드포인트.
|
||||
*
|
||||
@@ -19,34 +20,16 @@ export async function simpleChatCompletion(
|
||||
user: string,
|
||||
options: SimpleChatOptions,
|
||||
): Promise<string> {
|
||||
const isOllama = options.baseUrl.includes(':11434') || options.baseUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${options.baseUrl}/api/chat` : `${options.baseUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 120000);
|
||||
try {
|
||||
const messages = [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
];
|
||||
const body = isOllama
|
||||
? { 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}`);
|
||||
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);
|
||||
}
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — core/services.coreChat 으로 통일.
|
||||
// options.baseUrl 은 하위 호환용으로 남지만 무시된다 (코어가 설정의 ollamaUrl 사용 —
|
||||
// 기존 호출자들도 전부 config.ollamaUrl 을 넘기고 있었으므로 동작 동일).
|
||||
const result = await coreChat({
|
||||
system,
|
||||
user,
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs ?? 120000,
|
||||
temperature: options.temperature ?? 0.2,
|
||||
maxTokens: options.maxTokens ?? 1200,
|
||||
});
|
||||
return result.content;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* 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 },
|
||||
];
|
||||
|
||||
/** 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;
|
||||
|
||||
/**
|
||||
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
||||
* 반환값이 빈 문자열이면 이번 토큰은 전부 (아직) 숨김.
|
||||
*/
|
||||
push(token: string): string {
|
||||
this.pending += token;
|
||||
let out = '';
|
||||
|
||||
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
||||
for (;;) {
|
||||
if (this.mode === 'visible') {
|
||||
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;
|
||||
}
|
||||
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
||||
this.pending = this.pending.slice(-24);
|
||||
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;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -428,7 +428,17 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
||||
// 지식 스코프와 무관하게(에이전트 스코프가 있어도) 행동 제약으로 항상 주입.
|
||||
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, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||
return memoryBlock;
|
||||
}
|
||||
|
||||
@@ -71,6 +71,13 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
|
||||
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
|
||||
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
|
||||
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
|
||||
// [v2.2.301] 명시적 조사·보고서 요청은 절대 임계값 게이트보다 우선 발동 —
|
||||
// 짧은 "X 조사해줘"도 Report QA 파이프라인(의도 브리핑→채점→회귀 게이트)을
|
||||
// 타야 하기 때문. '요약/리뷰' 류 일반 키워드는 기존 결정대로 게이트 아래 유지.
|
||||
if (/(조사|리서치|보고서|레포트)/.test(prompt) || /\b(research|report)\b/i.test(prompt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const promptTokensForGate = estimateTokens(prompt);
|
||||
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
|
||||
|
||||
@@ -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])) };
|
||||
}
|
||||
@@ -16,22 +16,12 @@ const DEFAULT_BUDGET: ContextBudgetConfig = {
|
||||
maxChunks: 12
|
||||
};
|
||||
|
||||
/**
|
||||
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
|
||||
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
|
||||
*/
|
||||
export function estimateTokens(text: string): number {
|
||||
// 한국어 비율 추정
|
||||
const koreanChars = (text.match(/[가-힣]/g) || []).length;
|
||||
const totalChars = text.length;
|
||||
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
|
||||
// [코어 수렴] 토큰 추정은 lib/contextManager 의 단일 구현을 쓴다 (CJK 전반 커버·
|
||||
// 보수적 계수). 예전 이 파일의 자체 추정(한글만 1.5/0.25)은 제거 — 기존 소비자
|
||||
// 호환을 위해 같은 이름으로 재수출.
|
||||
import { estimateTokens } from '../lib/contextManager';
|
||||
export { estimateTokens };
|
||||
|
||||
// 한국어는 글자당 ~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 path from 'path';
|
||||
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 */ }
|
||||
}
|
||||
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
|
||||
// [조용한 폴백 금지] 설정은 돼 있는데 폴더가 없으면(예: 지식 재편 후 옛 경로) 로그로 드러낸다.
|
||||
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 {
|
||||
domain,
|
||||
|
||||
@@ -228,6 +228,42 @@ export class RetrievalOrchestrator {
|
||||
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
|
||||
* (`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(
|
||||
query: string,
|
||||
brain: BrainProfile,
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { RetrievalChunk } from './types';
|
||||
import { coreChat } from '../core/services';
|
||||
|
||||
export interface SemanticRerankOptions {
|
||||
ollamaUrl: string;
|
||||
@@ -94,46 +95,15 @@ async function callLlmForRerank(
|
||||
user: string,
|
||||
timeoutMs: number,
|
||||
): Promise<string> {
|
||||
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
|
||||
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const body = isOllama
|
||||
? {
|
||||
model, stream: false,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
options: { temperature: 0.0, num_predict: 256 },
|
||||
}
|
||||
: {
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: user },
|
||||
],
|
||||
stream: false, temperature: 0.0, max_tokens: 256,
|
||||
};
|
||||
const res = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data: any = await res.json();
|
||||
const content =
|
||||
data?.message?.content ??
|
||||
data?.choices?.[0]?.message?.content ??
|
||||
data?.choices?.[0]?.text ??
|
||||
data?.response ??
|
||||
'';
|
||||
return String(content || '');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 256토큰).
|
||||
const result = await coreChat({
|
||||
system, user, model,
|
||||
timeoutMs,
|
||||
temperature: 0.0,
|
||||
maxTokens: 256,
|
||||
});
|
||||
return String(result.content || '');
|
||||
|
||||
}
|
||||
|
||||
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
|
||||
|
||||
@@ -23,6 +23,8 @@ import { getConfig } from '../../config';
|
||||
export interface DispatcherDepsInputs {
|
||||
context: vscode.ExtensionContext;
|
||||
ai: AIService;
|
||||
/** [코어 수렴] 오케스트레이터 기반 두뇌 검색 thunk (AgentExecutor.retrieveBrainBlockForCompany). */
|
||||
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
|
||||
signal: AbortSignal;
|
||||
onEvent: (event: CompanyTurnEvent) => void;
|
||||
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
|
||||
@@ -44,6 +46,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
|
||||
return {
|
||||
context: inputs.context,
|
||||
ai: inputs.ai,
|
||||
retrieveBrainContext: inputs.retrieveBrainContext,
|
||||
defaultModel: cfg.defaultModel || 'gemma4:e2b',
|
||||
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
|
||||
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
|
||||
|
||||
@@ -1868,6 +1868,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
|
||||
const deps = buildDispatcherDeps({
|
||||
context: this._context,
|
||||
ai,
|
||||
// [코어 수렴] 기업 모드 검색을 메인 오케스트레이터 경로로 승격.
|
||||
retrieveBrainContext: (q, folders, limit) => this._agent.retrieveBrainBlockForCompany(q, folders, limit),
|
||||
signal: abort.signal,
|
||||
onEvent: emitter.emit,
|
||||
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { buildToneGuardBlock } from './lib/contextBuilders/toneGuard';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
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]
|
||||
- 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.)
|
||||
|
||||
@@ -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,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']);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
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', () => {
|
||||
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,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,65 @@
|
||||
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('답변 끝 <');
|
||||
});
|
||||
});
|
||||
@@ -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('표면 질문만 문자적으로 답하지 마라');
|
||||
});
|
||||
});
|
||||
@@ -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 상투어 금지]');
|
||||
});
|
||||
});
|
||||
+14
-3
@@ -42,9 +42,20 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
|
||||
expect(r!.dir).toBe(path.join('E:/ws', '.astra', 'wiki'));
|
||||
});
|
||||
|
||||
test('상대경로 설정은 무시하고 다음 후보로 (경로 오염 방지)', () => {
|
||||
const r = pickWikiDir('상대/경로', 'E:/brain', null);
|
||||
expect(r!.label).toBe('두뇌');
|
||||
test('[v2.2.304] 상대경로 설정은 두뇌 루트 기준으로 해석 (이식성)', () => {
|
||||
const r = pickWikiDir('상대/경로', '/brain', null);
|
||||
expect(r!.label).toBe('설정 폴더(두뇌 기준)');
|
||||
expect(r!.dir.replace(/\\/g, '/')).toContain('/brain/상대/경로');
|
||||
});
|
||||
|
||||
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
|
||||
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
|
||||
expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
|
||||
});
|
||||
|
||||
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
||||
const r = pickWikiDir('상대/경로', '', '/ws');
|
||||
expect(r!.label).toBe('워크스페이스');
|
||||
});
|
||||
|
||||
test('④ 전부 없으면 null', () => {
|
||||
|
||||
Reference in New Issue
Block a user