import assert from 'node:assert/strict'; import test from 'node:test'; import { createExperienceService } from './experience-service.mjs'; // Minimal in-memory pool covering the SQL shapes experience-service issues: // INSERT, scope-filtered SELECT, and the use_count bump UPDATE. function createMockPool() { const rows = []; const query = async (sql, params = []) => { if (sql.includes('INSERT INTO h5_experience')) { const [ id, scope, kind, title, body, tagsJson, sourceSessionId, sourceUserId, problem, environmentJson, hypothesis, actionJson, result, confidence, evidenceJson, parentExperienceId, supersedesId, status, createdAt, updatedAt, ] = params; rows.push({ id, scope, kind, title, body, tags_json: tagsJson, source_session_id: sourceSessionId, source_user_id: sourceUserId, use_count: 0, problem, environment_json: environmentJson, hypothesis, action_json: actionJson, result, confidence, evidence_json: evidenceJson, parent_experience_id: parentExperienceId, supersedes_id: supersedesId, status, created_at: createdAt, updated_at: updatedAt, }); return [{ affectedRows: 1 }]; } if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) { const [scope] = params; const matched = rows .filter((r) => r.scope === scope && (sql.includes("status = 'active'") ? r.status === 'active' : true)) .sort((a, b) => b.updated_at - a.updated_at); return [matched.map((r) => ({ ...r }))]; } if (sql.includes('UPDATE h5_experience SET use_count')) { for (const id of params) { const row = rows.find((r) => r.id === id); if (row) row.use_count += 1; } return [{ affectedRows: params.length }]; } if (sql.includes("SET status = 'archived'")) { const [parentId, updatedAt, ...ids] = params; for (const id of ids) { const row = rows.find((r) => r.id === id); if (row) { row.status = 'archived'; row.parent_experience_id = parentId; row.updated_at = updatedAt; } } return [{ affectedRows: ids.length }]; } throw new Error(`unexpected SQL: ${sql}`); }; return { pool: { query }, rows }; } test('record persists an experience with normalized fields', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 1000 }); const saved = await svc.record({ title: 'Headscale 部署', body: '在 103 上用固定公网 IP 部署 headscale,注意防火墙放行 41641/udp。', tags: ['headscale', 'headscale', ' 网络 ', ''], }); assert.equal(rows.length, 1); assert.equal(saved.scope, 'global'); assert.equal(saved.kind, 'lesson'); assert.deepEqual(saved.tags, ['headscale', '网络']); assert.equal(saved.useCount, 0); assert.equal(saved.status, 'active'); }); test('record persists V1 structured fields', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 2000 }); const saved = await svc.record({ kind: 'task_outcome', title: 'SSE 断线恢复', body: 'Portal replay 需映射 Goose Last-Event-ID', problem: 'H5 SSE 断线后消息丢失', environment: { runtime: 'portal', components: ['goose', 'sse'] }, action: { executor: 'goose', steps: ['replay'] }, result: 'success', confidence: 0.85, evidence: { sources: [{ source_id: 'job:abc', source_type: 'agent_job' }] }, }); assert.equal(saved.kind, 'task_outcome'); assert.equal(saved.problem, 'H5 SSE 断线后消息丢失'); assert.deepEqual(saved.environment, { runtime: 'portal', components: ['goose', 'sse'] }); assert.equal(saved.result, 'success'); assert.equal(saved.confidence, 0.85); }); test('record rejects empty title or body', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool); await assert.rejects(() => svc.record({ title: '', body: 'x' }), /标题不能为空/); await assert.rejects(() => svc.record({ title: 'x', body: '' }), /内容不能为空/); }); test('search ranks keyword matches and ignores empty queries', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 1_000_000 }); await svc.record({ title: 'Headscale 部署', body: 'headscale 防火墙 udp' }); await svc.record({ title: '无关经验', body: '这条跟查询完全无关的内容' }); assert.deepEqual(await svc.search(' '), []); const hits = await svc.search('headscale 部署'); assert.equal(hits.length, 1); assert.equal(hits[0].title, 'Headscale 部署'); }); test('search matches problem and environment haystack', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 3000 }); await svc.record({ title: '通用标题', body: '正文不含关键词', problem: 'MemFuse recall 排序偏低', environment: { runtime: 'memory-v2-pgvector' }, }); const hits = await svc.search('memfuse recall'); assert.equal(hits.length, 1); assert.equal(hits[0].problem, 'MemFuse recall 排序偏低'); }); test('search excludes non-active rows', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 4000 }); await svc.record({ title: '已归档', body: 'archived keyword match', status: 'archived' }); const hits = await svc.search('archived keyword'); assert.equal(hits.length, 0); }); test('search applies recency decay so newer wins on equal keyword score', async () => { const { pool } = createMockPool(); let clock = 0; const svc = createExperienceService(pool, { now: () => clock, recencyHalfLifeMs: 1000, }); clock = 0; await svc.record({ title: '部署指南 A', body: 'deploy 部署 指南' }); clock = 10_000; await svc.record({ title: '部署指南 B', body: 'deploy 部署 指南' }); clock = 10_000; const hits = await svc.search('部署 deploy 指南', { limit: 2 }); assert.equal(hits[0].title, '部署指南 B'); }); test('search bumps use_count for returned rows', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 5 }); await svc.record({ title: 'Caddy 灰度', body: 'caddy weighted_round_robin 分流' }); await svc.search('caddy 灰度'); assert.equal(rows[0].use_count, 1); }); test('search boosts environment component matches', async () => { const { pool } = createMockPool(); const svc = createExperienceService(pool, { now: () => 10_000 }); await svc.record({ title: '通用标题', body: '正文', environment: { runtime: 'goosed', components: ['docker'] }, }); await svc.record({ title: '另一标题', body: '正文', problem: 'goosed docker 部署', }); const hits = await svc.search('goosed docker', { limit: 1 }); assert.equal(hits.length, 1); assert.equal(hits[0].title, '通用标题'); }); test('reflect aggregates repeated task outcomes into execution_pattern', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 9000 }); for (let i = 0; i < 3; i += 1) { await svc.record({ kind: 'task_outcome', title: `SSE 修复 ${i}`, body: `body ${i}`, problem: 'SSE connection instability', result: 'success', confidence: 0.8, }); } const result = await svc.reflect({ minGroupSize: 3 }); assert.equal(result.ok, true); assert.equal(result.created, 1); assert.equal(result.archived, 3); assert.equal(rows.filter((row) => row.kind === 'execution_pattern').length, 1); assert.equal(rows.filter((row) => row.status === 'archived').length, 3); }); test('reflect dryRun reports eligible groups without writing', async () => { const { pool, rows } = createMockPool(); const svc = createExperienceService(pool, { now: () => 11_000 }); for (let i = 0; i < 3; i += 1) { await svc.record({ kind: 'task_outcome', title: `Deploy ${i}`, body: `body ${i}`, problem: 'headscale deploy', result: 'success', }); } const preview = await svc.reflect({ minGroupSize: 3, dryRun: true }); assert.equal(preview.dryRun, true); assert.equal(preview.groupsEligible, 1); assert.equal(rows.filter((row) => row.kind === 'execution_pattern').length, 0); });