b923e54eff
Extend h5_experience with structured fields, wire mindspace-agent-runner and agent-run-gateway to persist task_outcome records with provenance, and add local migration and verification scripts. Co-authored-by: Cursor <cursoragent@cursor.com>
179 lines
6.2 KiB
JavaScript
179 lines
6.2 KiB
JavaScript
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);
|
|
});
|