@clien-ai/mcp 0.11.0 → 0.12.1
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 +50 -0
- package/README.md +26 -8
- package/dist/hypothesis-semantics.js +230 -0
- package/dist/hypothesis-semantics.js.map +1 -0
- package/dist/tools/collections.js +183 -68
- package/dist/tools/collections.js.map +1 -1
- 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-signals.js +121 -0
- package/dist/tools/market-signals.js.map +1 -0
- package/dist/tools/market-sizing-proof.js +9 -3
- package/dist/tools/market-sizing-proof.js.map +1 -1
- package/dist/tools/output-schemas.js +20 -11
- package/dist/tools/output-schemas.js.map +1 -1
- package/dist/tools/registry.js +54 -40
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/report-digest.js +471 -73
- 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 +62 -56
- 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 +518 -22
- package/dist/types/report.js.map +1 -1
- package/dist/types/semantic-theme.js +43 -0
- package/dist/types/semantic-theme.js.map +1 -0
- package/package.json +1 -1
package/dist/types/report.js
CHANGED
|
@@ -12,12 +12,16 @@
|
|
|
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
|
+
import { parseReceiptId } from '../tools/receipt-children.js';
|
|
23
|
+
import { readSemanticTheme } from './semantic-theme.js';
|
|
24
|
+
export { CONFIDENCE_BASES, CONFIDENCE_LEVELS, EVIDENCE_SUFFICIENCY_LEVELS, HYPOTHESIS_SEMANTICS_VERSION, ROBUSTNESS_OUTCOMES, VERDICT_STATUSES, } from '../hypothesis-semantics.js';
|
|
21
25
|
/**
|
|
22
26
|
* A competitor discovered during the research run.
|
|
23
27
|
*
|
|
@@ -190,8 +194,6 @@ export const ReportSourceSchema = z
|
|
|
190
194
|
'single excerpt in `quote`.'),
|
|
191
195
|
})
|
|
192
196
|
.passthrough();
|
|
193
|
-
/** The machine verdict statuses a hypothesis / robustness re-ask can return. MIRROR of `VERDICT_STATUSES`. */
|
|
194
|
-
export const VERDICT_STATUSES = ['validated', 'invalidated', 'inconclusive'];
|
|
195
197
|
/**
|
|
196
198
|
* One reworded re-ask of a hypothesis verdict (robustness probe). MIRROR of
|
|
197
199
|
* `RobustnessVariantSchema` in `agent/src/types.ts`. Failed/flipped variants are
|
|
@@ -201,7 +203,7 @@ export const RobustnessVariantSchema = z
|
|
|
201
203
|
.object({
|
|
202
204
|
framing: z.string().describe('The exact reworded question the verdict was re-asked under.'),
|
|
203
205
|
returnedStatus: z.enum(VERDICT_STATUSES).describe('The verdict this framing returned.'),
|
|
204
|
-
returnedConfidence: z.number().describe('Confidence (0-1) of this framing\'s verdict.'),
|
|
206
|
+
returnedConfidence: z.number().min(0).max(1).describe('Confidence (0-1) of this framing\'s verdict.'),
|
|
205
207
|
agreed: z.boolean().describe('True when this framing returned the same verdict as the original synthesis.'),
|
|
206
208
|
reasoning: z.string().optional().describe('One-line reason for this framing\'s verdict.'),
|
|
207
209
|
})
|
|
@@ -216,16 +218,70 @@ export const RobustnessVariantSchema = z
|
|
|
216
218
|
export const RobustnessResultSchema = z
|
|
217
219
|
.object({
|
|
218
220
|
originalStatus: z.enum(VERDICT_STATUSES).describe('The synthesiser verdict that was re-tested.'),
|
|
219
|
-
survived: z.number().describe('How many framings returned the same verdict.'),
|
|
220
|
-
total: z.number().describe('How many framings were re-asked (may be < intended if budget-capped).'),
|
|
221
|
+
survived: z.number().int().min(0).describe('How many framings returned the same verdict.'),
|
|
222
|
+
total: z.number().int().positive().describe('How many framings were re-asked (may be < intended if budget-capped).'),
|
|
221
223
|
flipped: z.boolean().describe('True when at least one framing returned a different verdict.'),
|
|
222
224
|
downgradedStatus: z
|
|
223
225
|
.enum(VERDICT_STATUSES)
|
|
224
226
|
.optional()
|
|
225
227
|
.describe('Weaker verdict displayed when the original flipped under a rephrasing; absent when it held.'),
|
|
226
228
|
variants: z.array(RobustnessVariantSchema).describe('Every re-ask, passed AND failed — the inspectable working behind the survival count.'),
|
|
229
|
+
})
|
|
230
|
+
.passthrough()
|
|
231
|
+
.superRefine((value, ctx) => {
|
|
232
|
+
if (!readRobustnessCounts(value)) {
|
|
233
|
+
ctx.addIssue({
|
|
234
|
+
code: 'custom',
|
|
235
|
+
path: ['survived'],
|
|
236
|
+
message: 'Robustness survival count must be a possible survived/total ratio',
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!hasCoherentRobustnessWorking(value)) {
|
|
240
|
+
ctx.addIssue({
|
|
241
|
+
code: 'custom',
|
|
242
|
+
path: ['variants'],
|
|
243
|
+
message: 'Robustness variants must prove the recorded survival summary',
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
/**
|
|
248
|
+
* The effective-result vocabulary (FUL-358). MIRRORS of `CONFIDENCE_LEVELS`,
|
|
249
|
+
* `EVIDENCE_SUFFICIENCY_LEVELS`, `ROBUSTNESS_OUTCOMES` and `CONFIDENCE_BASES` in
|
|
250
|
+
* `agent/src/types.ts`, pinned by the schema-coupling test at the repo root.
|
|
251
|
+
*/
|
|
252
|
+
/**
|
|
253
|
+
* THE effective hypothesis result — verdict, reliability, coverage and robustness outcome,
|
|
254
|
+
* reconciled once by the producer. MIRROR of `FinalHypothesisStateSchema` in
|
|
255
|
+
* `agent/src/types.ts`.
|
|
256
|
+
*
|
|
257
|
+
* ⚠️ CODE-DERIVED by the agent, never model-written, so it can be quoted as-is. Three separate
|
|
258
|
+
* axes on purpose: `verdict` is which way the evidence pointed, `sufficiency` is how much of it
|
|
259
|
+
* there was, and `confidence` is how RELIABLE the verdict is — never the probability the
|
|
260
|
+
* hypothesis is true. `confidence` ABSENT means WITHHELD; read `confidenceBasis` for why.
|
|
261
|
+
*
|
|
262
|
+
* Absent on a legacy report, where the row's numeric `confidence` is the retired measure and is
|
|
263
|
+
* not comparable with these words.
|
|
264
|
+
*/
|
|
265
|
+
export const FinalHypothesisStateSchema = z
|
|
266
|
+
.object({
|
|
267
|
+
version: z.literal(HYPOTHESIS_SEMANTICS_VERSION).describe('Which semantics contract this block was written under.'),
|
|
268
|
+
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.'),
|
|
269
|
+
originalVerdict: z.enum(VERDICT_STATUSES).describe('The synthesiser\'s own verdict, preserved for audit even when the row was downgraded.'),
|
|
270
|
+
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.'),
|
|
271
|
+
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.'),
|
|
272
|
+
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.'),
|
|
273
|
+
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.'),
|
|
274
|
+
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.'),
|
|
227
275
|
})
|
|
228
276
|
.passthrough();
|
|
277
|
+
/**
|
|
278
|
+
* The cross-field trust boundary for a producer-owned `final` block. Shape-valid enums are not
|
|
279
|
+
* enough: the block must name the row's original verdict, never strengthen any recorded verdict,
|
|
280
|
+
* and carry an axis combination the producer can actually emit.
|
|
281
|
+
*/
|
|
282
|
+
export function isSemanticallyValidFinalHypothesisState(row) {
|
|
283
|
+
return normalizeFinalHypothesisState(row) !== null;
|
|
284
|
+
}
|
|
229
285
|
/** One piece of evidence for/against a hypothesis. MIRROR of `EvidenceSchema`. */
|
|
230
286
|
export const EvidenceSchema = z
|
|
231
287
|
.object({
|
|
@@ -246,14 +302,31 @@ export const HypothesisResultSchema = z
|
|
|
246
302
|
statement: z.string(),
|
|
247
303
|
category: z.enum(['problem', 'solution', 'market', 'willingness_to_pay']),
|
|
248
304
|
status: z.enum(VERDICT_STATUSES).describe('The verdict for this hypothesis.'),
|
|
249
|
-
confidence: z.number().describe('
|
|
305
|
+
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.'),
|
|
250
306
|
supportingEvidence: z.array(EvidenceSchema).optional(),
|
|
251
307
|
contradictingEvidence: z.array(EvidenceSchema).optional(),
|
|
252
308
|
robustness: RobustnessResultSchema.optional().describe('Robustness re-ask survival count for this verdict (absent on legacy reports / when not re-tested).'),
|
|
253
309
|
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.'),
|
|
310
|
+
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.'),
|
|
254
311
|
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.'),
|
|
255
312
|
})
|
|
256
|
-
.passthrough()
|
|
313
|
+
.passthrough()
|
|
314
|
+
.superRefine((row, ctx) => {
|
|
315
|
+
if (row.robustness && row.robustness.originalStatus !== row.status) {
|
|
316
|
+
ctx.addIssue({
|
|
317
|
+
code: 'custom',
|
|
318
|
+
path: ['robustness', 'originalStatus'],
|
|
319
|
+
message: 'Robustness must re-test this hypothesis result status',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (row.final !== undefined && !isSemanticallyValidFinalHypothesisState(row)) {
|
|
323
|
+
ctx.addIssue({
|
|
324
|
+
code: 'custom',
|
|
325
|
+
path: ['final'],
|
|
326
|
+
message: 'Final hypothesis semantics are incoherent with the row',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
});
|
|
257
330
|
/**
|
|
258
331
|
* Something a persona explicitly rejected — the willingness-to-say-no signal.
|
|
259
332
|
*
|
|
@@ -429,6 +502,19 @@ export const ReportIdentityPriorsSchema = z
|
|
|
429
502
|
.describe('How the persona got to this role and where they are heading — a short trajectory arc, e.g. "engineer → engineering manager".'),
|
|
430
503
|
})
|
|
431
504
|
.passthrough();
|
|
505
|
+
/**
|
|
506
|
+
* Frozen, decorative persona portraits a report may carry (FUL-771).
|
|
507
|
+
*
|
|
508
|
+
* This is deliberately a closed list of bundled Clien assets, not a URL schema.
|
|
509
|
+
* Treating any syntactically valid URL as a portrait would turn report_data into
|
|
510
|
+
* an arbitrary remote-image channel and could also leak a live persona identity.
|
|
511
|
+
*/
|
|
512
|
+
export const REPORT_PORTRAIT_PATHS = [
|
|
513
|
+
'/avatars/maya-chen.png',
|
|
514
|
+
'/avatars/james-rodriguez.png',
|
|
515
|
+
'/avatars/sarah-thompson.png',
|
|
516
|
+
];
|
|
517
|
+
export const ReportPortraitSchema = z.enum(REPORT_PORTRAIT_PATHS);
|
|
432
518
|
/**
|
|
433
519
|
* A report persona carrying its source RECEIPTS. Lean surface — `.passthrough()`
|
|
434
520
|
* keeps the full persona profile (goals, painPoints, psychographic, tools) the
|
|
@@ -440,6 +526,9 @@ export const ReportPersonaSchema = z
|
|
|
440
526
|
.object({
|
|
441
527
|
name: z.string(),
|
|
442
528
|
role: z.string().optional(),
|
|
529
|
+
portrait: ReportPortraitSchema.optional()
|
|
530
|
+
.catch(undefined)
|
|
531
|
+
.describe('Frozen decorative portrait selected from Clien bundled assets. Never a live persona join or an arbitrary remote URL.'),
|
|
443
532
|
sources: z.array(ReportSourceSchema).optional().describe('The real forum posts this persona is composited from — the receipts a claim\'s sourceId points at (may be empty when evidence was sparse).'),
|
|
444
533
|
insufficientEvidence: z.boolean().optional().describe('True when the forum pre-pass found too few real posts to ground this persona.'),
|
|
445
534
|
sourcesFound: z.number().optional().describe('Count of unique real posts the pre-pass surfaced for this persona.'),
|
|
@@ -764,13 +853,64 @@ export const MethodologyNoteSchema = z
|
|
|
764
853
|
* ⚠️ DELIBERATELY NOT `.passthrough()`. The producer also carries
|
|
765
854
|
* `researchTaskId`, an internal `validation_research_tasks.id` that is not an
|
|
766
855
|
* interview-link contract. Zod's default strip behaviour is the privacy boundary:
|
|
767
|
-
* only these
|
|
856
|
+
* only these fields cross into MCP's structured report payload. The content-addressed evidence
|
|
857
|
+
* block is public report identity; the internal research-task id remains excluded.
|
|
768
858
|
*/
|
|
769
859
|
export const InterviewHighlightSchema = z.object({
|
|
770
860
|
personaName: z.string(),
|
|
771
861
|
personaRole: z.string(),
|
|
772
862
|
overallImpression: z.string(),
|
|
773
863
|
quotesByHypothesis: z.record(z.string(), z.array(z.string())),
|
|
864
|
+
evidenceReference: z.object({
|
|
865
|
+
interviewId: z.string().min(1),
|
|
866
|
+
quotes: z.array(z.object({
|
|
867
|
+
quoteId: z.string().min(1),
|
|
868
|
+
hypothesisId: z.string().min(1),
|
|
869
|
+
text: z.string(),
|
|
870
|
+
})),
|
|
871
|
+
}).optional(),
|
|
872
|
+
});
|
|
873
|
+
export const EvidenceRefSchema = z.discriminatedUnion('kind', [
|
|
874
|
+
z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
|
|
875
|
+
z.object({
|
|
876
|
+
kind: z.literal('forum_receipt'),
|
|
877
|
+
receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
|
|
878
|
+
message: 'Invalid receipt id',
|
|
879
|
+
}),
|
|
880
|
+
}),
|
|
881
|
+
// Kept in the parser for compatibility, but stripped by projectReportDataForMcp because the
|
|
882
|
+
// researchTaskId is private and this surface has no external interview-receipt contract.
|
|
883
|
+
z.object({ kind: z.literal('interview_quote'), researchTaskId: z.string(), quoteId: z.string() }),
|
|
884
|
+
z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
|
|
885
|
+
z.object({ kind: z.literal('hypothesis'), hypothesisId: z.string().min(1) }),
|
|
886
|
+
]);
|
|
887
|
+
export const CrossCuttingEvidenceRefSchema = z.discriminatedUnion('kind', [
|
|
888
|
+
z.object({ kind: z.literal('report_evidence'), sourceId: z.string().min(1) }),
|
|
889
|
+
z.object({
|
|
890
|
+
kind: z.literal('forum_receipt'),
|
|
891
|
+
receiptId: z.string().refine((value) => parseReceiptId(value) !== null, {
|
|
892
|
+
message: 'Invalid receipt id',
|
|
893
|
+
}),
|
|
894
|
+
}),
|
|
895
|
+
z.object({ kind: z.literal('interview_quote_v2'), interviewId: z.string().min(1), quoteId: z.string().min(1) }),
|
|
896
|
+
]);
|
|
897
|
+
export const CrossCuttingFindingSchema = z.object({
|
|
898
|
+
claim: z.string().min(1),
|
|
899
|
+
whyItMatters: z.string().min(1),
|
|
900
|
+
confidence: z.enum(['low', 'medium', 'high']),
|
|
901
|
+
evidenceRefs: z.array(CrossCuttingEvidenceRefSchema),
|
|
902
|
+
});
|
|
903
|
+
export const MCP_OVERVIEW_SPAN_CAP = 8;
|
|
904
|
+
export const MCP_OVERVIEW_REF_CAP = 4;
|
|
905
|
+
export const ReportOverviewSchema = z.object({
|
|
906
|
+
text: z.string(),
|
|
907
|
+
claimSpansTruncated: z.boolean().optional(),
|
|
908
|
+
claimSpans: z.array(z.object({
|
|
909
|
+
span: z.string(),
|
|
910
|
+
claimType: z.enum(['externally_checkable', 'inference']),
|
|
911
|
+
evidenceRefsTruncated: z.boolean().optional(),
|
|
912
|
+
evidenceRefs: z.array(EvidenceRefSchema).transform((refs) => refs.slice(0, MCP_OVERVIEW_REF_CAP)),
|
|
913
|
+
})).transform((spans) => spans.slice(0, MCP_OVERVIEW_SPAN_CAP)),
|
|
774
914
|
});
|
|
775
915
|
/**
|
|
776
916
|
* The MCP-exposed shape of `_meta.report_data`. All fields optional —
|
|
@@ -797,14 +937,22 @@ export const ResearchReportDataSchema = z
|
|
|
797
937
|
hypothesisResults: z
|
|
798
938
|
.array(HypothesisResultSchema)
|
|
799
939
|
.optional()
|
|
800
|
-
.describe('Per-hypothesis
|
|
940
|
+
.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.'),
|
|
801
941
|
insights: z
|
|
802
942
|
.array(InsightSchema)
|
|
803
943
|
.optional()
|
|
804
944
|
.describe('Typed insight findings. Fresh source rows carry exact RRCP ids; legacy string rows remain inert text.'),
|
|
945
|
+
crossCuttingFindings: z
|
|
946
|
+
.array(CrossCuttingFindingSchema.optional().catch(undefined))
|
|
947
|
+
.optional()
|
|
948
|
+
.catch(undefined)
|
|
949
|
+
.describe('Evidence-gated cross-method findings. Present empty means the gate ran and no candidate qualified.'),
|
|
805
950
|
keyFindings: z.array(z.string()).optional(),
|
|
806
951
|
recommendations: z.array(z.unknown()).optional(),
|
|
807
952
|
executiveSummary: z.string().optional(),
|
|
953
|
+
reportTitle: z.string().optional(),
|
|
954
|
+
marketOverview: ReportOverviewSchema.optional().catch(undefined),
|
|
955
|
+
communityOverview: ReportOverviewSchema.optional().catch(undefined),
|
|
808
956
|
personasSynthesis: PersonasSynthesisSchema
|
|
809
957
|
.optional()
|
|
810
958
|
.catch(undefined)
|
|
@@ -934,19 +1082,195 @@ export const ResearchReportDataSchema = z
|
|
|
934
1082
|
* rather than represented as empty; absence says "unreadable", while `[]` would
|
|
935
1083
|
* falsely say the run recorded no highlights.
|
|
936
1084
|
*/
|
|
1085
|
+
const MCP_REPORT_TITLE_MAX_CODE_POINTS = 160;
|
|
1086
|
+
export function sanitizeMcpMarkdownTitle(value) {
|
|
1087
|
+
if (typeof value !== 'string')
|
|
1088
|
+
return 'Report';
|
|
1089
|
+
const title = value
|
|
1090
|
+
.replace(/[\p{Cc}\p{Cf}]/gu, ' ')
|
|
1091
|
+
.replace(/\s+/g, ' ')
|
|
1092
|
+
.trim();
|
|
1093
|
+
return title && Array.from(title).length <= MCP_REPORT_TITLE_MAX_CODE_POINTS ? title : 'Report';
|
|
1094
|
+
}
|
|
1095
|
+
export function sanitizeMcpReportTitle(value) {
|
|
1096
|
+
const title = sanitizeMcpMarkdownTitle(value);
|
|
1097
|
+
const latinWords = title.match(/[\p{L}\p{N}]+(?:['’-][\p{L}\p{N}]+)*/gu) ?? [];
|
|
1098
|
+
const cjkCharacters = title.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/gu) ?? [];
|
|
1099
|
+
const words = latinWords.filter((word) => !/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(word)).length + cjkCharacters.length;
|
|
1100
|
+
return words >= 6 && words <= 12 ? title : 'Report';
|
|
1101
|
+
}
|
|
937
1102
|
export function projectReportDataForMcp(raw) {
|
|
938
1103
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
|
|
939
1104
|
return raw;
|
|
940
1105
|
const record = raw;
|
|
1106
|
+
const asProjectionRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1107
|
+
? value
|
|
1108
|
+
: null;
|
|
1109
|
+
const safeProjectionUrl = (value) => {
|
|
1110
|
+
if (typeof value !== 'string' || !/^https?:\/\//i.test(value))
|
|
1111
|
+
return false;
|
|
1112
|
+
try {
|
|
1113
|
+
const url = new URL(value);
|
|
1114
|
+
return (url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
1115
|
+
!url.username && !url.password;
|
|
1116
|
+
}
|
|
1117
|
+
catch {
|
|
1118
|
+
return false;
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
const hasProjectionVisibleContent = (value) => typeof value === 'string' &&
|
|
1122
|
+
value.replace(/[\p{C}\p{Z}\p{Default_Ignorable_Code_Point}]/gu, '').length > 0;
|
|
941
1123
|
const hasHighlights = Object.prototype.hasOwnProperty.call(record, 'interviewHighlights');
|
|
1124
|
+
const hasHypothesisResults = Object.prototype.hasOwnProperty.call(record, 'hypothesisResults');
|
|
1125
|
+
const hasReportTitle = Object.prototype.hasOwnProperty.call(record, 'reportTitle');
|
|
1126
|
+
const hasOverview = Object.prototype.hasOwnProperty.call(record, 'marketOverview') ||
|
|
1127
|
+
Object.prototype.hasOwnProperty.call(record, 'communityOverview');
|
|
1128
|
+
const hasCrossCutting = Object.prototype.hasOwnProperty.call(record, 'crossCuttingFindings');
|
|
1129
|
+
const hasPersonas = Object.prototype.hasOwnProperty.call(record, 'personas');
|
|
1130
|
+
const hasForumResearch = Object.prototype.hasOwnProperty.call(record, 'forumResearch');
|
|
942
1131
|
const methodology = record.methodology;
|
|
943
1132
|
const hasRedditNote = methodology !== null &&
|
|
944
1133
|
typeof methodology === 'object' &&
|
|
945
1134
|
!Array.isArray(methodology) &&
|
|
946
1135
|
Object.prototype.hasOwnProperty.call(methodology, 'redditRetrieval');
|
|
947
|
-
if (!hasHighlights && !hasRedditNote)
|
|
1136
|
+
if (!hasHighlights && !hasRedditNote && !hasHypothesisResults && !hasOverview && !hasReportTitle && !hasCrossCutting && !hasPersonas && !hasForumResearch)
|
|
948
1137
|
return raw;
|
|
949
1138
|
const projected = { ...record };
|
|
1139
|
+
if (hasReportTitle)
|
|
1140
|
+
projected.reportTitle = sanitizeMcpReportTitle(record.reportTitle);
|
|
1141
|
+
// Validate the frozen decoration before either the typed parse or raw schema-drift exit.
|
|
1142
|
+
// Rebuilding only this field preserves the full forward-compatible persona object while an
|
|
1143
|
+
// object, identifier-bearing value or arbitrary URL fails closed to the normal initials path.
|
|
1144
|
+
if (hasPersonas && Array.isArray(record.personas)) {
|
|
1145
|
+
projected.personas = record.personas.map((rawPersona) => {
|
|
1146
|
+
const persona = asProjectionRecord(rawPersona);
|
|
1147
|
+
if (!persona)
|
|
1148
|
+
return rawPersona;
|
|
1149
|
+
const nextPersona = { ...persona };
|
|
1150
|
+
const portrait = ReportPortraitSchema.safeParse(persona.portrait);
|
|
1151
|
+
if (portrait.success)
|
|
1152
|
+
nextPersona.portrait = portrait.data;
|
|
1153
|
+
else
|
|
1154
|
+
delete nextPersona.portrait;
|
|
1155
|
+
return nextPersona;
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
else if (hasPersonas) {
|
|
1159
|
+
// A malformed container must not survive the raw schema-drift exit with an arbitrary nested
|
|
1160
|
+
// URL or identifier. Absence is the only honest forward-compatible projection here.
|
|
1161
|
+
delete projected.personas;
|
|
1162
|
+
}
|
|
1163
|
+
// `forumResearch` is intentionally forward-compatible and currently crosses the full-report
|
|
1164
|
+
// schema through `.passthrough()`. Scrub its optional display label before both the typed and
|
|
1165
|
+
// raw-drift exits so a source-title copy cannot survive in `_meta.report_data` even when the
|
|
1166
|
+
// prose renderer correctly withholds it.
|
|
1167
|
+
if (hasForumResearch) {
|
|
1168
|
+
const forum = asProjectionRecord(record.forumResearch);
|
|
1169
|
+
if (forum) {
|
|
1170
|
+
const nextForum = { ...forum };
|
|
1171
|
+
if (Array.isArray(forum.threads)) {
|
|
1172
|
+
nextForum.threads = forum.threads.map((rawThread) => {
|
|
1173
|
+
const thread = asProjectionRecord(rawThread);
|
|
1174
|
+
if (!thread)
|
|
1175
|
+
return rawThread;
|
|
1176
|
+
const nextThread = { ...thread };
|
|
1177
|
+
const theme = typeof thread.title === 'string'
|
|
1178
|
+
? readSemanticTheme(thread.theme, thread.title)
|
|
1179
|
+
: null;
|
|
1180
|
+
if (theme === null) {
|
|
1181
|
+
delete nextThread.theme;
|
|
1182
|
+
}
|
|
1183
|
+
else {
|
|
1184
|
+
nextThread.theme = theme;
|
|
1185
|
+
}
|
|
1186
|
+
return nextThread;
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
delete nextForum.threads;
|
|
1191
|
+
}
|
|
1192
|
+
projected.forumResearch = nextForum;
|
|
1193
|
+
}
|
|
1194
|
+
else {
|
|
1195
|
+
delete projected.forumResearch;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const projectOverviewRef = (rawRef) => {
|
|
1199
|
+
if (rawRef === null || typeof rawRef !== 'object' || Array.isArray(rawRef))
|
|
1200
|
+
return null;
|
|
1201
|
+
const ref = rawRef;
|
|
1202
|
+
if (ref.kind === 'report_evidence' && typeof ref.sourceId === 'string' && ref.sourceId.length > 0) {
|
|
1203
|
+
return { kind: 'report_evidence', sourceId: ref.sourceId };
|
|
1204
|
+
}
|
|
1205
|
+
if (ref.kind === 'forum_receipt' &&
|
|
1206
|
+
parseReceiptId(ref.receiptId)) {
|
|
1207
|
+
return { kind: 'forum_receipt', receiptId: ref.receiptId };
|
|
1208
|
+
}
|
|
1209
|
+
if (ref.kind === 'interview_quote_v2' &&
|
|
1210
|
+
typeof ref.interviewId === 'string' && ref.interviewId.length > 0 &&
|
|
1211
|
+
typeof ref.quoteId === 'string' && ref.quoteId.length > 0) {
|
|
1212
|
+
return { kind: 'interview_quote_v2', interviewId: ref.interviewId, quoteId: ref.quoteId };
|
|
1213
|
+
}
|
|
1214
|
+
if (ref.kind === 'hypothesis' && typeof ref.hypothesisId === 'string' && ref.hypothesisId.length > 0) {
|
|
1215
|
+
return { kind: 'hypothesis', hypothesisId: ref.hypothesisId };
|
|
1216
|
+
}
|
|
1217
|
+
// Interview references and unknown future variants are private by default. Rebuilding every
|
|
1218
|
+
// surviving object from an allowlist prevents schema-drift fallback from carrying opaque ids.
|
|
1219
|
+
return null;
|
|
1220
|
+
};
|
|
1221
|
+
for (const key of ['marketOverview', 'communityOverview']) {
|
|
1222
|
+
const overview = record[key];
|
|
1223
|
+
if (overview === null || typeof overview !== 'object' || Array.isArray(overview)) {
|
|
1224
|
+
delete projected[key];
|
|
1225
|
+
continue;
|
|
1226
|
+
}
|
|
1227
|
+
const overviewRecord = overview;
|
|
1228
|
+
if (typeof overviewRecord.text !== 'string' || !Array.isArray(overviewRecord.claimSpans)) {
|
|
1229
|
+
delete projected[key];
|
|
1230
|
+
continue;
|
|
1231
|
+
}
|
|
1232
|
+
const claimSpans = overviewRecord.claimSpans.slice(0, MCP_OVERVIEW_SPAN_CAP).flatMap((rawClaim) => {
|
|
1233
|
+
if (rawClaim === null || typeof rawClaim !== 'object' || Array.isArray(rawClaim))
|
|
1234
|
+
return [];
|
|
1235
|
+
const claim = rawClaim;
|
|
1236
|
+
if (typeof claim.span !== 'string' ||
|
|
1237
|
+
(claim.claimType !== 'externally_checkable' && claim.claimType !== 'inference'))
|
|
1238
|
+
return [];
|
|
1239
|
+
const rawEvidenceRefs = Array.isArray(claim.evidenceRefs) ? claim.evidenceRefs : [];
|
|
1240
|
+
const evidenceRefs = [];
|
|
1241
|
+
const seenRefs = new Set();
|
|
1242
|
+
for (const rawRef of rawEvidenceRefs.slice(0, MCP_OVERVIEW_REF_CAP)) {
|
|
1243
|
+
const safeRef = projectOverviewRef(rawRef);
|
|
1244
|
+
if (!safeRef)
|
|
1245
|
+
continue;
|
|
1246
|
+
const refKey = safeRef.kind === 'report_evidence'
|
|
1247
|
+
? `${safeRef.kind}:${safeRef.sourceId}`
|
|
1248
|
+
: safeRef.kind === 'forum_receipt'
|
|
1249
|
+
? `${safeRef.kind}:${safeRef.receiptId}`
|
|
1250
|
+
: safeRef.kind === 'interview_quote_v2'
|
|
1251
|
+
? `${safeRef.kind}:${safeRef.interviewId}:${safeRef.quoteId}`
|
|
1252
|
+
: `${safeRef.kind}:${safeRef.hypothesisId}`;
|
|
1253
|
+
if (seenRefs.has(refKey))
|
|
1254
|
+
continue;
|
|
1255
|
+
seenRefs.add(refKey);
|
|
1256
|
+
evidenceRefs.push(safeRef);
|
|
1257
|
+
}
|
|
1258
|
+
return [{
|
|
1259
|
+
span: claim.span,
|
|
1260
|
+
claimType: claim.claimType,
|
|
1261
|
+
evidenceRefsTruncated: rawEvidenceRefs.length > MCP_OVERVIEW_REF_CAP,
|
|
1262
|
+
evidenceRefs,
|
|
1263
|
+
}];
|
|
1264
|
+
});
|
|
1265
|
+
projected[key] = {
|
|
1266
|
+
text: overviewRecord.text,
|
|
1267
|
+
claimSpansTruncated: overviewRecord.claimSpans.length > MCP_OVERVIEW_SPAN_CAP,
|
|
1268
|
+
claimSpans,
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
// Cross-cutting refs must resolve against the exact public projection. A malformed sibling
|
|
1272
|
+
// withdraws the all-or-nothing highlight block below, so validating against the raw array would
|
|
1273
|
+
// retain a finding whose cited quote is absent from structured output and rendered evidence.
|
|
950
1274
|
if (hasHighlights) {
|
|
951
1275
|
const highlights = z.array(InterviewHighlightSchema).safeParse(record.interviewHighlights);
|
|
952
1276
|
if (highlights.success)
|
|
@@ -954,6 +1278,143 @@ export function projectReportDataForMcp(raw) {
|
|
|
954
1278
|
else
|
|
955
1279
|
delete projected.interviewHighlights;
|
|
956
1280
|
}
|
|
1281
|
+
if (hasCrossCutting) {
|
|
1282
|
+
// Current reports replace the legacy surface. The projection is itself externally visible
|
|
1283
|
+
// under `_meta.report_data`, so suppressing only the composed prose would still leak it.
|
|
1284
|
+
delete projected.insights;
|
|
1285
|
+
const resolvesCrossCuttingRef = (ref) => {
|
|
1286
|
+
if (ref.kind === 'report_evidence') {
|
|
1287
|
+
const match = /^RRCP-s(0|[1-9]\d*)$/.exec(ref.sourceId ?? '');
|
|
1288
|
+
const source = match && asProjectionRecord(Array.isArray(record.reportEvidence)
|
|
1289
|
+
? record.reportEvidence[Number(match[1])]
|
|
1290
|
+
: undefined);
|
|
1291
|
+
return Boolean(source &&
|
|
1292
|
+
safeProjectionUrl(source.url) &&
|
|
1293
|
+
(source.section === 'market' || source.section === 'competitor' || source.section === 'summary'));
|
|
1294
|
+
}
|
|
1295
|
+
if (ref.kind === 'forum_receipt') {
|
|
1296
|
+
const matches = (Array.isArray(asProjectionRecord(record.forumResearch)?.threads)
|
|
1297
|
+
? asProjectionRecord(record.forumResearch).threads
|
|
1298
|
+
: []).flatMap((rawThread) => {
|
|
1299
|
+
const thread = asProjectionRecord(rawThread);
|
|
1300
|
+
return (Array.isArray(thread?.receipts) ? thread.receipts : []).filter((rawReceipt) => {
|
|
1301
|
+
const receipt = asProjectionRecord(rawReceipt);
|
|
1302
|
+
return Boolean(receipt && receipt.receiptId === ref.receiptId &&
|
|
1303
|
+
hasProjectionVisibleContent(receipt.excerpt));
|
|
1304
|
+
});
|
|
1305
|
+
});
|
|
1306
|
+
return matches.length === 1;
|
|
1307
|
+
}
|
|
1308
|
+
if (ref.kind === 'interview_quote_v2') {
|
|
1309
|
+
const matches = (Array.isArray(projected.interviewHighlights) ? projected.interviewHighlights : [])
|
|
1310
|
+
.flatMap((rawHighlight) => {
|
|
1311
|
+
const evidence = asProjectionRecord(asProjectionRecord(rawHighlight)?.evidenceReference);
|
|
1312
|
+
if (!evidence || evidence.interviewId !== ref.interviewId)
|
|
1313
|
+
return [];
|
|
1314
|
+
return (Array.isArray(evidence.quotes) ? evidence.quotes : []).filter((rawQuote) => {
|
|
1315
|
+
const quote = asProjectionRecord(rawQuote);
|
|
1316
|
+
return Boolean(quote && quote.quoteId === ref.quoteId &&
|
|
1317
|
+
hasProjectionVisibleContent(quote.text));
|
|
1318
|
+
});
|
|
1319
|
+
});
|
|
1320
|
+
return matches.length === 1;
|
|
1321
|
+
}
|
|
1322
|
+
return false;
|
|
1323
|
+
};
|
|
1324
|
+
projected.crossCuttingFindings = Array.isArray(record.crossCuttingFindings)
|
|
1325
|
+
? record.crossCuttingFindings.slice(0, 5).flatMap((rawFinding) => {
|
|
1326
|
+
if (rawFinding === null || typeof rawFinding !== 'object' || Array.isArray(rawFinding))
|
|
1327
|
+
return [];
|
|
1328
|
+
const finding = rawFinding;
|
|
1329
|
+
if (!hasProjectionVisibleContent(finding.claim) ||
|
|
1330
|
+
!hasProjectionVisibleContent(finding.whyItMatters) ||
|
|
1331
|
+
!['low', 'medium', 'high'].includes(String(finding.confidence)))
|
|
1332
|
+
return [];
|
|
1333
|
+
const rawEvidenceRefs = Array.isArray(finding.evidenceRefs) ? finding.evidenceRefs : [];
|
|
1334
|
+
const evidenceRefs = rawEvidenceRefs
|
|
1335
|
+
.slice(0, 8)
|
|
1336
|
+
.flatMap((rawRef) => {
|
|
1337
|
+
const ref = projectOverviewRef(rawRef);
|
|
1338
|
+
return ref ? [ref] : [];
|
|
1339
|
+
});
|
|
1340
|
+
const methods = new Set(evidenceRefs.map((ref) => ref.kind));
|
|
1341
|
+
const refKeys = new Set(evidenceRefs.map((ref) => JSON.stringify(ref)));
|
|
1342
|
+
if (rawEvidenceRefs.length < 2 ||
|
|
1343
|
+
rawEvidenceRefs.length > 8 ||
|
|
1344
|
+
evidenceRefs.length !== rawEvidenceRefs.length ||
|
|
1345
|
+
refKeys.size < 2 ||
|
|
1346
|
+
methods.size < 2 ||
|
|
1347
|
+
!evidenceRefs.every(resolvesCrossCuttingRef))
|
|
1348
|
+
return [];
|
|
1349
|
+
return [{
|
|
1350
|
+
claim: finding.claim,
|
|
1351
|
+
whyItMatters: finding.whyItMatters,
|
|
1352
|
+
confidence: finding.confidence,
|
|
1353
|
+
evidenceRefs,
|
|
1354
|
+
}];
|
|
1355
|
+
})
|
|
1356
|
+
: [];
|
|
1357
|
+
}
|
|
1358
|
+
if (hasHypothesisResults && Array.isArray(record.hypothesisResults)) {
|
|
1359
|
+
projected.hypothesisResults = record.hypothesisResults.map((rawRow) => {
|
|
1360
|
+
if (rawRow === null || typeof rawRow !== 'object' || Array.isArray(rawRow))
|
|
1361
|
+
return rawRow;
|
|
1362
|
+
const row = rawRow;
|
|
1363
|
+
const safeRow = { ...row };
|
|
1364
|
+
let changed = false;
|
|
1365
|
+
if (Object.prototype.hasOwnProperty.call(row, 'final')) {
|
|
1366
|
+
const normalized = normalizeFinalHypothesisState(row);
|
|
1367
|
+
if (normalized)
|
|
1368
|
+
safeRow.final = normalized;
|
|
1369
|
+
else
|
|
1370
|
+
delete safeRow.final;
|
|
1371
|
+
changed = safeRow.final !== row.final;
|
|
1372
|
+
}
|
|
1373
|
+
const rawRobustness = row.robustness;
|
|
1374
|
+
const robustnessRecord = rawRobustness !== null &&
|
|
1375
|
+
typeof rawRobustness === 'object' &&
|
|
1376
|
+
!Array.isArray(rawRobustness)
|
|
1377
|
+
? rawRobustness
|
|
1378
|
+
: null;
|
|
1379
|
+
const nonRecordRobustnessIsUnsafe = rawRobustness !== undefined && rawRobustness !== null && robustnessRecord === null;
|
|
1380
|
+
const parentStatusIsUnsafe = robustnessRecord !== null &&
|
|
1381
|
+
!VERDICT_STATUSES.includes(row.status);
|
|
1382
|
+
const carriesMeasuredCounts = robustnessRecord !== null &&
|
|
1383
|
+
(Object.prototype.hasOwnProperty.call(robustnessRecord, 'survived') ||
|
|
1384
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'total'));
|
|
1385
|
+
const carriesVariants = robustnessRecord !== null &&
|
|
1386
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'variants');
|
|
1387
|
+
const carriesOriginalStatus = robustnessRecord !== null &&
|
|
1388
|
+
Object.prototype.hasOwnProperty.call(robustnessRecord, 'originalStatus');
|
|
1389
|
+
const originalStatusMatches = robustnessRecord !== null &&
|
|
1390
|
+
robustnessRecord.originalStatus === row.status;
|
|
1391
|
+
const workingIsUnsafe = carriesVariants && robustnessWorkingContradictsSummary(robustnessRecord);
|
|
1392
|
+
const originalStatusIsUnsafe = carriesOriginalStatus && !originalStatusMatches;
|
|
1393
|
+
const measuredCountsAreUnsafe = carriesMeasuredCounts && !readRobustnessCounts(robustnessRecord);
|
|
1394
|
+
if (nonRecordRobustnessIsUnsafe || parentStatusIsUnsafe) {
|
|
1395
|
+
delete safeRow.robustness;
|
|
1396
|
+
changed = true;
|
|
1397
|
+
}
|
|
1398
|
+
else if (robustnessRecord && (workingIsUnsafe || originalStatusIsUnsafe || measuredCountsAreUnsafe)) {
|
|
1399
|
+
// Keep a readable downgrade: a broken diagnostic ratio must not erase the weakest
|
|
1400
|
+
// recorded verdict. Contradictory detail cannot authenticate the summary or dependent
|
|
1401
|
+
// final block, so neither may cross the typed or raw-drift exit.
|
|
1402
|
+
const safeRobustness = { ...robustnessRecord };
|
|
1403
|
+
delete safeRobustness.survived;
|
|
1404
|
+
delete safeRobustness.total;
|
|
1405
|
+
delete safeRobustness.flipped;
|
|
1406
|
+
if (workingIsUnsafe || originalStatusIsUnsafe)
|
|
1407
|
+
delete safeRobustness.variants;
|
|
1408
|
+
if (originalStatusIsUnsafe)
|
|
1409
|
+
delete safeRobustness.originalStatus;
|
|
1410
|
+
safeRow.robustness = safeRobustness;
|
|
1411
|
+
changed = true;
|
|
1412
|
+
}
|
|
1413
|
+
if (!changed)
|
|
1414
|
+
return rawRow;
|
|
1415
|
+
return safeRow;
|
|
1416
|
+
});
|
|
1417
|
+
}
|
|
957
1418
|
// ⚠️ FUL-685 — THE REDDIT NOTE IS PROJECTED HERE FOR THE SAME REASON `researchTaskId` IS:
|
|
958
1419
|
// `prepareReportDataForMcp` returns this projection UNPARSED when the full schema drifts, so a
|
|
959
1420
|
// field stripped only by `RedditRetrievalNoteSchema` would come straight back on that path — and
|
|
@@ -1191,6 +1652,14 @@ export const MarketSizingLineSchema = z
|
|
|
1191
1652
|
}
|
|
1192
1653
|
}
|
|
1193
1654
|
});
|
|
1655
|
+
export const MarketSignalReferenceSchema = z.discriminatedUnion('kind', [
|
|
1656
|
+
z.object({ kind: z.literal('size'), scope: z.enum(['tam', 'sam', 'som']) }),
|
|
1657
|
+
z.object({
|
|
1658
|
+
kind: z.enum(['growth', 'buying_behavior', 'category_dynamics']),
|
|
1659
|
+
claimId: z.string().regex(/^RCLM-m(?:0|[1-9]\d*)$/),
|
|
1660
|
+
sourceId: z.string().regex(/^RRCP-s(?:0|[1-9]\d*)$/).optional(),
|
|
1661
|
+
}),
|
|
1662
|
+
]);
|
|
1194
1663
|
/**
|
|
1195
1664
|
* The market block a run established — the shape `scan_market` (FUL-479) surfaces to the caller
|
|
1196
1665
|
* and the server promotes into the Research → Market dossier.
|
|
@@ -1201,28 +1670,28 @@ export const MarketSizingLineSchema = z
|
|
|
1201
1670
|
* benefit, not an agent's. `extractMarket` does the flattening so this mirror describes what the
|
|
1202
1671
|
* agent is actually being told, and every field here maps 1:1 onto a `project_market` column.
|
|
1203
1672
|
*
|
|
1204
|
-
*
|
|
1205
|
-
*
|
|
1206
|
-
*
|
|
1207
|
-
*
|
|
1673
|
+
* `marketSignals` below are producer selectors, not proof on their own. `extractMarket` resolves
|
|
1674
|
+
* them against exact claims, receipts, and derivations and exposes only survivors as
|
|
1675
|
+
* `resolvedSignals`. The other fields remain legacy recorded context and never inherit a
|
|
1676
|
+
* selector's authority.
|
|
1208
1677
|
*/
|
|
1209
1678
|
export const MarketFindingsSchema = z
|
|
1210
1679
|
.object({
|
|
1211
1680
|
estimatedMarketSize: z
|
|
1212
1681
|
.string()
|
|
1213
1682
|
.optional()
|
|
1214
|
-
.describe('
|
|
1683
|
+
.describe('Legacy raw recorded market-size context, in the source\'s own words. Derived by the producer as ' +
|
|
1215
1684
|
'the first claim that is a size (never a rate). Absent or empty when the run established ' +
|
|
1216
|
-
'no size — which is a result, not a zero.'),
|
|
1685
|
+
'no size — which is a result, not a zero. Neutral unless emitted separately as a strict resolved signal.'),
|
|
1217
1686
|
growthTrend: z
|
|
1218
1687
|
.string()
|
|
1219
1688
|
.optional()
|
|
1220
|
-
.describe('"growing" | "stable" | "declining" | "unknown". "unknown"
|
|
1221
|
-
keyTrends: z.array(z.string()).optional().describe('
|
|
1689
|
+
.describe('Raw reported direction: "growing" | "stable" | "declining" | "unknown". Neutral recorded context, never cited growth; "unknown" means the sources disagreed or said nothing.'),
|
|
1690
|
+
keyTrends: z.array(z.string()).optional().describe('Neutral legacy context: industry trends this run recorded as moving the market.'),
|
|
1222
1691
|
claims: z
|
|
1223
1692
|
.array(MarketClaimLineSchema)
|
|
1224
1693
|
.optional()
|
|
1225
|
-
.describe('
|
|
1694
|
+
.describe('Raw recorded atomic market figures, one per entry, with source-named scope where present. Neutral legacy context, not independently citable.'),
|
|
1226
1695
|
marketSizing: z
|
|
1227
1696
|
.array(z.unknown())
|
|
1228
1697
|
.transform((items) => items.flatMap((item) => {
|
|
@@ -1230,11 +1699,19 @@ export const MarketFindingsSchema = z
|
|
|
1230
1699
|
return parsed.success ? [parsed.data] : [];
|
|
1231
1700
|
}))
|
|
1232
1701
|
.optional()
|
|
1233
|
-
.describe('Typed TAM/SAM/SOM
|
|
1702
|
+
.describe('Typed TAM/SAM/SOM proof candidates. Malformed siblings are omitted; only entries selected and proven in resolvedSignals are citable.'),
|
|
1703
|
+
marketSignals: z
|
|
1704
|
+
.array(z.unknown())
|
|
1705
|
+
.transform((items) => items.slice(0, 3).flatMap((item) => {
|
|
1706
|
+
const parsed = MarketSignalReferenceSchema.safeParse(item);
|
|
1707
|
+
return parsed.success ? [parsed.data] : [];
|
|
1708
|
+
}))
|
|
1709
|
+
.optional()
|
|
1710
|
+
.describe('Zero-to-three ordered producer selectors, not independently citable. extractMarket emits a selector in resolvedSignals only after strict sizing proof or an exact grounded claim, non-empty contained quote span, and receipt. Empty resolvedSignals means no selector survived proof, not that the raw context or market is absent, small, or zero.'),
|
|
1234
1711
|
positioningGaps: z
|
|
1235
1712
|
.array(z.string())
|
|
1236
1713
|
.optional()
|
|
1237
|
-
.describe('
|
|
1714
|
+
.describe('Neutral legacy context: segments the category under-serves and needs available tools do not meet, as recorded by the run; not independently citable or a competitor comparison.'),
|
|
1238
1715
|
})
|
|
1239
1716
|
.passthrough();
|
|
1240
1717
|
/**
|
|
@@ -1245,6 +1722,14 @@ export const MarketFindingsSchema = z
|
|
|
1245
1722
|
export const ForumThreadSchema = z
|
|
1246
1723
|
.object({
|
|
1247
1724
|
title: z.string(),
|
|
1725
|
+
theme: z
|
|
1726
|
+
.string()
|
|
1727
|
+
.trim()
|
|
1728
|
+
.max(48)
|
|
1729
|
+
.regex(/^[\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*(?: [\p{L}\p{N}]+(?:[\p{Pd}'’][\p{L}\p{N}]+)*)?$/u)
|
|
1730
|
+
.optional()
|
|
1731
|
+
.catch(undefined)
|
|
1732
|
+
.describe('Producer-authored 1-2-word semantic theme. Distinct from title, which remains source metadata and the discussion link label.'),
|
|
1248
1733
|
url: z.string().optional(),
|
|
1249
1734
|
platform: z.string().optional(),
|
|
1250
1735
|
/**
|
|
@@ -1319,5 +1804,16 @@ export const ForumThreadSchema = z
|
|
|
1319
1804
|
.describe('Receipt children of this discussion, when it yielded several bounded excerpts. Each names ' +
|
|
1320
1805
|
'one exact excerpt in `relevantQuotes`; absent means no rail minted per-excerpt ids for it.'),
|
|
1321
1806
|
})
|
|
1322
|
-
.passthrough()
|
|
1807
|
+
.passthrough()
|
|
1808
|
+
.overwrite((thread) => {
|
|
1809
|
+
if (!thread.theme)
|
|
1810
|
+
return thread;
|
|
1811
|
+
const theme = readSemanticTheme(thread.theme, thread.title);
|
|
1812
|
+
if (theme === null) {
|
|
1813
|
+
const withoutTheme = { ...thread };
|
|
1814
|
+
delete withoutTheme.theme;
|
|
1815
|
+
return withoutTheme;
|
|
1816
|
+
}
|
|
1817
|
+
return theme === thread.theme ? thread : { ...thread, theme };
|
|
1818
|
+
});
|
|
1323
1819
|
//# sourceMappingURL=report.js.map
|