v2.2.313~319: 보고 품질·조사 신뢰성 수술 + 네이버 쇼핑인사이트/검색어트렌드 연동
- 파트별 완성, 웹 실접속 증빙, 턴 의도 브리핑, 본문 불일치 검증, 하위 질문 체크리스트, 사이트 심층 수집 (PATCHNOTES.md v2.2.313~318 참조) - /naver-shopping, /naver-trend: 네이버 데이터랩 API 연동. compare/discover 자동 판별, API 배치 상한 캡슐화, 카테고리 트리 fuzzy 매칭, 자격증명은 Astra 설정 패널 SecretStorage 로 관리
This commit is contained in:
@@ -3,15 +3,15 @@
|
||||
<!-- ASTRA:AUTO-START -->
|
||||
|
||||
## Snapshot
|
||||
- **Workspace**: `connectai` `v2.2.312` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Workspace**: `connectai` `v2.2.318` _(absolute path varies by environment; resolved from the active VS Code workspace)_
|
||||
- **Description**: The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.
|
||||
- **Stack**: TypeScript, Node.js, VS Code Extension, LM Studio SDK, Test runner
|
||||
- **Stats**: 617 source files, ~90,403 lines across 5 top-level modules.
|
||||
- **Stats**: 633 source files, ~91,804 lines across 5 top-level modules.
|
||||
|
||||
## Last Refresh
|
||||
- **Time**: 2026-07-20T04:20:03.384Z
|
||||
- **Files newly analysed**: 1
|
||||
- **Files reused from cache**: 616
|
||||
- **Time**: 2026-07-21T04:22:55.352Z
|
||||
- **Files newly analysed**: 3
|
||||
- **Files reused from cache**: 630
|
||||
|
||||
## Directory Map
|
||||
```mermaid
|
||||
@@ -19,11 +19,11 @@ mindmap
|
||||
root((connectai))
|
||||
src/
|
||||
features/
|
||||
lib/
|
||||
agent/
|
||||
lib/
|
||||
sidebar/
|
||||
retrieval/
|
||||
intelligence/
|
||||
retrieval/
|
||||
media/
|
||||
tests/
|
||||
helpers/
|
||||
@@ -41,11 +41,11 @@ mindmap
|
||||
> Arrows: which top-level module imports from which.
|
||||
```mermaid
|
||||
flowchart LR
|
||||
src["src/<br/>342 files"]
|
||||
src["src/<br/>345 files"]
|
||||
media["media/<br/>6 files"]
|
||||
tests["tests/<br/>86 files"]
|
||||
tests["tests/<br/>91 files"]
|
||||
core_py["core_py/<br/>6 files"]
|
||||
docs["docs/<br/>177 files"]
|
||||
docs["docs/<br/>185 files"]
|
||||
tests --> src
|
||||
```
|
||||
|
||||
@@ -57,10 +57,10 @@ flowchart LR
|
||||
|
||||
## Hub Files
|
||||
> Imported by many other files — touching these has wide blast radius.
|
||||
- `src/utils.ts` — referenced by **116** files
|
||||
- `src/utils.ts` — referenced by **118** files
|
||||
- `src/config.ts` — referenced by **44** files
|
||||
- `src/agent.ts` — referenced by **37** files
|
||||
- `src/core/services.ts` — referenced by **23** files
|
||||
- `src/agent.ts` — referenced by **39** files
|
||||
- `src/core/services.ts` — referenced by **24** files
|
||||
- `src/retrieval/brainIndex.ts` — referenced by **14** files · Brain Index — persistent, mtime-keyed tokenized cache of the Second Brain RAG 검색은 매 질의마다 브레인의 모든 .md 파일을 읽고 토크나이즈해서 TF-IDF 점수를 계산했습니다 — 파일 수가 많아지면 그게 병목입니다. 이 모듈은 <brainPath>/.astra/brain-index.json 에
|
||||
- `src/agent/actions/types.ts` — referenced by **13** files
|
||||
- `src/integrations/telegram/telegramClient.ts` — referenced by **13** files
|
||||
@@ -68,15 +68,15 @@ flowchart LR
|
||||
|
||||
## Modules
|
||||
|
||||
### `src/` — 342 files, ~65,381 lines
|
||||
### `src/` — 345 files, ~66,209 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `src/features/` (126) — 데일리 브리핑 워처 — 평일(월~금) KST 09:30 에 "오늘의 할 일"을 텔레그램으로 발송. 소스: 1. Google Calendar — iCal 캐시 새로고침 후 오늘 일정 (readCalendarEvents
|
||||
- `src/agent/` (37) — <investigatefiles path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사. listfiles 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||
- `src/lib/` (37) — Astra Mode Architecture Context Builder. 의도: 사용자가 Astra 자체의 mode 디자인 (Guard vs Multi-Agent 가 별도 모드여야 하는지) 을 묻는 메타 질문에 답할
|
||||
- `src/agent/` (36) — <investigatefiles path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사. listfiles 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||
- `src/sidebar/` (32) — Brain profile lifecycle 의 pure helpers — sidebarProvider 의 add/edit/delete 흐름에서 modal UI 와 config 쓰기를 제외한 데이터 변환 만 격리. 현
|
||||
- `src/intelligence/` (21) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
||||
- `src/retrieval/` (20) — Actionability Scoring — 검색 결과를 "현재 작업 상태" 신호로 재가중. 기존 TF-IDF (단어 매칭) + recency (시간) 만으로는 "지금 이 사용자가 하고 있는 작업과 직접 연결 된 문서
|
||||
- `src/intelligence/` (19) — Confidence Engine — 답변 확신도 0~100 결정론적 산출. Self-Evolving OS 마스터 플랜 Phase 2 / Track 1-1. 신뢰 조건 T4 "확신이 없으면 사람에게 묻는다" 의 측정
|
||||
- `src/core/` (15) — Astra Path Resolver (경로 해결기) Astra의 모든 데이터 파일(.astra 디렉토리)의 경로를 중앙에서 관리합니다. 확장 프로그램의 설치 경로(extensionUri) 기반으로 .astra 디렉토
|
||||
- `src/extension/` (13) — 두뇌(Second Brain) 기본 위치 부트스트랩 — 첫 실행 온보딩. 문제: 두뇌 미설정 시 config 가 ~/.g1nation-brain(숨김 점폴더)로 조용히 폴백했다. - 폴더가 실제로 생성되지 않고, 설
|
||||
- `src/memory/` (9) — Distillation Loop — stale Episodic Memory → Long-Term "episode-digest" 승급. 배경: Episodic Memory 가 무한히 누적되면 검색 노이즈. 30일+ 지
|
||||
@@ -86,8 +86,8 @@ flowchart LR
|
||||
|
||||
**Key files**
|
||||
- `src/utils.ts` (538 lines)
|
||||
- `src/config.ts` (727 lines)
|
||||
- `src/agent.ts` (1979 lines)
|
||||
- `src/config.ts` (745 lines)
|
||||
- `src/agent.ts` (2229 lines)
|
||||
- `src/core/services.ts` (218 lines)
|
||||
- `src/features/company/types.ts` (446 lines) — Type definitions for the 1인 기업 (One-Person Company) mode. The mode turns the user into a virtual CEO that dispatches work to a roster of specialist agents. Each turn produces a session directory conta
|
||||
- `src/sidebarProvider.ts` (2992 lines)
|
||||
@@ -99,10 +99,10 @@ flowchart LR
|
||||
- `src/retrieval/scoring.ts` (541 lines) — Scoring Engine — TF-IDF + Bilingual Tokenizer 단순 includes() 키워드 매칭을 넘어서, TF-IDF 가중치 기반의 문서 스코어링을 제공합니다. 한국어/영어 양국어 토크나이저를 포함합니다.
|
||||
- `src/features/company/companyConfig.ts` (896 lines) — State + config plumbing for 1인 기업 모드. Two surfaces: - CompanyState (runtime data: enabled flag, company name, which agents are active, per-agent model overrides). Persisted in VS Code's globalState so
|
||||
- `src/features/stocks/types.ts` (61 lines) — Stocks 모듈 공유 타입. investresults/targetstocks.json 스키마를 그대로 받아서, ConnectAI 의 <workspace>/.astra/stocks.json 으로 옮긴 뒤 같은 필드명을 유지. 한글 필드명은 사용자의 도메인 데이터라 변경하지 않는다 — 마이그레이션 충돌 회피 + 사용자가 직접 JSON 편집할 때 frictio
|
||||
- `src/intelligence/requirementGraph.ts` (312 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
||||
- `src/lib/paths.ts` (151 lines)
|
||||
- `src/skills/agentKnowledgeMap.ts` (374 lines)
|
||||
- `src/retrieval/types.ts` (66 lines) — Retrieval Types (검색 결과 통합 타입) 모든 검색 소스(Brain, Memory, Project, Episode)의 결과를 통합 인터페이스로 정의합니다.
|
||||
- `src/intelligence/requirementGraph.ts` (312 lines) — Requirement Graph — 업무 유형별 필수 요소 정의 + 감지 + 커버리지 검사. Self-Evolving Digital Employee OS 마스터 플랜(docs/SELFEVOLVINGOSMASTERPLAN.md) Phase 1 / Track 2-1. 신뢰 조건 T3 "품질이 일관적이다 — 필수 요소 누락 없음" 담당. 동작 2단계: 1. In
|
||||
- `src/security.ts` (159 lines)
|
||||
- `src/lib/contextBuilders/promptDetection.ts` (107 lines) — 사용자 prompt 의 의도 분류 류 detection helpers. 모두 stateless 정규식 매칭. 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더 (buildJarvisProjectBriefContext 등) 가 이걸 의존하면서 god-file 안에서 서로 얽힘. 헬퍼만 먼저 떼면 의존 그래프가
|
||||
- `src/memory/types.ts` (151 lines) — Memory Type Definitions (메모리 타입 정의) Astra의 5-Layer Cognitive Memory System의 모든 타입을 정의합니다. ① Short-Term ② Long-Term ③ Project ④ Procedural ⑤ Episodic
|
||||
@@ -121,7 +121,7 @@ flowchart LR
|
||||
- `media/settings-panel.js` (577 lines)
|
||||
- `media/settings-panel.css` (254 lines) — Stylesheet
|
||||
|
||||
### `tests/` — 86 files, ~11,085 lines
|
||||
### `tests/` — 91 files, ~11,476 lines
|
||||
*Depends on*: `src/`
|
||||
|
||||
**Sub-directories**
|
||||
@@ -137,6 +137,7 @@ flowchart LR
|
||||
- `tests/telegramBot.test.ts` (363 lines) — Unit tests for TelegramBot + truncateForTelegram. Strategy: - TelegramBot is driven by an injected ITelegramClient stub. We script getUpdates to return queued batches and assert that: - the offset cur
|
||||
- `tests/criticReflectionEval.test.ts` (200 lines) — Critic Agent / Reflection Store / Task Eval Harness (Self-Evolving OS P1 잔여 + P3) 테스트.
|
||||
- `tests/emailCommand.test.ts` (282 lines) — v2.2.269 — /email (Gmail + Outlook) 순수 로직 테스트. COM/네트워크는 통합 영역이라 제외 — 참조 파싱·프롬프트·트랜스크립트 컷만 고정.
|
||||
- `tests/investigationPipeline.test.ts` (217 lines) — Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||
- `tests/lmStudioStreamer.test.ts` (295 lines) — Unit tests for LMStudioStreamer. Strategy: inject a fake ILMStudioClient that returns a fake model handle whose respond() yields a controllable async iterable. No real SDK or WebSocket touched.
|
||||
- `tests/meetRegistration.test.ts` (232 lines) — /meet 확신 게이트 — 분류·confirm 파싱·날짜 정규화 테스트. 정책: 확정+기한만 자동, 진행미정/기한미정/조건부는 보류, 반복은 첫 1회, 과거 날짜는 등록하되 완료확인 표기, 기한 해석 불가 확정건은 보류(추측 등록 금지).
|
||||
- `tests/secondBrainTrace.test.ts` (407 lines)
|
||||
@@ -146,7 +147,6 @@ flowchart LR
|
||||
- `tests/confidenceEngine.test.ts` (175 lines) — Confidence Engine + Escalation Engine (Self-Evolving OS Phase 2) 단위 테스트. 순수 함수만 검증 — vscode 의존 없음.
|
||||
- `tests/correctionLoop.test.ts` (158 lines) — Correction Loop 단위 테스트 — 순수 로직 (감지·프로필·레슨·큐 등록·영속화). LLM 의존 부분(classifyCorrection)은 엔드포인트 실패 → 휴리스틱 fallback 경로만 검증.
|
||||
- `tests/emailState.test.ts` (121 lines) — v2.2.271 — 이메일 영속 상태 머신 (syncThreads / detectOverdue / setThreadState). "명령 안 쳐도 f/u 되는" 핵심 로직: 창 밖 미회신 보존, done 자동 재개, 알림 중복 차단.
|
||||
- `tests/investigationPipeline.test.ts` (193 lines) — Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||
- `tests/knowledgeOps.test.ts` (174 lines) — Knowledge Validation / Belief Revision / Decay / Debt (Self-Evolving OS Phase 4 — 지식 운영) 테스트.
|
||||
- `tests/largeInputMapReduce.test.ts` (159 lines) — Phase 2 — large-input map-reduce core. Pure orchestration with an injected callLLM, so no network / SDK is touched.
|
||||
- `tests/needEngineQueue.test.ts` (159 lines) — Gap Detector / Need Engine / Knowledge Inventory / Learning Queue (Self-Evolving OS Phase 3 — 성장 루프 코어) 테스트.
|
||||
@@ -166,16 +166,16 @@ flowchart LR
|
||||
- `core_py/optimizer.py` (55 lines)
|
||||
- `core_py/queue_worker.py` (82 lines)
|
||||
|
||||
### `docs/` — 177 files, ~5,946 lines
|
||||
### `docs/` — 185 files, ~6,128 lines
|
||||
|
||||
**Sub-directories**
|
||||
- `docs/records/` (160) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/records/` (168) — Bug: /Volumes/Data/project/Antigravity/ConnectAI 프로젝트 코드 리뷰 해줄 수 있어? 개선할 부분이 있는지, 그러고...
|
||||
- `docs/docs/` (5) — Bug: Viewed integrationretrieval.test.ts:1-59 integrationretrieval.test.ts를 통해 ...
|
||||
- `docs/plans/` (2) — Alignment Self-Learning 개선 계획 (v2 — 적대적 리뷰 반영)
|
||||
- `docs/Meeting/` (0)
|
||||
|
||||
**Key files**
|
||||
- `docs/records/ConnectAI/timeline.md` (431 lines) — Project Timeline
|
||||
- `docs/records/ConnectAI/timeline.md` (455 lines) — Project Timeline
|
||||
- `docs/TELEGRAM_REMOTE_EXECUTION_PLAN.md` (452 lines) — Telegram Remote Execution 기획서
|
||||
- `docs/AgentEngine_Architecture.md` (314 lines) — AgentEngine Architecture Document
|
||||
- `docs/SELF_EVOLVING_OS_MASTER_PLAN.md` (275 lines) — ASTRA Self-Evolving Digital Employee OS — 마스터 개발 계획 v1.1
|
||||
@@ -244,7 +244,7 @@ flowchart LR
|
||||
- `g1nation.calendar.refresh` — Astra: Google Calendar 새로고침 📅
|
||||
- `g1nation.calendar.connectOAuth` — Astra: Google Calendar OAuth 연결 (쓰기) 🔐
|
||||
- `g1nation.devilAgent.toggle` — Astra: Toggle Devil Agent 🎭
|
||||
- **Configuration** (183 settings):
|
||||
- **Configuration** (187 settings):
|
||||
- `g1nation.multiAgentEnabled` *(boolean)* _(default: `false`)_ — Enable Multi-Agent Workflow (Planner -> Researcher -> Writer) for complex tasks.
|
||||
- `g1nation.datacollectBridgeTarget` *(string)* _(default: `"nas"`)_
|
||||
- `g1nation.datacollectBridgeUrl` *(string)* _(default: `"http://127.0.0.1:3002"`)_ — [local 타깃] Wiki/Datacollect MCP Bridge URL. /benchmark, /youtube, /wikify chat slash commands route here. The Bridge must be running (`npm run bridge` in the Datacollect project). 백엔드를 NAS로 옮긴 경우 보통 사
|
||||
@@ -305,7 +305,7 @@ flowchart LR
|
||||
- `g1nation.largeInputMapReduce` *(boolean)* _(default: `true`)_ — When a single message is too large to fit the model's context window, split it into chunks, extract only the request-relevant facts from each (no hallucination/summary), integrate them, and answer fro
|
||||
- `g1nation.mapReduceTriggerRatio` *(number)* _(default: `0.6`)_ — Map-reduce kicks in when a single message exceeds (effective context window × this ratio). Lower = engages sooner (safer for big inputs, more LLM calls). Default: 0.6
|
||||
- `g1nation.mapReduceConcurrency` *(number)* _(default: `2`)_ — How many chunk extractions run in parallel. Keep low on a single local GPU (one model serves them sequentially anyway). Default: 2
|
||||
- _…and 123 more_
|
||||
- _…and 127 more_
|
||||
|
||||
## Dependencies
|
||||
- **Runtime** (2): `@lmstudio/sdk`, `pdf-parse`
|
||||
@@ -353,7 +353,7 @@ Astra는 대표님의 명시적인 승인 하에 로컬 시스템의 강력한
|
||||
**Designed for High-Performance Decision Making.**
|
||||
Copyright (C) **g1nation**. All rights reserved.
|
||||
|
||||
_Last auto-scan: 2026-07-20T04:20:03.384Z · signature `ff87bb88`_
|
||||
_Last auto-scan: 2026-07-21T04:22:55.352Z · signature `ca99a170`_
|
||||
<!-- ASTRA:AUTO-END -->
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": "2026-07-20T04:20:03.483Z",
|
||||
"generatedAt": "2026-07-21T04:22:55.502Z",
|
||||
"files": {
|
||||
"src/agent/actions/brainOps.ts": {
|
||||
"mtimeMs": 1779764602582.9768,
|
||||
@@ -254,9 +254,9 @@
|
||||
]
|
||||
},
|
||||
"src/agent/handlePrompt/processFinalAnswer.ts": {
|
||||
"mtimeMs": 1779764602591.863,
|
||||
"size": 6606,
|
||||
"lines": 147,
|
||||
"mtimeMs": 1784522477358.286,
|
||||
"size": 7268,
|
||||
"lines": 158,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -273,6 +273,16 @@
|
||||
"src/lib/contextManager"
|
||||
]
|
||||
},
|
||||
"src/agent/handlePrompt/sectionedCompletion.ts": {
|
||||
"mtimeMs": 1784521634407.0134,
|
||||
"size": 6304,
|
||||
"lines": 116,
|
||||
"role": "[v2.2.313] 파트별 완성 (Sectioned Completion) — 사용자 제안 구현. 문제: 보고서형 답변이 출력 토큰 한계에 닿으면 자동 이어쓰기(같은 호출의 연장)를 최대 N회 시도하고, 그래도 못 채우면 \"더 좁은 주제로 나눠 질문하세요\" 안내로 끝났다. 나눠서 다시 묻는 부담을 사용자에게 떠넘긴 것. 해결: 한계에 닿은 초안을 받으면 백엔",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
"src/agent"
|
||||
]
|
||||
},
|
||||
"src/agent/hangulHygiene.ts": {
|
||||
"mtimeMs": 1781239601929.7876,
|
||||
"size": 3244,
|
||||
@@ -436,9 +446,9 @@
|
||||
]
|
||||
},
|
||||
"src/agent.ts": {
|
||||
"mtimeMs": 1784517985649.1714,
|
||||
"size": 113583,
|
||||
"lines": 1979,
|
||||
"mtimeMs": 1784602363613.244,
|
||||
"size": 130400,
|
||||
"lines": 2229,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -455,6 +465,9 @@
|
||||
"src/features/web/webFetch",
|
||||
"src/intelligence/correctionLoop",
|
||||
"src/intelligence/investigationPipeline",
|
||||
"src/intelligence/requirementGraph",
|
||||
"src/intelligence/turnIntentBrief",
|
||||
"src/intelligence/subQuestions",
|
||||
"src/lib/contextBuilders/multiAgentRouting",
|
||||
"src/lib/contextBuilders/thinkingPartnerContract",
|
||||
"src/lib/contextBuilders/droppedHistorySummary",
|
||||
@@ -527,6 +540,7 @@
|
||||
"src/agent/handlePrompt/applyAutoContinuation",
|
||||
"src/features/approval/approvalQueue",
|
||||
"src/intelligence/confidenceEngine",
|
||||
"src/core/services",
|
||||
"src/features/providers"
|
||||
]
|
||||
},
|
||||
@@ -577,9 +591,9 @@
|
||||
]
|
||||
},
|
||||
"src/config.ts": {
|
||||
"mtimeMs": 1784510604793.408,
|
||||
"size": 42409,
|
||||
"lines": 727,
|
||||
"mtimeMs": 1784602367128.109,
|
||||
"size": 43582,
|
||||
"lines": 745,
|
||||
"role": "",
|
||||
"imports": []
|
||||
},
|
||||
@@ -2410,9 +2424,9 @@
|
||||
]
|
||||
},
|
||||
"src/intelligence/investigationPipeline.ts": {
|
||||
"mtimeMs": 1784517973427.466,
|
||||
"size": 13722,
|
||||
"lines": 256,
|
||||
"mtimeMs": 1784521742026.5295,
|
||||
"size": 15022,
|
||||
"lines": 282,
|
||||
"role": "Investigation Pipeline — \"파일명만 보고 상상하는 조사\" 를 구조적으로 차단. (v2.2.309) 문제: 소형 로컬 모델은 <listfiles> 로 파일명 목록을 받으면 <readfile> 을 부르지 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션). 해법 3중: ① 증거 게이트 — 조사형 요청 ",
|
||||
"imports": [
|
||||
"src/config"
|
||||
@@ -2508,6 +2522,13 @@
|
||||
"src/intelligence/reflectionStore"
|
||||
]
|
||||
},
|
||||
"src/intelligence/subQuestions.ts": {
|
||||
"mtimeMs": 1784605847771.3826,
|
||||
"size": 4647,
|
||||
"lines": 85,
|
||||
"role": "[v2.2.318] 하위 질문 체크리스트 — \"질문 3개에 하나도 직접 답하지 않는\" 실사례 대응. 사용자가 한 메시지에 물음을 여러 개 명시하면(\"어떤 주제인지, 퀄리티는 어떤지, 도움이 될지\") 각 물음이 답변에서 다뤄지는지를 결정론으로 관리한다: 1) 프롬프트에서 물음 절(clause)을 규칙 기반 추출 (LLM 없음) 2) 답변 전: \"각 물음에 별",
|
||||
"imports": []
|
||||
},
|
||||
"src/intelligence/taskEvalHarness.ts": {
|
||||
"mtimeMs": 1781144360998.8086,
|
||||
"size": 7942,
|
||||
@@ -2517,6 +2538,15 @@
|
||||
"src/intelligence/requirementGraph"
|
||||
]
|
||||
},
|
||||
"src/intelligence/turnIntentBrief.ts": {
|
||||
"mtimeMs": 1784524229904.1582,
|
||||
"size": 7105,
|
||||
"lines": 121,
|
||||
"role": "[v2.2.316] 턴 의도 브리핑 — \"질문 의도를 판단하지 못한다\" 문제의 구조적 해결. 진단: 의도 브리핑(왜 묻는지·진짜 궁금증·답의 용도 추출→주입)은 멀티에이전트 보고서 경로(v2.2.301)에만 있었고, 일반 채팅 턴은 \"[답변 전 이해 원칙]\" 4줄짜리 추상 지시뿐이었다. 소형 로컬 모델은 추상 지시(\"의도를 파악하라\")를 실행하지 못한다 —",
|
||||
"imports": [
|
||||
"src/utils"
|
||||
]
|
||||
},
|
||||
"src/lib/contextBuilders/astraModeArchitecture.ts": {
|
||||
"mtimeMs": 1779764602634.5583,
|
||||
"size": 3982,
|
||||
@@ -2650,22 +2680,23 @@
|
||||
"imports": []
|
||||
},
|
||||
"src/lib/contextBuilders/multiAgentRouting.ts": {
|
||||
"mtimeMs": 1783907905794.681,
|
||||
"size": 5728,
|
||||
"lines": 103,
|
||||
"mtimeMs": 1784523758683.9956,
|
||||
"size": 6310,
|
||||
"lines": 111,
|
||||
"role": "",
|
||||
"imports": [
|
||||
"src/config",
|
||||
"src/lib/contextManager",
|
||||
"src/lib/contextBuilders/promptDetection",
|
||||
"src/lib/contextBuilders/astraModeArchitecture",
|
||||
"src/lib/contextBuilders/localProjectIntent"
|
||||
"src/lib/contextBuilders/localProjectIntent",
|
||||
"src/features/web/webFetch"
|
||||
]
|
||||
},
|
||||
"src/lib/contextBuilders/outputSanitization.ts": {
|
||||
"mtimeMs": 1784019840678.9001,
|
||||
"size": 5958,
|
||||
"lines": 108,
|
||||
"mtimeMs": 1784522459809.4607,
|
||||
"size": 9673,
|
||||
"lines": 175,
|
||||
"role": "모델 출력 후처리 (post-stream sanitization). 모두 stateless pure transform. 1) sanitizeAssistantContent — 모델이 답변에 흘려보낸 내부 마커 (rationale, reasoning channels, Harmony/GPT-OSS 채널 마커, [PROBLEM]/[GOAL]/[REASONING] ",
|
||||
"imports": []
|
||||
},
|
||||
@@ -2781,9 +2812,9 @@
|
||||
"imports": []
|
||||
},
|
||||
"src/lib/contextBuilders/urlContext.ts": {
|
||||
"mtimeMs": 1781491584068.7393,
|
||||
"size": 5546,
|
||||
"lines": 108,
|
||||
"mtimeMs": 1784602315425.4807,
|
||||
"size": 12372,
|
||||
"lines": 234,
|
||||
"role": "URL 컨텍스트 빌더 — 채팅 프롬프트에 URL 이 있으면 본문을 추출해 실데이터로 주입. 문제: /wikify 는 URL 에 접근하지만(브리지 /api/web-extract), 일반 채팅에 URL 을 주면 추출 경로가 없어 모델이 \"접근할 수 없습니다\"라고 답하거나 내용을 추측했다. v2: Bridge 100% 의존이 두 번째 공백이었다 — Bridge ",
|
||||
"imports": [
|
||||
"src/utils",
|
||||
@@ -3994,9 +4025,9 @@
|
||||
]
|
||||
},
|
||||
"tests/investigationPipeline.test.ts": {
|
||||
"mtimeMs": 1784518245844.1465,
|
||||
"size": 9847,
|
||||
"lines": 193,
|
||||
"mtimeMs": 1784521853054.2844,
|
||||
"size": 11267,
|
||||
"lines": 217,
|
||||
"role": "Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309) ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지",
|
||||
"imports": [
|
||||
"src/intelligence/investigationPipeline"
|
||||
@@ -4104,6 +4135,15 @@
|
||||
"src/lib/contextBuilders/multiAgentRouting"
|
||||
]
|
||||
},
|
||||
"tests/lsepClosersAndLeaks.test.ts": {
|
||||
"mtimeMs": 1784523140472.9927,
|
||||
"size": 3378,
|
||||
"lines": 63,
|
||||
"role": "[v2.2.314] 두 실사례 가드: 1) LSEP 마커 뒤 \"마무리 인사\"만 남고 본문이 추론 구간에 갇혀 증발한 답변. 2) \"요청 요약/사용자 의도 추론/핵심 확인 질문\" 내부 스캐폴딩 섹션 유출.",
|
||||
"imports": [
|
||||
"src/lib/contextBuilders/outputSanitization"
|
||||
]
|
||||
},
|
||||
"tests/meetPrompt.test.ts": {
|
||||
"mtimeMs": 1782114707092.5818,
|
||||
"size": 3643,
|
||||
@@ -4338,6 +4378,16 @@
|
||||
"src/features/secondBrainTrace"
|
||||
]
|
||||
},
|
||||
"tests/sectionedCompletion.test.ts": {
|
||||
"mtimeMs": 1784521835807.1162,
|
||||
"size": 4361,
|
||||
"lines": 97,
|
||||
"role": "[v2.2.313] 파트별 완성 — 길이 한계에 닿은 보고서를 남은 섹션 단위로 나눠 생성해 한 문서로 통합. \"더 좁은 주제로 나눠 질문하세요\" 떠넘기기의 대체.",
|
||||
"imports": [
|
||||
"src/agent/handlePrompt/sectionedCompletion",
|
||||
"src/agent"
|
||||
]
|
||||
},
|
||||
"tests/selfAssessContext.test.ts": {
|
||||
"mtimeMs": 1781232946761.048,
|
||||
"size": 3413,
|
||||
@@ -4435,6 +4485,15 @@
|
||||
"src/agent/actions/stripForDisplay"
|
||||
]
|
||||
},
|
||||
"tests/subQuestions.test.ts": {
|
||||
"mtimeMs": 1784605807159.133,
|
||||
"size": 2750,
|
||||
"lines": 57,
|
||||
"role": "[v2.2.318] 하위 질문 체크리스트 — \"물음 3개에 하나도 직접 답하지 않는\" 실사례 가드.",
|
||||
"imports": [
|
||||
"src/intelligence/subQuestions"
|
||||
]
|
||||
},
|
||||
"tests/systemPrompt.test.ts": {
|
||||
"mtimeMs": 1779764602687.5603,
|
||||
"size": 1487,
|
||||
@@ -4493,6 +4552,15 @@
|
||||
"src/core/transaction"
|
||||
]
|
||||
},
|
||||
"tests/turnIntentBrief.test.ts": {
|
||||
"mtimeMs": 1784533325652.0085,
|
||||
"size": 3596,
|
||||
"lines": 73,
|
||||
"role": "[v2.2.316] 턴 의도 브리핑 — \"질문 의도를 판단하지 못한다\" 문제의 구조적 해결. 게이팅(잡담 제외) / JSON 파싱 내성 / 주입 블록의 근거-경로 강제를 검증.",
|
||||
"imports": [
|
||||
"src/intelligence/turnIntentBrief"
|
||||
]
|
||||
},
|
||||
"tests/urlContext.test.ts": {
|
||||
"mtimeMs": 1781239218670.592,
|
||||
"size": 2655,
|
||||
@@ -4522,6 +4590,16 @@
|
||||
"src/features/web/webFetch"
|
||||
]
|
||||
},
|
||||
"tests/webFetchNotice.test.ts": {
|
||||
"mtimeMs": 1784601240938.898,
|
||||
"size": 4616,
|
||||
"lines": 77,
|
||||
"role": "[v2.2.315] 웹 실접속 결정론 표시 — \"사이트 분석 요청에 실접속 없이 일반론 답변\" (koritips.com 실사례) 재발 방지. 접속 실패는 답변 상단 경고, 성공은 하단 출처.",
|
||||
"imports": [
|
||||
"src/lib/contextBuilders/urlContext",
|
||||
"src/lib/contextBuilders/multiAgentRouting"
|
||||
]
|
||||
},
|
||||
"tests/wikiFormat.test.ts": {
|
||||
"mtimeMs": 1783044027299.6238,
|
||||
"size": 3405,
|
||||
@@ -4845,7 +4923,7 @@
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/chronicle.config.json": {
|
||||
"mtimeMs": 1784521189700.969,
|
||||
"mtimeMs": 1784607768925.8318,
|
||||
"size": 371,
|
||||
"lines": 11,
|
||||
"role": "JSON configuration",
|
||||
@@ -5243,6 +5321,34 @@
|
||||
"role": "ADR: 일단 조건을 하나 더 추가할께. 계열사는 하고 싶어 하지 않아. 우리한테 업무를 주는게 추가 업무로 이어진다고 생각해. 해서 이걸 뒤집을 정도로...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0057-e-wiki-connectai-프로젝트-분석해줘.md": {
|
||||
"mtimeMs": 1784521304979.044,
|
||||
"size": 1396,
|
||||
"lines": 19,
|
||||
"role": "ADR: E:\\Wiki\\connectai 프로젝트 분석해줘.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0058-e-wiki-connectai-프로젝트-분석해-그리고-내용을-출력해줘.md": {
|
||||
"mtimeMs": 1784522265657.1863,
|
||||
"size": 1384,
|
||||
"lines": 19,
|
||||
"role": "ADR: E:\\Wiki\\connectai 프로젝트 분석해 그리고 내용을 출력해줘.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0059-https-koritips-com-사이트에-대한-분석-및-평가해줘-예상되는-주요-구독자는-누구일-것인지-글의.md": {
|
||||
"mtimeMs": 1784533698646.8413,
|
||||
"size": 1628,
|
||||
"lines": 19,
|
||||
"role": "ADR: HTTPS://KORITIPS.COM 사이트에 대한 분석 및 평가해줘. 예상되는 주요 구독자는 누구일 것인지, 글의 퀄리티는 어떠한지,",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/decisions/ADR-0060-https-www-koritips-com-분석해줘-어떠한-주제로-블로그가-운영되고-있는지-글의-퀄리티는-어떠.md": {
|
||||
"mtimeMs": 1784534124012.3115,
|
||||
"size": 1686,
|
||||
"lines": 19,
|
||||
"role": "ADR: https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-05-02_answer-format-readability-tuning.md": {
|
||||
"mtimeMs": 1778028987330.4185,
|
||||
"size": 1564,
|
||||
@@ -5621,6 +5727,27 @@
|
||||
"role": "Development Log: 지금까지 너는 나에게 아이디어, 핵심 내용, 기대 효과 내용으로 써줬는데. 법정 의무 교육은 다른 포멧으로 줬어 기대 효과를 써줘. 법정 의무 ...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-07-21_https-caliverse-io-사이트에-대한-분석해줘_implementation.md": {
|
||||
"mtimeMs": 1784602271893.2676,
|
||||
"size": 1708,
|
||||
"lines": 22,
|
||||
"role": "Development Log: https://caliverse.io 사이트에 대한 분석해줘.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-07-21_https-koritips-com-사이트에-대한-분석하고-어떠한-주제로-구성되어-있는지-사이트의-목표는-무엇_implementation.md": {
|
||||
"mtimeMs": 1784607768898.34,
|
||||
"size": 1725,
|
||||
"lines": 22,
|
||||
"role": "Development Log: https://koritips.com 사이트에 대한 분석하고 어떠한 주제로 구성되어 있는지, 사이트의 목표는 무엇인지.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/development/2026-07-21_https-www-koritips-com-분석해줘-어떠한-주제로-블로그가-운영되고-있는지-글의-퀄리티는-어떠_implementation.md": {
|
||||
"mtimeMs": 1784601796252.6404,
|
||||
"size": 1572,
|
||||
"lines": 22,
|
||||
"role": "Development Log: https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/discussions/2026-05-13_volumes-data-project-antigravity-connectai-이-프로젝트-작업-할-거야.md": {
|
||||
"mtimeMs": 1778720117404.2578,
|
||||
"size": 668,
|
||||
@@ -5698,6 +5825,13 @@
|
||||
"role": "Discussion: 우리 개발실은 가우시안 스필리터, 그리고 월마트에서 이머시브 스토어, 그리고 ai로 영상, 사운드 제작팀이 있어. 인원은 30명정도야. 여기에는...",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/discussions/2026-07-20_e-wiki-connectai-프로젝트-분석해줘.md": {
|
||||
"mtimeMs": 1784522127183.7183,
|
||||
"size": 577,
|
||||
"lines": 16,
|
||||
"role": "Discussion: E:\\Wiki\\connectai 프로젝트 분석해줘.",
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/discussions/2026-07-20_e-wiki-connectai-프로젝트를-분석하고-구조에-대해서-설명해줘-2.md": {
|
||||
"mtimeMs": 1784509596722.4983,
|
||||
"size": 514,
|
||||
@@ -5783,9 +5917,9 @@
|
||||
"imports": []
|
||||
},
|
||||
"docs/records/ConnectAI/timeline.md": {
|
||||
"mtimeMs": 1784517524755.3872,
|
||||
"size": 26819,
|
||||
"lines": 431,
|
||||
"mtimeMs": 1784607768902.889,
|
||||
"size": 28140,
|
||||
"lines": 455,
|
||||
"role": "Project Timeline",
|
||||
"imports": []
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||
"createdAt": 1784521057661,
|
||||
"createdAt": 1785921598765,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "---\nid: wiki_on\ndate: 2026-07-20T04:17:37.663Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (18ms)\n",
|
||||
"createdAt": 1784521057663,
|
||||
"result": "---\nid: wiki_on\ndate: 2026-08-05T09:19:58.767Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (22ms)\n",
|
||||
"createdAt": 1785921598767,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "wiki_on",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-20T04:17:37.641Z",
|
||||
"totalElapsedMs": 24,
|
||||
"startTime": "2026-08-05T09:19:58.741Z",
|
||||
"totalElapsedMs": 27,
|
||||
"results": {
|
||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||
},
|
||||
@@ -12,16 +12,16 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "direct",
|
||||
"durationMs": 18,
|
||||
"durationMs": 22,
|
||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||
"ts": "2026-07-20T04:17:37.659Z"
|
||||
"ts": "2026-08-05T09:19:58.763Z"
|
||||
},
|
||||
{
|
||||
"from": "direct",
|
||||
"to": "completed",
|
||||
"durationMs": 5,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-20T04:17:37.664Z"
|
||||
"ts": "2026-08-05T09:19:58.768Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1784521065426,
|
||||
"createdAt": 1785921605167,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||
"createdAt": 1784521065425,
|
||||
"createdAt": 1785921605166,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"createdAt": 1784521065420,
|
||||
"createdAt": 1785921605162,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
"createdAt": 1784521065423,
|
||||
"createdAt": 1785921605164,
|
||||
"modelVersion": "unknown"
|
||||
}
|
||||
+9
-9
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"missionId": "stress_conflict_1784521065403",
|
||||
"missionId": "stress_conflict_1785921605143",
|
||||
"status": "completed",
|
||||
"startTime": "2026-07-20T04:17:45.404Z",
|
||||
"totalElapsedMs": 23,
|
||||
"startTime": "2026-08-05T09:20:05.143Z",
|
||||
"totalElapsedMs": 25,
|
||||
"results": {
|
||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||
@@ -14,30 +14,30 @@
|
||||
{
|
||||
"from": "idle",
|
||||
"to": "outline",
|
||||
"durationMs": 15,
|
||||
"durationMs": 18,
|
||||
"message": "답변 구조 잡는 중...",
|
||||
"ts": "2026-07-20T04:17:45.419Z"
|
||||
"ts": "2026-08-05T09:20:05.161Z"
|
||||
},
|
||||
{
|
||||
"from": "outline",
|
||||
"to": "section",
|
||||
"durationMs": 3,
|
||||
"durationMs": 2,
|
||||
"message": "본문 작성 중...",
|
||||
"ts": "2026-07-20T04:17:45.422Z"
|
||||
"ts": "2026-08-05T09:20:05.163Z"
|
||||
},
|
||||
{
|
||||
"from": "section",
|
||||
"to": "polish",
|
||||
"durationMs": 2,
|
||||
"message": "최종 다듬기 중...",
|
||||
"ts": "2026-07-20T04:17:45.424Z"
|
||||
"ts": "2026-08-05T09:20:05.165Z"
|
||||
},
|
||||
{
|
||||
"from": "polish",
|
||||
"to": "completed",
|
||||
"durationMs": 3,
|
||||
"message": "미션 완료",
|
||||
"ts": "2026-07-20T04:17:45.427Z"
|
||||
"ts": "2026-08-05T09:20:05.168Z"
|
||||
}
|
||||
],
|
||||
"resilienceMetrics": {
|
||||
@@ -1,5 +1,58 @@
|
||||
# Astra Patch Notes
|
||||
|
||||
## v2.2.319 (2026-08-05)
|
||||
### 🛍️ 네이버 쇼핑인사이트 · 검색어트렌드 연동 — `/naver-shopping`, `/naver-trend`
|
||||
사용자 요청: 네이버 쇼핑인사이트·검색어트렌드 API로 "오늘 검색어 많은 키워드 10개" 같은 자연어 질문에 답하고 싶음. 두 API 모두 비교 대상(분야 코드/키워드 묶음)을 미리 지정해야 상대 비율(ratio)만 돌려주는 구조라 "전체 실시간 top-N"을 직접 못 준다 — LLM이 도메인 지식으로 후보를 제안하고 실제 API로 검증해 순위를 매기는 방식(discover 모드)으로 갭을 메웠다.
|
||||
- **명시 비교(compare) vs 탐색(discover) 자동 판별**: intentParser 가 "패션의류 vs 화장품 비교"는 compare, "요즘 뜨는 뷰티 카테고리"는 discover 로 분류. discover 결과에는 "네이버 전체 실시간 순위 아님" 디스클레이머 고정 부착.
|
||||
- **API 상한 캡슐화(rankEngine)**: 쇼핑인사이트 카테고리 3개/요청, 검색어트렌드 키워드그룹 5개/요청 상한을 배치 순차 호출 + 병합정렬로 흡수 — 호출자는 "후보 N개 랭킹"만 요청하면 됨. 키워드는 groupName 1개 = keyword 1개로 보내 개별 순위를 보장(같은 그룹에 여러 키워드를 넣으면 API가 합산해버리는 함정 회피).
|
||||
- **카테고리 매칭(categoryCodes)**: 공식 문서 예시와 교차검증된 대분류 4종(패션의류/화장품·미용/디지털·가전/식품) + 네이버쇼핑 전체 카테고리 트리 4,967건(중/소/세분류)을 fuzzy 매칭 — "원피스" 같은 세부 카테고리도 정확한 cat_id 로 조회.
|
||||
- **자격증명**: Client ID/Secret 을 Astra 설정 → "Naver Trend" 섹션에서 SecretStorage 로 저장(providerConfig.ts 와 동일 패턴, settings.json 비침범).
|
||||
검증: tsc 무오류 + jest 1012 통과(신규 13) + esbuild 정상.
|
||||
|
||||
## v2.2.318 (2026-07-20)
|
||||
### ❓ 하위 질문 체크리스트 + 사이트 심층 수집 — "질문 3개에 하나도 답 안 함" 해결
|
||||
실사례: "어떤 주제인지, 글 퀄리티는 어떤지, 검색 유입자에게 도움이 될지" 3가지를 물었는데 답변은 평가 형용사("가치가 높습니다")만 나열 — 얻어낼 정보가 없음. 원인 2개: 명시된 물음별 답변을 강제하는 장치 부재 + 홈 1장(8천 자)만 수집해 "글 퀄리티" 판단 데이터 자체가 없음.
|
||||
- **하위 질문 체크리스트 (LLM 없음)**: 물음이 2개 이상 명시된 요청은 규칙 기반으로 물음 절을 추출해 "각 물음에 별도 소제목 + 구체 근거(글 제목·예시)로 답하라, 판단 형용사만은 실패"를 주입. 답변 후 각 물음의 특징 토큰이 답변에 없으면 "미답변 물음 감지" footer 로 어떤 물음이 빠졌는지 목록 표시.
|
||||
- **사이트 심층 수집 (`webDeepAnalyzeEnabled`, 기본 ON)**: 루트 URL 분석/평가 요청이면 홈 HTML 에서 같은 도메인 글 링크 상위 2개를 추가 수집(병렬) — 실제 글 본문을 근거로 퀄리티를 판단하게 한다. 수집된 글은 실접속 출처·본문 불일치 검증(v2.2.315~317)에도 자동 포함.
|
||||
검증: tsc 무오류 + jest 999 통과(신규 7) + esbuild 정상.
|
||||
|
||||
## v2.2.317 (2026-07-20)
|
||||
### 🔍 웹 본문 불일치 검증 — "접속했는데도 일반론" 마지막 그물
|
||||
실사례 후속: koritips.com(실체는 Blogging Tips 블로그)을 "IT 엔지니어 대상 기술 플랫폼"으로 서술 — 실접속 성패 표시(v2.2.315)만으로는 "접속은 성공했지만 모델이 본문을 무시한" 케이스를 못 잡는다. (해당 턴은 장기 세션 히스토리 속 직전 오답 + 미재시작 구버전에서 발생.)
|
||||
- **본문 반영 검증 (결정론)**: fetch 성공 시 제목·본문 표본의 특징 토큰(도메인 토큰 제외)을 추출해 답변과 대조 — 2개 미만 일치면 "⚠️ 본문 불일치 가능 — 일반론일 수 있음" footer 를 자동 부착. 재요청 문구 안내 포함.
|
||||
- **도달 증거 텔레메트리**: turn 이벤트 note 에 `webFetch=성공/시도` 기록 — "주입엔 항상 도달 증거를" 원칙을 웹 경로에도 적용.
|
||||
검증: tsc 무오류 + jest 992 통과(신규 5) + esbuild 정상.
|
||||
|
||||
## v2.2.316 (2026-07-20)
|
||||
### 🎯 턴 의도 브리핑 — "질문 의도를 판단하지 못한다" 구조적 해결
|
||||
멀티에이전트 보고서 경로에만 있던 의도 브리핑(v2.2.301)을 모든 실질 요청 턴으로 확장. 소형 로컬 모델은 "의도를 파악하라"는 추상 지시(기존 [답변 전 이해 원칙] 4줄)를 실행하지 못한다 — 파악된 의도를 *텍스트로 받아야* 따른다 (강제 주입 패턴 6번째 적용).
|
||||
- **매 요청 턴 브리핑 추출**(`intentBriefEnabled`, 기본 ON): 목적 / 진짜 궁금증(1~3) / 기대 산출물 형태 / 독자(본인·경영진·팀) / **근거 경로(web·local-files·brain·general)** / 명시 제약 — LLM 1회(JSON)로 추출해 보호 구역(dynamicBlocks)에 주입. "이 브리핑에 답하는 것이 성공 기준, 첫 문장은 목적에 대한 결론"을 명시.
|
||||
- **근거-경로 강제**: web 이면 "[URL CONTENT] 실데이터만 근거, 없으면 접속 실패 명시, 일반 지식 평가는 금지", local-files 면 "실제 읽은 파일만 근거, 부족하면 read/investigate 먼저" — koritips.com 실사례(근거 경로 착오)의 상류 방어.
|
||||
- **지연 상쇄**: 브리핑은 retrieval(1~8초)과 *병렬* 실행 — 체감 추가 지연 최소. 실패/타임아웃(10초)은 조용히 브리핑 없이 진행. `intentBriefModel` 로 빠른 소형 모델 지정 가능.
|
||||
검증: tsc 무오류 + jest 987 통과(신규 10) + esbuild 정상.
|
||||
|
||||
## v2.2.315 (2026-07-20)
|
||||
### 🌐 웹 실접속 증빙 — "사이트 분석"이 실접속 없이 일반론으로 끝나는 할루시네이션 차단
|
||||
실사례: "HTTPS://KORITIPS.COM 분석·평가해줘" → 실접속 여부가 어디에도 안 보이는 일반론 전략 제언 + 파일 기준 오탐 경고("파일 미확인 분석")까지 붙어 혼란.
|
||||
- **턴 단위 웹 접속 추적 + 결정론 표시**: URL 포함 요청은 접속 성패를 코드가 기록하고 답변에 박는다 — 전부 실패면 답변 *맨 위*에 "⚠️ 사이트 실접속 실패(사유) — 아래는 일반 지식 추정" 경고, 성공이면 답변 끝에 "🌐 실접속 확인: <URL> 본문을 근거로 사용" 출처. "접속 실패를 알리라"는 프롬프트 지시(소형 모델이 무시)를 코드 강제로 대체.
|
||||
- **오탐 수정**: URL 요청에는 파일 액션 기준의 "파일 미확인 분석" footer(v2.2.313)를 띄우지 않는다 — 웹 분석은 파일을 읽을 이유가 없다.
|
||||
- **라우팅 구멍 봉쇄**: URL 포함 요청은 멀티에이전트 워크플로우로 보내지 않는다 — 그 경로엔 웹 접근(URL 실데이터 주입)이 아예 없어 "사이트 조사해줘"가 실접속 0회 보고서로 끝났다. 단일 턴만 URL 본문을 주입받는다.
|
||||
검증: tsc 무오류 + jest 977 통과(신규 5) + esbuild 정상.
|
||||
|
||||
## v2.2.314 (2026-07-20)
|
||||
### 🛟 본문 증발 구제 + 내부 섹션 유출 제거
|
||||
실사례 2건: ① 분석 답변이 화면에 작성되다가 전부 지워지고 "이상으로 분석을 마칩니다"만 남음 — 모델이 본문 전체를 LSEP 추론 구간에 쓰고 가시 답변은 마무리 인사 한 줄만 낸 것(추론 숨김 처리가 본문까지 폐기). ② "요청 요약/사용자 의도 추론/프로젝트 기록 대상 확인/핵심 확인 질문" 내부 스캐폴딩 섹션이 답변에 그대로 노출.
|
||||
- **LSEP 마무리-인사-만 감지 → 3단 구제**: 마커 뒤가 마무리 인사뿐(<300자)이고 앞이 장문이면 thought-only 로 간주해 ① final-only 재생성 발동(이제 continuation depth 에서도 허용 — 분석 보고의 최종 라운드가 정확히 이 지점에서 실패했었다), ② 재생성도 실패하면 마커 앞 텍스트가 명백한 보고 형태(헤딩/불릿/문단 다수)일 때만 구제 승격 — 화면에서 지워진 그 본문을 되살린다. 형태가 애매하면 승격 안 함(생각 과정 노출 회귀 방지).
|
||||
- **내부 섹션 유출 결정론 제거 (stripLeakedInternalSections)**: 규칙 4가 금지하는 스캐폴딩 섹션 + "질문 의도:" 줄을 최종 답변에서 걷어낸다. Project Record Guard 턴(designerContext 활성)은 가드가 요구하는 섹션이므로 보존.
|
||||
검증: tsc 무오류 + jest 972 통과(신규 6) + esbuild 정상.
|
||||
|
||||
## v2.2.313 (2026-07-20)
|
||||
### 🧩 파트별 완성 + 파일 미확인 분석 경고
|
||||
실사례: 프로젝트 분석 답변이 "답변이 길어 자동으로 이어 정리했지만 여전히 길이 한계에 닿았습니다"로 끝나며 사용자에게 "나눠 질문하라"고 떠넘김 + 실제 파일은 하나도 안 읽고 사전 주입된 아키텍처 요약만으로 작성돼 구조 상세가 없었음.
|
||||
- **파트별 완성 (`sectionedCompletionEnabled`, 기본 ON — 사용자 제안 구현)**: 보고서·분석형 답변이 자동 이어쓰기로도 출력 한계에 닿으면, ① 남은 섹션 제목 추출(1회) → ② 섹션마다 *독립 호출*로 본문 생성(호출당 출력 예산 온전, 업무 유형 필수 요소를 outline 에 우선 반영) → ③ 결정적으로 이어 붙여 **한 문서로 통합**. 섹션 호출은 시스템+히스토리 프리픽스를 재사용해 KV 캐시로 프리필이 저렴하다. 실패 시 종전 안내로 폴백.
|
||||
- **파일 미확인 분석 경고 (3번째 그물)**: 로컬 프로젝트 분석 요청인데 이 턴에 액션(read/list/investigate)을 한 번도 안 쓰고 장문 분석을 낸 경우 "⚠️ 파일 미확인 분석" footer — hollow(목록만 봄, v2.2.309)·ungrounded(읽고도 인용 없음, v2.2.312) 둘 다 액션 1회 이상을 전제해 놓치던 사각지대.
|
||||
검증: tsc 무오류 + jest 966 통과(신규 12: sectionedCompletion 7 + unreadAnalysis 5) + esbuild 정상.
|
||||
|
||||
## v2.2.312 (2026-07-20)
|
||||
### 📋 보고 품질 — "## 4.부터 시작하는 보고서" 버그 + 일반론 총평 차단
|
||||
실사례: "connectai 프로젝트를 분석하고 보고해줘" → 답변이 "## 4. 종합 분석 결과"부터 시작(1~3 증발), 내용은 프로젝트명을 바꿔도 성립하는 7줄 일반론.
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"corePurpose": "",
|
||||
"detailLevel": "standard",
|
||||
"createdAt": "2026-05-20T09:42:40.003Z",
|
||||
"updatedAt": "2026-07-20T04:19:49.700Z"
|
||||
"updatedAt": "2026-07-24T09:42:33.480Z"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR: E:\Wiki\connectai 프로젝트 분석해줘.
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
E:\Wiki\connectai 프로젝트 분석해줘.
|
||||
|
||||
## Decision
|
||||
ConnectAI는 VS Code 환경에서 사용자의 개인적 지능 레이어 역할을 수행하며, 다층적 메모리 구조와 멀티 에이전트 워크플로를 통해 프로젝트의 맥맥을 관리하는 고도화된 시스템입니다. 이 프로젝트는 단순한 챗봇을 넘어, RAG(Retrieval-Augmented Generation)와 5계층 인지 메모리를 결합하여 깊은 프로젝트 컨텍스트를 유지하고, 에이전트 기반의 자동화된 의사결정을 지원하도록 설계되었습니다. ## 핵심 아키텍 architecture 및 기술 구조 - **인지 메모리 계층**: 단기, 장기, 프로젝트, 절차, 일화(Episodic)로 구분된 **5계층 인지 메모리** 구조를 채택하여 정보의 용도와 시간에 따른 체계적인 관리가 가능합니다. - **검색 및 인덱싱**: `brainIndex.ts`를 통해 Second Brain(Markdown 문서)을 토색화하고 TF-IDF 점수를 계산하여 관련성 높은 정보를 추출하는 RAG 엔진을 갖추고 있습니다. - **기술...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
@@ -0,0 +1,19 @@
|
||||
# ADR: E:\Wiki\connectai 프로젝트 분석해 그리고 내용을 출력해줘.
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
E:\Wiki\connectai 프로젝트 분석해 그리고 내용을 출력해줘.
|
||||
|
||||
## Decision
|
||||
ConnectAI 프로젝트는 VS Code 익스텐션 기술 스택(TypeScript, Node.js)을 기반으로 하며, 로컬 LLM(Ollama, LM Studio)과 사용자의 지식 데이터베이스(Second Brain)를 연결하여 개인화된 인텔리전스 레이어를 제공하는 구조입니다. ## 프로젝트 개요 ConnectAI는 단순한 채팅 도구를 넘어, 사용자의 로컬 환경에 존재하는 다양한 데이터(Markdown, YouTube, Web 등)를 수집하고 이를 LLM이 이해할 수 있는 형태로 정제하여 '지식의 선순환'을 만드는 것을 목표로 합니다. 핵심 기능은 에이전트 기반의 워크플로우, 지식 노후 점검, 그리고 1인 기업 모드(One-Person Company Mode)를 통한 업무 자동화입니다. ## 주요 기술 구조 및 특징 * **핵심 스택**: TypeScript 기반의 VS Code Extension 개발 환경을 사용하며, `@lmstudio/sdk`를 통해 로컬 모델과의 통신을 지원합...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: HTTPS://KORITIPS.COM 사이트에 대한 분석 및 평가해줘. 예상되는 주요 구독자는 누구일 것인지, 글의 퀄리티는 어떠한지,
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
HTTPS://KORITIPS.COM 사이트에 대한 분석 및 평가해줘. 예상되는 주요 구독자는 누구일 것인지, 글의 퀄리티는 어떠한지,
|
||||
|
||||
## Decision
|
||||
`koritips.com` 사이트는 기술적 통찰과 실무적인 가이드를 결합하여 제공하는 **전문 지식 공유 플랫폼**으로 판단됩니다. 이 사이트의 핵심 가치는 단순 정보 전달을 넘어, 복잡한 기술적 주제를 구조화하여 전달하는 데 있습니다. ## 1. 예상 주요 구독자 (Target Audience) 사이트의 콘텐츠 성격상 다음과 같은 세 그룹이 핵심 구독층이 될 것으로 추측됩니다. * **IT 엔지니어 및 개발자**: 특정 기술 스택(AI, 클라우드, 자동화 등)의 구현 방법이나 트러블슈팅 사례를 찾는 실무자입니다. 단순한 'How-to'를 넘어 아키텍처적 관점의 글을 소비할 가능성이 높습니다. * **기술 기획자 및 PM (Product Manager)**: 기술의 비즈니스적 가치나 도입 타당성을 검토하기 위해 최신 기술 트렌드와 사례 연구(Case Study)를 탐색하는 그룹입니다. * **1인 기업가 및 테크 프리너(Techpreneur)**: 자동화 도구(AI Agents, N...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# ADR: https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이...
|
||||
|
||||
## Status
|
||||
accepted
|
||||
|
||||
## Context
|
||||
https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이 글을 읽고 도움이 되었을지
|
||||
|
||||
## Decision
|
||||
`koritips.com` 사이트는 기술적 통찰과 실무적인 가이드를 결합하여 제공하는 **전문 지식 공유 플랫폼**으로 판단됩니다. 이 사이트의 핵심 가치는 단순 정보 전달을 넘어, 복잡한 기술적 주제를 구조화하여 전달하는 데 있습니다. ## 1. 예상 주요 구독자 (Target Audience) 사이트의 콘텐츠 성격상 다음과 같은 세 그룹이 핵심 구독층이 될 것으로 추측됩니다. * **IT 엔지니어 및 개발자**: 특정 기술 스택(AI, 클라우드, 자동화 등)의 구현 방법이나 트러블슈팅 사례를 찾는 실무자입니다. 단순한 'How-to'를 넘어 아키텍처적 관점의 글을 소비할 가능성이 높습니다. * **기술 기획자 및 PM (Product Manager)**: 기술의 비즈니스적 가치나 도입 타당성을 검토하기 위해 최신 기술 트렌드와 사례 연구(Case Study)를 탐색하는 그룹입니다. * **1인 기업가 및 테크 프리너(Techpreneur)**: 자동화 도구(AI Agents, N...
|
||||
|
||||
## Reason
|
||||
Captured automatically because the conversation contained decision-oriented language.
|
||||
|
||||
## Alternatives
|
||||
Not captured yet.
|
||||
|
||||
## Consequences
|
||||
- Future prompts should treat this as project context unless the user changes direction.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Development Log: https://caliverse.io 사이트에 대한 분석해줘.
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
`caliverse.io` 사이트는 단순한 웹사이트를 넘어, **데이터의 연결성과 지식의 구조화를 핵심 가치로 내세운 '링크드 데이터(Linked Data)' 기반의 실험적 플랫폼**으로 분석됩니다. 이 사이트의 특징을 세 가지 관점에서 정리해 드립니다. ## 1. 운영 주제 및 기술적 정체성 * **지식 그래프 및 링크드 데이터**: 이 사이트는 정보를 개별적인 페이지로 분절하여 저장하는 것이 아니라, 데이터 간의 관계(Relation)를 정의하고 연결하는 것에 집중합니다. 즉, '무엇을 보여주는가'보다 '데이터가 어떻게 연결되어 있는가'가 핵심 주제입니다. * **웹 벤치마크 및 실험적 플랫폼**: 제공된 컨텍스트에 따르면, 이 사이트는 특정 기술(예: RAG, 웹 크롤링 등)의 성능을 측정하거나 새로운 데이터 구조를 테스트하기 위한 벤치마크 도구로서의 성격을 띠고 있습니다. ## 2. 콘텐츠 품질 및 구조 * **구조적 정밀도**: 단순한 글쓰기가 아닌, 온톨로지(Ontolog...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- Domain_Programming\Topics_Rag\웹벤치마크 caliverse.io 2026-06-08.md
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Development Log: https://koritips.com 사이트에 대한 분석하고 어떠한 주제로 구성되어 있는지, 사이트의 목표는 무엇인지.
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
⚠️ 두뇌 근거 약함 — 일반 지식 기반 추정입니다. 이 주제는 현재 저의 두뇌(Second Brain)에 구체적인 분석 데이터가 부족하여, 학습 후보로 등록해 두었습니다. <koritips.com> 사이트는 도메인 명칭(`kori` + `tips`)과 기존에 제가 참조했던 기록을 바탕으로 추론할 때, **한국의 유용한 정보나 팁(Tips)을 정리하여 제공하는 정보 공유형 플랫폼**일 가능성이 매우 높습니다. ## 1. 예상 구성 주제 사이트 이름에서 유추할 수 있는 주요 콘텐츠 테마는 다음과 같습니다. * **생활/IT 꿀팁**: 일상생활에 필요한 유용한 정보나 최신 IT 기기, 소프트웨어 사용법 등. * **지식 가이드**: 특정 분야(예: 경제, 건강, 여행 등)의 초보자를 위한 가이드 및 노하우. * **문제 해결 방법(How-to)**: 사용자가 겪을 수 있는 불편함을 해결하기 위한 단계별 절차 안내. ## 2. 사이트의 목표 (추정) * **정보의 접근성 향상**: 복잡하...
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- No explicit changed file list was captured automatically.
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
# Development Log: https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이...
|
||||
|
||||
## Purpose
|
||||
Record the implementation or verification outcome from the current conversation.
|
||||
|
||||
## Implementation Summary
|
||||
결론적으로, `koritips.com`은 단순한 정보성 블로그를 넘어 전문적인 기술 지식을 체계적으로 정리하여 전달하는 **'기술 아카이브(Technical Archive)'**로서의 가치가 매우 높습니다. 이 블로그는 다음과 같은 측면에서 높은 평가를 받을 수 있습니다: * **신뢰성**: 검증된 기술적 원리를 바탕으로 깊이 있는 내용을 다룸으로써 콘텐츠의 권위를 확보하고 있습니다. * **지속 가능성**: 단순 트렌드 추종이 아닌, 기본 원리(Fundamentals)에 집중하고 있어 시간이 지나도 가치가 변하지 않는 'Long-form' 콘텐츠를 제공합니다. * **타겟 명확성**: 초보자보다는 실무 역량을 확장하고자 하는 중급 이상의 엔지니어에게 최적화된 전문적인 니치(Niche) 시장을 공략하고 있습니다.
|
||||
|
||||
## Architecture
|
||||
Captured automatically from the assistant response and active project context.
|
||||
|
||||
## Changed Files
|
||||
- No explicit changed file list was captured automatically.
|
||||
|
||||
## Dependency Notes
|
||||
No new dependency note was captured automatically.
|
||||
|
||||
## Bugs
|
||||
No bugs recorded.
|
||||
|
||||
## Lessons
|
||||
- Automatic project records should be generated in the background when the turn contains durable project knowledge.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Discussion: E:\Wiki\connectai 프로젝트 분석해줘.
|
||||
|
||||
## User Request Summary
|
||||
E:\Wiki\connectai 프로젝트 분석해줘.
|
||||
|
||||
## Interpreted Intent
|
||||
Capture a meaningful project discussion automatically instead of requiring manual record selection.
|
||||
|
||||
## Questions
|
||||
No explicit question was captured.
|
||||
|
||||
## Main Discussion
|
||||
- 이상으로 ConnectAI 프로젝트에 대한 분석을 마칩니다. 추가적인 기술적 세부 사항이나 특정 컴포넌트에 대한 심층적인 검토가 필요하시면 언제든 요청해 주세요.
|
||||
|
||||
## Decisions
|
||||
No decisions captured yet.
|
||||
@@ -429,3 +429,27 @@
|
||||
|
||||
## 2026-07-20
|
||||
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트를-분석하고-어떻게-되어-있는지-보고해줘.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto decision record created: decisions\ADR-0057-e-wiki-connectai-프로젝트-분석해줘.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto discussion record created: discussions\2026-07-20_e-wiki-connectai-프로젝트-분석해줘.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto decision record created: decisions\ADR-0058-e-wiki-connectai-프로젝트-분석해-그리고-내용을-출력해줘.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto decision record created: decisions\ADR-0059-https-koritips-com-사이트에-대한-분석-및-평가해줘-예상되는-주요-구독자는-누구일-것인지-글의.md
|
||||
|
||||
## 2026-07-20
|
||||
- Auto decision record created: decisions\ADR-0060-https-www-koritips-com-분석해줘-어떠한-주제로-블로그가-운영되고-있는지-글의-퀄리티는-어떠.md
|
||||
|
||||
## 2026-07-21
|
||||
- Auto development record created: development\2026-07-21_https-www-koritips-com-분석해줘-어떠한-주제로-블로그가-운영되고-있는지-글의-퀄리티는-어떠_implementation.md
|
||||
|
||||
## 2026-07-21
|
||||
- Auto development record created: development\2026-07-21_https-caliverse-io-사이트에-대한-분석해줘_implementation.md
|
||||
|
||||
## 2026-07-21
|
||||
- Auto development record created: development\2026-07-21_https-koritips-com-사이트에-대한-분석하고-어떠한-주제로-구성되어-있는지-사이트의-목표는-무엇_implementation.md
|
||||
|
||||
@@ -392,6 +392,36 @@
|
||||
<div id="googleError" class="error" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- Naver Trend (쇼핑인사이트 / 검색어트렌드) -->
|
||||
<section class="section" data-section="naver" data-tab="integrations">
|
||||
<h2>Naver Trend (쇼핑인사이트 · 검색어트렌드)</h2>
|
||||
<p class="hint"><code>/naver-shopping</code>, <code>/naver-trend</code> 명령이 쓰는 NAVER API HUB 자격증명. <a href="https://www.ncloud.com/product/applicationService/naverApiHub" target="_blank">NAVER API HUB</a> 콘솔의 Application → 인증 정보에서 Client ID/Secret 발급.</p>
|
||||
|
||||
<div class="row">
|
||||
<label>현재 상태</label>
|
||||
<div class="readout" id="naverStatus">—</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="naverClientId">Client ID</label>
|
||||
<div class="input-group">
|
||||
<input id="naverClientId" type="password" placeholder="X-NCP-APIGW-API-KEY-ID" autocomplete="off" spellcheck="false" />
|
||||
<button data-save="naver.clientId">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<label for="naverClientSecret">Client Secret</label>
|
||||
<div class="input-group">
|
||||
<input id="naverClientSecret" type="password" placeholder="X-NCP-APIGW-API-KEY" autocomplete="off" spellcheck="false" />
|
||||
<button data-save="naver.clientSecret">저장</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="naverFeedback" class="feedback" hidden></div>
|
||||
<div id="naverError" class="error" hidden></div>
|
||||
</section>
|
||||
|
||||
<!-- Cloud LLM Providers -->
|
||||
<section class="section" data-section="providers" data-tab="model">
|
||||
<h2>Cloud LLM Providers</h2>
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
const prGeminiKey = $('prGeminiKey');
|
||||
const prGeminiDefault = $('prGeminiDefault');
|
||||
|
||||
// ---- Naver Trend ----
|
||||
const naverStatus = $('naverStatus');
|
||||
const naverClientId = $('naverClientId');
|
||||
const naverClientSecret = $('naverClientSecret');
|
||||
|
||||
// ---- Banner ----
|
||||
const bannerError = $('bannerError');
|
||||
|
||||
@@ -300,6 +305,16 @@
|
||||
vscode.postMessage({ type: 'providers.update', providerId: 'gemini', defaultModel: prGeminiDefault.value })
|
||||
);
|
||||
|
||||
// ---- Naver Trend listeners ----
|
||||
document.querySelector('[data-save="naver.clientId"]').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'naver.update', clientId: naverClientId.value });
|
||||
naverClientId.value = '';
|
||||
});
|
||||
document.querySelector('[data-save="naver.clientSecret"]').addEventListener('click', () => {
|
||||
vscode.postMessage({ type: 'naver.update', clientSecret: naverClientSecret.value });
|
||||
naverClientSecret.value = '';
|
||||
});
|
||||
|
||||
document.querySelector('[data-save="advanced.ctxSize"]').addEventListener('click', () =>
|
||||
vscode.postMessage({ type: 'advanced.update', maxContextSize: Number(advCtxSize.value) })
|
||||
);
|
||||
@@ -552,6 +567,15 @@
|
||||
prGeminiKey.placeholder = pr.gemini.hasApiKey ? '••• 저장됨 (덮어쓰려면 새 값)' : 'AIzaSy...';
|
||||
setIfNotFocused(prGeminiDefault, pr.gemini.defaultModel);
|
||||
}
|
||||
|
||||
// ---- Naver Trend ----
|
||||
const nv = state.naver;
|
||||
if (nv) {
|
||||
naverClientId.placeholder = nv.hasCredentials ? '••• 저장됨 (덮어쓰려면 새 값 입력)' : 'X-NCP-APIGW-API-KEY-ID';
|
||||
naverClientSecret.placeholder = nv.hasCredentials ? '••• 저장됨 (덮어쓰려면 새 값 입력)' : 'X-NCP-APIGW-API-KEY';
|
||||
naverStatus.textContent = nv.hasCredentials ? '✅ Client ID/Secret 설정됨' : '⛔ 미설정 — /naver-shopping, /naver-trend 사용 불가';
|
||||
naverStatus.style.color = nv.hasCredentials ? '#10b981' : '';
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tabs (카테고리 네비) — 표현 계층만. 기존 refs/리스너/state 에 무영향 ----
|
||||
|
||||
+21
-1
@@ -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.312",
|
||||
"version": "2.2.319",
|
||||
"publisher": "g1nation",
|
||||
"license": "MIT",
|
||||
"icon": "assets/icon.png",
|
||||
@@ -1300,6 +1300,26 @@
|
||||
"default": true,
|
||||
"markdownDescription": "**KV 캐시 친화 프롬프트 분리** — 시스템 프롬프트를 '불변 본문(message[0])'과 '턴별 동적 컨텍스트(마지막 질문 직전 주입)'로 분리해 LM Studio/llama.cpp 의 prompt cache 프리픽스 재사용을 극대화. 매 턴 전체 재프리필(1만+ 토큰, 수십 초)을 직전 교환 분량으로 줄인다. 특정 모델 템플릿에서 응답 품질 문제가 보이면 끄세요."
|
||||
},
|
||||
"g1nation.webDeepAnalyzeEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**사이트 심층 수집** — 루트 URL 분석/평가 요청 시 내부 글 링크 상위 2개의 본문을 추가로 가져와, 글 퀄리티 같은 물음에 실데이터로 답하게 합니다."
|
||||
},
|
||||
"g1nation.intentBriefEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**턴 의도 브리핑** — 실질 요청 턴마다 '왜 묻는지·진짜 궁금증·기대 산출물·독자·근거 경로(웹/로컬/두뇌/일반)'를 별도 LLM 호출 1회로 분석해 시스템 프롬프트에 주입합니다. 검색과 병렬 실행되어 체감 지연은 거의 없습니다. 소형 모델이 표면 질문만 문자적으로 답하는 문제의 구조적 대책."
|
||||
},
|
||||
"g1nation.intentBriefModel": {
|
||||
"type": "string",
|
||||
"default": "",
|
||||
"markdownDescription": "의도 브리핑 전용 모델 ID — 비우면 현재 턴 모델 사용. 빠른 소형 모델을 지정하면 브리핑 지연이 줄어듭니다."
|
||||
},
|
||||
"g1nation.sectionedCompletionEnabled": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"markdownDescription": "**파트별 완성** — 보고서형 답변이 자동 이어쓰기로도 출력 토큰 한계에 닿으면, 남은 섹션을 백엔드에서 독립 호출로 나눠 생성한 뒤 한 문서로 통합합니다. '더 좁은 주제로 나눠 질문하세요' 안내 대신 완성본을 제공."
|
||||
},
|
||||
"g1nation.retrievalTokenBudget": {
|
||||
"type": "number",
|
||||
"default": 0,
|
||||
|
||||
+225
-9
@@ -24,10 +24,13 @@ import { buildAstraModeArchitectureContext } from './lib/contextBuilders/astraMo
|
||||
import { isScheduleRequest, buildScheduleContext } from './lib/contextBuilders/scheduleContext';
|
||||
import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/contextBuilders/selfAssessContext';
|
||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||
import { buildUrlContext, isUrlContextSuccessBlock, buildWebFetchNotice, extractSuccessMeta, detectWebContentMismatch, formatWebContentMismatchFooter, type TurnWebFetchStats } from './lib/contextBuilders/urlContext';
|
||||
import { extractUrls } from './features/web/webFetch';
|
||||
import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
|
||||
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
|
||||
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter, detectUnreadAnalysis, formatUnreadAnalysisFooter } from './intelligence/investigationPipeline';
|
||||
import { detectTaskType } from './intelligence/requirementGraph';
|
||||
import { shouldBuildTurnIntentBrief, buildTurnIntentBrief, formatTurnIntentBlock } from './intelligence/turnIntentBrief';
|
||||
import { buildSubQuestionBlock, checkSubQuestionCoverage, formatSubQuestionFooter } from './intelligence/subQuestions';
|
||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
||||
@@ -43,7 +46,7 @@ import {
|
||||
isAnalysisRequest,
|
||||
} from './lib/contextBuilders/promptDetection';
|
||||
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
|
||||
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale, splitLsepReasoning, isCloserOnlyAnswer, looksLikeReportBody } from './lib/contextBuilders/outputSanitization';
|
||||
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
|
||||
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
|
||||
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
|
||||
@@ -332,6 +335,12 @@ export class AgentExecutor {
|
||||
* KV 캐시도 깨졌다. actionStats 와 같은 이유로 depth 0 진입부에서만 초기화.
|
||||
*/
|
||||
memoryCtxCache: string | null;
|
||||
/**
|
||||
* [v2.2.315] 이번 turn 의 웹 실접속 결과 — URL 이 있는 요청에서 접속 성패를
|
||||
* 답변에 결정론으로 표시(실패=상단 경고, 성공=하단 출처)하기 위한 추적.
|
||||
* depth 0 진입부에서만 초기화 (memoryContext 의 resetTurnContext 와 무관).
|
||||
*/
|
||||
webFetch: TurnWebFetchStats | null;
|
||||
} = {
|
||||
retrieval: null,
|
||||
lessons: [],
|
||||
@@ -342,6 +351,7 @@ export class AgentExecutor {
|
||||
actionStats: { reads: 0, lists: 0, investigates: 0 },
|
||||
investigationModelOverride: null,
|
||||
memoryCtxCache: null,
|
||||
webFetch: null,
|
||||
};
|
||||
|
||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||
@@ -572,6 +582,7 @@ export class AgentExecutor {
|
||||
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
|
||||
this._turnCtx.investigationModelOverride = null;
|
||||
this._turnCtx.memoryCtxCache = null;
|
||||
this._turnCtx.webFetch = null;
|
||||
}
|
||||
|
||||
// 1. Prepare Context
|
||||
@@ -655,14 +666,62 @@ export class AgentExecutor {
|
||||
// v2: Bridge 추출 → 직접 fetch 폴백 (urlContext 내부) + 최대 2개 URL + config 게이트.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation && getConfig().webAutoFetchEnabled !== false) {
|
||||
const urls = extractUrls(prompt, 2);
|
||||
// [v2.2.315] 접속 성패 추적 — 답변에 결정론으로 표시(실패=상단 경고,
|
||||
// 성공=하단 출처). "실접속 없이 일반론으로 사이트 분석" 실사례의 재발 방지.
|
||||
if (urls.length > 0) {
|
||||
this._turnCtx.webFetch = { urls: urls.slice(), succeeded: [], failed: [] };
|
||||
}
|
||||
for (const url of urls) {
|
||||
try {
|
||||
contextBlock += `\n\n${await buildUrlContext(url)}`;
|
||||
logInfo('URL 컨텍스트 주입 시도.', { url });
|
||||
const block = await buildUrlContext(url);
|
||||
contextBlock += `\n\n${block}`;
|
||||
if (isUrlContextSuccessBlock(block)) {
|
||||
this._turnCtx.webFetch!.succeeded.push(url);
|
||||
(this._turnCtx.webFetch!.succeededDetail ??= []).push(extractSuccessMeta(url, block));
|
||||
} else {
|
||||
const reason = (block.match(/상태: 접근 실패 — ([^\n]*)/)?.[1] || '본문 추출 실패').slice(0, 120);
|
||||
this._turnCtx.webFetch!.failed.push({ url, reason });
|
||||
}
|
||||
logInfo('URL 컨텍스트 주입 시도.', { url, ok: isUrlContextSuccessBlock(block) });
|
||||
} catch (e: any) {
|
||||
this._turnCtx.webFetch!.failed.push({ url, reason: String(e?.message ?? e).slice(0, 120) });
|
||||
logError('URL 컨텍스트 주입 실패 (계속 진행).', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
// [v2.2.318] 사이트 심층 수집 — 루트 URL 분석/평가 요청이면 홈 1장으로는
|
||||
// "글의 퀄리티" 물음에 답할 데이터가 없다. 내부 글 링크 상위 2개를 추가
|
||||
// 수집 (병렬). 실패는 조용히 홈 데이터만으로 진행.
|
||||
try {
|
||||
const first = urls[0];
|
||||
const isRootUrl = urls.length === 1 && !!first && new URL(first).pathname.replace(/\/+$/, '') === '';
|
||||
if (isRootUrl && this._turnCtx.webFetch?.succeeded.length
|
||||
&& getConfig().webDeepAnalyzeEnabled !== false
|
||||
&& /(분석|평가|리뷰|퀄리티|품질|어떤|어떠)/.test(prompt)) {
|
||||
const { collectInternalArticleLinks } = await import('./lib/contextBuilders/urlContext');
|
||||
const articleUrls = await collectInternalArticleLinks(first, 2);
|
||||
if (articleUrls.length > 0) {
|
||||
const blocks = await Promise.all(articleUrls.map(async (au) => {
|
||||
try { return { au, block: await buildUrlContext(au) }; } catch { return null; }
|
||||
}));
|
||||
for (const b of blocks) {
|
||||
if (!b || !isUrlContextSuccessBlock(b.block)) continue;
|
||||
contextBlock += `\n\n${b.block}`;
|
||||
this._turnCtx.webFetch!.succeeded.push(b.au);
|
||||
(this._turnCtx.webFetch!.succeededDetail ??= []).push(extractSuccessMeta(b.au, b.block));
|
||||
}
|
||||
logInfo('사이트 심층 수집.', { root: first, articles: articleUrls });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('사이트 심층 수집 실패 (홈 데이터로 진행).', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
|
||||
// [v2.2.318] 하위 질문 체크리스트 — 물음이 2개 이상 명시된 요청은 각 물음에
|
||||
// 별도 답변을 강제 (LLM 없음, 규칙 기반). 커버리지는 답변 후 footer 로 검사.
|
||||
if (prompt && loopDepth === 0 && !isCasualConversation) {
|
||||
const subQBlock = buildSubQuestionBlock(prompt);
|
||||
if (subQBlock) contextBlock += `\n\n${subQBlock}`;
|
||||
}
|
||||
|
||||
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
|
||||
@@ -766,6 +825,30 @@ export class AgentExecutor {
|
||||
const secondBrainTraceCtx = secondBrainTrace
|
||||
? `\n\n${renderSecondBrainTraceContext(secondBrainTrace)}`
|
||||
: '';
|
||||
// [v2.2.316] 턴 의도 브리핑 — retrieval 과 *병렬* 시작 (검색이 1~8초 걸리는
|
||||
// 동안 브리핑이 함께 돌아 체감 추가 지연 최소화). 소형 모델은 "의도를
|
||||
// 파악하라"는 추상 지시를 실행하지 못한다 — 파악된 의도를 텍스트로 받아야
|
||||
// 따른다. 멀티에이전트 보고서 경로 전용이던 의도 브리핑의 전 턴 확장.
|
||||
let intentBriefPromise: Promise<import('./intelligence/turnIntentBrief').TurnIntentBrief | null> | null = null;
|
||||
if (loopDepth === 0 && config.intentBriefEnabled !== false
|
||||
&& shouldBuildTurnIntentBrief(prompt, isCasualConversation)) {
|
||||
const recentCtx = this.chatHistory
|
||||
.filter(m => !m.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');
|
||||
const briefModel = (config.intentBriefModel || '').trim() || actualModel;
|
||||
intentBriefPromise = buildTurnIntentBrief({
|
||||
prompt: prompt!,
|
||||
recentContext: recentCtx,
|
||||
llm: async (system, user, maxTokens) => {
|
||||
const { coreChat } = require('./core/services') as typeof import('./core/services');
|
||||
const r = await coreChat({ system, user, model: briefModel, temperature: 0.1, maxTokens, timeoutMs: 12_000 });
|
||||
return r.content;
|
||||
},
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
const retrievalStartMs = Date.now();
|
||||
// [v2.2.311] continuation depth 는 depth 0 의 memoryCtx 를 재사용 — 재검색
|
||||
// 3~8초 제거 + 빈 쿼리 재검색으로 청크가 갈리는 문제 제거 + 프롬프트 안정화
|
||||
@@ -790,6 +873,22 @@ export class AgentExecutor {
|
||||
});
|
||||
this._turnCtx.memoryCtxCache = memoryCtx;
|
||||
}
|
||||
// [v2.2.316] 병렬 시작한 의도 브리핑 수확 — 보호 구역(dynamicBlocks)에 주입.
|
||||
// 실패/타임아웃은 조용히 브리핑 없이 진행 (종전 동작).
|
||||
if (intentBriefPromise) {
|
||||
try {
|
||||
const _brief = await intentBriefPromise;
|
||||
const _blk = formatTurnIntentBlock(_brief);
|
||||
if (_blk) {
|
||||
this._turnCtx.dynamicBlocks.set('turn-intent-brief', _blk);
|
||||
logInfo('턴 의도 브리핑 주입.', {
|
||||
purpose: _brief!.purpose.slice(0, 60),
|
||||
accessPath: _brief!.accessPath,
|
||||
deliverable: _brief!.deliverable.slice(0, 40),
|
||||
});
|
||||
}
|
||||
} catch { /* 브리핑 없이 진행 */ }
|
||||
}
|
||||
if (loopDepth === 0 && !isCasualConversation && this._turnCtx.retrieval) {
|
||||
recordTelemetry({
|
||||
kind: 'retrieval',
|
||||
@@ -1279,10 +1378,35 @@ export class AgentExecutor {
|
||||
});
|
||||
}
|
||||
|
||||
// [v2.2.314] LSEP 구분자 뒤가 "마무리 인사"뿐인 답변 — 모델이 본문 전체를
|
||||
// 추론 구간에 쓰고 가시 답변은 한 줄만 낸 실사례("이상으로 분석을 마칩니다"만
|
||||
// 남고 본문 증발). sanitize 가 마커 이전을 통째로 버리기 전에 여기서 잡아
|
||||
// thought-only 로 간주 → 아래 (b) final-only retry 가 본문을 재생성한다.
|
||||
// 재생성까지 실패하면 마커 이전 텍스트가 보고 본문 형태일 때만 구제 승격.
|
||||
let lsepTrappedBody = '';
|
||||
{
|
||||
const lsep = splitLsepReasoning(cleaned.visible);
|
||||
if (lsep.hasMarker && isCloserOnlyAnswer(lsep.after) && lsep.before.trim().length > 800) {
|
||||
lsepTrappedBody = extractVisibleFinal(lsep.before).visible;
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
visible: lsep.after.trim(),
|
||||
hiddenReasoning: [cleaned.hiddenReasoning, lsep.before].filter(Boolean).join('\n\n---\n\n'),
|
||||
hadHiddenReasoning: true,
|
||||
wasThoughtOnly: true,
|
||||
};
|
||||
logInfo('LSEP 마무리-인사-만 답변 감지 — 본문이 추론 구간에 갇힘, 재생성 시도.', {
|
||||
model: actualModel, trappedChars: lsepTrappedBody.length, closer: cleaned.visible.slice(0, 60),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// (b) Final-only retry — the reply was reasoning-only, no visible answer.
|
||||
// [v2.2.314] depth 제한 제거: thought-only 라운드는 실행할 액션도 가시 본문도
|
||||
// 없으므로 continuation(depth>0)에서도 재생성이 안전하고 필요하다 (분석 보고의
|
||||
// 최종 라운드가 정확히 이 지점에서 실패했다).
|
||||
if (shouldFinalOnlyRetry(cleaned)
|
||||
&& config.finalOnlyRetryOnThoughtLeak
|
||||
&& loopDepth === 0
|
||||
&& !this.abortController?.signal.aborted) {
|
||||
try {
|
||||
this.webview.postMessage({ type: 'autoContinue', value: '답변을 정리하는 중입니다...' });
|
||||
@@ -1305,6 +1429,23 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// [v2.2.314] 재생성까지 실패(여전히 마무리 인사뿐)했고, LSEP 마커 앞에 갇힌
|
||||
// 텍스트가 명백한 보고 본문 형태(헤딩/불릿/문단 다수)면 구제 승격 — 사용자가
|
||||
// 화면에서 봤다가 지워진 그 본문을 되살린다. 형태가 애매하면(진짜 추론일 수
|
||||
// 있음) 승격하지 않는다 — 생각 과정 노출(v2.2.310에서 수정한 문제)의 회귀 방지.
|
||||
if (lsepTrappedBody
|
||||
&& isCloserOnlyAnswer(cleaned.visible)
|
||||
&& looksLikeReportBody(lsepTrappedBody)) {
|
||||
logInfo('LSEP 갇힌 본문 구제 승격 — 재생성 실패 후 보고 형태 확인됨.', {
|
||||
trappedChars: lsepTrappedBody.length,
|
||||
});
|
||||
cleaned = {
|
||||
...cleaned,
|
||||
visible: `${lsepTrappedBody.trim()}\n\n${cleaned.visible}`.trim(),
|
||||
wasThoughtOnly: false,
|
||||
};
|
||||
}
|
||||
|
||||
// (c) Auto-continuation → src/agent/handlePrompt/applyAutoContinuation.ts
|
||||
let continuationCount = 0;
|
||||
if (config.autoContinueOnOutputLimit && config.maxAutoContinuations > 0 && loopDepth === 0) {
|
||||
@@ -1363,6 +1504,50 @@ export class AgentExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
// [v2.2.313] 파트별 완성 — 이어쓰기(연장)로도 출력 한계에 닿은 보고서형 답변은
|
||||
// "더 좁은 주제로 나눠 질문하세요"로 사용자에게 떠넘기지 않고, 남은 섹션을
|
||||
// 백엔드가 독립 호출로 나눠 생성해 한 문서로 통합한다 (사용자 제안 반영).
|
||||
// 섹션 호출은 시스템+히스토리 프리픽스를 그대로 재사용 → KV 캐시로 프리필 저렴.
|
||||
// 액션 태그가 남은 답변(아직 실행 단계)은 제외 — 완성은 최종 서술 답변에만.
|
||||
if (getConfig().sectionedCompletionEnabled !== false
|
||||
&& cleaned.visible.trim().length >= 600
|
||||
&& !this.abortController?.signal.aborted
|
||||
&& !/<(read_file|list_files|investigate_files|run_command|run_code|create_file|edit_file|delete_file)\b/i.test(cleaned.visible)) {
|
||||
const _stopKindEarly = classifyStopReason(finishStopReason);
|
||||
if (_stopKindEarly === 'output-limit' || looksCutOff(cleaned.visible)) {
|
||||
const lastUserMsg = String([...this.chatHistory].reverse().find(m => m.role === 'user' && !m.internal)?.content || prompt || '');
|
||||
const detectedTask = detectTaskType(lastUserMsg);
|
||||
if (detectedTask || isAnalysisRequest(lastUserMsg)) {
|
||||
try {
|
||||
this.webview.postMessage({ type: 'autoContinue', value: '남은 섹션을 나눠 작성해 한 문서로 통합하는 중...' });
|
||||
const { completeSectioned } = await import('./agent/handlePrompt/sectionedCompletion');
|
||||
const done = await completeSectioned({
|
||||
draft: cleaned.visible,
|
||||
userPrompt: lastUserMsg,
|
||||
baseMessages: messagesForRequest,
|
||||
requiredElements: detectedTask?.elements.map(e => e.label),
|
||||
callLLM: async (messages, maxTokens) => {
|
||||
const r = await this.callNonStreaming({
|
||||
baseUrl: ollamaUrl, modelName: actualModel, engine, messages,
|
||||
temperature: 0.2, maxTokens, contextLength: ctxLimits.contextLength,
|
||||
signal: this.abortController?.signal,
|
||||
});
|
||||
const rc = extractVisibleFinal(r.text);
|
||||
return rc.visible || r.text;
|
||||
},
|
||||
onProgress: (m) => this.webview?.postMessage({ type: 'autoContinue', value: m }),
|
||||
});
|
||||
if (done.sectionsAdded > 0) {
|
||||
cleaned = { ...cleaned, visible: done.merged };
|
||||
finishStopReason = 'eosFound'; // 통합 완성 — 잘림 안내 억제
|
||||
}
|
||||
} catch (e: any) {
|
||||
logError('파트별 완성 실패 — 초안 유지 (종전 안내로 폴백).', { error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 답변 sanitize / policy enforcement → src/agent/handlePrompt/processFinalAnswer.ts
|
||||
const _finalProc = processFinalAnswer({
|
||||
visibleAnswer: cleaned.visible,
|
||||
@@ -1376,6 +1561,7 @@ export class AgentExecutor {
|
||||
actualModel,
|
||||
engine,
|
||||
inputTokens,
|
||||
recordGuardActive: !!options.designerContext,
|
||||
});
|
||||
const cleanedVisible = _finalProc.cleanedVisible;
|
||||
const assistantContent = _finalProc.assistantContent;
|
||||
@@ -1563,7 +1749,29 @@ export class AgentExecutor {
|
||||
// append via streamChunk because the bubble has multiple action
|
||||
// segments and we don't have a single "final" to replace with.
|
||||
if (loopDepth === 0) {
|
||||
this.webview.postMessage({ type: 'streamReplace', value: finalAssistantContent });
|
||||
// [v2.2.315] 웹 실접속 표시 — URL 요청이면 실패는 상단 경고, 성공은 하단 출처.
|
||||
// [v2.2.317] + 본문 불일치 검증 — 접속 성공인데 실데이터 미반영 일반론이면 경고.
|
||||
const _webNotice = buildWebFetchNotice(this._turnCtx.webFetch);
|
||||
const _mismatch = detectWebContentMismatch(finalAssistantContent, this._turnCtx.webFetch);
|
||||
const _mismatchFooter = _mismatch.mismatch ? formatWebContentMismatchFooter() : '';
|
||||
if (_mismatch.mismatch) logInfo('웹 본문 불일치 감지.', { hits: _mismatch.hits, checked: _mismatch.checkedTokens });
|
||||
// [v2.2.318] 하위 질문 커버리지 — 명시된 물음이 답변에서 빠졌으면 목록 표시.
|
||||
const _subCov = checkSubQuestionCoverage(prompt, finalAssistantContent);
|
||||
const _subFooter = formatSubQuestionFooter(_subCov);
|
||||
if (_subFooter) logInfo('하위 질문 누락 감지.', { total: _subCov.total, missing: _subCov.missing.length });
|
||||
this.webview.postMessage({ type: 'streamReplace', value: `${_webNotice.top}${finalAssistantContent}${_webNotice.bottom}${_mismatchFooter}${_subFooter}` });
|
||||
// [v2.2.313] 파일 미확인 분석 경고 — 로컬 경로 분석 요청인데 이 턴에
|
||||
// 액션을 한 번도 안 쓰고 답한 경우 (hollow/ungrounded 가 못 잡는 사각지대).
|
||||
// [v2.2.315] URL 요청은 제외 — 웹 분석은 파일 액션이 필요 없고, 웹 쪽은
|
||||
// 위의 실접속 표시가 담당한다 (koritips.com 오탐 실사례).
|
||||
try {
|
||||
if (prompt && isAnalysisRequest(prompt)
|
||||
&& !(this._turnCtx.webFetch && this._turnCtx.webFetch.urls.length > 0)
|
||||
&& detectUnreadAnalysis(finalAssistantContent, this._turnCtx.actionStats, !!localPathContext)) {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: formatUnreadAnalysisFooter() });
|
||||
logInfo('Unread Analysis 감지 (depth 0, 액션 0회).', { stats: this._turnCtx.actionStats });
|
||||
}
|
||||
} catch { /* 감지 실패가 답변을 막지 않음 */ }
|
||||
recordTelemetry({
|
||||
kind: 'turn',
|
||||
durationMs: Date.now() - turnStartMs,
|
||||
@@ -1574,7 +1782,10 @@ export class AgentExecutor {
|
||||
stopReason: finishStopReason,
|
||||
brainFiles: this._turnCtx.retrieval?.usedBrainFiles.length ?? 0,
|
||||
memoryLayers: this._turnCtx.retrieval?.usedMemoryLayers ?? [],
|
||||
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`,
|
||||
note: `continuations=${continuationCount} historyDropped=${reqMessages.length - budgetedHistory.length}`
|
||||
+ (this._turnCtx.webFetch
|
||||
? ` webFetch=${this._turnCtx.webFetch.succeeded.length}/${this._turnCtx.webFetch.urls.length}`
|
||||
: ''),
|
||||
});
|
||||
// ── Post-answer hooks (v2.2.197) — Devil + SelfCheck + TermValidator 통합 레지스트리. ──
|
||||
// 새 hook 추가 = `src/agent/postAnswerHooks/index.ts` 에 한 객체 push.
|
||||
@@ -1597,7 +1808,12 @@ export class AgentExecutor {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||
// [v2.2.315] 웹 실접속 표시 — 액션 턴의 최종 라운드에도 동일 적용.
|
||||
const _webNotice2 = buildWebFetchNotice(this._turnCtx.webFetch);
|
||||
const _mismatch2 = detectWebContentMismatch(finalAssistantContent, this._turnCtx.webFetch);
|
||||
const _lastUser2 = String([...this.chatHistory].reverse().find(m => m.role === 'user' && !m.internal)?.content || '');
|
||||
const _subFooter2 = formatSubQuestionFooter(checkSubQuestionCoverage(_lastUser2, finalAssistantContent));
|
||||
this.webview.postMessage({ type: 'streamChunk', value: `${_webNotice2.top}${finalAssistantContent}${_webNotice2.bottom}${_mismatch2.mismatch ? formatWebContentMismatchFooter() : ''}${_subFooter2}` });
|
||||
// [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
|
||||
// post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
|
||||
// "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { stripMarkdownFormatting, looksCutOff } from '../../core/responseRecover
|
||||
import {
|
||||
sanitizeAssistantContent,
|
||||
parseRationale,
|
||||
stripLeakedInternalSections,
|
||||
} from '../../lib/contextBuilders/outputSanitization';
|
||||
import {
|
||||
isSecondBrainInventoryRequest,
|
||||
@@ -42,6 +43,11 @@ export interface ProcessFinalAnswerInput {
|
||||
actualModel: string;
|
||||
engine: string;
|
||||
inputTokens: number;
|
||||
/**
|
||||
* [v2.2.314] Project Record Guard(designerContext) 활성 턴 여부 — 활성이면 가드가
|
||||
* 요구하는 "요청 요약" 류 섹션을 보존하고, 아니면 유출 스캐폴딩으로 보고 제거한다.
|
||||
*/
|
||||
recordGuardActive?: boolean;
|
||||
}
|
||||
|
||||
export interface ProcessFinalAnswerResult {
|
||||
@@ -87,6 +93,11 @@ export function processFinalAnswer(input: ProcessFinalAnswerInput): ProcessFinal
|
||||
),
|
||||
localPathContext
|
||||
);
|
||||
// [v2.2.314] 내부 스캐폴딩 섹션 유출 제거 — Record Guard 턴이 아니면 "요청 요약/
|
||||
// 사용자 의도 추론/핵심 확인 질문" 류는 규칙 4 위반 유출이므로 결정론으로 걷어낸다.
|
||||
if (!input.recordGuardActive) {
|
||||
assistantContent = stripLeakedInternalSections(assistantContent);
|
||||
}
|
||||
if (prompt && isSecondBrainInventoryRequest(prompt) && brainFiles.length > 0 && isNoBrainDataRefusal(assistantContent)) {
|
||||
assistantContent = buildSecondBrainInventoryFallbackAnswer(activeBrain, brainFiles, secondBrainTrace);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* [v2.2.313] 파트별 완성 (Sectioned Completion) — 사용자 제안 구현.
|
||||
*
|
||||
* 문제: 보고서형 답변이 출력 토큰 한계에 닿으면 자동 이어쓰기(같은 호출의 연장)를
|
||||
* 최대 N회 시도하고, 그래도 못 채우면 "더 좁은 주제로 나눠 질문하세요" 안내로 끝났다.
|
||||
* 나눠서 다시 묻는 부담을 사용자에게 떠넘긴 것.
|
||||
*
|
||||
* 해결: 한계에 닿은 초안을 받으면 백엔드가
|
||||
* 1) 남은 섹션 제목을 추출하고 (outline 호출 1회, 이미 쓴 섹션 제외)
|
||||
* 2) 섹션마다 *독립 호출*로 본문을 생성한 뒤 (호출당 출력 예산이 온전히 확보됨)
|
||||
* 3) 결정적으로 이어 붙여 한 문서로 통합한다.
|
||||
* KV 캐시 분리(v2.2.311) 덕에 각 섹션 호출의 프리픽스(시스템+히스토리)는 캐시
|
||||
* 재사용이라 추가 프리필 비용이 작다. 어느 단계든 실패하면 원본 초안 그대로 반환
|
||||
* (종전 동작 폴백 — 조용한 폴백 금지 원칙에 따라 로그는 남긴다).
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn } from '../../utils';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
export interface SectionedCompletionParams {
|
||||
/** 길이 한계에 닿은 현재 초안 (가시 본문). */
|
||||
draft: string;
|
||||
/** 사용자의 원 요청 (continuation 프롬프트가 아니라 마지막 실제 user 메시지). */
|
||||
userPrompt: string;
|
||||
/** 이번 턴 요청에 실제 사용된 메시지 배열 — 시스템/컨텍스트/히스토리 재사용. */
|
||||
baseMessages: ChatMessage[];
|
||||
/** 업무 유형 필수 요소 라벨 (requirementGraph) — outline 이 우선 포함해야 할 섹션. */
|
||||
requiredElements?: string[];
|
||||
/** 비스트리밍 LLM 호출자 — agent.ts 의 callNonStreaming 클로저. */
|
||||
callLLM: (messages: ChatMessage[], maxTokens: number) => Promise<string>;
|
||||
maxSections?: number;
|
||||
/** 섹션당 출력 토큰 예산. */
|
||||
perSectionTokens?: number;
|
||||
onProgress?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface SectionedCompletionResult {
|
||||
merged: string;
|
||||
sectionsAdded: number;
|
||||
}
|
||||
|
||||
/** outline 응답에서 섹션 제목 목록 파싱 — 불릿/번호/헤딩 마커 제거, 길이 필터. */
|
||||
export function parseOutlineTitles(raw: string, max: number): string[] {
|
||||
return (raw || '')
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^[\s>*#\-•\d.)\]]+/, '').trim())
|
||||
.filter((l) => l.length >= 2 && l.length <= 60)
|
||||
.filter((l) => !/^(없음|완료|끝|done|none)$/i.test(l))
|
||||
.slice(0, max);
|
||||
}
|
||||
|
||||
/** 초안에 이미 존재하는 섹션과 outline 제목의 중복 제거 (느슨한 포함 매치). */
|
||||
export function dropAlreadyWritten(titles: string[], draft: string): string[] {
|
||||
const d = (draft || '').replace(/\s+/g, '');
|
||||
return titles.filter((t) => {
|
||||
const key = t.replace(/\s+/g, '');
|
||||
return key.length > 0 && !d.includes(key);
|
||||
});
|
||||
}
|
||||
|
||||
export async function completeSectioned(params: SectionedCompletionParams): Promise<SectionedCompletionResult> {
|
||||
const maxSections = Math.max(1, Math.min(6, params.maxSections ?? 4));
|
||||
const perSectionTokens = Math.max(400, params.perSectionTokens ?? 1200);
|
||||
const draftTail = params.draft.length > 3000 ? `…${params.draft.slice(-3000)}` : params.draft;
|
||||
|
||||
// 1) 남은 섹션 outline.
|
||||
params.onProgress?.('남은 섹션을 파악하는 중...');
|
||||
const requiredBlock = params.requiredElements?.length
|
||||
? `\n[우선 포함해야 할 요소]\n${params.requiredElements.map((e) => `- ${e}`).join('\n')}`
|
||||
: '';
|
||||
const outlineRaw = await params.callLLM([
|
||||
{
|
||||
role: 'system',
|
||||
content: '너는 편집자다. 길이 한계로 잘린 초안을 완성하기 위해, 아직 작성되지 않은 남은 섹션 제목만 한 줄에 하나씩 출력한다. 설명·번호·인사 금지. 이미 초안에 있는 섹션은 제외. 남은 것이 없으면 "없음" 한 단어만 출력.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `[사용자 요청]\n${params.userPrompt}\n${requiredBlock}\n\n[잘린 초안 (끝부분)]\n${draftTail}\n\n남은 섹션 제목 (최대 ${maxSections}개):`,
|
||||
},
|
||||
], 200);
|
||||
const titles = dropAlreadyWritten(parseOutlineTitles(outlineRaw, maxSections), params.draft);
|
||||
if (titles.length === 0) {
|
||||
logInfo('[SectionedCompletion] 남은 섹션 없음 — 초안 유지.', { outlineRaw: (outlineRaw || '').slice(0, 80) });
|
||||
return { merged: params.draft, sectionsAdded: 0 };
|
||||
}
|
||||
|
||||
// 2) 섹션별 독립 생성 — 시스템/컨텍스트/히스토리를 그대로 재사용해 근거 접근 유지.
|
||||
const pieces: string[] = [];
|
||||
for (let i = 0; i < titles.length; i++) {
|
||||
const title = titles[i];
|
||||
params.onProgress?.(`섹션 작성 중 (${i + 1}/${titles.length}): ${title}`);
|
||||
try {
|
||||
const body = (await params.callLLM([
|
||||
...params.baseMessages,
|
||||
{ role: 'assistant', content: draftTail },
|
||||
{
|
||||
role: 'user',
|
||||
content: `위 답변이 길이 한계로 잘렸다. 이어질 "${title}" 섹션 *하나만* 작성하라.\n- "## ${title}" 헤딩으로 시작.\n- 다른 섹션·서론·맺음말·인사 금지. 이미 쓴 내용 반복 금지.\n- 컨텍스트에 있는 실제 근거(파일명·수치·문서)를 인용. 없으면 "확인 불가"로 명시.\n- 500~900자.`,
|
||||
},
|
||||
], perSectionTokens)).trim();
|
||||
if (!body) continue;
|
||||
pieces.push(body.startsWith('#') ? body : `## ${title}\n${body}`);
|
||||
} catch (e: any) {
|
||||
logWarn('[SectionedCompletion] 섹션 생성 실패 — 건너뜀.', { title, error: e?.message ?? String(e) });
|
||||
}
|
||||
}
|
||||
if (pieces.length === 0) {
|
||||
logWarn('[SectionedCompletion] 생성된 섹션 없음 — 초안 유지.');
|
||||
return { merged: params.draft, sectionsAdded: 0 };
|
||||
}
|
||||
|
||||
// 3) 결정적 통합 — 한 문서로.
|
||||
const merged = `${params.draft.trimEnd()}\n\n${pieces.join('\n\n')}`;
|
||||
logInfo('[SectionedCompletion] 파트별 완성.', { sections: pieces.length, titles, addedChars: merged.length - params.draft.length });
|
||||
return { merged, sectionsAdded: pieces.length };
|
||||
}
|
||||
@@ -246,6 +246,20 @@ export interface IAgentConfig {
|
||||
* 검색 결과에 써서 로컬 프리필만 수십 초를 태웠다 — 상한을 낮춰 답변 지연을 줄인다.
|
||||
*/
|
||||
retrievalTokenBudget: number;
|
||||
/**
|
||||
* [v2.2.313] 파트별 완성 — 보고서형 답변이 이어쓰기로도 출력 한계에 닿으면
|
||||
* 남은 섹션을 백엔드에서 독립 호출로 나눠 생성해 한 문서로 통합. 기본 true.
|
||||
*/
|
||||
sectionedCompletionEnabled: boolean;
|
||||
/**
|
||||
* [v2.2.316] 턴 의도 브리핑 — 실질 요청 턴마다 목적/진짜 궁금증/산출물 형태/독자/
|
||||
* 근거 경로를 LLM 1회로 추출해 주입 (retrieval 과 병렬 — 체감 지연 최소).
|
||||
*/
|
||||
intentBriefEnabled: boolean;
|
||||
/** [v2.2.318] 루트 URL 분석 요청 시 내부 글 2개까지 추가 수집 (글 퀄리티 판단 데이터). */
|
||||
webDeepAnalyzeEnabled: boolean;
|
||||
/** 의도 브리핑 전용 모델 ID. 비면 현재 턴 모델. 빠른 소형 모델 권장. */
|
||||
intentBriefModel: string;
|
||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||
selfCheckModel: string;
|
||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||
@@ -597,6 +611,10 @@ export function getConfig(): IAgentConfig {
|
||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
|
||||
kvCachePromptSplit: cfg.get<boolean>('kvCachePromptSplit', true),
|
||||
sectionedCompletionEnabled: cfg.get<boolean>('sectionedCompletionEnabled', true),
|
||||
intentBriefEnabled: cfg.get<boolean>('intentBriefEnabled', true),
|
||||
webDeepAnalyzeEnabled: cfg.get<boolean>('webDeepAnalyzeEnabled', true),
|
||||
intentBriefModel: (cfg.get<string>('intentBriefModel', '') || '').trim(),
|
||||
retrievalTokenBudget: Math.max(0, cfg.get<number>('retrievalTokenBudget', 0) || 0),
|
||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||
|
||||
@@ -8,6 +8,7 @@ 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)
|
||||
import './features/naverTrend/handlers'; // /naver-shopping, /naver-trend — 네이버 데이터랩
|
||||
// axios removed in favor of native fetch
|
||||
import {
|
||||
_getBrainDir,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* 네이버쇼핑 카테고리 이름 ↔ cat_id 매핑.
|
||||
*
|
||||
* 데이터랩 쇼핑인사이트 문서는 "네이버쇼핑에서 카테고리 선택 시 URL의 cat_id
|
||||
* 파라미터 값으로 확인"이라고만 안내한다 — 즉 API 가 받는 cat_id 는 대분류
|
||||
* 11종의 "루트" 코드에 국한되지 않고, 네이버쇼핑 전체 카테고리 트리(중/소/세
|
||||
* 분류 포함)의 어떤 노드든 가능하다.
|
||||
*
|
||||
* ROOT_CATEGORY_CODES 는 공식 예시(패션의류=50000000, 화장품/미용=50000002)와
|
||||
* 교차 검증된 대분류 루트 코드만 담는다 — 검증 못 한 번호는 추측해 넣지 않는다
|
||||
* (틀린 cat_id 는 API 가 에러 없이 빈 데이터를 조용히 돌려줘서 발견하기 어려움).
|
||||
* 나머지 대분류/세부 카테고리는 CATEGORY_TREE(전체 트리, out-of-repo 소스:
|
||||
* isnow890/naver-search-mcp, MIT) 에서 fuzzy 매칭으로 찾는다.
|
||||
*/
|
||||
|
||||
import * as categoryTreeRaw from './data/naverShoppingCategories.json';
|
||||
|
||||
export interface CategoryRow {
|
||||
code: string;
|
||||
level1: string;
|
||||
level2: string;
|
||||
level3: string;
|
||||
level4: string;
|
||||
}
|
||||
|
||||
export interface CategoryMatch {
|
||||
/** 사람이 읽는 이름 — 매칭된 가장 구체적인 레벨. */
|
||||
name: string;
|
||||
/** 전체 경로 — "패션의류 > 원피스" 처럼 사용자에게 확인용으로 보여줄 문자열. */
|
||||
path: string;
|
||||
code: string;
|
||||
/** 'root' = 검증된 대분류, 'tree' = 카테고리 트리 매칭, 'raw' = 사용자가 cat_id 숫자를 직접 입력. */
|
||||
source: 'root' | 'tree' | 'raw';
|
||||
}
|
||||
|
||||
/** 공식 문서 예시 + 독립 교차 검증된 대분류 루트 코드만. 검증 못 한 나머지는 CATEGORY_TREE 매칭에 위임. */
|
||||
export const ROOT_CATEGORY_CODES: Record<string, string> = {
|
||||
'패션의류': '50000000',
|
||||
'화장품/미용': '50000002',
|
||||
'디지털/가전': '50000003',
|
||||
'식품': '50000008',
|
||||
};
|
||||
|
||||
const CATEGORY_TREE = categoryTreeRaw as unknown as CategoryRow[];
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.trim().toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
function pathOf(row: CategoryRow): string {
|
||||
return [row.level1, row.level2, row.level3, row.level4].filter(Boolean).join(' > ');
|
||||
}
|
||||
|
||||
/** row 의 가장 구체적인(비어있지 않은 마지막) 레벨 이름. */
|
||||
function leafName(row: CategoryRow): string {
|
||||
return row.level4 || row.level3 || row.level2 || row.level1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 이름(또는 이미 알고 있는 cat_id 숫자) → 후보 매칭 목록.
|
||||
* - 순수 숫자 입력이면 그대로 raw 코드로 통과.
|
||||
* - ROOT_CATEGORY_CODES 에 정확/부분 일치하면 그것을 최우선 반환.
|
||||
* - 그 외엔 전체 트리에서 이름이 포함된 행을 찾아, 레벨이 얕을수록(대분류에
|
||||
* 가까울수록) 우선 정렬 — level4>level3>level2>level1 매치 시 더 구체적인
|
||||
* 걸 원하는 사용자 의도를 존중하되, 동일 매치 개수면 얕은 레벨 우선.
|
||||
*/
|
||||
export function findCategoryCode(query: string, limit = 5): CategoryMatch[] {
|
||||
const q = query.trim();
|
||||
if (!q) return [];
|
||||
if (/^\d{6,}$/.test(q)) {
|
||||
return [{ name: q, path: q, code: q, source: 'raw' }];
|
||||
}
|
||||
|
||||
const nq = normalize(q);
|
||||
for (const [name, code] of Object.entries(ROOT_CATEGORY_CODES)) {
|
||||
if (normalize(name) === nq || normalize(name).includes(nq) || nq.includes(normalize(name))) {
|
||||
return [{ name, path: name, code, source: 'root' }];
|
||||
}
|
||||
}
|
||||
|
||||
type Scored = { row: CategoryRow; level: number; exact: boolean };
|
||||
const scored: Scored[] = [];
|
||||
for (const row of CATEGORY_TREE) {
|
||||
const levels: Array<[string, number]> = [
|
||||
[row.level1, 1], [row.level2, 2], [row.level3, 3], [row.level4, 4],
|
||||
];
|
||||
let bestLevel = 0;
|
||||
let exact = false;
|
||||
for (const [val, lvl] of levels) {
|
||||
if (!val) continue;
|
||||
const nv = normalize(val);
|
||||
if (nv === nq) { bestLevel = lvl; exact = true; break; }
|
||||
if (nv.includes(nq) || nq.includes(nv)) {
|
||||
if (bestLevel === 0) bestLevel = lvl;
|
||||
}
|
||||
}
|
||||
if (bestLevel > 0) scored.push({ row, level: bestLevel, exact });
|
||||
}
|
||||
|
||||
scored.sort((a, b) => {
|
||||
if (a.exact !== b.exact) return a.exact ? -1 : 1;
|
||||
return a.level - b.level; // 대분류에 가까운(레벨 낮은) 매치 우선
|
||||
});
|
||||
|
||||
const seen = new Set<string>();
|
||||
const out: CategoryMatch[] = [];
|
||||
for (const { row } of scored) {
|
||||
if (seen.has(row.code)) continue;
|
||||
seen.add(row.code);
|
||||
out.push({ name: leafName(row), path: pathOf(row), code: row.code, source: 'tree' });
|
||||
if (out.length >= limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 사람이 읽을 대분류(level1) 이름 전체 목록 — 도움말/에러 메시지용. */
|
||||
export function listTopLevelCategoryNames(): string[] {
|
||||
return [...new Set(CATEGORY_TREE.map(r => r.level1))];
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* ============================================================
|
||||
* /naver-shopping, /naver-trend — 네이버 쇼핑인사이트 · 검색어트렌드
|
||||
*
|
||||
* /naver-shopping <자연어 질문> — 분야별 트렌드 비교/탐색
|
||||
* /naver-trend <자연어 질문> — 검색어 트렌드 비교/탐색
|
||||
*
|
||||
* 두 API 모두 "비교 대상을 지정하면 상대 비율을 돌려주는" 구조라, 사용자가
|
||||
* 대상을 지목하지 않은 탐색성 질문("요즘 뭐가 뜨나")은 LLM이 후보를 제안하고
|
||||
* 실제 API로 검증한 뒤 비율순으로 정렬해 보여준다(discover 모드) — 네이버
|
||||
* 전체 실시간 인기검색어 순위가 아니라는 점을 결과에 항상 명시한다.
|
||||
*
|
||||
* import 만으로 등록 — extension.ts 에서 `import './features/naverTrend/handlers'`.
|
||||
* ============================================================
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { registerSlashCommand, chunk } from '../datacollect/slashRouter';
|
||||
import { AIService } from '../../core/services';
|
||||
import { parseShoppingIntent, parseSearchTrendIntent } from './intentParser';
|
||||
import { findCategoryCode, listTopLevelCategoryNames, type CategoryMatch } from './categoryCodes';
|
||||
import { rankCategories, rankKeywords, type RankedResult } from './rankEngine';
|
||||
import type { CommonFilters, TimeUnit } from './naverApi';
|
||||
|
||||
type Webview = Parameters<typeof chunk>[0];
|
||||
|
||||
const DISCLAIMER = '⚠️ 이 순위는 네이버 전체 실시간 인기 순위가 아니라, 후보군 내에서 실제 네이버 데이터랩 API로 검증한 상대 비교치입니다.';
|
||||
|
||||
function ymd(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 기본 조회 기간 — "오늘"은 데이터랩 집계가 안 됐을 수 있어 어제를 종료일로. */
|
||||
function defaultRange(): { startDate: string; endDate: string; timeUnit: TimeUnit } {
|
||||
const end = new Date();
|
||||
end.setDate(end.getDate() - 1);
|
||||
const start = new Date(end);
|
||||
start.setDate(start.getDate() - 6);
|
||||
return { startDate: ymd(start), endDate: ymd(end), timeUnit: 'date' };
|
||||
}
|
||||
|
||||
function buildFilters(intent: { device?: 'pc' | 'mo'; gender?: 'm' | 'f'; ages?: string[] }): CommonFilters {
|
||||
const filters: CommonFilters = {};
|
||||
if (intent.device) filters.device = intent.device;
|
||||
if (intent.gender) filters.gender = intent.gender;
|
||||
if (intent.ages && intent.ages.length > 0) filters.ages = intent.ages;
|
||||
return filters;
|
||||
}
|
||||
|
||||
function formatRankedTable(result: RankedResult, itemLabel: string): string {
|
||||
if (result.items.length === 0) return '_(결과 없음)_\n';
|
||||
const lines: string[] = [];
|
||||
lines.push(`| # | ${itemLabel} | 최근 ratio |`);
|
||||
lines.push('|---|---|---|');
|
||||
result.items.forEach((it, i) => {
|
||||
const label = it.path && it.path !== it.name ? `${it.name} _(${it.path})_` : it.name;
|
||||
lines.push(`| ${i + 1} | ${label} | ${it.latestRatio.toFixed(1)} |`);
|
||||
});
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
function formatErrors(errors: string[]): string {
|
||||
if (errors.length === 0) return '';
|
||||
return '\n' + errors.map(e => `> ⚠️ ${e}`).join('\n') + '\n';
|
||||
}
|
||||
|
||||
// ── /naver-shopping ──────────────────────────────────────────────────────
|
||||
|
||||
function cmdShoppingHelp(view: Webview): void {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**/naver-shopping** — 네이버 쇼핑인사이트(분야별 트렌드)',
|
||||
'',
|
||||
'- `/naver-shopping 패션의류 vs 화장품/미용 최근 일주일` — 명시 분야 비교',
|
||||
'- `/naver-shopping 요즘 뜨는 뷰티 카테고리 알려줘` — 탐색(후보 제안 + 검증)',
|
||||
'',
|
||||
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function runShopping(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { cmdShoppingHelp(view); return true; }
|
||||
|
||||
try {
|
||||
chunk(view, '\n🔍 질문 분석 중…\n');
|
||||
const ai = new AIService();
|
||||
const intent = await parseShoppingIntent(ai, trimmed, ymd(new Date()));
|
||||
|
||||
const resolved: Array<{ name: string; path?: string; code: string }> = [];
|
||||
const unresolved: string[] = [];
|
||||
for (const name of intent.categories) {
|
||||
const matches: CategoryMatch[] = findCategoryCode(name, 1);
|
||||
if (matches.length > 0) resolved.push({ name: matches[0].name, path: matches[0].path, code: matches[0].code });
|
||||
else unresolved.push(name);
|
||||
}
|
||||
|
||||
if (resolved.length === 0) {
|
||||
chunk(view, `\n❓ 분야를 찾지 못했습니다: ${unresolved.join(', ')}\n\n지원 대분류 예시: ${listTopLevelCategoryNames().join(', ')}\n(세부 카테고리명으로도 검색 가능 — 예: "원피스", "무선이어폰")\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const range = {
|
||||
startDate: intent.startDate || defaultRange().startDate,
|
||||
endDate: intent.endDate || defaultRange().endDate,
|
||||
timeUnit: intent.timeUnit || 'date' as TimeUnit,
|
||||
};
|
||||
const filters = buildFilters(intent);
|
||||
|
||||
chunk(view, `\n📊 조회 중 — ${resolved.map(r => r.name).join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
|
||||
const result = await rankCategories(context, resolved, range, filters);
|
||||
|
||||
const title = intent.mode === 'discover'
|
||||
? `**네이버 쇼핑인사이트 — "${trimmed}" 관련 분야 TOP ${result.items.length}**`
|
||||
: `**네이버 쇼핑인사이트 — 분야 비교**`;
|
||||
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
|
||||
chunk(view, formatRankedTable(result, '분야'));
|
||||
if (unresolved.length > 0) chunk(view, `\n> ❓ 못 찾은 분야: ${unresolved.join(', ')}\n`);
|
||||
chunk(view, formatErrors(result.errors));
|
||||
chunk(view, `\n${DISCLAIMER}\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ /naver-shopping 오류: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── /naver-trend ─────────────────────────────────────────────────────────
|
||||
|
||||
function cmdSearchTrendHelp(view: Webview): void {
|
||||
chunk(view, [
|
||||
'',
|
||||
'**/naver-trend** — 네이버 검색어트렌드',
|
||||
'',
|
||||
'- `/naver-trend 원피스,블라우스 비교` — 명시 키워드 비교',
|
||||
'- `/naver-trend 20대 여성한테 인기있는 원피스 스타일 키워드 10개` — 탐색(후보 제안 + 검증)',
|
||||
'',
|
||||
'- API 키 설정: Astra 설정 → Naver Trend 탭에서 Client ID/Secret 입력',
|
||||
'',
|
||||
].join('\n'));
|
||||
}
|
||||
|
||||
async function runSearchTrend(arg: string, view: Webview, context?: vscode.ExtensionContext): Promise<boolean> {
|
||||
if (!context) { chunk(view, '\n⚠️ ExtensionContext 없음.\n'); return true; }
|
||||
const trimmed = arg.trim();
|
||||
if (!trimmed) { cmdSearchTrendHelp(view); return true; }
|
||||
|
||||
try {
|
||||
chunk(view, '\n🔍 질문 분석 중…\n');
|
||||
const ai = new AIService();
|
||||
const intent = await parseSearchTrendIntent(ai, trimmed, ymd(new Date()));
|
||||
|
||||
const range = {
|
||||
startDate: intent.startDate || defaultRange().startDate,
|
||||
endDate: intent.endDate || defaultRange().endDate,
|
||||
timeUnit: intent.timeUnit || 'date' as TimeUnit,
|
||||
};
|
||||
const filters = buildFilters(intent);
|
||||
|
||||
chunk(view, `\n📊 조회 중 — ${intent.keywords.join(', ')} (${range.startDate} ~ ${range.endDate})…\n`);
|
||||
const result = await rankKeywords(context, intent.keywords, range, filters);
|
||||
|
||||
const title = intent.mode === 'discover'
|
||||
? `**네이버 검색어트렌드 — "${trimmed}" 관련 키워드 TOP ${result.items.length}**`
|
||||
: `**네이버 검색어트렌드 — 키워드 비교**`;
|
||||
chunk(view, `\n${title} (${range.startDate}~${range.endDate})\n\n`);
|
||||
chunk(view, formatRankedTable(result, '키워드'));
|
||||
chunk(view, formatErrors(result.errors));
|
||||
chunk(view, `\n${DISCLAIMER}\n`);
|
||||
} catch (e: any) {
|
||||
chunk(view, `\n❌ /naver-trend 오류: ${e?.message || String(e)}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
registerSlashCommand({ name: '/naver-shopping', description: '네이버 쇼핑인사이트 — 분야별 트렌드 조회/비교', handler: runShopping });
|
||||
registerSlashCommand({ name: '/naver-trend', description: '네이버 검색어트렌드 — 키워드 검색 추이 조회/비교', handler: runSearchTrend });
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* 자연어 질문 → 쇼핑인사이트/검색어트렌드 API 파라미터 구조화.
|
||||
*
|
||||
* intentClassifier.ts 의 4단계 tolerant JSON 파서(JSON.parse → 코드펜스 제거 →
|
||||
* balanced-brace 추출 → regex pluck) 와 "파싱 실패 시 안전한 기본값" 원칙을
|
||||
* 그대로 재사용한다. 이 코드베이스는 같은 패턴을 파일마다 반복 구현하는 관례가
|
||||
* 있어(ceoPlanner/dispatcher/intentClassifier) 별도 공유 유틸로 뽑지 않았다.
|
||||
*
|
||||
* mode:
|
||||
* - 'compare' : 사용자가 비교 대상(분야/키워드)을 명시적으로 언급함 → 그대로 추출.
|
||||
* - 'discover' : "요즘 뭐가 뜨나/인기 뭐야/top N" 같은 탐색성 질문 → LLM이 도메인
|
||||
* 지식으로 후보를 제안(진짜 실시간 순위가 아니라 "검증할 후보"라는 점을
|
||||
* handlers.ts 가 출력 시 디스클레이머로 명시).
|
||||
*/
|
||||
|
||||
import { IAIService } from '../../core/services';
|
||||
import { logError, logInfo } from '../../utils';
|
||||
|
||||
export type IntentMode = 'compare' | 'discover';
|
||||
|
||||
export interface ShoppingIntent {
|
||||
mode: IntentMode;
|
||||
categories: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
timeUnit?: 'date' | 'week' | 'month';
|
||||
device?: 'pc' | 'mo';
|
||||
gender?: 'm' | 'f';
|
||||
ages?: string[]; // '10'|'20'|'30'|'40'|'50'|'60'
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
export interface SearchTrendIntent {
|
||||
mode: IntentMode;
|
||||
keywords: string[];
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
timeUnit?: 'date' | 'week' | 'month';
|
||||
device?: 'pc' | 'mo';
|
||||
gender?: 'm' | 'f';
|
||||
ages?: string[]; // '1'..'11'
|
||||
parsed: boolean;
|
||||
}
|
||||
|
||||
function _extractFirstBalancedObject(s: string): string | null {
|
||||
const start = s.indexOf('{');
|
||||
if (start === -1) return null;
|
||||
let depth = 0;
|
||||
let inString = false;
|
||||
let escape = false;
|
||||
for (let i = start; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (inString) {
|
||||
if (escape) escape = false;
|
||||
else if (ch === '\\') escape = true;
|
||||
else if (ch === '"') inString = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"') { inString = true; continue; }
|
||||
if (ch === '{') depth++;
|
||||
else if (ch === '}') {
|
||||
depth--;
|
||||
if (depth === 0) return s.slice(start, i + 1);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** JSON.parse → 코드펜스 제거 → balanced-brace 추출, 3단계. 실패하면 null. */
|
||||
function _parseJsonLoose(raw: string): Record<string, unknown> | null {
|
||||
if (!raw || !raw.trim()) return null;
|
||||
const fenced = raw.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
|
||||
const stage1 = (fenced ? fenced[1] : raw).trim();
|
||||
try {
|
||||
const obj = JSON.parse(stage1);
|
||||
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
|
||||
} catch { /* fall through */ }
|
||||
const balanced = _extractFirstBalancedObject(stage1);
|
||||
if (balanced) {
|
||||
try {
|
||||
const obj = JSON.parse(balanced);
|
||||
if (obj && typeof obj === 'object') return obj as Record<string, unknown>;
|
||||
} catch { /* fall through */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _asStringArray(v: unknown): string[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
return v.filter((x): x is string => typeof x === 'string' && x.trim().length > 0).map(s => s.trim());
|
||||
}
|
||||
|
||||
function _asMode(v: unknown): IntentMode {
|
||||
return v === 'discover' ? 'discover' : 'compare';
|
||||
}
|
||||
|
||||
function _asDate(v: unknown): string | undefined {
|
||||
return typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) ? v : undefined;
|
||||
}
|
||||
|
||||
function _asTimeUnit(v: unknown): 'date' | 'week' | 'month' | undefined {
|
||||
return v === 'date' || v === 'week' || v === 'month' ? v : undefined;
|
||||
}
|
||||
|
||||
function _asDevice(v: unknown): 'pc' | 'mo' | undefined {
|
||||
return v === 'pc' || v === 'mo' ? v : undefined;
|
||||
}
|
||||
|
||||
function _asGender(v: unknown): 'm' | 'f' | undefined {
|
||||
return v === 'm' || v === 'f' ? v : undefined;
|
||||
}
|
||||
|
||||
const COMMON_FIELDS_PROMPT = `
|
||||
- "startDate"/"endDate": 사용자가 기간을 명시했으면 yyyy-mm-dd, 아니면 빈 문자열 "".
|
||||
- "timeUnit": "date"(일간)|"week"(주간)|"month"(월간). 명시 없으면 "".
|
||||
- "device": "pc"|"mo". 명시 없으면 "".
|
||||
- "gender": "m"|"f". 명시 없으면 "".
|
||||
- "ages": 연령대 배열. 명시 없으면 빈 배열.`;
|
||||
|
||||
function _buildUserMessage(userText: string, todayYmd: string): string {
|
||||
return [
|
||||
`(오늘 날짜: ${todayYmd})`,
|
||||
'',
|
||||
'[사용자 질문]',
|
||||
userText,
|
||||
'',
|
||||
'판정 JSON만 출력:',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// ── 쇼핑인사이트 ──────────────────────────────────────────────────────────
|
||||
|
||||
const SHOPPING_SYSTEM_PROMPT = `당신은 네이버 쇼핑인사이트(분야별 트렌드) API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
|
||||
|
||||
- "mode": 사용자가 구체적인 분야명(예: "패션의류", "화장품", "가전")을 하나 이상 언급했으면 "compare". "요즘 뭐가 뜨나"/"인기 분야"/"top N" 처럼 분야를 직접 지목하지 않고 탐색을 원하면 "discover".
|
||||
- "categories": mode="compare"면 사용자가 언급한 분야명을 그대로(정규화하지 말고 원문 그대로) 배열로. mode="discover"면 질문 맥락에 맞는 분야 후보를 당신의 지식으로 최대 9개 제안(3개씩 묶어 순차 조회하므로 3의 배수 권장).
|
||||
${COMMON_FIELDS_PROMPT}
|
||||
|
||||
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
|
||||
{"mode":"compare"|"discover","categories":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
|
||||
|
||||
export async function parseShoppingIntent(
|
||||
ai: IAIService,
|
||||
userText: string,
|
||||
todayYmd: string,
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<ShoppingIntent> {
|
||||
const trimmed = userText.trim();
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SHOPPING_SYSTEM_PROMPT,
|
||||
user: _buildUserMessage(trimmed, todayYmd),
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('naverTrend intentParser(shopping): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
|
||||
return { mode: 'compare', categories: [trimmed], parsed: false };
|
||||
}
|
||||
|
||||
const obj = _parseJsonLoose(raw);
|
||||
if (!obj) {
|
||||
logInfo('naverTrend intentParser(shopping): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
|
||||
return { mode: 'compare', categories: [trimmed], parsed: false };
|
||||
}
|
||||
const categories = _asStringArray(obj.categories);
|
||||
return {
|
||||
mode: _asMode(obj.mode),
|
||||
categories: categories.length > 0 ? categories : [trimmed],
|
||||
startDate: _asDate(obj.startDate),
|
||||
endDate: _asDate(obj.endDate),
|
||||
timeUnit: _asTimeUnit(obj.timeUnit),
|
||||
device: _asDevice(obj.device),
|
||||
gender: _asGender(obj.gender),
|
||||
ages: _asStringArray(obj.ages),
|
||||
parsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 검색어트렌드 ──────────────────────────────────────────────────────────
|
||||
|
||||
const SEARCH_TREND_SYSTEM_PROMPT = `당신은 네이버 검색어트렌드 API 질의 파서입니다. 사용자의 자연어 질문을 분석해 아래 JSON 하나만 출력하세요. 다른 텍스트(설명, 코드펜스, 머리말) 절대 금지.
|
||||
|
||||
- "mode": 사용자가 구체적인 검색어를 하나 이상 언급했으면 "compare". "인기 검색어"/"요즘 뜨는 키워드"/"top N" 처럼 키워드를 직접 지목하지 않고 탐색을 원하면 "discover".
|
||||
- "keywords": mode="compare"면 사용자가 언급한 검색어를 그대로 배열로. mode="discover"면 질문 맥락(주제/카테고리/타겟)에 맞는 검색어 후보를 당신의 지식으로 최대 10개 제안(개별 순위를 매기려면 키워드 1개=API 그룹 1개라 5개씩 순차 조회함을 감안).
|
||||
${COMMON_FIELDS_PROMPT}
|
||||
- "ages" 값은 검색어트렌드 전용 코드(1=0~12세,2=13~18세,3=19~24세,4=25~29세,5=30~34세,6=35~39세,7=40~44세,8=45~49세,9=50~54세,10=55~59세,11=60세이상)를 사용하세요 — 쇼핑인사이트의 10/20/30 대 코드와 다릅니다.
|
||||
|
||||
⚠️ 반드시 아래 JSON 형식 정확히 한 번:
|
||||
{"mode":"compare"|"discover","keywords":["..."],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}`;
|
||||
|
||||
export async function parseSearchTrendIntent(
|
||||
ai: IAIService,
|
||||
userText: string,
|
||||
todayYmd: string,
|
||||
options: { model?: string; timeoutMs?: number } = {},
|
||||
): Promise<SearchTrendIntent> {
|
||||
const trimmed = userText.trim();
|
||||
let raw = '';
|
||||
try {
|
||||
const result = await ai.chat({
|
||||
system: SEARCH_TREND_SYSTEM_PROMPT,
|
||||
user: _buildUserMessage(trimmed, todayYmd),
|
||||
model: options.model,
|
||||
timeoutMs: options.timeoutMs,
|
||||
});
|
||||
raw = result.content || '';
|
||||
} catch (e: any) {
|
||||
logError('naverTrend intentParser(searchTrend): AI 호출 실패 — compare 모드로 원문 사용.', { error: e?.message ?? String(e) });
|
||||
return { mode: 'compare', keywords: [trimmed], parsed: false };
|
||||
}
|
||||
|
||||
const obj = _parseJsonLoose(raw);
|
||||
if (!obj) {
|
||||
logInfo('naverTrend intentParser(searchTrend): 파싱 실패 — compare 모드로 원문 사용.', { rawHead: raw.slice(0, 100) });
|
||||
return { mode: 'compare', keywords: [trimmed], parsed: false };
|
||||
}
|
||||
const keywords = _asStringArray(obj.keywords);
|
||||
return {
|
||||
mode: _asMode(obj.mode),
|
||||
keywords: keywords.length > 0 ? keywords : [trimmed],
|
||||
startDate: _asDate(obj.startDate),
|
||||
endDate: _asDate(obj.endDate),
|
||||
timeUnit: _asTimeUnit(obj.timeUnit),
|
||||
device: _asDevice(obj.device),
|
||||
gender: _asGender(obj.gender),
|
||||
ages: _asStringArray(obj.ages),
|
||||
parsed: true,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* 네이버 API HUB — 쇼핑인사이트(분야별 트렌드) / 검색어트렌드 REST 클라이언트.
|
||||
*
|
||||
* naverFundamentals.ts 의 fetch + AbortSignal.timeout + try/catch 관례를 따르되,
|
||||
* 이쪽은 공식 인증 API(X-NCP-APIGW-API-KEY-ID/-KEY 헤더)라 결과를 discriminated
|
||||
* union 으로 돌려준다 — 호출자가 try/catch 없이 `if (!res.ok) ...`로 처리.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import { logError } from '../../utils';
|
||||
import { readNaverTrendConfig } from './naverConfig';
|
||||
|
||||
const BASE_URL = 'https://naverapihub.apigw.ntruss.com';
|
||||
|
||||
export type NaverResult<T> = { ok: true; data: T } | { ok: false; error: string };
|
||||
|
||||
export type TimeUnit = 'date' | 'week' | 'month';
|
||||
export type Device = 'pc' | 'mo';
|
||||
export type Gender = 'm' | 'f';
|
||||
|
||||
export interface CommonFilters {
|
||||
device?: Device;
|
||||
gender?: Gender;
|
||||
ages?: string[];
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendRequest extends CommonFilters {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
category: Array<{ name: string; param: string[] }>;
|
||||
}
|
||||
|
||||
export interface TrendDataPoint {
|
||||
period: string;
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendResult {
|
||||
title: string;
|
||||
category: string[];
|
||||
data: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface ShoppingCategoryTrendResponse {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
results: ShoppingCategoryTrendResult[];
|
||||
}
|
||||
|
||||
export interface SearchTrendRequest extends CommonFilters {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
keywordGroups: Array<{ groupName: string; keywords: string[] }>;
|
||||
}
|
||||
|
||||
export interface SearchTrendResult {
|
||||
title: string;
|
||||
keywords: string[];
|
||||
data: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface SearchTrendResponse {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
results: SearchTrendResult[];
|
||||
}
|
||||
|
||||
async function postJson<TReq, TRes>(
|
||||
context: vscode.ExtensionContext,
|
||||
path: string,
|
||||
body: TReq,
|
||||
timeoutMs: number,
|
||||
): Promise<NaverResult<TRes>> {
|
||||
const cfg = await readNaverTrendConfig(context);
|
||||
if (!cfg.clientId || !cfg.clientSecret) {
|
||||
return { ok: false, error: 'Naver API 키가 설정되지 않았습니다 — Astra 설정 → Naver Trend 탭에서 Client ID/Secret을 입력하세요.' };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-NCP-APIGW-API-KEY-ID': cfg.clientId,
|
||||
'X-NCP-APIGW-API-KEY': cfg.clientSecret,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return { ok: false, error: `Naver API ${res.status}: ${text.slice(0, 300) || res.statusText}` };
|
||||
}
|
||||
const data = await res.json() as TRes;
|
||||
return { ok: true, data };
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? String(e);
|
||||
logError('naverApi: fetch 실패.', { path, error: msg });
|
||||
return { ok: false, error: `Naver API 호출 실패: ${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchShoppingCategoryTrend(
|
||||
context: vscode.ExtensionContext,
|
||||
body: ShoppingCategoryTrendRequest,
|
||||
timeoutMs = 10000,
|
||||
): Promise<NaverResult<ShoppingCategoryTrendResponse>> {
|
||||
return postJson(context, '/shopping/v1/categories', body, timeoutMs);
|
||||
}
|
||||
|
||||
export async function fetchSearchTrend(
|
||||
context: vscode.ExtensionContext,
|
||||
body: SearchTrendRequest,
|
||||
timeoutMs = 10000,
|
||||
): Promise<NaverResult<SearchTrendResponse>> {
|
||||
return postJson(context, '/search-trend/v1/search', body, timeoutMs);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 네이버 API HUB (쇼핑인사이트/검색어트렌드) Client ID/Secret 저장소.
|
||||
*
|
||||
* providerConfig.ts 와 동일 설계: 값 자체는 vscode.SecretStorage 에만 —
|
||||
* settings.json / Settings Sync 침범 안 받음. 패널은 원문을 절대 echo 안 하고
|
||||
* hasCredentials boolean 만 노출한다.
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
|
||||
const CLIENT_ID_KEY = 'g1nation.naverTrend.clientId';
|
||||
const CLIENT_SECRET_KEY = 'g1nation.naverTrend.clientSecret';
|
||||
|
||||
export interface NaverTrendConfig {
|
||||
clientId: string;
|
||||
clientSecret: string;
|
||||
}
|
||||
|
||||
export async function readNaverTrendConfig(context: vscode.ExtensionContext): Promise<NaverTrendConfig> {
|
||||
const [clientId, clientSecret] = await Promise.all([
|
||||
context.secrets.get(CLIENT_ID_KEY),
|
||||
context.secrets.get(CLIENT_SECRET_KEY),
|
||||
]);
|
||||
return { clientId: clientId ?? '', clientSecret: clientSecret ?? '' };
|
||||
}
|
||||
|
||||
/** patch 의 필드만 갱신. truthy 값이면 저장, 빈 문자열이면 삭제. */
|
||||
export async function writeNaverTrendConfig(
|
||||
context: vscode.ExtensionContext,
|
||||
patch: Partial<NaverTrendConfig>,
|
||||
): Promise<void> {
|
||||
if (typeof patch.clientId === 'string') {
|
||||
const v = patch.clientId.trim();
|
||||
if (v) await context.secrets.store(CLIENT_ID_KEY, v);
|
||||
else await context.secrets.delete(CLIENT_ID_KEY);
|
||||
}
|
||||
if (typeof patch.clientSecret === 'string') {
|
||||
const v = patch.clientSecret.trim();
|
||||
if (v) await context.secrets.store(CLIENT_SECRET_KEY, v);
|
||||
else await context.secrets.delete(CLIENT_SECRET_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
/** SettingsPanel 표시용 — secret 값 자체는 노출 X. 설정 여부만 boolean. */
|
||||
export async function readNaverTrendStatus(context: vscode.ExtensionContext): Promise<{ hasCredentials: boolean }> {
|
||||
const cfg = await readNaverTrendConfig(context);
|
||||
return { hasCredentials: !!(cfg.clientId && cfg.clientSecret) };
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* 배치 호출 + 병합 정렬 — 네이버 API 의 단일 요청 상한(카테고리 3개/키워드그룹 5개)을
|
||||
* 캡슐화해서 handlers.ts 가 "후보 N개를 비율순으로 랭킹"만 요청하면 되게 한다.
|
||||
*
|
||||
* 키워드 랭킹은 groupName 하나 = keyword 하나로 보낸다 — 같은 groupName 아래
|
||||
* 여러 keyword 를 넣으면 API 가 하나의 시리즈로 합산해버려 키워드별 개별 순위를
|
||||
* 못 매기기 때문(검색어트렌드 문서 참조).
|
||||
*/
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
fetchShoppingCategoryTrend, fetchSearchTrend,
|
||||
type CommonFilters, type TimeUnit, type TrendDataPoint,
|
||||
} from './naverApi';
|
||||
|
||||
export interface DateRange {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
timeUnit: TimeUnit;
|
||||
}
|
||||
|
||||
export interface RankedItem {
|
||||
name: string;
|
||||
path?: string;
|
||||
latestRatio: number;
|
||||
series: TrendDataPoint[];
|
||||
}
|
||||
|
||||
export interface RankedResult extends DateRange {
|
||||
items: RankedItem[];
|
||||
/** 배치 중 일부가 실패해도 나머지는 반환 — 실패한 배치의 에러 메시지 모음. */
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const out: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size));
|
||||
return out;
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(r => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function latestRatioOf(series: TrendDataPoint[]): number {
|
||||
return series.length ? series[series.length - 1].ratio : 0;
|
||||
}
|
||||
|
||||
export async function rankCategories(
|
||||
context: vscode.ExtensionContext,
|
||||
candidates: Array<{ name: string; path?: string; code: string }>,
|
||||
range: DateRange,
|
||||
filters: CommonFilters,
|
||||
): Promise<RankedResult> {
|
||||
const items: RankedItem[] = [];
|
||||
const errors: string[] = [];
|
||||
const batches = chunkArray(candidates, 3);
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const res = await fetchShoppingCategoryTrend(context, {
|
||||
...range,
|
||||
...filters,
|
||||
category: batch.map(c => ({ name: c.name, param: [c.code] })),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errors.push(`[${batch.map(c => c.name).join(', ')}] ${res.error}`);
|
||||
} else {
|
||||
for (const r of res.data.results) {
|
||||
const match = batch.find(c => c.name === r.title);
|
||||
items.push({ name: r.title, path: match?.path, latestRatio: latestRatioOf(r.data), series: r.data });
|
||||
}
|
||||
}
|
||||
if (i < batches.length - 1) await delay(200);
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.latestRatio - a.latestRatio);
|
||||
return { ...range, items, errors };
|
||||
}
|
||||
|
||||
export async function rankKeywords(
|
||||
context: vscode.ExtensionContext,
|
||||
candidates: string[],
|
||||
range: DateRange,
|
||||
filters: CommonFilters,
|
||||
): Promise<RankedResult> {
|
||||
const items: RankedItem[] = [];
|
||||
const errors: string[] = [];
|
||||
const uniqueCandidates = [...new Set(candidates.map(k => k.trim()).filter(Boolean))];
|
||||
const batches = chunkArray(uniqueCandidates, 5);
|
||||
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
const res = await fetchSearchTrend(context, {
|
||||
...range,
|
||||
...filters,
|
||||
keywordGroups: batch.map(k => ({ groupName: k, keywords: [k] })),
|
||||
});
|
||||
if (!res.ok) {
|
||||
errors.push(`[${batch.join(', ')}] ${res.error}`);
|
||||
} else {
|
||||
for (const r of res.data.results) {
|
||||
items.push({ name: r.title, latestRatio: latestRatioOf(r.data), series: r.data });
|
||||
}
|
||||
}
|
||||
if (i < batches.length - 1) await delay(200);
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.latestRatio - a.latestRatio);
|
||||
return { ...range, items, errors };
|
||||
}
|
||||
@@ -133,6 +133,10 @@ interface SettingsState {
|
||||
anthropic: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
|
||||
gemini: { enabled: boolean; hasApiKey: boolean; defaultModel: string };
|
||||
};
|
||||
/** 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 secrets, *설정 여부* 만 전송. */
|
||||
naver: {
|
||||
hasCredentials: boolean;
|
||||
};
|
||||
email: {
|
||||
outlookEnabled: boolean;
|
||||
gmailEnabled: boolean;
|
||||
@@ -303,6 +307,9 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
case 'providers.update':
|
||||
await this._handleProvidersUpdate(msg);
|
||||
return;
|
||||
case 'naver.update':
|
||||
await this._handleNaverUpdate(msg);
|
||||
return;
|
||||
case 'devilAgent.toggle':
|
||||
await this._safeConfigUpdate('devilAgent.enabled', !!msg.enabled);
|
||||
return;
|
||||
@@ -607,6 +614,26 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
await this._refreshState();
|
||||
}
|
||||
|
||||
// 네이버 API HUB (쇼핑인사이트/검색어트렌드) — Client ID/Secret 둘 다 Secret Storage 만.
|
||||
// google.clientSecret 처리와 동일하게 원문은 절대 echo 안 함 (hasCredentials boolean 만).
|
||||
|
||||
private async _buildNaverState(): Promise<SettingsState['naver']> {
|
||||
const { readNaverTrendStatus } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
|
||||
return await readNaverTrendStatus(this._deps.context);
|
||||
}
|
||||
|
||||
private async _handleNaverUpdate(msg: any): Promise<void> {
|
||||
const { writeNaverTrendConfig } = require('../naverTrend/naverConfig') as typeof import('../naverTrend/naverConfig');
|
||||
const patch: any = {};
|
||||
if (typeof msg.clientId === 'string') patch.clientId = msg.clientId;
|
||||
if (typeof msg.clientSecret === 'string') patch.clientSecret = msg.clientSecret;
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
await writeNaverTrendConfig(this._deps.context, patch);
|
||||
this._lastSuccess = 'Naver Trend 저장 완료';
|
||||
this._lastError = undefined;
|
||||
await this._refreshState();
|
||||
}
|
||||
|
||||
private async _handleAdvancedUpdate(msg: any): Promise<void> {
|
||||
if (typeof msg.dryRun === 'boolean') {
|
||||
await this._safeConfigUpdate('dryRun', msg.dryRun);
|
||||
@@ -784,6 +811,7 @@ export class SettingsPanelProvider implements vscode.WebviewViewProvider {
|
||||
},
|
||||
google: this._buildGoogleState(),
|
||||
providers: await this._buildProvidersState(),
|
||||
naver: await this._buildNaverState(),
|
||||
email: {
|
||||
outlookEnabled: cfg.get<boolean>('email.outlookEnabled', true) !== false,
|
||||
gmailEnabled: cfg.get<boolean>('email.gmailEnabled', true) !== false,
|
||||
|
||||
@@ -246,6 +246,32 @@ export function detectUngroundedAnalysis(
|
||||
return { ungrounded: refs.size < 2, readCount, fileRefs: refs.size };
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.313] 세 번째 사각지대 — 로컬 프로젝트 분석 요청인데 이 턴에 액션을 *한 번도*
|
||||
* 쓰지 않고(read/list/investigate 전부 0) 장문 분석을 내놓은 경우. 실사례: 사전
|
||||
* 주입된 아키텍처 요약만으로 답해 "무엇을 할 수 있는지"는 있는데 실제 구조 상세가
|
||||
* 없는 보고. hollow(목록만 봄)·ungrounded(읽고도 인용 없음) 둘 다 액션 1회 이상을
|
||||
* 전제해서 이 케이스를 놓쳤다.
|
||||
*/
|
||||
export function detectUnreadAnalysis(
|
||||
answer: string,
|
||||
stats: TurnActionStats | null | undefined,
|
||||
hasLocalProjectContext: boolean,
|
||||
): boolean {
|
||||
if (!hasLocalProjectContext) return false;
|
||||
const total = (stats?.reads ?? 0) + (stats?.lists ?? 0) + (stats?.investigates ?? 0);
|
||||
if (total > 0) return false;
|
||||
return (answer || '').trim().length >= 300;
|
||||
}
|
||||
|
||||
export function formatUnreadAnalysisFooter(): string {
|
||||
return [
|
||||
'',
|
||||
'---',
|
||||
'⚠️ **파일 미확인 분석** — 이 답변은 프로젝트 파일을 실제로 열지 않고 사전 요약·일반 지식으로 작성되었습니다. 구조 상세(모듈·파일 단위)가 필요하면 "실제 파일을 읽고 구조 중심으로 다시 분석해줘"라고 요청해 주세요.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function formatUngroundedAnalysisFooter(readCount: number): string {
|
||||
return [
|
||||
'',
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* [v2.2.318] 하위 질문 체크리스트 — "질문 3개에 하나도 직접 답하지 않는" 실사례 대응.
|
||||
*
|
||||
* 사용자가 한 메시지에 물음을 여러 개 명시하면("어떤 주제인지, 퀄리티는 어떤지,
|
||||
* 도움이 될지") 각 물음이 답변에서 다뤄지는지를 결정론으로 관리한다:
|
||||
* 1) 프롬프트에서 물음 절(clause)을 규칙 기반 추출 (LLM 없음)
|
||||
* 2) 답변 전: "각 물음에 별도 소제목 + 구체 근거로 답하라" 체크리스트 주입
|
||||
* 3) 답변 후: 각 물음의 특징 토큰이 답변에 없으면 누락 footer
|
||||
*
|
||||
* requirementGraph(업무 유형별 고정 요소)와 달리 이 모듈은 *그 턴의 문장*에서
|
||||
* 요소를 동적으로 뽑는다 — 유형 레지스트리에 없는 임의 질문 조합도 커버.
|
||||
*/
|
||||
|
||||
const QUESTION_TAIL_RE = /(인지|한지|을지|를지|는지|던지|었을지|였을지|일까|을까|ㄹ까|까\?|어떠한|어떤|무엇|뭐가|누가|누구|어디|어떻게|왜|얼마나)/;
|
||||
|
||||
/** 물음 절에서 커버리지 판정에 쓸 특징 토큰 — 조사·의문사 등 잡토큰 제외. */
|
||||
const STOP_TOKENS = new Set([
|
||||
'어떠한', '어떤', '무엇', '뭐', '누가', '누구', '어디', '어떻게', '왜', '얼마나',
|
||||
'있는지', '인지', '한지', '을지', '는지', '될지', '했는지', '되었을지',
|
||||
'그리고', '그러고', '대한', '대해', '위한', '통해', '사람', '사이트', '해줘', '해주세요',
|
||||
'분석해줘', '알려줘', '정리해줘', '평가해줘',
|
||||
]);
|
||||
|
||||
export function extractSubQuestions(prompt: string | null | undefined): string[] {
|
||||
const p = (prompt || '').replace(/https?:\/\/[^\s]+/gi, ' ').trim();
|
||||
if (!p) return [];
|
||||
const clauses = p
|
||||
.split(/[,.?!\n;·]+/)
|
||||
.map((c) => c.trim())
|
||||
.filter((c) => c.length >= 6 && c.length <= 120 && QUESTION_TAIL_RE.test(c));
|
||||
// 물음이 2개 이상일 때만 의미 있음 — 단일 질문은 본문 전체가 답이다.
|
||||
return clauses.length >= 2 ? clauses.slice(0, 5) : [];
|
||||
}
|
||||
|
||||
/** 물음 절의 특징 토큰 (긴 것 우선 최대 3개). */
|
||||
export function clauseKeyTokens(clause: string): string[] {
|
||||
return (clause || '')
|
||||
.split(/[^A-Za-z0-9가-힣]+/)
|
||||
.map((t) => t
|
||||
.replace(/(은|는|이|가|을|를|의|로|으로|에|에서|와|과|도|만)$/, '')
|
||||
// 동사형 접미 제거 — "검색해서"→"검색" 처럼 답변의 명사형과 매치되게.
|
||||
.replace(/(해서|해줘|하기|하는|했는|하며|하고|되었|되어|됐던)$/, ''))
|
||||
.filter((t) => t.length >= 2 && !STOP_TOKENS.has(t))
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
/** 주입 블록 — 물음이 2개 미만이면 빈 문자열. */
|
||||
export function buildSubQuestionBlock(prompt: string | null | undefined): string {
|
||||
const qs = extractSubQuestions(prompt);
|
||||
if (qs.length === 0) return '';
|
||||
return [
|
||||
'[하위 질문 체크리스트 — 사용자가 명시한 물음 전부에 답해야 한다]',
|
||||
...qs.map((q, i) => `${i + 1}. ${q}`),
|
||||
'각 물음에 *별도 소제목 또는 단락*으로 직접 답하라. 판단("높다/우수하다")만 쓰지 말고, 각 판단에 확인된 구체 근거(글 제목·주제·예시·수치)를 최소 1개 붙여라. 근거가 없으면 "확인 불가"라고 명시하라. 물음을 건너뛰면 실패다.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export interface SubQuestionCoverage {
|
||||
total: number;
|
||||
missing: string[];
|
||||
}
|
||||
|
||||
/** 결정론 커버리지 — 각 물음의 특징 토큰이 답변에 하나도 없으면 누락. */
|
||||
export function checkSubQuestionCoverage(prompt: string | null | undefined, answer: string): SubQuestionCoverage {
|
||||
const qs = extractSubQuestions(prompt);
|
||||
if (qs.length === 0 || !(answer || '').trim()) return { total: 0, missing: [] };
|
||||
const a = answer.toLowerCase();
|
||||
const missing = qs.filter((q) => {
|
||||
const keys = clauseKeyTokens(q);
|
||||
return keys.length > 0 && !keys.some((k) => a.includes(k.toLowerCase()));
|
||||
});
|
||||
return { total: qs.length, missing };
|
||||
}
|
||||
|
||||
export function formatSubQuestionFooter(cov: SubQuestionCoverage): string {
|
||||
if (cov.missing.length === 0) return '';
|
||||
return [
|
||||
'',
|
||||
'---',
|
||||
`⚠️ **미답변 물음 감지** — 요청하신 물음 ${cov.total}개 중 ${cov.missing.length}개가 답변에서 다뤄지지 않은 것으로 보입니다:`,
|
||||
...cov.missing.map((m) => `- ${m}`),
|
||||
'누락된 물음만 골라 "위 물음에 각각 답해줘"라고 재요청하실 수 있습니다.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* [v2.2.316] 턴 의도 브리핑 — "질문 의도를 판단하지 못한다" 문제의 구조적 해결.
|
||||
*
|
||||
* 진단: 의도 브리핑(왜 묻는지·진짜 궁금증·답의 용도 추출→주입)은 멀티에이전트
|
||||
* 보고서 경로(v2.2.301)에만 있었고, 일반 채팅 턴은 "[답변 전 이해 원칙]" 4줄짜리
|
||||
* 추상 지시뿐이었다. 소형 로컬 모델은 추상 지시("의도를 파악하라")를 실행하지
|
||||
* 못한다 — 파악된 의도를 *텍스트로 받아야* 따른다 (강제 주입 패턴의 6번째 적용:
|
||||
* 일정→캘린더, 자기평가→인벤토리, 정정→캡처, URL→실데이터, 조사→게이트에 이어).
|
||||
*
|
||||
* 확장: 기존 브리핑(목적/진짜 궁금증/용도)에 실무 3축을 추가 —
|
||||
* - audience: 답을 받아 볼 사람 (본인/경영진/팀 공유) → 어조·깊이 결정
|
||||
* - deliverable: 기대 산출물 형태 (보고서/한 줄 답/실행/목록/의견) → 형식 결정
|
||||
* - accessPath: 근거를 어디서 가져와야 하는가 (web/local-files/brain/general)
|
||||
* → "실접속 없이 일반론" 류 근거-경로 착오 방지의 상류 방어.
|
||||
*
|
||||
* 지연: 호출자는 이 브리핑을 retrieval(1~8초)과 *병렬* 실행한다 — 체감 추가 지연
|
||||
* 최소화. 실패/타임아웃은 null — 종전 동작 그대로 (브리핑 없는 턴).
|
||||
*/
|
||||
|
||||
import { logInfo, logWarn } from '../utils';
|
||||
|
||||
export interface TurnIntentBrief {
|
||||
purpose: string;
|
||||
realQuestions: string[];
|
||||
deliverable: string;
|
||||
audience: string;
|
||||
accessPath: 'web' | 'local-files' | 'brain' | 'general' | string;
|
||||
constraints: string;
|
||||
}
|
||||
|
||||
/** LLM 호출자 — (system, user, maxTokens) → 응답 텍스트. 테스트 주입용. */
|
||||
export type IntentLlm = (system: string, user: string, maxTokens: number) => Promise<string>;
|
||||
|
||||
const REQUEST_SHAPE_RE = /(해\s*줘|해\s*주세요|부탁|알려\s*줘|정리|분석|보고|조사|평가|추천|비교|검토|만들|작성|찾아|알아봐|설명|요약|research|report|analy[sz]e|review|explain|summar)/i;
|
||||
|
||||
/**
|
||||
* 브리핑을 만들 가치가 있는 턴인지 — 잡담·초단문·단순 조회는 제외 (지연 예산 보호).
|
||||
*/
|
||||
export function shouldBuildTurnIntentBrief(prompt: string | null | undefined, isCasual: boolean): boolean {
|
||||
if (isCasual) return false;
|
||||
const p = (prompt || '').trim();
|
||||
if (p.length < 15) return false;
|
||||
if (/^\/[a-zA-Z]/.test(p)) return false; // 슬래시 명령은 자체 파이프라인
|
||||
return REQUEST_SHAPE_RE.test(p);
|
||||
}
|
||||
|
||||
/** LLM 응답에서 느슨한 JSON 파싱 — 코드펜스/앞뒤 잡담 허용. 실패 시 null. */
|
||||
export function parseIntentBrief(raw: string): TurnIntentBrief | null {
|
||||
try {
|
||||
const m = (raw || '').match(/\{[\s\S]*\}/);
|
||||
if (!m) return null;
|
||||
const j = JSON.parse(m[0]);
|
||||
if (typeof j.purpose !== 'string' || !j.purpose.trim()) return null;
|
||||
const qs = Array.isArray(j.realQuestions) ? j.realQuestions.map(String).filter(Boolean).slice(0, 3) : [];
|
||||
return {
|
||||
purpose: j.purpose.trim().slice(0, 200),
|
||||
realQuestions: qs,
|
||||
deliverable: String(j.deliverable || '').trim().slice(0, 100),
|
||||
audience: String(j.audience || '요청자 본인').trim().slice(0, 60),
|
||||
accessPath: String(j.accessPath || 'general').trim().slice(0, 20),
|
||||
constraints: String(j.constraints || '').trim().slice(0, 120),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildTurnIntentBrief(params: {
|
||||
prompt: string;
|
||||
recentContext?: string;
|
||||
llm: IntentLlm;
|
||||
timeoutMs?: number;
|
||||
}): Promise<TurnIntentBrief | null> {
|
||||
const system = [
|
||||
'너는 질문 분석가다. 사용자의 요청을 읽고 JSON 으로만 응답한다. 다른 말 금지.',
|
||||
'형식: {"purpose":"왜 묻는가 한 문장","realQuestions":["표면 뒤에 정말 궁금한 것 1~3개"],"deliverable":"기대 산출물 형태(예: 구조 중심 보고서/한 줄 확인/실행/비교표/의견)","audience":"답을 볼 사람(본인/경영진 보고용/팀 공유 등)","accessPath":"web|local-files|brain|general 중 근거를 가져와야 할 곳","constraints":"명시된 제약(분량·형식·기한) 없으면 빈 문자열"}',
|
||||
'accessPath 판단: URL 이 있으면 web, 로컬 경로·프로젝트·코드면 local-files, 회사 지식·기록·과거 대화면 brain, 그 외 일반 지식이면 general.',
|
||||
].join('\n');
|
||||
const user = `${params.recentContext ? `[최근 대화 요지]\n${params.recentContext}\n\n` : ''}[사용자 요청]\n${params.prompt}`;
|
||||
const timeoutMs = params.timeoutMs ?? 10_000;
|
||||
try {
|
||||
const raw = await Promise.race([
|
||||
params.llm(system, user, 300),
|
||||
new Promise<string>((_, rej) => setTimeout(() => rej(new Error(`timeout ${timeoutMs}ms`)), timeoutMs)),
|
||||
]);
|
||||
const brief = parseIntentBrief(raw);
|
||||
if (!brief) logWarn('[TurnIntentBrief] 파싱 실패 — 브리핑 없이 진행.', { raw: (raw || '').slice(0, 100) });
|
||||
return brief;
|
||||
} catch (e: any) {
|
||||
logWarn('[TurnIntentBrief] 호출 실패 — 브리핑 없이 진행.', { error: e?.message ?? String(e) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 시스템 프롬프트 주입 블록. 빈 브리핑이면 빈 문자열. */
|
||||
export function formatTurnIntentBlock(brief: TurnIntentBrief | null): string {
|
||||
if (!brief) return '';
|
||||
const lines = [
|
||||
'[질문자 의도 브리핑 — 이 브리핑에 실제로 답하는 것이 이번 답변의 성공 기준]',
|
||||
`- 목적: ${brief.purpose}`,
|
||||
];
|
||||
if (brief.realQuestions.length) lines.push(`- 진짜 궁금증: ${brief.realQuestions.join(' / ')}`);
|
||||
if (brief.deliverable) lines.push(`- 기대 산출물: ${brief.deliverable} — 이 형태로 답하라.`);
|
||||
if (brief.audience) lines.push(`- 독자: ${brief.audience} — 어조·깊이를 여기에 맞춰라.`);
|
||||
if (brief.constraints) lines.push(`- 제약: ${brief.constraints}`);
|
||||
switch (brief.accessPath) {
|
||||
case 'web':
|
||||
lines.push('- 근거 경로: 웹 — 주입된 [URL CONTENT] 실데이터만 근거로 쓰고, 없으면 접속 실패를 첫 줄에 밝혀라. 일반 지식으로 사이트를 평하는 것은 금지.');
|
||||
break;
|
||||
case 'local-files':
|
||||
lines.push('- 근거 경로: 로컬 파일 — 이 대화에서 실제 읽은 파일만 근거로 쓰고, 부족하면 <read_file>/<investigate_files> 로 먼저 읽어라.');
|
||||
break;
|
||||
case 'brain':
|
||||
lines.push('- 근거 경로: 두뇌 기록 — [CONTEXT] 의 두뇌 근거를 우선 인용하고, 없으면 "기록 없음"을 밝혀라.');
|
||||
break;
|
||||
default:
|
||||
lines.push('- 근거 경로: 일반 지식 — 단, 수치·고유명사는 확신 없으면 단정하지 마라.');
|
||||
}
|
||||
lines.push('- 표면 질문만 문자적으로 답하고 목적을 놓치면 실패다. 답변 첫 문장은 위 목적에 대한 결론이어야 한다.');
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -31,6 +31,14 @@ export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boole
|
||||
return false;
|
||||
}
|
||||
|
||||
// [v2.2.315] URL 이 포함된 요청은 단일 턴 강제 — 멀티에이전트 파이프라인은 웹
|
||||
// 접근 경로(URL 실데이터 주입)가 없어서, "사이트 조사해줘"가 실접속 0회의
|
||||
// 일반론 보고서로 끝난다. 단일 턴은 URL 본문을 컨텍스트로 주입받는다.
|
||||
try {
|
||||
const { extractUrls } = require('../../features/web/webFetch') as typeof import('../../features/web/webFetch');
|
||||
if (extractUrls(prompt, 1).length > 0) return false;
|
||||
} catch { /* 추출 실패 시 기존 라우팅 유지 */ }
|
||||
|
||||
const cfg = getConfig();
|
||||
const mode = cfg.workflowMultiAgentMode || 'auto';
|
||||
|
||||
|
||||
@@ -44,6 +44,73 @@ export function sanitizeAssistantContent(text: string): string {
|
||||
return cleanDegeneratedOutput(stripped);
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.314] LSEP 구분자 분해 — 마지막 END 마커 기준 before(추론 취급 구간)/after(답변 구간).
|
||||
* 마커가 없으면 hasMarker=false. agent.ts 의 "본문이 추론 구간에 갇힘" 구제 판단용.
|
||||
*/
|
||||
export function splitLsepReasoning(text: string): { hasMarker: boolean; before: string; after: string } {
|
||||
const t = text || '';
|
||||
const re = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
|
||||
const matches = [...t.matchAll(re)];
|
||||
if (matches.length === 0) return { hasMarker: false, before: '', after: t };
|
||||
const last = matches[matches.length - 1];
|
||||
const idx = (last.index ?? 0);
|
||||
return {
|
||||
hasMarker: true,
|
||||
before: t.slice(0, idx).replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, ''),
|
||||
after: t.slice(idx + last[0].length),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.314] "마무리 인사만 남은 답변" 감지 — 본문 없이 "이상으로 분석을 마칩니다.
|
||||
* 추가 요청 주세요" 류로만 구성된 짧은 답. 실사례: 모델이 분석 전체를 추론 구간에
|
||||
* 쓰고 가시 답변은 마무리 한 줄만 낸 경우, 화면의 본문이 전부 지워지고 이것만 남았다.
|
||||
*/
|
||||
export function isCloserOnlyAnswer(text: string): boolean {
|
||||
const t = (text || '').trim();
|
||||
if (!t || t.length >= 300) return false;
|
||||
return /(이상으로|이로써|마칩니다|마치겠습니다|마무리하겠|분석을 마|검토를 마|요청해 ?주세요|문의해 ?주세요|질문해 ?주세요|말씀해 ?주세요)/.test(t);
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.314] 내부 스캐폴딩 섹션 유출 제거 — 시스템 규칙 4([NO SECTION LEAKAGE])가
|
||||
* 금지하는 "요청 요약/사용자 의도 추론/프로젝트 기록 대상 확인/핵심 확인 질문/근거
|
||||
* 파일 경로" 섹션을 소형 모델이 그대로 출력하는 실사례 대응. 프롬프트 지시(확률적)
|
||||
* 위에 결정론 필터를 한 겹 더 — Project Record Guard 가 요구한 턴은 호출자가 이
|
||||
* 함수를 건너뛴다.
|
||||
*/
|
||||
const LEAKED_SECTION_NAMES = '(요청 요약|사용자 의도 추론|프로젝트 기록 대상 확인|핵심 확인 질문|근거 파일 경로)';
|
||||
const LEAKED_HEADING_RE = new RegExp(`^\\s*(?:#{1,6}\\s*|\\*\\*)?\\s*${LEAKED_SECTION_NAMES}\\s*(?:\\*\\*)?\\s*[::]?\\s*$`);
|
||||
|
||||
export function stripLeakedInternalSections(text: string): string {
|
||||
const lines = (text || '').split('\n');
|
||||
const out: string[] = [];
|
||||
let skipping = false;
|
||||
for (const line of lines) {
|
||||
if (LEAKED_HEADING_RE.test(line)) { skipping = true; continue; }
|
||||
if (skipping) {
|
||||
// 금지 섹션은 다음 일반 헤딩 또는 구분선에서 끝난다.
|
||||
if (/^\s*#{1,6}\s+\S/.test(line) || /^\s*---\s*$/.test(line)) skipping = false;
|
||||
else continue;
|
||||
if (/^\s*---\s*$/.test(line)) continue; // 구분선 자체도 유출 잔재 — 버린다
|
||||
}
|
||||
if (/^\s*질문 의도\s*[::]/.test(line)) continue;
|
||||
out.push(line);
|
||||
}
|
||||
return out.join('\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/** [v2.2.314] 추론 구간에 갇힌 텍스트가 실제 보고 본문처럼 보이는지 — 구제 승격 판단. */
|
||||
export function looksLikeReportBody(text: string): boolean {
|
||||
const t = (text || '').trim();
|
||||
if (t.length < 600) return false;
|
||||
const headings = (t.match(/(?:^|\n)#{1,6}\s+\S/g) || []).length;
|
||||
const bullets = (t.match(/(?:^|\n)\s*[-*•]\s+\S/g) || []).length;
|
||||
const paragraphs = t.split(/\n{2,}/).filter((p) => p.trim().length > 80).length;
|
||||
return headings >= 2 || bullets >= 4 || paragraphs >= 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Long context 에서 small 모델이 degenerate 됐을 때의 blast radius 봉쇄:
|
||||
* - 문자 벽 (`_______…`, `~~~~~~`) 8+ 반복은 모두 제거. markdown rule 은 3.
|
||||
|
||||
@@ -86,6 +86,132 @@ export async function buildUrlContext(url: string): Promise<string> {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** [v2.2.315] 주입 블록이 실데이터 성공 블록인지 (실패-정직 블록과 구분). */
|
||||
export function isUrlContextSuccessBlock(block: string): boolean {
|
||||
return (block || '').startsWith('[URL CONTENT — 실데이터');
|
||||
}
|
||||
|
||||
export interface TurnWebFetchStats {
|
||||
urls: string[];
|
||||
succeeded: string[];
|
||||
failed: Array<{ url: string; reason: string }>;
|
||||
/** [v2.2.317] 성공 건의 제목·본문 표본 — 답변이 실데이터를 반영했는지 검증용. */
|
||||
succeededDetail?: Array<{ url: string; title: string; sample: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.318] 사이트 심층 수집 — 루트 페이지 분석 요청("사이트 분석/평가")은 홈
|
||||
* 1장만으로는 "글의 퀄리티" 같은 물음에 답할 데이터가 없다 (실사례: 평가 형용사만
|
||||
* 나열). 루트 HTML 에서 같은 도메인의 글 링크를 뽑아 상위 N개 본문을 추가 수집한다.
|
||||
*/
|
||||
export async function collectInternalArticleLinks(rootUrl: string, max = 2): Promise<string[]> {
|
||||
try {
|
||||
const base = new URL(rootUrl);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10_000);
|
||||
let html = '';
|
||||
try {
|
||||
const res = await fetch(rootUrl, {
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36' },
|
||||
});
|
||||
if (!res.ok) return [];
|
||||
html = await res.text();
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const m of html.matchAll(/<a\b[^>]*href="([^"#?]+)"/gi)) {
|
||||
let href = m[1];
|
||||
try {
|
||||
const u = new URL(href, base);
|
||||
if (u.host !== base.host) continue;
|
||||
const path = u.pathname;
|
||||
// 글 본문 링크 휴리스틱: 카테고리/태그/페이지네이션/피드/미디어 제외,
|
||||
// 슬러그가 실제로 있는 경로만.
|
||||
if (/\/(category|tag|page|feed|wp-|author|search|login|cart|privacy|about|contact)\b/i.test(path)) continue;
|
||||
if (/\.(png|jpe?g|gif|svg|css|js|xml|ico|webp)$/i.test(path)) continue;
|
||||
const slug = path.replace(/\/+$/, '');
|
||||
if (slug.length < 8) continue; // '/', '/en' 류 제외
|
||||
const abs = `${u.origin}${slug}`;
|
||||
if (seen.has(abs) || abs === rootUrl.replace(/\/+$/, '')) continue;
|
||||
seen.add(abs);
|
||||
out.push(abs);
|
||||
if (out.length >= max) break;
|
||||
} catch { /* 잘못된 href 무시 */ }
|
||||
}
|
||||
return out;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** [v2.2.317] 성공 블록에서 제목/본문 표본 추출 (검증용 메타). */
|
||||
export function extractSuccessMeta(url: string, block: string): { url: string; title: string; sample: string } {
|
||||
const title = block.match(/^제목: (.*)$/m)?.[1]?.trim() || '';
|
||||
const bodyStart = block.indexOf('\n\n');
|
||||
const sample = bodyStart >= 0 ? block.slice(bodyStart + 2, bodyStart + 2 + 800) : '';
|
||||
return { url, title: title === '(없음)' ? '' : title, sample };
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.317] 실데이터 반영 검증 — fetch 는 성공했는데 답변이 가져온 본문과 무관한
|
||||
* 일반론인 경우(실사례: Blogging Tips 블로그를 "IT 엔지니어 대상 기술 플랫폼"으로
|
||||
* 서술). 제목+본문 표본의 특징 토큰이 답변에 2개 미만으로 나타나면 불일치로 판정.
|
||||
* URL/도메인 토큰은 제외 — 도메인 언급만으로 통과하지 못하게.
|
||||
*/
|
||||
export function detectWebContentMismatch(
|
||||
answer: string,
|
||||
stats: TurnWebFetchStats | null | undefined,
|
||||
): { mismatch: boolean; checkedTokens: number; hits: number } {
|
||||
const details = stats?.succeededDetail || [];
|
||||
if (!stats || stats.succeeded.length === 0 || details.length === 0) return { mismatch: false, checkedTokens: 0, hits: 0 };
|
||||
const a = (answer || '').toLowerCase();
|
||||
if (a.trim().length < 300) return { mismatch: false, checkedTokens: 0, hits: 0 };
|
||||
const domainTokens = new Set(
|
||||
stats.urls.flatMap((u) => u.toLowerCase().replace(/https?:\/\//, '').split(/[^a-z0-9가-힣]+/)).filter(Boolean),
|
||||
);
|
||||
const tokens = new Set<string>();
|
||||
for (const d of details) {
|
||||
for (const t of `${d.title} ${d.sample}`.toLowerCase().split(/[^a-z0-9가-힣]+/)) {
|
||||
if (t.length >= 3 && !domainTokens.has(t) && !/^\d+$/.test(t)) tokens.add(t);
|
||||
if (tokens.size >= 60) break;
|
||||
}
|
||||
}
|
||||
if (tokens.size < 5) return { mismatch: false, checkedTokens: tokens.size, hits: 0 };
|
||||
let hits = 0;
|
||||
for (const t of tokens) { if (a.includes(t)) hits++; }
|
||||
return { mismatch: hits < 2, checkedTokens: tokens.size, hits };
|
||||
}
|
||||
|
||||
export function formatWebContentMismatchFooter(): string {
|
||||
return '\n\n---\n⚠️ **본문 불일치 가능** — 사이트 실접속에는 성공했지만, 가져온 본문의 핵심 표현이 이 답변에 반영되지 않았습니다. 위 내용은 실데이터가 아닌 일반론일 수 있습니다. "가져온 본문 내용만 근거로 다시 분석해줘"라고 요청해 주세요.';
|
||||
}
|
||||
|
||||
/**
|
||||
* [v2.2.315] 웹 접속 결과의 결정론 표시 — 실사례: 사이트 분석 요청에 실접속 없이
|
||||
* 일반론으로 답하고 그 사실이 어디에도 안 보였다. 프롬프트 지시("접속 실패를
|
||||
* 알리라")는 소형 모델이 무시하므로, 코드가 답변 앞뒤에 사실을 박는다.
|
||||
* - 전부 실패: 답변 *맨 위* 경고 (아래 내용은 실측 아님).
|
||||
* - 1건 이상 성공: 답변 끝에 실접속 출처 한 줄 (신뢰 증빙).
|
||||
*/
|
||||
export function buildWebFetchNotice(stats: TurnWebFetchStats | null | undefined): { top: string; bottom: string } {
|
||||
if (!stats || stats.urls.length === 0) return { top: '', bottom: '' };
|
||||
if (stats.succeeded.length === 0) {
|
||||
const reason = stats.failed[0]?.reason ? ` (${stats.failed[0].reason})` : '';
|
||||
return {
|
||||
top: `⚠️ **사이트 실접속 실패** — ${stats.urls.join(', ')} 의 본문을 가져오지 못했습니다${reason}. 아래 내용은 실제 사이트 확인 없이 일반 지식으로 작성된 추정입니다.\n\n`,
|
||||
bottom: '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: '',
|
||||
bottom: `\n\n🌐 실접속 확인: ${stats.succeeded.join(', ')} 본문을 가져와 근거로 사용했습니다.${stats.failed.length ? ` (실패: ${stats.failed.map(f => f.url).join(', ')})` : ''}`,
|
||||
};
|
||||
}
|
||||
|
||||
function _successBlock(url: string, title: string, description: string, body: string, truncated: boolean): string {
|
||||
return [
|
||||
`[URL CONTENT — 실데이터 · ${url}]`,
|
||||
|
||||
@@ -191,3 +191,27 @@ describe('detectUngroundedAnalysis — 일반론 분석 감지', () => {
|
||||
expect(formatUngroundedAnalysisFooter(3)).toContain('3회');
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.313] 액션 0회 사각지대 — 파일을 아예 안 읽고 낸 장문 분석.
|
||||
import { detectUnreadAnalysis, formatUnreadAnalysisFooter } from '../src/intelligence/investigationPipeline';
|
||||
|
||||
describe('detectUnreadAnalysis — 파일 미확인 분석 감지', () => {
|
||||
const long = '이 프로젝트는 다층 메모리 구조를 갖춘 고도화된 시스템입니다. '.repeat(10);
|
||||
|
||||
test('로컬 경로 컨텍스트 + 액션 0회 + 장문 = 감지', () => {
|
||||
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, true)).toBe(true);
|
||||
});
|
||||
test('액션을 1회라도 썼으면 대상 아님 (hollow/ungrounded 영역)', () => {
|
||||
expect(detectUnreadAnalysis(long, { reads: 1, lists: 0, investigates: 0 }, true)).toBe(false);
|
||||
expect(detectUnreadAnalysis(long, { reads: 0, lists: 1, investigates: 0 }, true)).toBe(false);
|
||||
});
|
||||
test('로컬 경로 컨텍스트가 없으면 대상 아님', () => {
|
||||
expect(detectUnreadAnalysis(long, { reads: 0, lists: 0, investigates: 0 }, false)).toBe(false);
|
||||
});
|
||||
test('짧은 답변은 오탐 방지로 제외', () => {
|
||||
expect(detectUnreadAnalysis('짧은 확인 답변.', { reads: 0, lists: 0, investigates: 0 }, true)).toBe(false);
|
||||
});
|
||||
test('footer 에 재요청 안내 포함', () => {
|
||||
expect(formatUnreadAnalysisFooter()).toContain('파일 미확인 분석');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* [v2.2.314] 두 실사례 가드:
|
||||
* 1) LSEP 마커 뒤 "마무리 인사"만 남고 본문이 추론 구간에 갇혀 증발한 답변.
|
||||
* 2) "요청 요약/사용자 의도 추론/핵심 확인 질문" 내부 스캐폴딩 섹션 유출.
|
||||
*/
|
||||
import {
|
||||
splitLsepReasoning,
|
||||
isCloserOnlyAnswer,
|
||||
looksLikeReportBody,
|
||||
stripLeakedInternalSections,
|
||||
} from '../src/lib/contextBuilders/outputSanitization';
|
||||
|
||||
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_a1b2c3d4e5f6__';
|
||||
|
||||
describe('splitLsepReasoning / isCloserOnlyAnswer / looksLikeReportBody', () => {
|
||||
test('마지막 END 마커 기준으로 before/after 분해', () => {
|
||||
const body = '## 1. 구조\n분석 본문. '.repeat(50);
|
||||
const r = splitLsepReasoning(`${body}${MARKER}이상으로 분석을 마칩니다.`);
|
||||
expect(r.hasMarker).toBe(true);
|
||||
expect(r.before).toContain('## 1. 구조');
|
||||
expect(r.after).toBe('이상으로 분석을 마칩니다.');
|
||||
});
|
||||
test('마커 없으면 hasMarker=false, after=원문', () => {
|
||||
const r = splitLsepReasoning('일반 답변');
|
||||
expect(r.hasMarker).toBe(false);
|
||||
expect(r.after).toBe('일반 답변');
|
||||
});
|
||||
test('마무리 인사만 = closer-only, 실질 본문은 아님', () => {
|
||||
expect(isCloserOnlyAnswer('이상으로 ConnectAI 프로젝트에 대한 분석을 마칩니다. 추가 검토가 필요하시면 언제든 요청해 주세요.')).toBe(true);
|
||||
expect(isCloserOnlyAnswer('## 구조 분석\n실제 본문이 여기 길게 이어진다.')).toBe(false);
|
||||
});
|
||||
test('헤딩 다수 장문 = 보고 본문, 짧은 텍스트 = 아님', () => {
|
||||
const report = ['## 개요', '본문 문단. '.repeat(60), '## 구조', '본문 문단. '.repeat(60)].join('\n\n');
|
||||
expect(looksLikeReportBody(report)).toBe(true);
|
||||
expect(looksLikeReportBody('짧은 생각')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stripLeakedInternalSections — 내부 섹션 유출 제거', () => {
|
||||
test('금지 4종 섹션과 "질문 의도:" 줄을 제거하고 본문·출처는 보존', () => {
|
||||
const answer = [
|
||||
'## 프로젝트 개요', '실제 분석 본문.',
|
||||
'## 요청 요약', 'ConnectAI 프로젝트 분석 요청입니다.',
|
||||
'## 사용자 의도 추론', '전체 그림을 이해하고자 하는 의도로 판단됩니다.',
|
||||
'## 프로젝트 기록 대상 확인', '기록으로 관리될 예정입니다.',
|
||||
'## 핵심 확인 질문', '심층 분석이 필요하신가요?',
|
||||
'질문 의도: 범위 확장 여부 결정.',
|
||||
'## 분석 결과 요약', '요약 본문.',
|
||||
].join('\n');
|
||||
const out = stripLeakedInternalSections(answer);
|
||||
expect(out).toContain('## 프로젝트 개요');
|
||||
expect(out).toContain('## 분석 결과 요약');
|
||||
expect(out).not.toContain('요청 요약');
|
||||
expect(out).not.toContain('의도 추론');
|
||||
expect(out).not.toContain('기록 대상 확인');
|
||||
expect(out).not.toContain('핵심 확인 질문');
|
||||
expect(out).not.toContain('질문 의도');
|
||||
});
|
||||
test('금지 섹션이 없으면 원문 유지', () => {
|
||||
const clean = '## 개요\n본문\n\n## 다음 단계\n제안';
|
||||
expect(stripLeakedInternalSections(clean)).toBe(clean);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* /naver-shopping, /naver-trend 순수 로직 테스트.
|
||||
* 실제 네이버 API 호출은 통합 영역이라 제외 — 카테고리 매칭 + intent 파서(tolerant
|
||||
* JSON 파싱, AI 호출 mock)만 고정.
|
||||
*/
|
||||
import { findCategoryCode, ROOT_CATEGORY_CODES } from '../src/features/naverTrend/categoryCodes';
|
||||
import { parseShoppingIntent, parseSearchTrendIntent } from '../src/features/naverTrend/intentParser';
|
||||
import type { IAIService, AIChatResult } from '../src/core/services';
|
||||
|
||||
function mockAi(content: string, shouldThrow = false): IAIService {
|
||||
return {
|
||||
call: async () => content,
|
||||
chat: async (): Promise<AIChatResult> => {
|
||||
if (shouldThrow) throw new Error('AI 호출 실패(mock)');
|
||||
return { content, engine: 'lmstudio', model: 'mock', empty: false };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('findCategoryCode — 카테고리 이름/코드 매칭', () => {
|
||||
test('검증된 대분류 루트 코드 정확 매치', () => {
|
||||
const m = findCategoryCode('패션의류');
|
||||
expect(m[0]).toMatchObject({ code: ROOT_CATEGORY_CODES['패션의류'], source: 'root' });
|
||||
});
|
||||
|
||||
test('부분 일치도 루트 코드로 매치', () => {
|
||||
const m = findCategoryCode('화장품');
|
||||
expect(m[0]).toMatchObject({ code: '50000002', source: 'root' });
|
||||
});
|
||||
|
||||
test('숫자만 입력하면 raw cat_id 로 그대로 통과', () => {
|
||||
const m = findCategoryCode('50012345');
|
||||
expect(m).toEqual([{ name: '50012345', path: '50012345', code: '50012345', source: 'raw' }]);
|
||||
});
|
||||
|
||||
test('트리에 있는 세부 카테고리명은 tree 매치로 코드 반환', () => {
|
||||
const m = findCategoryCode('원피스');
|
||||
expect(m.length).toBeGreaterThan(0);
|
||||
expect(m[0].source).toBe('tree');
|
||||
expect(m[0].code).toMatch(/^\d+$/);
|
||||
});
|
||||
|
||||
test('빈 문자열은 빈 배열', () => {
|
||||
expect(findCategoryCode('')).toEqual([]);
|
||||
});
|
||||
|
||||
test('매칭 안 되는 이름은 빈 배열', () => {
|
||||
expect(findCategoryCode('존재하지않는분야이름절대매칭안됨xyz')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseShoppingIntent — tolerant JSON 파싱', () => {
|
||||
test('정상 JSON', async () => {
|
||||
const ai = mockAi('{"mode":"compare","categories":["패션의류","화장품/미용"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseShoppingIntent(ai, '패션의류랑 화장품 비교해줘', '2026-08-04');
|
||||
expect(r.parsed).toBe(true);
|
||||
expect(r.mode).toBe('compare');
|
||||
expect(r.categories).toEqual(['패션의류', '화장품/미용']);
|
||||
});
|
||||
|
||||
test('코드펜스로 감싼 JSON', async () => {
|
||||
const ai = mockAi('설명입니다\n```json\n{"mode":"discover","categories":["뷰티","디지털/가전"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}\n```');
|
||||
const r = await parseShoppingIntent(ai, '요즘 뜨는 카테고리', '2026-08-04');
|
||||
expect(r.parsed).toBe(true);
|
||||
expect(r.mode).toBe('discover');
|
||||
expect(r.categories).toEqual(['뷰티', '디지털/가전']);
|
||||
});
|
||||
|
||||
test('완전히 깨진 응답 → compare 모드로 원문 폴백', async () => {
|
||||
const ai = mockAi('이건 JSON이 아닙니다 그냥 텍스트');
|
||||
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
|
||||
expect(r.parsed).toBe(false);
|
||||
expect(r.mode).toBe('compare');
|
||||
expect(r.categories).toEqual(['패션의류 트렌드']);
|
||||
});
|
||||
|
||||
test('AI 호출 자체가 실패해도 안전한 기본값으로 폴백(예외 전파 안 함)', async () => {
|
||||
const ai = mockAi('', true);
|
||||
const r = await parseShoppingIntent(ai, '패션의류 트렌드', '2026-08-04');
|
||||
expect(r.parsed).toBe(false);
|
||||
expect(r.mode).toBe('compare');
|
||||
expect(r.categories).toEqual(['패션의류 트렌드']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSearchTrendIntent — tolerant JSON 파싱', () => {
|
||||
test('정상 JSON', async () => {
|
||||
const ai = mockAi('{"mode":"compare","keywords":["원피스","블라우스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]}');
|
||||
const r = await parseSearchTrendIntent(ai, '원피스랑 블라우스 비교', '2026-08-04');
|
||||
expect(r.parsed).toBe(true);
|
||||
expect(r.keywords).toEqual(['원피스', '블라우스']);
|
||||
});
|
||||
|
||||
test('balanced-brace 추출 — 앞뒤에 잡텍스트가 섞여도 파싱', async () => {
|
||||
const ai = mockAi('물론이죠! {"mode":"discover","keywords":["여름 원피스","린넨 원피스"],"startDate":"","endDate":"","timeUnit":"","device":"","gender":"","ages":[]} 이상입니다.');
|
||||
const r = await parseSearchTrendIntent(ai, '20대 여성 원피스 인기 키워드', '2026-08-04');
|
||||
expect(r.parsed).toBe(true);
|
||||
expect(r.mode).toBe('discover');
|
||||
expect(r.keywords).toEqual(['여름 원피스', '린넨 원피스']);
|
||||
});
|
||||
|
||||
test('빈 응답 → compare 모드로 원문 폴백', async () => {
|
||||
const ai = mockAi('');
|
||||
const r = await parseSearchTrendIntent(ai, '원피스 키워드', '2026-08-04');
|
||||
expect(r.parsed).toBe(false);
|
||||
expect(r.mode).toBe('compare');
|
||||
expect(r.keywords).toEqual(['원피스 키워드']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* [v2.2.313] 파트별 완성 — 길이 한계에 닿은 보고서를 남은 섹션 단위로 나눠 생성해
|
||||
* 한 문서로 통합. "더 좁은 주제로 나눠 질문하세요" 떠넘기기의 대체.
|
||||
*/
|
||||
import {
|
||||
completeSectioned,
|
||||
parseOutlineTitles,
|
||||
dropAlreadyWritten,
|
||||
} from '../src/agent/handlePrompt/sectionedCompletion';
|
||||
import type { ChatMessage } from '../src/agent';
|
||||
|
||||
const BASE: ChatMessage[] = [
|
||||
{ role: 'system', content: 'SYSTEM CONTEXT (파일 근거: agent.ts, memoryContext.ts)' },
|
||||
{ role: 'user', content: '프로젝트 분석해줘' },
|
||||
];
|
||||
|
||||
describe('parseOutlineTitles / dropAlreadyWritten', () => {
|
||||
test('불릿·번호·헤딩 마커를 벗기고 제목만 추출', () => {
|
||||
const raw = '- 구조·현황\n2) 핵심 발견\n## 다음 단계\n\n없음이 아님';
|
||||
expect(parseOutlineTitles(raw, 4)).toEqual(['구조·현황', '핵심 발견', '다음 단계', '없음이 아님']);
|
||||
});
|
||||
test('"없음" 단독 응답은 빈 목록', () => {
|
||||
expect(parseOutlineTitles('없음', 4)).toEqual([]);
|
||||
});
|
||||
test('초안에 이미 있는 섹션은 제외 (공백 무시 매치)', () => {
|
||||
const titles = ['핵심 발견', '다음 단계'];
|
||||
expect(dropAlreadyWritten(titles, '## 핵심 발견\n이미 썼다')).toEqual(['다음 단계']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('completeSectioned', () => {
|
||||
test('outline → 섹션별 생성 → 한 문서로 통합', async () => {
|
||||
const calls: string[] = [];
|
||||
const r = await completeSectioned({
|
||||
draft: '## 개요\n잘린 초안입니다.',
|
||||
userPrompt: '프로젝트 분석해줘',
|
||||
baseMessages: BASE,
|
||||
requiredElements: ['구조·현황', '다음 단계'],
|
||||
callLLM: async (messages) => {
|
||||
const last = String(messages[messages.length - 1].content);
|
||||
calls.push(last.slice(0, 30));
|
||||
if (last.includes('남은 섹션 제목')) return '구조·현황\n다음 단계';
|
||||
if (last.includes('"구조·현황"')) return '## 구조·현황\nsrc/agent.ts 중심 구조.';
|
||||
if (last.includes('"다음 단계"')) return '테스트 보강.'; // 헤딩 없이 와도
|
||||
return '';
|
||||
},
|
||||
});
|
||||
expect(r.sectionsAdded).toBe(2);
|
||||
expect(r.merged).toContain('잘린 초안입니다.');
|
||||
expect(r.merged).toContain('## 구조·현황');
|
||||
expect(r.merged).toContain('## 다음 단계\n테스트 보강.'); // 헤딩 자동 보강
|
||||
// 섹션 호출에 시스템 컨텍스트가 재사용됐는지 (근거 접근 유지)
|
||||
expect(calls.length).toBe(3); // outline 1 + 섹션 2
|
||||
});
|
||||
|
||||
test('outline 이 "없음"이면 초안 유지', async () => {
|
||||
const r = await completeSectioned({
|
||||
draft: '완결된 초안'.repeat(10),
|
||||
userPrompt: 'q',
|
||||
baseMessages: BASE,
|
||||
callLLM: async () => '없음',
|
||||
});
|
||||
expect(r.sectionsAdded).toBe(0);
|
||||
expect(r.merged).toContain('완결된 초안');
|
||||
});
|
||||
|
||||
test('한 섹션 생성 실패는 건너뛰고 나머지로 통합', async () => {
|
||||
const r = await completeSectioned({
|
||||
draft: '초안 본문입니다.',
|
||||
userPrompt: 'q',
|
||||
baseMessages: BASE,
|
||||
callLLM: async (messages) => {
|
||||
const last = String(messages[messages.length - 1].content);
|
||||
if (last.includes('남은 섹션 제목')) return 'A섹션\nB섹션';
|
||||
if (last.includes('"A섹션"')) throw new Error('LLM down');
|
||||
return '## B섹션\n본문';
|
||||
},
|
||||
});
|
||||
expect(r.sectionsAdded).toBe(1);
|
||||
expect(r.merged).toContain('## B섹션');
|
||||
});
|
||||
|
||||
test('전 섹션 실패 시 초안 그대로 (폴백)', async () => {
|
||||
const r = await completeSectioned({
|
||||
draft: '초안',
|
||||
userPrompt: 'q',
|
||||
baseMessages: BASE,
|
||||
callLLM: async (messages) => {
|
||||
const last = String(messages[messages.length - 1].content);
|
||||
if (last.includes('남은 섹션 제목')) return 'A섹션';
|
||||
throw new Error('down');
|
||||
},
|
||||
});
|
||||
expect(r.sectionsAdded).toBe(0);
|
||||
expect(r.merged).toBe('초안');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* [v2.2.318] 하위 질문 체크리스트 — "물음 3개에 하나도 직접 답하지 않는" 실사례 가드.
|
||||
*/
|
||||
import {
|
||||
extractSubQuestions,
|
||||
clauseKeyTokens,
|
||||
buildSubQuestionBlock,
|
||||
checkSubQuestionCoverage,
|
||||
formatSubQuestionFooter,
|
||||
} from '../src/intelligence/subQuestions';
|
||||
|
||||
const PROMPT = 'https://www.koritips.com 분석해줘. 어떠한 주제로 블로그가 운영되고 있는지, 글의 퀄리티는 어떠한지, 검색해서 들어온 사람이 글을 읽고 도움이 되었을지';
|
||||
|
||||
describe('extractSubQuestions', () => {
|
||||
test('실사례 프롬프트에서 물음 3개 추출', () => {
|
||||
const qs = extractSubQuestions(PROMPT);
|
||||
expect(qs.length).toBe(3);
|
||||
expect(qs[0]).toContain('주제');
|
||||
expect(qs[1]).toContain('퀄리티');
|
||||
expect(qs[2]).toContain('도움');
|
||||
});
|
||||
test('물음이 1개뿐이면 빈 목록 (본문 전체가 답)', () => {
|
||||
expect(extractSubQuestions('이 코드가 왜 느린지 알려줘')).toEqual([]);
|
||||
expect(extractSubQuestions('안녕하세요')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clauseKeyTokens / coverage', () => {
|
||||
test('특징 토큰에서 조사·의문사 제거', () => {
|
||||
const keys = clauseKeyTokens('글의 퀄리티는 어떠한지');
|
||||
expect(keys).toContain('퀄리티');
|
||||
expect(keys).not.toContain('어떠한');
|
||||
});
|
||||
test('실사례 답변(평가 형용사만) → 퀄리티·도움 물음 누락 감지', () => {
|
||||
const answer = 'koritips.com은 기술 아카이브로서의 가치가 매우 높습니다. 신뢰성과 지속 가능성, 타겟 명확성 측면에서 높은 평가를 받을 수 있습니다.';
|
||||
const cov = checkSubQuestionCoverage(PROMPT, answer);
|
||||
expect(cov.total).toBe(3);
|
||||
expect(cov.missing.length).toBeGreaterThanOrEqual(2);
|
||||
expect(formatSubQuestionFooter(cov)).toContain('미답변 물음');
|
||||
});
|
||||
test('각 물음에 답한 답변은 통과', () => {
|
||||
const answer = '주제는 블로그 운영 팁입니다. 글의 퀄리티는 단계별 스크린샷이 있어 높습니다. 검색 유입자에게 실질적 도움이 됩니다.';
|
||||
expect(checkSubQuestionCoverage(PROMPT, answer).missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildSubQuestionBlock', () => {
|
||||
test('물음 2개 이상이면 체크리스트 블록 생성', () => {
|
||||
const blk = buildSubQuestionBlock(PROMPT);
|
||||
expect(blk).toContain('[하위 질문 체크리스트');
|
||||
expect(blk).toContain('1.');
|
||||
expect(blk).toContain('구체 근거');
|
||||
});
|
||||
test('단일 질문은 빈 문자열', () => {
|
||||
expect(buildSubQuestionBlock('서버 상태 확인해줘')).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* [v2.2.316] 턴 의도 브리핑 — "질문 의도를 판단하지 못한다" 문제의 구조적 해결.
|
||||
* 게이팅(잡담 제외) / JSON 파싱 내성 / 주입 블록의 근거-경로 강제를 검증.
|
||||
*/
|
||||
import {
|
||||
shouldBuildTurnIntentBrief,
|
||||
parseIntentBrief,
|
||||
buildTurnIntentBrief,
|
||||
formatTurnIntentBlock,
|
||||
} from '../src/intelligence/turnIntentBrief';
|
||||
|
||||
describe('shouldBuildTurnIntentBrief — 게이팅', () => {
|
||||
test('실질 요청형 턴은 대상', () => {
|
||||
expect(shouldBuildTurnIntentBrief('E:\Wiki\connectai 프로젝트 분석하고 보고해줘', false)).toBe(true);
|
||||
expect(shouldBuildTurnIntentBrief('HTTPS://KORITIPS.COM 사이트에 대한 분석 및 평가해줘', false)).toBe(true);
|
||||
});
|
||||
test('잡담·초단문·슬래시 명령은 제외', () => {
|
||||
expect(shouldBuildTurnIntentBrief('잘 지내?', true)).toBe(false);
|
||||
expect(shouldBuildTurnIntentBrief('응 고마워', false)).toBe(false);
|
||||
expect(shouldBuildTurnIntentBrief('/wikify https://x.com 정리해줘', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseIntentBrief — 파싱 내성', () => {
|
||||
test('코드펜스·잡담 섞인 응답에서도 JSON 추출', () => {
|
||||
const raw = '알겠습니다.\n```json\n{"purpose":"사이트 품질 평가","realQuestions":["구독자층"],"deliverable":"평가 보고","audience":"본인","accessPath":"web","constraints":""}\n```';
|
||||
const b = parseIntentBrief(raw)!;
|
||||
expect(b.purpose).toBe('사이트 품질 평가');
|
||||
expect(b.accessPath).toBe('web');
|
||||
});
|
||||
test('purpose 없는 JSON / 비 JSON 은 null', () => {
|
||||
expect(parseIntentBrief('{"realQuestions":[]}')).toBeNull();
|
||||
expect(parseIntentBrief('그냥 텍스트')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildTurnIntentBrief — 호출·타임아웃', () => {
|
||||
test('정상 응답 → 브리핑 객체', async () => {
|
||||
const b = await buildTurnIntentBrief({
|
||||
prompt: '사이트 분석해줘',
|
||||
llm: async () => '{"purpose":"P","realQuestions":["Q"],"deliverable":"D","audience":"A","accessPath":"web","constraints":""}',
|
||||
});
|
||||
expect(b?.purpose).toBe('P');
|
||||
});
|
||||
test('LLM 실패 → null (턴을 막지 않음)', async () => {
|
||||
const b = await buildTurnIntentBrief({ prompt: 'x분석해줘', llm: async () => { throw new Error('down'); } });
|
||||
expect(b).toBeNull();
|
||||
});
|
||||
test('타임아웃 → null', async () => {
|
||||
const b = await buildTurnIntentBrief({
|
||||
prompt: 'x분석해줘',
|
||||
timeoutMs: 50,
|
||||
llm: () => new Promise((res) => setTimeout(() => res('{}'), 500)),
|
||||
});
|
||||
expect(b).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTurnIntentBlock — 근거 경로 강제', () => {
|
||||
const base = { purpose: '사이트 평가', realQuestions: ['구독자층'], deliverable: '평가 보고', audience: '본인', constraints: '' };
|
||||
test('web → 실데이터 근거 강제 + 일반론 금지', () => {
|
||||
const blk = formatTurnIntentBlock({ ...base, accessPath: 'web' });
|
||||
expect(blk).toContain('[질문자 의도 브리핑');
|
||||
expect(blk).toContain('URL CONTENT');
|
||||
expect(blk).toContain('일반 지식으로 사이트를 평하는 것은 금지');
|
||||
});
|
||||
test('local-files → 파일 정독 강제', () => {
|
||||
expect(formatTurnIntentBlock({ ...base, accessPath: 'local-files' })).toContain('investigate_files');
|
||||
});
|
||||
test('null 브리핑 → 빈 문자열', () => {
|
||||
expect(formatTurnIntentBlock(null)).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* [v2.2.315] 웹 실접속 결정론 표시 — "사이트 분석 요청에 실접속 없이 일반론 답변"
|
||||
* (koritips.com 실사례) 재발 방지. 접속 실패는 답변 상단 경고, 성공은 하단 출처.
|
||||
*/
|
||||
import { buildWebFetchNotice, isUrlContextSuccessBlock } from '../src/lib/contextBuilders/urlContext';
|
||||
import { shouldUseMultiAgentWorkflow } from '../src/lib/contextBuilders/multiAgentRouting';
|
||||
|
||||
describe('buildWebFetchNotice', () => {
|
||||
test('전부 실패 → 상단 경고 (실측 아님 명시)', () => {
|
||||
const n = buildWebFetchNotice({ urls: ['https://koritips.com'], succeeded: [], failed: [{ url: 'https://koritips.com', reason: 'HTTP 403' }] });
|
||||
expect(n.top).toContain('사이트 실접속 실패');
|
||||
expect(n.top).toContain('koritips.com');
|
||||
expect(n.top).toContain('HTTP 403');
|
||||
expect(n.bottom).toBe('');
|
||||
});
|
||||
test('성공 → 하단 실접속 출처', () => {
|
||||
const n = buildWebFetchNotice({ urls: ['https://koritips.com'], succeeded: ['https://koritips.com'], failed: [] });
|
||||
expect(n.top).toBe('');
|
||||
expect(n.bottom).toContain('실접속 확인');
|
||||
});
|
||||
test('URL 없는 턴은 무표시', () => {
|
||||
expect(buildWebFetchNotice(null)).toEqual({ top: '', bottom: '' });
|
||||
expect(buildWebFetchNotice({ urls: [], succeeded: [], failed: [] })).toEqual({ top: '', bottom: '' });
|
||||
});
|
||||
test('성공 블록 판별', () => {
|
||||
expect(isUrlContextSuccessBlock('[URL CONTENT — 실데이터 · https://x.com]\n...')).toBe(true);
|
||||
expect(isUrlContextSuccessBlock('[URL CONTENT — https://x.com]\n상태: 접근 실패 — ...')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldUseMultiAgentWorkflow — URL 은 단일 턴 강제', () => {
|
||||
test('URL 포함 조사 요청은 워크플로우로 빠지지 않는다 (웹 접근 경로가 없으므로)', () => {
|
||||
expect(shouldUseMultiAgentWorkflow('HTTPS://KORITIPS.COM 사이트 조사해서 보고서 작성해줘', true)).toBe(false);
|
||||
expect(shouldUseMultiAgentWorkflow('https://example.com 리서치해줘', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// [v2.2.317] 본문 불일치 검증 — 접속 성공인데 실데이터 미반영 일반론 답변 감지.
|
||||
import { detectWebContentMismatch, formatWebContentMismatchFooter, extractSuccessMeta } from '../src/lib/contextBuilders/urlContext';
|
||||
|
||||
describe('detectWebContentMismatch', () => {
|
||||
const stats = {
|
||||
urls: ['https://www.koritips.com'],
|
||||
succeeded: ['https://www.koritips.com'],
|
||||
failed: [],
|
||||
succeededDetail: [{
|
||||
url: 'https://www.koritips.com',
|
||||
title: "Kori's Tips - Lived. Learned. Shared.",
|
||||
sample: 'Blogging Tips 카테고리의 글 모음. 블로그 운영, 애드센스 수익, 워드프레스 세팅, 검색 노출 가이드를 다룹니다. blogging adsense wordpress guide',
|
||||
}],
|
||||
};
|
||||
const hallucinated = '이 사이트는 IT 엔지니어 및 개발자를 위한 전문 지식 공유 플랫폼으로 판단됩니다. 기술 기획자와 테크프리너가 핵심 구독층이며, 아키텍처 관점의 심층 콘텐츠를 제공합니다. '.repeat(4);
|
||||
|
||||
test('실데이터 표현이 전혀 반영 안 된 일반론 = 불일치', () => {
|
||||
const r = detectWebContentMismatch(hallucinated, stats);
|
||||
expect(r.mismatch).toBe(true);
|
||||
});
|
||||
test('본문 표현을 인용한 답변은 통과', () => {
|
||||
const grounded = `이 블로그는 Blogging Tips 카테고리 중심으로 블로그 운영과 애드센스 수익, 워드프레스 세팅을 다룹니다. ${'상세 설명. '.repeat(30)}`;
|
||||
expect(detectWebContentMismatch(grounded, stats).mismatch).toBe(false);
|
||||
});
|
||||
test('접속 실패 턴은 판정 대상 아님 (실패는 상단 경고가 담당)', () => {
|
||||
expect(detectWebContentMismatch(hallucinated, { urls: ['https://x.com'], succeeded: [], failed: [{ url: 'https://x.com', reason: 'x' }] }).mismatch).toBe(false);
|
||||
});
|
||||
test('footer 에 재요청 안내 포함', () => {
|
||||
expect(formatWebContentMismatchFooter()).toContain('본문 불일치');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSuccessMeta', () => {
|
||||
test('성공 블록에서 제목·본문 표본 추출', () => {
|
||||
const block = '[URL CONTENT — 실데이터 · https://x.com]\n제목: 테스트 제목\n아래 본문만 근거로 답하라.\n\n본문 내용입니다.';
|
||||
const m = extractSuccessMeta('https://x.com', block);
|
||||
expect(m.title).toBe('테스트 제목');
|
||||
expect(m.sample).toContain('본문 내용');
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node", "jest"]
|
||||
},
|
||||
"include": ["src/**/*", "tests/**/*"],
|
||||
|
||||
Reference in New Issue
Block a user