diff --git a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts index e9845d334..72d23630a 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/anthropic.ts @@ -336,6 +336,103 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Anthropic Messages transport for the topic classifier + arbitration. +// The prompt strings (TOPIC_CLASSIFIER_PROMPT / TOPIC_ARBITRATION_PROMPT) and +// the parser (parseTopicClassifyResult) are re-imported from openai.ts so all +// providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Anthropic Messages transport for the topic classifier + arbitration. +// Scoped to these two callers only so pre-existing anthropic helpers keep +// their inline implementations (minimum-diff discipline). Header order below +// is deliberate: cfg.headers is spread FIRST so user-supplied headers cannot +// override the credential and required `anthropic-version` that follow. +async function callAnthropicMessagesTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Anthropic ${errorLabel}: apiKey is required`); + const endpoint = cfg.endpoint ?? "https://api.anthropic.com/v1/messages"; + const model = cfg.model ?? "claude-3-haiku-20240307"; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + "x-api-key": cfg.apiKey, + "anthropic-version": "2023-06-01", + }; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + temperature: 0, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Anthropic ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { content?: Array<{ type: string; text: string }> }; + const content = Array.isArray(json?.content) ? json.content : []; + return content.find((c) => c.type === "text")?.text?.trim() ?? ""; +} + +export async function classifyTopicAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callAnthropicMessagesTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callAnthropicMessagesTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupAnthropic( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts index 1fcd0b359..a62640324 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/bedrock.ts @@ -344,6 +344,109 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Bedrock Converse transport for the topic classifier + arbitration. +// Shares prompt strings and the JSON parser with openai.ts so all providers +// stay behaviorally identical modulo the wire format. `endpoint` is required +// here, matching every other Bedrock helper in this file. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Default Bedrock model for the topic-classifier / arbitration helpers. +// Scoped to the two functions below to avoid churn in pre-existing helpers. +const DEFAULT_BEDROCK_TOPIC_MODEL = "anthropic.claude-3-haiku-20240307-v1:0"; + +// Shared Converse transport used by the topic classifier and arbitration. +// Only these two callers use it — pre-existing bedrock helpers keep their +// original inline implementations to minimise diff. +async function bedrockConverseTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + const model = cfg.model ?? DEFAULT_BEDROCK_TOPIC_MODEL; + const endpoint = cfg.endpoint; + if (!endpoint) { + throw new Error(`Bedrock ${errorLabel} requires 'endpoint'`); + } + + const url = `${endpoint}/model/${model}/converse`; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + system: [{ text: systemPrompt }], + messages: [{ role: "user", content: [{ text: userContent }] }], + inferenceConfig: { temperature: 0, maxTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Bedrock ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { output?: { message?: { content?: Array<{ text: string }> } } }; + return json.output?.message?.content?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await bedrockConverseTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await bedrockConverseTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Bedrock topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Bedrock topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupBedrock( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts index 3fafe570d..eab5b3d5c 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/gemini.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/gemini.ts @@ -336,6 +336,106 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Gemini generateContent transport for the topic classifier + +// arbitration. Shares prompt strings and the JSON parser with openai.ts so +// all providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Gemini generateContent transport for the topic classifier + +// arbitration. Scoped to these two callers only so pre-existing gemini +// helpers keep their inline URL construction (minimum-diff discipline). +async function callGeminiTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxOutputTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Gemini ${errorLabel}: apiKey is required`); + const model = cfg.model ?? "gemini-1.5-flash"; + const endpoint = + cfg.endpoint ?? + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + + const u = new URL(endpoint); + u.searchParams.set("key", cfg.apiKey); + const url = u.toString(); + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userContent }] }], + generationConfig: { temperature: 0, maxOutputTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Gemini ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text: string }> } }> }; + return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callGeminiTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callGeminiTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Gemini topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Gemini topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupGemini( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/apps/memos-local-openclaw/src/ingest/providers/index.ts b/apps/memos-local-openclaw/src/ingest/providers/index.ts index c5dd1a85c..972930f9e 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/index.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/index.ts @@ -5,9 +5,9 @@ import { parseJsonOrJson5 } from "../../shared/json5"; import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai"; import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; -import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; -import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini"; -import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock"; +import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic, classifyTopicAnthropic, arbitrateTopicSplitAnthropic } from "./anthropic"; +import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini, classifyTopicGemini, arbitrateTopicSplitGemini } from "./gemini"; +import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock, classifyTopicBedrock, arbitrateTopicSplitBedrock } from "./bedrock"; /** * Resolve a SecretInput (string | SecretRef) to a plain string. @@ -714,9 +714,11 @@ function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessag case "voyage": return classifyTopicOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return classifyTopicAnthropic(taskState, newMessage, cfg, log); case "gemini": + return classifyTopicGemini(taskState, newMessage, cfg, log); case "bedrock": - return classifyTopicOpenAI(taskState, newMessage, cfg, log); + return classifyTopicBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } @@ -737,9 +739,11 @@ function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessa case "voyage": return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return arbitrateTopicSplitAnthropic(taskState, newMessage, cfg, log); case "gemini": + return arbitrateTopicSplitGemini(taskState, newMessage, cfg, log); case "bedrock": - return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + return arbitrateTopicSplitBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } diff --git a/apps/memos-local-openclaw/src/ingest/providers/openai.ts b/apps/memos-local-openclaw/src/ingest/providers/openai.ts index 60f878e4c..3455a02b5 100644 --- a/apps/memos-local-openclaw/src/ingest/providers/openai.ts +++ b/apps/memos-local-openclaw/src/ingest/providers/openai.ts @@ -269,7 +269,7 @@ export interface TopicClassifyResult { reason: string; // may be empty for compact responses } -const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +export const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. Sub-questions, tools, methods, details of current topic = S.`; @@ -317,7 +317,7 @@ export async function classifyTopicOpenAI( return parseTopicClassifyResult(raw, log); } -const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +export const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. Reply one word: NEW or SAME`; diff --git a/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts b/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts new file mode 100644 index 000000000..51dee4819 --- /dev/null +++ b/apps/memos-local-openclaw/tests/topic-classifier-dispatch.test.ts @@ -0,0 +1,270 @@ +/** + * Regression test for issue #1611: + * + * `Summarizer.classifyTopic` and `Summarizer.arbitrateTopicSplit` used to + * dispatch to the OpenAI implementation for EVERY provider — including + * `anthropic`, `gemini`, and `bedrock` — because their case arms in the + * `callTopicClassifier` / `callTopicArbitration` switch statements silently + * fell through to `classifyTopicOpenAI` / `arbitrateTopicSplitOpenAI`. + * + * That broke summarizers configured against Anthropic-only endpoints (e.g. + * Kimi Code's `/coding/v1/messages`), where the OpenAI helper's + * `/chat/completions` URL simply doesn't exist and every call 404'd. + * + * These tests stub `globalThis.fetch` and assert that each provider hits the + * transport-appropriate URL with a body shape the target API actually + * accepts. A regression that routed `anthropic` back through the OpenAI + * transport would either hit `/chat/completions` (wrong URL) or emit an + * `OpenAI topic-classifier failed` error string (wrong label) — both are + * asserted below. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { Summarizer } from "../src/ingest/providers"; +import type { SummarizerConfig, Logger } from "../src/types"; + +const silentLog: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +}; + +interface CapturedRequest { + url: string; + headers: Record; + body: Record; +} + +/** + * Replace global.fetch with a recorder. Each invocation returns a canned + * successful response whose shape matches `provider`. + */ +function installFetchRecorder( + provider: "openai" | "anthropic" | "gemini" | "bedrock", + replyText: string, +): CapturedRequest[] { + const captured: CapturedRequest[] = []; + + const buildResponse = () => { + switch (provider) { + case "openai": + return { choices: [{ message: { content: replyText } }] }; + case "anthropic": + return { content: [{ type: "text", text: replyText }] }; + case "gemini": + return { candidates: [{ content: { parts: [{ text: replyText }] } }] }; + case "bedrock": + return { output: { message: { content: [{ text: replyText }] } } }; + } + }; + + const fakeFetch = vi.fn(async (url: string | URL, init?: RequestInit) => { + const body = init?.body ? JSON.parse(init.body as string) : {}; + const headers: Record = {}; + if (init?.headers) { + const h = init.headers as Record; + for (const [k, v] of Object.entries(h)) headers[k] = String(v); + } + captured.push({ url: String(url), headers, body }); + return new Response(JSON.stringify(buildResponse()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + + vi.stubGlobal("fetch", fakeFetch); + return captured; +} + +function installErrorFetch(status: number, errorBody: string): void { + const fakeFetch = vi.fn(async () => + new Response(errorBody, { status, headers: { "Content-Type": "application/json" } }), + ); + vi.stubGlobal("fetch", fakeFetch); +} + +describe("classifyTopic / arbitrateTopicSplit dispatch by provider (issue #1611)", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("openai_compatible → POSTs /chat/completions with system+user messages", async () => { + const captured = installFetchRecorder("openai", '{"d":"S","c":0.9}'); + const cfg: SummarizerConfig = { + provider: "openai_compatible", + endpoint: "https://api.example.com/v1", + apiKey: "sk-test", + model: "test-model", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("SAME"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe("https://api.example.com/v1/chat/completions"); + expect(captured[0].headers.Authorization).toBe("Bearer sk-test"); + const msgs = captured[0].body.messages as Array<{ role: string; content: string }>; + expect(msgs[0].role).toBe("system"); + expect(msgs[0].content).toContain("Classify if NEW MESSAGE"); + expect(msgs[1].role).toBe("user"); + expect(msgs[1].content).toContain("TASK:\ntask state"); + }); + + it("anthropic → POSTs /v1/messages with x-api-key and system prompt (regression: was hitting /chat/completions)", async () => { + const captured = installFetchRecorder("anthropic", '{"d":"N","c":0.85}'); + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("NEW"); + expect(result?.confidence).toBeCloseTo(0.85); + + expect(captured).toHaveLength(1); + // Must NOT contain /chat/completions — that's the pre-fix bug. + expect(captured[0].url).not.toContain("/chat/completions"); + expect(captured[0].url).toBe("https://api.kimi.com/coding/v1/messages"); + expect(captured[0].headers["x-api-key"]).toBe("kimi-test"); + expect(captured[0].headers["anthropic-version"]).toBe("2023-06-01"); + expect(captured[0].body.system).toContain("Classify if NEW MESSAGE"); + expect(captured[0].body.model).toBe("kimi-for-coding"); + const anthropicMessages = captured[0].body.messages as Array<{ role: string; content: string }>; + expect(anthropicMessages[0].role).toBe("user"); + expect(anthropicMessages[0].content).toContain("TASK:\ntask state"); + }); + + it("gemini → POSTs :generateContent with systemInstruction and apiKey query param", async () => { + const captured = installFetchRecorder("gemini", '{"d":"S","c":0.7}'); + const cfg: SummarizerConfig = { + provider: "gemini", + apiKey: "gemini-test", + model: "gemini-1.5-flash", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("SAME"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toContain(":generateContent?key=gemini-test"); + expect(captured[0].url).toContain("gemini-1.5-flash"); + // Body must use Gemini's structured shape, not OpenAI's. + expect(captured[0].body.messages).toBeUndefined(); + const sysInstr = captured[0].body.systemInstruction as { parts: Array<{ text: string }> }; + expect(sysInstr.parts[0].text).toContain("Classify if NEW MESSAGE"); + const contents = captured[0].body.contents as Array<{ parts: Array<{ text: string }> }>; + expect(contents[0].parts[0].text).toContain("TASK:\ntask state"); + }); + + it("bedrock → POSTs /model//converse with system[] and messages[]", async () => { + const captured = installFetchRecorder("bedrock", '{"d":"N","c":0.6}'); + const cfg: SummarizerConfig = { + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com", + apiKey: "aws-test", + model: "anthropic.claude-3-haiku-20240307-v1:0", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.classifyTopic("task state", "new msg"); + expect(result?.decision).toBe("NEW"); + + expect(captured).toHaveLength(1); + expect(captured[0].url).toBe( + "https://bedrock-runtime.us-east-1.amazonaws.com/model/anthropic.claude-3-haiku-20240307-v1:0/converse", + ); + // Body must use Bedrock Converse shape. + expect(captured[0].body.choices).toBeUndefined(); + const system = captured[0].body.system as Array<{ text: string }>; + expect(system[0].text).toContain("Classify if NEW MESSAGE"); + const bedrockMessages = captured[0].body.messages as Array<{ role: string; content: Array<{ text: string }> }>; + expect(bedrockMessages[0].role).toBe("user"); + expect(bedrockMessages[0].content[0].text).toContain("TASK:\ntask state"); + }); + + it("arbitrateTopicSplit for anthropic → POSTs /v1/messages, returns NEW/SAME", async () => { + const captured = installFetchRecorder("anthropic", "NEW"); + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("NEW"); + expect(captured[0].url).toBe("https://api.kimi.com/coding/v1/messages"); + expect(captured[0].body.system).toContain("A classifier flagged this message"); + }); + + it("arbitrateTopicSplit for gemini → POSTs :generateContent, returns SAME on non-NEW reply", async () => { + const captured = installFetchRecorder("gemini", "same"); + const cfg: SummarizerConfig = { + provider: "gemini", + apiKey: "gemini-test", + model: "gemini-1.5-flash", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("SAME"); + expect(captured[0].url).toContain(":generateContent?key=gemini-test"); + }); + + it("arbitrateTopicSplit for bedrock → POSTs /converse, uses system[] shape", async () => { + const captured = installFetchRecorder("bedrock", "NEW\n"); + const cfg: SummarizerConfig = { + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com", + apiKey: "aws-test", + model: "anthropic.claude-3-haiku-20240307-v1:0", + }; + const sum = new Summarizer(cfg, silentLog); + + const result = await sum.arbitrateTopicSplit("task", "msg"); + expect(result).toBe("NEW"); + expect(captured[0].url).toContain("/model/anthropic.claude-3-haiku-20240307-v1:0/converse"); + const bsystem = captured[0].body.system as Array<{ text: string }>; + expect(bsystem[0].text).toContain("A classifier flagged this message"); + }); + + it("anthropic 404 surfaces as 'Anthropic topic-classifier failed', not 'OpenAI' — the actual issue #1611 signature", async () => { + installErrorFetch( + 404, + '{"error":{"message":"The requested resource was not found","type":"resource_not_found_error"}}', + ); + const errorMessages: string[] = []; + const captureLog: Logger = { + debug: () => {}, + info: () => {}, + warn: (m) => errorMessages.push(m), + error: (m) => errorMessages.push(m), + }; + const cfg: SummarizerConfig = { + provider: "anthropic", + endpoint: "https://api.kimi.com/coding/v1/messages", + apiKey: "kimi-test", + model: "kimi-for-coding", + }; + const sum = new Summarizer(cfg, captureLog); + + const result = await sum.classifyTopic("state", "msg"); + // No fallback configs, so classifyTopic returns null after logging. + expect(result).toBeNull(); + // The recorded error message must reference the Anthropic transport, not OpenAI. + const combined = errorMessages.join("\n"); + expect(combined).toContain("Anthropic topic-classifier failed"); + expect(combined).not.toContain("OpenAI topic-classifier failed"); + }); +}); diff --git a/packages/memos-core/src/ingest/providers/anthropic.ts b/packages/memos-core/src/ingest/providers/anthropic.ts index e9845d334..72d23630a 100644 --- a/packages/memos-core/src/ingest/providers/anthropic.ts +++ b/packages/memos-core/src/ingest/providers/anthropic.ts @@ -336,6 +336,103 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Anthropic Messages transport for the topic classifier + arbitration. +// The prompt strings (TOPIC_CLASSIFIER_PROMPT / TOPIC_ARBITRATION_PROMPT) and +// the parser (parseTopicClassifyResult) are re-imported from openai.ts so all +// providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Anthropic Messages transport for the topic classifier + arbitration. +// Scoped to these two callers only so pre-existing anthropic helpers keep +// their inline implementations (minimum-diff discipline). Header order below +// is deliberate: cfg.headers is spread FIRST so user-supplied headers cannot +// override the credential and required `anthropic-version` that follow. +async function callAnthropicMessagesTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Anthropic ${errorLabel}: apiKey is required`); + const endpoint = cfg.endpoint ?? "https://api.anthropic.com/v1/messages"; + const model = cfg.model ?? "claude-3-haiku-20240307"; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + "x-api-key": cfg.apiKey, + "anthropic-version": "2023-06-01", + }; + + const resp = await fetch(endpoint, { + method: "POST", + headers, + body: JSON.stringify({ + model, + max_tokens: maxTokens, + temperature: 0, + system: systemPrompt, + messages: [{ role: "user", content: userContent }], + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Anthropic ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { content?: Array<{ type: string; text: string }> }; + const content = Array.isArray(json?.content) ? json.content : []; + return content.find((c) => c.type === "text")?.text?.trim() ?? ""; +} + +export async function classifyTopicAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callAnthropicMessagesTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitAnthropic( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callAnthropicMessagesTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupAnthropic( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/bedrock.ts b/packages/memos-core/src/ingest/providers/bedrock.ts index 1fcd0b359..a62640324 100644 --- a/packages/memos-core/src/ingest/providers/bedrock.ts +++ b/packages/memos-core/src/ingest/providers/bedrock.ts @@ -344,6 +344,109 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Bedrock Converse transport for the topic classifier + arbitration. +// Shares prompt strings and the JSON parser with openai.ts so all providers +// stay behaviorally identical modulo the wire format. `endpoint` is required +// here, matching every other Bedrock helper in this file. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Default Bedrock model for the topic-classifier / arbitration helpers. +// Scoped to the two functions below to avoid churn in pre-existing helpers. +const DEFAULT_BEDROCK_TOPIC_MODEL = "anthropic.claude-3-haiku-20240307-v1:0"; + +// Shared Converse transport used by the topic classifier and arbitration. +// Only these two callers use it — pre-existing bedrock helpers keep their +// original inline implementations to minimise diff. +async function bedrockConverseTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxTokens: number, + errorLabel: string, +): Promise { + const model = cfg.model ?? DEFAULT_BEDROCK_TOPIC_MODEL; + const endpoint = cfg.endpoint; + if (!endpoint) { + throw new Error(`Bedrock ${errorLabel} requires 'endpoint'`); + } + + const url = `${endpoint}/model/${model}/converse`; + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + system: [{ text: systemPrompt }], + messages: [{ role: "user", content: [{ text: userContent }] }], + inferenceConfig: { temperature: 0, maxTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Bedrock ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { output?: { message?: { content?: Array<{ text: string }> } } }; + return json.output?.message?.content?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await bedrockConverseTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitBedrock( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await bedrockConverseTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Bedrock topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Bedrock topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupBedrock( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/gemini.ts b/packages/memos-core/src/ingest/providers/gemini.ts index 3fafe570d..eab5b3d5c 100644 --- a/packages/memos-core/src/ingest/providers/gemini.ts +++ b/packages/memos-core/src/ingest/providers/gemini.ts @@ -336,6 +336,106 @@ import { DEDUP_JUDGE_PROMPT, parseDedupResult } from "./openai"; import type { DedupResult } from "./openai"; export type { DedupResult } from "./openai"; +// ─── Structured Topic Classifier / Arbitration ─── +// +// Native Gemini generateContent transport for the topic classifier + +// arbitration. Shares prompt strings and the JSON parser with openai.ts so +// all providers stay behaviorally identical modulo the wire format. + +import { + TOPIC_CLASSIFIER_PROMPT, + TOPIC_ARBITRATION_PROMPT, + parseTopicClassifyResult, +} from "./openai"; +import type { TopicClassifyResult } from "./openai"; +export type { TopicClassifyResult } from "./openai"; + +// Shared Gemini generateContent transport for the topic classifier + +// arbitration. Scoped to these two callers only so pre-existing gemini +// helpers keep their inline URL construction (minimum-diff discipline). +async function callGeminiTopic( + systemPrompt: string, + userContent: string, + cfg: SummarizerConfig, + maxOutputTokens: number, + errorLabel: string, +): Promise { + if (!cfg.apiKey) throw new Error(`Gemini ${errorLabel}: apiKey is required`); + const model = cfg.model ?? "gemini-1.5-flash"; + const endpoint = + cfg.endpoint ?? + `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`; + + const u = new URL(endpoint); + u.searchParams.set("key", cfg.apiKey); + const url = u.toString(); + const headers: Record = { + "Content-Type": "application/json", + ...cfg.headers, + }; + + const resp = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify({ + systemInstruction: { parts: [{ text: systemPrompt }] }, + contents: [{ parts: [{ text: userContent }] }], + generationConfig: { temperature: 0, maxOutputTokens }, + }), + signal: AbortSignal.timeout(cfg.timeoutMs ?? 15_000), + }); + + if (!resp.ok) { + const body = await resp.text(); + throw new Error(`Gemini ${errorLabel} failed (${resp.status}): ${body}`); + } + + const json = (await resp.json()) as { candidates?: Array<{ content?: { parts?: Array<{ text: string }> } }> }; + return json.candidates?.[0]?.content?.parts?.[0]?.text?.trim() ?? ""; +} + +export async function classifyTopicGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const raw = await callGeminiTopic( + TOPIC_CLASSIFIER_PROMPT, + userContent, + cfg, + 60, + "topic-classifier", + ); + log.debug(`Topic classifier raw: "${raw}"`); + return parseTopicClassifyResult(raw, log); +} + +export async function arbitrateTopicSplitGemini( + taskState: string, + newMessage: string, + cfg: SummarizerConfig, + log: Logger, +): Promise { + const userContent = `TASK:\n${taskState}\n\nMSG:\n${newMessage}`; + const text = await callGeminiTopic( + TOPIC_ARBITRATION_PROMPT, + userContent, + cfg, + 10, + "topic-arbitration", + ); + const answer = text.toUpperCase(); + log.debug(`Topic arbitration result: "${answer}"`); + if (!answer) { + log.warn("Gemini topic-arbitration returned empty text; defaulting to SAME"); + } else if (!answer.startsWith("NEW") && !answer.startsWith("SAME")) { + log.warn(`Gemini topic-arbitration returned unexpected value "${answer}"; defaulting to SAME`); + } + return answer.startsWith("NEW") ? "NEW" : "SAME"; +} + export async function judgeDedupGemini( newSummary: string, candidates: Array<{ index: number; summary: string; chunkId: string }>, diff --git a/packages/memos-core/src/ingest/providers/index.ts b/packages/memos-core/src/ingest/providers/index.ts index b08818520..005edc23a 100644 --- a/packages/memos-core/src/ingest/providers/index.ts +++ b/packages/memos-core/src/ingest/providers/index.ts @@ -4,9 +4,9 @@ import type { SummarizerConfig, SummaryProvider, Logger, OpenClawAPI } from "../ import { summarizeOpenAI, summarizeTaskOpenAI, generateTaskTitleOpenAI, judgeNewTopicOpenAI, classifyTopicOpenAI, arbitrateTopicSplitOpenAI, filterRelevantOpenAI, judgeDedupOpenAI, parseFilterResult, parseDedupResult, parseTopicClassifyResult } from "./openai"; import type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; export type { FilterResult, DedupResult, TopicClassifyResult } from "./openai"; -import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic } from "./anthropic"; -import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini } from "./gemini"; -import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock } from "./bedrock"; +import { summarizeAnthropic, summarizeTaskAnthropic, generateTaskTitleAnthropic, judgeNewTopicAnthropic, filterRelevantAnthropic, judgeDedupAnthropic, classifyTopicAnthropic, arbitrateTopicSplitAnthropic } from "./anthropic"; +import { summarizeGemini, summarizeTaskGemini, generateTaskTitleGemini, judgeNewTopicGemini, filterRelevantGemini, judgeDedupGemini, classifyTopicGemini, arbitrateTopicSplitGemini } from "./gemini"; +import { summarizeBedrock, summarizeTaskBedrock, generateTaskTitleBedrock, judgeNewTopicBedrock, filterRelevantBedrock, judgeDedupBedrock, classifyTopicBedrock, arbitrateTopicSplitBedrock } from "./bedrock"; /** * Resolve a SecretInput (string | SecretRef) to a plain string. @@ -713,9 +713,11 @@ function callTopicClassifier(cfg: SummarizerConfig, taskState: string, newMessag case "voyage": return classifyTopicOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return classifyTopicAnthropic(taskState, newMessage, cfg, log); case "gemini": + return classifyTopicGemini(taskState, newMessage, cfg, log); case "bedrock": - return classifyTopicOpenAI(taskState, newMessage, cfg, log); + return classifyTopicBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } @@ -736,9 +738,11 @@ function callTopicArbitration(cfg: SummarizerConfig, taskState: string, newMessa case "voyage": return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); case "anthropic": + return arbitrateTopicSplitAnthropic(taskState, newMessage, cfg, log); case "gemini": + return arbitrateTopicSplitGemini(taskState, newMessage, cfg, log); case "bedrock": - return arbitrateTopicSplitOpenAI(taskState, newMessage, cfg, log); + return arbitrateTopicSplitBedrock(taskState, newMessage, cfg, log); default: throw new Error(`Unknown summarizer provider: ${cfg.provider}`); } diff --git a/packages/memos-core/src/ingest/providers/openai.ts b/packages/memos-core/src/ingest/providers/openai.ts index 825e2131d..45eeb808f 100644 --- a/packages/memos-core/src/ingest/providers/openai.ts +++ b/packages/memos-core/src/ingest/providers/openai.ts @@ -262,7 +262,7 @@ export interface TopicClassifyResult { reason: string; // may be empty for compact responses } -const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. +export const TOPIC_CLASSIFIER_PROMPT = `Classify if NEW MESSAGE continues current task or starts an unrelated one. Output ONLY JSON: {"d":"S"|"N","c":0.0-1.0} d=S(same) or N(new). c=confidence. Default S. Only N if completely unrelated domain. Sub-questions, tools, methods, details of current topic = S.`; @@ -310,7 +310,7 @@ export async function classifyTopicOpenAI( return parseTopicClassifyResult(raw, log); } -const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? +export const TOPIC_ARBITRATION_PROMPT = `A classifier flagged this message as possibly new topic (low confidence). Is it truly UNRELATED, or a sub-question/follow-up? Tools/methods/details of current task = SAME. Shared entity/theme = SAME. Entirely different domain = NEW. Reply one word: NEW or SAME`;