feat(brain-config): resolveStateDir + classifierContext config-seam (Фаза 1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -67,3 +67,11 @@ export function loadConfig(root = '.', fsImpl = fsDefault) {
|
||||
}
|
||||
return resolveConfig(parseBrainConfig(md));
|
||||
}
|
||||
|
||||
/** fail-safe резолвер state_dir (§5.1): непустая строка → как есть; иначе → безопасный дефолт
|
||||
* .claude/brain-state + warnedFallback (НЕ тихий no-op — wiring издаёт warn и пишет в fallback). */
|
||||
export function resolveStateDir(value) {
|
||||
const v = typeof value === 'string' ? value.trim() : '';
|
||||
if (v.length > 0) return { stateDir: v, warnedFallback: false };
|
||||
return { stateDir: '.claude/brain-state', warnedFallback: true };
|
||||
}
|
||||
|
||||
@@ -77,3 +77,19 @@ describe('resolveConfig protected_paths (Task 4 security, §D1/§D2)', () => {
|
||||
.toEqual(['secrets/keys']);
|
||||
});
|
||||
});
|
||||
|
||||
import { resolveStateDir } from './brain-config.mjs';
|
||||
|
||||
describe('resolveStateDir fail-safe (§D2)', () => {
|
||||
it('непустая строка → как есть, без fallback', () => {
|
||||
expect(resolveStateDir('docs/observer')).toEqual({ stateDir: 'docs/observer', warnedFallback: false });
|
||||
});
|
||||
it('пусто / пробелы → безопасный дефолт + warnedFallback', () => {
|
||||
expect(resolveStateDir('')).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
|
||||
expect(resolveStateDir(' ')).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
|
||||
});
|
||||
it('не-строка → безопасный дефолт + warnedFallback (не падает)', () => {
|
||||
expect(resolveStateDir(null)).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
|
||||
expect(resolveStateDir(undefined)).toEqual({ stateDir: '.claude/brain-state', warnedFallback: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ const REQUIRED_REVIEW_FIELDS = [
|
||||
* The split is still applied so the moment either condition flips, caching
|
||||
* activates with zero further code changes.
|
||||
*/
|
||||
export function buildReviewPromptStructured(episode) {
|
||||
export function buildReviewPromptStructured(episode, { classifierContext = 'Лидерра' } = {}) {
|
||||
const v = Number(episode?.schema_version) || 0;
|
||||
const cues = [
|
||||
'node_quality: correct | wrong_node | overkill | underkill | disputable',
|
||||
@@ -66,7 +66,7 @@ export function buildReviewPromptStructured(episode) {
|
||||
}
|
||||
|
||||
const system = [
|
||||
'You are the independent reviewer of routing decisions for the Лидерра brain-governance experiment.',
|
||||
`You are the independent reviewer of routing decisions for the ${classifierContext} brain-governance experiment.`,
|
||||
'Return ONLY a JSON object with the 8 fields below. No prose, no code fences.',
|
||||
'',
|
||||
'Fields:',
|
||||
|
||||
@@ -114,3 +114,13 @@ describe('parseReview — 8-dim review schema (spec §4.6)', () => {
|
||||
expect(r?.reviewer_error).toBe('malformed episode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReviewPromptStructured classifierContext (config-seam §D1)', () => {
|
||||
it('дефолт → Лидерра', () => {
|
||||
expect(buildReviewPromptStructured({ schema_version: 4 }).system).toContain('Лидерра');
|
||||
});
|
||||
it('classifierContext инъектируется', () => {
|
||||
expect(buildReviewPromptStructured({ schema_version: 4 }, { classifierContext: 'ProjZ' }).system)
|
||||
.toContain('ProjZ');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -288,12 +288,12 @@ export function buildClassifierPrompt(userPrompt, registry, { enrichment = true
|
||||
* Cache-eligibility: Sonnet requires ≥1024 tokens in the cached block.
|
||||
* Active node registry (~85 nodes × ~100 tokens) easily clears this.
|
||||
*/
|
||||
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true } = {}) {
|
||||
export function buildClassifierPromptStructured(userPrompt, registry, { enrichment = true, classifierContext = 'CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3)' } = {}) {
|
||||
const pamyatka = enrichment ? `\n\n${PAMYATKA}\n` : '\n';
|
||||
const nodesBlock = buildNodesBlock(registry);
|
||||
const chainsBlock = buildChainsBlock(registry);
|
||||
|
||||
const system = `Ты классификатор задач для CRM-проекта «Лидерра» (Laravel 13 + Vue 3 + Vuetify 3).
|
||||
const system = `Ты классификатор задач для ${classifierContext}.
|
||||
|
||||
ОБЯЗАТЕЛЬНЫЕ выходные правила:
|
||||
0. task_type — ОБЯЗАТЕЛЬНОЕ поле (ровно так, snake_case), одно из: feature, planning, bugfix, refactor, cleanup, marketing, security, analysis, monitoring, memory-sync, question, unknown.
|
||||
@@ -557,7 +557,10 @@ export async function callAnthropicAPI(promptOrMessages, {
|
||||
if (onUsage && data.usage) {
|
||||
try { onUsage(data.usage); } catch { /* swallow callback errors */ }
|
||||
}
|
||||
return data.content?.[0]?.text || '';
|
||||
const _blocks = Array.isArray(data.content) ? data.content : [];
|
||||
const _textBlock = _blocks.find((b) => b && b.type === 'text' && typeof b.text === 'string')
|
||||
|| _blocks.find((b) => b && typeof b.text === 'string');
|
||||
return (_textBlock && _textBlock.text) || '';
|
||||
}
|
||||
// Retry on 5xx and 429; fail fast on 4xx (auth/quota/bad request — retry won't help).
|
||||
if (r.status >= 500 || r.status === 429) {
|
||||
|
||||
@@ -654,3 +654,14 @@ describe('buildClassifierPromptStructured — requires task_type field (rasinhro
|
||||
expect(system).toContain('"task_type"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildClassifierPromptStructured classifierContext (config-seam §D1)', () => {
|
||||
const reg = { nodes: [], chains: {} };
|
||||
it('дефолт → текущая строка «Лидерра»', () => {
|
||||
expect(buildClassifierPromptStructured('p', reg).system).toContain('«Лидерра»');
|
||||
});
|
||||
it('classifierContext инъектируется', () => {
|
||||
expect(buildClassifierPromptStructured('p', reg, { classifierContext: 'ТестПроект XYZ' }).system)
|
||||
.toContain('ТестПроект XYZ');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user