agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
279 lines • 12.4 kB
JavaScript
;
/**
* Fill and grammar — how a template id and its vars become a typed sentence.
*
* `fillParts` substitutes placeholders NON-recursively (the `renderCommentary`
* precedent): a var's value is never read as a template, so a tool that
* declares "{{x}}" in its own words gets "{{x}}" printed, not substituted.
* A missing var is a programming error and throws — `account.ts` catches it
* per sentence and writes `unreadable.line@1` instead.
*
* English-only surfaces (named for the later locale map): the `count` pairs
* inside the table, `allOf`, `joinAnd`, `distance` (all table-driven except
* `joinAnd`'s ", " / " and ").
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.joinSentences = exports.dedupePointers = exports.pointerKey = exports.saidBy = exports.chip = exports.sentence = exports.sentenceSource = exports.fillParts = exports.joinAnd = exports.verb = exports.count = exports.n = exports.clipFact = exports.v = exports.isPresentationVar = exports.weakest = exports.MAX_VAR_CHARS = exports.MAX_POINTERS = void 0;
const templates_js_1 = require("./templates.js");
const pointerKey_js_1 = require("./pointerKey.js");
Object.defineProperty(exports, "pointerKey", { enumerable: true, get: function () { return pointerKey_js_1.answerAccountPointerKey; } });
/** Pointers one sentence carries at most (its printed vars' leaves come first). */
exports.MAX_POINTERS = 12;
/** A string var longer than this is clipped, with `part.clipped@1` after it. */
exports.MAX_VAR_CHARS = 2000;
// ── voucher ranking ──────────────────────────────────────────────────────
const RANK = { person: 0, library: 1, tool: 2, model: 3, app: 4 };
const rankOf = (source) => RANK[source.startsWith('tool:') ? 'tool' : source] ?? 4;
/** The weakest of the given sources (library > tool > model > app; person strongest). */
function weakest(sources) {
let out = 'person';
for (const s of sources)
if (rankOf(s) > rankOf(out))
out = s;
return out;
}
exports.weakest = weakest;
/** A var is presentation, not truth, when it is a skill's LABEL (`<name>Label`). */
const isPresentationVar = (name) => name.endsWith('Label');
exports.isPresentationVar = isPresentationVar;
// ── vars ─────────────────────────────────────────────────────────────────
/** A var read from the record (or a declaration). Long strings are clipped. */
function v(value, source, from, max = exports.MAX_VAR_CHARS) {
const clipped = typeof value === 'string' && value.length > max;
return {
value: clipped ? value.slice(0, max) : value,
source,
...(from !== undefined && { from }),
...(clipped && { clipped: true }),
};
}
exports.v = v;
/** A fact's text value, cut at `max` characters with `clipped: true` — the size bound. */
function clipFact(value, max) {
return value.length > max ? { value: value.slice(0, max), clipped: true } : { value };
}
exports.clipFact = clipFact;
/** A number the account computed (a count) — no pointer of its own. */
const n = (value, source = 'library') => ({
value,
source,
});
exports.n = n;
// ── fill ─────────────────────────────────────────────────────────────────
const PLACEHOLDER = /\{\{([^}]+)\}\}/g;
function varOf(vars, name, id) {
const found = vars[name];
if (found === undefined)
throw new Error(`answer-account: template ${id} needs var "${name}"`);
return found;
}
function numberOf(vars, name, id) {
const value = varOf(vars, name, id).value;
if (typeof value !== 'number')
throw new Error(`answer-account: var "${name}" of ${id} is not a number`);
return value;
}
const countWords = (count, one, many) => `${count} ${count === 1 ? one : many}`;
/** "n one" / "n many" — an explicit pair, never a suffix. */
exports.count = countWords;
/** "was" / "were" by count. */
const verb = (count, one, many) => count === 1 ? one : many;
exports.verb = verb;
/** ≤ 3 values inline: "a", "a and b", "a, b and c". Longer lists are the caller's bulleted list. */
function joinAnd(values) {
if (values.length <= 1)
return values.join('');
return `${values.slice(0, -1).join(', ')} and ${values[values.length - 1]}`;
}
exports.joinAnd = joinAnd;
function textOf(id, vars) {
return fillParts(id, vars);
}
function expandPlaceholder(body, vars, id) {
const countMatch = /^count:([A-Za-z0-9_]+),'([^']*)','([^']*)'$/.exec(body);
if (countMatch) {
const [, name = '', one = '', many = ''] = countMatch;
return [{ text: countWords(numberOf(vars, name, id), one, many) }];
}
const allOfMatch = /^allOf:([A-Za-z0-9_]+)$/.exec(body);
if (allOfMatch) {
const k = numberOf(vars, allOfMatch[1] ?? '', id);
if (k === 1)
return fillParts('part.allOf.one', {}).parts;
if (k === 2)
return fillParts('part.allOf.two', {}).parts;
return fillParts('part.allOf.many', { n: (0, exports.n)(k) }).parts;
}
const [name, kind] = body.split(':');
const variable = varOf(vars, name, id);
const value = String(variable.value);
const clippedTail = variable.clipped ? fillParts('part.clipped', {}).parts : [];
switch (kind) {
case undefined:
return [{ text: value }, ...clippedTail];
case 'code':
return [{ code: value }, ...clippedTail];
case 'quote':
return [{ quote: value, source: variable.source }, ...clippedTail];
case 'label':
return [{ label: value, source: variable.source }, ...clippedTail];
case 'skill': {
const label = vars[`${name}Label`];
return label === undefined
? fillParts('part.skill.bare', { id: variable }).parts
: fillParts('part.skill.labelled', { id: variable, label }).parts;
}
case 'distance': {
const distance = numberOf(vars, name, id);
const windowed = vars[`${name}Windowed`]?.value === 1;
if (windowed)
return fillParts('distance.atLeast', { n: (0, exports.n)(distance) }).parts;
if (distance === 1)
return fillParts('distance.previous', {}).parts;
return fillParts('distance.back', { n: (0, exports.n)(distance) }).parts;
}
default:
throw new Error(`answer-account: unknown placeholder kind "${kind}" in ${id}`);
}
}
function mergeText(parts) {
const out = [];
for (const part of parts) {
const last = out[out.length - 1];
if ('text' in part && last !== undefined && 'text' in last) {
out[out.length - 1] = { text: last.text + part.text };
}
else if (!('text' in part) || part.text.length > 0) {
out.push(part);
}
}
return out;
}
const partText = (part) => 'text' in part
? part.text
: 'code' in part
? part.code
: 'quote' in part
? part.quote
: part.label;
/**
* Fill one template. Returns the plain text and the same text as typed parts.
* Throws on a missing var or an unknown placeholder kind (a programming error).
*/
function fillParts(id, vars) {
const template = templates_js_1.ANSWER_ACCOUNT_TEMPLATES[id];
if (template === undefined)
throw new Error(`answer-account: unknown template ${String(id)}`);
const raw = [];
let at = 0;
for (const match of template.text.matchAll(PLACEHOLDER)) {
raw.push({ text: template.text.slice(at, match.index) });
raw.push(...expandPlaceholder(match[1] ?? '', vars, id));
at = (match.index ?? 0) + match[0].length;
}
raw.push({ text: template.text.slice(at) });
const parts = mergeText(raw);
return { text: parts.map(partText).join(''), parts };
}
exports.fillParts = fillParts;
/** The template's own voucher, resolved (`tool` → `tool:<the tool var's value>`). */
function voucherOf(id, vars) {
const voucher = templates_js_1.ANSWER_ACCOUNT_TEMPLATES[id].voucher;
if (voucher !== 'tool')
return voucher;
// The tool NAME is recorded by the library; the CLAIM is the tool's own.
return `tool:${String(varOf(vars, 'tool', id).value)}`;
}
/** The weakest-voucher rule over a sentence's inputs (labels excluded — presentation). */
function sentenceSource(id, vars, basis = []) {
const truth = Object.entries(vars)
.filter(([name]) => !(0, exports.isPresentationVar)(name))
.map(([, variable]) => variable.source);
return weakest([voucherOf(id, vars), ...truth, ...basis]);
}
exports.sentenceSource = sentenceSource;
/** Build one sentence. Throws only on a programming error (see `fillParts`). */
function sentence(id, spec = {}) {
const vars = spec.vars ?? {};
const { text, parts } = textOf(id, vars);
const template = templates_js_1.ANSWER_ACCOUNT_TEMPLATES[id];
// The vars' own pointers first (each one is a leaf the sentence printed), then the
// sentence's evidence — capped: a sentence about 3,000 rows points at a sample of them.
const pointers = dedupePointers([
...Object.values(vars).flatMap((variable) => (variable.from ? [variable.from] : [])),
...(spec.pointers ?? []),
]).slice(0, exports.MAX_POINTERS);
return {
text,
parts,
template: { id, version: template.version },
vars,
source: sentenceSource(id, vars, spec.basis),
status: spec.status ?? 'recorded',
pointers,
...(spec.missing !== undefined && { missing: spec.missing }),
...(spec.chips !== undefined && spec.chips.length > 0 && { chips: spec.chips }),
...(spec.item === true && { item: true }),
};
}
exports.sentence = sentence;
/** A fixed-word chip. */
function chip(mark, id, tone = 'plain', vars = {}) {
return {
mark,
text: fillParts(id, vars).text,
tone,
template: { id, version: templates_js_1.ANSWER_ACCOUNT_TEMPLATES[id].version },
};
}
exports.chip = chip;
/** "said by: …" for one source. */
function saidBy(source) {
const who = source === 'person'
? fillParts('chip.who.person', {}).text
: source === 'library'
? fillParts('chip.who.library', {}).text
: source === 'model'
? fillParts('chip.who.model', {}).text
: source === 'app'
? fillParts('chip.who.app', {}).text
: source.slice('tool:'.length);
return chip('said-by', 'chip.saidBy', 'plain', { who: { value: who, source: 'library' } });
}
exports.saidBy = saidBy;
function dedupePointers(pointers) {
const seen = new Set();
const out = [];
for (const p of pointers) {
const key = (0, pointerKey_js_1.answerAccountPointerKey)(p);
if (seen.has(key))
continue;
seen.add(key);
out.push(p);
}
return out;
}
exports.dedupePointers = dedupePointers;
/**
* Two signal sentences as one line (`summary.signals@1`): the parts of both,
* the vars of both (prefixed), the weakest voucher of both.
*/
function joinSentences(first, second) {
const id = second === undefined ? 'summary.signal' : 'summary.signals';
const template = templates_js_1.ANSWER_ACCOUNT_TEMPLATES[id];
const prefixed = (s, prefix) => Object.fromEntries(Object.entries(s.vars).map(([k, value]) => [`${prefix}.${k}`, value]));
const parts = second === undefined
? first.parts
: mergeText([...first.parts, { text: ' ' }, ...second.parts]);
return {
text: second === undefined ? first.text : `${first.text} ${second.text}`,
parts,
template: { id, version: template.version },
vars: { ...prefixed(first, 'first'), ...(second ? prefixed(second, 'second') : {}) },
source: weakest([first.source, ...(second ? [second.source] : [])]),
status: first.status,
pointers: dedupePointers([...first.pointers, ...(second?.pointers ?? [])]),
};
}
exports.joinSentences = joinSentences;
//# sourceMappingURL=render.js.map