Files
2026-07-13 13:39:12 +08:00

197 lines
6.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Hard-budget post-pass (#17): compress to ≤ N cl100k tokens.
*
* Deterministic, independent of the relevance engine. Splits prose into
* sentences/lines, ranks by average scoreToken ascending, drops the lowest-
* saliency units until the body fits the target, then reconstructs original order.
* Units containing FORCE_PRESERVE_RE anchors (errors, numbers, URLs, code) are
* never dropped.
*/
import type { CompressionResult } from "./types.ts";
import { scoreToken } from "./ultraHeuristic.ts";
import { countTextTokens } from "../../../src/shared/utils/tiktokenCounter.ts";
import { createCompressionStats } from "./stats.ts";
interface HardBudgetOptions {
targetTokens?: number;
targetRatio?: number;
}
/**
* Units containing these patterns must never be dropped.
* Anchored to meaningful signals only — never matches a bare end-of-sentence
* period (which would make every prose line "preserve" → permanent no-op):
* - `\d` digits (numbers, line refs, ports)
* - `https?:\/\/` URLs
* - `(?:Error|…|Traceback):` error/exception headers
* - "```" code fences
* - `^\s*at\s` stack-trace frames (digit-less)
* - `\/[\w.-]+\/` real multi-segment paths (but NOT "and/or")
* - `[A-Za-z_]\w*=\S` key=value (credential/config lines, digit-less)
*/
const UNIT_PRESERVE_RE =
/\d|https?:\/\/|(?:Error|Exception|TypeError|RangeError|SyntaxError|ReferenceError|Traceback):|```|^\s*at\s|\/[\w.-]+\/|[A-Za-z_]\w*=\S/i;
/** Average scoreToken for each word in the unit (sentence/line). */
function scoreUnit(unit: string): number {
const words = unit.split(/\s+/).filter(Boolean);
if (words.length === 0) return 0.5;
const total = words.reduce((sum, w) => sum + scoreToken(w), 0);
return total / words.length;
}
/** Returns true when the unit must never be dropped. */
function mustPreserve(unit: string): boolean {
return UNIT_PRESERVE_RE.test(unit);
}
/**
* Split text into droppable units (lines, optionally sentence-split for long prose lines).
*/
function splitUnits(text: string): string[] {
return text.split(/\n/).flatMap((line) => {
if (line.trim() === "") return [line];
// Only sentence-split pure prose lines (no numbers, URLs, error patterns, code)
if (!UNIT_PRESERVE_RE.test(line) && line.length > 60) {
const sentences = line.split(/(?<=[.!?])\s+/);
return sentences.length > 1 ? sentences : [line];
}
return [line];
});
}
interface TaggedUnit {
i: number;
u: string;
tokens: number;
score: number;
preserve: boolean;
}
function tagUnits(units: string[]): TaggedUnit[] {
return units.map((u, i) => ({
i,
u,
tokens: countTextTokens(u),
score: scoreUnit(u),
preserve: mustPreserve(u),
}));
}
function dropToTarget(tagged: TaggedUnit[], targetTokens: number): Set<number> {
const dropped = new Set<number>();
let tokCount = tagged.reduce((s, x) => s + x.tokens, 0);
// Sort droppable candidates by score ascending (lowest first = drop first)
const candidates = tagged
.filter((x) => !x.preserve)
.sort((a, b) => a.score - b.score);
for (const candidate of candidates) {
if (tokCount <= targetTokens) break;
dropped.add(candidate.i);
tokCount -= candidate.tokens;
}
return dropped;
}
function rebuildText(tagged: TaggedUnit[], dropped: Set<number>): string {
return tagged
.filter((x) => !dropped.has(x.i))
.map((x) => x.u)
.join("\n");
}
function compressText(text: string, targetTokens: number): string {
const currentTokens = countTextTokens(text);
if (currentTokens <= targetTokens) return text;
const units = splitUnits(text);
if (units.length <= 1) return text;
const tagged = tagUnits(units);
const dropped = dropToTarget(tagged, targetTokens);
if (dropped.size === 0) return text;
return rebuildText(tagged, dropped);
}
function extractMessages(body: Record<string, unknown>): Array<{ role: string; content: unknown }> {
const msgs = body.messages;
if (!Array.isArray(msgs)) return [];
return msgs as Array<{ role: string; content: unknown }>;
}
export function applyHardBudget(
body: Record<string, unknown>,
opts: HardBudgetOptions
): CompressionResult {
const { targetTokens, targetRatio } = opts;
if (targetTokens == null && targetRatio == null) {
return { body, compressed: false, stats: null };
}
const messages = extractMessages(body);
if (messages.length === 0) return { body, compressed: false, stats: null };
// Measure total tokens across all messages
const totalText = messages
.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)))
.join(" ");
const totalTokens = countTextTokens(totalText);
// targetTokens wins when both are set
const effectiveTarget =
targetTokens != null
? targetTokens
: Math.floor(totalTokens * (targetRatio as number));
if (totalTokens <= effectiveTarget) {
return { body, compressed: false, stats: null };
}
// Distribute the aggregate budget proportionally per message so the SUM stays
// ≤ target (passing the full target to each message would let an N-message body
// come back N× over budget).
const newMessages = messages.map((m) => {
if (typeof m.content !== "string") return m;
const msgTokens = countTextTokens(m.content);
const perMsgTarget =
totalTokens > 0 ? Math.floor(effectiveTarget * (msgTokens / totalTokens)) : effectiveTarget;
const out = compressText(m.content, perMsgTarget);
return out === m.content ? m : { ...m, content: out };
});
const changed = newMessages.some(
(m, i) => JSON.stringify(m) !== JSON.stringify(messages[i])
);
// Measure the result to detect when preserve-guarded content makes the target
// unreachable, so callers are not silently left over budget.
const usedMessages = changed ? newMessages : messages;
const resultTokens = countTextTokens(
usedMessages
.map((m) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)))
.join(" ")
);
const overBudget = resultTokens > effectiveTarget;
// Nothing changed and we are within budget → genuine no-op.
if (!changed && !overBudget) {
return { body, compressed: false, stats: null };
}
const newBody = changed ? { ...body, messages: newMessages } : body;
const stats = createCompressionStats(body, newBody, "stacked", ["hard-budget"]);
if (overBudget) {
const warning = `hard-budget: could not reach target (${resultTokens} > ${effectiveTarget}; preserved content exceeds budget)`;
stats.validationWarnings = [...(stats.validationWarnings ?? []), warning];
}
return { body: newBody, compressed: changed, stats };
}