@clien-ai/mcp 0.10.9 → 0.12.0
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/CHANGELOG.md +152 -0
- package/README.md +33 -10
- package/dist/hypothesis-semantics.js +230 -0
- package/dist/hypothesis-semantics.js.map +1 -0
- package/dist/tools/hypothesis-final.js +55 -0
- package/dist/tools/hypothesis-final.js.map +1 -0
- package/dist/tools/interview-scripts.js +50 -21
- package/dist/tools/interview-scripts.js.map +1 -1
- package/dist/tools/market-sizing-proof.js +69 -4
- package/dist/tools/market-sizing-proof.js.map +1 -1
- package/dist/tools/personas.js +9 -2
- package/dist/tools/personas.js.map +1 -1
- package/dist/tools/receipt-children.js +127 -0
- package/dist/tools/receipt-children.js.map +1 -0
- package/dist/tools/registry.js +49 -11
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/render-safety.js +80 -0
- package/dist/tools/render-safety.js.map +1 -1
- package/dist/tools/report-digest.js +560 -46
- package/dist/tools/report-digest.js.map +1 -1
- package/dist/tools/research.js +28 -3
- package/dist/tools/research.js.map +1 -1
- package/dist/tools/scoped-research.js +40 -11
- package/dist/tools/scoped-research.js.map +1 -1
- package/dist/tools/status.js +46 -2
- package/dist/tools/status.js.map +1 -1
- package/dist/types/report.js +452 -24
- package/dist/types/report.js.map +1 -1
- package/package.json +1 -1
package/dist/types/report.js
CHANGED
|
@@ -12,12 +12,14 @@
|
|
|
12
12
|
* — see how research.ts handles it.
|
|
13
13
|
*/
|
|
14
14
|
import { z } from 'zod';
|
|
15
|
+
import { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_PRIORITIES, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, hasCoherentRobustnessWorking, normalizeFinalHypothesisState, readRobustnessCounts, robustnessWorkingContradictsSummary, } from '../hypothesis-semantics.js';
|
|
15
16
|
// ⚠️ A types module importing from `tools/` reads backwards, and it is deliberate.
|
|
16
17
|
// `content-type-display.ts` imports NOTHING — it is the package's content-type vocabulary, which
|
|
17
18
|
// happens to live beside the two renderers that were its first callers, and the FUL-341 coupling
|
|
18
19
|
// test parses `CANONICAL_CONTENT_TYPES` out of that exact path as TEXT. Moving the file to break
|
|
19
20
|
// the layering would cost more than the layering does.
|
|
20
21
|
import { CONTENT_TYPE_EXCLUSIONS, CONTENT_TYPE_STATES } from '../tools/content-type-display.js';
|
|
22
|
+
export { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, } from '../hypothesis-semantics.js';
|
|
21
23
|
/**
|
|
22
24
|
* A competitor discovered during the research run.
|
|
23
25
|
*
|
|
@@ -108,7 +110,21 @@ export const ClaimSchema = z
|
|
|
108
110
|
quoteSpan: z
|
|
109
111
|
.string()
|
|
110
112
|
.optional()
|
|
111
|
-
|
|
113
|
+
// FUL-685: `verbatim` retired on THIS field only. The span is exact against the text we
|
|
114
|
+
// cached — that is what verification means — but the cached text is an EXCERPT of the
|
|
115
|
+
// source, and on the Reddit rail the provider is measured transforming what it returns
|
|
116
|
+
// (FUL-697). "Verbatim span from the source" would be the false half of a true sentence.
|
|
117
|
+
.describe('The exact span of the cached source excerpt that grounds this claim (present for GROUNDED).'),
|
|
118
|
+
// FUL-682 (T5 / D15). Present only on a GROUNDED claim whose source is a multi-receipt community
|
|
119
|
+
// source — one permalink that legitimately yields several bounded excerpts. `sourceId` still
|
|
120
|
+
// locates the parent; this locates the exact child. Legacy reports omit it and resolve
|
|
121
|
+
// positionally, unchanged. ⚠️ READ SINCE FUL-685 (T8) — see the `receipts` note below for how
|
|
122
|
+
// this id resolves and what happens when it does not. D18 still gates the publish that makes
|
|
123
|
+
// any of it reachable from a client.
|
|
124
|
+
receiptId: z
|
|
125
|
+
.string()
|
|
126
|
+
.optional()
|
|
127
|
+
.describe('Receipt-child id ("rr1:<hex>") naming the exact cached excerpt containing `quoteSpan`, when the source carries several. Absent on legacy single-excerpt receipts.'),
|
|
112
128
|
})
|
|
113
129
|
.passthrough();
|
|
114
130
|
/**
|
|
@@ -122,7 +138,7 @@ export const ReportSourceSchema = z
|
|
|
122
138
|
.object({
|
|
123
139
|
url: z.string().describe('Live URL the quote was collected from.'),
|
|
124
140
|
platform: z.string().describe('The host the quote came from (e.g. "Hacker News", "Stack Exchange").'),
|
|
125
|
-
quote: z.string().describe('
|
|
141
|
+
quote: z.string().describe('Cached excerpt, collected at retrieval time (≤600 chars) so it renders even if the URL later 404s.'),
|
|
126
142
|
topic: z.string().optional().describe('Producer-authored 1–2-word topic for the quote; absent on legacy or malformed records.'),
|
|
127
143
|
retrievedAt: z.string().describe('ISO-8601 timestamp of when the quote was retrieved.'),
|
|
128
144
|
authorHandle: z.string().optional().describe('Author handle where platform terms permit; hidden in the public share view.'),
|
|
@@ -145,10 +161,37 @@ export const ReportSourceSchema = z
|
|
|
145
161
|
' Stamped from the source by code, never model-emitted. ABSENT means UNCLASSIFIED: the ' +
|
|
146
162
|
'source type is genuinely unknown, NOT checked and found benign. ' +
|
|
147
163
|
CONTENT_TYPE_EXCLUSIONS),
|
|
164
|
+
// FUL-683 (T6 / D13): the source parent's RECEIPT CHILDREN, present only when one URL yields
|
|
165
|
+
// several bounded excerpts (today, an accepted Reddit thread). Every other source omits it and
|
|
166
|
+
// keeps its single-`quote` behaviour, which is what makes legacy reports parse unchanged.
|
|
167
|
+
// Lenient scalars for the same reason as the fields above.
|
|
168
|
+
//
|
|
169
|
+
// ⚠️ READ SINCE FUL-685 (T8), which is the MCP half of FUL-711. `resolvePersonaReceipt`
|
|
170
|
+
// resolves a claim's `receiptId` to the ONE child it names and `renderReceiptPools` prints
|
|
171
|
+
// that child's excerpt on its own `↳` line under the source's single row. An id that does not
|
|
172
|
+
// resolve under THIS parent takes the receipt away rather than falling back to `quote`.
|
|
173
|
+
receipts: z
|
|
174
|
+
.array(z
|
|
175
|
+
.object({
|
|
176
|
+
receiptId: z.string().describe('Content-addressed child id ("rr1:<16 hex>") derived from the normalized URL plus the exact excerpt.'),
|
|
177
|
+
excerpt: z.string().describe('The bounded excerpt this receipt child holds, cached at retrieval — an excerpt from a discussion, not a speaker\'s exact words.'),
|
|
178
|
+
retrievedAt: z.string().describe('ISO-8601 timestamp of when this excerpt was retrieved.'),
|
|
179
|
+
})
|
|
180
|
+
.passthrough())
|
|
181
|
+
.optional()
|
|
182
|
+
// FUL-685: `.catch(undefined)` on the ARRAY, matching the app mirror
|
|
183
|
+
// (`StagingSourceSchema.receipts`) and the fail-soft-per-field rule the sibling
|
|
184
|
+
// `identityPriors` states. Without it ONE malformed child — an `excerpt` that arrived as a
|
|
185
|
+
// number, say — fails `ResearchReportDataSchema` and drops the WHOLE report to the untyped
|
|
186
|
+
// raw-passthrough branch, losing the typing of every unrelated field to a defect in one
|
|
187
|
+
// enrichment array. Degrading to absent instead costs exactly what it should: any claim
|
|
188
|
+
// naming one of those children then fails closed at `resolveSourceReceipt`.
|
|
189
|
+
.catch(undefined)
|
|
190
|
+
.describe('Receipt children of this source, when one discussion yielded several excerpts. A claim ' +
|
|
191
|
+
'carrying `receiptId` addresses exactly one of these; absent means the source has the ' +
|
|
192
|
+
'single excerpt in `quote`.'),
|
|
148
193
|
})
|
|
149
194
|
.passthrough();
|
|
150
|
-
/** The machine verdict statuses a hypothesis / robustness re-ask can return. MIRROR of `VERDICT_STATUSES`. */
|
|
151
|
-
export const VERDICT_STATUSES = ['validated', 'invalidated', 'inconclusive'];
|
|
152
195
|
/**
|
|
153
196
|
* One reworded re-ask of a hypothesis verdict (robustness probe). MIRROR of
|
|
154
197
|
* `RobustnessVariantSchema` in `agent/src/types.ts`. Failed/flipped variants are
|
|
@@ -158,7 +201,7 @@ export const RobustnessVariantSchema = z
|
|
|
158
201
|
.object({
|
|
159
202
|
framing: z.string().describe('The exact reworded question the verdict was re-asked under.'),
|
|
160
203
|
returnedStatus: z.enum(VERDICT_STATUSES).describe('The verdict this framing returned.'),
|
|
161
|
-
returnedConfidence: z.number().describe('Confidence (0-1) of this framing\'s verdict.'),
|
|
204
|
+
returnedConfidence: z.number().min(0).max(1).describe('Confidence (0-1) of this framing\'s verdict.'),
|
|
162
205
|
agreed: z.boolean().describe('True when this framing returned the same verdict as the original synthesis.'),
|
|
163
206
|
reasoning: z.string().optional().describe('One-line reason for this framing\'s verdict.'),
|
|
164
207
|
})
|
|
@@ -173,16 +216,70 @@ export const RobustnessVariantSchema = z
|
|
|
173
216
|
export const RobustnessResultSchema = z
|
|
174
217
|
.object({
|
|
175
218
|
originalStatus: z.enum(VERDICT_STATUSES).describe('The synthesiser verdict that was re-tested.'),
|
|
176
|
-
survived: z.number().describe('How many framings returned the same verdict.'),
|
|
177
|
-
total: z.number().describe('How many framings were re-asked (may be < intended if budget-capped).'),
|
|
219
|
+
survived: z.number().int().min(0).describe('How many framings returned the same verdict.'),
|
|
220
|
+
total: z.number().int().positive().describe('How many framings were re-asked (may be < intended if budget-capped).'),
|
|
178
221
|
flipped: z.boolean().describe('True when at least one framing returned a different verdict.'),
|
|
179
222
|
downgradedStatus: z
|
|
180
223
|
.enum(VERDICT_STATUSES)
|
|
181
224
|
.optional()
|
|
182
225
|
.describe('Weaker verdict displayed when the original flipped under a rephrasing; absent when it held.'),
|
|
183
226
|
variants: z.array(RobustnessVariantSchema).describe('Every re-ask, passed AND failed — the inspectable working behind the survival count.'),
|
|
227
|
+
})
|
|
228
|
+
.passthrough()
|
|
229
|
+
.superRefine((value, ctx) => {
|
|
230
|
+
if (!readRobustnessCounts(value)) {
|
|
231
|
+
ctx.addIssue({
|
|
232
|
+
code: 'custom',
|
|
233
|
+
path: ['survived'],
|
|
234
|
+
message: 'Robustness survival count must be a possible survived/total ratio',
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
if (!hasCoherentRobustnessWorking(value)) {
|
|
238
|
+
ctx.addIssue({
|
|
239
|
+
code: 'custom',
|
|
240
|
+
path: ['variants'],
|
|
241
|
+
message: 'Robustness variants must prove the recorded survival summary',
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
/**
|
|
246
|
+
* The effective-result vocabulary (FUL-358). MIRRORS of `CONFIDENCE_LEVELS`,
|
|
247
|
+
* `EVIDENCE_SUFFICIENCY_LEVELS`, `ROBUSTNESS_OUTCOMES` and `CONFIDENCE_BASES` in
|
|
248
|
+
* `agent/src/types.ts`, pinned by the schema-coupling test at the repo root.
|
|
249
|
+
*/
|
|
250
|
+
/**
|
|
251
|
+
* THE effective hypothesis result — verdict, reliability, coverage and robustness outcome,
|
|
252
|
+
* reconciled once by the producer. MIRROR of `FinalHypothesisStateSchema` in
|
|
253
|
+
* `agent/src/types.ts`.
|
|
254
|
+
*
|
|
255
|
+
* ⚠️ CODE-DERIVED by the agent, never model-written, so it can be quoted as-is. Three separate
|
|
256
|
+
* axes on purpose: `verdict` is which way the evidence pointed, `sufficiency` is how much of it
|
|
257
|
+
* there was, and `confidence` is how RELIABLE the verdict is — never the probability the
|
|
258
|
+
* hypothesis is true. `confidence` ABSENT means WITHHELD; read `confidenceBasis` for why.
|
|
259
|
+
*
|
|
260
|
+
* Absent on a legacy report, where the row's numeric `confidence` is the retired measure and is
|
|
261
|
+
* not comparable with these words.
|
|
262
|
+
*/
|
|
263
|
+
export const FinalHypothesisStateSchema = z
|
|
264
|
+
.object({
|
|
265
|
+
version: z.literal(HYPOTHESIS_SEMANTICS_VERSION).describe('Which semantics contract this block was written under.'),
|
|
266
|
+
verdict: z.enum(VERDICT_STATUSES).describe('The EFFECTIVE verdict every surface shows. A robustness downgrade is applied only when it makes a strictly weaker claim, so a malformed re-ask block can never upgrade a row.'),
|
|
267
|
+
originalVerdict: z.enum(VERDICT_STATUSES).describe('The synthesiser\'s own verdict, preserved for audit even when the row was downgraded.'),
|
|
268
|
+
confidence: z.enum(CONFIDENCE_LEVELS).optional().describe('How reliable the final verdict is. ABSENT means withheld — see confidenceBasis. Never a probability that the hypothesis is true.'),
|
|
269
|
+
confidenceBasis: z.enum(CONFIDENCE_BASES).describe('Why confidence says what it says: read off the evidence, recomputed after the verdict flipped under rephrasing, or withheld because the run attached no evidence.'),
|
|
270
|
+
sufficiency: z.enum(EVIDENCE_SUFFICIENCY_LEVELS).describe('How much evidence sits behind the verdict, across how many research methods. A coverage axis, independent of which way the verdict pointed.'),
|
|
271
|
+
robustness: z.enum(ROBUSTNESS_OUTCOMES).describe('What the robustness pass did: held, flipped, or never re-asked. `not_retested` is explicit — it is not the same as held.'),
|
|
272
|
+
priority: z.enum(HYPOTHESIS_PRIORITIES).optional().catch(undefined).describe('The product priority of the underlying hypothesis, for ranking what to go and ask about first.'),
|
|
184
273
|
})
|
|
185
274
|
.passthrough();
|
|
275
|
+
/**
|
|
276
|
+
* The cross-field trust boundary for a producer-owned `final` block. Shape-valid enums are not
|
|
277
|
+
* enough: the block must name the row's original verdict, never strengthen any recorded verdict,
|
|
278
|
+
* and carry an axis combination the producer can actually emit.
|
|
279
|
+
*/
|
|
280
|
+
export function isSemanticallyValidFinalHypothesisState(row) {
|
|
281
|
+
return normalizeFinalHypothesisState(row) !== null;
|
|
282
|
+
}
|
|
186
283
|
/** One piece of evidence for/against a hypothesis. MIRROR of `EvidenceSchema`. */
|
|
187
284
|
export const EvidenceSchema = z
|
|
188
285
|
.object({
|
|
@@ -203,14 +300,31 @@ export const HypothesisResultSchema = z
|
|
|
203
300
|
statement: z.string(),
|
|
204
301
|
category: z.enum(['problem', 'solution', 'market', 'willingness_to_pay']),
|
|
205
302
|
status: z.enum(VERDICT_STATUSES).describe('The verdict for this hypothesis.'),
|
|
206
|
-
confidence: z.number().describe('
|
|
303
|
+
confidence: z.number().describe('Legacy numeric confidence stored on either the retired 0-1 or 0-100 scale. Preserve and label it as legacy; never compare it with final.confidence, rank on it, or trend it.'),
|
|
207
304
|
supportingEvidence: z.array(EvidenceSchema).optional(),
|
|
208
305
|
contradictingEvidence: z.array(EvidenceSchema).optional(),
|
|
209
306
|
robustness: RobustnessResultSchema.optional().describe('Robustness re-ask survival count for this verdict (absent on legacy reports / when not re-tested).'),
|
|
210
307
|
scopeCaveat: z.string().optional().describe('Belief-adjacent scope-mismatch note (FUL-102): present when this hypothesis\'s persona support came from a persona speaking outside its credibility domain. Treat such support as directional, not grounded.'),
|
|
308
|
+
final: FinalHypothesisStateSchema.optional().describe('THE effective result (FUL-358) — verdict, reliability, evidence sufficiency and robustness outcome, reconciled once and read by every surface. Prefer `final.verdict` over `status` and `final.confidence` over the numeric `confidence` above. Absent on legacy reports, where the numeric `confidence` is the retired measure.'),
|
|
211
309
|
evidenceReading: z.string().optional().describe('One plain-language line naming what this verdict rests on — how many findings support and contradict it, and which research methods they came from. CODE-COMPOSED from this row\'s own evidence arrays, never written by the model, so it can be quoted as-is. It deliberately says nothing about whether the personas disagreed: that is `sycophancySignals.disagreements`, a stricter predicate over persona identity, and reading a split out of this line would contradict it. Absent on reports written before FUL-396 and on a hypothesis the run attached no evidence to.'),
|
|
212
310
|
})
|
|
213
|
-
.passthrough()
|
|
311
|
+
.passthrough()
|
|
312
|
+
.superRefine((row, ctx) => {
|
|
313
|
+
if (row.robustness && row.robustness.originalStatus !== row.status) {
|
|
314
|
+
ctx.addIssue({
|
|
315
|
+
code: 'custom',
|
|
316
|
+
path: ['robustness', 'originalStatus'],
|
|
317
|
+
message: 'Robustness must re-test this hypothesis result status',
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if (row.final !== undefined && !isSemanticallyValidFinalHypothesisState(row)) {
|
|
321
|
+
ctx.addIssue({
|
|
322
|
+
code: 'custom',
|
|
323
|
+
path: ['final'],
|
|
324
|
+
message: 'Final hypothesis semantics are incoherent with the row',
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
});
|
|
214
328
|
/**
|
|
215
329
|
* Something a persona explicitly rejected — the willingness-to-say-no signal.
|
|
216
330
|
*
|
|
@@ -593,6 +707,109 @@ export const SaturationNoteSchema = z
|
|
|
593
707
|
estimatedUsdSavable: z.number(),
|
|
594
708
|
})
|
|
595
709
|
.passthrough();
|
|
710
|
+
/**
|
|
711
|
+
* The five machine-readable outcomes of the Reddit community-evidence rail. MIRROR of the agent's
|
|
712
|
+
* `REDDIT_RAIL_OUTCOMES`; pinned equal by
|
|
713
|
+
* `__tests__/integration/mcp/reddit-outcome-vocabulary-coupling.test.ts` in the root suite.
|
|
714
|
+
*
|
|
715
|
+
* ⚠️ `completed_empty` IS NOT "ZERO SOURCES". It is reachable only when every planned request
|
|
716
|
+
* reached a parseable terminal response. Inferring it from an empty accepted set would present a
|
|
717
|
+
* provider failure as an honest empty search — a false claim about coverage (F7), and the one
|
|
718
|
+
* relabelling the plan forbids by name.
|
|
719
|
+
*/
|
|
720
|
+
export const REDDIT_RETRIEVAL_OUTCOMES = [
|
|
721
|
+
'not_run',
|
|
722
|
+
'failed',
|
|
723
|
+
'completed_empty',
|
|
724
|
+
'completed_partial',
|
|
725
|
+
'completed',
|
|
726
|
+
];
|
|
727
|
+
/**
|
|
728
|
+
* The reduced, code-stamped Reddit summary a completed report freezes at
|
|
729
|
+
* `methodology.redditRetrieval` (D6 / FUL-685). MIRROR of the agent's
|
|
730
|
+
* `RedditRetrievalNoteSchema`.
|
|
731
|
+
*
|
|
732
|
+
* ⚠️ ABSENT MEANS **UNRECORDED**, and the digest says nothing at all rather than guessing. Every
|
|
733
|
+
* report written before this field existed omits it; so does a run of an app deploy that predates
|
|
734
|
+
* it. "This run did not search Reddit" and "nobody wrote down whether it did" are different
|
|
735
|
+
* facts, and only one of them is knowable from silence.
|
|
736
|
+
*
|
|
737
|
+
* ⚠️ TYPED, NOT LENIENT, ON `outcome` — but the WHOLE note is `.catch(undefined)` at its use
|
|
738
|
+
* site. The digest branches on `outcome`, so a token it does not recognise cannot be rendered
|
|
739
|
+
* safely; degrading the note to absent puts such a report back on the unrecorded path instead of
|
|
740
|
+
* rejecting an otherwise valid legacy report. The counters stay `z.number()` rather than
|
|
741
|
+
* `.int()` for the usual mirror reason: this package must read what the producer wrote, not
|
|
742
|
+
* re-adjudicate it.
|
|
743
|
+
*
|
|
744
|
+
* ⚠️ CARRIES NO RAW TEXT, NO URL AND NO ATTEMPT ROWS. `validation_reddit_attempts` is private
|
|
745
|
+
* operational truth and never crosses into an MCP payload.
|
|
746
|
+
*/
|
|
747
|
+
/**
|
|
748
|
+
* The grammar of a Reddit-rail CODE TOKEN — `outcome` and `reason` both (FUL-719 / D6).
|
|
749
|
+
*
|
|
750
|
+
* ⚠️ THE LENIENCY THAT MATTERS IS ACCEPTING AN UNKNOWN TOKEN, NOT ARBITRARY TEXT. `outcome` is
|
|
751
|
+
* deliberately not a `z.enum` so a SIXTH state from a newer app still parses and the digest can
|
|
752
|
+
* say UNKNOWN (see the field comment below); nothing about that argument needs the value to be
|
|
753
|
+
* unbounded in length or content. Left as a bare `z.string()`, both fields are approved keys the
|
|
754
|
+
* projection copies VERBATIM into `structuredContent.report_data` and `_meta.report_data` — so a
|
|
755
|
+
* raw slab of provider text, a prompt, or a user's decision context riding on `reason` crosses the
|
|
756
|
+
* telemetry seam into a stored report a reader can share, which is exactly the leak D6/F16 forbid
|
|
757
|
+
* and the one the unknown-KEY projection did not close. A review probe demonstrated it.
|
|
758
|
+
*
|
|
759
|
+
* Deliberately narrow: lowercase, digits and `_`, first character a letter, 64 characters. Every
|
|
760
|
+
* value in the producer's `REDDIT_RAIL_OUTCOMES` and `REDDIT_RAIL_REASON_CODES` satisfies it, and
|
|
761
|
+
* the root coupling test pins that so a future producer token this grammar would silently reject
|
|
762
|
+
* fails there first.
|
|
763
|
+
*/
|
|
764
|
+
export const REDDIT_CODE_TOKEN_PATTERN = /^[a-z][a-z0-9_]{0,63}$/;
|
|
765
|
+
/** True only for a string that is a bounded {@link REDDIT_CODE_TOKEN_PATTERN} code token. */
|
|
766
|
+
export function isRedditCodeToken(value) {
|
|
767
|
+
return typeof value === 'string' && REDDIT_CODE_TOKEN_PATTERN.test(value);
|
|
768
|
+
}
|
|
769
|
+
export const RedditRetrievalNoteSchema = z
|
|
770
|
+
.object({
|
|
771
|
+
// ⚠️ DELIBERATELY `z.string()`, NOT `z.enum(REDDIT_RETRIEVAL_OUTCOMES)`, and the difference is
|
|
772
|
+
// a version-skew signal. This client and the app ship on INDEPENDENT release cycles, and a
|
|
773
|
+
// client older than the deploy answering it is the normal pairing rather than an edge case
|
|
774
|
+
// (the same asymmetry that forced `deriveMessageCount`'s fallback). A strict enum plus the
|
|
775
|
+
// `.catch(undefined)` below would turn a SIXTH outcome into an absent note — and an absent
|
|
776
|
+
// note means UNRECORDED, so the digest would fall silent about a run that really did search
|
|
777
|
+
// Reddit. That is precisely the ambiguity a typed outcome exists to remove.
|
|
778
|
+
// `renderRedditRetrieval` owns the unknown case instead: it renders "an outcome this client
|
|
779
|
+
// does not recognise … treat coverage as UNKNOWN", which is honest in both directions and
|
|
780
|
+
// tells the reader what to do about it. A value that is not even a string still fails, and
|
|
781
|
+
// the `.catch(undefined)` below then puts the report back on the unrecorded path.
|
|
782
|
+
//
|
|
783
|
+
// ⚠️ BUT BOUNDED BY {@link REDDIT_CODE_TOKEN_PATTERN} (FUL-719): unknown TOKEN yes, raw TEXT
|
|
784
|
+
// never. An out-of-grammar value fails the note, and the use-site `.catch(undefined)` then
|
|
785
|
+
// reads it as UNRECORDED — the fail-closed direction, and the same answer a note carrying an
|
|
786
|
+
// unreadable member already gets.
|
|
787
|
+
outcome: z.string().regex(REDDIT_CODE_TOKEN_PATTERN).describe('How the Reddit rail settled: not_run | failed | completed_empty | completed_partial | completed. A token outside that set means this client is older than the app that answered — the digest renders it as UNKNOWN, never as coverage. An ABSENT note means UNRECORDED, which is a different fact.'),
|
|
788
|
+
reason: z.string().regex(REDDIT_CODE_TOKEN_PATTERN).describe('One bounded reason code for that outcome (e.g. "ok", "flag_disabled", "rate_limited"). A code token, never free text — an out-of-grammar value is refused rather than forwarded.'),
|
|
789
|
+
// ⚠️ THE THREE INTENT COUNTERS AND `costUsd` ARE THE PRODUCER'S (FUL-719). This mirror briefly
|
|
790
|
+
// carried a different reduction — no intent counters, and `spentMicroUsd` where the producer
|
|
791
|
+
// writes `costUsd` — which would have rejected every real note for a missing member and left
|
|
792
|
+
// the digest silent on exactly the runs that searched Reddit. A mirror mirrors what is
|
|
793
|
+
// written, not what would have been tidier to write.
|
|
794
|
+
plannedIntents: z.number().describe('Query intents fixed before the first call — the cap this pass ran under.'),
|
|
795
|
+
completedIntents: z.number().describe('Planned intents that reached a parseable terminal response.'),
|
|
796
|
+
failedIntents: z.number().describe('Planned intents that did not.'),
|
|
797
|
+
acceptedSourceCount: z.number().describe('Distinct accepted Reddit discussions — the breadth number. NOT the excerpt count.'),
|
|
798
|
+
acceptedExcerptCount: z.number().describe('Accepted bounded excerpts across those discussions; one discussion can yield several.'),
|
|
799
|
+
requestsIssued: z.number().describe('Provider requests actually issued, retries included.'),
|
|
800
|
+
toolRequests: z.number().describe('Billable searches issued. Spend scales with this, not with requestsIssued.'),
|
|
801
|
+
latencyMs: z.number().describe('Wall-clock duration of the whole Reddit pass.'),
|
|
802
|
+
costUsd: z.number().describe('Settled provider spend for the pass, in USD.'),
|
|
803
|
+
});
|
|
804
|
+
/**
|
|
805
|
+
* The ONLY keys of the Reddit note that may leave this package (D6).
|
|
806
|
+
*
|
|
807
|
+
* ⚠️ DERIVED FROM THE SCHEMA, NOT RETYPED. `projectReportDataForMcp` copies exactly these forward
|
|
808
|
+
* on both its exits — the typed parse and the schema-drift fallback — so a field the app adds
|
|
809
|
+
* upstream is dropped until someone adds it here on purpose. A hand-written second list would let
|
|
810
|
+
* the two disagree, and the disagreement would show up as a leak rather than as a test failure.
|
|
811
|
+
*/
|
|
812
|
+
export const REDDIT_RETRIEVAL_NOTE_KEYS = Object.keys(RedditRetrievalNoteSchema.shape);
|
|
596
813
|
/** The evidence-scope and run-cost counters already rendered by the report markdown. */
|
|
597
814
|
export const MethodologyNoteSchema = z
|
|
598
815
|
.object({
|
|
@@ -602,6 +819,14 @@ export const MethodologyNoteSchema = z
|
|
|
602
819
|
totalTokensUsed: z.number(),
|
|
603
820
|
jobDurationSeconds: z.number(),
|
|
604
821
|
saturation: SaturationNoteSchema.optional(),
|
|
822
|
+
// FUL-685 (T8 / D6). `.catch(undefined)` and not merely `.optional()`: a malformed or
|
|
823
|
+
// unrecognised value must degrade THIS FIELD to unrecorded, never reject an otherwise valid
|
|
824
|
+
// legacy report — the same fail-soft-per-field posture `personasSynthesis` takes.
|
|
825
|
+
redditRetrieval: RedditRetrievalNoteSchema
|
|
826
|
+
.optional()
|
|
827
|
+
.catch(undefined)
|
|
828
|
+
.describe('Code-stamped summary of the Reddit community-evidence rail, frozen at completion. ' +
|
|
829
|
+
'ABSENT means UNRECORDED — never disabled, empty, or failed.'),
|
|
605
830
|
})
|
|
606
831
|
.passthrough();
|
|
607
832
|
/**
|
|
@@ -643,7 +868,7 @@ export const ResearchReportDataSchema = z
|
|
|
643
868
|
hypothesisResults: z
|
|
644
869
|
.array(HypothesisResultSchema)
|
|
645
870
|
.optional()
|
|
646
|
-
.describe('Per-hypothesis
|
|
871
|
+
.describe('Per-hypothesis results. Prefer `final`: it is the effective verdict, categorical reliability, evidence sufficiency and robustness outcome. Raw `status` and numeric `confidence` are legacy compatibility fields; do not rank or compare the numeric measure.'),
|
|
647
872
|
insights: z
|
|
648
873
|
.array(InsightSchema)
|
|
649
874
|
.optional()
|
|
@@ -784,14 +1009,159 @@ export function projectReportDataForMcp(raw) {
|
|
|
784
1009
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
785
1010
|
return raw;
|
|
786
1011
|
const record = raw;
|
|
787
|
-
|
|
1012
|
+
const hasHighlights = Object.prototype.hasOwnProperty.call(record, 'interviewHighlights');
|
|
1013
|
+
const hasHypothesisResults = Object.prototype.hasOwnProperty.call(record, 'hypothesisResults');
|
|
1014
|
+
const methodology = record.methodology;
|
|
1015
|
+
const hasRedditNote = methodology !== null &&
|
|
1016
|
+
typeof methodology === 'object' &&
|
|
1017
|
+
!Array.isArray(methodology) &&
|
|
1018
|
+
Object.prototype.hasOwnProperty.call(methodology, 'redditRetrieval');
|
|
1019
|
+
if (!hasHighlights && !hasRedditNote && !hasHypothesisResults)
|
|
788
1020
|
return raw;
|
|
789
1021
|
const projected = { ...record };
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1022
|
+
if (hasHypothesisResults && Array.isArray(record.hypothesisResults)) {
|
|
1023
|
+
projected.hypothesisResults = record.hypothesisResults.map((rawRow) => {
|
|
1024
|
+
if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow))
|
|
1025
|
+
return rawRow;
|
|
1026
|
+
const row = rawRow;
|
|
1027
|
+
const safeRow = { ...row };
|
|
1028
|
+
let changed = false;
|
|
1029
|
+
if (Object.prototype.hasOwnProperty.call(row, 'final')) {
|
|
1030
|
+
const normalized = normalizeFinalHypothesisState(row);
|
|
1031
|
+
if (normalized)
|
|
1032
|
+
safeRow.final = normalized;
|
|
1033
|
+
else
|
|
1034
|
+
delete safeRow.final;
|
|
1035
|
+
changed = safeRow.final !== row.final;
|
|
1036
|
+
}
|
|
1037
|
+
const rawRobustness = row.robustness;
|
|
1038
|
+
const robustnessRecord = rawRobustness !== null &&
|
|
1039
|
+
typeof rawRobustness === 'object' &&
|
|
1040
|
+
!Array.isArray(rawRobustness)
|
|
1041
|
+
? rawRobustness
|
|
1042
|
+
: null;
|
|
1043
|
+
const nonRecordRobustnessIsUnsafe = rawRobustness !== undefined && rawRobustness !== null && robustnessRecord === null;
|
|
1044
|
+
const parentStatusIsUnsafe = robustnessRecord !== null &&
|
|
1045
|
+
!VERDICT_STATUSES.includes(row.status);
|
|
1046
|
+
const carriesMeasuredCounts = robustnessRecord !== null &&
|
|
1047
|
+
(Object.prototype.hasOwnProperty.call(robustnessRecord, 'survived') ||
|
|
1048
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'total'));
|
|
1049
|
+
const carriesVariants = robustnessRecord !== null &&
|
|
1050
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'variants');
|
|
1051
|
+
const carriesOriginalStatus = robustnessRecord !== null &&
|
|
1052
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'originalStatus');
|
|
1053
|
+
const originalStatusMatches = robustnessRecord !== null &&
|
|
1054
|
+
robustnessRecord.originalStatus === row.status;
|
|
1055
|
+
const workingIsUnsafe = carriesVariants && robustnessWorkingContradictsSummary(robustnessRecord);
|
|
1056
|
+
const originalStatusIsUnsafe = carriesOriginalStatus && !originalStatusMatches;
|
|
1057
|
+
const measuredCountsAreUnsafe = carriesMeasuredCounts && !readRobustnessCounts(robustnessRecord);
|
|
1058
|
+
if (nonRecordRobustnessIsUnsafe || parentStatusIsUnsafe) {
|
|
1059
|
+
delete safeRow.robustness;
|
|
1060
|
+
changed = true;
|
|
1061
|
+
}
|
|
1062
|
+
else if (robustnessRecord && (workingIsUnsafe || originalStatusIsUnsafe || measuredCountsAreUnsafe)) {
|
|
1063
|
+
// Keep a readable downgrade: a broken diagnostic ratio must not erase the weakest
|
|
1064
|
+
// recorded verdict. Contradictory detail cannot authenticate the summary or dependent
|
|
1065
|
+
// final block, so neither may cross the typed or raw-drift exit.
|
|
1066
|
+
const safeRobustness = { ...robustnessRecord };
|
|
1067
|
+
delete safeRobustness.survived;
|
|
1068
|
+
delete safeRobustness.total;
|
|
1069
|
+
delete safeRobustness.flipped;
|
|
1070
|
+
if (workingIsUnsafe || originalStatusIsUnsafe)
|
|
1071
|
+
delete safeRobustness.variants;
|
|
1072
|
+
if (originalStatusIsUnsafe)
|
|
1073
|
+
delete safeRobustness.originalStatus;
|
|
1074
|
+
safeRow.robustness = safeRobustness;
|
|
1075
|
+
changed = true;
|
|
1076
|
+
}
|
|
1077
|
+
if (!changed)
|
|
1078
|
+
return rawRow;
|
|
1079
|
+
return safeRow;
|
|
1080
|
+
});
|
|
1081
|
+
}
|
|
1082
|
+
if (hasHighlights) {
|
|
1083
|
+
const highlights = z.array(InterviewHighlightSchema).safeParse(record.interviewHighlights);
|
|
1084
|
+
if (highlights.success)
|
|
1085
|
+
projected.interviewHighlights = highlights.data;
|
|
1086
|
+
else
|
|
1087
|
+
delete projected.interviewHighlights;
|
|
1088
|
+
}
|
|
1089
|
+
// ⚠️ FUL-685 — THE REDDIT NOTE IS PROJECTED HERE FOR THE SAME REASON `researchTaskId` IS:
|
|
1090
|
+
// `prepareReportDataForMcp` returns this projection UNPARSED when the full schema drifts, so a
|
|
1091
|
+
// field stripped only by `RedditRetrievalNoteSchema` would come straight back on that path — and
|
|
1092
|
+
// schema drift is the NORMAL case for a partial payload, not an exotic one.
|
|
1093
|
+
//
|
|
1094
|
+
// D6 makes this note the public face of a private ledger: no raw Reddit text, no transient
|
|
1095
|
+
// context, no attempt rows. Anything the app adds to it later is forwarded verbatim into
|
|
1096
|
+
// `_meta.report_data` and `structuredContent.report_data` unless it is projected away HERE,
|
|
1097
|
+
// before either exit. An unparseable note is DROPPED rather than forwarded, because absent means
|
|
1098
|
+
// UNRECORDED — an honest silence — while a half-readable note is a claim nobody has judged.
|
|
1099
|
+
if (hasRedditNote) {
|
|
1100
|
+
const source = methodology;
|
|
1101
|
+
const nextMethodology = { ...source };
|
|
1102
|
+
const rawNote = source.redditRetrieval;
|
|
1103
|
+
if (rawNote !== null && typeof rawNote === 'object' && !Array.isArray(rawNote)) {
|
|
1104
|
+
// ⚠️ AN ALLOWLIST, NOT A `safeParse`. Validating the note all-or-nothing would DROP a note
|
|
1105
|
+
// whose `outcome` is perfectly readable because one telemetry number arrived corrupt — and
|
|
1106
|
+
// an absent note means UNRECORDED, so a `failed` run would silently lose "the Reddit pass
|
|
1107
|
+
// did NOT complete", which is exactly the honest-limitation signal F7 requires it to carry.
|
|
1108
|
+
// The renderer already handles an unreadable count by omitting the counts sentence.
|
|
1109
|
+
//
|
|
1110
|
+
// Copying approved keys forward is also the only shape that stays correct as the note grows:
|
|
1111
|
+
// a field added upstream is absent here until someone adds it to this list, which is the
|
|
1112
|
+
// decision D6 wants made deliberately rather than by default.
|
|
1113
|
+
//
|
|
1114
|
+
// ⚠️ AND THE VALUE IS CHECKED, NOT ONLY THE KEY (FUL-719). An allowlist of key NAMES closes
|
|
1115
|
+
// the unknown-field path and nothing else: `reason` is an APPROVED key, so a secret placed
|
|
1116
|
+
// in it was copied forward verbatim into both exits — a leak a review probe demonstrated,
|
|
1117
|
+
// and one digest truncation cannot touch because these are typed channels, not prose. So
|
|
1118
|
+
// each approved key is copied only when its value has the SHAPE the contract promises: a
|
|
1119
|
+
// bounded code token for the two code fields, a finite number for the six counters (a
|
|
1120
|
+
// string in `acceptedSourceCount` is the same leak wearing a counter's name).
|
|
1121
|
+
//
|
|
1122
|
+
// ⚠️ PER-KEY, NOT WHOLE-NOTE, for the reason the allowlist itself exists: dropping the note
|
|
1123
|
+
// over one bad telemetry number would cost a `failed` run its "the Reddit pass did NOT
|
|
1124
|
+
// complete" sentence, which is the honest-limitation signal F7 requires it to carry. The
|
|
1125
|
+
// renderer already omits the counts sentence when a count is unreadable, and the parsed path
|
|
1126
|
+
// is stricter of its own accord — a missing `reason` there fails the note and reads as
|
|
1127
|
+
// UNRECORDED.
|
|
1128
|
+
const src = rawNote;
|
|
1129
|
+
const note = {};
|
|
1130
|
+
for (const key of REDDIT_RETRIEVAL_NOTE_KEYS) {
|
|
1131
|
+
if (!Object.prototype.hasOwnProperty.call(src, key))
|
|
1132
|
+
continue;
|
|
1133
|
+
const value = src[key];
|
|
1134
|
+
const admissible = key === 'outcome' || key === 'reason'
|
|
1135
|
+
? isRedditCodeToken(value)
|
|
1136
|
+
: typeof value === 'number' && Number.isFinite(value);
|
|
1137
|
+
if (admissible)
|
|
1138
|
+
note[key] = value;
|
|
1139
|
+
}
|
|
1140
|
+
// ⚠️ PER-KEY DEGRADATION STARTS AFTER `outcome` SURVIVES, NEVER BEFORE IT (FUL-728). The
|
|
1141
|
+
// loop above admits keys independently, so a note whose `outcome` is missing or refused
|
|
1142
|
+
// still reached this line — as literal `{}`, or as `reason` plus counters — and was
|
|
1143
|
+
// forwarded verbatim into `_meta.report_data` and `structuredContent.report_data` as a
|
|
1144
|
+
// PRESENT `redditRetrieval`. The contract makes absence mean UNRECORDED; a note that
|
|
1145
|
+
// exists and says nothing is a different fact, and the one nobody wrote down. A client
|
|
1146
|
+
// reading the typed channel cannot tell them apart, and the digest cannot help — it keys
|
|
1147
|
+
// every branch on `outcome` and renders nothing without one, so the text goes silent while
|
|
1148
|
+
// the payload asserts a record.
|
|
1149
|
+
//
|
|
1150
|
+
// This is not the whole-note validation the comment above refuses. Losing a COUNTER costs
|
|
1151
|
+
// a number and the renderer already omits the counts sentence; losing `outcome` costs the
|
|
1152
|
+
// fact the note exists to carry, and every sentence keyed on it — including the `failed`
|
|
1153
|
+
// run's "the Reddit pass did NOT complete", which F7 requires. There is nothing left to
|
|
1154
|
+
// degrade to, so the honest projection is absence.
|
|
1155
|
+
if (typeof note.outcome === 'string')
|
|
1156
|
+
nextMethodology.redditRetrieval = note;
|
|
1157
|
+
else
|
|
1158
|
+
delete nextMethodology.redditRetrieval;
|
|
1159
|
+
}
|
|
1160
|
+
else {
|
|
1161
|
+
delete nextMethodology.redditRetrieval;
|
|
1162
|
+
}
|
|
1163
|
+
projected.methodology = nextMethodology;
|
|
1164
|
+
}
|
|
795
1165
|
return projected;
|
|
796
1166
|
}
|
|
797
1167
|
/**
|
|
@@ -887,16 +1257,25 @@ export const MarketSizingLineSchema = z
|
|
|
887
1257
|
operation: z.literal('multiply'),
|
|
888
1258
|
formula: z.string().trim().min(1),
|
|
889
1259
|
assumptions: z.array(z.string().trim().min(1)).min(1).max(8),
|
|
1260
|
+
range: z.object({
|
|
1261
|
+
low: z.number().nonnegative(),
|
|
1262
|
+
base: z.number().positive(),
|
|
1263
|
+
high: z.number().positive(),
|
|
1264
|
+
}).optional(),
|
|
890
1265
|
inputs: z.array(z.object({
|
|
891
1266
|
label: z.string().trim().min(1),
|
|
892
1267
|
value: z.number().positive(),
|
|
893
|
-
kind: z.enum(['currency', 'ratio']),
|
|
1268
|
+
kind: z.enum(['currency', 'ratio', 'ratio_assumption']),
|
|
894
1269
|
currency: z.string().trim().toUpperCase().refine((code) => ISO_CURRENCY_CODES.has(code), 'currency must be a supported ISO 4217 code').optional(),
|
|
895
|
-
period: z.string().trim().min(1),
|
|
896
|
-
geography: z.string().trim().min(1),
|
|
897
|
-
audience: z.string().trim().min(1),
|
|
898
|
-
claimId: z.string().regex(/^RCLM-m(?:0|[1-9]\d*)$/),
|
|
899
|
-
sourceId: z.string().regex(/^RRCP-s(?:0|[1-9]\d*)$/),
|
|
1270
|
+
period: z.string().trim().min(1).optional(),
|
|
1271
|
+
geography: z.string().trim().min(1).optional(),
|
|
1272
|
+
audience: z.string().trim().min(1).optional(),
|
|
1273
|
+
claimId: z.string().regex(/^RCLM-m(?:0|[1-9]\d*)$/).optional(),
|
|
1274
|
+
sourceId: z.string().regex(/^RRCP-s(?:0|[1-9]\d*)$/).optional(),
|
|
1275
|
+
rationale: z.string().trim().min(1).max(500).optional(),
|
|
1276
|
+
low: z.number().min(0).max(1).optional(),
|
|
1277
|
+
base: z.number().positive().max(1).optional(),
|
|
1278
|
+
high: z.number().positive().max(1).optional(),
|
|
900
1279
|
}).passthrough()).min(2).max(8),
|
|
901
1280
|
}).passthrough().optional(),
|
|
902
1281
|
})
|
|
@@ -917,14 +1296,32 @@ export const MarketSizingLineSchema = z
|
|
|
917
1296
|
if (currencyInputs.length !== 1 || currencyInputs[0]?.currency !== entry.currency) {
|
|
918
1297
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'derived sizing requires exactly one input in the output currency' });
|
|
919
1298
|
}
|
|
920
|
-
if (derivation.inputs.some((input) => input.
|
|
921
|
-
|
|
1299
|
+
if (derivation.inputs.some((input) => input.kind === 'ratio_assumption'
|
|
1300
|
+
? input.currency !== undefined || input.period !== undefined || input.geography !== undefined ||
|
|
1301
|
+
input.audience !== undefined || input.claimId !== undefined || input.sourceId !== undefined ||
|
|
1302
|
+
!input.rationale || input.low === undefined || input.base === undefined || input.high === undefined ||
|
|
1303
|
+
input.low > input.base || input.base > input.high || input.value !== input.base
|
|
1304
|
+
: input.period !== entry.period || input.geography !== entry.geography || input.audience !== entry.audience ||
|
|
1305
|
+
!input.claimId || !input.sourceId ||
|
|
1306
|
+
(input.kind === 'ratio' ? input.currency !== undefined || input.value > 1 : !input.currency))) {
|
|
922
1307
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'derived inputs have incompatible boundary or units' });
|
|
923
1308
|
}
|
|
924
1309
|
const product = derivation.inputs.reduce((total, input) => total * input.value, 1);
|
|
925
1310
|
if (!Number.isFinite(product) || Math.abs(product - entry.value) > Math.max(0.01, entry.value * 1e-9)) {
|
|
926
1311
|
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'derived value does not reconcile with typed inputs' });
|
|
927
1312
|
}
|
|
1313
|
+
const expectedRange = derivation.inputs.reduce((range, input) => ({
|
|
1314
|
+
low: range.low * (input.kind === 'ratio_assumption' ? input.low : input.value),
|
|
1315
|
+
base: range.base * input.value,
|
|
1316
|
+
high: range.high * (input.kind === 'ratio_assumption' ? input.high : input.value),
|
|
1317
|
+
}), { low: 1, base: 1, high: 1 });
|
|
1318
|
+
if (derivation.inputs.some((input) => input.kind === 'ratio_assumption')) {
|
|
1319
|
+
const range = derivation.range;
|
|
1320
|
+
const matches = range && ['low', 'base', 'high'].every((key) => Math.abs(range[key] - expectedRange[key]) <= Math.max(0.01, expectedRange[key] * 1e-9));
|
|
1321
|
+
if (!matches) {
|
|
1322
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'assumption-derived sizing requires the code-computed range' });
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
928
1325
|
});
|
|
929
1326
|
/**
|
|
930
1327
|
* The market block a run established — the shape `scan_market` (FUL-479) surfaces to the caller
|
|
@@ -990,6 +1387,15 @@ export const ForumThreadSchema = z
|
|
|
990
1387
|
* pre-pivot stored reports still carry it and the schema is `.passthrough()`:
|
|
991
1388
|
* removing it would keep the data flowing, just untyped. Not rendered on any
|
|
992
1389
|
* surface (see `renderThreads` in `tools/scoped-research.ts`).
|
|
1390
|
+
*
|
|
1391
|
+
* ⚠️ FUL-685 — REDDIT IS COMING BACK, AND THIS FIELD IS STILL NOT HOW. The community-evidence
|
|
1392
|
+
* rail reaches Reddit through a different provider entirely, and its v1 contract omits the
|
|
1393
|
+
* subreddit along with the author handle and every engagement figure — an accepted thread
|
|
1394
|
+
* carries a code-stamped `platform: "Reddit"`, its normalized permalink, and bounded
|
|
1395
|
+
* excerpts, and nothing else. So a value here still means a pre-pivot stored report, and a
|
|
1396
|
+
* renderer that started printing it would be describing the old world. (The two sentences
|
|
1397
|
+
* above are the FUL-131 record and are left as written; the provider posture behind the new
|
|
1398
|
+
* rail is settled separately, on its own terms.)
|
|
993
1399
|
*/
|
|
994
1400
|
subreddit: z.string().optional(),
|
|
995
1401
|
sentimentSummary: z.string().optional(),
|
|
@@ -1022,6 +1428,28 @@ export const ForumThreadSchema = z
|
|
|
1022
1428
|
// the day `get_report` needed the wording too, the choice was one constant or a fifth
|
|
1023
1429
|
// spelling. `content-type-display.test.ts` pins it across all six surfaces.
|
|
1024
1430
|
CONTENT_TYPE_EXCLUSIONS),
|
|
1431
|
+
// FUL-718. The same receipt-child shape `ReportSourceSchema.receipts` mirrors, on the other
|
|
1432
|
+
// surface that can carry them: a scoped Community update has no persona spine, so its
|
|
1433
|
+
// `forumResearch.threads[]` is where an accepted discussion's per-excerpt identity lives.
|
|
1434
|
+
// Lenient scalars for the same reason as every field above — `extractItems` DROPS an element
|
|
1435
|
+
// that fails to parse, and one odd child must not cost a whole thread.
|
|
1436
|
+
//
|
|
1437
|
+
// ⚠️ SCHEMA ONLY. Nothing here reads it yet; T8 owns the rendering half, and the digest still
|
|
1438
|
+
// resolves a receipt positionally through the parent.
|
|
1439
|
+
receipts: z
|
|
1440
|
+
.array(z
|
|
1441
|
+
.object({
|
|
1442
|
+
receiptId: z.string().describe('Content-addressed child id ("rr1:<16 hex>") derived from the normalized URL plus the exact excerpt.'),
|
|
1443
|
+
excerpt: z.string().describe('The exact cached excerpt this receipt child holds.'),
|
|
1444
|
+
retrievedAt: z.string().describe('ISO-8601 timestamp of when this excerpt was retrieved.'),
|
|
1445
|
+
})
|
|
1446
|
+
.passthrough())
|
|
1447
|
+
.optional()
|
|
1448
|
+
// Match ReportSourceSchema: one malformed child degrades this optional enrichment rather
|
|
1449
|
+
// than making extractItems discard the otherwise-valid paid discussion.
|
|
1450
|
+
.catch(undefined)
|
|
1451
|
+
.describe('Receipt children of this discussion, when it yielded several bounded excerpts. Each names ' +
|
|
1452
|
+
'one exact excerpt in `relevantQuotes`; absent means no rail minted per-excerpt ids for it.'),
|
|
1025
1453
|
})
|
|
1026
1454
|
.passthrough();
|
|
1027
1455
|
//# sourceMappingURL=report.js.map
|