diff --git a/apps/memos-local-plugin/core/memory/l3/abstract.ts b/apps/memos-local-plugin/core/memory/l3/abstract.ts index 0bde96fb3..f5f0ae4b0 100644 --- a/apps/memos-local-plugin/core/memory/l3/abstract.ts +++ b/apps/memos-local-plugin/core/memory/l3/abstract.ts @@ -102,30 +102,11 @@ export async function abstractDraft( temperature: 0.15, malformedRetries: 1, schemaHint: `{"title":"...","domain_tags":["..."],"environment":[{"label":"...","description":"...","evidenceIds":["..."]}],"inference":[...],"constraints":[...],"body":"markdown","confidence":0..1,"supersedes_world_ids":[]}`, - validate: (v) => { - const o = v as Record; - if (typeof o.title !== "string" || !(o.title as string).trim()) { - throw new MemosError( - ERROR_CODES.LLM_OUTPUT_MALFORMED, - "l3.abstraction: 'title' must be a non-empty string", - { got: o.title }, - ); - } - const triple = ["environment", "inference", "constraints"]; - for (const k of triple) { - if (!Array.isArray(o[k])) { - throw new MemosError( - ERROR_CODES.LLM_OUTPUT_MALFORMED, - `l3.abstraction: '${k}' must be an array`, - { got: o[k] }, - ); - } - } - }, }, ); const draft = normaliseDraft(rsp.value); + assertDraftMinimallyUsable(draft); if (deps.validate) deps.validate(draft); return { ok: true, draft }; } catch (err) { @@ -260,13 +241,15 @@ function packPolicy( function normaliseDraft(value: Record): L3AbstractionDraft { const triple = pickTriple(value); + const domainTags = normaliseTags(value.domain_tags); + const body = typeof value.body === "string" ? sanitizeDerivedMarkdown(value.body) : ""; return { - title: sanitizeDerivedText(value.title), - domainTags: normaliseTags(value.domain_tags), + title: deriveTitle(value.title, triple, body, domainTags), + domainTags, environment: triple.environment, inference: triple.inference, constraints: triple.constraints, - body: typeof value.body === "string" ? sanitizeDerivedMarkdown(value.body) : "", + body, confidence: clamp01(typeof value.confidence === "number" ? value.confidence : 0.5), supersedesWorldIds: Array.isArray(value.supersedes_world_ids) ? (value.supersedes_world_ids as unknown[]) @@ -289,17 +272,27 @@ function pickTriple(value: Record): { } function toEntries(raw: unknown): L3AbstractionDraftEntry[] { - if (!Array.isArray(raw)) return []; - return raw + const items = Array.isArray(raw) ? raw : raw == null ? [] : [raw]; + return items .map((r): L3AbstractionDraftEntry | null => { + if (typeof r === "string") { + const description = sanitizeDerivedMarkdown(r); + return description ? { label: "", description, evidenceIds: undefined } : null; + } if (!r || typeof r !== "object") return null; const o = r as Record; - const label = typeof o.label === "string" ? sanitizeDerivedText(o.label) : ""; - const description = typeof o.description === "string" ? sanitizeDerivedMarkdown(o.description) : ""; + const label = firstText(o, ["label", "name", "title", "heading", "key"]); + const description = firstMarkdown(o, [ + "description", + "body", + "text", + "content", + "detail", + "summary", + "value", + ]); if (!label && !description) return null; - const evidenceIds = Array.isArray(o.evidenceIds) - ? (o.evidenceIds as unknown[]).filter((s): s is string => typeof s === "string") - : undefined; + const evidenceIds = normaliseEvidenceIds(o.evidenceIds ?? o.evidence_ids ?? o.evidence); return { label, description, evidenceIds }; }) .filter((e): e is L3AbstractionDraftEntry => e !== null) @@ -328,15 +321,88 @@ function buildBody(draft: L3AbstractionDraft): string { } function normaliseTags(raw: unknown): string[] { - if (!Array.isArray(raw)) return []; + const values = Array.isArray(raw) + ? raw + : typeof raw === "string" + ? raw.split(/[,;\n]/) + : raw == null + ? [] + : [raw]; return dedupeStrings( - (raw as unknown[]) - .filter((s): s is string => typeof s === "string") + values + .map((value) => { + if (typeof value === "string") return value; + if (value && typeof value === "object") { + return firstText(value as Record, ["label", "name", "tag", "value", "key"]); + } + return ""; + }) .map((s) => s.trim().toLowerCase()) .filter((s) => s.length > 0 && s.length < 24), ).slice(0, 6); } +function firstText(o: Record, keys: readonly string[]): string { + for (const key of keys) { + const cleaned = sanitizeDerivedText(o[key]); + if (cleaned) return cleaned; + } + return ""; +} + +function firstMarkdown(o: Record, keys: readonly string[]): string { + for (const key of keys) { + const cleaned = sanitizeDerivedMarkdown(o[key]); + if (cleaned) return cleaned; + } + return ""; +} + +function normaliseEvidenceIds(raw: unknown): string[] | undefined { + const values = Array.isArray(raw) + ? raw + : typeof raw === "string" + ? raw.split(/[,;\n]/) + : []; + const ids = values + .map((value) => sanitizeDerivedText(value)) + .filter((value) => value.length > 0); + return ids.length > 0 ? dedupeStrings(ids) : undefined; +} + +function deriveTitle( + rawTitle: unknown, + triple: ReturnType, + body: string, + domainTags: readonly string[], +): string { + const title = sanitizeDerivedText(rawTitle); + if (title) return title.slice(0, 160); + for (const bucket of [triple.inference, triple.environment, triple.constraints]) { + for (const entry of bucket) { + const candidate = entry.label || entry.description; + if (candidate) return sanitizeDerivedText(candidate).slice(0, 160); + } + } + const bodyLine = body + .split(/\r?\n/) + .map((line) => sanitizeDerivedText(line.replace(/^#+\s*/, ""))) + .find((line) => line.length > 0); + if (bodyLine) return bodyLine.slice(0, 160); + return domainTags.join(" ").slice(0, 160); +} + +function assertDraftMinimallyUsable(draft: L3AbstractionDraft): void { + const hasEntries = + draft.environment.length > 0 || draft.inference.length > 0 || draft.constraints.length > 0; + if (draft.title || draft.body || draft.domainTags.length > 0 || hasEntries) return; + throw new MemosError( + ERROR_CODES.LLM_OUTPUT_MALFORMED, + "l3.abstraction: empty draft after normalisation", + {}, + ); +} + function dedupeStrings(arr: readonly string[]): string[] { return Array.from(new Set(arr)); } diff --git a/apps/memos-local-plugin/tests/unit/memory/l3/abstract.test.ts b/apps/memos-local-plugin/tests/unit/memory/l3/abstract.test.ts index ff759b17f..1f4d72992 100644 --- a/apps/memos-local-plugin/tests/unit/memory/l3/abstract.test.ts +++ b/apps/memos-local-plugin/tests/unit/memory/l3/abstract.test.ts @@ -3,7 +3,7 @@ * - happy path → ok + draft with normalised tags + triple * - LLM disabled → llm_disabled * - LLM throws → llm_failed (no uncaught throw) - * - malformed JSON (missing environment[]) → llm_failed + * - salvageable JSON drafts are normalised before persistence */ import { describe, expect, it } from "vitest"; @@ -180,7 +180,7 @@ describe("memory/l3/abstract", () => { expect(res.detail).toContain("boom"); }); - it("returns llm_failed when the LLM returns missing triple", async () => { + it("salvages a draft with missing triple arrays", async () => { const llm = fakeLlm({ completeJson: { [OP]: { @@ -193,10 +193,75 @@ describe("memory/l3/abstract", () => { { cluster: mkCluster(), evidenceByPolicy: new Map() }, { llm, log, config: cfg() }, ); + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.draft.title).toBe("missing triple"); + expect(res.draft.environment).toEqual([]); + expect(res.draft.inference).toEqual([]); + expect(res.draft.constraints).toEqual([]); + }); + + it("normalises salvageable string/object entries and tag strings", async () => { + const llm = fakeLlm({ + completeJson: { + [OP]: { + title: "", + domain_tags: "Docker, Alpine; pip\nWheels", + environment: "Alpine uses musl libc.", + inference: [{ body: "Binary wheels often fail on musl.", name: "Wheel mismatch" }], + constraints: ["Install system headers before pip."], + confidence: 0.8, + }, + }, + }); + + const res = await abstractDraft( + { cluster: mkCluster(), evidenceByPolicy: new Map() }, + { llm, log, config: cfg() }, + ); + + expect(res.ok).toBe(true); + if (!res.ok) return; + expect(res.draft.title).toBe("Wheel mismatch"); + expect(res.draft.domainTags).toEqual(["docker", "alpine", "pip", "wheels"]); + expect(res.draft.environment).toEqual([ + { label: "", description: "Alpine uses musl libc.", evidenceIds: undefined }, + ]); + expect(res.draft.inference).toEqual([ + { + label: "Wheel mismatch", + description: "Binary wheels often fail on musl.", + evidenceIds: undefined, + }, + ]); + expect(res.draft.constraints).toEqual([ + { label: "", description: "Install system headers before pip.", evidenceIds: undefined }, + ]); + }); + + it("still rejects drafts with no usable content after normalisation", async () => { + const llm = fakeLlm({ + completeJson: { + [OP]: { + title: "", + domain_tags: "", + environment: [], + inference: [], + constraints: [], + body: "", + }, + }, + }); + + const res = await abstractDraft( + { cluster: mkCluster(), evidenceByPolicy: new Map() }, + { llm, log, config: cfg() }, + ); + expect(res.ok).toBe(false); if (res.ok) return; expect(res.reason).toBe("llm_failed"); - expect(res.detail ?? "").toMatch(/environment|inference|constraints/); + expect(res.detail ?? "").toContain("empty draft"); }); it("buildWorldModelRow wires draft + cluster into a persist-ready row", () => { @@ -227,4 +292,3 @@ describe("memory/l3/abstract", () => { expect(row.body).toContain("Environment"); }); }); -