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 && r.status === 'active') .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 }]; } 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); });