fullstackgtm 0.16.0 → 0.17.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 +36 -0
- package/dist/cli.js +121 -12
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/llm.d.ts +7 -0
- package/dist/llm.js +7 -1
- package/dist/market.d.ts +19 -0
- package/dist/market.js +76 -0
- package/dist/marketClassify.d.ts +49 -0
- package/dist/marketClassify.js +201 -0
- package/dist/mcp.js +45 -0
- package/package.json +1 -1
- package/src/cli.ts +129 -12
- package/src/index.ts +11 -0
- package/src/llm.ts +7 -1
- package/src/market.ts +92 -0
- package/src/marketClassify.ts +286 -0
- package/src/mcp.ts +65 -0
package/dist/mcp.js
CHANGED
|
@@ -47,6 +47,8 @@ import { builtinAuditRules } from "./rules.js";
|
|
|
47
47
|
import { sampleSnapshot } from "./sampleData.js";
|
|
48
48
|
import { normalizeTranscript, parseCall } from "./calls.js";
|
|
49
49
|
import { extractInsightsLlm, resolveLlmCredential } from "./llm.js";
|
|
50
|
+
import { computeFrontStates, createFileObservationStore, loadCaptureTexts, loadMarketConfig, validateObservationSet, verifyEvidenceSpans, } from "./market.js";
|
|
51
|
+
import { buildWorksheet } from "./marketClassify.js";
|
|
50
52
|
import { resolveRecord } from "./resolve.js";
|
|
51
53
|
import { suggestValues } from "./suggest.js";
|
|
52
54
|
function content(value) {
|
|
@@ -244,6 +246,49 @@ export async function startMcpServer() {
|
|
|
244
246
|
});
|
|
245
247
|
return content(output === "markdown" ? formatPatchPlanRun(run) : run);
|
|
246
248
|
});
|
|
249
|
+
server.registerTool("fullstackgtm_market_worksheet", {
|
|
250
|
+
title: "Market Map Classification Worksheet",
|
|
251
|
+
description: "Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
|
|
252
|
+
"the claim taxonomy with judging definitions, the surface rule, and the captured page " +
|
|
253
|
+
"texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
|
|
254
|
+
"and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
|
|
255
|
+
"ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
|
|
256
|
+
"character against the captures, so never paraphrase.",
|
|
257
|
+
inputSchema: {
|
|
258
|
+
vendorId: z.string(),
|
|
259
|
+
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
260
|
+
captureRun: z.string().optional(),
|
|
261
|
+
},
|
|
262
|
+
}, async ({ vendorId, configPath, captureRun }) => {
|
|
263
|
+
const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
264
|
+
return content(buildWorksheet(config, vendorId, { captureRun }));
|
|
265
|
+
});
|
|
266
|
+
server.registerTool("fullstackgtm_market_observe", {
|
|
267
|
+
title: "Submit Market Map Observations",
|
|
268
|
+
description: "Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
|
|
269
|
+
"Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
|
|
270
|
+
"span against the stored capture it cites. Returns problems if rejected; nothing is " +
|
|
271
|
+
"stored unless the whole set passes. Observations are append-only — use a new runLabel.",
|
|
272
|
+
inputSchema: {
|
|
273
|
+
observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
|
|
274
|
+
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
275
|
+
},
|
|
276
|
+
}, async ({ observationsPath, configPath }) => {
|
|
277
|
+
const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
278
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8"));
|
|
279
|
+
const problems = validateObservationSet(config, set);
|
|
280
|
+
const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
|
|
281
|
+
if (problems.length > 0 || failures.length > 0) {
|
|
282
|
+
return content({
|
|
283
|
+
accepted: false,
|
|
284
|
+
problems,
|
|
285
|
+
spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
await createFileObservationStore(config.category).append(set);
|
|
289
|
+
const fronts = computeFrontStates(config, set);
|
|
290
|
+
return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
|
|
291
|
+
});
|
|
247
292
|
const transport = new StdioServerTransport();
|
|
248
293
|
await server.connect(transport);
|
|
249
294
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fullstackgtm",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Open-source agentic GTM ops framework: canonical GTM data model, pluggable deterministic audits, reviewable dry-run patch plans, approval-gated write-back with conflict detection, and cross-system entity resolution. HubSpot, Salesforce, and Stripe connectors included.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Full Stack GTM",
|
package/src/cli.ts
CHANGED
|
@@ -44,11 +44,14 @@ import {
|
|
|
44
44
|
computeFrontStates,
|
|
45
45
|
createFileObservationStore,
|
|
46
46
|
diffFrontStates,
|
|
47
|
+
loadCaptureTexts,
|
|
47
48
|
loadMarketConfig,
|
|
48
49
|
starterMarketConfig,
|
|
49
50
|
validateObservationSet,
|
|
51
|
+
verifyEvidenceSpans,
|
|
50
52
|
type ObservationSet,
|
|
51
53
|
} from "./market.ts";
|
|
54
|
+
import { buildWorksheet, classifyMarket } from "./marketClassify.ts";
|
|
52
55
|
import { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
53
56
|
import {
|
|
54
57
|
DEFAULT_RUBRIC,
|
|
@@ -107,13 +110,18 @@ Usage:
|
|
|
107
110
|
found (exists/ambiguous) — call before ANY record creation
|
|
108
111
|
fullstackgtm market init --category <name> start a market map: vendors + claim taxonomy as reviewable config
|
|
109
112
|
fullstackgtm market capture [--config <path>] [--run <label>]
|
|
110
|
-
fullstackgtm market
|
|
113
|
+
fullstackgtm market classify [--run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
114
|
+
fullstackgtm market worksheet --vendor <id> [--out <path>]
|
|
115
|
+
fullstackgtm market observe --from <observations.json> [--unverified]
|
|
111
116
|
fullstackgtm market fronts [--run <label>] [--diff <prior-run>] [--json]
|
|
112
117
|
fullstackgtm market report [--run <label>] [--format md|html] [--out <path>]
|
|
118
|
+
fullstackgtm market refresh [--run <label>] [--model m]
|
|
113
119
|
the live competitive map: capture vendor pages (content-addressed),
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
classify intensity per claim (LLM bring-your-own-key, or fill the
|
|
121
|
+
worksheet with any agent) — every quoted span is verified verbatim
|
|
122
|
+
against the stored capture it cites before it's accepted — then
|
|
123
|
+
compute deterministic front states and drift, render the field
|
|
124
|
+
report. refresh = capture → classify → drift → report in one step
|
|
117
125
|
fullstackgtm suggest --plan-id <id> | --plan <path> [source options] [--json] [--out <path>]
|
|
118
126
|
derive values for requires_human_* placeholders
|
|
119
127
|
from snapshot evidence, with confidence + reasons
|
|
@@ -696,15 +704,22 @@ the free keyword baseline; score always needs a key (scoring is LLM work).`);
|
|
|
696
704
|
* TTY a missing key is captured once (validated, stored 0600 like provider
|
|
697
705
|
* logins). Non-interactive contexts get an actionable error instead.
|
|
698
706
|
*/
|
|
699
|
-
async function requireLlmCredential(
|
|
707
|
+
async function requireLlmCredential(
|
|
708
|
+
command: "parse" | "score" | "market classify" = "parse",
|
|
709
|
+
): Promise<{ provider: LlmProvider; apiKey: string }> {
|
|
700
710
|
const resolved = resolveLlmCredential();
|
|
701
711
|
if (resolved) return resolved;
|
|
702
712
|
// Scoring is inherently LLM work — there is no keyword fallback to suggest.
|
|
703
713
|
const fallbackHint =
|
|
704
|
-
command === "parse"
|
|
714
|
+
command === "parse"
|
|
715
|
+
? ", or pass --deterministic for the free keyword baseline"
|
|
716
|
+
: command === "score"
|
|
717
|
+
? " (call score has no non-LLM mode)"
|
|
718
|
+
: ", or classify by hand: `market worksheet --vendor <id>` then `market observe --from`";
|
|
719
|
+
const work = command === "score" ? "scoring" : command === "parse" ? "extraction" : "classification";
|
|
705
720
|
if (!process.stdin.isTTY) {
|
|
706
721
|
throw new Error(
|
|
707
|
-
`LLM ${
|
|
722
|
+
`LLM ${work} needs an API key. Set ANTHROPIC_API_KEY or OPENAI_API_KEY, or run \`echo "$KEY" | fullstackgtm login anthropic\` (or \`login openai\`) once${fallbackHint}.`,
|
|
708
723
|
);
|
|
709
724
|
}
|
|
710
725
|
console.error("LLM parsing needs an API key (Anthropic or OpenAI) — yours, used directly with the provider.");
|
|
@@ -824,9 +839,11 @@ function buildCallPlan(
|
|
|
824
839
|
/**
|
|
825
840
|
* The market map: claim taxonomy in a reviewable config file, page captures
|
|
826
841
|
* and append-only observations under the profile home, deterministic front
|
|
827
|
-
* states and reports computed from the store.
|
|
828
|
-
*
|
|
829
|
-
*
|
|
842
|
+
* states and reports computed from the store. Intensity readings enter as
|
|
843
|
+
* proposals through two channels — `classify` (LLM, bring-your-own-key, the
|
|
844
|
+
* call-intelligence pattern) and `worksheet`/`observe` (an agent or human
|
|
845
|
+
* fills the worksheet) — and BOTH pass the same mechanical gate: every quoted
|
|
846
|
+
* span is verified verbatim against the stored capture it cites.
|
|
830
847
|
*/
|
|
831
848
|
async function marketCommand(args: string[]) {
|
|
832
849
|
const [subcommand, ...rest] = args;
|
|
@@ -836,9 +853,18 @@ async function marketCommand(args: string[]) {
|
|
|
836
853
|
console.log(`Usage:
|
|
837
854
|
market init --category <name> [--out <path>] write a starter market.config.json
|
|
838
855
|
market capture [--config <path>] [--run <label>]
|
|
839
|
-
market
|
|
856
|
+
market classify [--run <label>] [--capture-run <label>] [--vendor <id>] [--model m] [--out <path>]
|
|
857
|
+
market worksheet --vendor <id> [--capture-run <label>] [--out <path>]
|
|
858
|
+
market observe --from <observations.json> [--unverified]
|
|
840
859
|
market fronts [--config <path>] [--run <label>] [--diff <prior-run>] [--json]
|
|
841
860
|
market report [--config <path>] [--run <label>] [--format md|html] [--out <path>]
|
|
861
|
+
market refresh [--run <label>] [--model m] capture → classify → fronts drift → HTML report
|
|
862
|
+
|
|
863
|
+
classify uses your Anthropic/OpenAI key (like call parse) to read the stored
|
|
864
|
+
captures and propose intensity readings; worksheet is the no-key path (an
|
|
865
|
+
agent or human fills it, submits via observe). Either way, every quoted span
|
|
866
|
+
is verified character-for-character against the capture it cites before the
|
|
867
|
+
observation is accepted — quotes that aren't on the page bounce.
|
|
842
868
|
|
|
843
869
|
The taxonomy (vendors + claims) is config you review and version; captures
|
|
844
870
|
and observations live under ~/.fullstackgtm/market/<category> (profile-scoped,
|
|
@@ -883,11 +909,100 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
883
909
|
process.exitCode = 1;
|
|
884
910
|
return;
|
|
885
911
|
}
|
|
912
|
+
if (!rest.includes("--unverified")) {
|
|
913
|
+
const { textByHash } = loadCaptureTexts(config.category);
|
|
914
|
+
const failures = verifyEvidenceSpans(set.observations, textByHash);
|
|
915
|
+
if (failures.length > 0) {
|
|
916
|
+
console.error(`Rejected: ${failures.length} evidence span(s) failed verification against the stored captures`);
|
|
917
|
+
for (const failure of failures.slice(0, 20)) {
|
|
918
|
+
console.error(` - ${failure.vendorId} × ${failure.claimId}: ${failure.problem}`);
|
|
919
|
+
}
|
|
920
|
+
console.error("Quotes must be copied verbatim from the captured pages. (--unverified skips this gate when the captures genuinely live elsewhere.)");
|
|
921
|
+
process.exitCode = 1;
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
886
925
|
await store.append(set);
|
|
887
926
|
console.log(`Appended ${set.runLabel}: ${set.observations.length} observations (${set.extractor})`);
|
|
888
927
|
return;
|
|
889
928
|
}
|
|
890
929
|
|
|
930
|
+
if (subcommand === "worksheet") {
|
|
931
|
+
const vendorId = option(rest, "--vendor");
|
|
932
|
+
if (!vendorId) throw new Error("market worksheet requires --vendor <id>");
|
|
933
|
+
const worksheet = buildWorksheet(config, vendorId, { captureRun: option(rest, "--capture-run") ?? undefined });
|
|
934
|
+
const outPath = option(rest, "--out");
|
|
935
|
+
const payload = `${JSON.stringify(worksheet, null, 2)}\n`;
|
|
936
|
+
if (outPath) {
|
|
937
|
+
writeFileSync(resolve(process.cwd(), outPath), payload);
|
|
938
|
+
console.log(`Wrote ${outPath} (${worksheet.pages.length} captured pages, ${worksheet.claims.length} claims)`);
|
|
939
|
+
} else {
|
|
940
|
+
console.log(payload);
|
|
941
|
+
}
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
if (subcommand === "classify") {
|
|
946
|
+
const credential = await requireLlmCredential("market classify");
|
|
947
|
+
const vendorFilter = option(rest, "--vendor");
|
|
948
|
+
const outPath = option(rest, "--out");
|
|
949
|
+
if (vendorFilter && !outPath) {
|
|
950
|
+
throw new Error(
|
|
951
|
+
"market classify --vendor produces a partial set (coverage validation would reject it) — pass --out <path> to inspect/merge it by hand",
|
|
952
|
+
);
|
|
953
|
+
}
|
|
954
|
+
const result = await classifyMarket(config, {
|
|
955
|
+
llm: { ...credential, model: option(rest, "--model") ?? undefined },
|
|
956
|
+
runLabel: option(rest, "--run") ?? option(rest, "--capture-run") ?? "run-1",
|
|
957
|
+
captureRun: option(rest, "--capture-run") ?? undefined,
|
|
958
|
+
vendors: vendorFilter ? [vendorFilter] : undefined,
|
|
959
|
+
});
|
|
960
|
+
if (result.retriedVendorIds.length > 0) {
|
|
961
|
+
console.error(`Span verification bounced ${result.retriedVendorIds.join(", ")} once; retry passed.`);
|
|
962
|
+
}
|
|
963
|
+
if (outPath) {
|
|
964
|
+
writeFileSync(resolve(process.cwd(), outPath), `${JSON.stringify(result.set, null, 2)}\n`);
|
|
965
|
+
console.log(`Wrote ${outPath}: ${result.set.observations.length} verified observations (${result.set.extractor})`);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
const problems = validateObservationSet(config, result.set);
|
|
969
|
+
if (problems.length > 0) {
|
|
970
|
+
throw new Error(`Classified set failed validation: ${problems.slice(0, 5).join("; ")}`);
|
|
971
|
+
}
|
|
972
|
+
await store.append(result.set);
|
|
973
|
+
console.log(
|
|
974
|
+
`Appended ${result.set.runLabel}: ${result.set.observations.length} observations, every span verified (${result.set.extractor})`,
|
|
975
|
+
);
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
if (subcommand === "refresh") {
|
|
980
|
+
const credential = await requireLlmCredential("market classify");
|
|
981
|
+
const runLabel = option(rest, "--run") ?? `run-${new Date().toISOString().slice(0, 10)}`;
|
|
982
|
+
const prior = await store.latest();
|
|
983
|
+
console.log(`Capturing ${config.vendors.length} vendors as ${runLabel}…`);
|
|
984
|
+
const captured = await captureMarket(config, { runLabel });
|
|
985
|
+
const failed = captured.entries.filter((entry) => !entry.captureHash);
|
|
986
|
+
if (failed.length > 0) console.log(`${failed.length} page(s) failed/empty — affected cells will verify against remaining pages or read unobservable.`);
|
|
987
|
+
console.log(`Classifying with ${credential.provider}…`);
|
|
988
|
+
const result = await classifyMarket(config, {
|
|
989
|
+
llm: { ...credential, model: option(rest, "--model") ?? undefined },
|
|
990
|
+
runLabel,
|
|
991
|
+
captureRun: runLabel,
|
|
992
|
+
});
|
|
993
|
+
await store.append(result.set);
|
|
994
|
+
const fronts = computeFrontStates(config, result.set);
|
|
995
|
+
if (prior) {
|
|
996
|
+
const drift = diffFrontStates(computeFrontStates(config, prior), fronts);
|
|
997
|
+
if (drift.length === 0) console.log(`No front changes since ${prior.runLabel}.`);
|
|
998
|
+
for (const change of drift) console.log(`CHANGED ${change.claimId}: ${change.before} → ${change.after}`);
|
|
999
|
+
}
|
|
1000
|
+
const outPath = option(rest, "--out") ?? `${config.category}-${runLabel}.html`;
|
|
1001
|
+
writeFileSync(resolve(process.cwd(), outPath), marketMapToHtml(config, result.set));
|
|
1002
|
+
console.log(`Wrote ${outPath}`);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
891
1006
|
const loadSet = async (): Promise<ObservationSet> => {
|
|
892
1007
|
const runLabel = option(rest, "--run");
|
|
893
1008
|
const set = runLabel ? await store.get(runLabel) : await store.latest();
|
|
@@ -938,7 +1053,9 @@ recomputed deterministically on every invocation — never stored.`);
|
|
|
938
1053
|
return;
|
|
939
1054
|
}
|
|
940
1055
|
|
|
941
|
-
throw new Error(
|
|
1056
|
+
throw new Error(
|
|
1057
|
+
`Unknown market subcommand: ${subcommand} (try: init, capture, classify, worksheet, observe, fronts, report, refresh)`,
|
|
1058
|
+
);
|
|
942
1059
|
}
|
|
943
1060
|
|
|
944
1061
|
/**
|
package/src/index.ts
CHANGED
|
@@ -136,12 +136,15 @@ export {
|
|
|
136
136
|
createFileObservationStore,
|
|
137
137
|
diffFrontStates,
|
|
138
138
|
extractReadableText,
|
|
139
|
+
loadCaptureTexts,
|
|
139
140
|
loadMarketConfig,
|
|
140
141
|
marketHome,
|
|
142
|
+
normalizeForMatch,
|
|
141
143
|
observationId,
|
|
142
144
|
parseMarketConfig,
|
|
143
145
|
starterMarketConfig,
|
|
144
146
|
validateObservationSet,
|
|
147
|
+
verifyEvidenceSpans,
|
|
145
148
|
type CaptureEntry,
|
|
146
149
|
type CaptureOptions,
|
|
147
150
|
type ClaimFront,
|
|
@@ -155,7 +158,15 @@ export {
|
|
|
155
158
|
type ObservationConfidence,
|
|
156
159
|
type ObservationSet,
|
|
157
160
|
type ObservationStore,
|
|
161
|
+
type SpanVerificationFailure,
|
|
158
162
|
} from "./market.ts";
|
|
163
|
+
export {
|
|
164
|
+
buildWorksheet,
|
|
165
|
+
classifyMarket,
|
|
166
|
+
type ClassifyMarketOptions,
|
|
167
|
+
type ClassifyMarketResult,
|
|
168
|
+
type MarketWorksheet,
|
|
169
|
+
} from "./marketClassify.ts";
|
|
159
170
|
export { marketMapToHtml, marketMapToMarkdown } from "./marketReport.ts";
|
|
160
171
|
export { suggestValues, type SuggestionConfidence, type ValueSuggestion } from "./suggest.ts";
|
|
161
172
|
export type {
|
package/src/llm.ts
CHANGED
|
@@ -239,7 +239,13 @@ export function parseRubric(json: string): Rubric {
|
|
|
239
239
|
|
|
240
240
|
// ── Provider plumbing (raw fetch, forced tool calls) ───────────────────────
|
|
241
241
|
|
|
242
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Shared constrained-tool-call plumbing: force the model to answer through a
|
|
244
|
+
* single tool whose input_schema is the output contract. Exported for other
|
|
245
|
+
* semi-deterministic features (market classification) — every LLM feature in
|
|
246
|
+
* the package goes through this one seam.
|
|
247
|
+
*/
|
|
248
|
+
export async function forcedToolCall(
|
|
243
249
|
prompt: string,
|
|
244
250
|
toolName: string,
|
|
245
251
|
schema: object,
|
package/src/market.ts
CHANGED
|
@@ -408,6 +408,98 @@ export function validateObservationSet(config: MarketConfig, set: ObservationSet
|
|
|
408
408
|
return problems;
|
|
409
409
|
}
|
|
410
410
|
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Evidence span verification — the deterministic gate that makes the
|
|
413
|
+
// verbatim-quote rule mechanical instead of a prompt instruction. Because the
|
|
414
|
+
// source documents are *stored* (unlike call transcripts, which pass through),
|
|
415
|
+
// every quoted span can be checked against the capture it cites before the
|
|
416
|
+
// observation is accepted. Comparison is whitespace-normalized only: case and
|
|
417
|
+
// wording must match the page exactly.
|
|
418
|
+
|
|
419
|
+
export function loadCaptureTexts(
|
|
420
|
+
category: string,
|
|
421
|
+
directory?: string,
|
|
422
|
+
): { entries: CaptureEntry[]; textByHash: Map<string, string> } {
|
|
423
|
+
const dir = directory ?? join(marketHome(category), "captures");
|
|
424
|
+
const manifestPath = join(dir, "manifest.json");
|
|
425
|
+
const entries: CaptureEntry[] = existsSync(manifestPath)
|
|
426
|
+
? (JSON.parse(readFileSync(manifestPath, "utf8")) as CaptureEntry[])
|
|
427
|
+
: [];
|
|
428
|
+
const textByHash = new Map<string, string>();
|
|
429
|
+
for (const entry of entries) {
|
|
430
|
+
if (entry.captureHash && !textByHash.has(entry.captureHash)) {
|
|
431
|
+
try {
|
|
432
|
+
textByHash.set(entry.captureHash, readFileSync(join(dir, `${entry.captureHash}.txt`), "utf8"));
|
|
433
|
+
} catch {
|
|
434
|
+
// Missing capture file: verification of anything citing it will fail loudly.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return { entries, textByHash };
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Whitespace-only normalization for span matching, plus one extraction
|
|
443
|
+
* artifact: the HTML-to-text step can emit a line break before punctuation
|
|
444
|
+
* that follows an inline tag ("placements\n. Districts"), which no honest
|
|
445
|
+
* quoter would reproduce — so whitespace *before* punctuation is dropped
|
|
446
|
+
* too. Words, casing, and characters must still match the page exactly.
|
|
447
|
+
*/
|
|
448
|
+
export function normalizeForMatch(value: string): string {
|
|
449
|
+
return value
|
|
450
|
+
.replace(/\s+([.,;:!?])/g, "$1")
|
|
451
|
+
.replace(/\s+/g, " ")
|
|
452
|
+
.trim();
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
export type SpanVerificationFailure = {
|
|
456
|
+
vendorId: string;
|
|
457
|
+
claimId: string;
|
|
458
|
+
quote: string;
|
|
459
|
+
problem: string;
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
export function verifyEvidenceSpans(
|
|
463
|
+
observations: MarketObservation[],
|
|
464
|
+
textByHash: Map<string, string>,
|
|
465
|
+
): SpanVerificationFailure[] {
|
|
466
|
+
const failures: SpanVerificationFailure[] = [];
|
|
467
|
+
for (const obs of observations) {
|
|
468
|
+
for (const evidence of obs.evidence) {
|
|
469
|
+
const quote = evidence.text ?? "";
|
|
470
|
+
const hash = String(evidence.metadata?.captureHash ?? "");
|
|
471
|
+
if (!hash) {
|
|
472
|
+
failures.push({
|
|
473
|
+
vendorId: obs.vendorId,
|
|
474
|
+
claimId: obs.claimId,
|
|
475
|
+
quote,
|
|
476
|
+
problem: "evidence has no captureHash — spans must cite a stored capture",
|
|
477
|
+
});
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
const captureText = textByHash.get(hash);
|
|
481
|
+
if (captureText === undefined) {
|
|
482
|
+
failures.push({
|
|
483
|
+
vendorId: obs.vendorId,
|
|
484
|
+
claimId: obs.claimId,
|
|
485
|
+
quote,
|
|
486
|
+
problem: `capture ${hash.slice(0, 12)} not found — evidence must stay resolvable`,
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
490
|
+
if (!normalizeForMatch(captureText).includes(normalizeForMatch(quote))) {
|
|
491
|
+
failures.push({
|
|
492
|
+
vendorId: obs.vendorId,
|
|
493
|
+
claimId: obs.claimId,
|
|
494
|
+
quote,
|
|
495
|
+
problem: `quote not found verbatim in capture ${hash.slice(0, 12)}`,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return failures;
|
|
501
|
+
}
|
|
502
|
+
|
|
411
503
|
// ---------------------------------------------------------------------------
|
|
412
504
|
// Front states — deterministic, recomputed every time, never stored.
|
|
413
505
|
|