P0-P2: heartbeat→mind wiring, structured JSON output, bge-m3 embeddings
This commit is contained in:
1 parent
702700dcb9
commit
a6fde100ca
9 files changed
+184
-35
No files matched your search
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Embedding Client — локальные эмбеддинги через Ollama /api/embeddings.
|
||||
*
|
||||
* Модель: bge-m3 (dim=1024) — та же, что в content-atom.
|
||||
* НЕ переключаться на nomic-embed-text (dim=768) — разный размер вектора
|
||||
* = переиндексация Qdrant.
|
||||
*/
|
||||
|
||||
export interface EmbeddingClient {
|
||||
embed(text: string): Promise<number[]>;
|
||||
}
|
||||
|
||||
/** Ollama EmbeddingClient — bge-m3. */
|
||||
export class OllamaEmbeddingClient implements EmbeddingClient {
|
||||
constructor(
|
||||
private baseUrl: string,
|
||||
private modelName = 'bge-m3',
|
||||
) {}
|
||||
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const resp = await fetch(`${this.baseUrl}/api/embeddings`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ model: this.modelName, prompt: text }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`Embedding error: ${resp.status}`);
|
||||
const data: any = await resp.json();
|
||||
return data.embedding ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Векторная арифметика — cosine similarity.
|
||||
* Держим здесь, чтобы memory.ts не зависел от внешних библиотек.
|
||||
*/
|
||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length === 0 || b.length === 0) return 0;
|
||||
let dot = 0, na = 0, nb = 0;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dot += a[i] * b[i];
|
||||
na += a[i] * a[i];
|
||||
nb += b[i] * b[i];
|
||||
}
|
||||
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
||||
}
|
||||
+11
-2
@@ -37,11 +37,18 @@ export class Heartbeat {
|
||||
private sessionState: SessionState = 'cold';
|
||||
private lastSessionId: string | null = null;
|
||||
private readonly config: HeartbeatConfig;
|
||||
/** Колбэк, который выполняется на каждом тике (в прода: mind.handleTick). */
|
||||
private onTick?: (trigger: TickTrigger) => Promise<void>;
|
||||
|
||||
constructor(config: Partial<HeartbeatConfig> = {}) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/** Подключает обработчик тика (оркестратор mind). */
|
||||
setOnTick(fn: (trigger: TickTrigger) => Promise<void>) {
|
||||
this.onTick = fn;
|
||||
}
|
||||
|
||||
/** Любой тик сбрасывает idle-таймер (по спецификации Animus). */
|
||||
private resetTimer() {
|
||||
if (this.timer) clearTimeout(this.timer);
|
||||
@@ -68,8 +75,10 @@ export class Heartbeat {
|
||||
const sessionId = await this.resolveSession(trigger);
|
||||
this.sessionState = 'active';
|
||||
|
||||
// здесь вызывается mind (см. mind.ts)
|
||||
// await this.runMind(sessionId, trigger);
|
||||
// вызываем mind — теперь реальный запуск оркестратора
|
||||
if (this.onTick) {
|
||||
await this.onTick(trigger);
|
||||
}
|
||||
|
||||
this.sessionState = 'warm';
|
||||
this.lastSessionId = sessionId;
|
||||
|
||||
+22
-15
@@ -7,6 +7,8 @@
|
||||
* long-term — в LanceDB (или Qdrant на a2000 для прода).
|
||||
*/
|
||||
|
||||
import { EmbeddingClient, cosineSimilarity } from './embedding.js';
|
||||
|
||||
export interface Thought {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -39,8 +41,12 @@ export class MemorySystem {
|
||||
private notepads = new Map<string, ContactNotepad>();
|
||||
private selfObservationsList: SelfObservation[] = [];
|
||||
private longTerm: LongTermMemory[] = [];
|
||||
/** Embedding-клиент (Ollama bge-m3). Если null — long-term без векторного поиска. */
|
||||
private embeddingClient: EmbeddingClient | null;
|
||||
|
||||
constructor(private recentCount: number) {}
|
||||
constructor(private recentCount: number, embeddingClient?: EmbeddingClient) {
|
||||
this.embeddingClient = embeddingClient ?? null;
|
||||
}
|
||||
|
||||
// === Short-term: последние мысли/опыт/сообщения ===
|
||||
addThought(text: string): Thought {
|
||||
@@ -74,19 +80,26 @@ export class MemorySystem {
|
||||
return this.selfObservationsList;
|
||||
}
|
||||
|
||||
// === Long-term: факты/опыт/процедуры/исходы + векторный поиск ===
|
||||
addLongTerm(text: string, type: LongTermMemory['type']): LongTermMemory {
|
||||
// === Long-term: факты/опыт/процедуры/исходы + эмбедд для векторного поиска ===
|
||||
async addLongTerm(text: string, type: LongTermMemory['type']): Promise<LongTermMemory> {
|
||||
const m: LongTermMemory = { id: `lt-${Date.now()}`, text, type, createdAt: Date.now() };
|
||||
if (this.embeddingClient) {
|
||||
m.embedding = await this.embeddingClient.embed(text);
|
||||
}
|
||||
this.longTerm.push(m);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** Поиск по long-term по эмбедду (в прода заменяется на LanceDB/Qdrant query). */
|
||||
searchLongTerm(queryEmbedding: number[], topK = 3): LongTermMemory[] {
|
||||
// cosine similarity — заглушка, в прода: LanceDB.search
|
||||
/** Поиск по long-term по тексту через эмбедд (в прода заменяется на LanceDB/Qdrant). */
|
||||
async searchLongTerm(query: string, topK = 3): Promise<LongTermMemory[]> {
|
||||
if (!this.embeddingClient) {
|
||||
// без embedding-клиента — простой fallback
|
||||
return this.longTerm.slice(0, topK);
|
||||
}
|
||||
const q = await this.embeddingClient.embed(query);
|
||||
const scored = this.longTerm
|
||||
.filter(m => m.embedding)
|
||||
.map(m => ({ m, score: cosine(queryEmbedding, m.embedding!) }))
|
||||
.map(m => ({ m, score: cosineSimilarity(q, m.embedding!) }))
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, topK)
|
||||
.map(x => x.m);
|
||||
@@ -94,16 +107,10 @@ export class MemorySystem {
|
||||
}
|
||||
|
||||
/** Консолидация: перенос недавних мыслей в long-term (по спецификации Animus). */
|
||||
consolidate(recent: Thought[]): void {
|
||||
async consolidate(recent: Thought[]): Promise<void> {
|
||||
for (const t of recent) {
|
||||
// эвристика: только «значимые» мысли (по тегам/ключевым словам)
|
||||
this.addLongTerm(t.text, 'experience');
|
||||
await this.addLongTerm(t.text, 'experience');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cosine(a: number[], b: number[]): number {
|
||||
let dot = 0, na = 0, nb = 0;
|
||||
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
|
||||
return Math.sqrt(na) * Math.sqrt(nb) ? dot / (Math.sqrt(na) * Math.sqrt(nb)) : 0;
|
||||
}
|
||||
+58
-9
@@ -51,6 +51,7 @@ export class SparkLLMClient implements LLMClient {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: this.modelName,
|
||||
response_format: { type: 'json_object' }, // structured output
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: context },
|
||||
@@ -62,21 +63,53 @@ export class SparkLLMClient implements LLMClient {
|
||||
if (!resp.ok) throw new Error(`Spark LLM error: ${resp.status}`);
|
||||
const data: any = await resp.json();
|
||||
const content = data.choices?.[0]?.message?.content ?? '';
|
||||
// Spark X2.5-4B: извлекаем thoughts/emotion/decision из structured-выхода
|
||||
// Spark X2.5-4B отдаёт JSON: {thoughts, emotion, decision}
|
||||
return parseSparkOutput(content);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSparkOutput(content: string): LLMResult {
|
||||
// эвристика: Spark форматирует блоки. В прода — structured output (json mode).
|
||||
// 1. Пробуем распарсить JSON (structured output)
|
||||
let parsed: any = null;
|
||||
try {
|
||||
parsed = JSON.parse(content);
|
||||
} catch {
|
||||
// fallback: ищем JSON-блок в тексте
|
||||
const match = content.match(/({[\s\S]*})/);
|
||||
if (match) {
|
||||
try { parsed = JSON.parse(match[1]); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
return {
|
||||
text: parsed.text ?? parsed.reply ?? '',
|
||||
thoughts: parsed.thoughts ?? '',
|
||||
emotion: {
|
||||
valence: clampNum(parsed.emotion?.valence ?? parsed.valence, 0.5),
|
||||
arousal: clampNum(parsed.emotion?.arousal ?? parsed.arousal, 0.5),
|
||||
},
|
||||
decision: {
|
||||
reply: parsed.decision?.reply ?? parsed.reply ?? null,
|
||||
subAgent: parsed.decision?.subAgent ?? parsed.subAgent ?? undefined,
|
||||
action: parsed.decision?.action ?? parsed.action ?? undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Fallback: raw-текст как reply
|
||||
return {
|
||||
text: content,
|
||||
thoughts: content,
|
||||
emotion: { valence: 0.5, arousal: 0.5 },
|
||||
decision: { reply: null },
|
||||
decision: { reply: content || null },
|
||||
};
|
||||
}
|
||||
|
||||
function clampNum(v: number, fallback: number): number {
|
||||
return typeof v === 'number' && !Number.isNaN(v) ? v : fallback;
|
||||
}
|
||||
|
||||
export class Mind {
|
||||
private heartbeat: Heartbeat;
|
||||
private memory: MemorySystem;
|
||||
@@ -103,17 +136,17 @@ export class Mind {
|
||||
// 1. Short-term memory: мысли mind
|
||||
this.memory.addThought(result.thoughts);
|
||||
|
||||
// 2. Emotion decay + consolidation
|
||||
this.consolidate();
|
||||
// 2. Emotion decay + consolidation (async — эмбеддится в long-term)
|
||||
await this.consolidate();
|
||||
|
||||
// 3. Если mind решил ответить — отправляем (channel layer)
|
||||
if (result.decision.reply) {
|
||||
console.log('[reply]', result.decision.reply);
|
||||
await this.sendReply(result.decision.reply);
|
||||
}
|
||||
|
||||
// 4. Если делегировал саб-агенту — запускаем (в прода: sub-agent SDK)
|
||||
if (result.decision.subAgent) {
|
||||
console.log('[sub-agent]', result.decision.subAgent, '— в MVP не запускается');
|
||||
await this.runSubAgent(result.decision.subAgent);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +161,23 @@ export class Mind {
|
||||
}
|
||||
|
||||
/** Консолидация: перенос недавних мыслей в long-term. */
|
||||
private consolidate(): void {
|
||||
this.memory.consolidate(this.memory.recentThoughts());
|
||||
private async consolidate(): Promise<void> {
|
||||
await this.memory.consolidate(this.memory.recentThoughts());
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel layer — отправка ответа.
|
||||
* В MVP: console (вывод). В прода: Telegram/SMS/Web (подключается отдельно).
|
||||
*/
|
||||
private async sendReply(text: string): Promise<void> {
|
||||
console.log('[reply]', text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-agent — делегирование тяжёлого reasoning.
|
||||
* В прода: Ornith-9B через sub-agent SDK. Пока — заглушка.
|
||||
*/
|
||||
private async runSubAgent(agent: string): Promise<void> {
|
||||
console.log('[sub-agent]', agent, '— sub-agent SDK (P3)');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user