Compare commits
2 Commits
47b3b9f93a
...
5feacb4799
| Author | SHA1 | Date | |
|---|---|---|---|
| 5feacb4799 | |||
| 137b83d8b6 |
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "직답 결과 — single-pass mock 응답입니다.",
|
"result": "직답 결과 — single-pass mock 응답입니다.",
|
||||||
"createdAt": 1783757602271,
|
"createdAt": 1784020111869,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "---\nid: wiki_on\ndate: 2026-07-11T08:13:22.272Z\ntype: knowledge_artifact\nstandard: P-Reinforce v3.0\ntags: [automated, connect_ai, brain_sync]\n---\n\n## 📌 Brief Summary\n직답 결과 — single-pass mock 응답입니다.\n\n직답 결과 — single-pass mock 응답입니다.\n---\n## 🛡️ Reliability & Audit Summary\n> [!NOTE]\n> 이 문서는 ConnectAI의 **Intelligent Resilience** 엔진에 의해 검증 및 정제되었습니다.\n\n| Metric | Value | Status |\n| :--- | :--- | :--- |\n| **Conflict Risk** | `0/100` | ✅ Low |\n| **Fallbacks Used** | `0` | ✅ None |\n| **Auto Retries** | `0` | ✅ Stable |\n| **Deduplication** | `0` | Standard |\n| **Processing Time** | `0.0s` | ✅ Fast |\n\n### 🔍 Decision Audit Trail\n- **[DIRECT]** 답변 작성 중... (단일 호출 fast-path) (12ms)\n",
|
"result": "---\nid: wiki_on\ndate: 2026-07-14T09:08:31.873Z\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": 1783757602272,
|
"createdAt": 1784020111873,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "wiki_on",
|
"missionId": "wiki_on",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-11T08:13:22.259Z",
|
"startTime": "2026-07-14T09:08:31.847Z",
|
||||||
"totalElapsedMs": 13,
|
"totalElapsedMs": 27,
|
||||||
"results": {
|
"results": {
|
||||||
"direct": "직답 결과 — single-pass mock 응답입니다."
|
"direct": "직답 결과 — single-pass mock 응답입니다."
|
||||||
},
|
},
|
||||||
@@ -12,16 +12,16 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "direct",
|
"to": "direct",
|
||||||
"durationMs": 12,
|
"durationMs": 18,
|
||||||
"message": "답변 작성 중... (단일 호출 fast-path)",
|
"message": "답변 작성 중... (단일 호출 fast-path)",
|
||||||
"ts": "2026-07-11T08:13:22.271Z"
|
"ts": "2026-07-14T09:08:31.865Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "direct",
|
"from": "direct",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 9,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-11T08:13:22.272Z"
|
"ts": "2026-07-14T09:08:31.874Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783757609132,
|
"createdAt": 1784020117514,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
"result": "Final report with inconsistencies. This should be long enough to pass validation.",
|
||||||
"createdAt": 1783757609132,
|
"createdAt": 1784020117513,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"result": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"createdAt": 1783757609131,
|
"createdAt": 1784020117508,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"result": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
"createdAt": 1783757609131,
|
"createdAt": 1784020117511,
|
||||||
"modelVersion": "unknown"
|
"modelVersion": "unknown"
|
||||||
}
|
}
|
||||||
+11
-11
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"missionId": "stress_conflict_1783757609119",
|
"missionId": "stress_conflict_1784020117492",
|
||||||
"status": "completed",
|
"status": "completed",
|
||||||
"startTime": "2026-07-11T08:13:29.119Z",
|
"startTime": "2026-07-14T09:08:37.493Z",
|
||||||
"totalElapsedMs": 13,
|
"totalElapsedMs": 22,
|
||||||
"results": {
|
"results": {
|
||||||
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
"outline": "[{\"heading\":\"본문\",\"scope\":\"전체 답변\"}]",
|
||||||
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
"section_0": "[CONFLICT WARNING] 성능이 200% 증가했습니다. vs 그러나 동시에 50% 감소했습니다. 최적화와 성능 저하가 동시에 발견됨.",
|
||||||
@@ -14,30 +14,30 @@
|
|||||||
{
|
{
|
||||||
"from": "idle",
|
"from": "idle",
|
||||||
"to": "outline",
|
"to": "outline",
|
||||||
"durationMs": 11,
|
"durationMs": 14,
|
||||||
"message": "답변 구조 잡는 중...",
|
"message": "답변 구조 잡는 중...",
|
||||||
"ts": "2026-07-11T08:13:29.130Z"
|
"ts": "2026-07-14T09:08:37.507Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "outline",
|
"from": "outline",
|
||||||
"to": "section",
|
"to": "section",
|
||||||
"durationMs": 1,
|
"durationMs": 3,
|
||||||
"message": "본문 작성 중...",
|
"message": "본문 작성 중...",
|
||||||
"ts": "2026-07-11T08:13:29.131Z"
|
"ts": "2026-07-14T09:08:37.510Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "section",
|
"from": "section",
|
||||||
"to": "polish",
|
"to": "polish",
|
||||||
"durationMs": 0,
|
"durationMs": 2,
|
||||||
"message": "최종 다듬기 중...",
|
"message": "최종 다듬기 중...",
|
||||||
"ts": "2026-07-11T08:13:29.131Z"
|
"ts": "2026-07-14T09:08:37.512Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"from": "polish",
|
"from": "polish",
|
||||||
"to": "completed",
|
"to": "completed",
|
||||||
"durationMs": 1,
|
"durationMs": 3,
|
||||||
"message": "미션 완료",
|
"message": "미션 완료",
|
||||||
"ts": "2026-07-11T08:13:29.132Z"
|
"ts": "2026-07-14T09:08:37.515Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"resilienceMetrics": {
|
"resilienceMetrics": {
|
||||||
+14
-2
@@ -2,7 +2,7 @@
|
|||||||
"name": "astra",
|
"name": "astra",
|
||||||
"displayName": "Astra",
|
"displayName": "Astra",
|
||||||
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
"description": "The personal intelligence layer for Antigravity and VS Code. A private cognitive partner for deep project context, memory, and proactive strategic decision-making.",
|
||||||
"version": "2.2.307",
|
"version": "2.2.310",
|
||||||
"publisher": "g1nation",
|
"publisher": "g1nation",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"icon": "assets/icon.png",
|
"icon": "assets/icon.png",
|
||||||
@@ -54,6 +54,10 @@
|
|||||||
"command": "g1nation.growth.report",
|
"command": "g1nation.growth.report",
|
||||||
"title": "Astra: 성장 리포트 (Reflection 추이)"
|
"title": "Astra: 성장 리포트 (Reflection 추이)"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"command": "g1nation.growth.standingRules",
|
||||||
|
"title": "Astra: 상시 행동 규칙 (Standing Rules)"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"command": "g1nation.growth.learningQueue",
|
"command": "g1nation.growth.learningQueue",
|
||||||
"title": "Astra: 학습 큐 갱신 (Need Engine)"
|
"title": "Astra: 학습 큐 갱신 (Need Engine)"
|
||||||
@@ -352,7 +356,10 @@
|
|||||||
"g1nation.meetTaskDateFallback": {
|
"g1nation.meetTaskDateFallback": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"default": "today",
|
"default": "today",
|
||||||
"enum": ["today", "hold"],
|
"enum": [
|
||||||
|
"today",
|
||||||
|
"hold"
|
||||||
|
],
|
||||||
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
"markdownDescription": "`/meet` 액션 아이템 중 **기한이 없는(파싱 불가) 확정·기한미정** 항목의 처리. `today`(기본): 회의록 **작성일(오늘)** 을 기한으로 잡아 자동 등록 — 매번 '자동 등록 0건'이 되던 문제를 해소. `hold`: 이전처럼 등록하지 않고 보류→`/meet confirm` 으로 수동 등록. (진행미정·조건부 항목은 이 설정과 무관하게 항상 보류.)"
|
||||||
},
|
},
|
||||||
"g1nation.meetTaskDetailExpand": {
|
"g1nation.meetTaskDetailExpand": {
|
||||||
@@ -1282,6 +1289,11 @@
|
|||||||
"type": "number",
|
"type": "number",
|
||||||
"default": 0,
|
"default": 0,
|
||||||
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
|
"markdownDescription": "**Stocks 보고서 전용 텔레그램 chatId** — fallback. `g1nation.telegram.allowedChatIds` 가 비어있을 때만 사용. 0 = 미설정."
|
||||||
|
},
|
||||||
|
"g1nation.investigationModel": {
|
||||||
|
"type": "string",
|
||||||
|
"default": "",
|
||||||
|
"markdownDescription": "조사형 요청('~폴더 조사해줘') 전용 모델 오버라이드. 예: 'claude-code:sonnet'. 소형 로컬 모델이 파일명만 보고 내용을 상상하는 헛조사 방지책의 하나 — 비우면 현재 선택 모델 사용."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+66
-9
@@ -24,7 +24,8 @@ import { isSelfAssessRequest, isAboutSelf, buildSelfAssessContext } from './lib/
|
|||||||
import { ensureFeatureInventory } from './extension/featureInventory';
|
import { ensureFeatureInventory } from './extension/featureInventory';
|
||||||
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
import { buildUrlContext } from './lib/contextBuilders/urlContext';
|
||||||
import { extractUrls } from './features/web/webFetch';
|
import { extractUrls } from './features/web/webFetch';
|
||||||
import { looksLikeCorrection, captureCorrection } from './intelligence/correctionLoop';
|
import { looksLikeCorrection, looksLikeBehaviorComplaint, captureCorrection } from './intelligence/correctionLoop';
|
||||||
|
import { isLocalInvestigationPrompt, detectHollowInvestigation, formatHollowInvestigationFooter } from './intelligence/investigationPipeline';
|
||||||
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
import { shouldUseMultiAgentWorkflow } from './lib/contextBuilders/multiAgentRouting';
|
||||||
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
import { buildThinkingPartnerResponseContract } from './lib/contextBuilders/thinkingPartnerContract';
|
||||||
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
import { buildDroppedHistorySummary } from './lib/contextBuilders/droppedHistorySummary';
|
||||||
@@ -163,6 +164,7 @@ import { applyRunCommandActions } from './agent/actions/runCommand';
|
|||||||
import { applyCalculateActions } from './agent/actions/calculate';
|
import { applyCalculateActions } from './agent/actions/calculate';
|
||||||
import { applyRunCodeActions } from './agent/actions/runCode';
|
import { applyRunCodeActions } from './agent/actions/runCode';
|
||||||
import { applyListFilesActions } from './agent/actions/listFiles';
|
import { applyListFilesActions } from './agent/actions/listFiles';
|
||||||
|
import { applyInvestigateFilesActions } from './agent/actions/investigateFiles';
|
||||||
import { applyWebFetchActions } from './agent/actions/webFetch';
|
import { applyWebFetchActions } from './agent/actions/webFetch';
|
||||||
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
import { applyBrainOpsActions } from './agent/actions/brainOps';
|
||||||
import { applyCalendarActions } from './agent/actions/calendar';
|
import { applyCalendarActions } from './agent/actions/calendar';
|
||||||
@@ -316,6 +318,10 @@ export class AgentExecutor {
|
|||||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||||
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
|
/** Confidence Engine 검색 신호 (Phase 2) — memoryContext 가 채움. */
|
||||||
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
confidenceSignals: import('./intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||||
|
/** [v2.2.309] 이번 turn 의 액션 실행 통계 — Hollow Investigation 감지용 (loop depth 누적). */
|
||||||
|
actionStats: { reads: number; lists: number; investigates: number };
|
||||||
|
/** [v2.2.309] 조사 턴 모델 오버라이드 — depth 0 에서 결정, continuation 에도 유지. */
|
||||||
|
investigationModelOverride: string | null;
|
||||||
} = {
|
} = {
|
||||||
retrieval: null,
|
retrieval: null,
|
||||||
lessons: [],
|
lessons: [],
|
||||||
@@ -323,6 +329,8 @@ export class AgentExecutor {
|
|||||||
dynamicBlocks: new Map(),
|
dynamicBlocks: new Map(),
|
||||||
selfCheckSources: [],
|
selfCheckSources: [],
|
||||||
confidenceSignals: null,
|
confidenceSignals: null,
|
||||||
|
actionStats: { reads: 0, lists: 0, investigates: 0 },
|
||||||
|
investigationModelOverride: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
/** Per-turn state 일괄 정리. turn 시작/abort/load session 시 호출. */
|
||||||
@@ -333,6 +341,10 @@ export class AgentExecutor {
|
|||||||
this._turnCtx.dynamicBlocks.clear();
|
this._turnCtx.dynamicBlocks.clear();
|
||||||
this._turnCtx.selfCheckSources = [];
|
this._turnCtx.selfCheckSources = [];
|
||||||
this._turnCtx.confidenceSignals = null;
|
this._turnCtx.confidenceSignals = null;
|
||||||
|
// actionStats / investigationModelOverride 는 여기서 리셋하지 않는다 —
|
||||||
|
// resetTurnContext 는 continuation depth 에서도 호출되는데(메모리 컨텍스트 재구축),
|
||||||
|
// 이 둘은 사용자 turn 전체(모든 depth)에 걸쳐 누적/유지되어야 한다.
|
||||||
|
// 리셋은 loopDepth === 0 진입부에서만 (handlePrompt 초입).
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly options: AgentExecutorOptions;
|
private readonly options: AgentExecutorOptions;
|
||||||
@@ -545,6 +557,9 @@ export class AgentExecutor {
|
|||||||
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
|
// buildMemoryContext, the previous turn's value would otherwise leak into this turn's
|
||||||
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
// "참조 범위" footer (the exact "안녕 → 🔎 참조: 에피소드기억" bug).
|
||||||
this.resetTurnContext();
|
this.resetTurnContext();
|
||||||
|
// [v2.2.309] turn 전체(모든 depth) 누적 상태 — depth 0 에서만 초기화.
|
||||||
|
this._turnCtx.actionStats = { reads: 0, lists: 0, investigates: 0 };
|
||||||
|
this._turnCtx.investigationModelOverride = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Prepare Context
|
// 1. Prepare Context
|
||||||
@@ -618,6 +633,7 @@ export class AgentExecutor {
|
|||||||
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
- 이 워크스페이스의 코드·문서·기능에 대한 주장은 *이 대화에서 실제로 읽은 파일*에만 근거하라.
|
||||||
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
- 확인하지 않은 구현을 "~로 보입니다", "~일 것입니다"라고 추측 서술하는 것은 금지. 먼저 <list_files path="..."/> 와 <read_file path="..."/> 태그로 관련 파일을 직접 열어 확인한 뒤 답하라. 태그를 emit 하면 시스템이 파일 내용을 주입하고 자동으로 이어서 답변하게 된다.
|
||||||
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
- ⚠️ "소스 코드 확인이 필요합니다"라고 말만 하고 끝내는 것은 금지다. 확인이 필요하다고 판단했다면 *바로 이 답변 안에서* <list_files>/<read_file> 태그를 emit 하라 — 그것이 확인하는 방법이다. 태그로 접근 불가능한 대상(외부 시스템·미설치 도구 등)에 한해서만 "확인하지 못함"으로 명시하라.
|
||||||
|
- 폴더 전체를 조사해야 하면 파일을 하나씩 읽지 말고 <investigate_files path="상대경로" focus="조사 목적"/> 태그 하나를 emit 하라 — 시스템이 폴더의 모든 텍스트 파일을 실제로 읽어 파일별 노트를 주입한다. list_files 로 받은 *파일명*만 보고 내용을 서술하는 것은 날조다.
|
||||||
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
- "X 기능을 추가하라"고 제안하기 전에 그 기능이 이미 구현돼 있는지 해당 모듈을 찾아 읽어라. 이미 있는 기능을 새로 만들라고 제안하는 것은 잘못된 분석이다.
|
||||||
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
- 일반론·추측으로 빈칸을 채우지 마라.`;
|
||||||
}
|
}
|
||||||
@@ -637,24 +653,28 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*이면 fire-and-forget
|
// [Correction Loop ①] 이 발화가 직전 답변에 대한 *정정*(사실) 또는 *행동/스타일
|
||||||
// 캡처 — 오류 분류 → 태깅 레슨 + 회귀 케이스(.astra/eval/corrections.jsonl).
|
// 지적*("또 ~하네", "하지 말라고 했잖아")이면 fire-and-forget 캡처 — 오류 분류 →
|
||||||
// 정정 자체가 Ground Truth 가 되어 주간 회귀 테스트·약점 프로필의 원료가 된다.
|
// 태깅 레슨 + 회귀 케이스. 행동 지적은 추가로 Standing Rule 로 정규화되어
|
||||||
|
// 다음 턴부터(새 세션 포함) 매 턴 주입된다 (v2.2.308 — 세션 휘발 문제 해결).
|
||||||
// 턴 응답을 막지 않는다 (await 없음).
|
// 턴 응답을 막지 않는다 (await 없음).
|
||||||
if (prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt)) {
|
const isFactCorrection = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeCorrection(prompt));
|
||||||
|
const isBehaviorComplaint = !!(prompt && loopDepth === 0 && activeBrain?.localBrainPath && looksLikeBehaviorComplaint(prompt));
|
||||||
|
if (isFactCorrection || isBehaviorComplaint) {
|
||||||
const visible = this.chatHistory.filter(m => !m.internal);
|
const visible = this.chatHistory.filter(m => !m.internal);
|
||||||
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
||||||
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
|
const lastUserIdx = lastAssistant ? visible.lastIndexOf(lastAssistant) - 1 : -1;
|
||||||
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
|
const priorQuestion = lastUserIdx >= 0 && visible[lastUserIdx]?.role === 'user' ? visible[lastUserIdx].content : '';
|
||||||
if (lastAssistant && priorQuestion) {
|
if (lastAssistant && priorQuestion) {
|
||||||
void captureCorrection({
|
void captureCorrection({
|
||||||
brainPath: activeBrain.localBrainPath,
|
brainPath: activeBrain!.localBrainPath,
|
||||||
question: priorQuestion,
|
question: priorQuestion,
|
||||||
wrongAnswer: lastAssistant.content,
|
wrongAnswer: lastAssistant.content,
|
||||||
correction: prompt,
|
correction: prompt!,
|
||||||
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
|
llm: { baseUrl: config.ollamaUrl, model: configDefaultModel },
|
||||||
|
behaviorHint: isBehaviorComplaint,
|
||||||
}).then(file => {
|
}).then(file => {
|
||||||
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file });
|
if (file) logInfo('Correction Loop: 정정 캡처 완료.', { lesson: file, behavior: isBehaviorComplaint });
|
||||||
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
|
}).catch((e: any) => logError('Correction Loop 캡처 실패 (무시).', { error: e?.message ?? String(e) }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -670,7 +690,18 @@ export class AgentExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
|
// 3. API Request Setup (라인 229에서 이미 추출한 ollamaUrl, configDefaultModel 재사용)
|
||||||
const actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
let actualModel = (modelName && modelName.trim()) || configDefaultModel;
|
||||||
|
// [v2.2.309-D] 조사형 요청은 설정된 상위 모델로 라우팅 (예: "claude-code:sonnet").
|
||||||
|
// 소형 로컬 모델의 lazy tool-use 할루시네이션 대책의 마지막 층 — depth 0 에서
|
||||||
|
// 결정하고 continuation(액션 결과로 이어지는 답변)에도 같은 모델을 유지한다.
|
||||||
|
if (loopDepth === 0 && prompt && config.investigationModel
|
||||||
|
&& isLocalInvestigationPrompt(prompt)) {
|
||||||
|
this._turnCtx.investigationModelOverride = config.investigationModel;
|
||||||
|
logInfo('조사 요청 감지 — investigationModel 로 라우팅.', { model: config.investigationModel });
|
||||||
|
}
|
||||||
|
if (this._turnCtx.investigationModelOverride) {
|
||||||
|
actualModel = this._turnCtx.investigationModelOverride;
|
||||||
|
}
|
||||||
// Bound the in-memory history before building the request — shrinks bulky
|
// Bound the in-memory history before building the request — shrinks bulky
|
||||||
// older tool-result bodies and drops the oldest messages past the cap.
|
// older tool-result bodies and drops the oldest messages past the cap.
|
||||||
capChatHistory(this.chatHistory, {
|
capChatHistory(this.chatHistory, {
|
||||||
@@ -994,6 +1025,12 @@ export class AgentExecutor {
|
|||||||
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
|
||||||
const postLiveToken = (token: string) => {
|
const postLiveToken = (token: string) => {
|
||||||
const visible = liveFilter ? liveFilter.push(token) : token;
|
const visible = liveFilter ? liveFilter.push(token) : token;
|
||||||
|
// [v2.2.310] LM Studio LSEP 구분자 — 여는 마커 없이 흐른 추론이 화면에
|
||||||
|
// 이미 표시된 상태. streamReplace 로 걷어내고 실제 답변부터 다시.
|
||||||
|
if (liveFilter?.consumeSeparatorSignal()) {
|
||||||
|
this.webview?.postMessage({ type: 'streamReplace', value: visible });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1499,6 +1536,7 @@ export class AgentExecutor {
|
|||||||
engine,
|
engine,
|
||||||
selfCheckSources: this._turnCtx.selfCheckSources,
|
selfCheckSources: this._turnCtx.selfCheckSources,
|
||||||
confidenceSignals: this._turnCtx.confidenceSignals,
|
confidenceSignals: this._turnCtx.confidenceSignals,
|
||||||
|
actionStats: this._turnCtx.actionStats,
|
||||||
callNonStreaming: (p) => this.callNonStreaming(p),
|
callNonStreaming: (p) => this.callNonStreaming(p),
|
||||||
getAbortSignal: () => this.abortController?.signal,
|
getAbortSignal: () => this.abortController?.signal,
|
||||||
getWebview: () => this.webview,
|
getWebview: () => this.webview,
|
||||||
@@ -1508,6 +1546,16 @@ export class AgentExecutor {
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
this.webview.postMessage({ type: 'streamChunk', value: finalAssistantContent });
|
||||||
|
// [v2.2.309] Hollow Investigation — 액션 turn 의 최종 답변(continuation)은
|
||||||
|
// post-answer hooks(depth 0 전용)를 타지 않으므로 여기서 직접 검사한다.
|
||||||
|
// "list_files 만 하고 read/investigate 없이 파일 여러 개를 서술" = 헛조사.
|
||||||
|
try {
|
||||||
|
const hollowInv = detectHollowInvestigation(finalAssistantContent, this._turnCtx.actionStats);
|
||||||
|
if (hollowInv.hollow) {
|
||||||
|
this.webview.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(hollowInv.fileMentions) });
|
||||||
|
logInfo('Hollow Investigation 감지 (continuation).', { files: hollowInv.fileMentions, stats: this._turnCtx.actionStats });
|
||||||
|
}
|
||||||
|
} catch { /* 감지 실패가 답변을 막지 않음 */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -1766,6 +1814,15 @@ export class AgentExecutor {
|
|||||||
await applyCalculateActions(ctx);
|
await applyCalculateActions(ctx);
|
||||||
await applyRunCodeActions(ctx);
|
await applyRunCodeActions(ctx);
|
||||||
await applyListFilesActions(ctx);
|
await applyListFilesActions(ctx);
|
||||||
|
await applyInvestigateFilesActions(ctx);
|
||||||
|
|
||||||
|
// [v2.2.309] Hollow Investigation 감지용 액션 통계 (report 마커 기반, turn 누적).
|
||||||
|
for (const line of report) {
|
||||||
|
if (line.startsWith('📖 Read:')) this._turnCtx.actionStats.reads++;
|
||||||
|
else if (line.startsWith('📂 Listed:')) this._turnCtx.actionStats.lists++;
|
||||||
|
else if (line.startsWith('🔎 Investigated:')) this._turnCtx.actionStats.investigates++;
|
||||||
|
}
|
||||||
|
|
||||||
await applyWebFetchActions(ctx);
|
await applyWebFetchActions(ctx);
|
||||||
await applyBrainOpsActions(ctx);
|
await applyBrainOpsActions(ctx);
|
||||||
await applyCalendarActions(ctx);
|
await applyCalendarActions(ctx);
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* <investigate_files path="..." focus="..."/> — 폴더(또는 단일 파일) 강제 정독 조사.
|
||||||
|
*
|
||||||
|
* list_files 가 "이름 목록"을 주는 것과 달리, 이 액션은 대상 파일들을 코드가 직접
|
||||||
|
* 읽어 파일별 추출 노트(Map)를 만들고 병합 노트를 컨텍스트에 주입한다 — 모델이
|
||||||
|
* 파일명만 보고 내용을 상상하는 헛조사(할루시네이션)를 구조적으로 차단. (v2.2.309)
|
||||||
|
* 노트는 내용 해시 캐시로 영속화되어 재조사 시 재사용된다 (온디맨드 지식화).
|
||||||
|
*/
|
||||||
|
import { HandlerContext } from './types';
|
||||||
|
import { validatePath } from '../../security';
|
||||||
|
import { getConfig } from '../../config';
|
||||||
|
import { simpleChatCompletion } from '../../intelligence/llmCall';
|
||||||
|
import { runInvestigation, formatInvestigationNotes } from '../../intelligence/investigationPipeline';
|
||||||
|
import { logInfo, logError } from '../../utils';
|
||||||
|
|
||||||
|
export async function applyInvestigateFilesActions(ctx: HandlerContext): Promise<void> {
|
||||||
|
const { aiMessage, rootPath, report } = ctx;
|
||||||
|
const regex = /<investigate_files\s+path=['"]?([^'">]+)['"]?(?:\s+focus=['"]?([^'">]*)['"]?)?\s*\/?>(?:<\/investigate_files>)?/gi;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
|
while ((match = regex.exec(aiMessage)) !== null) {
|
||||||
|
const relPath = match[1].trim() || '.';
|
||||||
|
const focus = (match[2] || '').trim();
|
||||||
|
try {
|
||||||
|
validatePath(rootPath, relPath); // 경로 탈출 방지 (결과는 pipeline 이 재해석)
|
||||||
|
const cfg = getConfig();
|
||||||
|
const llm = { baseUrl: cfg.ollamaUrl, model: cfg.defaultModel };
|
||||||
|
const result = await runInvestigation({
|
||||||
|
rootPath,
|
||||||
|
relDir: relPath,
|
||||||
|
llmCall: (system, user) => simpleChatCompletion(system, user, {
|
||||||
|
...llm, temperature: 0.1, maxTokens: 600, timeoutMs: 120000,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.notes.length === 0) {
|
||||||
|
report.push(`🔎 Investigate: ${relPath} — 읽을 텍스트 파일 없음 (제외 ${result.skipped.length}건)`);
|
||||||
|
ctx.chatHistory.push({
|
||||||
|
role: 'system', internal: true,
|
||||||
|
content: `[Result of investigate_files "${relPath}"] 읽을 수 있는 텍스트 파일이 없다. 제외: ${result.skipped.slice(0, 10).join(', ') || '—'}`,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.chatHistory.push({ role: 'system', internal: true, content: formatInvestigationNotes(result, focus) });
|
||||||
|
report.push(`🔎 Investigated: ${relPath} — 파일 ${result.notes.length}개 정독 (캐시 ${result.cacheHits} · LLM ${result.llmCalls} · 제외 ${result.skipped.length})`);
|
||||||
|
logInfo('Investigation pipeline 완료.', { relPath, files: result.notes.length, cacheHits: result.cacheHits, llmCalls: result.llmCalls });
|
||||||
|
} catch (err: any) {
|
||||||
|
report.push(`❌ Investigate failed: ${relPath} — ${err?.message ?? err}`);
|
||||||
|
logError('Investigation pipeline 실패.', { relPath, error: err?.message ?? String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
|
|||||||
const post = (token: string) => {
|
const post = (token: string) => {
|
||||||
if (params.postLiveDeltas && token) {
|
if (params.postLiveDeltas && token) {
|
||||||
const visible = liveFilter.push(token);
|
const visible = liveFilter.push(token);
|
||||||
|
// [v2.2.310] LM Studio LSEP 구분자 — 마커 이전에 라이브로 나간 추론 텍스트를
|
||||||
|
// streamReplace 로 화면에서 걷어내고, 마커 이후(실제 답변)부터 다시 시작.
|
||||||
|
if (liveFilter.consumeSeparatorSignal()) {
|
||||||
|
deps.getWebview()?.postMessage({ type: 'streamReplace', value: visible });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { appendSuccessPattern } from '../../intelligence/skillScore';
|
|||||||
import { getConfig } from '../../config';
|
import { getConfig } from '../../config';
|
||||||
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
import { detectReimplementedProposals, formatReimplementationFooter } from '../../extension/featureConceptMap';
|
||||||
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
import { isSelfAssessRequest, isAboutSelf } from '../../lib/contextBuilders/selfAssessContext';
|
||||||
|
import { detectHollowInvestigation, formatHollowInvestigationFooter } from '../../intelligence/investigationPipeline';
|
||||||
|
import { logInfo } from '../../utils';
|
||||||
|
|
||||||
const devilRebuttalHook: PostAnswerHook = {
|
const devilRebuttalHook: PostAnswerHook = {
|
||||||
id: 'devil-rebuttal',
|
id: 'devil-rebuttal',
|
||||||
@@ -86,6 +88,23 @@ const termValidatorHook: PostAnswerHook = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.309] Hollow Investigation — "목록만 조회(list_files)하고 읽지는 않은 채(read/
|
||||||
|
* investigate 0회) 파일 여러 개를 서술한 답변" 감지. 소형 모델의 파일명 상상 조사를
|
||||||
|
* 사용자에게 즉시 노출한다 (결정론적, LLM 호출 없음).
|
||||||
|
*/
|
||||||
|
const hollowInvestigationHook: PostAnswerHook = {
|
||||||
|
id: 'hollow-investigation',
|
||||||
|
runAsync: false,
|
||||||
|
run(ctx: PostAnswerHookContext): void {
|
||||||
|
if (!ctx.assistantAnswer?.trim()) return;
|
||||||
|
const { hollow, fileMentions } = detectHollowInvestigation(ctx.assistantAnswer, ctx.actionStats);
|
||||||
|
if (!hollow) return;
|
||||||
|
ctx.getWebview()?.postMessage({ type: 'streamChunk', value: formatHollowInvestigationFooter(fileMentions) });
|
||||||
|
logInfo('Hollow Investigation 감지 — list만 하고 read 없이 파일 서술.', { files: fileMentions });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const requirementCoverageHook: PostAnswerHook = {
|
const requirementCoverageHook: PostAnswerHook = {
|
||||||
id: 'requirement-coverage',
|
id: 'requirement-coverage',
|
||||||
runAsync: false,
|
runAsync: false,
|
||||||
@@ -253,6 +272,7 @@ export const POST_ANSWER_HOOKS: PostAnswerHook[] = [
|
|||||||
devilRebuttalHook,
|
devilRebuttalHook,
|
||||||
postHocSelfCheckHook,
|
postHocSelfCheckHook,
|
||||||
termValidatorHook,
|
termValidatorHook,
|
||||||
|
hollowInvestigationHook,
|
||||||
requirementCoverageHook,
|
requirementCoverageHook,
|
||||||
confidenceEscalationHook,
|
confidenceEscalationHook,
|
||||||
criticLoopHook,
|
criticLoopHook,
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ export interface PostAnswerHookContext {
|
|||||||
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
selfCheckSources: Array<{ title: string; excerpt: string }>;
|
||||||
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
|
/** Confidence Engine 검색 신호 (Phase 2). memoryContext 가 채움 — 검색 안 돈 turn 은 null. */
|
||||||
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
confidenceSignals?: import('../../intelligence/confidenceEngine').RetrievalConfidenceSignals | null;
|
||||||
|
/** [v2.2.309] 이번 turn 액션 실행 통계 — Hollow Investigation(헛조사) 감지용. */
|
||||||
|
actionStats?: { reads: number; lists: number; investigates: number } | null;
|
||||||
/** Devil Agent 가 호출 — non-streaming LLM. */
|
/** Devil Agent 가 호출 — non-streaming LLM. */
|
||||||
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
callNonStreaming: (params: any) => Promise<{ text: string; stopReason?: string }>;
|
||||||
/** Abort signal accessor. */
|
/** Abort signal accessor. */
|
||||||
|
|||||||
@@ -227,6 +227,12 @@ export interface IAgentConfig {
|
|||||||
* 결과는 답변 아래 footer 한 줄로 표시.
|
* 결과는 답변 아래 footer 한 줄로 표시.
|
||||||
*/
|
*/
|
||||||
selfCheckEnabled: boolean;
|
selfCheckEnabled: boolean;
|
||||||
|
/**
|
||||||
|
* [v2.2.309] 조사형 요청("~폴더 조사해줘") 전용 모델 오버라이드.
|
||||||
|
* 예: "claude-code:sonnet" — 소형 로컬 모델의 lazy tool-use(파일명만 보고 서술)
|
||||||
|
* 할루시네이션 대책. 빈 문자열이면 비활성 (현재 선택 모델 그대로).
|
||||||
|
*/
|
||||||
|
investigationModel: string;
|
||||||
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
/** Self-check 전용 모델 ID. 비면 defaultModel. 빠른 작은 모델 권장. */
|
||||||
selfCheckModel: string;
|
selfCheckModel: string;
|
||||||
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
/** Self-check LLM 호출 타임아웃 (초). 기본 6. */
|
||||||
@@ -576,6 +582,7 @@ export function getConfig(): IAgentConfig {
|
|||||||
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
intentClarificationEnabled: cfg.get<boolean>('intentClarificationEnabled', true),
|
||||||
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
intentClarificationStrictness: (cfg.get<string>('intentClarificationStrictness', 'medium') as 'low' | 'medium' | 'high') || 'medium',
|
||||||
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
citationTraceEnabled: cfg.get<boolean>('citationTraceEnabled', true),
|
||||||
|
investigationModel: (cfg.get<string>('investigationModel', '') || '').trim(),
|
||||||
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
selfCheckEnabled: cfg.get<boolean>('selfCheckEnabled', false),
|
||||||
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
selfCheckModel: cfg.get<string>('selfCheckModel', '') || '',
|
||||||
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
selfCheckTimeoutSec: Math.max(1, Math.min(60, cfg.get<number>('selfCheckTimeoutSec', 6))),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { computeSkillScores, formatSkillScoresMarkdown, loadSuccessPatterns, for
|
|||||||
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
|
import { runResearch, formatProposalMarkdown } from '../intelligence/researchAgent';
|
||||||
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
|
import type { ExistingKnowledgeRef } from '../intelligence/knowledgeValidation';
|
||||||
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
|
import { loadQueue, saveQueue, mergeNeedsIntoQueue, formatQueueMarkdown, LEARNING_QUEUE_REL_PATH } from '../intelligence/learningQueue';
|
||||||
|
import { loadStandingRules, buildStandingRulesBlock, STANDING_RULES_REL_PATH } from '../intelligence/correctionLoop';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 검색 평가 명령 묶음 (Phase 1-나).
|
* 검색 평가 명령 묶음 (Phase 1-나).
|
||||||
@@ -43,6 +44,7 @@ export function registerEvalCommands(): vscode.Disposable[] {
|
|||||||
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
vscode.commands.registerCommand('g1nation.embeddings.backfill', backfillEmbeddingsCommand),
|
||||||
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
|
vscode.commands.registerCommand('g1nation.eval.tasks', runTaskEvalCommand),
|
||||||
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
|
vscode.commands.registerCommand('g1nation.growth.report', growthReportCommand),
|
||||||
|
vscode.commands.registerCommand('g1nation.growth.standingRules', standingRulesCommand),
|
||||||
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
|
vscode.commands.registerCommand('g1nation.growth.learningQueue', learningQueueCommand),
|
||||||
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
|
vscode.commands.registerCommand('g1nation.knowledge.decayAudit', decayAuditCommand),
|
||||||
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
|
vscode.commands.registerCommand('g1nation.research.runQueue', researchRunQueueCommand),
|
||||||
@@ -307,6 +309,57 @@ async function runTaskEvalCommand(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
|
/** 성장 리포트 — Reflection 기록(.astra/growth/reflections.jsonl)의 주별 추이 + 반복 실수 Top. */
|
||||||
|
/**
|
||||||
|
* Standing Rules 열람 — "주입엔 항상 도달 증거를" 원칙의 확인 창구.
|
||||||
|
* 현재 매 턴 주입 중인 상시 행동 규칙과 실제 주입 블록 원문을 파일로 열어준다.
|
||||||
|
* 규칙 수정/삭제는 standing-rules.json 을 직접 편집 (Permission Based Learning).
|
||||||
|
*/
|
||||||
|
async function standingRulesCommand(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const brain = getActiveBrainProfile();
|
||||||
|
if (!brain?.localBrainPath || !fs.existsSync(brain.localBrainPath)) {
|
||||||
|
vscode.window.showErrorMessage('활성 두뇌 폴더를 찾을 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rules = loadStandingRules(brain.localBrainPath);
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
const retired = rules.filter(r => r.status !== 'active');
|
||||||
|
const block = buildStandingRulesBlock(rules);
|
||||||
|
const md = [
|
||||||
|
'# 상시 행동 규칙 (Standing Rules)',
|
||||||
|
'',
|
||||||
|
`사용자 지적("또 ~하네", "하지 말라고 했잖아")이 명령형 규칙으로 정규화되어 여기 쌓이고,`,
|
||||||
|
`**매 턴 시스템 프롬프트 보호 구역에 주입**됩니다 — 세션을 새로 열어도 유지됩니다.`,
|
||||||
|
`수정/삭제: \`${STANDING_RULES_REL_PATH}\` 파일을 직접 편집하세요.`,
|
||||||
|
'',
|
||||||
|
`## 활성 규칙 ${active.length}개`,
|
||||||
|
'',
|
||||||
|
...(active.length
|
||||||
|
? active.sort((a, b) => b.hits - a.hits).map(r =>
|
||||||
|
`- **${r.rule}** — 지적 ${r.hits}회, 최근 ${r.lastHitAt.slice(0, 10)}\n - 원 발화: "${r.origin}"`)
|
||||||
|
: ['(아직 없음 — 행동/형식을 지적하면 자동으로 쌓입니다)']),
|
||||||
|
'',
|
||||||
|
`## 은퇴 규칙 ${retired.length}개`,
|
||||||
|
...(retired.length ? retired.map(r => `- ${r.rule} (지적 ${r.hits}회)`) : ['(없음)']),
|
||||||
|
'',
|
||||||
|
'## 실제 주입 블록 (도달 증거)',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
block || '(활성 규칙이 없어 이번 턴에는 주입되지 않음)',
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
const reportPath = path.join(brain.localBrainPath, '.astra', 'growth', 'standing-rules-report.md');
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(reportPath, md, 'utf8');
|
||||||
|
const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(reportPath));
|
||||||
|
await vscode.window.showTextDocument(doc, { preview: false });
|
||||||
|
} catch (err: any) {
|
||||||
|
logError('Standing rules command failed.', { error: err?.message || String(err) });
|
||||||
|
vscode.window.showErrorMessage(`상시 규칙 열람 실패: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function growthReportCommand(): Promise<void> {
|
async function growthReportCommand(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const brain = getActiveBrainProfile();
|
const brain = getActiveBrainProfile();
|
||||||
|
|||||||
@@ -178,8 +178,21 @@ export async function captureCorrection(opts: {
|
|||||||
wrongAnswer: string;
|
wrongAnswer: string;
|
||||||
correction: string;
|
correction: string;
|
||||||
llm: { baseUrl: string; model: string };
|
llm: { baseUrl: string; model: string };
|
||||||
|
/** 행동/스타일 지적으로 감지됨 (looksLikeBehaviorComplaint) — Standing Rule 파생 대상. */
|
||||||
|
behaviorHint?: boolean;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
|
const { tag, title } = await classifyCorrection(opts.question, opts.wrongAnswer, opts.correction, opts.llm);
|
||||||
|
|
||||||
|
// [④ Standing Rules] 행동/스타일 교정은 명령형 규칙으로 정규화해 즉시 영속화 —
|
||||||
|
// 다음 턴부터(그리고 새 세션에서도) 매 턴 주입된다. 태그가 형식오류/지시불이행
|
||||||
|
// 이거나 감지 단계에서 행동 지적 패턴(behaviorHint)에 걸린 경우만.
|
||||||
|
if (opts.behaviorHint || tag === '형식오류' || tag === '지시불이행') {
|
||||||
|
try {
|
||||||
|
const rule = await deriveStandingRule(opts.wrongAnswer, opts.correction, opts.llm);
|
||||||
|
if (rule) upsertStandingRule(opts.brainPath, rule, opts.correction);
|
||||||
|
} catch { /* 규칙 파생 실패가 레슨 캡처를 막지 않음 */ }
|
||||||
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const c: CorrectionCase = {
|
const c: CorrectionCase = {
|
||||||
ts: now.toISOString(),
|
ts: now.toISOString(),
|
||||||
@@ -380,3 +393,165 @@ export function formatRegressionReport(results: RegressionResult[], meta: { date
|
|||||||
}
|
}
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ④ Standing Rules — 세션을 넘는 상시 행동 규칙 ────────────────────────────
|
||||||
|
//
|
||||||
|
// 배경: 기존 파이프라인(①~③)은 *사실 정정*을 영속화하지만, "결론 수정 문구 쓰지 마"
|
||||||
|
// 같은 *행동/스타일 교정*은 (a) 감지 패턴에 안 걸리고 (b) 레슨은 유사도 검색이라
|
||||||
|
// 질문 내용과 무관한 상시 규칙이 주입되지 않아 새 세션에서 증발했다 (v2.2.308).
|
||||||
|
//
|
||||||
|
// 해법: 행동 교정을 명령형 1줄 규칙으로 정규화해 파일에 영속화하고, 검색과 무관하게
|
||||||
|
// 매 턴 behavior-constraints 보호 구역에 주입한다. 상한(10개)과 재지적 카운트로
|
||||||
|
// 프롬프트 오염을 막고, 파일은 사람이 열어 수정/삭제 가능 (Permission Based Learning).
|
||||||
|
|
||||||
|
export const STANDING_RULES_REL_PATH = path.join('.astra', 'growth', 'standing-rules.json');
|
||||||
|
export const MAX_ACTIVE_STANDING_RULES = 10;
|
||||||
|
|
||||||
|
export interface StandingRule {
|
||||||
|
id: string; // 정규화된 규칙의 해시
|
||||||
|
rule: string; // 명령형 1줄 (모든 답변에 적용)
|
||||||
|
origin: string; // 규칙을 만든 원 발화 (사용자 지적)
|
||||||
|
createdAt: string; // ISO
|
||||||
|
lastHitAt: string; // 마지막 재지적 ISO
|
||||||
|
hits: number; // 지적 누계 (1 = 최초) — 클수록 은퇴 대상에서 멂
|
||||||
|
status: 'active' | 'retired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 행동/스타일 지적 감지 — 사실 정정(looksLikeCorrection)과 별개의 그물.
|
||||||
|
// "또 ~하네", "하지 말라고 했잖아", "몇 번을 말해" 같은 반복 지적이 핵심 신호다.
|
||||||
|
const BEHAVIOR_COMPLAINT_RE = [
|
||||||
|
/^\s*또\s+.{1,80}(하네|하잖|이네|했네|라고\s*(썼|하)|나왔|그러)/, // "또 결론 수정이라고 썼네"
|
||||||
|
/(하지\s*말라(고|니까)|말라고\s*했(잖|는데)|말랬잖)/, // "하지 말라고 했잖아"
|
||||||
|
/(몇\s*번(을|이나)?\s*(말|얘기|이야기)|매번\s*똑같)/, // "몇 번을 말해"
|
||||||
|
/(왜\s*자꾸|자꾸\s*왜|계속\s*(그러|반복)|또\s*반복)/, // "왜 자꾸 그래"
|
||||||
|
/(붙이지\s*마|쓰지\s*마|넣지\s*마|빼\s*달라고|빼라고)/, // 출력 형식 지시
|
||||||
|
];
|
||||||
|
|
||||||
|
export function looksLikeBehaviorComplaint(prompt: string): boolean {
|
||||||
|
const t = (prompt || '').trim();
|
||||||
|
if (t.length < 4 || t.length > 1500) return false;
|
||||||
|
return BEHAVIOR_COMPLAINT_RE.some(re => re.test(t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM 으로 지적 → 명령형 규칙 정규화. 실패 시 휴리스틱 fallback.
|
||||||
|
const RULE_SYSTEM = [
|
||||||
|
'너는 AI 행동 규칙 작성기다. 사용자가 AI 답변의 습관/형식/태도를 지적했다.',
|
||||||
|
'이 지적을 앞으로 모든 답변에 적용할 명령형 규칙 한 줄(한국어, 70자 이내)로 요약하라.',
|
||||||
|
'내용 지식(사실)이 아니라 행동 규칙만 만든다. 행동 규칙으로 만들 수 없으면 빈 문자열.',
|
||||||
|
'반드시 JSON 한 줄만 출력: {"rule":"<규칙 또는 빈 문자열>"}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
export async function deriveStandingRule(
|
||||||
|
wrongAnswer: string,
|
||||||
|
correction: string,
|
||||||
|
llm: { baseUrl: string; model: string },
|
||||||
|
): Promise<string> {
|
||||||
|
const fallback = (): string => {
|
||||||
|
// "~하지 마/말라" 문장이 있으면 그 문장 자체가 규칙에 가장 가깝다.
|
||||||
|
const m = correction.match(/[^.!?\n]*(?:하지\s*마|말라|쓰지\s*마|붙이지\s*마|넣지\s*마)[^.!?\n]*/);
|
||||||
|
const base = (m ? m[0] : correction).trim().replace(/\s+/g, ' ').slice(0, 70);
|
||||||
|
return base ? `${base} — 이 지적을 반복하게 하지 않는다` : '';
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const user = [
|
||||||
|
`[AI 답변 발췌] ${wrongAnswer.slice(0, MAX_FIELD_CHARS)}`,
|
||||||
|
`[사용자 지적] ${correction.slice(0, MAX_FIELD_CHARS)}`,
|
||||||
|
].join('\n');
|
||||||
|
const raw = await simpleChatCompletion(RULE_SYSTEM, user, {
|
||||||
|
baseUrl: llm.baseUrl, model: llm.model, temperature: 0.1, maxTokens: 120, timeoutMs: 30000,
|
||||||
|
});
|
||||||
|
const m = raw.match(/\{[\s\S]*?\}/);
|
||||||
|
if (!m) return fallback();
|
||||||
|
const rule = String(JSON.parse(m[0])?.rule || '').trim().replace(/\s+/g, ' ').slice(0, 90);
|
||||||
|
return rule || fallback();
|
||||||
|
} catch {
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRuleText(rule: string): string {
|
||||||
|
return rule.toLowerCase().replace(/[^a-z0-9가-힣]+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleId(rule: string): string {
|
||||||
|
// 의존성 없는 짧은 해시 (djb2) — 파일에서 사람이 규칙을 찾을 때 쓰는 식별자.
|
||||||
|
const s = normalizeRuleText(rule);
|
||||||
|
let h = 5381;
|
||||||
|
for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
|
||||||
|
return 'sr-' + h.toString(36);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 같은 규칙인지 — 토큰 자카드 유사도 (LLM 없이, 재지적 시 hits 만 올리기 위함).
|
||||||
|
export function isSameRule(a: string, b: string): boolean {
|
||||||
|
const ta = new Set(normalizeRuleText(a).split(' ').filter(w => w.length >= 2));
|
||||||
|
const tb = new Set(normalizeRuleText(b).split(' ').filter(w => w.length >= 2));
|
||||||
|
if (ta.size === 0 || tb.size === 0) return false;
|
||||||
|
let inter = 0;
|
||||||
|
for (const w of ta) if (tb.has(w)) inter++;
|
||||||
|
return inter / (ta.size + tb.size - inter) >= 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadStandingRules(brainPath: string): StandingRule[] {
|
||||||
|
try {
|
||||||
|
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||||
|
if (!fs.existsSync(file)) return [];
|
||||||
|
const o = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
return Array.isArray(o) ? o.filter(r => r && typeof r.rule === 'string') : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveStandingRules(brainPath: string, rules: StandingRule[]): boolean {
|
||||||
|
try {
|
||||||
|
const file = path.join(brainPath, STANDING_RULES_REL_PATH);
|
||||||
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||||
|
fs.writeFileSync(file, JSON.stringify(rules, null, 2) + '\n', 'utf8');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 규칙 추가/재지적 반영. 중복이면 hits++ (은퇴했던 규칙도 부활), 신규면 추가.
|
||||||
|
* active 가 상한을 넘으면 hits 낮고 오래된 것부터 은퇴. 저장까지 수행.
|
||||||
|
*/
|
||||||
|
export function upsertStandingRule(brainPath: string, rule: string, origin: string, nowIso?: string): StandingRule[] {
|
||||||
|
const now = nowIso || new Date().toISOString();
|
||||||
|
const rules = loadStandingRules(brainPath);
|
||||||
|
const existing = rules.find(r => isSameRule(r.rule, rule));
|
||||||
|
if (existing) {
|
||||||
|
existing.hits += 1;
|
||||||
|
existing.lastHitAt = now;
|
||||||
|
existing.status = 'active'; // 재지적 = 아직 필요한 규칙
|
||||||
|
} else {
|
||||||
|
rules.push({
|
||||||
|
id: ruleId(rule), rule: rule.trim(), origin: origin.slice(0, 200),
|
||||||
|
createdAt: now, lastHitAt: now, hits: 1, status: 'active',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// 상한 초과 시 은퇴: hits 오름차순 → lastHitAt 오래된 순.
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
if (active.length > MAX_ACTIVE_STANDING_RULES) {
|
||||||
|
const toRetire = active
|
||||||
|
.sort((a, b) => (a.hits - b.hits) || a.lastHitAt.localeCompare(b.lastHitAt))
|
||||||
|
.slice(0, active.length - MAX_ACTIVE_STANDING_RULES);
|
||||||
|
for (const r of toRetire) r.status = 'retired';
|
||||||
|
}
|
||||||
|
saveStandingRules(brainPath, rules);
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 매 턴 주입 블록 — 검색과 무관한 상시 규칙. active 없으면 ''. */
|
||||||
|
export function buildStandingRulesBlock(rules: StandingRule[]): string {
|
||||||
|
const active = rules.filter(r => r.status === 'active')
|
||||||
|
.sort((a, b) => (b.hits - a.hits) || b.lastHitAt.localeCompare(a.lastHitAt))
|
||||||
|
.slice(0, MAX_ACTIVE_STANDING_RULES);
|
||||||
|
if (active.length === 0) return '';
|
||||||
|
const lines = ['[상시 행동 규칙 — 사용자 지적으로 축적됨. 모든 답변에서 반드시 지켜라]'];
|
||||||
|
for (const r of active) {
|
||||||
|
lines.push(`- ${r.rule}${r.hits >= 2 ? ` (지적 ${r.hits}회)` : ''}`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/**
|
||||||
|
* Investigation Pipeline — "파일명만 보고 상상하는 조사" 를 구조적으로 차단. (v2.2.309)
|
||||||
|
*
|
||||||
|
* 문제: 소형 로컬 모델은 <list_files> 로 파일명 목록을 받으면 <read_file> 을 부르지
|
||||||
|
* 않고 이름에서 내용을 상상해 조사 결과를 지어낸다 (lazy tool-use 할루시네이션).
|
||||||
|
*
|
||||||
|
* 해법 3중:
|
||||||
|
* ① 증거 게이트 — 조사형 요청 감지 시 "읽지 않은 파일 서술 금지" 행동 제약 주입
|
||||||
|
* (buildInvestigationGateBlock → memoryContext behavior-constraints 채널).
|
||||||
|
* ② Map-Reduce — <investigate_files> 액션이 폴더의 파일을 *코드가 하나씩 강제로
|
||||||
|
* 읽어* 파일별 추출 노트 생성(Map) 후 병합 노트를 컨텍스트에 주입(Reduce 는
|
||||||
|
* 본 턴 모델이 노트 기반으로 수행). 모델에게 "읽을지" 선택권 자체가 없다.
|
||||||
|
* ③ 온디맨드 지식화 캐시 — 파일 내용 해시를 키로 노트를 .astra/cache/investigate 에
|
||||||
|
* 저장. 같은 내용은 재조사 시 LLM 호출 없이 재사용, 파일이 바뀐 것만 갱신.
|
||||||
|
* 전량 위키화 없이 조사가 닿는 곳만 점진적으로 지식화된다.
|
||||||
|
*
|
||||||
|
* 노트는 질문 비의존(파일의 목적/핵심 사실/관계)으로 뽑는다 — 캐시 재사용률을
|
||||||
|
* 최대화하기 위함 (질문 특화 해석은 Reduce 단계의 본 턴 모델 몫).
|
||||||
|
*/
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { EXCLUDED_DIRS } from '../config';
|
||||||
|
|
||||||
|
// ── 상수 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const INVESTIGATE_CACHE_REL_DIR = path.join('.astra', 'cache', 'investigate');
|
||||||
|
export const MAX_INVESTIGATION_FILES = 40; // 초과분은 skipped 로 보고 (조용한 누락 금지)
|
||||||
|
export const MAX_CHARS_PER_FILE = 20_000; // 파일당 읽기 상한 (소형 모델 충실 구간)
|
||||||
|
|
||||||
|
const TEXT_EXTS = new Set([
|
||||||
|
'.md', '.txt', '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.jsonl',
|
||||||
|
'.html', '.css', '.py', '.yml', '.yaml', '.csv', '.xml', '.sh', '.bat', '.ps1', '.sql', '.env',
|
||||||
|
]);
|
||||||
|
|
||||||
|
export interface InvestigationFileNote {
|
||||||
|
relPath: string;
|
||||||
|
note: string;
|
||||||
|
fromCache: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InvestigationResult {
|
||||||
|
dir: string;
|
||||||
|
notes: InvestigationFileNote[];
|
||||||
|
/** 상한·확장자·읽기실패로 제외된 파일 (조용한 누락 방지용 보고). */
|
||||||
|
skipped: string[];
|
||||||
|
cacheHits: number;
|
||||||
|
llmCalls: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ① 조사형 요청 감지 + 증거 게이트 블록 ───────────────────────────────────
|
||||||
|
|
||||||
|
const INVESTIGATE_VERB_RE = /(조사|분석|검토|파악|리뷰|정리)\s*(해|을|를|좀|부탁|해줘|해주|하고|해서)/;
|
||||||
|
const LOCAL_TARGET_RE = /(폴더|파일|디렉토리|프로젝트|코드|문서|리소스|소스)|([A-Za-z]:\\|\.{0,2}\/[\w가-힣])/;
|
||||||
|
|
||||||
|
/** 로컬 자료를 대상으로 한 조사형 요청인지 (증거 게이트·모델 라우팅 공용 감지기). */
|
||||||
|
export function isLocalInvestigationPrompt(prompt: string): boolean {
|
||||||
|
const t = (prompt || '').trim();
|
||||||
|
if (t.length < 6) return false;
|
||||||
|
return INVESTIGATE_VERB_RE.test(t) && LOCAL_TARGET_RE.test(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 조사형 요청일 때만 행동 제약 블록 반환, 아니면 ''. */
|
||||||
|
export function buildInvestigationGateBlock(prompt: string): string {
|
||||||
|
if (!isLocalInvestigationPrompt(prompt)) return '';
|
||||||
|
return [
|
||||||
|
'[조사 증거 게이트 — 로컬 자료 조사 시 필수]',
|
||||||
|
'- 파일명·폴더 목록만 보고 내용을 서술하는 것 금지. 내용에 대한 모든 주장은 실제로 읽은 것만 쓴다.',
|
||||||
|
'- 폴더 단위 조사는 <investigate_files path="상대경로" focus="조사 목적"/> 한 번으로 위임하라 — 각 파일을 자동으로 읽어 파일별 노트를 만들어준다. 개별 파일은 <read_file>.',
|
||||||
|
'- 읽지 않은 파일을 언급해야 하면 이름 뒤에 "(미확인)"을 붙여라.',
|
||||||
|
'- 조사 결과의 각 주장 끝에 근거 파일 경로를 붙여라 (예: [src/agent.ts]). 근거 없는 주장은 쓰지 않는다.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ②③ 파일 수집 → 노트 추출(캐시) → 병합 ──────────────────────────────────
|
||||||
|
|
||||||
|
export function collectInvestigationFiles(
|
||||||
|
rootPath: string,
|
||||||
|
relDir: string,
|
||||||
|
maxFiles = MAX_INVESTIGATION_FILES,
|
||||||
|
): { files: Array<{ relPath: string; absPath: string }>; skipped: string[] } {
|
||||||
|
const files: Array<{ relPath: string; absPath: string }> = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const baseAbs = path.resolve(rootPath, relDir || '.');
|
||||||
|
|
||||||
|
const walk = (abs: string, rel: string): void => {
|
||||||
|
let entries: fs.Dirent[];
|
||||||
|
try { entries = fs.readdirSync(abs, { withFileTypes: true }); } catch { skipped.push(rel + '/ (읽기 실패)'); return; }
|
||||||
|
// 결정적 순서 (테스트·캐시 재현성)
|
||||||
|
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const e of entries) {
|
||||||
|
if (e.name.startsWith('.') || EXCLUDED_DIRS.has(e.name)) continue;
|
||||||
|
const childAbs = path.join(abs, e.name);
|
||||||
|
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
||||||
|
if (e.isDirectory()) { walk(childAbs, childRel); continue; }
|
||||||
|
if (!TEXT_EXTS.has(path.extname(e.name).toLowerCase())) { skipped.push(childRel + ' (비텍스트)'); continue; }
|
||||||
|
if (files.length >= maxFiles) { skipped.push(childRel + ' (상한 초과)'); continue; }
|
||||||
|
files.push({ relPath: childRel, absPath: childAbs });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const st = fs.existsSync(baseAbs) ? fs.statSync(baseAbs) : null;
|
||||||
|
if (st?.isFile()) {
|
||||||
|
files.push({ relPath: relDir, absPath: baseAbs });
|
||||||
|
} else if (st?.isDirectory()) {
|
||||||
|
walk(baseAbs, '');
|
||||||
|
}
|
||||||
|
return { files, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
const EXTRACT_SYSTEM = [
|
||||||
|
'너는 파일 내용 분석기다. 제공된 파일 *내용*만 근거로 사실을 추출하라. 내용에 없는 것을 지어내지 말 것.',
|
||||||
|
'출력 형식 (개조식, 명사형 종결):',
|
||||||
|
'목적: <이 파일이 무엇인지 한 줄>',
|
||||||
|
'핵심:',
|
||||||
|
'- <핵심 사실/설정/결정 3~8개 — 수치·이름·경로 포함>',
|
||||||
|
'관계: <다른 파일/모듈과의 연결이 내용에 명시되어 있으면, 없으면 "—">',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function cacheKey(content: string): string {
|
||||||
|
return crypto.createHash('sha1').update(content).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function cachePathFor(rootPath: string, key: string): string {
|
||||||
|
return path.join(rootPath, INVESTIGATE_CACHE_REL_DIR, `${key}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더(또는 단일 파일)를 강제로 읽어 파일별 노트 생성. llmCall 은 주입식(테스트 용이).
|
||||||
|
* 캐시: sha1(내용) → 노트. 내용이 같으면 질문이 달라도 재사용 (질문 비의존 노트).
|
||||||
|
*/
|
||||||
|
export async function runInvestigation(opts: {
|
||||||
|
rootPath: string;
|
||||||
|
relDir: string;
|
||||||
|
llmCall: (system: string, user: string) => Promise<string>;
|
||||||
|
maxFiles?: number;
|
||||||
|
onProgress?: (done: number, total: number, relPath: string) => void;
|
||||||
|
}): Promise<InvestigationResult> {
|
||||||
|
const { files, skipped } = collectInvestigationFiles(opts.rootPath, opts.relDir, opts.maxFiles ?? MAX_INVESTIGATION_FILES);
|
||||||
|
const result: InvestigationResult = { dir: opts.relDir, notes: [], skipped, cacheHits: 0, llmCalls: 0 };
|
||||||
|
|
||||||
|
for (let i = 0; i < files.length; i++) {
|
||||||
|
const f = files[i];
|
||||||
|
let content: string;
|
||||||
|
try { content = fs.readFileSync(f.absPath, 'utf8').slice(0, MAX_CHARS_PER_FILE); }
|
||||||
|
catch { result.skipped.push(f.relPath + ' (읽기 실패)'); continue; }
|
||||||
|
if (!content.trim()) { result.skipped.push(f.relPath + ' (빈 파일)'); continue; }
|
||||||
|
|
||||||
|
const key = cacheKey(content);
|
||||||
|
const cacheFile = cachePathFor(opts.rootPath, key);
|
||||||
|
let note: string | null = null;
|
||||||
|
let fromCache = false;
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(cacheFile)) {
|
||||||
|
const cached = JSON.parse(fs.readFileSync(cacheFile, 'utf8'));
|
||||||
|
if (cached && typeof cached.note === 'string' && cached.note.trim()) {
|
||||||
|
note = cached.note;
|
||||||
|
fromCache = true;
|
||||||
|
result.cacheHits++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch { /* 캐시 손상 → 재추출 */ }
|
||||||
|
|
||||||
|
if (note === null) {
|
||||||
|
try {
|
||||||
|
note = (await opts.llmCall(EXTRACT_SYSTEM, `[파일] ${f.relPath}\n\n${content}`)).trim();
|
||||||
|
result.llmCalls++;
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(path.dirname(cacheFile), { recursive: true });
|
||||||
|
fs.writeFileSync(cacheFile, JSON.stringify({ relPath: f.relPath, note, ts: new Date().toISOString() }, null, 2), 'utf8');
|
||||||
|
} catch { /* 캐시 저장 실패는 무해 */ }
|
||||||
|
} catch (e: any) {
|
||||||
|
result.skipped.push(f.relPath + ` (추출 실패: ${e?.message ?? e})`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.notes.push({ relPath: f.relPath, note, fromCache });
|
||||||
|
opts.onProgress?.(i + 1, files.length, f.relPath);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 병합 노트 — 액션이 chatHistory 에 internal system 메시지로 주입하는 본문. */
|
||||||
|
export function formatInvestigationNotes(result: InvestigationResult, focus?: string): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`[Result of investigate_files "${result.dir}"] — 아래는 각 파일을 *실제로 읽고* 추출한 노트다 (${result.notes.length}개 파일, 캐시 재사용 ${result.cacheHits}건).`);
|
||||||
|
if (focus?.trim()) lines.push(`조사 목적: ${focus.trim()}`);
|
||||||
|
lines.push('이 노트를 근거로 답하되, 각 주장 끝에 근거 파일 경로를 붙여라. 노트에 없는 내용은 "노트에 근거 없음"으로 표기.');
|
||||||
|
lines.push('');
|
||||||
|
for (const n of result.notes) {
|
||||||
|
lines.push(`── [${n.relPath}]`);
|
||||||
|
lines.push(n.note);
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
if (result.skipped.length) {
|
||||||
|
lines.push(`(미확인 파일 ${result.skipped.length}건 — 필요 시 <read_file> 로 개별 확인: ${result.skipped.slice(0, 10).join(', ')}${result.skipped.length > 10 ? ' 외' : ''})`);
|
||||||
|
}
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Hollow Investigation 감지 (postAnswerHook 용 순수 함수) ──────────────────
|
||||||
|
|
||||||
|
export interface TurnActionStats { reads: number; lists: number; investigates: number }
|
||||||
|
|
||||||
|
const FILE_MENTION_RE = /[\w가-힣.\-]{2,}\.(?:md|txt|ts|tsx|js|jsx|json|html|css|py|pdf|docx?|xlsx?|pptx?|ya?ml|csv)\b/gi;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "목록만 보고(list) 읽지는 않았는데(read/investigate 0) 파일들을 서술" = 헛조사.
|
||||||
|
* 파일명 언급 2개 이상일 때만 (1개는 단순 지칭일 수 있음 — 오탐 방지).
|
||||||
|
*/
|
||||||
|
export function detectHollowInvestigation(
|
||||||
|
answer: string,
|
||||||
|
stats: TurnActionStats | null | undefined,
|
||||||
|
): { hollow: boolean; fileMentions: string[] } {
|
||||||
|
if (!stats || stats.lists === 0) return { hollow: false, fileMentions: [] };
|
||||||
|
if (stats.reads > 0 || stats.investigates > 0) return { hollow: false, fileMentions: [] };
|
||||||
|
const mentions = [...new Set(Array.from((answer || '').matchAll(FILE_MENTION_RE), m => m[0]))];
|
||||||
|
return { hollow: mentions.length >= 2, fileMentions: mentions.slice(0, 8) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHollowInvestigationFooter(fileMentions: string[]): string {
|
||||||
|
return [
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
`⚠️ **헛조사 감지** — 이 답변은 파일 목록만 조회하고 실제 내용은 읽지 않은 채 파일 ${fileMentions.length}건(${fileMentions.slice(0, 4).join(', ')}${fileMentions.length > 4 ? ' 외' : ''})을 서술했습니다.`,
|
||||||
|
'내용 검증이 필요하면 다시 요청해 주세요 — 다음 턴에서 `<investigate_files>`로 각 파일을 실제로 읽고 답합니다.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
@@ -27,6 +27,28 @@ const TAG_OPENERS: { literal: string; closer: RegExp }[] = [
|
|||||||
{ literal: '<analysis>', closer: /<\/analysis>/i },
|
{ literal: '<analysis>', closer: /<\/analysis>/i },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델/버전 조합에서 LM Studio 가
|
||||||
|
* 추론 텍스트를 *여는 마커 없이* content 스트림 맨 앞에 흘리고, 실제 답변과의
|
||||||
|
* 경계에 이 합성 마커를 삽입한다:
|
||||||
|
* `<추론 텍스트>__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_<hex>__<답변>`
|
||||||
|
* 여는 마커가 없어 앞부분을 미리 숨길 수는 없다 — 대신 END 마커를 만나는 순간
|
||||||
|
* `separatorSeen` 신호를 세워, 호출자(스트림 루프)가 streamReplace 로 그때까지
|
||||||
|
* 표시된 추론을 화면에서 걷어내게 한다. START 변형이 오면 일반 opener 처럼 숨긴다.
|
||||||
|
*/
|
||||||
|
const LSEP_CONST_PREFIX = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_';
|
||||||
|
const LSEP_END_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/i;
|
||||||
|
const LSEP_START_RE = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_[0-9a-f]{6,64}__/i;
|
||||||
|
|
||||||
|
/** tail 이 LSEP 마커의 접두사가 될 가능성 (emit 보류 판단, '<' 계열과 별도). */
|
||||||
|
function couldBeLsepPrefix(tail: string): boolean {
|
||||||
|
if (!tail.startsWith('_')) return false;
|
||||||
|
if (tail.length <= LSEP_CONST_PREFIX.length) return LSEP_CONST_PREFIX.startsWith(tail);
|
||||||
|
if (!tail.startsWith(LSEP_CONST_PREFIX)) return false;
|
||||||
|
// 상수부 이후: (START|END 일부) + '_' + hex + 닫는 '__' 진행 중이면 보류
|
||||||
|
return /^[A-Z]{0,5}_?[0-9a-f]{0,64}_{0,2}$/i.test(tail.slice(LSEP_CONST_PREFIX.length));
|
||||||
|
}
|
||||||
|
|
||||||
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
|
/** Harmony 채널 마커 — `<|channel|>` 외에 `<channel|>` / `<|channel>` 변형도 (sanitize 와 동일). */
|
||||||
const CHANNEL_MARKER = /<\|?channel\|?>/i;
|
const CHANNEL_MARKER = /<\|?channel\|?>/i;
|
||||||
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
|
const HIDDEN_CHANNEL_WORD = /^(?:thought|analysis|commentary|reasoning)\b/i;
|
||||||
@@ -58,6 +80,15 @@ export class LiveReasoningFilter {
|
|||||||
private pending = '';
|
private pending = '';
|
||||||
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
|
/** hiddenTag 모드에서 기다리는 닫힘 패턴. */
|
||||||
private closer: RegExp | null = null;
|
private closer: RegExp | null = null;
|
||||||
|
/** [v2.2.310] LSEP END 마커 감지 신호 — 호출자가 streamReplace 로 화면 리셋. */
|
||||||
|
private separatorSeen = false;
|
||||||
|
|
||||||
|
/** LSEP 구분자를 만났는지 (1회성 소비 — 읽으면 리셋). */
|
||||||
|
consumeSeparatorSignal(): boolean {
|
||||||
|
const v = this.separatorSeen;
|
||||||
|
this.separatorSeen = false;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
* 토큰을 누적하고, 화면에 내보내도 안전한 텍스트 델타를 반환한다.
|
||||||
@@ -70,6 +101,29 @@ export class LiveReasoningFilter {
|
|||||||
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
// 상태 전이가 한 토큰 안에서 여러 번 일어날 수 있어 (열림+닫힘 동시 도착) 루프.
|
||||||
for (;;) {
|
for (;;) {
|
||||||
if (this.mode === 'visible') {
|
if (this.mode === 'visible') {
|
||||||
|
// [v2.2.310] LSEP 마커 — START 가 END 보다 먼저면 정상 블록(숨김),
|
||||||
|
// END 만 단독 등장(여는 마커 없이 추론이 먼저 흐른 케이스)이면 리셋 신호.
|
||||||
|
const sepStart = this.pending.match(LSEP_START_RE);
|
||||||
|
const sepEnd = this.pending.match(LSEP_END_RE);
|
||||||
|
const startIdx = sepStart?.index ?? -1;
|
||||||
|
const endIdx = sepEnd?.index ?? -1;
|
||||||
|
if (startIdx >= 0 && (endIdx < 0 || startIdx < endIdx)) {
|
||||||
|
// START 변형 — 일반 opener 처럼 END 까지 숨김.
|
||||||
|
out += this.pending.slice(0, startIdx);
|
||||||
|
this.pending = this.pending.slice(startIdx + sepStart![0].length);
|
||||||
|
this.mode = 'hiddenTag';
|
||||||
|
this.closer = LSEP_END_RE;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (endIdx >= 0) {
|
||||||
|
// 여는 마커 없이 흘러온 추론의 끝 — 이미 화면에 나간 앞부분은 되돌릴
|
||||||
|
// 수 없으니 신호만 세우고(호출자가 streamReplace), 잔여 추론+마커 폐기.
|
||||||
|
this.pending = this.pending.slice(endIdx + sepEnd![0].length);
|
||||||
|
this.separatorSeen = true;
|
||||||
|
out = ''; // 마커 앞 잔여분(추론)은 방출 취소
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const hit = this.findEarliestOpener(this.pending);
|
const hit = this.findEarliestOpener(this.pending);
|
||||||
if (hit) {
|
if (hit) {
|
||||||
out += this.pending.slice(0, hit.index);
|
out += this.pending.slice(0, hit.index);
|
||||||
@@ -127,7 +181,8 @@ export class LiveReasoningFilter {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
// 닫힘이 토큰 경계에 걸칠 수 있으니 꼬리만 남기고 버린다.
|
||||||
this.pending = this.pending.slice(-24);
|
// (LSEP END 마커는 ~85자라 넉넉히 보존 — v2.2.310)
|
||||||
|
this.pending = this.pending.slice(-100);
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +234,12 @@ export class LiveReasoningFilter {
|
|||||||
const tail = s.slice(s.length - k);
|
const tail = s.slice(s.length - k);
|
||||||
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
|
if (tail.startsWith('<') && couldBeOpenerPrefix(tail)) return k;
|
||||||
}
|
}
|
||||||
|
// [v2.2.310] LSEP 마커('_' 시작, 최대 ~110자) 접두사 보류 — 토큰 경계 분절 대응.
|
||||||
|
const maxLsep = Math.min(s.length, 110);
|
||||||
|
for (let k = 1; k <= maxLsep; k++) {
|
||||||
|
const tail = s.slice(s.length - k);
|
||||||
|
if (tail.startsWith('_') && couldBeLsepPrefix(tail)) return k;
|
||||||
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import type { RetrievalOrchestrator } from '../../retrieval';
|
|||||||
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
||||||
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
||||||
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
import { backfillBrainEmbeddings, backfillBrainChunkEmbeddings } from '../../retrieval/brainIndex';
|
||||||
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap } from '../../intelligence/correctionLoop';
|
import { loadWeaknessProfile, buildSelfReviewBlock, registerKnowledgeGap, loadStandingRules, buildStandingRulesBlock } from '../../intelligence/correctionLoop';
|
||||||
|
import { buildInvestigationGateBlock } from '../../intelligence/investigationPipeline';
|
||||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||||
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
|
import { resolveDomainScope, classifyKnowledgeDomain, buildDomainRoutingHint } from '../../retrieval/domainRouter';
|
||||||
import {
|
import {
|
||||||
@@ -418,6 +419,14 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
|||||||
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
// [Correction Loop ③-c] 약점 프로필 → 자기검토 블록. 최근 정정 통계가 다음 턴의
|
||||||
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
// 행동을 직접 바꾼다 (태그 2회 이상만 — 1회성 실수로 프롬프트를 어지럽히지 않게).
|
||||||
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
const selfReviewBlock = buildSelfReviewBlock(loadWeaknessProfile(deps.activeBrain.localBrainPath));
|
||||||
|
// [Correction Loop ④] Standing Rules — 행동/스타일 교정의 영속 규칙. 레슨(유사도
|
||||||
|
// 검색)과 달리 질문 내용과 무관하게 *매 턴* 주입된다. 세션을 새로 열어도 유지 —
|
||||||
|
// "self-reflection 이 단기 기억에 갇힌다" 문제의 구조적 해결 (v2.2.308).
|
||||||
|
const standingRulesBlock = buildStandingRulesBlock(loadStandingRules(deps.activeBrain.localBrainPath));
|
||||||
|
|
||||||
|
// [조사 증거 게이트 v2.2.309] 로컬 자료 조사형 요청이면 "읽지 않은 파일 서술 금지 +
|
||||||
|
// investigate_files 위임" 제약 주입 — 파일명만 보고 상상하는 헛조사 차단의 1차 방어선.
|
||||||
|
const investigationGateBlock = buildInvestigationGateBlock(deps.currentPrompt);
|
||||||
|
|
||||||
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
// 행동 제약 블록(자기검토·확신도 정책·레슨 체크리스트)은 dynamicBlocks 채널로 —
|
||||||
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
// [CONTEXT] *밖* 보호 구역에 주입되어 context-overflow truncation 에서도 살아남는다.
|
||||||
@@ -438,7 +447,7 @@ export async function buildMemoryContext(deps: MemoryContextDeps): Promise<strin
|
|||||||
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
|
'- 첫 문장은 그 목적에 바로 답하는 결론으로 시작하라. 해석이 갈리면 가장 가능성 높은 해석으로 답하되, 어떤 해석으로 답했는지 한 줄로 밝혀라.',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const constraintBlock = [intentPrinciple, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
const constraintBlock = [intentPrinciple, standingRulesBlock, investigationGateBlock, routingHint, selfReviewBlock, groundingBlock, lessonBlock].filter(Boolean).join('\n\n');
|
||||||
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
if (constraintBlock) blocks.set('behavior-constraints', constraintBlock);
|
||||||
return memoryBlock;
|
return memoryBlock;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,16 @@
|
|||||||
* `<|return|>`) 보수적으로 매칭.
|
* `<|return|>`) 보수적으로 매칭.
|
||||||
*/
|
*/
|
||||||
export function sanitizeAssistantContent(text: string): string {
|
export function sanitizeAssistantContent(text: string): string {
|
||||||
const stripped = text
|
// [v2.2.310] LM Studio 합성 추론 구분자 — 일부 모델 조합에서 추론 텍스트가
|
||||||
|
// *여는 마커 없이* 답변 앞에 흐르고 이 END 마커로만 경계가 표시된다.
|
||||||
|
// 마지막 END 마커 이전은 전부 추론 → 폐기. 남은 마커 잔재도 제거.
|
||||||
|
let t = text;
|
||||||
|
const lsepEnd = /__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_[0-9a-f]{6,64}__/gi;
|
||||||
|
const lsepParts = t.split(lsepEnd);
|
||||||
|
if (lsepParts.length > 1) t = lsepParts[lsepParts.length - 1];
|
||||||
|
t = t.replace(/__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_[A-Z]+_[0-9a-f]{6,64}__/gi, '');
|
||||||
|
|
||||||
|
const stripped = t
|
||||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
/**
|
||||||
|
* Investigation Pipeline — 파일명만 보고 상상하는 헛조사 차단 3중 방어. (v2.2.309)
|
||||||
|
* ① 조사형 요청 감지 + 증거 게이트 ② 강제 정독 Map + 해시 캐시 ③ Hollow 감지
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
isLocalInvestigationPrompt,
|
||||||
|
buildInvestigationGateBlock,
|
||||||
|
collectInvestigationFiles,
|
||||||
|
runInvestigation,
|
||||||
|
formatInvestigationNotes,
|
||||||
|
detectHollowInvestigation,
|
||||||
|
formatHollowInvestigationFooter,
|
||||||
|
INVESTIGATE_CACHE_REL_DIR,
|
||||||
|
MAX_INVESTIGATION_FILES,
|
||||||
|
} from '../src/intelligence/investigationPipeline';
|
||||||
|
|
||||||
|
function tmpRoot(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-inv-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isLocalInvestigationPrompt — 조사형 요청 감지', () => {
|
||||||
|
test('폴더/프로젝트 조사 요청 감지', () => {
|
||||||
|
expect(isLocalInvestigationPrompt('E:\\Wiki\\Weekly 프로젝트 조사해줘')).toBe(true);
|
||||||
|
expect(isLocalInvestigationPrompt('이 폴더의 문서들 분석해서 정리해줘')).toBe(true);
|
||||||
|
expect(isLocalInvestigationPrompt('./docs 검토 부탁해')).toBe(true);
|
||||||
|
});
|
||||||
|
test('로컬 대상이 없는 일반 요청은 미감지', () => {
|
||||||
|
expect(isLocalInvestigationPrompt('오늘 날씨 어때?')).toBe(false);
|
||||||
|
expect(isLocalInvestigationPrompt('삼성전자 주가 조사해줘')).toBe(false); // 로컬 자료 아님
|
||||||
|
});
|
||||||
|
test('증거 게이트 블록은 감지 시에만 생성', () => {
|
||||||
|
expect(buildInvestigationGateBlock('이 폴더 코드 분석해줘')).toContain('investigate_files');
|
||||||
|
expect(buildInvestigationGateBlock('안녕')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('collectInvestigationFiles — 파일 수집', () => {
|
||||||
|
test('텍스트 파일만, 결정적 순서, 제외 목록 보고', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'docs'));
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'b.md'), '내용B');
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'a.md'), '내용A');
|
||||||
|
fs.writeFileSync(path.join(root, 'docs', 'img.png'), 'binary');
|
||||||
|
const { files, skipped } = collectInvestigationFiles(root, 'docs');
|
||||||
|
expect(files.map(f => f.relPath)).toEqual(['a.md', 'b.md']);
|
||||||
|
expect(skipped.some(s => s.includes('img.png'))).toBe(true);
|
||||||
|
});
|
||||||
|
test('상한 초과분은 skipped 로 (조용한 누락 금지)', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'many'));
|
||||||
|
for (let i = 0; i < MAX_INVESTIGATION_FILES + 5; i++) {
|
||||||
|
fs.writeFileSync(path.join(root, 'many', `f${String(i).padStart(3, '0')}.txt`), 'x' + i);
|
||||||
|
}
|
||||||
|
const { files, skipped } = collectInvestigationFiles(root, 'many');
|
||||||
|
expect(files).toHaveLength(MAX_INVESTIGATION_FILES);
|
||||||
|
expect(skipped.filter(s => s.includes('상한 초과'))).toHaveLength(5);
|
||||||
|
});
|
||||||
|
test('단일 파일 경로도 지원', () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.writeFileSync(path.join(root, 'one.md'), '단일');
|
||||||
|
const { files } = collectInvestigationFiles(root, 'one.md');
|
||||||
|
expect(files).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runInvestigation — 강제 정독 + 캐시', () => {
|
||||||
|
test('모든 파일에 LLM 추출 실행, 노트 생성', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), 'X 파일 내용');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'y.md'), 'Y 파일 내용');
|
||||||
|
const calls: string[] = [];
|
||||||
|
const r = await runInvestigation({
|
||||||
|
rootPath: root, relDir: 'p',
|
||||||
|
llmCall: async (_s, u) => { calls.push(u); return '목적: 테스트\n핵심:\n- 사실1'; },
|
||||||
|
});
|
||||||
|
expect(r.notes).toHaveLength(2);
|
||||||
|
expect(r.llmCalls).toBe(2);
|
||||||
|
expect(r.cacheHits).toBe(0);
|
||||||
|
// LLM 입력에 실제 파일 내용이 들어감 (파일명만이 아니라)
|
||||||
|
expect(calls[0]).toContain('X 파일 내용');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('같은 내용 재조사 → 캐시 재사용 (LLM 0회) / 내용 변경 → 재추출', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), '원본 내용');
|
||||||
|
const llm = jest.fn(async () => '목적: v1');
|
||||||
|
await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// 2차: 동일 내용 → 캐시
|
||||||
|
const r2 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(1); // 추가 호출 없음
|
||||||
|
expect(r2.cacheHits).toBe(1);
|
||||||
|
expect(r2.notes[0].fromCache).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(root, INVESTIGATE_CACHE_REL_DIR))).toBe(true);
|
||||||
|
|
||||||
|
// 3차: 내용 변경 → 재추출
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'x.md'), '변경된 내용');
|
||||||
|
const r3 = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: llm });
|
||||||
|
expect(llm).toHaveBeenCalledTimes(2);
|
||||||
|
expect(r3.cacheHits).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('추출 실패 파일은 skipped 로 계속 진행', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'ok.md'), '정상');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'zz.md'), '실패 대상');
|
||||||
|
const r = await runInvestigation({
|
||||||
|
rootPath: root, relDir: 'p',
|
||||||
|
llmCall: async (_s, u) => {
|
||||||
|
if (u.includes('실패 대상')) throw new Error('LLM down');
|
||||||
|
return '목적: ok';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(r.notes).toHaveLength(1);
|
||||||
|
expect(r.skipped.some(s => s.includes('zz.md') && s.includes('추출 실패'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('병합 노트에 파일 경로·미확인 목록 포함', async () => {
|
||||||
|
const root = tmpRoot();
|
||||||
|
fs.mkdirSync(path.join(root, 'p'));
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'a.md'), 'A');
|
||||||
|
fs.writeFileSync(path.join(root, 'p', 'bin.png'), 'x');
|
||||||
|
const r = await runInvestigation({ rootPath: root, relDir: 'p', llmCall: async () => '목적: A' });
|
||||||
|
const notes = formatInvestigationNotes(r, '구조 파악');
|
||||||
|
expect(notes).toContain('[a.md]');
|
||||||
|
expect(notes).toContain('조사 목적: 구조 파악');
|
||||||
|
expect(notes).toContain('미확인 파일');
|
||||||
|
expect(notes).toContain('실제로 읽고');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectHollowInvestigation — 헛조사 감지', () => {
|
||||||
|
const answer = '이 프로젝트는 server.mjs가 백엔드이고 dashboard.html이 화면입니다. config.json은 설정입니다.';
|
||||||
|
|
||||||
|
test('list만 하고 read 0 + 파일 2개 이상 서술 = 헛조사', () => {
|
||||||
|
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||||
|
expect(r.hollow).toBe(true);
|
||||||
|
expect(r.fileMentions.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
test('실제로 읽었으면(read>0) 정상', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 2, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('investigate_files 를 썼으면 정상', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 1 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('list 자체가 없으면 판정 대상 아님', () => {
|
||||||
|
expect(detectHollowInvestigation(answer, { reads: 0, lists: 0, investigates: 0 }).hollow).toBe(false);
|
||||||
|
expect(detectHollowInvestigation(answer, null).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('파일 언급 1개는 오탐 방지 위해 통과', () => {
|
||||||
|
const one = 'README.md 를 확인해보세요.';
|
||||||
|
expect(detectHollowInvestigation(one, { reads: 0, lists: 1, investigates: 0 }).hollow).toBe(false);
|
||||||
|
});
|
||||||
|
test('경고 footer 에 파일명 포함', () => {
|
||||||
|
const r = detectHollowInvestigation(answer, { reads: 0, lists: 1, investigates: 0 });
|
||||||
|
const footer = formatHollowInvestigationFooter(r.fileMentions);
|
||||||
|
expect(footer).toContain('헛조사 감지');
|
||||||
|
expect(footer).toContain('investigate_files');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,3 +63,74 @@ describe('LiveReasoningFilter — 스트리밍 중 추론 구간 실시간 차
|
|||||||
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
|
expect(run(['답변 끝 <'])).toBe('답변 끝 <');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('[v2.2.310] LM Studio LSEP 합성 추론 구분자', () => {
|
||||||
|
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||||
|
|
||||||
|
/** run + separator 신호 관찰 버전. */
|
||||||
|
function runWithSignal(tokens: string[]): { out: string; replacedAt: number[] } {
|
||||||
|
const f = new LiveReasoningFilter();
|
||||||
|
let out = '';
|
||||||
|
const replacedAt: number[] = [];
|
||||||
|
tokens.forEach((t, i) => {
|
||||||
|
const visible = f.push(t);
|
||||||
|
if (f.consumeSeparatorSignal()) {
|
||||||
|
out = visible; // streamReplace 시뮬레이션 — 화면 리셋
|
||||||
|
replacedAt.push(i);
|
||||||
|
} else {
|
||||||
|
out += visible;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
out += f.flush();
|
||||||
|
return { out, replacedAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('END 마커 뒤 텍스트만 최종 화면에 남는다 (실사례 재현)', () => {
|
||||||
|
const { out, replacedAt } = runWithSignal([
|
||||||
|
'User message: "안녕, 잘지내?" 분석... Option 1... Final choice...',
|
||||||
|
MARKER + '잘 지내고 있습니다. 별일 없으셨나요?',
|
||||||
|
]);
|
||||||
|
expect(out).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||||
|
expect(replacedAt).toEqual([1]); // 마커 도착 시점에 streamReplace 신호
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마커가 토큰 경계에 잘게 쪼개져도 잡는다', () => {
|
||||||
|
const { out } = runWithSignal([
|
||||||
|
'추론 텍스트 어쩌고 ',
|
||||||
|
'__LM_STUDIO_INTERNAL_LSEP',
|
||||||
|
'_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c',
|
||||||
|
'9e5f3a7b8c1d2e6a__',
|
||||||
|
'실제 답변',
|
||||||
|
]);
|
||||||
|
expect(out).toBe('실제 답변');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('START 변형이 오면 END 까지 통째로 숨긴다', () => {
|
||||||
|
const start = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_START_abc123def456__';
|
||||||
|
const { out } = runWithSignal(['답변 앞부분 ', start + '숨길 추론 ' + MARKER, '답변 뒷부분']);
|
||||||
|
expect(out).toBe('답변 앞부분 답변 뒷부분');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('마커 없는 일반 밑줄 텍스트는 그대로 통과 (오탐 없음)', () => {
|
||||||
|
const { out, replacedAt } = runWithSignal(['snake_case_variable 와 __init__ 메서드', ' 설명입니다']);
|
||||||
|
expect(out).toBe('snake_case_variable 와 __init__ 메서드 설명입니다');
|
||||||
|
expect(replacedAt).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v2.2.310] sanitizeAssistantContent — LSEP 최종 정리 (이중 방어)', () => {
|
||||||
|
const { sanitizeAssistantContent } = require('../src/lib/contextBuilders/outputSanitization');
|
||||||
|
const MARKER = '__LM_STUDIO_INTERNAL_LSEP_SYNTHETIC_REASONING_END_f4e9a8d2c6b14d0c9e5f3a7b8c1d2e6a__';
|
||||||
|
|
||||||
|
test('마커 앞 추론 전체 제거, 답변만 남긴다', () => {
|
||||||
|
const raw = 'User message 분석... Option 1... Final choice...' + MARKER + '잘 지내고 있습니다. 별일 없으셨나요?';
|
||||||
|
expect(sanitizeAssistantContent(raw)).toBe('잘 지내고 있습니다. 별일 없으셨나요?');
|
||||||
|
});
|
||||||
|
test('마커가 여러 번이면 마지막 마커 뒤만', () => {
|
||||||
|
const raw = '추론1' + MARKER + '중간' + MARKER + '최종 답변';
|
||||||
|
expect(sanitizeAssistantContent(raw)).toBe('최종 답변');
|
||||||
|
});
|
||||||
|
test('마커 없으면 기존 동작 그대로', () => {
|
||||||
|
expect(sanitizeAssistantContent('일반 답변입니다.')).toBe('일반 답변입니다.');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/**
|
||||||
|
* Standing Rules (Correction Loop ④) — 행동/스타일 교정의 세션 영속화. (v2.2.308)
|
||||||
|
*
|
||||||
|
* 배경: self-reflection 이 컨텍스트 창(단기 기억)에 갇혀 새 세션에서 증발하는 문제.
|
||||||
|
* 행동 지적 → 명령형 규칙 파일 영속화 → 매 턴 주입으로 구조적으로 해결한다.
|
||||||
|
*/
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as path from 'path';
|
||||||
|
import {
|
||||||
|
looksLikeBehaviorComplaint,
|
||||||
|
loadStandingRules,
|
||||||
|
saveStandingRules,
|
||||||
|
upsertStandingRule,
|
||||||
|
buildStandingRulesBlock,
|
||||||
|
isSameRule,
|
||||||
|
MAX_ACTIVE_STANDING_RULES,
|
||||||
|
STANDING_RULES_REL_PATH,
|
||||||
|
StandingRule,
|
||||||
|
} from '../src/intelligence/correctionLoop';
|
||||||
|
|
||||||
|
function tmpBrain(): string {
|
||||||
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'astra-sr-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('looksLikeBehaviorComplaint — 행동/스타일 지적 감지', () => {
|
||||||
|
test('반복 지적: "또 ~라고 썼네" (이번 이슈의 원 사례)', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('또 결론 수정이라고 썼네')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"하지 말라고 했잖아"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('결론 유지 문구 넣지 말라고 했잖아')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"몇 번을 말해"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('몇 번을 말해야 알아들어')).toBe(true);
|
||||||
|
});
|
||||||
|
test('"왜 자꾸 ~"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('왜 자꾸 영어로 답해')).toBe(true);
|
||||||
|
});
|
||||||
|
test('출력 형식 지시: "붙이지 마"', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('출처 표기를 답변 중간에 붙이지 마')).toBe(true);
|
||||||
|
});
|
||||||
|
test('일반 질문은 오탐하지 않음', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('오늘 주가 어때?')).toBe(false);
|
||||||
|
expect(looksLikeBehaviorComplaint('회의록 요약해줘')).toBe(false);
|
||||||
|
// "또"로 시작하지만 지적이 아닌 추가 요청
|
||||||
|
expect(looksLikeBehaviorComplaint('또 다른 방법도 알려줘')).toBe(false);
|
||||||
|
});
|
||||||
|
test('빈/초장문 입력 방어', () => {
|
||||||
|
expect(looksLikeBehaviorComplaint('')).toBe(false);
|
||||||
|
expect(looksLikeBehaviorComplaint('또 ' + 'x'.repeat(2000))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isSameRule — 재지적 판정 (토큰 자카드)', () => {
|
||||||
|
test('같은 규칙의 표현 변형은 동일 판정', () => {
|
||||||
|
expect(isSameRule(
|
||||||
|
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||||
|
'답변에 결론 수정 문구를 절대 붙이지 않는다',
|
||||||
|
)).toBe(true);
|
||||||
|
});
|
||||||
|
test('다른 규칙은 구분', () => {
|
||||||
|
expect(isSameRule(
|
||||||
|
'답변에 결론 수정 문구를 붙이지 않는다',
|
||||||
|
'모든 답변은 한국어로 작성한다',
|
||||||
|
)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('upsertStandingRule — 저장/중복/상한', () => {
|
||||||
|
test('신규 규칙 저장 → 파일 생성 + active', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
const rules = upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '또 결론 수정이라고 썼네');
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].status).toBe('active');
|
||||||
|
expect(rules[0].hits).toBe(1);
|
||||||
|
expect(fs.existsSync(path.join(brain, STANDING_RULES_REL_PATH))).toBe(true);
|
||||||
|
// 라운드트립
|
||||||
|
const loaded = loadStandingRules(brain);
|
||||||
|
expect(loaded[0].rule).toBe('결론 수정 라벨을 붙이지 않는다');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('같은 규칙 재지적 → hits 증가 (새 항목 추가 아님)', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
upsertStandingRule(brain, '결론 수정 라벨을 붙이지 않는다', '지적1');
|
||||||
|
const rules = upsertStandingRule(brain, '결론 수정 라벨을 절대 붙이지 않는다', '지적2');
|
||||||
|
expect(rules).toHaveLength(1);
|
||||||
|
expect(rules[0].hits).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('은퇴한 규칙이 재지적되면 부활', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
const retired: StandingRule[] = [{
|
||||||
|
id: 'sr-x', rule: '영어 단어를 남발하지 않는다', origin: 'o',
|
||||||
|
createdAt: '2026-01-01T00:00:00Z', lastHitAt: '2026-01-01T00:00:00Z', hits: 1, status: 'retired',
|
||||||
|
}];
|
||||||
|
saveStandingRules(brain, retired);
|
||||||
|
const rules = upsertStandingRule(brain, '영어 단어를 남발하지 않는다', '또 영어 남발이네');
|
||||||
|
expect(rules[0].status).toBe('active');
|
||||||
|
expect(rules[0].hits).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('상한 초과 시 hits 낮고 오래된 규칙부터 은퇴', () => {
|
||||||
|
const brain = tmpBrain();
|
||||||
|
// 서로 유사도 판정(isSameRule)에 걸리지 않는 실제로 구분되는 규칙 10개
|
||||||
|
const distinct = [
|
||||||
|
'답변은 항상 한국어로 작성한다',
|
||||||
|
'수치를 말할 때 근거 문서를 인용한다',
|
||||||
|
'표를 남용하지 않는다',
|
||||||
|
'이모지 사용을 최소화한다',
|
||||||
|
'결론부터 먼저 말한다',
|
||||||
|
'추측일 때는 추정임을 명시한다',
|
||||||
|
'링크는 마크다운 형식으로 쓴다',
|
||||||
|
'제목에 물음표를 넣지 않는다',
|
||||||
|
'인사말을 매번 반복하지 않는다',
|
||||||
|
'코드 예시에는 설명 주석을 단다',
|
||||||
|
];
|
||||||
|
distinct.forEach((r, i) => upsertStandingRule(brain, r, `origin${i}`, `2026-01-0${(i % 9) + 1}T00:00:00Z`));
|
||||||
|
expect(loadStandingRules(brain)).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||||
|
// 규칙 하나에 hits 보강 (은퇴 대상에서 멀어지게)
|
||||||
|
upsertStandingRule(brain, '이모지 사용을 최소화한다', 'again', '2026-02-01T00:00:00Z');
|
||||||
|
// 상한+1 번째 신규 규칙
|
||||||
|
const rules = upsertStandingRule(brain, '긴 목록 대신 요약 문단을 우선한다', 'new', '2026-03-01T00:00:00Z');
|
||||||
|
const active = rules.filter(r => r.status === 'active');
|
||||||
|
expect(active).toHaveLength(MAX_ACTIVE_STANDING_RULES);
|
||||||
|
// hits 2인 규칙은 살아남고, 방금 들어온 신규도 active
|
||||||
|
expect(active.some(r => r.rule.includes('이모지'))).toBe(true);
|
||||||
|
expect(active.some(r => r.rule.includes('요약 문단'))).toBe(true);
|
||||||
|
expect(rules.some(r => r.status === 'retired')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildStandingRulesBlock — 매 턴 주입 블록', () => {
|
||||||
|
test('active 규칙을 hits 내림차순으로 포함', () => {
|
||||||
|
const rules: StandingRule[] = [
|
||||||
|
{ id: 'a', rule: '규칙A', origin: '', createdAt: '', lastHitAt: '2026-01-01', hits: 1, status: 'active' },
|
||||||
|
{ id: 'b', rule: '규칙B', origin: '', createdAt: '', lastHitAt: '2026-01-02', hits: 3, status: 'active' },
|
||||||
|
{ id: 'c', rule: '규칙C', origin: '', createdAt: '', lastHitAt: '2026-01-03', hits: 1, status: 'retired' },
|
||||||
|
];
|
||||||
|
const block = buildStandingRulesBlock(rules);
|
||||||
|
expect(block).toContain('[상시 행동 규칙');
|
||||||
|
expect(block.indexOf('규칙B')).toBeLessThan(block.indexOf('규칙A')); // hits 순
|
||||||
|
expect(block).toContain('(지적 3회)');
|
||||||
|
expect(block).not.toContain('규칙C'); // retired 제외
|
||||||
|
});
|
||||||
|
test('규칙 없으면 빈 문자열 (프롬프트 오염 없음)', () => {
|
||||||
|
expect(buildStandingRulesBlock([])).toBe('');
|
||||||
|
expect(buildStandingRulesBlock([
|
||||||
|
{ id: 'c', rule: 'x', origin: '', createdAt: '', lastHitAt: '', hits: 1, status: 'retired' },
|
||||||
|
])).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -50,7 +50,8 @@ describe('pickWikiDir — 저장 폴더 우선순위', () => {
|
|||||||
|
|
||||||
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
|
test('[v2.2.304] ../ 상대경로로 두뇌 밖 형제 폴더도 지정 가능', () => {
|
||||||
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
|
const r = pickWikiDir('../../Premium/자료', '/Wiki/10_Wiki/Topics', null);
|
||||||
expect(r!.dir.replace(/\\/g, '/')).toBe('/Wiki/Premium/자료');
|
// Windows에서는 path.resolve가 드라이브 문자를 붙이므로(E:/Wiki/...) 끝부분만 검증
|
||||||
|
expect(r!.dir.replace(/\\/g, '/')).toMatch(/\/Wiki\/Premium\/자료$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
test('상대경로인데 두뇌도 없으면 다음 후보(워크스페이스)로', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user