diff --git a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json index 27a02cb..d95d5e2 100644 --- a/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json +++ b/.astra/tests/engine/.astra/cache/7fa9e2c0ed212d5dbde1172e996cde86955f34dda22a6e02b95c9adc0a456927.json @@ -1,5 +1,5 @@ { "result": "직답 결과 — single-pass mock 응답입니다.", - "createdAt": 1783259143996, + "createdAt": 1783757602271, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json index fa66f5a..da05e71 100644 --- a/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json +++ b/.astra/tests/engine/.astra/cache/8c208151bed9108b665cd93e98fc10d377a9fef641dd359504b8d53aecd0a4c3.json @@ -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" } \ No newline at end of file diff --git a/.astra/tests/engine/.astra/missions/wiki_on.json b/.astra/tests/engine/.astra/missions/wiki_on.json index e57df1e..9816569 100644 --- a/.astra/tests/engine/.astra/missions/wiki_on.json +++ b/.astra/tests/engine/.astra/missions/wiki_on.json @@ -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": { diff --git a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json index 3630481..83dfebc 100644 --- a/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json +++ b/.astra/tests/stress/.astra/cache/21818066876cbf8515758bc351bb3d9d44f32b0e4cd024b2e055db3a0d34417e.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1783259150864, + "createdAt": 1783757609132, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json index 3630481..83dfebc 100644 --- a/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json +++ b/.astra/tests/stress/.astra/cache/4fc755e372f1dd80d6bffc7b2ef973124fb64ba505f767c53a783833bbc3fa6a.json @@ -1,5 +1,5 @@ { "result": "Final report with inconsistencies. This should be long enough to pass validation.", - "createdAt": 1783259150864, + "createdAt": 1783757609132, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json index ffeb18e..7cf6c34 100644 --- a/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json +++ b/.astra/tests/stress/.astra/cache/6e559207c4542d959700ff14f360e6575e54853929e991e579e318f2f5a19030.json @@ -1,5 +1,5 @@ { "result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]", - "createdAt": 1783259150863, + "createdAt": 1783757609131, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json index f9efc0e..77d370c 100644 --- a/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json +++ b/.astra/tests/stress/.astra/cache/f65136cebc95448a7e93a45745cb73b3a5a01af5d82391ec29a25bd72b8239a5.json @@ -1,5 +1,5 @@ { "result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.", - "createdAt": 1783259150863, + "createdAt": 1783757609131, "modelVersion": "unknown" } \ No newline at end of file diff --git a/.astra/tests/stress/.astra/missions/stress_conflict_1783259150850.json b/.astra/tests/stress/.astra/missions/stress_conflict_1783757609119.json similarity index 78% rename from .astra/tests/stress/.astra/missions/stress_conflict_1783259150850.json rename to .astra/tests/stress/.astra/missions/stress_conflict_1783757609119.json index daf57f0..f5af788 100644 --- a/.astra/tests/stress/.astra/missions/stress_conflict_1783259150850.json +++ b/.astra/tests/stress/.astra/missions/stress_conflict_1783757609119.json @@ -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": { diff --git a/PATCHNOTES.md b/PATCHNOTES.md index ea59c4c..ae1e4c3 100644 --- a/PATCHNOTES.md +++ b/PATCHNOTES.md @@ -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 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 [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 …`, `…`)이 화면에 그대로 흐르다가 완료 시에야 사라지던 문제, 그리고 인사말에까지 "확신도 40/100 · 사람 확인 필요" footer 가 붙던 노이즈를 정리. +- **LiveReasoningFilter (신규)**: 라이브 스트리밍(liveStreamTokens=true, 기본)에서 추론 구간을 *토큰 단위로* 걸러 화면에 아예 흐르지 않게 차단. 답변 본문은 기존처럼 실시간 표시 (TTFT 체감 유지). 마커가 토큰 두 개에 걸쳐 쪼개져 와도(``) 잡는 보류(holdback) 상태 기계. Harmony 채널(thought/analysis/commentary/reasoning 숨김, final 통과)·``/``/`` 지원. 놓친 마커는 완료 시 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 는 분류 표시용이라 복원이 불가능했다. diff --git a/media/settings-panel.html b/media/settings-panel.html index 5945849..88f7096 100644 --- a/media/settings-panel.html +++ b/media/settings-panel.html @@ -182,32 +182,32 @@

단계별 지식 폴더 (소형 모델 정밀 검색)

-

질문이 수학·논리 / 코딩·디버깅 / 일반 상식으로 분류되면 전체 두뇌 대신 General + 해당 Specialty 폴더만 검색해 무관한 문서를 배제합니다. 경로는 활성 두뇌 기준 상대 경로이며, Specialty 가 비어 있는 단계는 기존처럼 전체 두뇌를 검색합니다.

+

질문이 수학·논리 / 코딩·디버깅 / 일반 상식으로 분류되면 전체 두뇌 대신 General(공통) + 해당 Specialty 폴더만 검색해 무관한 문서를 배제합니다. 멀티 에이전트 지식 체계(_Common + Domain_*)와 짝을 이루는 기능으로, 경로는 활성 두뇌 기준 상대 경로(절대 경로도 가능)입니다. Specialty 가 비어 있거나 폴더가 없는 단계는 전체 두뇌를 검색하며, 에이전트별 지식 매핑(agent-knowledge-map)이 있으면 그쪽이 우선합니다.

- +
- +
- +
- +
diff --git a/package.json b/package.json index aa7a3ab..b7b1800 100644 --- a/package.json +++ b/package.json @@ -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, diff --git a/src/agent.ts b/src/agent.ts index 850c0df..bda6fff 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -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 { + 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((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 가 /<|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 diff --git a/src/agent/actions/calculate.ts b/src/agent/actions/calculate.ts index 334996d..ee6fcac 100644 --- a/src/agent/actions/calculate.ts +++ b/src/agent/actions/calculate.ts @@ -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 }; + /** * — 수학·논리·수치 계산을 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 { - 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 { const { aiMessage, report } = ctx; const calcRegex = /([\s\S]*?)<\/calculate>/gi; @@ -83,32 +58,33 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise } 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 를 추가해 를 다시 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에러를 참고해 코드를 고친 뒤 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`, + content: `[Result of calculate — ERROR]\n${r.timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 를 다시 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 를 추가해 를 다시 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이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`, }); } } diff --git a/src/agent/actions/runCode.ts b/src/agent/actions/runCode.ts index 5dbbed1..935793a 100644 --- a/src/agent/actions/runCode.ts +++ b/src/agent/actions/runCode.ts @@ -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 { @@ -87,7 +73,7 @@ export async function applyRunCodeActions(ctx: HandlerContext): Promise { } 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())}` : '', diff --git a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts index 2549f5d..1a10959 100644 --- a/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts +++ b/src/agent/handlePrompt/buildAstraModeSystemPrompt.ts @@ -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 diff --git a/src/agent/llm/streamChatOnce.ts b/src/agent/llm/streamChatOnce.ts index 3a5b3f3..f51c23b 100644 --- a/src/agent/llm/streamChatOnce.ts +++ b/src/agent/llm/streamChatOnce.ts @@ -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; + // 라이브 표시 시 추론 구간(/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 }); } }; diff --git a/src/agent/multiAgent/workflow.ts b/src/agent/multiAgent/workflow.ts index e4a42d7..84e21c2 100644 --- a/src/agent/multiAgent/workflow.ts +++ b/src/agent/multiAgent/workflow.ts @@ -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 } }); diff --git a/src/agent/postAnswerHooks/index.ts b/src/agent/postAnswerHooks/index.ts index c206d62..5bd5da3 100644 --- a/src/agent/postAnswerHooks/index.ts +++ b/src/agent/postAnswerHooks/index.ts @@ -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 의 결정론적 회고를 // /.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천. diff --git a/src/agent/postHocSelfCheck.ts b/src/agent/postHocSelfCheck.ts index e4605b3..408df5d 100644 --- a/src/agent/postHocSelfCheck.ts +++ b/src/agent/postHocSelfCheck.ts @@ -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); diff --git a/src/agents/factory.ts b/src/agents/factory.ts index 4b09a2f..08d65e4 100644 --- a/src/agents/factory.ts +++ b/src/agents/factory.ts @@ -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 { - 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; } -// 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. diff --git a/src/agents/reportQa.ts b/src/agents/reportQa.ts new file mode 100644 index 0000000..248d3fd --- /dev/null +++ b/src/agents/reportQa.ts @@ -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 { + 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 { + 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 }; +} diff --git a/src/config.ts b/src/config.ts index ada53b9..c00ab52 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를 * /.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의 @@ -394,8 +404,8 @@ export interface IAgentConfig { // ─── Stream 표시 ─── /** * 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부. - * - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단. - * - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음. + * - true(기본): 실시간 표시. 추론 구간(/Harmony thought)은 LiveReasoningFilter 가 토큰 단위로 차단. + * - false: 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시. */ liveStreamTokens: boolean; /** @@ -460,6 +470,15 @@ function toBrainProfile(raw: Partial | 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('requirementGraphEnabled', true), requirementCoverageEnabled: cfg.get('requirementCoverageEnabled', true), epistemicGuardEnabled: cfg.get('epistemicGuardEnabled', true), - confidenceEngineEnabled: cfg.get('confidenceEngineEnabled', true), - escalationEnabled: cfg.get('escalationEnabled', true), + // v2.2.300: footer 기본 비표시 (사용자 피드백) — 계산·Reflection 은 항상 수행. + confidenceEngineEnabled: cfg.get('confidenceEngineEnabled', false), + escalationEnabled: cfg.get('escalationEnabled', false), criticLoopEnabled: cfg.get('criticLoopEnabled', true), + reportQaEnabled: cfg.get('reportQaEnabled', true), + reportQaThreshold: Math.max(50, Math.min(95, cfg.get('reportQaThreshold', 80))), + reportQaMaxRevisions: Math.max(0, Math.min(5, cfg.get('reportQaMaxRevisions', 2))), reflectionEnabled: cfg.get('reflectionEnabled', true), orgMemoryEnabled: cfg.get('orgMemoryEnabled', true), actionabilityEnabled: cfg.get('actionabilityEnabled', true), diff --git a/src/core/health.ts b/src/core/health.ts index ec10fe7..cdadc35 100644 --- a/src/core/health.ts +++ b/src/core/health.ts @@ -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 || ''); diff --git a/src/core/services.ts b/src/core/services.ts index 893ddd6..a32e187 100644 --- a/src/core/services.ts +++ b/src/core/services.ts @@ -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 { + return new AIService().chat(req); +} + /** * BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체 */ diff --git a/src/extension.ts b/src/extension.ts index 9e680cc..1117d38 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -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 diff --git a/src/features/briefing/dailyBriefing.ts b/src/features/briefing/dailyBriefing.ts index 0f7a307..e9e8a52 100644 --- a/src/features/briefing/dailyBriefing.ts +++ b/src/features/briefing/dailyBriefing.ts @@ -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('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('dailyBriefing.time', '09:30'), 9, 30); } /** 다음 발송까지 ms — 평일 스킵 포함. */ diff --git a/src/features/claude/claudeLauncher.ts b/src/features/claude/claudeLauncher.ts new file mode 100644 index 0000000..ba37ec8 --- /dev/null +++ b/src/features/claude/claudeLauncher.ts @@ -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 { + 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, +}); diff --git a/src/features/company/dispatcher.ts b/src/features/company/dispatcher.ts index ba7bf1e..2b4b46d 100644 --- a/src/features/company/dispatcher.ts +++ b/src/features/company/dispatcher.ts @@ -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; /** 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.', { diff --git a/src/features/datacollect/bridgeLauncher.ts b/src/features/datacollect/bridgeLauncher.ts index 384b7f2..53edc00 100644 --- a/src/features/datacollect/bridgeLauncher.ts +++ b/src/features/datacollect/bridgeLauncher.ts @@ -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 { 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 { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ } } diff --git a/src/features/datacollect/handlers.ts b/src/features/datacollect/handlers.ts index c6c46a9..4e906e4 100644 --- a/src/features/datacollect/handlers.ts +++ b/src/features/datacollect/handlers.ts @@ -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 { +/** + * /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> = { + 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(['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 { + const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg); if (!url) { - chunk(view, `사용법: \`/benchmark [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`); + chunk(view, [ + '사용법: `/benchmark [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('datacollectCrawlDepth', 1) ?? 1); @@ -126,23 +167,84 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise('defaultModel', '') || 'gemma4:e2b').trim(); - chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`); + const partList = BENCHMARK_MODE_PARTS[mode]; + const PART_LABELS: Record = { + 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 { 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 { const payload: Record = { 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(); diff --git a/src/features/datacollect/prompts/handoffDoc.ts b/src/features/datacollect/prompts/handoffDoc.ts new file mode 100644 index 0000000..910a90e --- /dev/null +++ b/src/features/datacollect/prompts/handoffDoc.ts @@ -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)} +\`\`\` +`; +} diff --git a/src/features/datacollect/prompts/layoutSkeleton.ts b/src/features/datacollect/prompts/layoutSkeleton.ts new file mode 100644 index 0000000..cb73f37 --- /dev/null +++ b/src/features/datacollect/prompts/layoutSkeleton.ts @@ -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(); + 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'); +} diff --git a/src/features/datacollect/prompts/synthesisPrompt.ts b/src/features/datacollect/prompts/synthesisPrompt.ts index 727b257..11bd17c 100644 --- a/src/features/datacollect/prompts/synthesisPrompt.ts +++ b/src/features/datacollect/prompts/synthesisPrompt.ts @@ -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} diff --git a/src/features/datacollect/wikiSave.ts b/src/features/datacollect/wikiSave.ts index 94103e6..2bbca8b 100644 Binary files a/src/features/datacollect/wikiSave.ts and b/src/features/datacollect/wikiSave.ts differ diff --git a/src/features/growth/growthCycleWatcher.ts b/src/features/growth/growthCycleWatcher.ts index 2325673..b347dc9 100644 --- a/src/features/growth/growthCycleWatcher.ts +++ b/src/features/growth/growthCycleWatcher.ts @@ -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('growthCycle.day', 0))); - const raw = (cfg.get('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('growthCycle.time', '20:00'), 20, 0); + return { day, hour: t.hour, minute: t.minute }; } function msUntilNextFire(): number { diff --git a/src/features/growth/sleepDigest.ts b/src/features/growth/sleepDigest.ts index 274a64b..e97106f 100644 --- a/src/features/growth/sleepDigest.ts +++ b/src/features/growth/sleepDigest.ts @@ -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 { // ── 일일 스케줄러 (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('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('sleepDigest.time', '03:00'), 3, 0); } function msUntilNextFire(): number { diff --git a/src/features/providers/claudeCode.ts b/src/features/providers/claudeCode.ts new file mode 100644 index 0000000..65de2f6 --- /dev/null +++ b/src/features/providers/claudeCode.ts @@ -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('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 { + 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(`\n${sys.join('\n\n')}\n`); + 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 { + 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((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 { + 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' }, + }); + } +} diff --git a/src/features/providers/index.ts b/src/features/providers/index.ts index 8bb2512..19a70af 100644 --- a/src/features/providers/index.ts +++ b/src/features/providers/index.ts @@ -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; } diff --git a/src/features/providers/types.ts b/src/features/providers/types.ts index e7b7707..1461960 100644 --- a/src/features/providers/types.ts +++ b/src/features/providers/types.ts @@ -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'; diff --git a/src/features/selfReflector/selfReflectorExecution.ts b/src/features/selfReflector/selfReflectorExecution.ts index 1a99f77..7c70df8 100644 --- a/src/features/selfReflector/selfReflectorExecution.ts +++ b/src/features/selfReflector/selfReflectorExecution.ts @@ -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). */ diff --git a/src/features/setup/datacollectSetup.ts b/src/features/setup/datacollectSetup.ts index 06f28eb..9f89427 100644 --- a/src/features/setup/datacollectSetup.ts +++ b/src/features/setup/datacollectSetup.ts @@ -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 { }; // 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); diff --git a/src/features/stocks/stocksWatcher.ts b/src/features/stocks/stocksWatcher.ts index d99155b..ae224dd 100644 --- a/src/features/stocks/stocksWatcher.ts +++ b/src/features/stocks/stocksWatcher.ts @@ -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 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각. diff --git a/src/intelligence/llmCall.ts b/src/intelligence/llmCall.ts index cac6b79..9acef85 100644 --- a/src/intelligence/llmCall.ts +++ b/src/intelligence/llmCall.ts @@ -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 { - 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; } diff --git a/src/lib/contextBuilders/liveReasoningFilter.ts b/src/lib/contextBuilders/liveReasoningFilter.ts new file mode 100644 index 0000000..ae6fea0 --- /dev/null +++ b/src/lib/contextBuilders/liveReasoningFilter.ts @@ -0,0 +1,184 @@ +/** + * LiveReasoningFilter — 스트리밍 *도중* 모델의 내부 추론을 화면에서 차단하는 + * 상태 기계 (per-generation 1 인스턴스, stateful). + * + * 배경: liveStreamTokens=true(기본) 라이브 스트리밍에서 `<|channel|>thought …` / + * `` 같은 추론 구간이 생성 내내 그대로 노출됐다가 완료 시 + * streamReplace 로만 사라졌다. 사용자가 볼 필요 없는 텍스트가 답변 대기 내내 + * 화면을 채우는 문제 — 이 필터가 토큰 단위로 추론 구간을 걸러 "보여도 되는" + * 델타만 통과시킨다. 최종 정리는 여전히 outputSanitization(streamReplace)이 + * 책임지므로, 여기서 놓친 마커는 완료 시 반드시 정리된다 (이중 방어). + * + * 처리하는 마커 (outputSanitization.sanitizeAssistantContent 와 동일 계열): + * - `` / `` / `` + * - Harmony 채널: `<|channel|>thought|analysis|commentary|reasoning …` 는 숨기고, + * `<|channel|>final` (+ 뒤따르는 `<|message|>`) 마커는 제거 후 본문 통과. + * `<|end|>` / `<|return|>` 는 숨김 종료. + * + * 토큰 경계 대응: 마커가 토큰 두 개에 걸쳐 쪼개져 와도 (``) 잡히도록, + * 열림 마커의 접두사가 될 수 있는 꼬리는 emit 을 보류(holdback)한다. + * "Thinking Process:" 류 평문 휴리스틱은 라이브에서는 오탐 위험이 커서 다루지 + * 않는다 — 완료 시 streamReplace 가 정리. + */ + +const TAG_OPENERS: { literal: string; closer: RegExp }[] = [ + { literal: '', closer: /<\/think(?:ing)?>/i }, + { literal: '', closer: /<\/think(?:ing)?>/i }, + { literal: '', closer: /<\/analysis>/i }, +]; + +/** Harmony 채널 마커 — `<|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>']) { + 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*?$/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; + } +} diff --git a/src/lib/contextBuilders/memoryContext.ts b/src/lib/contextBuilders/memoryContext.ts index 2139e69..25b6687 100644 --- a/src/lib/contextBuilders/memoryContext.ts +++ b/src/lib/contextBuilders/memoryContext.ts @@ -428,7 +428,17 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise `- ${p}`), + '', + '대신: 같이 일하는 동료의 말투로, 담백한 존댓말. 감정적인 말에는 상담사 공식이 아니라 사람의 짧은 반응 한두 문장 — 필요하면 자연스럽게 하나만 되묻기. 오버해서 친한 척도 하지 마라.', + '예시 — 사용자: "오늘 참 기분이 슬퍼"', + ' 나쁜 답: "힘든 감정을 느끼고 계시군요. 괜찮으시다면 이야기하거나 프로젝트를 검토해 드릴 수 있습니다. 저는 언제든 여기에 있습니다." (금지 표현 3개)', + ' 좋은 답: "그런 날이 있죠. 무슨 일 있었어요? 얘기해도 되고, 그냥 다른 걸 하면서 잊어도 됩니다."', + ].join('\n'); +} diff --git a/src/lib/execUtil.ts b/src/lib/execUtil.ts new file mode 100644 index 0000000..3a4bdd5 --- /dev/null +++ b/src/lib/execUtil.ts @@ -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 { + 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 { + 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; } diff --git a/src/lib/kstSchedule.ts b/src/lib/kstSchedule.ts new file mode 100644 index 0000000..83021c8 --- /dev/null +++ b/src/lib/kstSchedule.ts @@ -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])) }; +} diff --git a/src/retrieval/contextBudget.ts b/src/retrieval/contextBudget.ts index bf14bd9..e81b072 100644 --- a/src/retrieval/contextBudget.ts +++ b/src/retrieval/contextBudget.ts @@ -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); -} /** * 검색 결과 청크들을 토큰 예산 내에서 선택합니다. diff --git a/src/retrieval/domainRouter.ts b/src/retrieval/domainRouter.ts index 41070b0..fac22e3 100644 --- a/src/retrieval/domainRouter.ts +++ b/src/retrieval/domainRouter.ts @@ -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, diff --git a/src/retrieval/index.ts b/src/retrieval/index.ts index 4c8fd37..597fb2e 100644 --- a/src/retrieval/index.ts +++ b/src/retrieval/index.ts @@ -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, diff --git a/src/retrieval/semanticRerank.ts b/src/retrieval/semanticRerank.ts index cce912a..117c93e 100644 --- a/src/retrieval/semanticRerank.ts +++ b/src/retrieval/semanticRerank.ts @@ -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 { - 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. */ diff --git a/src/sidebar/builders/dispatcherDepsBuilder.ts b/src/sidebar/builders/dispatcherDepsBuilder.ts index 0ae0c33..fd1c90d 100644 --- a/src/sidebar/builders/dispatcherDepsBuilder.ts +++ b/src/sidebar/builders/dispatcherDepsBuilder.ts @@ -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; signal: AbortSignal; onEvent: (event: CompanyTurnEvent) => void; /** Action tag (`` 등) 를 실제 파일시스템에 적용. 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 diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 610d803..553148e 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -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), diff --git a/src/utils.ts b/src/utils.ts index a9e42d9..e9f5d97 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -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.) diff --git a/tests/benchmarkModes.test.ts b/tests/benchmarkModes.test.ts new file mode 100644 index 0000000..74b3cd9 --- /dev/null +++ b/tests/benchmarkModes.test.ts @@ -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. 원본 사이트 재구축 명세'); + }); +}); diff --git a/tests/claudeCodeProvider.test.ts b/tests/claudeCodeProvider.test.ts new file mode 100644 index 0000000..bf88cfb --- /dev/null +++ b/tests/claudeCodeProvider.test.ts @@ -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('\n너는 아스트라다.\n'); + 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']); + }); +}); diff --git a/tests/domainRouter.test.ts b/tests/domainRouter.test.ts index 4ff248a..fd1882b 100644 --- a/tests/domainRouter.test.ts +++ b/tests/domainRouter.test.ts @@ -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 확정', () => { diff --git a/tests/handoffDoc.test.ts b/tests/handoffDoc.test.ts new file mode 100644 index 0000000..b3818c5 --- /dev/null +++ b/tests/handoffDoc.test.ts @@ -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'); + }); +}); diff --git a/tests/layoutSkeleton.test.ts b/tests/layoutSkeleton.test.ts new file mode 100644 index 0000000..99c18b6 --- /dev/null +++ b/tests/layoutSkeleton.test.ts @@ -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('섹션 데이터가 스캔에 없습니다'); + }); +}); diff --git a/tests/liveReasoningFilter.test.ts b/tests/liveReasoningFilter.test.ts new file mode 100644 index 0000000..2297a20 --- /dev/null +++ b/tests/liveReasoningFilter.test.ts @@ -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(' 블록은 통째로 숨긴다', () => { + expect(run(['사용자가 인사했다', '반갑습니다.'])).toBe('반갑습니다.'); + }); + + test('토큰 경계에 걸쳐 쪼개진 도 잡는다', () => { + expect(run(['내부 ', '추론답', '변입니다'])).toBe('답변입니다'); + }); + + test(' 변형도 처리한다', () => { + expect(run(['...', 'A'])).toBe('A'); + expect(run(['...', 'B'])).toBe('B'); + }); + + test('본문 중간의 think 블록도 앞뒤 본문을 보존한다', () => { + expect(run(['결론은 X입니다. ', '검산', '근거는 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(['끝나지 않는 추론...'])).toBe(''); + }); + + test('마커가 아닌 < 문자는 보류 후 정상 방출된다', () => { + expect(run(['a < b 이고 ', 'c > d 입니다'])).toBe('a < b 이고 c > d 입니다'); + expect(run(['제네릭 Array 타입'])).toBe('제네릭 Array 타입'); + }); + + test('스트림이 잠재 마커 접두사로 끝나면 flush 로 회수된다', () => { + expect(run(['답변 끝 <'])).toBe('답변 끝 <'); + }); +}); diff --git a/tests/reportQa.test.ts b/tests/reportQa.test.ts new file mode 100644 index 0000000..7f77cb6 --- /dev/null +++ b/tests/reportQa.test.ts @@ -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('표면 질문만 문자적으로 답하지 마라'); + }); +}); diff --git a/tests/toneGuard.test.ts b/tests/toneGuard.test.ts new file mode 100644 index 0000000..9ddfbad --- /dev/null +++ b/tests/toneGuard.test.ts @@ -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 상투어 금지]'); + }); +}); diff --git a/tests/wikiSave.test.ts b/tests/wikiSave.test.ts index 0d174d3..307a978 100644 --- a/tests/wikiSave.test.ts +++ b/tests/wikiSave.test.ts @@ -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', () => {