agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
277 lines • 11 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.paragraphSpans = exports.hardCut = exports.toPieces = exports.mergeShortSpansForward = exports.resolveMinChars = exports.foldRunts = exports.applyOverlap = exports.packSpans = exports.trimSpan = exports.pageLocator = void 0;
const constants_js_1 = require("./constants.js");
/**
* Which page an offset falls on, 1-based, or `undefined` when the document is
* not paginated.
*
* Pages were joined with `'\n\n'` by the loader, so the boundaries are
* reconstructible exactly. A chunk that straddles a page break is attributed
* to the page it STARTS on — one number, the one you would turn to first.
*/
function pageLocator(doc) {
const pages = doc.pages;
if (!pages || pages.length === 0)
return () => undefined;
const bounds = [];
let cursor = 0;
for (const page of pages) {
bounds.push(cursor);
cursor += page.length + 2; // the '\n\n' the loader joined with
}
return (offset) => {
let page = 1;
for (let i = 0; i < bounds.length; i++) {
if (offset >= (bounds[i] ?? 0))
page = i + 1;
else
break;
}
return page;
};
}
exports.pageLocator = pageLocator;
/**
* Trim a span inward past leading/trailing whitespace, keeping the offsets
* honest.
*
* The naive version — `slice().trim()` — produces text that no longer matches
* its own offsets. This moves the offsets instead, so the invariant holds.
* Returns `undefined` when the span is entirely whitespace.
*/
function trimSpan(text, span) {
let start = span.start;
let end = span.end;
while (start < end && /\s/.test(text[start] ?? ''))
start += 1;
while (end > start && /\s/.test(text[end - 1] ?? ''))
end -= 1;
if (start >= end)
return undefined;
return {
start,
end,
...(span.heading !== undefined && { heading: span.heading }),
...(span.contentStart !== undefined && { contentStart: span.contentStart }),
};
}
exports.trimSpan = trimSpan;
/**
* Pack consecutive units (paragraphs, lines) into spans of at most `maxChars`,
* then apply `overlapChars` of backward overlap.
*
* Units are never split by this function — a single unit longer than `maxChars`
* becomes its own oversized span, and the caller decides whether to hard-cut
* it. That separation matters: a 4,000-character paragraph is a real thing in
* real documents, and silently slicing it mid-word is a different decision from
* packing.
*/
function packSpans(units, maxChars) {
const out = [];
let current;
for (const unit of units) {
if (current === undefined) {
current = unit;
continue;
}
// Same heading, and the merged span still fits.
const fits = unit.end - current.start <= maxChars;
const sameSection = current.heading === unit.heading;
if (fits && sameSection) {
current = {
start: current.start,
end: unit.end,
...(current.heading !== undefined && { heading: current.heading }),
};
continue;
}
out.push(current);
current = unit;
}
if (current !== undefined)
out.push(current);
return out;
}
exports.packSpans = packSpans;
/**
* Extend each span backward by `overlapChars`, without crossing the previous
* span's start.
*
* Backward rather than forward so a chunk always ENDS on the boundary its
* strategy chose — a heading section that ends mid-sentence because of overlap
* would be a section that lies about where it ends.
*/
function applyOverlap(spans, overlapChars) {
if (overlapChars <= 0)
return [...spans];
return spans.map((span, i) => {
if (i === 0)
return span;
const floor = spans[i - 1]?.start ?? 0;
const start = Math.max(floor, span.start - overlapChars);
// Remember where the chunk's own content starts, so the page it is
// attributed to is the page it is ABOUT, not the page its run-up borrowed
// from.
return start === span.start ? span : { ...span, start, contentStart: span.start };
});
}
exports.applyOverlap = applyOverlap;
/**
* Fold a too-small span back into its predecessor.
*
* A fragment of a few characters — a stray heading, a signature line — embeds
* to noise and then competes for a top-K slot with real passages.
*
* **It never folds across a heading boundary.** A merged chunk carries the
* FIRST span's heading, so folding a short section into the one before it
* would produce a chunk labelled `heading="Alpha"` whose text is Beta's — a
* citation that names the wrong section, which is worse than a small chunk.
* Two spans merge only when they are already the same section.
*/
function foldRunts(spans, minChars = constants_js_1.MIN_CHUNK_CHARS) {
const out = [];
for (const span of spans) {
const previous = out[out.length - 1];
const sameSection = previous !== undefined && previous.heading === span.heading;
if (previous !== undefined && sameSection && span.end - span.start < minChars) {
out[out.length - 1] = {
start: previous.start,
end: span.end,
...(previous.heading !== undefined && { heading: previous.heading }),
};
continue;
}
out.push(span);
}
return out;
}
exports.foldRunts = foldRunts;
/**
* Resolve the effective `minChars` floor for a splitter (8.20.0).
*
* Unset → `min(DEFAULT_MIN_CHARS, maxChars / 4)`: 250 at the default target,
* shrinking proportionally when the caller asked for small chunks, so a
* defaulted floor can never swallow the chunk size the caller chose.
*
* An EXPLICIT floor is validated instead of clamped: a floor at or above the
* target would merge every chunk into its neighbour, which is a configuration
* contradiction, not a preference — refused by name rather than rounded away.
*/
function resolveMinChars(minChars, maxChars) {
if (minChars === undefined) {
return Math.min(constants_js_1.DEFAULT_MIN_CHARS, Math.floor(maxChars / 4));
}
if (!Number.isFinite(minChars) || minChars < 0) {
throw new Error(`minChars must be a non-negative number of characters — received ${String(minChars)}. ` +
`Pass 0 to disable the floor (heading-only chunks are still never emitted).`);
}
if (minChars >= maxChars) {
throw new Error(`minChars (${minChars}) must be smaller than maxChars (${maxChars}) — a floor at or ` +
`above the target chunk size would merge every chunk into its neighbour. Lower ` +
`minChars, or raise maxChars.`);
}
return minChars;
}
exports.resolveMinChars = resolveMinChars;
/**
* Merge sub-floor spans FORWARD into their next neighbour (8.20.0).
*
* For heading-less span families (paragraphs, and `byHeading`'s no-headings
* fallback). A span under the floor is never dropped and never shipped alone:
*
* - adjacent short spans accumulate; the moment the accumulation reaches the
* floor it is emitted as its own span (two 150-char paragraphs make a fine
* 300-char chunk — better than pushing both into a full-sized neighbour);
* - an accumulation still under the floor joins the NEXT full span, so the
* short text leads the merged chunk;
* - at the end of the document there is no next, so a trailing accumulation
* merges BACKWARD into the last emitted span;
* - a document that is nothing but short spans stays one merged span —
* there is no neighbour, and "never dropped" wins.
*
* Merged spans slice the SOURCE (start of the first constituent to end of the
* last), so the offset invariant holds by construction, as everywhere else.
*/
function mergeShortSpansForward(spans, minChars) {
if (minChars <= 0 || spans.length <= 1)
return [...spans];
const out = [];
let pendingStart;
let lastEnd = 0;
for (const span of spans) {
lastEnd = span.end;
if (span.end - span.start < minChars && pendingStart === undefined) {
pendingStart = span.start;
continue;
}
if (pendingStart !== undefined) {
// Accumulating: emit as soon as the accumulation clears the floor,
// whether the closer was itself short or full-sized.
if (span.end - pendingStart >= minChars) {
out.push({ start: pendingStart, end: span.end });
pendingStart = undefined;
}
continue;
}
out.push(span);
}
if (pendingStart !== undefined) {
const previous = out.pop();
out.push(previous === undefined
? { start: pendingStart, end: lastEnd }
: { start: previous.start, end: lastEnd });
}
return out;
}
exports.mergeShortSpansForward = mergeShortSpansForward;
/** Turn spans into pieces, slicing the text so the invariant holds by construction. */
function toPieces(doc, spans) {
const pageAt = pageLocator(doc);
const pieces = [];
for (const span of spans) {
const trimmed = trimSpan(doc.text, span);
if (trimmed === undefined)
continue;
// See `Span.contentStart`: the page is the one this chunk is about.
const page = pageAt(span.contentStart ?? trimmed.start);
pieces.push({
// Sliced, never concatenated. This is the invariant.
text: doc.text.slice(trimmed.start, trimmed.end),
charStart: trimmed.start,
charEnd: trimmed.end,
...(trimmed.heading !== undefined && { heading: trimmed.heading }),
...(page !== undefined && { page }),
});
}
return pieces;
}
exports.toPieces = toPieces;
/** Hard-cut a span that no packing could fit, at `maxChars` with overlap. */
function hardCut(span, maxChars, overlapChars) {
const out = [];
const stride = Math.max(1, maxChars - overlapChars);
for (let start = span.start; start < span.end; start += stride) {
const end = Math.min(span.end, start + maxChars);
out.push({ start, end, ...(span.heading !== undefined && { heading: span.heading }) });
if (end >= span.end)
break;
}
return out;
}
exports.hardCut = hardCut;
/** Split into paragraph spans on blank lines, keeping offsets. */
function paragraphSpans(text) {
const spans = [];
const separator = /\n[ \t]*\n/g;
let cursor = 0;
let match;
while ((match = separator.exec(text)) !== null) {
spans.push({ start: cursor, end: match.index });
cursor = match.index + match[0].length;
}
spans.push({ start: cursor, end: text.length });
return spans.filter((s) => s.end > s.start);
}
exports.paragraphSpans = paragraphSpans;
//# sourceMappingURL=shared.js.map