182 lines
5.9 KiB
JavaScript
182 lines
5.9 KiB
JavaScript
|
|
import crypto from 'node:crypto';
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Shared experience store (etat C of the goose scale plan).
|
|||
|
|
*
|
|||
|
|
* All goosed Worker instances read/write the same learned experience so that
|
|||
|
|
* capability is not siloed per instance. This first implementation is backed by
|
|||
|
|
* MySQL (`h5_experience`) with a keyword + recency ranking. The retrieval scoring
|
|||
|
|
* is deliberately isolated in `rankRows` so that swapping to PostgreSQL + pgvector
|
|||
|
|
* later only replaces the query/scoring layer, not the calling contract
|
|||
|
|
* (record / search / reflect).
|
|||
|
|
*
|
|||
|
|
* @param {import('mysql2/promise').Pool} pool
|
|||
|
|
* @param {object} [options]
|
|||
|
|
* @param {() => number} [options.now] - injectable clock for tests
|
|||
|
|
*/
|
|||
|
|
export function createExperienceService(pool, options = {}) {
|
|||
|
|
const now = options.now ?? (() => Date.now());
|
|||
|
|
// Recency half-life: a record this old contributes half its keyword score.
|
|||
|
|
const halfLifeMs = Math.max(
|
|||
|
|
60_000,
|
|||
|
|
Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000),
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
function normalizeTags(tags) {
|
|||
|
|
if (!Array.isArray(tags)) return [];
|
|||
|
|
return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function rowToExperience(row) {
|
|||
|
|
return {
|
|||
|
|
id: row.id,
|
|||
|
|
scope: row.scope,
|
|||
|
|
kind: row.kind,
|
|||
|
|
title: row.title,
|
|||
|
|
body: row.body,
|
|||
|
|
tags: row.tags_json ? safeJsonArray(row.tags_json) : [],
|
|||
|
|
sourceSessionId: row.source_session_id ?? null,
|
|||
|
|
sourceUserId: row.source_user_id ?? null,
|
|||
|
|
useCount: Number(row.use_count ?? 0),
|
|||
|
|
createdAt: Number(row.created_at),
|
|||
|
|
updatedAt: Number(row.updated_at),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function safeJsonArray(value) {
|
|||
|
|
if (Array.isArray(value)) return value;
|
|||
|
|
try {
|
|||
|
|
const parsed = JSON.parse(value);
|
|||
|
|
return Array.isArray(parsed) ? parsed : [];
|
|||
|
|
} catch {
|
|||
|
|
return [];
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Keyword overlap × recency decay. Kept separate so the pgvector backend can
|
|||
|
|
// replace this with cosine similarity without touching search()'s shape.
|
|||
|
|
function rankRows(rows, queryTerms, nowMs) {
|
|||
|
|
const terms = queryTerms;
|
|||
|
|
return rows
|
|||
|
|
.map((row) => {
|
|||
|
|
const haystack = `${row.title}\n${row.body}`.toLowerCase();
|
|||
|
|
let keywordScore = 0;
|
|||
|
|
for (const term of terms) {
|
|||
|
|
if (haystack.includes(term)) keywordScore += 1;
|
|||
|
|
}
|
|||
|
|
const ageMs = Math.max(0, nowMs - Number(row.updated_at));
|
|||
|
|
const recency = Math.pow(0.5, ageMs / halfLifeMs);
|
|||
|
|
const score = keywordScore * recency;
|
|||
|
|
return { row, score };
|
|||
|
|
})
|
|||
|
|
.filter((entry) => entry.score > 0)
|
|||
|
|
.sort((a, b) => b.score - a.score);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function tokenize(query) {
|
|||
|
|
return [
|
|||
|
|
...new Set(
|
|||
|
|
String(query ?? '')
|
|||
|
|
.toLowerCase()
|
|||
|
|
.split(/[^\p{L}\p{N}]+/u)
|
|||
|
|
.map((t) => t.trim())
|
|||
|
|
.filter((t) => t.length >= 2),
|
|||
|
|
),
|
|||
|
|
];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Persist a piece of experience. Returns the stored record.
|
|||
|
|
*/
|
|||
|
|
async function record(input) {
|
|||
|
|
const title = String(input?.title ?? '').trim();
|
|||
|
|
const body = String(input?.body ?? '').trim();
|
|||
|
|
if (!title) throw experienceError('经验标题不能为空', 'invalid_experience_input');
|
|||
|
|
if (!body) throw experienceError('经验内容不能为空', 'invalid_experience_input');
|
|||
|
|
const id = crypto.randomUUID();
|
|||
|
|
const ts = now();
|
|||
|
|
const scope = String(input?.scope ?? 'global').trim() || 'global';
|
|||
|
|
const kind = String(input?.kind ?? 'lesson').trim() || 'lesson';
|
|||
|
|
const tags = normalizeTags(input?.tags);
|
|||
|
|
await pool.query(
|
|||
|
|
`INSERT INTO h5_experience
|
|||
|
|
(id, scope, kind, title, body, tags_json, source_session_id, source_user_id,
|
|||
|
|
use_count, created_at, updated_at)
|
|||
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
|||
|
|
[
|
|||
|
|
id,
|
|||
|
|
scope,
|
|||
|
|
kind,
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
JSON.stringify(tags),
|
|||
|
|
input?.sourceSessionId ?? null,
|
|||
|
|
input?.sourceUserId ?? null,
|
|||
|
|
ts,
|
|||
|
|
ts,
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
return rowToExperience({
|
|||
|
|
id,
|
|||
|
|
scope,
|
|||
|
|
kind,
|
|||
|
|
title,
|
|||
|
|
body,
|
|||
|
|
tags_json: tags,
|
|||
|
|
source_session_id: input?.sourceSessionId ?? null,
|
|||
|
|
source_user_id: input?.sourceUserId ?? null,
|
|||
|
|
use_count: 0,
|
|||
|
|
created_at: ts,
|
|||
|
|
updated_at: ts,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Retrieve the most relevant experience for a query within a scope.
|
|||
|
|
* @returns {Promise<Array>} ranked experiences (best first), max `limit`.
|
|||
|
|
*/
|
|||
|
|
async function search(query, { scope = 'global', limit = 5 } = {}) {
|
|||
|
|
const terms = tokenize(query);
|
|||
|
|
if (terms.length === 0) return [];
|
|||
|
|
// Pull a bounded candidate set by scope+recency, then rank in JS. The
|
|||
|
|
// candidate cap keeps this cheap on MySQL; pgvector would push ranking into SQL.
|
|||
|
|
const [rows] = await pool.query(
|
|||
|
|
`SELECT id, scope, kind, title, body, tags_json, source_session_id,
|
|||
|
|
source_user_id, use_count, created_at, updated_at
|
|||
|
|
FROM h5_experience
|
|||
|
|
WHERE scope = ?
|
|||
|
|
ORDER BY updated_at DESC
|
|||
|
|
LIMIT 500`,
|
|||
|
|
[scope],
|
|||
|
|
);
|
|||
|
|
const ranked = rankRows(rows, terms, now()).slice(0, Math.max(1, limit));
|
|||
|
|
if (ranked.length > 0) {
|
|||
|
|
// Best-effort usage bump so popular experience can be surfaced/weighted later.
|
|||
|
|
const ids = ranked.map((entry) => entry.row.id);
|
|||
|
|
const placeholders = ids.map(() => '?').join(',');
|
|||
|
|
await pool
|
|||
|
|
.query(
|
|||
|
|
`UPDATE h5_experience SET use_count = use_count + 1 WHERE id IN (${placeholders})`,
|
|||
|
|
ids,
|
|||
|
|
)
|
|||
|
|
.catch(() => {});
|
|||
|
|
}
|
|||
|
|
return ranked.map((entry) => rowToExperience(entry.row));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Placeholder for the reflection pass that distills raw session traces into
|
|||
|
|
* durable lessons. Wired later once the record() trigger points are defined;
|
|||
|
|
* kept here so the calling contract is stable.
|
|||
|
|
*/
|
|||
|
|
async function reflect() {
|
|||
|
|
return { ok: false, reason: 'not_implemented' };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return { record, search, reflect };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function experienceError(message, code) {
|
|||
|
|
return Object.assign(new Error(message), { code });
|
|||
|
|
}
|