diff --git a/changelog.d/features/7530-compression-guidance.md b/changelog.d/features/7530-compression-guidance.md new file mode 100644 index 0000000000..10a6593049 --- /dev/null +++ b/changelog.d/features/7530-compression-guidance.md @@ -0,0 +1 @@ +- **feat(dashboard):** surface in-product guidance for the Settings → Prompt Compression engines — each `engineCatalog.ts` entry now carries a `guidance` block (quality/latency tradeoffs, lossy flag, cache impact) sourced from `docs/compression/*.md`, rendered as an expandable per-engine detail with a "safe default" indicator for lossless engines (Session Dedup, CCR, Lite, Headroom) plus a link to the full compression guide (#7530). diff --git a/open-sse/services/compression/engineCatalog.ts b/open-sse/services/compression/engineCatalog.ts index 09ab676720..df43a94877 100644 --- a/open-sse/services/compression/engineCatalog.ts +++ b/open-sse/services/compression/engineCatalog.ts @@ -1,3 +1,23 @@ +// Cache impact is a qualitative estimate of how much an engine's per-request output +// variance disrupts upstream prompt-prefix caching (e.g. Anthropic/OpenAI prompt +// caching): "none"/"low" = deterministic, cache-friendly; "high" = output shape varies +// enough across requests (summarization, query-dependent pruning) that cached prefixes +// are less likely to be reused. Source: docs/compression/COMPRESSION_GUIDE.md, +// docs/compression/COMPRESSION_ENGINES.md (#7530). +export type CacheImpact = "none" | "low" | "moderate" | "high"; + +export interface EngineGuidance { + // Short, in-product explanation of the quality/latency tradeoff — adapted from + // docs/compression/COMPRESSION_GUIDE.md / COMPRESSION_ENGINES.md, not invented. + tradeoffs: string; + // true = the engine can drop/alter content a later turn might have needed (semantic + // condensation, summarization, pruning). false = structural/formatting-only, safe to + // leave on. "Safe default" status is DERIVED from this flag (see isSafeDefault) rather + // than duplicated as its own field. + lossy: boolean; + cacheImpact: CacheImpact; +} + export interface EngineMeta { id: string; label: string; @@ -5,6 +25,7 @@ export interface EngineMeta { levels?: string[]; // intensity options; undefined = no level selector isSingleMode: boolean; // can be the effective mode when it is the only engine on description: string; + guidance: EngineGuidance; } export const ENGINE_CATALOG: Record = { @@ -14,6 +35,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 3, isSingleMode: false, description: "Cross-turn block deduplication.", + guidance: { + tradeoffs: + "Lossless — elides only text already sent earlier in the same session; nothing is summarized or dropped. Negligible latency overhead.", + lossy: false, + cacheImpact: "low", + }, }, ccr: { id: "ccr", @@ -21,6 +48,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 4, isSingleMode: false, description: "Content-addressed retrieval markers.", + guidance: { + tradeoffs: + "Lossless — replaces large repeated/contiguous blocks with content-addressed references instead of deleting them; the original content stays retrievable.", + lossy: false, + cacheImpact: "low", + }, }, lite: { id: "lite", @@ -28,6 +61,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 5, isSingleMode: true, description: "Whitespace/format cleanup.", + guidance: { + tradeoffs: + "Safest mode (~15% savings, <1ms latency): whitespace/dedup/formatting cleanup only, zero semantic change. Always safe to leave on.", + lossy: false, + cacheImpact: "none", + }, }, rtk: { id: "rtk", @@ -36,6 +75,12 @@ export const ENGINE_CATALOG: Record = { levels: ["minimal", "standard", "aggressive"], isSingleMode: true, description: "Command-output filtering.", + guidance: { + tradeoffs: + "Strips ANSI noise, progress bars, and repeated lines from command/tool output while preserving failures, warnings, and summaries (60-90% upstream savings). The 'aggressive' level trims more tail context than 'minimal'/'standard'.", + lossy: true, + cacheImpact: "moderate", + }, }, headroom: { id: "headroom", @@ -43,6 +88,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 15, isSingleMode: false, description: "Tabular JSON compaction.", + guidance: { + tradeoffs: + "Lossless columnar compaction (SmartCrusher) of homogeneous JSON-array payloads into a compact '[N rows]' form — no data is discarded.", + lossy: false, + cacheImpact: "low", + }, }, relevance: { id: "relevance", @@ -50,6 +101,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 18, isSingleMode: true, description: "Extractive sentence scoring against the last user query.", + guidance: { + tradeoffs: + "Drops sentences scored as less relevant to the last user query — output depends on the query, so it can omit context a later turn needs.", + lossy: true, + cacheImpact: "moderate", + }, }, caveman: { id: "caveman", @@ -58,6 +115,12 @@ export const ENGINE_CATALOG: Record = { levels: ["lite", "full", "ultra"], isSingleMode: true, description: "Rule-based prose compression.", + guidance: { + tradeoffs: + "Rule-based prose condensation (~30% savings at 'full'): strips filler and hedging while preserving meaning, but rewrites text so it is not byte-identical to the original.", + lossy: true, + cacheImpact: "moderate", + }, }, aggressive: { id: "aggressive", @@ -65,6 +128,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 30, isSingleMode: true, description: "Summarize + age old turns.", + guidance: { + tradeoffs: + "Summarizes and progressively ages older turns (~50% savings) — trades older-turn fidelity for context headroom in long sessions; summarized turns can't be perfectly reconstructed.", + lossy: true, + cacheImpact: "high", + }, }, llmlingua: { id: "llmlingua", @@ -72,6 +141,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 35, isSingleMode: false, description: "Semantic pruning (ONNX).", + guidance: { + tradeoffs: + "Semantic token pruning via a small ONNX classifier — removes individual tokens judged low-information. Fail-opens (returns the original text) on any error, so the worst case is no savings, never corruption.", + lossy: true, + cacheImpact: "high", + }, }, ultra: { id: "ultra", @@ -79,6 +154,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 40, isSingleMode: true, description: "Heuristic token pruning (+ optional SLM).", + guidance: { + tradeoffs: + "Maximum-compression mode (~75% savings): heuristic pruning, code-block thinning, and binary-search truncation. Highest risk of losing context a later turn depended on — best reserved for hitting context limits.", + lossy: true, + cacheImpact: "high", + }, }, omniglyph: { id: "omniglyph", @@ -86,6 +167,12 @@ export const ENGINE_CATALOG: Record = { stackPriority: 90, isSingleMode: true, description: "Contexto-como-imagem (Claude Fable 5, rota direta).", + guidance: { + tradeoffs: + "Experimental context-as-image encoding routed directly to Claude Fable 5 only — the most aggressive and least broadly compatible option; not recommended as a general-purpose default.", + lossy: true, + cacheImpact: "high", + }, }, }; @@ -96,3 +183,11 @@ export const ENGINE_IDS: string[] = Object.values(ENGINE_CATALOG) export function engineMeta(id: string): EngineMeta { return ENGINE_CATALOG[id]; } + +// "Safe default" = not lossy. Derived rather than a duplicated stored field (open +// question resolved in #7530: engineCatalog had no prior lossy-style attribute, so we +// added one and compute safe-default from it instead of two independently-maintained +// booleans). +export function isSafeDefault(id: string): boolean { + return !engineMeta(id).guidance.lossy; +} diff --git a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx index da6a856b06..3e416834c9 100644 --- a/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx +++ b/src/app/(dashboard)/dashboard/context/settings/CompressionPanel.tsx @@ -28,6 +28,7 @@ import { outputStyleMeta, } from "../../../../../../open-sse/services/compression/outputStyles/catalog.ts"; import { deriveDefaultPlan } from "../../../../../../open-sse/services/compression/deriveDefaultPlan.ts"; +import EngineGuidanceDetail from "./EngineGuidanceDetail"; import { DEFAULT_CONTEXT_BUDGET, type ContextBudgetConfig, @@ -99,6 +100,9 @@ export default function CompressionPanel() { const uiLang = (useLocale() || "en").split("-")[0]; const [config, setConfig] = useState(DEFAULT_CONFIG); const [mcpAccessibility, setMcpAccessibility] = useState(true); + // #7530 — per-engine expandable guidance (tradeoffs/lossy/cache-impact); collapsed by + // default so the grid stays scannable. + const [expandedGuidance, setExpandedGuidance] = useState>({}); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [status, setStatus] = useState<"" | "saved" | "error">(""); @@ -163,6 +167,10 @@ export default function CompressionPanel() { save({ engines }); }; + const toggleGuidance = (id: string) => { + setExpandedGuidance((prev) => ({ ...prev, [id]: !prev[id] })); + }; + const setOutputStyle = (id: string, patch: { enabled?: boolean; level?: CavemanIntensity }) => { const current = config.outputStyles ?? []; const existing = current.find((s) => s.id === id); @@ -225,6 +233,18 @@ export default function CompressionPanel() {

{t("compressionTitle")}

{t("compressionDesc")}

+ + {t("compressionGuidanceFullGuideLink")} + +
@@ -290,6 +310,12 @@ export default function CompressionPanel() {

{meta.description}

+ toggleGuidance(id)} + />
{levels && ( diff --git a/src/app/(dashboard)/dashboard/context/settings/EngineGuidanceDetail.tsx b/src/app/(dashboard)/dashboard/context/settings/EngineGuidanceDetail.tsx new file mode 100644 index 0000000000..4e72a1a66a --- /dev/null +++ b/src/app/(dashboard)/dashboard/context/settings/EngineGuidanceDetail.tsx @@ -0,0 +1,74 @@ +"use client"; + +// EngineGuidanceDetail — per-engine expandable guidance block for CompressionPanel +// (#7530). Surfaces the `guidance` metadata from engineCatalog.ts (tradeoffs, lossy +// flag → "safe default" badge, cache impact) so operators can see quality/latency +// tradeoffs without leaving Settings. Extracted into its own component to keep +// CompressionPanel's per-row JSX flat (cognitive-complexity ratchet). +// +// Like the rest of the engine row, the guidance copy itself is catalog-hardcoded +// English (not i18n) so it stays deterministic; only the surrounding chrome (toggle +// button, badge, "cache impact" label) is translated. + +import { useTranslations } from "next-intl"; +import type { CacheImpact, EngineGuidance } from "../../../../../../open-sse/services/compression/engineCatalog.ts"; + +const CACHE_IMPACT_LABEL: Record = { + none: "None", + low: "Low", + moderate: "Moderate", + high: "High", +}; + +interface EngineGuidanceDetailProps { + id: string; + guidance: EngineGuidance; + expanded: boolean; + onToggle: () => void; +} + +export default function EngineGuidanceDetail({ + id, + guidance, + expanded, + onToggle, +}: EngineGuidanceDetailProps) { + const t = useTranslations("settings"); + return ( +
+
+ {!guidance.lossy && ( + + {t("compressionGuidanceSafeDefault")} + + )} + +
+ {expanded && ( +
+

{guidance.tradeoffs}

+

+ + {t("compressionGuidanceCacheImpact")}: + {" "} + {CACHE_IMPACT_LABEL[guidance.cacheImpact]} +

+
+ )} +
+ ); +} diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9ac7b9806a..3107e2c6b8 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -5729,6 +5729,11 @@ "clearSyncedPricing": "Clear Synced Pricing", "compressionTitle": "Prompt Compression", "compressionDesc": "Reduce token usage by compressing prompts before sending to providers", + "compressionGuidanceFullGuideLink": "Full compression guide", + "compressionGuidanceShow": "Details", + "compressionGuidanceHide": "Hide details", + "compressionGuidanceSafeDefault": "Safe default", + "compressionGuidanceCacheImpact": "Cache impact", "tokenSaverTitle": "Token Saver", "tokenSaverSubtitle": "Spend fewer tokens on every request.", "tokenSaverToolOutput": "Tool output", diff --git a/src/i18n/messages/pt-BR.json b/src/i18n/messages/pt-BR.json index c5de20dd48..1ad5153121 100644 --- a/src/i18n/messages/pt-BR.json +++ b/src/i18n/messages/pt-BR.json @@ -5691,6 +5691,11 @@ "clearSyncedPricing": "Limpar Sync", "compressionTitle": "Prompt Compression", "compressionDesc": "Reduce token usage by compressing prompts before sending to providers", + "compressionGuidanceFullGuideLink": "Guia completo de compressão", + "compressionGuidanceShow": "Detalhes", + "compressionGuidanceHide": "Ocultar detalhes", + "compressionGuidanceSafeDefault": "Padrão seguro", + "compressionGuidanceCacheImpact": "Impacto no cache", "tokenSaverTitle": "__MISSING__:Token Saver", "tokenSaverSubtitle": "__MISSING__:Spend fewer tokens on every request.", "tokenSaverToolOutput": "__MISSING__:Tool output", diff --git a/tests/unit/compression/engine-guidance-7530.test.ts b/tests/unit/compression/engine-guidance-7530.test.ts new file mode 100644 index 0000000000..3021b486b4 --- /dev/null +++ b/tests/unit/compression/engine-guidance-7530.test.ts @@ -0,0 +1,54 @@ +// #7530 — in-product guidance for Prompt Compression engines. +// +// Regression guard for the richer `guidance` metadata on every engineCatalog.ts entry +// (tradeoffs, lossy flag, cache impact) and the derived "safe default" helper. This is +// the TDD-required test (Hard Rule #18): it FAILED before the `guidance` field existed +// (every `engineMeta(id).guidance` was `undefined`) and passes now that every catalog +// entry carries complete guidance. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ENGINE_IDS, + engineMeta, + isSafeDefault, + type CacheImpact, +} from "@omniroute/open-sse/services/compression/engineCatalog.ts"; + +const VALID_CACHE_IMPACTS: CacheImpact[] = ["none", "low", "moderate", "high"]; + +test("every engine in the catalog has a complete guidance entry", () => { + for (const id of ENGINE_IDS) { + const meta = engineMeta(id); + assert.ok(meta.guidance, `${id} is missing a guidance entry`); + assert.equal(typeof meta.guidance.tradeoffs, "string", `${id} guidance.tradeoffs must be a string`); + assert.ok( + meta.guidance.tradeoffs.length >= 20, + `${id} guidance.tradeoffs reads as a placeholder (too short): "${meta.guidance.tradeoffs}"` + ); + assert.equal(typeof meta.guidance.lossy, "boolean", `${id} guidance.lossy must be a boolean`); + assert.ok( + VALID_CACHE_IMPACTS.includes(meta.guidance.cacheImpact), + `${id} guidance.cacheImpact "${meta.guidance.cacheImpact}" is not one of ${VALID_CACHE_IMPACTS.join(", ")}` + ); + } +}); + +test("isSafeDefault is derived from guidance.lossy (no duplicated flag)", () => { + for (const id of ENGINE_IDS) { + assert.equal(isSafeDefault(id), !engineMeta(id).guidance.lossy, `${id} safe-default mismatch`); + } +}); + +test("lossless structural engines are flagged as safe defaults", () => { + for (const id of ["session-dedup", "ccr", "lite", "headroom"]) { + assert.equal(isSafeDefault(id), true, `${id} should be a safe default (lossless)`); + assert.equal(engineMeta(id).guidance.lossy, false, `${id} should be marked non-lossy`); + } +}); + +test("lossy semantic-condensation engines are NOT flagged as safe defaults", () => { + for (const id of ["rtk", "relevance", "caveman", "aggressive", "llmlingua", "ultra", "omniglyph"]) { + assert.equal(isSafeDefault(id), false, `${id} should NOT be a safe default (lossy)`); + assert.equal(engineMeta(id).guidance.lossy, true, `${id} should be marked lossy`); + } +}); diff --git a/tests/unit/ui/compression-guidance-7530.test.tsx b/tests/unit/ui/compression-guidance-7530.test.tsx new file mode 100644 index 0000000000..f9d3ac46c6 --- /dev/null +++ b/tests/unit/ui/compression-guidance-7530.test.tsx @@ -0,0 +1,163 @@ +// @vitest-environment jsdom +// +// #7530 — in-product guidance for Prompt Compression engines. +// Asserts the Settings -> Prompt Compression panel surfaces each engine's guidance +// detail (expand/collapse), shows the "safe default" indicator only for lossless +// engines, and links out to the full compression guide. +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + ENGINE_IDS, + engineMeta, + isSafeDefault, +} from "../../../open-sse/services/compression/engineCatalog.ts"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, + useLocale: () => "en", +})); + +const containers: HTMLElement[] = []; +const roots: Array<{ unmount: () => void }> = []; + +function mount(ui: React.ReactElement): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + containers.push(container); + const root = createRoot(container); + roots.push(root); + act(() => { + root.render(ui); + }); + return container; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await act(async () => { + while (roots.length > 0) { + roots.pop()?.unmount(); + } + }); + for (let i = 0; i < 10; i++) { + await Promise.resolve(); + } + while (containers.length > 0) { + containers.pop()?.remove(); + } + document.body.innerHTML = ""; +}); + +async function flush() { + await act(async () => { + for (let i = 0; i < 10; i++) await Promise.resolve(); + }); +} + +function setupFetchMock() { + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); + + const initialConfig = { + enabled: true, + autoTriggerTokens: 0, + preserveSystemPrompt: true, + engines: {}, + activeComboId: null, + cavemanOutputMode: { enabled: false, intensity: "full", autoClarity: true }, + }; + + vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL) => { + const url = input.toString(); + if (url.includes("/api/settings/compression/mcp-accessibility")) { + return json({ enabled: true, maxTextChars: 50000 }); + } + if (url.includes("/api/settings/compression")) { + return json(initialConfig); + } + return json({}, 404); + }); +} + +describe("CompressionPanel — engine guidance (#7530)", () => { + it("links to the full compression guide", async () => { + setupFetchMock(); + const { default: CompressionPanel } = await import( + "@/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const link = container.querySelector('[data-testid="compression-guide-link"]'); + expect(link).toBeTruthy(); + expect(link?.getAttribute("href")).toContain("docs/compression/COMPRESSION_GUIDE.md"); + }); + + it("shows the safe-default badge only for lossless engines", async () => { + setupFetchMock(); + const { default: CompressionPanel } = await import( + "@/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + for (const id of ENGINE_IDS) { + const badge = container.querySelector(`[data-testid="engine-safe-default-${id}"]`); + if (isSafeDefault(id)) { + expect(badge, `${id} is lossless — expected the safe-default badge`).toBeTruthy(); + } else { + expect(badge, `${id} is lossy — should NOT show the safe-default badge`).toBeFalsy(); + } + } + }); + + it("expands an engine's guidance detail (tradeoffs + cache impact) on toggle", async () => { + setupFetchMock(); + const { default: CompressionPanel } = await import( + "@/app/(dashboard)/dashboard/context/settings/CompressionPanel" + ); + + let container!: HTMLElement; + await act(async () => { + container = mount(); + }); + await flush(); + + const engineId = "caveman"; + expect( + container.querySelector(`[data-testid="engine-guidance-detail-${engineId}"]`) + ).toBeFalsy(); + + const toggle = container.querySelector( + `[data-testid="engine-guidance-toggle-${engineId}"]` + ) as HTMLButtonElement | null; + expect(toggle, "guidance toggle button must exist").toBeTruthy(); + + await act(async () => { + toggle!.click(); + }); + await flush(); + + const detail = container.querySelector(`[data-testid="engine-guidance-detail-${engineId}"]`); + expect(detail).toBeTruthy(); + expect(detail?.textContent).toContain(engineMeta(engineId).guidance.tradeoffs); + }); +});