import crypto from 'node:crypto'; /** * PostgreSQL + pgvector backed experience store (etat C, polyglot mode). * * Same contract as the MySQL `createExperienceService` (record / search / * reflect) so callers (mindspace-agent-runner) are backend-agnostic. The MySQL * business DB is untouched; only this shared-experience workload lives on PG. * * Semantic search via pgvector is used when an `embed(text) => number[]` function * is supplied; otherwise it degrades to keyword (ILIKE) + recency, identical in * spirit to the MySQL version, so it is useful before an embedding provider is wired. * * The `pg` driver is imported dynamically so this module loads even when the * dependency is not yet installed; it is only required once the service is built. * * @param {object} options * @param {string} options.connectionString - e.g. process.env.EXPERIENCE_PG_URL * @param {(text: string) => Promise} [options.embed] - embedding fn * @param {number} [options.embeddingDim=1536] - vector dimension (must match embed) * @param {() => number} [options.now] * @param {number} [options.recencyHalfLifeMs] */ export async function createPgExperienceService(options = {}) { const connectionString = options.connectionString; if (!connectionString) { throw new Error('createPgExperienceService 需要 connectionString(建议用 EXPERIENCE_PG_URL)'); } const now = options.now ?? (() => Date.now()); const embed = options.embed ?? null; const embeddingDim = Math.max(1, Number(options.embeddingDim ?? 1536)); const halfLifeMs = Math.max( 60_000, Number(options.recencyHalfLifeMs ?? 30 * 24 * 60 * 60 * 1000), ); const { default: pg } = await import('pg'); const pool = new pg.Pool({ connectionString, max: Number(options.poolMax ?? 10) }); // Idempotent setup. pgvector must be available on the server (CREATE EXTENSION). await pool.query('CREATE EXTENSION IF NOT EXISTS vector'); await pool.query(` CREATE TABLE IF NOT EXISTS h5_experience ( id UUID PRIMARY KEY, scope TEXT NOT NULL DEFAULT 'global', kind TEXT NOT NULL DEFAULT 'lesson', title TEXT NOT NULL, body TEXT NOT NULL, tags JSONB NOT NULL DEFAULT '[]'::jsonb, source_session_id TEXT, source_user_id TEXT, use_count BIGINT NOT NULL DEFAULT 0, embedding vector(${embeddingDim}), created_at BIGINT NOT NULL, updated_at BIGINT NOT NULL ) `); await pool.query( 'CREATE INDEX IF NOT EXISTS idx_h5_experience_scope_updated ON h5_experience (scope, updated_at DESC)', ); // Trigram-ish keyword fallback index (btree on lower(title)) is cheap; the // ANN vector index is created lazily once rows have embeddings. await pool.query( "CREATE INDEX IF NOT EXISTS idx_h5_experience_embedding ON h5_experience USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)", ).catch(() => { // ivfflat needs the extension + may warn on empty table; non-fatal. }); function normalizeTags(tags) { if (!Array.isArray(tags)) return []; return [...new Set(tags.map((t) => String(t).trim()).filter(Boolean))]; } function toVectorLiteral(vec) { // pgvector accepts a string like '[0.1,0.2,...]' return `[${vec.map((n) => Number(n)).join(',')}]`; } function rowToExperience(row) { return { id: row.id, scope: row.scope, kind: row.kind, title: row.title, body: row.body, tags: Array.isArray(row.tags) ? row.tags : [], 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), }; } 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); let embedding = null; if (embed) { try { const vec = await embed(`${title}\n${body}`); if (Array.isArray(vec) && vec.length === embeddingDim) embedding = toVectorLiteral(vec); } catch { embedding = null; // embedding failure must not block recording } } await pool.query( `INSERT INTO h5_experience (id, scope, kind, title, body, tags, source_session_id, source_user_id, use_count, embedding, created_at, updated_at) VALUES ($1,$2,$3,$4,$5,$6::jsonb,$7,$8,0,$9::vector,$10,$11)`, [ id, scope, kind, title, body, JSON.stringify(tags), input?.sourceSessionId ?? null, input?.sourceUserId ?? null, embedding, ts, ts, ], ); return rowToExperience({ id, scope, kind, title, body, tags, source_session_id: input?.sourceSessionId ?? null, source_user_id: input?.sourceUserId ?? null, use_count: 0, created_at: ts, updated_at: ts, }); } async function search(query, { scope = 'global', limit = 5 } = {}) { const text = String(query ?? '').trim(); if (!text) return []; const max = Math.max(1, limit); // Vector path: cosine distance ordering, recency as tiebreaker. if (embed) { try { const vec = await embed(text); if (Array.isArray(vec) && vec.length === embeddingDim) { const { rows } = await pool.query( `SELECT id, scope, kind, title, body, tags, source_session_id, source_user_id, use_count, created_at, updated_at FROM h5_experience WHERE scope = $1 AND embedding IS NOT NULL ORDER BY embedding <=> $2::vector, updated_at DESC LIMIT $3`, [scope, toVectorLiteral(vec), max], ); if (rows.length > 0) { await bumpUseCount(rows.map((r) => r.id)); return rows.map(rowToExperience); } } } catch { // fall through to keyword path } } // Keyword fallback: ILIKE any term, ordered by recency. const terms = tokenize(text); if (terms.length === 0) return []; const likeClauses = terms.map((_, i) => `(title ILIKE $${i + 2} OR body ILIKE $${i + 2})`); const params = [scope, ...terms.map((t) => `%${t}%`), max]; const { rows } = await pool.query( `SELECT id, scope, kind, title, body, tags, source_session_id, source_user_id, use_count, created_at, updated_at FROM h5_experience WHERE scope = $1 AND (${likeClauses.join(' OR ')}) ORDER BY updated_at DESC LIMIT $${terms.length + 2}`, params, ); if (rows.length > 0) await bumpUseCount(rows.map((r) => r.id)); return rows.map(rowToExperience); } async function bumpUseCount(ids) { if (!ids.length) return; await pool .query('UPDATE h5_experience SET use_count = use_count + 1 WHERE id = ANY($1::uuid[])', [ids]) .catch(() => {}); } function tokenize(query) { return [ ...new Set( String(query ?? '') .toLowerCase() .split(/[^\p{L}\p{N}]+/u) .map((t) => t.trim()) .filter((t) => t.length >= 2), ), ]; } async function reflect() { return { ok: false, reason: 'not_implemented' }; } async function close() { await pool.end(); } return { record, search, reflect, close }; } function experienceError(message, code) { return Object.assign(new Error(message), { code }); }