- 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>
229 lines
6.9 KiB
TypeScript
229 lines
6.9 KiB
TypeScript
import { streamChat } from "@/lib/vllm";
|
|
import { sendMessage } from "@/lib/synap";
|
|
import { assembleBrief } from "@/lib/briefing";
|
|
import {
|
|
addPersonalizationHints,
|
|
ensurePersonalizationSession,
|
|
ensurePersonalizationUser,
|
|
recordObserverPersonalization,
|
|
} from "@/lib/tidal-personalization";
|
|
import type { ObserverOutput } from "@/lib/types";
|
|
|
|
interface ChatBody {
|
|
messages: { role: "user" | "assistant"; content: string }[];
|
|
conversationId?: string;
|
|
personId?: string;
|
|
}
|
|
|
|
// --- Per-conversation state (in-memory, lost on restart — fine for M3) ---
|
|
const turnCounts = new Map<string, number>();
|
|
const signalBuffers = new Map<string, ObserverOutput[]>();
|
|
const lastTopics = new Map<string, string>();
|
|
|
|
const SYNTHESIS_INTERVAL = 5;
|
|
|
|
export async function POST(req: Request) {
|
|
const requestTimestamp = Date.now();
|
|
|
|
let body: ChatBody;
|
|
try {
|
|
body = await req.json();
|
|
} catch {
|
|
return new Response("Invalid JSON", { status: 400 });
|
|
}
|
|
|
|
if (!body.messages?.length) {
|
|
return new Response("No messages provided", { status: 400 });
|
|
}
|
|
|
|
const lastUserMsg = body.messages.findLast((m) => m.role === "user");
|
|
const conversationId = body.conversationId;
|
|
const personId = body.personId;
|
|
|
|
// Keep tidal personalization state hot, but never block chat if unavailable.
|
|
if (personId) {
|
|
ensurePersonalizationUser(personId).catch((err) =>
|
|
console.error("[tidal] ensure user failed:", err)
|
|
);
|
|
}
|
|
if (personId && conversationId) {
|
|
ensurePersonalizationSession(conversationId, personId).catch((err) =>
|
|
console.error("[tidal] ensure session failed:", err)
|
|
);
|
|
}
|
|
|
|
// 1. Store user message in Synap (non-blocking — don't delay stream start)
|
|
if (conversationId && lastUserMsg) {
|
|
sendMessage("user", lastUserMsg.content, conversationId).catch((err) =>
|
|
console.error("[synap] failed to store user message:", err.message)
|
|
);
|
|
}
|
|
|
|
// 2. Assemble communication brief (replaces scatter-shot recall + cohort loading)
|
|
const synapBrief = personId
|
|
? await assembleBrief(personId).catch((err) => {
|
|
console.error("[brief] assembly failed:", err.message);
|
|
return undefined;
|
|
})
|
|
: undefined;
|
|
const brief = personId
|
|
? await addPersonalizationHints(personId, synapBrief)
|
|
: synapBrief;
|
|
|
|
const encoder = new TextEncoder();
|
|
let fullResponse = "";
|
|
|
|
const stream = new ReadableStream({
|
|
async start(controller) {
|
|
try {
|
|
for await (const token of streamChat(body.messages, brief)) {
|
|
fullResponse += token;
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ token })}\n\n`)
|
|
);
|
|
}
|
|
controller.enqueue(encoder.encode("data: [DONE]\n\n"));
|
|
} catch (err) {
|
|
const message =
|
|
err instanceof Error ? err.message : "Connection failed";
|
|
controller.enqueue(
|
|
encoder.encode(`data: ${JSON.stringify({ error: message })}\n\n`)
|
|
);
|
|
} finally {
|
|
controller.close();
|
|
|
|
// 5. Store assistant response in Synap (after stream ends)
|
|
if (conversationId && fullResponse) {
|
|
sendMessage("aeries", fullResponse, conversationId).catch((err) =>
|
|
console.error(
|
|
"[synap] failed to store assistant message:",
|
|
err.message
|
|
)
|
|
);
|
|
}
|
|
|
|
// 6. Fire deep observer (non-blocking)
|
|
if (conversationId && lastUserMsg && fullResponse) {
|
|
fireDeepObserver(
|
|
lastUserMsg.content,
|
|
fullResponse,
|
|
conversationId,
|
|
requestTimestamp,
|
|
body.messages,
|
|
personId
|
|
).catch((err) =>
|
|
console.error("[observe] failed:", err.message)
|
|
);
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache",
|
|
Connection: "keep-alive",
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Deep observer: Tier 1 structured extraction + Tier 2 periodic synthesis + profile update. */
|
|
async function fireDeepObserver(
|
|
userMessage: string,
|
|
assistantMessage: string,
|
|
conversationId: string,
|
|
requestTimestamp: number,
|
|
allMessages: { role: string; content: string }[],
|
|
personId?: string
|
|
): Promise<void> {
|
|
const {
|
|
extractObserverOutput,
|
|
outputToSignalMemories,
|
|
storeSignals,
|
|
synthesizeObservations,
|
|
storeObservations,
|
|
} = await import("@/lib/observer");
|
|
|
|
// Track turns
|
|
const turn = (turnCounts.get(conversationId) ?? 0) + 1;
|
|
turnCounts.set(conversationId, turn);
|
|
|
|
// Compute latency (time between request arrival and now — approximation)
|
|
const latencySeconds = Math.round((Date.now() - requestTimestamp) / 1000);
|
|
|
|
// Tier 1: Structured extraction
|
|
const output = await extractObserverOutput(userMessage, assistantMessage, {
|
|
turnNumber: turn,
|
|
latencySeconds,
|
|
previousTopic: lastTopics.get(conversationId),
|
|
});
|
|
|
|
if (!output) {
|
|
console.log(`[observer] turn ${turn}: extraction returned null`);
|
|
return;
|
|
}
|
|
|
|
console.log(
|
|
`[observer] turn ${turn}: topic=${output.topic.primary}, sentiment=${output.engagement.sentiment_score}, formality=${output.style.formality}`
|
|
);
|
|
|
|
// Track topic for next turn's context
|
|
lastTopics.set(conversationId, output.topic.primary);
|
|
|
|
// Store dimension-tagged signals in Synap (with person tag)
|
|
const memories = outputToSignalMemories(output, conversationId, personId);
|
|
await storeSignals(memories);
|
|
|
|
// Buffer for synthesis
|
|
const buffer = signalBuffers.get(conversationId) ?? [];
|
|
buffer.push(output);
|
|
// Keep only last 10 to bound memory
|
|
if (buffer.length > 10) buffer.shift();
|
|
signalBuffers.set(conversationId, buffer);
|
|
|
|
// Tier 2: Periodic synthesis (every N turns)
|
|
if (turn % SYNTHESIS_INTERVAL === 0 && buffer.length >= 3) {
|
|
console.log(`[observer] turn ${turn}: running Tier 2 synthesis`);
|
|
|
|
// Build conversation snippet from last few messages
|
|
const snippet = allMessages
|
|
.slice(-6)
|
|
.map((m) => `${m.role}: ${m.content.slice(0, 100)}`)
|
|
.join("\n");
|
|
|
|
const observations = await synthesizeObservations(buffer, snippet);
|
|
if (observations.length) {
|
|
await storeObservations(observations, conversationId, personId);
|
|
}
|
|
}
|
|
|
|
if (personId) {
|
|
await recordObserverPersonalization({
|
|
personId,
|
|
conversationId,
|
|
turn,
|
|
assistantMessage,
|
|
output,
|
|
}).catch((err) =>
|
|
console.error("[tidal] observer personalization write failed:", err)
|
|
);
|
|
}
|
|
|
|
// M4: Update person profile after signal extraction
|
|
if (personId) {
|
|
const { computeProfile, storeProfile, loadProfile } = await import(
|
|
"@/lib/cohorts"
|
|
);
|
|
|
|
const existing = await loadProfile(personId);
|
|
const updated = computeProfile([output], existing, personId);
|
|
|
|
console.log(
|
|
`[cohorts] updated profile for ${personId.slice(0, 8)}…: interactions=${updated.interactionCount}, cohorts=[${updated.cohorts.map((c) => `${c.cohort}(${c.probability.toFixed(2)})`).join(", ")}]`
|
|
);
|
|
|
|
await storeProfile(updated);
|
|
}
|
|
}
|