Files
memind/experience-service-pg.mjs
T
john 25f8223253 refactor: Business logic and dependencies updates
- 核心服务代码更新 (db, server, auth, proxy)
- Agent 相关模块更新 (mindspace, experience)
- 前端组件和 hooks 更新
- 数据库 schema 更新
- 依赖版本更新
2026-06-27 08:25:02 +08:00

228 lines
7.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<number[]>} [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 });
}