- 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>
199 lines
5.4 KiB
TypeScript
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;
|
|
}
|
|
}
|