@clien-ai/mcp 0.10.2 → 0.10.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -2
- package/dist/tools/personas.js +6 -3
- package/dist/tools/personas.js.map +1 -1
- package/dist/tools/registry.js +38 -14
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/report-digest.js +374 -169
- package/dist/tools/report-digest.js.map +1 -1
- package/dist/tools/reports.js +4 -14
- package/dist/tools/reports.js.map +1 -1
- package/dist/tools/research.js +10 -17
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/status.js +4 -14
- package/dist/tools/status.js.map +1 -1
- package/dist/types/report.js +125 -0
- package/dist/types/report.js.map +1 -1
- package/package.json +1 -1
|
@@ -217,23 +217,6 @@ function renderId(raw, absent = '(no id)') {
|
|
|
217
217
|
return absent;
|
|
218
218
|
return safeId(raw) ?? '(unrenderable id)';
|
|
219
219
|
}
|
|
220
|
-
/**
|
|
221
|
-
* What a GROUNDED claim says when its receipt pointer did not render.
|
|
222
|
-
*
|
|
223
|
-
* The same absent-vs-unrenderable split as `renderId`, and here it changes what
|
|
224
|
-
* the reader should DO. "no receipt id — unresolvable" tells an agent the claim
|
|
225
|
-
* is grounded in something the report failed to record, which is a defect in the
|
|
226
|
-
* run. A pointer that exists but is unrenderable is a defect in the POINTER: the
|
|
227
|
-
* receipt is in `structuredContent` / `_meta` and can still be resolved there.
|
|
228
|
-
* Printing the first for the second sends the agent to re-run research it does
|
|
229
|
-
* not need.
|
|
230
|
-
*/
|
|
231
|
-
function groundedWithoutReceipt(rawSourceId) {
|
|
232
|
-
const absent = rawSourceId === undefined || rawSourceId === null || String(rawSourceId).trim() === '';
|
|
233
|
-
return absent
|
|
234
|
-
? ' · GROUNDED but no receipt id — unresolvable'
|
|
235
|
-
: ' · GROUNDED, receipt id present but unrenderable — resolve it from the raw report data';
|
|
236
|
-
}
|
|
237
220
|
function num(value) {
|
|
238
221
|
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
239
222
|
}
|
|
@@ -273,12 +256,61 @@ function capNote(shown, total, channel, path) {
|
|
|
273
256
|
return '';
|
|
274
257
|
return `\n_(showing ${shown} of ${total} — the remaining ${total - shown} are in \`${channel}.${path}\`)_`;
|
|
275
258
|
}
|
|
276
|
-
|
|
259
|
+
/** Return the normalized URL only when it is a safe, non-credentialed web receipt. */
|
|
260
|
+
function safeReceiptUrl(raw) {
|
|
261
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
262
|
+
return null;
|
|
263
|
+
try {
|
|
264
|
+
const url = new URL(raw.trim());
|
|
265
|
+
if ((url.protocol !== 'http:' && url.protocol !== 'https:') || url.username || url.password) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
return url.href;
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
/** Resolve the single, persona-owned presentation grant behind a GROUNDED claim. */
|
|
275
|
+
function resolvePersonaReceipt(rawClaim, personas) {
|
|
276
|
+
const claim = asRecord(rawClaim);
|
|
277
|
+
if (str(claim?.state) !== 'GROUNDED')
|
|
278
|
+
return null;
|
|
279
|
+
const personaIndex = personas.findIndex((_, index) => str(claim?.personaId) === `persona-${index}`);
|
|
280
|
+
if (personaIndex < 0)
|
|
281
|
+
return null;
|
|
282
|
+
const sources = asArray(asRecord(personas[personaIndex])?.sources);
|
|
283
|
+
const sourceIndex = sources.findIndex((_, index) => str(claim?.sourceId) === `RCP-p${personaIndex}-s${index}`);
|
|
284
|
+
if (sourceIndex < 0)
|
|
285
|
+
return null;
|
|
286
|
+
const source = asRecord(sources[sourceIndex]);
|
|
287
|
+
const href = safeReceiptUrl(source?.url);
|
|
288
|
+
if (!source || !href)
|
|
289
|
+
return null;
|
|
290
|
+
return {
|
|
291
|
+
sourceId: `RCP-p${personaIndex}-s${sourceIndex}`,
|
|
292
|
+
href,
|
|
293
|
+
quote: collapseWhitespace(str(source.quote) ?? ''),
|
|
294
|
+
source,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
function resolvePersonaRows(reportData) {
|
|
298
|
+
const personas = asArray(reportData.personas);
|
|
299
|
+
return asArray(reportData.claims).map((claim) => ({
|
|
300
|
+
claim,
|
|
301
|
+
receipt: resolvePersonaReceipt(claim, personas),
|
|
302
|
+
}));
|
|
303
|
+
}
|
|
304
|
+
function tallyPersonaStates(rows) {
|
|
277
305
|
const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
|
|
278
|
-
for (const raw of
|
|
306
|
+
for (const { claim: raw, receipt } of rows) {
|
|
279
307
|
const state = str(asRecord(raw)?.state);
|
|
280
|
-
if (state === 'GROUNDED')
|
|
281
|
-
|
|
308
|
+
if (state === 'GROUNDED') {
|
|
309
|
+
if (receipt)
|
|
310
|
+
tally.grounded += 1;
|
|
311
|
+
else
|
|
312
|
+
tally.noReceipt += 1;
|
|
313
|
+
}
|
|
282
314
|
else if (state === 'SPECULATION')
|
|
283
315
|
tally.speculation += 1;
|
|
284
316
|
else if (state === 'NO_RECEIPT')
|
|
@@ -308,7 +340,7 @@ function tallyLine(tally, total) {
|
|
|
308
340
|
* itself is what an agent pattern-matches on — a digest that says "this claim has
|
|
309
341
|
* a receipt" without printing `RCP-p0-s2` leaves the agent unable to follow it.
|
|
310
342
|
*/
|
|
311
|
-
function renderPersonaClaim(raw) {
|
|
343
|
+
function renderPersonaClaim(raw, resolvedReceipt) {
|
|
312
344
|
const claim = asRecord(raw);
|
|
313
345
|
if (!claim)
|
|
314
346
|
return '- (unreadable claim entry)';
|
|
@@ -323,92 +355,103 @@ function renderPersonaClaim(raw) {
|
|
|
323
355
|
//
|
|
324
356
|
// The GROUNDED gates keep reading the RAW `str()` value: flattening trims, so
|
|
325
357
|
// gating on the flattened one would newly admit `" GROUNDED "` as grounded and
|
|
326
|
-
// put the row's badge at odds with `
|
|
358
|
+
// put the row's badge at odds with `tallyPersonaStates`, which reads raw. Flattening
|
|
327
359
|
// is for the RENDER; it must not widen what counts as a receipt.
|
|
328
360
|
const rawState = str(claim.state);
|
|
329
|
-
const
|
|
361
|
+
const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
|
|
362
|
+
const state = rawState === 'GROUNDED' && !resolvedReceipt ? 'NO_RECEIPT' : storedState;
|
|
330
363
|
const id = renderId(claim.id);
|
|
331
364
|
const personaId = safeId(claim.personaId);
|
|
332
365
|
const sourceId = safeId(claim.sourceId);
|
|
333
366
|
const text = safeInline(claim.text, CLAIM_TEXT_MAX);
|
|
334
367
|
// Gated on `state`, not on `sourceId` presence — see `renderReportClaim` for
|
|
335
368
|
// why a pointer on a non-GROUNDED claim must never render as a receipt.
|
|
336
|
-
let
|
|
337
|
-
if (
|
|
338
|
-
|
|
369
|
+
let provenance = '';
|
|
370
|
+
if (resolvedReceipt) {
|
|
371
|
+
provenance = ` ← ${resolvedReceipt.sourceId}`;
|
|
372
|
+
}
|
|
373
|
+
else if (rawState === 'GROUNDED') {
|
|
374
|
+
provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
|
|
339
375
|
}
|
|
340
376
|
else if (sourceId) {
|
|
341
|
-
|
|
377
|
+
provenance = ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
|
|
342
378
|
}
|
|
343
379
|
const who = personaId ? ` (${personaId})` : '';
|
|
344
380
|
const body = text ? ` — "${text}"` : '';
|
|
345
|
-
return `- [${state}] ${id}${who}${
|
|
381
|
+
return `- [${state}] ${id}${who}${provenance}${body}`;
|
|
346
382
|
}
|
|
347
383
|
/**
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
355
|
-
* This digest is the THIRD mirror of that label inside the MCP package — the tool
|
|
356
|
-
* advert in `registry.ts` is the other two — and it is the one that renders into
|
|
357
|
-
* the payload an agent actually READS. When the re-map first landed it was missed:
|
|
358
|
-
* the advert promised a claim "marked \"Inferred\"" while this line still emitted
|
|
359
|
-
* `model-attested`, so an agent keying on the advert's word would have found
|
|
360
|
-
* nothing, and the same server described one tier two different ways.
|
|
361
|
-
*
|
|
362
|
-
* Neither reading is grounding. The attestation's `sourceId` is printed under an
|
|
363
|
-
* explicit `cited →` label rather than the bare `←` used for real receipts, so
|
|
364
|
-
* the two can never be skim-read as the same thing. That distinction matters MORE now
|
|
365
|
-
* that the word itself is shared with a span-verified receipt.
|
|
366
|
-
*
|
|
367
|
-
* FUL-615: an attested claim now also carries the WINDOW the judge read, clipped to
|
|
368
|
-
* `RECEIPT_QUOTE_MAX` on a labelled continuation line. `cited` was the one provenance
|
|
369
|
-
* tier on this surface whose supporting text reached no agent-visible channel at all —
|
|
370
|
-
* a GROUNDED claim's span is readable as the `RCP-` excerpt it steers, but an attested
|
|
371
|
-
* claim's pointer resolves into `reportEvidence`, whose rows deliberately carry no quote
|
|
372
|
-
* (FUL-560). So the tier a reader is asked to weigh ("stronger than recall, still NOT a
|
|
373
|
-
* receipt") was the one tier they could not see for themselves.
|
|
374
|
-
*
|
|
375
|
-
* ⚠️ THE ABSENCE OF A QUOTE USED TO BE HALF THE SEPARATION, and this change spends it.
|
|
376
|
-
* What replaces it is `ATTESTED_WINDOW_LABEL` — see its doc for why a bare indented
|
|
377
|
-
* quote would have made an attested row read as a grounded one.
|
|
378
|
-
*
|
|
379
|
-
* DIGEST COST, in the idiom `RECEIPT_QUOTE_MAX`'s own doc sets, because a digest an
|
|
380
|
-
* agent truncates is a digest it does not read. Bounded by construction: this spine
|
|
381
|
-
* renders at most `CLAIM_RENDER_CAP` (40) rows and only attested ones get a window, so
|
|
382
|
-
* the worst case is 40 × (200 + ~40 for the label, indent, quotes and newline) ≈ 9.6 KB.
|
|
383
|
-
* Today it is 0 on every prod report — the A2 grantor writes no attestations yet.
|
|
384
|
+
* Resolve the one presentation grant behind either report-level Cited treatment.
|
|
385
|
+
*
|
|
386
|
+
* A stored GROUNDED grade and a model-attestation marker are both untrusted report JSON, not proof
|
|
387
|
+
* by themselves. Either earns its presentation only when its canonical pointer indexes the exact
|
|
388
|
+
* `reportEvidence` pool and the target has a non-credentialed HTTP(S) destination. An attestation
|
|
389
|
+
* additionally needs its nonblank captured window. Keeping this as one resolver lets the row,
|
|
390
|
+
* tally, pointer and window consume the same answer instead of minting orphan citations.
|
|
384
391
|
*/
|
|
385
|
-
function
|
|
392
|
+
function resolveReportReceipt(rawClaim, reportEvidence) {
|
|
393
|
+
const claim = asRecord(rawClaim);
|
|
394
|
+
const rawState = str(claim?.state);
|
|
395
|
+
let kind;
|
|
396
|
+
let sourceId = '';
|
|
397
|
+
let quote = '';
|
|
398
|
+
if (rawState === 'GROUNDED') {
|
|
399
|
+
kind = 'grounded';
|
|
400
|
+
sourceId = typeof claim?.sourceId === 'string' ? claim.sourceId.trim() : '';
|
|
401
|
+
}
|
|
402
|
+
else if (rawState === 'NO_RECEIPT') {
|
|
403
|
+
kind = 'attested';
|
|
404
|
+
const attestation = asRecord(claim?.attestation);
|
|
405
|
+
sourceId = typeof attestation?.sourceId === 'string' ? attestation.sourceId.trim() : '';
|
|
406
|
+
quote = typeof attestation?.quote === 'string' ? collapseWhitespace(attestation.quote) : '';
|
|
407
|
+
if (!quote)
|
|
408
|
+
return null;
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
if (!sourceId)
|
|
414
|
+
return null;
|
|
415
|
+
const parsed = /^RRCP-s(0|[1-9]\d*)$/.exec(sourceId);
|
|
416
|
+
if (!parsed)
|
|
417
|
+
return null;
|
|
418
|
+
const sourceIndex = Number(parsed[1]);
|
|
419
|
+
if (!Number.isSafeInteger(sourceIndex))
|
|
420
|
+
return null;
|
|
421
|
+
const source = asRecord(reportEvidence[sourceIndex]);
|
|
422
|
+
if (!source || !safeReceiptUrl(source.url))
|
|
423
|
+
return null;
|
|
424
|
+
return { kind, sourceId, quote };
|
|
425
|
+
}
|
|
426
|
+
function renderReportClaim(raw, receipt) {
|
|
386
427
|
const claim = asRecord(raw);
|
|
387
428
|
if (!claim)
|
|
388
429
|
return '- (unreadable claim entry)';
|
|
389
430
|
// FUL-253: same guard and same raw-gate split as `renderPersonaClaim` — this
|
|
390
431
|
// spine's rows are the ones a forged `[GROUNDED] … ← RRCP-s0` would hide in.
|
|
391
432
|
const rawState = str(claim.state);
|
|
392
|
-
const
|
|
433
|
+
const storedState = safeInline(claim.state, 40) ?? 'UNKNOWN_STATE';
|
|
434
|
+
const state = rawState === 'GROUNDED' && receipt?.kind !== 'grounded'
|
|
435
|
+
? 'NO_RECEIPT'
|
|
436
|
+
: storedState;
|
|
393
437
|
const id = renderId(claim.id);
|
|
394
438
|
const section = safeInline(claim.section, 40);
|
|
395
439
|
const sourceId = safeId(claim.sourceId);
|
|
396
440
|
const text = safeInline(claim.text, CLAIM_TEXT_MAX);
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
//
|
|
400
|
-
//
|
|
401
|
-
//
|
|
402
|
-
// types `state` and `sourceId` independently and `.passthrough()`s unknown
|
|
403
|
-
// shapes, and `getReport` hands the RAW payload through when Zod rejects it, so
|
|
404
|
-
// `{ state: 'NO_RECEIPT', sourceId: 'RRCP-s0' }` genuinely reaches this code.
|
|
405
|
-
// An agent following that arrow would treat a NO_RECEIPT figure as source-checked.
|
|
441
|
+
// The bare `←` arrow means RECEIPT, so raw state and pointer presence are both
|
|
442
|
+
// insufficient. The shared resolver must also find the exact evidence row and a
|
|
443
|
+
// safe destination; otherwise even a stored GROUNDED grade degrades to neutral
|
|
444
|
+
// audit copy. `getReport` hands RAW payload through after schema drift, so every
|
|
445
|
+
// one of those checks is a render-boundary responsibility.
|
|
406
446
|
let provenance = '';
|
|
407
|
-
if (
|
|
408
|
-
provenance =
|
|
447
|
+
if (receipt?.kind === 'grounded') {
|
|
448
|
+
provenance = ` ← ${receipt.sourceId}`;
|
|
449
|
+
}
|
|
450
|
+
else if (rawState === 'GROUNDED') {
|
|
451
|
+
provenance = ' · Stored grade: GROUNDED — receipt unavailable; do not cite';
|
|
409
452
|
}
|
|
410
|
-
else if (
|
|
411
|
-
provenance = ` · ${ATTESTED_LABEL} → ${
|
|
453
|
+
else if (receipt?.kind === 'attested') {
|
|
454
|
+
provenance = ` · ${ATTESTED_LABEL} → ${receipt.sourceId} (NOT a receipt)`;
|
|
412
455
|
}
|
|
413
456
|
else if (rawState === 'NO_RECEIPT') {
|
|
414
457
|
provenance = ` · ${UNSOURCED_LABEL} — from model knowledge, unverified`;
|
|
@@ -416,11 +459,11 @@ function renderReportClaim(raw) {
|
|
|
416
459
|
// A pointer on a non-grounded claim is shown but explicitly disarmed, so it is
|
|
417
460
|
// neither hidden from the reader nor readable as grounding.
|
|
418
461
|
if (rawState !== 'GROUNDED' && sourceId) {
|
|
419
|
-
provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${
|
|
462
|
+
provenance += ` (carries ${sourceId}, which does NOT grant grounding — state is ${storedState})`;
|
|
420
463
|
}
|
|
421
464
|
const where = section ? ` (${section})` : '';
|
|
422
465
|
const body = text ? ` — "${text}"` : '';
|
|
423
|
-
return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(
|
|
466
|
+
return `- [${state}] ${id}${where}${provenance}${body}${attestedWindow(receipt)}`;
|
|
424
467
|
}
|
|
425
468
|
/**
|
|
426
469
|
* The attested claim's window: the captured snapshot text an independent judge read
|
|
@@ -445,31 +488,48 @@ function renderReportClaim(raw) {
|
|
|
445
488
|
* budget of its own, for the reason that constant's doc gives: an agent reading the
|
|
446
489
|
* same source text clipped two different ways has to wonder which is the real wording.
|
|
447
490
|
*/
|
|
448
|
-
function attestedWindow(
|
|
449
|
-
if (
|
|
491
|
+
function attestedWindow(receipt) {
|
|
492
|
+
if (receipt?.kind !== 'attested')
|
|
450
493
|
return '';
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
494
|
+
return `\n ${ATTESTED_WINDOW_LABEL}: "${clipReceiptQuote(receipt.quote, [], RECEIPT_QUOTE_MAX)}"`;
|
|
495
|
+
}
|
|
496
|
+
function tallyReportStates(rows) {
|
|
497
|
+
const tally = { grounded: 0, speculation: 0, noReceipt: 0, other: 0 };
|
|
498
|
+
for (const { claim: raw, receipt } of rows) {
|
|
499
|
+
const state = str(asRecord(raw)?.state);
|
|
500
|
+
if (state === 'GROUNDED') {
|
|
501
|
+
if (receipt?.kind === 'grounded')
|
|
502
|
+
tally.grounded += 1;
|
|
503
|
+
else
|
|
504
|
+
tally.noReceipt += 1;
|
|
505
|
+
}
|
|
506
|
+
else if (state === 'SPECULATION')
|
|
507
|
+
tally.speculation += 1;
|
|
508
|
+
else if (state === 'NO_RECEIPT')
|
|
509
|
+
tally.noReceipt += 1;
|
|
510
|
+
else
|
|
511
|
+
tally.other += 1;
|
|
512
|
+
}
|
|
513
|
+
return tally;
|
|
455
514
|
}
|
|
456
515
|
function renderPersonaSpine(reportData, channel) {
|
|
457
|
-
const
|
|
458
|
-
if (
|
|
516
|
+
const rows = resolvePersonaRows(reportData);
|
|
517
|
+
if (rows.length === 0) {
|
|
459
518
|
return ('### Persona claim spine — NOT PRESENT on this run\n' +
|
|
460
519
|
'No persona claim registry was produced, so NOTHING in the persona/interview prose above ' +
|
|
461
520
|
'has been machine-checked. Absent is not clean: treat those statements as unverified.');
|
|
462
521
|
}
|
|
463
|
-
const tally =
|
|
464
|
-
const shown =
|
|
465
|
-
const lines = shown.map(renderPersonaClaim);
|
|
466
|
-
return (`### Persona claim spine — ${tallyLine(tally,
|
|
522
|
+
const tally = tallyPersonaStates(rows);
|
|
523
|
+
const shown = rows.slice(0, CLAIM_RENDER_CAP);
|
|
524
|
+
const lines = shown.map(({ claim, receipt }) => renderPersonaClaim(claim, receipt));
|
|
525
|
+
return (`### Persona claim spine — ${tallyLine(tally, rows.length)}\n` +
|
|
467
526
|
'A GROUNDED claim\'s receipt id (`RCP-p{i}-s{j}`) indexes `personas[i].sources[j]` — the cached ' +
|
|
468
|
-
'forum post its quote span was code-verified against.
|
|
527
|
+
'forum post its quote span was code-verified against. A stored GROUNDED grade whose canonical, ' +
|
|
528
|
+
'persona-owned safe receipt is unavailable is rendered and counted NO_RECEIPT. NO_RECEIPT can mean a bad claim OR merely ' +
|
|
469
529
|
'sparse evidence: check that persona\'s `insufficientEvidence` / `sourcesFound` below before ' +
|
|
470
530
|
'discounting it. Anything not GROUNDED is unverified.\n' +
|
|
471
531
|
`${lines.join('\n')}` +
|
|
472
|
-
capNote(shown.length,
|
|
532
|
+
capNote(shown.length, rows.length, channel, 'report_data.claims'));
|
|
473
533
|
}
|
|
474
534
|
function renderReportSpine(reportData, channel) {
|
|
475
535
|
const claims = asArray(reportData.reportClaims);
|
|
@@ -479,14 +539,16 @@ function renderReportSpine(reportData, channel) {
|
|
|
479
539
|
'executive-summary assertion in the prose above has been machine-checked. Treat every such ' +
|
|
480
540
|
'figure as unverified.');
|
|
481
541
|
}
|
|
482
|
-
const
|
|
483
|
-
const
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
})
|
|
542
|
+
const reportEvidence = asArray(reportData.reportEvidence);
|
|
543
|
+
const rows = claims.map((claim) => ({
|
|
544
|
+
claim,
|
|
545
|
+
receipt: resolveReportReceipt(claim, reportEvidence),
|
|
546
|
+
}));
|
|
547
|
+
const tally = tallyReportStates(rows);
|
|
548
|
+
const attested = rows.filter(({ receipt }) => receipt?.kind === 'attested').length;
|
|
487
549
|
const attestedNote = attested > 0 ? ` (of which ${attested} ${ATTESTED_LABEL})` : '';
|
|
488
|
-
const shown =
|
|
489
|
-
const lines = shown.map(renderReportClaim);
|
|
550
|
+
const shown = rows.slice(0, CLAIM_RENDER_CAP);
|
|
551
|
+
const lines = shown.map(({ claim, receipt }) => renderReportClaim(claim, receipt));
|
|
490
552
|
return (`### Report claim spine — ${tallyLine(tally, claims.length)}${attestedNote}\n` +
|
|
491
553
|
'A SEPARATE pool from the persona spine — the two never cross. Sections are `market` (sizing/' +
|
|
492
554
|
'trend figures), `competitor` (profile facts) and `summary` (an assertion quoted VERBATIM from ' +
|
|
@@ -497,8 +559,9 @@ function renderReportSpine(reportData, channel) {
|
|
|
497
559
|
'supporting page an independent check vouched for, but no exact span was pinned — stronger than ' +
|
|
498
560
|
`recall, still NOT a receipt. The \`${ATTESTED_WINDOW_LABEL}\` line beneath such a row is that page's ` +
|
|
499
561
|
'text as we captured it: read it as the context the judge weighed, never as a span checked against ' +
|
|
500
|
-
'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. ' +
|
|
501
|
-
'
|
|
562
|
+
'the claim. Unmarked NO_RECEIPT is recalled model knowledge, unverified. A stored GROUNDED grade ' +
|
|
563
|
+
'whose receipt is unavailable is shown as neutral audit context and counted NO_RECEIPT. ' +
|
|
564
|
+
'Key on the rendered state and receipt arrow, never on stored state or source-id presence alone.\n' +
|
|
502
565
|
`${lines.join('\n')}` +
|
|
503
566
|
capNote(shown.length, claims.length, channel, 'report_data.reportClaims'));
|
|
504
567
|
}
|
|
@@ -506,9 +569,9 @@ function renderReportSpine(reportData, channel) {
|
|
|
506
569
|
* The verified spans each persona receipt has to be able to SHOW, keyed by the
|
|
507
570
|
* receipt id its claims point at (FUL-252).
|
|
508
571
|
*
|
|
509
|
-
* Gated on
|
|
510
|
-
*
|
|
511
|
-
*
|
|
572
|
+
* Gated on the same resolved receipt as `renderPersonaClaim`: only a GROUNDED claim
|
|
573
|
+
* with a canonical, persona-owned safe receipt earned a badge, so only that claim's
|
|
574
|
+
* span is what the excerpt owes the reader. Keying on the mere presence of
|
|
512
575
|
* `quoteSpan` would let a SPECULATION claim steer the window — pushing the excerpt
|
|
513
576
|
* away from the sentence a real receipt was verified against, in favour of one no
|
|
514
577
|
* check ever ran on. `getReport` hands the RAW payload through when Zod rejects it,
|
|
@@ -518,21 +581,20 @@ function renderReportSpine(reportData, channel) {
|
|
|
518
581
|
* has no visible badge, so windowing a quote to support it would spend the excerpt
|
|
519
582
|
* on a claim the agent cannot see, at the cost of one it can.
|
|
520
583
|
*/
|
|
521
|
-
function collectVerifiedSpans(
|
|
584
|
+
function collectVerifiedSpans(rows) {
|
|
522
585
|
const spans = new Map();
|
|
523
|
-
for (const raw of
|
|
586
|
+
for (const { claim: raw, receipt } of rows.slice(0, CLAIM_RENDER_CAP)) {
|
|
524
587
|
const claim = asRecord(raw);
|
|
525
|
-
if (!claim ||
|
|
588
|
+
if (!claim || !receipt)
|
|
526
589
|
continue;
|
|
527
|
-
const sourceId = str(claim.sourceId);
|
|
528
590
|
const span = str(claim.quoteSpan);
|
|
529
|
-
if (!
|
|
591
|
+
if (!span)
|
|
530
592
|
continue;
|
|
531
|
-
const existing = spans.get(sourceId);
|
|
593
|
+
const existing = spans.get(receipt.sourceId);
|
|
532
594
|
if (existing)
|
|
533
595
|
existing.push(span);
|
|
534
596
|
else
|
|
535
|
-
spans.set(sourceId, [span]);
|
|
597
|
+
spans.set(receipt.sourceId, [span]);
|
|
536
598
|
}
|
|
537
599
|
return spans;
|
|
538
600
|
}
|
|
@@ -561,54 +623,55 @@ function collectVerifiedSpans(reportData) {
|
|
|
561
623
|
* from them — `publishedDate`, the figure's actual recency (FUL-148).
|
|
562
624
|
*/
|
|
563
625
|
function renderReceiptPools(reportData, channel) {
|
|
564
|
-
const personas = asArray(reportData.personas);
|
|
565
626
|
const reportEvidence = asArray(reportData.reportEvidence);
|
|
566
|
-
const
|
|
627
|
+
const personaRows = resolvePersonaRows(reportData).slice(0, CLAIM_RENDER_CAP);
|
|
628
|
+
const spansByReceipt = collectVerifiedSpans(personaRows);
|
|
629
|
+
const personaReceiptById = new Map();
|
|
630
|
+
for (const { receipt } of personaRows) {
|
|
631
|
+
if (receipt)
|
|
632
|
+
personaReceiptById.set(receipt.sourceId, receipt);
|
|
633
|
+
}
|
|
567
634
|
const personaReceipts = [];
|
|
568
|
-
|
|
569
|
-
const
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
|
|
609
|
-
personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
|
|
610
|
-
});
|
|
611
|
-
});
|
|
635
|
+
for (const [receiptId, receipt] of personaReceiptById) {
|
|
636
|
+
const source = receipt.source;
|
|
637
|
+
// FUL-253: the quote below was already flattened — these two were not, and
|
|
638
|
+
// they sit on the SAME `- RCP-…` line, so a newline in either forges the
|
|
639
|
+
// receipt row the comment below says a forged quote would.
|
|
640
|
+
const platform = safeInline(source?.platform, 40) ?? 'unknown platform';
|
|
641
|
+
const url = safeInline(receipt.href, 200) ?? '(no url)';
|
|
642
|
+
// Indented under its own receipt line so the pool still scans as a list of
|
|
643
|
+
// pointers; a receipt with no cached quote simply has no second line, which
|
|
644
|
+
// is itself worth seeing — it is a pointer that grounds nothing until fetched.
|
|
645
|
+
//
|
|
646
|
+
// `collapseWhitespace` is a FORGERY GUARD, not formatting: this pool is a
|
|
647
|
+
// list of `- RCP-i-j — …` lines, and a quote containing a newline plus a
|
|
648
|
+
// convincing `- RCP-` prefix would add an entry pointing at a source nobody
|
|
649
|
+
// retrieved. A whitespace-only quote also collapses to '' here and correctly
|
|
650
|
+
// renders as a bare pointer rather than as `""` — "the source said nothing".
|
|
651
|
+
//
|
|
652
|
+
// FUL-252: WHICH `RECEIPT_QUOTE_MAX` characters is chosen by the spans of the
|
|
653
|
+
// GROUNDED claims pointing at THIS receipt, not by the quote's head. Passing
|
|
654
|
+
// the receipt's own id is the whole wiring — `RCP-p{i}-s{j}` is the pointer
|
|
655
|
+
// `renderPersonaClaim` prints, so the two sides of the arrow are built from
|
|
656
|
+
// the same expression and cannot drift into windowing the wrong receipt.
|
|
657
|
+
const quote = receipt.quote;
|
|
658
|
+
const clipped = clipReceiptQuote(quote, spansByReceipt.get(receiptId) ?? [], RECEIPT_QUOTE_MAX);
|
|
659
|
+
const quoteLine = quote ? `\n "${clipped}"` : '';
|
|
660
|
+
// FUL-560: the source type, stated ONCE for the pool (in the header below) and inline only
|
|
661
|
+
// on a row that breaks the stated rule. This pool is the one place in the package where the
|
|
662
|
+
// type is a near-constant: `stampEvidenceMetadata` (agent-side) prunes every non-`user_voice`
|
|
663
|
+
// source out of `persona.sources`, so a per-row line here would print the same word down
|
|
664
|
+
// forty rows and bury the one that mattered. The header carries the claim; this carries the
|
|
665
|
+
// exception, in the shared wording.
|
|
666
|
+
//
|
|
667
|
+
// ⚠️ ABSENT AND NON-CANONICAL ARE BOTH DEVIATIONS. `canonicalContentType` fails closed, so a
|
|
668
|
+
// legacy untyped receipt (M0a predates plenty of stored runs) and a token from a newer
|
|
669
|
+
// producer both land here rather than being silently absorbed by the header's claim — which
|
|
670
|
+
// would be exactly the "silence reads as reassurance" fail-open this change exists to close.
|
|
671
|
+
const personaType = canonicalContentType(source?.contentType);
|
|
672
|
+
const deviationLine = personaType === 'user_voice' ? '' : contentTypeDeviationLine(source?.contentType);
|
|
673
|
+
personaReceipts.push(`- ${receiptId} — ${platform} — ${url}${quoteLine}${deviationLine}`);
|
|
674
|
+
}
|
|
612
675
|
const evidenceReceipts = reportEvidence.map((raw, n) => {
|
|
613
676
|
const source = asRecord(raw);
|
|
614
677
|
// FUL-253: every field on this row is scraped-page metadata, and the row is
|
|
@@ -640,13 +703,13 @@ function renderReceiptPools(reportData, channel) {
|
|
|
640
703
|
});
|
|
641
704
|
if (personaReceipts.length === 0 && evidenceReceipts.length === 0) {
|
|
642
705
|
return ('### Receipt pools — EMPTY\n' +
|
|
643
|
-
'No
|
|
644
|
-
'Any receipt id referenced in the prose is unresolvable.');
|
|
706
|
+
'No visible claim resolves to a canonical, persona-owned safe receipt and no report evidence ' +
|
|
707
|
+
'was attached to this run. Any receipt id referenced in the prose is unresolvable.');
|
|
645
708
|
}
|
|
646
709
|
const sections = ['### Receipt pools'];
|
|
647
710
|
if (personaReceipts.length > 0) {
|
|
648
711
|
const shown = personaReceipts.slice(0, CLAIM_RENDER_CAP);
|
|
649
|
-
sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) —
|
|
712
|
+
sections.push(`**Persona receipts** (${plural(personaReceipts.length, 'cached forum post')}) — safe targets of the visible \`RCP-\` pointers above. ` +
|
|
650
713
|
'Every receipt here is a first-hand `user_voice` post unless its own row says otherwise:\n' +
|
|
651
714
|
shown.join('\n') +
|
|
652
715
|
capNote(shown.length, personaReceipts.length, channel, 'report_data.personas[].sources'));
|
|
@@ -1057,6 +1120,87 @@ function renderRobustness(reportData, channel) {
|
|
|
1057
1120
|
return (`${header}\n${lines.join('\n')}` +
|
|
1058
1121
|
capNote(shown.length, withRobustness.length, channel, 'report_data.hypothesisResults'));
|
|
1059
1122
|
}
|
|
1123
|
+
// ---------------------------------------------------------------------------
|
|
1124
|
+
// Model-authored framing — prose side of the proof separator (FUL-570)
|
|
1125
|
+
// ---------------------------------------------------------------------------
|
|
1126
|
+
/** Defensive ceiling for one model-authored framing value read from raw report_data. */
|
|
1127
|
+
const MODEL_FRAMING_TEXT_MAX = 400;
|
|
1128
|
+
/** Runaway backstop for assumptions / risks; normal briefs carry only a handful. */
|
|
1129
|
+
const MODEL_FRAMING_LIST_CAP = 10;
|
|
1130
|
+
export const MODEL_FRAMING_HEADING = '## Model-authored report framing — not evidence or findings';
|
|
1131
|
+
function framingList(raw) {
|
|
1132
|
+
return asArray(raw)
|
|
1133
|
+
.map((value) => safeInline(value, MODEL_FRAMING_TEXT_MAX))
|
|
1134
|
+
.filter((value) => Boolean(value));
|
|
1135
|
+
}
|
|
1136
|
+
function renderPersonasSynthesis(reportData) {
|
|
1137
|
+
const synthesis = asRecord(reportData.personasSynthesis);
|
|
1138
|
+
if (!synthesis)
|
|
1139
|
+
return '';
|
|
1140
|
+
const headline = safeInline(synthesis.headline, MODEL_FRAMING_TEXT_MAX);
|
|
1141
|
+
const body = safeInline(synthesis.body, MODEL_FRAMING_TEXT_MAX);
|
|
1142
|
+
// The producer treats the pair atomically: a headline without its justification is not a
|
|
1143
|
+
// degraded synthesis, it is an assertion with the reasoning removed. Preserve that rule on
|
|
1144
|
+
// the raw-passthrough path too.
|
|
1145
|
+
if (!headline || !body)
|
|
1146
|
+
return '';
|
|
1147
|
+
return ('### Persona-roster synthesis — model-authored, not evidence\n\n' +
|
|
1148
|
+
`**${headline}**\n\n${body}`);
|
|
1149
|
+
}
|
|
1150
|
+
function renderResearchBrief(reportData) {
|
|
1151
|
+
const brief = asRecord(reportData.researchBrief);
|
|
1152
|
+
if (!brief)
|
|
1153
|
+
return '';
|
|
1154
|
+
const lines = [];
|
|
1155
|
+
const targetMarket = safeInline(brief.targetMarket, MODEL_FRAMING_TEXT_MAX);
|
|
1156
|
+
const valueProposition = safeInline(brief.valueProposition, MODEL_FRAMING_TEXT_MAX);
|
|
1157
|
+
const competitiveLandscape = safeInline(brief.competitiveLandscape, MODEL_FRAMING_TEXT_MAX);
|
|
1158
|
+
if (targetMarket)
|
|
1159
|
+
lines.push(`- Target market: ${targetMarket}`);
|
|
1160
|
+
if (valueProposition)
|
|
1161
|
+
lines.push(`- Value proposition: ${valueProposition}`);
|
|
1162
|
+
const assumptions = framingList(brief.assumptions);
|
|
1163
|
+
if (assumptions.length > 0) {
|
|
1164
|
+
lines.push('- Assumptions to test:');
|
|
1165
|
+
lines.push(...assumptions
|
|
1166
|
+
.slice(0, MODEL_FRAMING_LIST_CAP)
|
|
1167
|
+
.map((assumption) => ` - ${assumption}`));
|
|
1168
|
+
if (assumptions.length > MODEL_FRAMING_LIST_CAP) {
|
|
1169
|
+
lines.push(` - … ${assumptions.length - MODEL_FRAMING_LIST_CAP} more in report_data`);
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
if (competitiveLandscape) {
|
|
1173
|
+
lines.push(`- Competitive landscape: ${competitiveLandscape}`);
|
|
1174
|
+
}
|
|
1175
|
+
const risks = framingList(brief.keyRisks);
|
|
1176
|
+
if (risks.length > 0) {
|
|
1177
|
+
lines.push('- Key risks:');
|
|
1178
|
+
lines.push(...risks.slice(0, MODEL_FRAMING_LIST_CAP).map((risk) => ` - ${risk}`));
|
|
1179
|
+
if (risks.length > MODEL_FRAMING_LIST_CAP) {
|
|
1180
|
+
lines.push(` - … ${risks.length - MODEL_FRAMING_LIST_CAP} more in report_data`);
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
if (lines.length === 0)
|
|
1184
|
+
return '';
|
|
1185
|
+
return '### Research brief — model-generated framing, not findings\n\n' + lines.join('\n');
|
|
1186
|
+
}
|
|
1187
|
+
/**
|
|
1188
|
+
* The approved model-authored context block.
|
|
1189
|
+
*
|
|
1190
|
+
* `productName` and `problemStatement` remain typed but are deliberately not repeated: the
|
|
1191
|
+
* former is already the report title and the latter restates the caller's idea. Only the five
|
|
1192
|
+
* actionable framing fields approved in FUL-570 render here. This block is composed ABOVE the
|
|
1193
|
+
* proof separator and labels itself twice; no line can be mistaken for a graded finding.
|
|
1194
|
+
*/
|
|
1195
|
+
function renderModelFraming(reportData) {
|
|
1196
|
+
const data = asRecord(reportData);
|
|
1197
|
+
if (!data)
|
|
1198
|
+
return '';
|
|
1199
|
+
const sections = [renderPersonasSynthesis(data), renderResearchBrief(data)].filter(Boolean);
|
|
1200
|
+
if (sections.length === 0)
|
|
1201
|
+
return '';
|
|
1202
|
+
return `${MODEL_FRAMING_HEADING}\n\n${sections.join('\n\n')}`;
|
|
1203
|
+
}
|
|
1060
1204
|
/**
|
|
1061
1205
|
* The rendered budget for the run-level verdict rationale.
|
|
1062
1206
|
*
|
|
@@ -1140,6 +1284,64 @@ const NO_TRUST_DATA = `${DIGEST_HEADING}\n\n` +
|
|
|
1140
1284
|
'verified against a source: there is no claim registry, no receipts, no QA flags, and no ' +
|
|
1141
1285
|
'anti-sycophancy readout to check it against. Treat every figure, quote, and verdict above as ' +
|
|
1142
1286
|
'UNVERIFIED — do not cite anything from it as source-checked.';
|
|
1287
|
+
export const COMPETITOR_SOURCE_QUALITY_HEADING = '### Competitor source quality — code-derived flags';
|
|
1288
|
+
const MARKDOWN_LINK_NAME = /^\[([^\]\n]+)\]\([^)\n]+\)$/;
|
|
1289
|
+
const PARTIAL_MARKDOWN_LINK_NAME = /\[[^\]\n]*\]\(/;
|
|
1290
|
+
const URL_LIKE_NAME = /(?:\bhttps?:\/\/|\bwww\.)/i;
|
|
1291
|
+
/**
|
|
1292
|
+
* Render an identifier, never a link. A competitor name is model-authored and can
|
|
1293
|
+
* itself contain Markdown link syntax or a raw URL even when the separate `url`
|
|
1294
|
+
* field is withheld. An exact Markdown link keeps only its label; other URL-like
|
|
1295
|
+
* names fail closed to a placeholder. Remaining Markdown punctuation, including
|
|
1296
|
+
* reference-link brackets and dots that Markdown may autolink, is removed.
|
|
1297
|
+
*/
|
|
1298
|
+
function competitorIdentifier(raw) {
|
|
1299
|
+
const flattened = safeInline(raw, CLAIM_TEXT_MAX);
|
|
1300
|
+
if (!flattened)
|
|
1301
|
+
return '(unnamed competitor)';
|
|
1302
|
+
const markdownLink = MARKDOWN_LINK_NAME.exec(flattened);
|
|
1303
|
+
const candidate = markdownLink?.[1] ?? flattened;
|
|
1304
|
+
if ((!markdownLink && PARTIAL_MARKDOWN_LINK_NAME.test(flattened)) ||
|
|
1305
|
+
URL_LIKE_NAME.test(candidate)) {
|
|
1306
|
+
return '(competitor name withheld: URL-like)';
|
|
1307
|
+
}
|
|
1308
|
+
const plain = collapseWhitespace(candidate.replace(/[^\p{L}\p{N}&'’+\- ]/gu, ' '));
|
|
1309
|
+
return plain || '(unnamed competitor)';
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* FUL-587's compact disclosure. The aggregate and the decision to mark a row are code-derived
|
|
1313
|
+
* from the exact canonical `marketing_seo` token. Only the flagged rows are named; positioning,
|
|
1314
|
+
* notes, url and threat level remain model-authored prose and never cross the proof separator.
|
|
1315
|
+
*
|
|
1316
|
+
* The competitor name is the approved row identifier, not a prose block. It is flattened and
|
|
1317
|
+
* capped before rendering, so a stored name cannot mint a digest row or heading of its own.
|
|
1318
|
+
*/
|
|
1319
|
+
function renderCompetitorSourceQuality(reportData) {
|
|
1320
|
+
const competitors = asArray(reportData.competitors);
|
|
1321
|
+
if (competitors.length === 0)
|
|
1322
|
+
return '';
|
|
1323
|
+
const readable = competitors
|
|
1324
|
+
.map(asRecord)
|
|
1325
|
+
.filter((competitor) => competitor !== null);
|
|
1326
|
+
const unreadableCount = competitors.length - readable.length;
|
|
1327
|
+
const flagged = readable.filter((competitor) => canonicalContentType(competitor.contentType) === 'marketing_seo');
|
|
1328
|
+
const shown = flagged.slice(0, LIST_RENDER_CAP);
|
|
1329
|
+
const lines = shown.map((competitor) => `- ⚠️ ${competitorIdentifier(competitor.name)} — \`marketing_seo\``);
|
|
1330
|
+
const aggregate = `${flagged.length} of ${readable.length} competitor rows are flagged \`marketing_seo\`. ` +
|
|
1331
|
+
'The mark is a source-quality classification used by synthesis filters, not a claim-level ' +
|
|
1332
|
+
'grounding verdict. Check reportClaims[] states and receipts: a competitor’s own page may ' +
|
|
1333
|
+
'ground a claim about itself only after span verification. Unmarked rows are not certified ' +
|
|
1334
|
+
'as grounding evidence by this summary.' +
|
|
1335
|
+
(unreadableCount > 0
|
|
1336
|
+
? ` ${unreadableCount} additional competitor array ${unreadableCount === 1 ? 'entry was' : 'entries were'} unreadable and could not be classified; do not treat that gap as clean.`
|
|
1337
|
+
: '');
|
|
1338
|
+
const cap = flagged.length > shown.length
|
|
1339
|
+
? `\n\n_Only ${shown.length} of ${flagged.length} flagged rows are named here; read report_data.competitors[] for the full structured list._`
|
|
1340
|
+
: '';
|
|
1341
|
+
return (`${COMPETITOR_SOURCE_QUALITY_HEADING}\n\n${aggregate}` +
|
|
1342
|
+
(lines.length > 0 ? `\n\n${lines.join('\n')}` : '') +
|
|
1343
|
+
cap);
|
|
1344
|
+
}
|
|
1143
1345
|
/**
|
|
1144
1346
|
* Build the trust digest appended to `get_report`'s text.
|
|
1145
1347
|
*
|
|
@@ -1155,6 +1357,7 @@ export function buildTrustDigest(reportData, channel) {
|
|
|
1155
1357
|
const sections = [
|
|
1156
1358
|
renderPersonaSpine(data, channel),
|
|
1157
1359
|
renderReportSpine(data, channel),
|
|
1360
|
+
renderCompetitorSourceQuality(data),
|
|
1158
1361
|
renderReceiptPools(data, channel),
|
|
1159
1362
|
renderPersonas(data, channel),
|
|
1160
1363
|
renderSycophancy(data, channel),
|
|
@@ -1231,17 +1434,19 @@ function renderReportGaps(reportData) {
|
|
|
1231
1434
|
* a field can reach a caller through one tool and not the others while every test stays
|
|
1232
1435
|
* green. FUL-510's `verdictSummary` is the field that made the seam worth naming.
|
|
1233
1436
|
*
|
|
1234
|
-
* The order is the contract. Report prose first (the run's markdown, then
|
|
1235
|
-
* verdict rationale), then `---`, then
|
|
1236
|
-
* digest's authority rule is that the last
|
|
1237
|
-
* and anything above the separator is prose
|
|
1437
|
+
* The order is the contract. Report prose first (the run's markdown, then the explicitly
|
|
1438
|
+
* labelled model-authored framing and its own stored verdict rationale), then `---`, then
|
|
1439
|
+
* the machine-checked digest LAST — because the digest's authority rule is that the last
|
|
1440
|
+
* such section in the message is the real one, and anything above the separator is prose
|
|
1441
|
+
* that may resemble it.
|
|
1238
1442
|
*/
|
|
1239
1443
|
export function composeReportText(reportMarkdown, reportData, channel) {
|
|
1240
1444
|
const rationale = renderVerdictRationale(reportData);
|
|
1445
|
+
const framing = renderModelFraming(reportData);
|
|
1241
1446
|
// FUL-586: the completeness banner goes FIRST — before the prose it is a caveat about.
|
|
1242
1447
|
const gaps = renderReportGaps(reportData);
|
|
1243
1448
|
return (`${gaps ? `${gaps}\n\n---\n\n` : ''}` +
|
|
1244
|
-
`${reportMarkdown}${rationale ? `\n\n${rationale}` : ''}` +
|
|
1449
|
+
`${reportMarkdown}${framing ? `\n\n${framing}` : ''}${rationale ? `\n\n${rationale}` : ''}` +
|
|
1245
1450
|
`\n\n---\n\n${buildTrustDigest(reportData, channel)}`);
|
|
1246
1451
|
}
|
|
1247
1452
|
//# sourceMappingURL=report-digest.js.map
|