Implement Experience V1 reflect, retrieval boost, and product metrics.
Add rule-based reflect() to aggregate task outcomes, environment-aware search scoring, experience_saved/recalled events, and structured injection blocks per the V1 schema. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
mapExperienceRow,
|
||||||
|
normalizeExperienceRecordInput,
|
||||||
|
parseExperienceJson,
|
||||||
|
} from './experience-schema.mjs';
|
||||||
|
|
||||||
|
export const DEFAULT_REFLECT_MIN_GROUP_SIZE = 3;
|
||||||
|
|
||||||
|
export function problemFingerprint(problem) {
|
||||||
|
const text = String(problem ?? '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, ' ');
|
||||||
|
if (!text) return '';
|
||||||
|
return text.slice(0, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function environmentMatchBoost(row, terms) {
|
||||||
|
if (!terms.length) return 0;
|
||||||
|
const environment = parseExperienceJson(row.environment_json ?? row.environment, null);
|
||||||
|
if (!environment) return 0;
|
||||||
|
const haystack = [
|
||||||
|
environment.runtime,
|
||||||
|
...(Array.isArray(environment.components) ? environment.components : []),
|
||||||
|
environment.region,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase();
|
||||||
|
if (!haystack) return 0;
|
||||||
|
let boost = 0;
|
||||||
|
for (const term of terms) {
|
||||||
|
if (haystack.includes(term)) boost += 0.5;
|
||||||
|
}
|
||||||
|
return boost;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scoreExperienceRow(row, terms, nowMs, halfLifeMs) {
|
||||||
|
const haystack = [
|
||||||
|
row.title,
|
||||||
|
row.body,
|
||||||
|
row.problem,
|
||||||
|
row.environment_json ? JSON.stringify(parseExperienceJson(row.environment_json, {})) : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n')
|
||||||
|
.toLowerCase();
|
||||||
|
|
||||||
|
let keywordScore = 0;
|
||||||
|
for (const term of terms) {
|
||||||
|
if (haystack.includes(term)) keywordScore += 1;
|
||||||
|
}
|
||||||
|
keywordScore += environmentMatchBoost(row, terms);
|
||||||
|
if (keywordScore <= 0) return { row, score: 0 };
|
||||||
|
|
||||||
|
const ageMs = Math.max(0, nowMs - Number(row.updated_at ?? row.updatedAt ?? 0));
|
||||||
|
const recency = Math.pow(0.5, ageMs / halfLifeMs);
|
||||||
|
return { row, score: keywordScore * recency };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTaskOutcomesByProblem(rows, minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE) {
|
||||||
|
const buckets = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (String(row.kind ?? '').toLowerCase() !== 'task_outcome') continue;
|
||||||
|
if (row.parent_experience_id ?? row.parentExperienceId) continue;
|
||||||
|
if (String(row.status ?? 'active').toLowerCase() !== 'active') continue;
|
||||||
|
const fp = problemFingerprint(row.problem || row.title);
|
||||||
|
if (!fp) continue;
|
||||||
|
if (!buckets.has(fp)) buckets.set(fp, []);
|
||||||
|
buckets.get(fp).push(row);
|
||||||
|
}
|
||||||
|
return [...buckets.values()].filter((group) => group.length >= minGroupSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findExistingExecutionPattern(rows, fingerprint) {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (String(row.kind ?? '').toLowerCase() !== 'execution_pattern') continue;
|
||||||
|
if (String(row.status ?? 'active').toLowerCase() !== 'active') continue;
|
||||||
|
const fp = problemFingerprint(row.problem || row.title);
|
||||||
|
if (fp === fingerprint) return row;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExecutionPatternAggregate(rows) {
|
||||||
|
const sorted = [...rows].sort(
|
||||||
|
(a, b) => Number(b.updated_at ?? b.updatedAt ?? 0) - Number(a.updated_at ?? a.updatedAt ?? 0),
|
||||||
|
);
|
||||||
|
const problem = sorted[0].problem || sorted[0].title;
|
||||||
|
const results = sorted.map((row) => row.result).filter(Boolean);
|
||||||
|
const successCount = results.filter((value) => value === 'success').length;
|
||||||
|
const result =
|
||||||
|
successCount >= results.length / 2
|
||||||
|
? 'success'
|
||||||
|
: results.every((value) => value === 'failure')
|
||||||
|
? 'failure'
|
||||||
|
: 'partial';
|
||||||
|
|
||||||
|
const confidences = sorted
|
||||||
|
.map((row) => (row.confidence == null ? null : Number(row.confidence)))
|
||||||
|
.filter((value) => Number.isFinite(value));
|
||||||
|
const confidence =
|
||||||
|
confidences.length > 0
|
||||||
|
? Number((confidences.reduce((sum, value) => sum + value, 0) / confidences.length).toFixed(3))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const components = new Set();
|
||||||
|
let runtime = null;
|
||||||
|
const steps = new Set();
|
||||||
|
let executor = null;
|
||||||
|
const sources = [];
|
||||||
|
const seenSourceIds = new Set();
|
||||||
|
|
||||||
|
for (const row of sorted) {
|
||||||
|
const environment = parseExperienceJson(row.environment_json ?? row.environment, null);
|
||||||
|
if (environment?.runtime) runtime = environment.runtime;
|
||||||
|
for (const component of environment?.components ?? []) {
|
||||||
|
if (component) components.add(String(component));
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = parseExperienceJson(row.action_json ?? row.action, null);
|
||||||
|
if (action?.executor) executor = action.executor;
|
||||||
|
for (const step of action?.steps ?? []) {
|
||||||
|
if (step) steps.add(String(step));
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidence = parseExperienceJson(row.evidence_json ?? row.evidence, null);
|
||||||
|
for (const source of evidence?.sources ?? []) {
|
||||||
|
const sourceId = String(source?.source_id ?? '').trim();
|
||||||
|
if (sourceId && seenSourceIds.has(sourceId)) continue;
|
||||||
|
if (sourceId) seenSourceIds.add(sourceId);
|
||||||
|
sources.push(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const bodyLines = sorted.slice(0, 5).map((row) => `- ${row.title}: ${row.body}`);
|
||||||
|
if (sorted.length > 5) {
|
||||||
|
bodyLines.push(`- … 另有 ${sorted.length - 5} 条同类任务记录`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizeExperienceRecordInput({
|
||||||
|
kind: 'execution_pattern',
|
||||||
|
title: String(problem).slice(0, 200),
|
||||||
|
body: `聚合 ${sorted.length} 次同类任务经验:\n${bodyLines.join('\n')}`,
|
||||||
|
problem,
|
||||||
|
result,
|
||||||
|
confidence,
|
||||||
|
environment: {
|
||||||
|
runtime,
|
||||||
|
components: [...components],
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
executor,
|
||||||
|
steps: [...steps],
|
||||||
|
},
|
||||||
|
evidence: {
|
||||||
|
sources,
|
||||||
|
provenance: {
|
||||||
|
aggregated_from: sorted.map((row) => row.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tags: ['execution_pattern', 'reflect'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planExperienceReflection(rows, { minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE } = {}) {
|
||||||
|
const activeRows = rows.filter((row) => String(row.status ?? 'active').toLowerCase() === 'active');
|
||||||
|
const groups = groupTaskOutcomesByProblem(activeRows, minGroupSize);
|
||||||
|
const plans = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
const fingerprint = problemFingerprint(group[0].problem || group[0].title);
|
||||||
|
if (!fingerprint) continue;
|
||||||
|
if (findExistingExecutionPattern(activeRows, fingerprint)) continue;
|
||||||
|
plans.push({
|
||||||
|
fingerprint,
|
||||||
|
sourceIds: group.map((row) => row.id),
|
||||||
|
payload: buildExecutionPatternAggregate(group),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapReflectResult(savedRow) {
|
||||||
|
return mapExperienceRow(savedRow);
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
import {
|
||||||
|
buildExecutionPatternAggregate,
|
||||||
|
environmentMatchBoost,
|
||||||
|
groupTaskOutcomesByProblem,
|
||||||
|
planExperienceReflection,
|
||||||
|
problemFingerprint,
|
||||||
|
} from './experience-reflect.mjs';
|
||||||
|
|
||||||
|
test('problemFingerprint normalizes punctuation and spacing', () => {
|
||||||
|
assert.equal(problemFingerprint(' SSE connection!!! '), 'sse connection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('groupTaskOutcomesByProblem groups active task outcomes by problem', () => {
|
||||||
|
const groups = groupTaskOutcomesByProblem([
|
||||||
|
{ id: '1', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'A' },
|
||||||
|
{ id: '2', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'B' },
|
||||||
|
{ id: '3', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'C' },
|
||||||
|
{ id: '4', kind: 'task_outcome', status: 'active', problem: 'Other', title: 'D' },
|
||||||
|
{ id: '5', kind: 'lesson', status: 'active', problem: 'SSE 断线', title: 'E' },
|
||||||
|
], 3);
|
||||||
|
assert.equal(groups.length, 1);
|
||||||
|
assert.equal(groups[0].length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('environmentMatchBoost rewards component matches', () => {
|
||||||
|
const boost = environmentMatchBoost(
|
||||||
|
{ environment_json: JSON.stringify({ runtime: 'goosed', components: ['docker', 'postgres'] }) },
|
||||||
|
['goosed', 'docker'],
|
||||||
|
);
|
||||||
|
assert.equal(boost, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildExecutionPatternAggregate merges structured fields', () => {
|
||||||
|
const payload = buildExecutionPatternAggregate([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
title: 'SSE 修复',
|
||||||
|
body: '改 server.rs',
|
||||||
|
problem: 'SSE connection instability',
|
||||||
|
result: 'success',
|
||||||
|
confidence: 0.8,
|
||||||
|
updated_at: 100,
|
||||||
|
environment_json: JSON.stringify({ runtime: 'goosed', components: ['docker'] }),
|
||||||
|
action_json: JSON.stringify({ executor: 'goose', steps: ['edit server.rs'] }),
|
||||||
|
evidence_json: JSON.stringify({ sources: [{ source_id: 'run:1' }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
title: 'SSE 再修',
|
||||||
|
body: '补 replay 映射',
|
||||||
|
problem: 'SSE connection instability',
|
||||||
|
result: 'success',
|
||||||
|
confidence: 0.9,
|
||||||
|
updated_at: 200,
|
||||||
|
environment_json: JSON.stringify({ components: ['portal'] }),
|
||||||
|
action_json: JSON.stringify({ executor: 'goose', steps: ['replay mapping'] }),
|
||||||
|
evidence_json: JSON.stringify({ sources: [{ source_id: 'run:2' }] }),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
title: 'SSE 第三次',
|
||||||
|
body: '终态恢复',
|
||||||
|
problem: 'SSE connection instability',
|
||||||
|
result: 'partial',
|
||||||
|
confidence: 0.7,
|
||||||
|
updated_at: 300,
|
||||||
|
environment_json: JSON.stringify({ components: ['sse'] }),
|
||||||
|
action_json: JSON.stringify({ executor: 'goose', steps: ['finish sync'] }),
|
||||||
|
evidence_json: JSON.stringify({ sources: [{ source_id: 'run:1' }] }),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
assert.equal(payload.kind, 'execution_pattern');
|
||||||
|
assert.equal(payload.result, 'success');
|
||||||
|
assert.equal(payload.confidence, 0.8);
|
||||||
|
assert.deepEqual(payload.environment.components.sort(), ['docker', 'portal', 'sse']);
|
||||||
|
assert.equal(payload.evidence.sources.length, 2);
|
||||||
|
assert.deepEqual(payload.evidence.provenance.aggregated_from, ['3', '2', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('planExperienceReflection skips groups with existing execution_pattern', () => {
|
||||||
|
const plans = planExperienceReflection([
|
||||||
|
{ id: '1', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'A' },
|
||||||
|
{ id: '2', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'B' },
|
||||||
|
{ id: '3', kind: 'task_outcome', status: 'active', problem: 'SSE 断线', title: 'C' },
|
||||||
|
{ id: 'p1', kind: 'execution_pattern', status: 'active', problem: 'SSE 断线', title: 'pattern' },
|
||||||
|
], { minGroupSize: 3 });
|
||||||
|
assert.equal(plans.length, 0);
|
||||||
|
});
|
||||||
+31
-7
@@ -137,6 +137,24 @@ export function experienceSearchHaystack(row) {
|
|||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatExperienceMonth(timestampMs) {
|
||||||
|
const ts = Number(timestampMs);
|
||||||
|
if (!Number.isFinite(ts) || ts <= 0) return 'unknown';
|
||||||
|
const date = new Date(ts);
|
||||||
|
const year = date.getUTCFullYear();
|
||||||
|
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatExperienceActionSummary(action) {
|
||||||
|
if (!action || typeof action !== 'object') return '';
|
||||||
|
const steps = Array.isArray(action.steps) ? action.steps.filter(Boolean) : [];
|
||||||
|
if (steps.length > 0) return steps.slice(0, 3).join(' → ');
|
||||||
|
const artifacts = Array.isArray(action.artifacts) ? action.artifacts.filter(Boolean) : [];
|
||||||
|
if (artifacts.length > 0) return `artifacts: ${artifacts.slice(0, 3).join(', ')}`;
|
||||||
|
return action.executor ? String(action.executor) : '';
|
||||||
|
}
|
||||||
|
|
||||||
export function formatExperienceInjectionBlock(hits = []) {
|
export function formatExperienceInjectionBlock(hits = []) {
|
||||||
if (!Array.isArray(hits) || hits.length === 0) return '';
|
if (!Array.isArray(hits) || hits.length === 0) return '';
|
||||||
const lines = hits.map((hit) => formatExperienceInjectionLine(hit));
|
const lines = hits.map((hit) => formatExperienceInjectionLine(hit));
|
||||||
@@ -144,19 +162,25 @@ export function formatExperienceInjectionBlock(hits = []) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatExperienceInjectionLine(hit) {
|
export function formatExperienceInjectionLine(hit) {
|
||||||
const parts = [`- ${hit.title}: ${hit.body}`];
|
const month = formatExperienceMonth(hit.createdAt);
|
||||||
if (hit.problem) parts.push(` 问题: ${hit.problem}`);
|
const headline = hit.problem || hit.title;
|
||||||
if (hit.result) {
|
const resultPart = hit.result
|
||||||
const conf = hit.confidence == null ? '' : `, conf=${hit.confidence}`;
|
? ` (${hit.result}${hit.confidence == null ? '' : `, conf=${hit.confidence}`})`
|
||||||
parts.push(` 结果: ${hit.result}${conf}`);
|
: '';
|
||||||
}
|
const parts = [`- [${month}] ${headline}${resultPart}`];
|
||||||
|
|
||||||
const runtime = hit.environment?.runtime;
|
const runtime = hit.environment?.runtime;
|
||||||
const components = Array.isArray(hit.environment?.components)
|
const components = Array.isArray(hit.environment?.components)
|
||||||
? hit.environment.components.filter(Boolean).join(' + ')
|
? hit.environment.components.filter(Boolean).join(' + ')
|
||||||
: '';
|
: '';
|
||||||
if (runtime || components) {
|
if (runtime || components) {
|
||||||
parts.push(` 环境: ${[runtime, components].filter(Boolean).join(' · ')}`);
|
parts.push(` 环境: ${[runtime, components].filter(Boolean).join(' + ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actionSummary = formatExperienceActionSummary(hit.action);
|
||||||
|
if (actionSummary) parts.push(` 处理: ${actionSummary}`);
|
||||||
|
|
||||||
|
parts.push(` 摘要: ${hit.body}`);
|
||||||
return parts.join('\n');
|
return parts.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,22 +66,25 @@ test('formatExperienceInjectionLine includes structured hints', () => {
|
|||||||
problem: '公网访问失败',
|
problem: '公网访问失败',
|
||||||
result: 'success',
|
result: 'success',
|
||||||
confidence: 0.9,
|
confidence: 0.9,
|
||||||
|
createdAt: Date.parse('2026-09-15T00:00:00Z'),
|
||||||
environment: { runtime: '103', components: ['caddy', 'portal'] },
|
environment: { runtime: '103', components: ['caddy', 'portal'] },
|
||||||
|
action: { executor: 'goose', steps: ['edit server.rs', 'reload caddy'] },
|
||||||
});
|
});
|
||||||
assert.match(line, /部署: 摘要/);
|
assert.match(line, /\[2026-09\] 公网访问失败 \(success, conf=0.9\)/);
|
||||||
assert.match(line, /问题: 公网访问失败/);
|
assert.match(line, /环境: 103 \+ caddy \+ portal/);
|
||||||
assert.match(line, /结果: success, conf=0.9/);
|
assert.match(line, /处理: edit server.rs → reload caddy/);
|
||||||
assert.match(line, /环境: 103 · caddy \+ portal/);
|
assert.match(line, /摘要: 摘要/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formatExperienceInjectionBlock wraps lines with header', () => {
|
test('formatExperienceInjectionBlock wraps lines with header', () => {
|
||||||
const block = formatExperienceInjectionBlock([
|
const block = formatExperienceInjectionBlock([
|
||||||
{ title: 'A', body: 'one' },
|
{ title: 'A', body: 'one', createdAt: Date.parse('2026-01-01T00:00:00Z') },
|
||||||
{ title: 'B', body: 'two' },
|
{ title: 'B', body: 'two', createdAt: Date.parse('2026-02-01T00:00:00Z') },
|
||||||
]);
|
]);
|
||||||
assert.match(block, /^# 相关经验/);
|
assert.match(block, /^# 相关经验/);
|
||||||
assert.match(block, /- A: one/);
|
assert.match(block, /\[2026-01\] A/);
|
||||||
assert.match(block, /- B: two/);
|
assert.match(block, /摘要: one/);
|
||||||
|
assert.match(block, /\[2026-02\] B/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('buildMindspaceJobExperiencePayload maps failed jobs to failure result', () => {
|
test('buildMindspaceJobExperiencePayload maps failed jobs to failure result', () => {
|
||||||
|
|||||||
+130
-17
@@ -1,11 +1,24 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
experienceSearchHaystack,
|
|
||||||
mapExperienceRow,
|
mapExperienceRow,
|
||||||
normalizeExperienceRecordInput,
|
normalizeExperienceRecordInput,
|
||||||
serializeExperienceJson,
|
|
||||||
} from './experience-schema.mjs';
|
} from './experience-schema.mjs';
|
||||||
|
import {
|
||||||
|
DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
||||||
|
planExperienceReflection,
|
||||||
|
scoreExperienceRow,
|
||||||
|
} from './experience-reflect.mjs';
|
||||||
|
import {
|
||||||
|
MEMORY_V2_PRODUCT_EVENT_TYPES,
|
||||||
|
recordMemoryV2ProductEvent,
|
||||||
|
} from './memory-v2-product-events.mjs';
|
||||||
|
|
||||||
|
const PG_EXPERIENCE_SELECT = `SELECT id, scope, kind, title, body, tags, source_session_id,
|
||||||
|
source_user_id, use_count, problem, environment_json, hypothesis,
|
||||||
|
action_json, result, confidence, evidence_json, parent_experience_id,
|
||||||
|
supersedes_id, status, created_at, updated_at
|
||||||
|
FROM h5_experience`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
|
* PostgreSQL + pgvector backed experience store (etat C, polyglot mode).
|
||||||
@@ -27,6 +40,7 @@ import {
|
|||||||
* @param {number} [options.embeddingDim=1536] - vector dimension (must match embed)
|
* @param {number} [options.embeddingDim=1536] - vector dimension (must match embed)
|
||||||
* @param {() => number} [options.now]
|
* @param {() => number} [options.now]
|
||||||
* @param {number} [options.recencyHalfLifeMs]
|
* @param {number} [options.recencyHalfLifeMs]
|
||||||
|
* @param {import('mysql2/promise').Pool} [options.productEventsPool] - MySQL pool for product events
|
||||||
*/
|
*/
|
||||||
export async function createPgExperienceService(options = {}) {
|
export async function createPgExperienceService(options = {}) {
|
||||||
const connectionString = options.connectionString;
|
const connectionString = options.connectionString;
|
||||||
@@ -34,6 +48,7 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
throw new Error('createPgExperienceService 需要 connectionString(建议用 EXPERIENCE_PG_URL)');
|
throw new Error('createPgExperienceService 需要 connectionString(建议用 EXPERIENCE_PG_URL)');
|
||||||
}
|
}
|
||||||
const now = options.now ?? (() => Date.now());
|
const now = options.now ?? (() => Date.now());
|
||||||
|
const productEventsPool = options.productEventsPool ?? null;
|
||||||
const embed = options.embed ?? null;
|
const embed = options.embed ?? null;
|
||||||
const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536));
|
const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536));
|
||||||
const halfLifeMs = Math.max(
|
const halfLifeMs = Math.max(
|
||||||
@@ -110,6 +125,45 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitExperienceSaved(saved, input) {
|
||||||
|
if (!productEventsPool?.query) return;
|
||||||
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_SAVED,
|
||||||
|
userId: saved.sourceUserId,
|
||||||
|
sessionId: saved.sourceSessionId,
|
||||||
|
data: {
|
||||||
|
experienceId: saved.id,
|
||||||
|
kind: saved.kind,
|
||||||
|
scope: saved.scope,
|
||||||
|
result: saved.result ?? null,
|
||||||
|
source: input?.source ?? null,
|
||||||
|
},
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitExperienceRecalled({ query, hits, scope }) {
|
||||||
|
if (!productEventsPool?.query || !hits.length) return;
|
||||||
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_RECALLED,
|
||||||
|
userId: hits[0]?.sourceUserId ?? null,
|
||||||
|
data: {
|
||||||
|
query: String(query ?? '').slice(0, 500),
|
||||||
|
scope,
|
||||||
|
hitCount: hits.length,
|
||||||
|
experienceIds: hits.map((hit) => hit.id),
|
||||||
|
kinds: hits.map((hit) => hit.kind),
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rankRows(rows, queryTerms, nowMs) {
|
||||||
|
return rows
|
||||||
|
.map((row) => scoreExperienceRow(row, queryTerms, nowMs, halfLifeMs))
|
||||||
|
.filter((entry) => entry.score > 0)
|
||||||
|
.sort((a, b) => b.score - a.score);
|
||||||
|
}
|
||||||
|
|
||||||
async function record(input) {
|
async function record(input) {
|
||||||
const normalized = normalizeExperienceRecordInput(input);
|
const normalized = normalizeExperienceRecordInput(input);
|
||||||
if (!normalized.title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
if (!normalized.title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
||||||
@@ -160,7 +214,7 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
ts,
|
ts,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
return rowToExperience({
|
const saved = rowToExperience({
|
||||||
id,
|
id,
|
||||||
scope: normalized.scope,
|
scope: normalized.scope,
|
||||||
kind: normalized.kind,
|
kind: normalized.kind,
|
||||||
@@ -183,6 +237,8 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
created_at: ts,
|
created_at: ts,
|
||||||
updated_at: ts,
|
updated_at: ts,
|
||||||
});
|
});
|
||||||
|
void emitExperienceSaved(saved, input);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search(query, { scope = 'global', limit = 5 } = {}) {
|
async function search(query, { scope = 'global', limit = 5 } = {}) {
|
||||||
@@ -208,7 +264,9 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
);
|
);
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
await bumpUseCount(rows.map((r) => r.id));
|
await bumpUseCount(rows.map((r) => r.id));
|
||||||
return rows.map(rowToExperience);
|
const hits = rows.map(rowToExperience);
|
||||||
|
void emitExperienceRecalled({ query: text, hits, scope });
|
||||||
|
return hits;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -216,24 +274,25 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keyword fallback: ILIKE any term, ordered by recency.
|
// Keyword fallback: fetch candidate rows, rank with environment/problem boost.
|
||||||
const terms = tokenize(text);
|
const terms = tokenize(text);
|
||||||
if (terms.length === 0) return [];
|
if (terms.length === 0) return [];
|
||||||
const likeClauses = terms.map((_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2})`);
|
const likeClauses = terms.map(
|
||||||
const params = [scope, ...terms.map((t) => `%${t}%`), max];
|
(_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2} OR problem ILIKE $${i + 2} OR environment_json::text ILIKE $${i + 2})`,
|
||||||
|
);
|
||||||
|
const params = [scope, ...terms.map((t) => `%${t}%`)];
|
||||||
const { rows } = await pool.query(
|
const { rows } = await pool.query(
|
||||||
`SELECT id, scope, kind, title, body, tags, source_session_id,
|
`${PG_EXPERIENCE_SELECT}
|
||||||
source_user_id, use_count, problem, environment_json, hypothesis,
|
|
||||||
action_json, result, confidence, evidence_json, parent_experience_id,
|
|
||||||
supersedes_id, status, created_at, updated_at
|
|
||||||
FROM h5_experience
|
|
||||||
WHERE scope = $1 AND status = 'active' AND (${likeClauses.join(' OR ')})
|
WHERE scope = $1 AND status = 'active' AND (${likeClauses.join(' OR ')})
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT $${terms.length + 2}`,
|
LIMIT 500`,
|
||||||
params,
|
params,
|
||||||
);
|
);
|
||||||
if (rows.length > 0) await bumpUseCount(rows.map((r) => r.id));
|
const ranked = rankRows(rows, terms, now()).slice(0, max);
|
||||||
return rows.map(rowToExperience);
|
if (ranked.length > 0) await bumpUseCount(ranked.map((entry) => entry.row.id));
|
||||||
|
const hits = ranked.map((entry) => rowToExperience(entry.row));
|
||||||
|
void emitExperienceRecalled({ query: text, hits, scope });
|
||||||
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function bumpUseCount(ids) {
|
async function bumpUseCount(ids) {
|
||||||
@@ -255,8 +314,62 @@ export async function createPgExperienceService(options = {}) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reflect() {
|
async function reflect({
|
||||||
return { ok: false, reason: 'not_implemented' };
|
scope = 'global',
|
||||||
|
minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
||||||
|
dryRun = false,
|
||||||
|
} = {}) {
|
||||||
|
const { rows } = await pool.query(
|
||||||
|
`${PG_EXPERIENCE_SELECT}
|
||||||
|
WHERE scope = $1
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 2000`,
|
||||||
|
[scope],
|
||||||
|
);
|
||||||
|
const plans = planExperienceReflection(rows, { minGroupSize });
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dryRun: true,
|
||||||
|
scope,
|
||||||
|
groupsEligible: plans.length,
|
||||||
|
wouldArchive: plans.reduce((sum, plan) => sum + plan.sourceIds.length, 0),
|
||||||
|
previews: plans.map((plan) => ({
|
||||||
|
fingerprint: plan.fingerprint,
|
||||||
|
sourceCount: plan.sourceIds.length,
|
||||||
|
title: plan.payload.title,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let archived = 0;
|
||||||
|
const patterns = [];
|
||||||
|
for (const plan of plans) {
|
||||||
|
const saved = await record({
|
||||||
|
...plan.payload,
|
||||||
|
scope,
|
||||||
|
source: 'reflect',
|
||||||
|
});
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE h5_experience
|
||||||
|
SET status = 'archived', parent_experience_id = $1::uuid, updated_at = $2
|
||||||
|
WHERE id = ANY($3::uuid[])`,
|
||||||
|
[saved.id, now(), plan.sourceIds],
|
||||||
|
);
|
||||||
|
created += 1;
|
||||||
|
archived += plan.sourceIds.length;
|
||||||
|
patterns.push(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dryRun: false,
|
||||||
|
scope,
|
||||||
|
created,
|
||||||
|
archived,
|
||||||
|
patterns,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function close() {
|
async function close() {
|
||||||
|
|||||||
+116
-22
@@ -6,6 +6,21 @@ import {
|
|||||||
normalizeExperienceRecordInput,
|
normalizeExperienceRecordInput,
|
||||||
serializeExperienceJson,
|
serializeExperienceJson,
|
||||||
} from './experience-schema.mjs';
|
} from './experience-schema.mjs';
|
||||||
|
import {
|
||||||
|
DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
||||||
|
planExperienceReflection,
|
||||||
|
scoreExperienceRow,
|
||||||
|
} from './experience-reflect.mjs';
|
||||||
|
import {
|
||||||
|
MEMORY_V2_PRODUCT_EVENT_TYPES,
|
||||||
|
recordMemoryV2ProductEvent,
|
||||||
|
} from './memory-v2-product-events.mjs';
|
||||||
|
|
||||||
|
const EXPERIENCE_SELECT_SQL = `SELECT id, scope, kind, title, body, tags_json, source_session_id,
|
||||||
|
source_user_id, use_count, problem, environment_json, hypothesis,
|
||||||
|
action_json, result, confidence, evidence_json, parent_experience_id,
|
||||||
|
supersedes_id, status, created_at, updated_at
|
||||||
|
FROM h5_experience`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared experience store (etat C of the goose scale plan).
|
* Shared experience store (etat C of the goose scale plan).
|
||||||
@@ -20,9 +35,11 @@ import {
|
|||||||
* @param {import('mysql2/promise').Pool} pool
|
* @param {import('mysql2/promise').Pool} pool
|
||||||
* @param {object} [options]
|
* @param {object} [options]
|
||||||
* @param {() => number} [options.now] - injectable clock for tests
|
* @param {() => number} [options.now] - injectable clock for tests
|
||||||
|
* @param {import('mysql2/promise').Pool} [options.productEventsPool] - MySQL pool for product events
|
||||||
*/
|
*/
|
||||||
export function createExperienceService(pool, options = {}) {
|
export function createExperienceService(pool, options = {}) {
|
||||||
const now = options.now ?? (() => Date.now());
|
const now = options.now ?? (() => Date.now());
|
||||||
|
const productEventsPool = options.productEventsPool ?? pool;
|
||||||
const halfLifeMs = Math.max(
|
const halfLifeMs = Math.max(
|
||||||
60_000,
|
60_000,
|
||||||
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
|
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
|
||||||
@@ -30,16 +47,7 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
|
|
||||||
function rankRows(rows, queryTerms, nowMs) {
|
function rankRows(rows, queryTerms, nowMs) {
|
||||||
return rows
|
return rows
|
||||||
.map((row) => {
|
.map((row) => scoreExperienceRow(row, queryTerms, nowMs, halfLifeMs))
|
||||||
const haystack = experienceSearchHaystack(row);
|
|
||||||
let keywordScore = 0;
|
|
||||||
for (const term of queryTerms) {
|
|
||||||
if (haystack.includes(term)) keywordScore += 1;
|
|
||||||
}
|
|
||||||
const ageMs = Math.max(0, nowMs - Number(row.updated_at));
|
|
||||||
const recency = Math.pow(0.5, ageMs / halfLifeMs);
|
|
||||||
return { row, score: keywordScore * recency };
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.score > 0)
|
.filter((entry) => entry.score > 0)
|
||||||
.sort((a, b) => b.score - a.score);
|
.sort((a, b) => b.score - a.score);
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,38 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function emitExperienceSaved(saved, input) {
|
||||||
|
if (!productEventsPool?.query) return;
|
||||||
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_SAVED,
|
||||||
|
userId: saved.sourceUserId,
|
||||||
|
sessionId: saved.sourceSessionId,
|
||||||
|
data: {
|
||||||
|
experienceId: saved.id,
|
||||||
|
kind: saved.kind,
|
||||||
|
scope: saved.scope,
|
||||||
|
result: saved.result ?? null,
|
||||||
|
source: input?.source ?? null,
|
||||||
|
},
|
||||||
|
createdAt: saved.createdAt,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitExperienceRecalled({ query, hits, scope }) {
|
||||||
|
if (!productEventsPool?.query || !hits.length) return;
|
||||||
|
await recordMemoryV2ProductEvent(productEventsPool, {
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_RECALLED,
|
||||||
|
userId: hits[0]?.sourceUserId ?? null,
|
||||||
|
data: {
|
||||||
|
query: String(query ?? '').slice(0, 500),
|
||||||
|
scope,
|
||||||
|
hitCount: hits.length,
|
||||||
|
experienceIds: hits.map((hit) => hit.id),
|
||||||
|
kinds: hits.map((hit) => hit.kind),
|
||||||
|
},
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persist a piece of experience. Returns the stored record.
|
* Persist a piece of experience. Returns the stored record.
|
||||||
*/
|
*/
|
||||||
@@ -100,7 +140,7 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
return mapExperienceRow({
|
const saved = mapExperienceRow({
|
||||||
id,
|
id,
|
||||||
scope: normalized.scope,
|
scope: normalized.scope,
|
||||||
kind: normalized.kind,
|
kind: normalized.kind,
|
||||||
@@ -123,6 +163,8 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
created_at: ts,
|
created_at: ts,
|
||||||
updated_at: ts,
|
updated_at: ts,
|
||||||
});
|
});
|
||||||
|
void emitExperienceSaved(saved, input);
|
||||||
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -133,11 +175,7 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
const terms = tokenize(query);
|
const terms = tokenize(query);
|
||||||
if (terms.length === 0) return [];
|
if (terms.length === 0) return [];
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, scope, kind, title, body, tags_json, source_session_id,
|
`${EXPERIENCE_SELECT_SQL}
|
||||||
source_user_id, use_count, problem, environment_json, hypothesis,
|
|
||||||
action_json, result, confidence, evidence_json, parent_experience_id,
|
|
||||||
supersedes_id, status, created_at, updated_at
|
|
||||||
FROM h5_experience
|
|
||||||
WHERE scope = ? AND status = 'active'
|
WHERE scope = ? AND status = 'active'
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
LIMIT 500`,
|
LIMIT 500`,
|
||||||
@@ -154,16 +192,72 @@ export function createExperienceService(pool, options = {}) {
|
|||||||
)
|
)
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
return ranked.map((entry) => mapExperienceRow(entry.row));
|
const hits = ranked.map((entry) => mapExperienceRow(entry.row));
|
||||||
|
void emitExperienceRecalled({ query, hits, scope });
|
||||||
|
return hits;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Placeholder for the reflection pass that distills raw session traces into
|
* Rule-based reflection: aggregate repeated task_outcome rows into one
|
||||||
* durable lessons. Wired later once the record() trigger points are defined;
|
* execution_pattern. No LLM; idempotent per problem fingerprint.
|
||||||
* kept here so the calling contract is stable.
|
|
||||||
*/
|
*/
|
||||||
async function reflect() {
|
async function reflect({
|
||||||
return { ok: false, reason: 'not_implemented' };
|
scope = 'global',
|
||||||
|
minGroupSize = DEFAULT_REFLECT_MIN_GROUP_SIZE,
|
||||||
|
dryRun = false,
|
||||||
|
} = {}) {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`${EXPERIENCE_SELECT_SQL}
|
||||||
|
WHERE scope = ?
|
||||||
|
ORDER BY updated_at DESC
|
||||||
|
LIMIT 2000`,
|
||||||
|
[scope],
|
||||||
|
);
|
||||||
|
const plans = planExperienceReflection(rows, { minGroupSize });
|
||||||
|
if (dryRun) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dryRun: true,
|
||||||
|
scope,
|
||||||
|
groupsEligible: plans.length,
|
||||||
|
wouldArchive: plans.reduce((sum, plan) => sum + plan.sourceIds.length, 0),
|
||||||
|
previews: plans.map((plan) => ({
|
||||||
|
fingerprint: plan.fingerprint,
|
||||||
|
sourceCount: plan.sourceIds.length,
|
||||||
|
title: plan.payload.title,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = 0;
|
||||||
|
let archived = 0;
|
||||||
|
const patterns = [];
|
||||||
|
for (const plan of plans) {
|
||||||
|
const saved = await record({
|
||||||
|
...plan.payload,
|
||||||
|
scope,
|
||||||
|
source: 'reflect',
|
||||||
|
});
|
||||||
|
const placeholders = plan.sourceIds.map(() => '?').join(',');
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE h5_experience
|
||||||
|
SET status = 'archived', parent_experience_id = ?, updated_at = ?
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
[saved.id, now(), ...plan.sourceIds],
|
||||||
|
);
|
||||||
|
created += 1;
|
||||||
|
archived += plan.sourceIds.length;
|
||||||
|
patterns.push(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
dryRun: false,
|
||||||
|
scope,
|
||||||
|
created,
|
||||||
|
archived,
|
||||||
|
patterns,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { record, search, reflect };
|
return { record, search, reflect };
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ function createMockPool() {
|
|||||||
if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) {
|
if (sql.includes('FROM h5_experience') && sql.includes('WHERE scope = ?')) {
|
||||||
const [scope] = params;
|
const [scope] = params;
|
||||||
const matched = rows
|
const matched = rows
|
||||||
.filter((r) => r.scope === scope && r.status === 'active')
|
.filter((r) => r.scope === scope && (sql.includes("status = 'active'") ? r.status === 'active' : true))
|
||||||
.sort((a, b) => b.updated_at - a.updated_at);
|
.sort((a, b) => b.updated_at - a.updated_at);
|
||||||
return [matched.map((r) => ({ ...r }))];
|
return [matched.map((r) => ({ ...r }))];
|
||||||
}
|
}
|
||||||
@@ -69,6 +69,18 @@ function createMockPool() {
|
|||||||
}
|
}
|
||||||
return [{ affectedRows: params.length }];
|
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}`);
|
throw new Error(`unexpected SQL: ${sql}`);
|
||||||
};
|
};
|
||||||
return { pool: { query }, rows };
|
return { pool: { query }, rows };
|
||||||
@@ -176,3 +188,60 @@ test('search bumps use_count for returned rows', async () => {
|
|||||||
await svc.search('caddy 灰度');
|
await svc.search('caddy 灰度');
|
||||||
assert.equal(rows[0].use_count, 1);
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export const MEMORY_V2_PRODUCT_EVENT_TYPES = Object.freeze({
|
|||||||
PROMOTED: 'memory_promoted',
|
PROMOTED: 'memory_promoted',
|
||||||
RESOLVED_INJECTED: 'memory_resolved_injected',
|
RESOLVED_INJECTED: 'memory_resolved_injected',
|
||||||
RECALL_HIT: 'memory_recall_hit',
|
RECALL_HIT: 'memory_recall_hit',
|
||||||
|
EXPERIENCE_SAVED: 'experience_saved',
|
||||||
|
EXPERIENCE_RECALLED: 'experience_recalled',
|
||||||
});
|
});
|
||||||
|
|
||||||
const TABLE = 'h5_memory_v2_product_events';
|
const TABLE = 'h5_memory_v2_product_events';
|
||||||
@@ -170,6 +172,21 @@ async function countMemoryItemsCreated(pool, { sinceMs, userId }) {
|
|||||||
return Number(rows[0]?.count ?? 0);
|
return Number(rows[0]?.count ?? 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function countExperienceRowsCreated(pool, { sinceMs, userId }) {
|
||||||
|
if (!(await tableExists(pool, 'h5_experience'))) return 0;
|
||||||
|
const clauses = ['created_at >= ?', "status = 'active'"];
|
||||||
|
const params = [sinceMs];
|
||||||
|
if (userId) {
|
||||||
|
clauses.push('source_user_id = ?');
|
||||||
|
params.push(String(userId));
|
||||||
|
}
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS count FROM h5_experience WHERE ${clauses.join(' AND ')}`,
|
||||||
|
params,
|
||||||
|
);
|
||||||
|
return Number(rows[0]?.count ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
export async function aggregateMemoryV2ProductMetrics(
|
export async function aggregateMemoryV2ProductMetrics(
|
||||||
pool,
|
pool,
|
||||||
{ since = '7d', userId = null, now = Date.now() } = {},
|
{ since = '7d', userId = null, now = Date.now() } = {},
|
||||||
@@ -183,10 +200,13 @@ export async function aggregateMemoryV2ProductMetrics(
|
|||||||
promotedEvents,
|
promotedEvents,
|
||||||
injectedEvents,
|
injectedEvents,
|
||||||
recallHitEvents,
|
recallHitEvents,
|
||||||
|
experienceSavedEvents,
|
||||||
|
experienceRecalledEvents,
|
||||||
candidateSavedFallback,
|
candidateSavedFallback,
|
||||||
promotedFallback,
|
promotedFallback,
|
||||||
injectedFallback,
|
injectedFallback,
|
||||||
recallHitFallback,
|
recallHitFallback,
|
||||||
|
experienceSavedFallback,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
countProductEvents(pool, {
|
countProductEvents(pool, {
|
||||||
sinceMs,
|
sinceMs,
|
||||||
@@ -208,10 +228,21 @@ export async function aggregateMemoryV2ProductMetrics(
|
|||||||
userId,
|
userId,
|
||||||
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RECALL_HIT,
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.RECALL_HIT,
|
||||||
}),
|
}),
|
||||||
|
countProductEvents(pool, {
|
||||||
|
sinceMs,
|
||||||
|
userId,
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_SAVED,
|
||||||
|
}),
|
||||||
|
countProductEvents(pool, {
|
||||||
|
sinceMs,
|
||||||
|
userId,
|
||||||
|
eventType: MEMORY_V2_PRODUCT_EVENT_TYPES.EXPERIENCE_RECALLED,
|
||||||
|
}),
|
||||||
countCandidatesCreated(pool, { sinceMs, userId }),
|
countCandidatesCreated(pool, { sinceMs, userId }),
|
||||||
countMemoryItemsCreated(pool, { sinceMs, userId }),
|
countMemoryItemsCreated(pool, { sinceMs, userId }),
|
||||||
countAgentMemoryEvents(pool, { sinceMs, userId, injectionOnly: true }),
|
countAgentMemoryEvents(pool, { sinceMs, userId, injectionOnly: true }),
|
||||||
countAgentRecallHits(pool, { sinceMs, userId }),
|
countAgentRecallHits(pool, { sinceMs, userId }),
|
||||||
|
countExperienceRowsCreated(pool, { sinceMs, userId }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const events = {
|
const events = {
|
||||||
@@ -219,6 +250,8 @@ export async function aggregateMemoryV2ProductMetrics(
|
|||||||
memory_promoted: promotedEvents || promotedFallback,
|
memory_promoted: promotedEvents || promotedFallback,
|
||||||
memory_resolved_injected: injectedEvents || injectedFallback,
|
memory_resolved_injected: injectedEvents || injectedFallback,
|
||||||
memory_recall_hit: recallHitEvents || recallHitFallback,
|
memory_recall_hit: recallHitEvents || recallHitFallback,
|
||||||
|
experience_saved: experienceSavedEvents || experienceSavedFallback,
|
||||||
|
experience_recalled: experienceRecalledEvents,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -234,6 +267,8 @@ export async function aggregateMemoryV2ProductMetrics(
|
|||||||
memory_promoted: promotedEvents > 0 ? 'product_events' : 'memory_items_table',
|
memory_promoted: promotedEvents > 0 ? 'product_events' : 'memory_items_table',
|
||||||
memory_resolved_injected: injectedEvents > 0 ? 'product_events' : 'agent_run_events',
|
memory_resolved_injected: injectedEvents > 0 ? 'product_events' : 'agent_run_events',
|
||||||
memory_recall_hit: recallHitEvents > 0 ? 'product_events' : 'agent_run_events',
|
memory_recall_hit: recallHitEvents > 0 ? 'product_events' : 'agent_run_events',
|
||||||
|
experience_saved: experienceSavedEvents > 0 ? 'product_events' : 'h5_experience',
|
||||||
|
experience_recalled: experienceRecalledEvents > 0 ? 'product_events' : 'none',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,5 +51,7 @@ test('aggregateMemoryV2ProductMetrics falls back to legacy tables', async () =>
|
|||||||
assert.equal(metrics.events.memory_promoted, 8);
|
assert.equal(metrics.events.memory_promoted, 8);
|
||||||
assert.equal(metrics.events.memory_resolved_injected, 7);
|
assert.equal(metrics.events.memory_resolved_injected, 7);
|
||||||
assert.equal(metrics.events.memory_recall_hit, 5);
|
assert.equal(metrics.events.memory_recall_hit, 5);
|
||||||
|
assert.equal(metrics.events.experience_saved, 0);
|
||||||
|
assert.equal(metrics.events.experience_recalled, 0);
|
||||||
assert.equal(metrics.sources.memory_candidate_saved, 'candidates_table');
|
assert.equal(metrics.sources.memory_candidate_saved, 'candidates_table');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ export async function bootstrapMindSpaceService({
|
|||||||
const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
const apiSecret = env.TKMIND_SERVER__SECRET_KEY ?? 'local-dev-secret';
|
||||||
let experienceService = null;
|
let experienceService = null;
|
||||||
if (runtime.experienceEnabled) {
|
if (runtime.experienceEnabled) {
|
||||||
experienceService = createExperienceService(pool);
|
experienceService = createExperienceService(pool, { productEventsPool: pool });
|
||||||
}
|
}
|
||||||
const agentRunner = createMindSpaceAgentRunner({
|
const agentRunner = createMindSpaceAgentRunner({
|
||||||
apiTarget,
|
apiTarget,
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export async function bootstrapPortalAgentServices({
|
|||||||
experienceService =
|
experienceService =
|
||||||
await createPgExperienceServiceFn({
|
await createPgExperienceServiceFn({
|
||||||
connectionString: env.EXPERIENCE_PG_URL,
|
connectionString: env.EXPERIENCE_PG_URL,
|
||||||
|
productEventsPool: pool,
|
||||||
});
|
});
|
||||||
logger.log(
|
logger.log(
|
||||||
'Experience store: PostgreSQL + pgvector',
|
'Experience store: PostgreSQL + pgvector',
|
||||||
@@ -91,10 +92,10 @@ export async function bootstrapPortalAgentServices({
|
|||||||
'Experience PG init failed, falling back to MySQL store:',
|
'Experience PG init failed, falling back to MySQL store:',
|
||||||
error instanceof Error ? error.message : error,
|
error instanceof Error ? error.message : error,
|
||||||
);
|
);
|
||||||
experienceService = createExperienceServiceFn(pool);
|
experienceService = createExperienceServiceFn(pool, { productEventsPool: pool });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
experienceService = createExperienceServiceFn(pool);
|
experienceService = createExperienceServiceFn(pool, { productEventsPool: pool });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user