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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { DEFAULT_MODELS, forcedToolCall, type LlmCallOptions } from "./llm.ts";
|
|
2
|
+
import {
|
|
3
|
+
loadCaptureTexts,
|
|
4
|
+
observationId,
|
|
5
|
+
verifyEvidenceSpans,
|
|
6
|
+
type CaptureEntry,
|
|
7
|
+
type MarketClaim,
|
|
8
|
+
type MarketConfig,
|
|
9
|
+
type MarketObservation,
|
|
10
|
+
type ObservationSet,
|
|
11
|
+
type SpanVerificationFailure,
|
|
12
|
+
} from "./market.ts";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* LLM intensity classification for the market map — the same
|
|
16
|
+
* semi-deterministic posture as call extraction, with one upgrade calls
|
|
17
|
+
* can't have: because the source pages are stored captures, every quoted
|
|
18
|
+
* span is verified mechanically against the capture it cites before the
|
|
19
|
+
* observation is accepted. A reading whose quote isn't verbatim on the page
|
|
20
|
+
* bounces back to the model once with the failures named; if it still can't
|
|
21
|
+
* quote the page, classification fails rather than storing unverifiable
|
|
22
|
+
* evidence.
|
|
23
|
+
*
|
|
24
|
+
* Deterministic parts stay deterministic: vendors with no usable captures
|
|
25
|
+
* score UNOBSERVABLE on every claim without an LLM call, and front states
|
|
26
|
+
* downstream are computed from the store, never from model output.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
// Bound cost and context: a vendor's pages are classified in one call.
|
|
30
|
+
const MAX_DOSSIER_CHARS = 48_000;
|
|
31
|
+
|
|
32
|
+
const CLASSIFY_INSTRUCTIONS = `Classify this vendor's messaging intensity for EVERY claim listed.
|
|
33
|
+
Rules:
|
|
34
|
+
- Judge ONLY from the captured pages below. Do not use outside knowledge of the vendor.
|
|
35
|
+
- intensity per the surface rule: "loud" = hero copy or a top-level-nav named product/program with a dedicated page; "quiet" = present on any page below that; "absent" = nowhere in the captures.
|
|
36
|
+
- evidence quotes MUST be verbatim spans copied exactly from the captured text (≤300 chars). Every loud or quiet reading needs at least one quote. If you cannot quote it, the reading is absent.
|
|
37
|
+
- An explicit disavowal ("we do not offer X", "call 988") is absent — put the disavowal quote in reason, it is informative signal.
|
|
38
|
+
- url must be the page the quote came from, exactly as given in the page headers below.
|
|
39
|
+
- reason: one reviewer-facing sentence.
|
|
40
|
+
- Return a reading for every claim id. Never invent claim ids.`;
|
|
41
|
+
|
|
42
|
+
const classifySchema = (claimIds: string[]) =>
|
|
43
|
+
({
|
|
44
|
+
type: "object",
|
|
45
|
+
required: ["readings"],
|
|
46
|
+
properties: {
|
|
47
|
+
readings: {
|
|
48
|
+
type: "array",
|
|
49
|
+
items: {
|
|
50
|
+
type: "object",
|
|
51
|
+
required: ["claimId", "intensity", "confidence", "reason", "evidence"],
|
|
52
|
+
properties: {
|
|
53
|
+
claimId: { type: "string", enum: claimIds },
|
|
54
|
+
intensity: { type: "string", enum: ["loud", "quiet", "absent"] },
|
|
55
|
+
confidence: { type: "string", enum: ["high", "medium", "low"] },
|
|
56
|
+
reason: { type: "string", description: "One reviewer-facing sentence." },
|
|
57
|
+
evidence: {
|
|
58
|
+
type: "array",
|
|
59
|
+
items: {
|
|
60
|
+
type: "object",
|
|
61
|
+
required: ["quote", "url"],
|
|
62
|
+
properties: {
|
|
63
|
+
quote: { type: "string", description: "VERBATIM span copied exactly from the captured page text. Never paraphrase." },
|
|
64
|
+
url: { type: "string", description: "The page URL the quote came from, exactly as shown in the page header." },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}) as const;
|
|
73
|
+
|
|
74
|
+
type LlmReading = {
|
|
75
|
+
claimId: string;
|
|
76
|
+
intensity: "loud" | "quiet" | "absent";
|
|
77
|
+
confidence: "high" | "medium" | "low";
|
|
78
|
+
reason: string;
|
|
79
|
+
evidence: Array<{ quote: string; url: string }>;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function buildDossier(entries: CaptureEntry[], textByHash: Map<string, string>): string {
|
|
83
|
+
const pages = entries
|
|
84
|
+
.filter((entry) => entry.captureHash && textByHash.has(entry.captureHash))
|
|
85
|
+
.map((entry) => ({ entry, text: textByHash.get(entry.captureHash as string) as string }));
|
|
86
|
+
if (pages.length === 0) return "";
|
|
87
|
+
const budget = Math.floor(MAX_DOSSIER_CHARS / pages.length);
|
|
88
|
+
return pages
|
|
89
|
+
.map(({ entry, text }) => {
|
|
90
|
+
const body =
|
|
91
|
+
text.length <= budget
|
|
92
|
+
? text
|
|
93
|
+
: `${text.slice(0, budget / 2)}\n[... middle of page truncated ...]\n${text.slice(-budget / 2)}`;
|
|
94
|
+
return `=== PAGE (${entry.kind}) ${entry.url} ===\n${body}`;
|
|
95
|
+
})
|
|
96
|
+
.join("\n\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function claimsBlock(claims: MarketClaim[]): string {
|
|
100
|
+
return claims
|
|
101
|
+
.map((claim) => `- ${claim.id}: ${claim.capability}\n How to judge: ${claim.definition}`)
|
|
102
|
+
.join("\n");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type ClassifyMarketOptions = {
|
|
106
|
+
llm: LlmCallOptions;
|
|
107
|
+
/** Observation run label to produce; must be new (the store is append-only). */
|
|
108
|
+
runLabel: string;
|
|
109
|
+
/** Capture run to classify; defaults to the most recent run in the manifest. */
|
|
110
|
+
captureRun?: string;
|
|
111
|
+
/** Restrict to these vendor ids (e.g. one new vendor); defaults to all. */
|
|
112
|
+
vendors?: string[];
|
|
113
|
+
/** Captures directory override (tests); defaults to the profile market home. */
|
|
114
|
+
capturesDir?: string;
|
|
115
|
+
now?: () => Date;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export type ClassifyMarketResult = {
|
|
119
|
+
set: ObservationSet;
|
|
120
|
+
model: string;
|
|
121
|
+
/** Cells where the model's quote failed mechanical verification and the retry fixed it. */
|
|
122
|
+
retriedVendorIds: string[];
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
export async function classifyMarket(
|
|
126
|
+
config: MarketConfig,
|
|
127
|
+
options: ClassifyMarketOptions,
|
|
128
|
+
): Promise<ClassifyMarketResult> {
|
|
129
|
+
const model = options.llm.model ?? DEFAULT_MODELS[options.llm.provider];
|
|
130
|
+
const { entries, textByHash } = loadCaptureTexts(config.category, options.capturesDir);
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
|
|
133
|
+
}
|
|
134
|
+
const captureRun = options.captureRun ?? entries[entries.length - 1].runLabel;
|
|
135
|
+
const runEntries = entries.filter((entry) => entry.runLabel === captureRun);
|
|
136
|
+
if (runEntries.length === 0) {
|
|
137
|
+
throw new Error(`No captures for run "${captureRun}" — available: ${[...new Set(entries.map((e) => e.runLabel))].join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
const observedAt = (options.now ?? (() => new Date()))().toISOString();
|
|
140
|
+
const vendorIds = options.vendors ?? config.vendors.map((vendor) => vendor.id);
|
|
141
|
+
const claimIds = config.claims.map((claim) => claim.id);
|
|
142
|
+
|
|
143
|
+
const observations: MarketObservation[] = [];
|
|
144
|
+
const retriedVendorIds: string[] = [];
|
|
145
|
+
|
|
146
|
+
for (const vendorId of vendorIds) {
|
|
147
|
+
const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
|
|
148
|
+
if (!vendor) throw new Error(`Unknown vendor "${vendorId}"`);
|
|
149
|
+
const vendorEntries = runEntries.filter((entry) => entry.vendorId === vendorId);
|
|
150
|
+
const hashByUrl = new Map(
|
|
151
|
+
vendorEntries.filter((entry) => entry.captureHash).map((entry) => [entry.url, entry.captureHash as string]),
|
|
152
|
+
);
|
|
153
|
+
const dossier = buildDossier(vendorEntries, textByHash);
|
|
154
|
+
|
|
155
|
+
if (!dossier) {
|
|
156
|
+
// Deterministic: no usable captures means UNOBSERVABLE everywhere — never
|
|
157
|
+
// ask a model to judge pages that were never read.
|
|
158
|
+
for (const claim of config.claims) {
|
|
159
|
+
observations.push({
|
|
160
|
+
id: observationId(config.category, options.runLabel, vendorId, claim.id),
|
|
161
|
+
vendorId,
|
|
162
|
+
claimId: claim.id,
|
|
163
|
+
observedAt,
|
|
164
|
+
intensity: "unobservable",
|
|
165
|
+
confidence: "high",
|
|
166
|
+
reason: `No usable captures for ${vendor.name} in run ${captureRun} — cannot judge.`,
|
|
167
|
+
evidence: [],
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const prompt = (feedback: string) =>
|
|
174
|
+
`${CLASSIFY_INSTRUCTIONS}\n\nSurface rule for this category:\n${config.surfaceRule ?? "(default rule above)"}\n\nClaims to classify (all of them):\n${claimsBlock(config.claims)}\n${feedback}\nVendor: ${vendor.name}\nCaptured pages:\n${dossier}`;
|
|
175
|
+
|
|
176
|
+
const attempt = async (feedback: string): Promise<{ readings: LlmReading[]; problems: string[]; failures: SpanVerificationFailure[] }> => {
|
|
177
|
+
const result = (await forcedToolCall(prompt(feedback), "classify_market_claims", classifySchema(claimIds), model, options.llm)) as {
|
|
178
|
+
readings?: LlmReading[];
|
|
179
|
+
};
|
|
180
|
+
const readings = (result.readings ?? []).filter((reading) => claimIds.includes(reading.claimId));
|
|
181
|
+
const seen = new Set(readings.map((reading) => reading.claimId));
|
|
182
|
+
const problems = claimIds.filter((claimId) => !seen.has(claimId)).map((claimId) => `missing reading for ${claimId}`);
|
|
183
|
+
const candidate = readings.map((reading) => toObservation(reading, vendorId));
|
|
184
|
+
const failures = verifyEvidenceSpans(candidate, textByHash);
|
|
185
|
+
return { readings, problems, failures };
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const toObservation = (reading: LlmReading, vendor: string): MarketObservation => ({
|
|
189
|
+
id: observationId(config.category, options.runLabel, vendor, reading.claimId),
|
|
190
|
+
vendorId: vendor,
|
|
191
|
+
claimId: reading.claimId,
|
|
192
|
+
observedAt,
|
|
193
|
+
intensity: reading.intensity,
|
|
194
|
+
confidence: reading.confidence,
|
|
195
|
+
reason: reading.reason,
|
|
196
|
+
evidence: (reading.evidence ?? []).map((item, index) => ({
|
|
197
|
+
id: `${observationId(config.category, options.runLabel, vendor, reading.claimId)}_ev${index}`,
|
|
198
|
+
sourceSystem: "web" as const,
|
|
199
|
+
sourceObjectType: "page",
|
|
200
|
+
sourceObjectId: item.url,
|
|
201
|
+
text: item.quote,
|
|
202
|
+
observedAt,
|
|
203
|
+
metadata: { url: item.url, captureHash: hashByUrl.get(item.url) ?? "" },
|
|
204
|
+
})),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
let outcome = await attempt("");
|
|
208
|
+
if (outcome.problems.length > 0 || outcome.failures.length > 0) {
|
|
209
|
+
retriedVendorIds.push(vendorId);
|
|
210
|
+
const failureLines = [
|
|
211
|
+
...outcome.problems,
|
|
212
|
+
...outcome.failures.map((failure) => `${failure.claimId}: ${failure.problem} (your quote: "${failure.quote.slice(0, 80)}")`),
|
|
213
|
+
].join("\n- ");
|
|
214
|
+
outcome = await attempt(
|
|
215
|
+
`\nYour previous answer had problems. Fix exactly these and answer again in full:\n- ${failureLines}\nQuotes must be copied character-for-character from the captured text.\n`,
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (outcome.problems.length > 0 || outcome.failures.length > 0) {
|
|
219
|
+
const detail = [...outcome.problems, ...outcome.failures.map((failure) => `${failure.claimId}: ${failure.problem}`)].slice(0, 10);
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Classification for ${vendor.name} failed mechanical verification after a retry:\n ${detail.join("\n ")}\nNothing was stored. Re-run, try another --model, or classify this vendor by hand via the worksheet.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
for (const reading of outcome.readings) observations.push(toObservation(reading, vendorId));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
set: {
|
|
229
|
+
id: `set_${config.category}_${options.runLabel}`,
|
|
230
|
+
category: config.category,
|
|
231
|
+
runLabel: options.runLabel,
|
|
232
|
+
runAt: observedAt,
|
|
233
|
+
extractor: `llm:${options.llm.provider}:${model}`,
|
|
234
|
+
observations,
|
|
235
|
+
},
|
|
236
|
+
model,
|
|
237
|
+
retriedVendorIds,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The agent-driven alternative to LLM classification: a worksheet carrying
|
|
243
|
+
* everything needed to classify one vendor by hand or by an agent driving
|
|
244
|
+
* the CLI/MCP — claims with judging definitions, the surface rule, and the
|
|
245
|
+
* captured page texts. Submissions come back through `market observe`,
|
|
246
|
+
* which runs the same validation and span verification as `classify`.
|
|
247
|
+
*/
|
|
248
|
+
export type MarketWorksheet = {
|
|
249
|
+
category: string;
|
|
250
|
+
captureRun: string;
|
|
251
|
+
surfaceRule?: string;
|
|
252
|
+
vendor: { id: string; name: string };
|
|
253
|
+
claims: MarketClaim[];
|
|
254
|
+
pages: Array<{ kind: CaptureEntry["kind"]; url: string; captureHash: string; text: string }>;
|
|
255
|
+
instructions: string;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
export function buildWorksheet(
|
|
259
|
+
config: MarketConfig,
|
|
260
|
+
vendorId: string,
|
|
261
|
+
options: { captureRun?: string; capturesDir?: string } = {},
|
|
262
|
+
): MarketWorksheet {
|
|
263
|
+
const vendor = config.vendors.find((candidate) => candidate.id === vendorId);
|
|
264
|
+
if (!vendor) throw new Error(`Unknown vendor "${vendorId}"`);
|
|
265
|
+
const { entries, textByHash } = loadCaptureTexts(config.category, options.capturesDir);
|
|
266
|
+
const captureRun = options.captureRun ?? entries[entries.length - 1]?.runLabel;
|
|
267
|
+
if (!captureRun) throw new Error(`No captures for ${config.category} — run \`market capture\` first`);
|
|
268
|
+
const pages = entries
|
|
269
|
+
.filter((entry) => entry.runLabel === captureRun && entry.vendorId === vendorId && entry.captureHash)
|
|
270
|
+
.map((entry) => ({
|
|
271
|
+
kind: entry.kind,
|
|
272
|
+
url: entry.url,
|
|
273
|
+
captureHash: entry.captureHash as string,
|
|
274
|
+
text: textByHash.get(entry.captureHash as string) ?? "",
|
|
275
|
+
}));
|
|
276
|
+
return {
|
|
277
|
+
category: config.category,
|
|
278
|
+
captureRun,
|
|
279
|
+
surfaceRule: config.surfaceRule,
|
|
280
|
+
vendor: { id: vendor.id, name: vendor.name },
|
|
281
|
+
claims: config.claims,
|
|
282
|
+
pages,
|
|
283
|
+
instructions:
|
|
284
|
+
"Produce one observation per claim (intensity loud|quiet|absent from these pages only; unobservable only if a page you need failed to capture). Every loud/quiet reading must quote a verbatim span (≤300 chars) from a page's text, with that page's url and captureHash in evidence metadata. Submit as an ObservationSet via `market observe --from <file>` — quotes are mechanically verified against the captures.",
|
|
285
|
+
};
|
|
286
|
+
}
|
package/src/mcp.ts
CHANGED
|
@@ -48,6 +48,16 @@ import { builtinAuditRules } from "./rules.ts";
|
|
|
48
48
|
import { sampleSnapshot } from "./sampleData.ts";
|
|
49
49
|
import { normalizeTranscript, parseCall } from "./calls.ts";
|
|
50
50
|
import { extractInsightsLlm, resolveLlmCredential } from "./llm.ts";
|
|
51
|
+
import {
|
|
52
|
+
computeFrontStates,
|
|
53
|
+
createFileObservationStore,
|
|
54
|
+
loadCaptureTexts,
|
|
55
|
+
loadMarketConfig,
|
|
56
|
+
validateObservationSet,
|
|
57
|
+
verifyEvidenceSpans,
|
|
58
|
+
type ObservationSet,
|
|
59
|
+
} from "./market.ts";
|
|
60
|
+
import { buildWorksheet } from "./marketClassify.ts";
|
|
51
61
|
import { resolveRecord } from "./resolve.ts";
|
|
52
62
|
import { suggestValues } from "./suggest.ts";
|
|
53
63
|
import type { CanonicalGtmSnapshot, GtmConnector, PatchPlan } from "./types.ts";
|
|
@@ -307,6 +317,61 @@ export async function startMcpServer() {
|
|
|
307
317
|
},
|
|
308
318
|
);
|
|
309
319
|
|
|
320
|
+
server.registerTool(
|
|
321
|
+
"fullstackgtm_market_worksheet",
|
|
322
|
+
{
|
|
323
|
+
title: "Market Map Classification Worksheet",
|
|
324
|
+
description:
|
|
325
|
+
"Get everything needed to classify ONE vendor's messaging intensity for a market map: " +
|
|
326
|
+
"the claim taxonomy with judging definitions, the surface rule, and the captured page " +
|
|
327
|
+
"texts. Read each claim's definition, judge loud/quiet/absent from the page texts only, " +
|
|
328
|
+
"and quote verbatim spans (≤300 chars) for every loud/quiet reading. Submit the full " +
|
|
329
|
+
"ObservationSet via fullstackgtm_market_observe — quotes are verified character-for-" +
|
|
330
|
+
"character against the captures, so never paraphrase.",
|
|
331
|
+
inputSchema: {
|
|
332
|
+
vendorId: z.string(),
|
|
333
|
+
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
334
|
+
captureRun: z.string().optional(),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
async ({ vendorId, configPath, captureRun }) => {
|
|
338
|
+
const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
339
|
+
return content(buildWorksheet(config, vendorId, { captureRun }));
|
|
340
|
+
},
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
server.registerTool(
|
|
344
|
+
"fullstackgtm_market_observe",
|
|
345
|
+
{
|
|
346
|
+
title: "Submit Market Map Observations",
|
|
347
|
+
description:
|
|
348
|
+
"Submit a complete ObservationSet (every vendor × claim cell) for a market map run. " +
|
|
349
|
+
"Validates coverage, the verbatim-evidence rule, and mechanically verifies every quoted " +
|
|
350
|
+
"span against the stored capture it cites. Returns problems if rejected; nothing is " +
|
|
351
|
+
"stored unless the whole set passes. Observations are append-only — use a new runLabel.",
|
|
352
|
+
inputSchema: {
|
|
353
|
+
observationsPath: z.string().describe("Path to the ObservationSet JSON file"),
|
|
354
|
+
configPath: z.string().optional().describe("Path to market.config.json (default ./market.config.json)"),
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
async ({ observationsPath, configPath }) => {
|
|
358
|
+
const config = loadMarketConfig(resolve(process.cwd(), configPath ?? "market.config.json"));
|
|
359
|
+
const set = JSON.parse(readFileSync(resolve(process.cwd(), observationsPath), "utf8")) as ObservationSet;
|
|
360
|
+
const problems = validateObservationSet(config, set);
|
|
361
|
+
const failures = verifyEvidenceSpans(set.observations, loadCaptureTexts(config.category).textByHash);
|
|
362
|
+
if (problems.length > 0 || failures.length > 0) {
|
|
363
|
+
return content({
|
|
364
|
+
accepted: false,
|
|
365
|
+
problems,
|
|
366
|
+
spanFailures: failures.map((failure) => `${failure.vendorId} × ${failure.claimId}: ${failure.problem}`),
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
await createFileObservationStore(config.category).append(set);
|
|
370
|
+
const fronts = computeFrontStates(config, set);
|
|
371
|
+
return content({ accepted: true, runLabel: set.runLabel, observations: set.observations.length, fronts });
|
|
372
|
+
},
|
|
373
|
+
);
|
|
374
|
+
|
|
310
375
|
const transport = new StdioServerTransport();
|
|
311
376
|
await server.connect(transport);
|
|
312
377
|
}
|