tidaldb/applications/iknowyou/lib/tidal-personalization.ts
jordan eca7765e8d fix: heal_region re-delivers missed WAL batches so partitioned followers converge immediately after heal
- Extract redeliver_missed(tx, db, log) helper into cluster_transport.rs
- heal_region now removes partition then immediately ships any missed
  batch-log entries to the healed follower's channel
- await_convergence refactored to call the same helper (no logic change)
- tidal-server: reload_text_index before search in cluster mode
- tidal-server: write_signal returns Result instead of panicking on unknown signal
- tidal-server: leader shows lag_events=0 (writes directly, no receiver thread)
- tidal-server: fix cluster mode error propagation (ServerError::from)
- docs/runbooks/cluster.md: add full cluster operations runbook
- docker/: add Dockerfile for containerised cluster deployment
- README.md: add tidal-server HTTP API getting-started section
- Split oversized source files per CODING_GUIDELINES §9

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 11:57:01 -07:00

199 lines
5.4 KiB
TypeScript

import type { CommunicationBrief, ObserverOutput } from "./types";
const ENGINE_URL =
process.env.IKY_ENGINE_URL?.replace(/\/$/, "") ?? "http://127.0.0.1:7777";
const REQUEST_TIMEOUT_MS = 1500;
interface RetrievedItem {
item_id: number;
creator_id: number | null;
title: string | null;
category: string | null;
score: number;
}
interface RetrieveResponse {
items: RetrievedItem[];
}
function hash32(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return (h >>> 0) % 2_147_483_647;
}
function personToUserId(personId: string): number {
return 100_000 + hash32(`person:${personId}`);
}
function creatorFromDomain(domain: string): number {
return 10_000 + hash32(`domain:${domain.toLowerCase()}`);
}
function itemIdForTurn(conversationId: string, turn: number): number {
return 1_000_000 + hash32(`conv:${conversationId}:turn:${turn}`);
}
async function request(path: string, init: RequestInit = {}): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
return await fetch(`${ENGINE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
async function postJson(path: string, body: unknown): Promise<boolean> {
try {
const res = await request(path, {
method: "POST",
body: JSON.stringify(body),
});
return res.ok;
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[tidal] POST ${path} failed: ${msg}`);
return false;
}
}
export async function ensurePersonalizationUser(personId: string): Promise<void> {
const userId = personToUserId(personId);
await postJson("/v1/users/upsert", {
user_id: userId,
metadata: {
role: "user",
source: "iknowyou",
},
});
}
export async function ensurePersonalizationSession(
conversationId: string,
personId: string
): Promise<void> {
const userId = personToUserId(personId);
await postJson("/v1/sessions/start", {
conversation_id: conversationId,
user_id: userId,
agent_id: "aeries",
});
}
async function upsertAssistantItem(
conversationId: string,
turn: number,
assistantMessage: string,
domain: string
): Promise<{ itemId: number; creatorId: number }> {
const itemId = itemIdForTurn(conversationId, turn);
const creatorId = creatorFromDomain(domain || "general");
await postJson("/v1/items/upsert", {
item_id: itemId,
creator_id: creatorId,
title: assistantMessage.slice(0, 120),
category: domain || "general",
});
return { itemId, creatorId };
}
function actionFromObserver(output: ObserverOutput): "more" | "less" | "view" {
const s = output.engagement.sentiment_score;
if (s >= 0.6 && output.engagement.substantive) return "more";
if (s <= 0.4 || output.dynamics.redirected) return "less";
return "view";
}
export async function recordObserverPersonalization(params: {
personId: string;
conversationId: string;
turn: number;
assistantMessage: string;
output: ObserverOutput;
}): Promise<void> {
const { personId, conversationId, turn, assistantMessage, output } = params;
const userId = personToUserId(personId);
const domain = output.topic.domain || "general";
const { itemId, creatorId } = await upsertAssistantItem(
conversationId,
turn,
assistantMessage,
domain
);
const action = actionFromObserver(output);
await postJson("/v1/feedback", {
user_id: userId,
item_id: itemId,
creator_id: creatorId,
action,
});
// Keep sessions warm with signal-level writes for per-conversation context.
await postJson("/v1/sessions/signal", {
conversation_id: conversationId,
signal_type: action === "more" ? "like" : action === "less" ? "skip" : "view",
item_id: itemId,
weight: 1.0,
annotation: `topic:${output.topic.primary} domain:${domain}`,
});
// Optional auxiliary memory (Synap when configured in engine server).
if (output.engagement.sentiment_score >= 0.75) {
await postJson("/v1/aux/observation", {
person_id: userId,
observation: `Strong positive response to ${domain} / ${output.topic.primary} conversation style`,
});
}
}
export async function addPersonalizationHints(
personId: string,
brief?: CommunicationBrief
): Promise<CommunicationBrief | undefined> {
if (!brief) return brief;
const userId = personToUserId(personId);
try {
const res = await request(`/v1/retrieve?user_id=${userId}&limit=5`);
if (!res.ok) return brief;
const data = await res.json();
if (!Array.isArray(data?.items) || !data.items.length) return brief;
const items = data.items as RetrievedItem[];
const hintLines = items.map((item) => {
const category = item.category ?? "general";
const title = item.title ?? `item-${item.item_id}`;
return `tidal hint: ${category} style (score ${item.score.toFixed(2)}) via \"${title}\"`;
});
return {
...brief,
observations: [...hintLines, ...brief.observations].slice(0, 8),
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error(`[tidal] personalization hints failed: ${msg}`);
return brief;
}
}