From 288cb04d2e3e718e8b08ad0b42ae9a7a8cee4c45 Mon Sep 17 00:00:00 2001 From: Edgecaser Date: Wed, 1 Apr 2026 18:43:15 -0700 Subject: [PATCH 1/2] Add deterministic integration layer: format-facts, pricing-diff, classify-request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three new helper scripts with full test coverage, wired into four skills and the research-brief retrieval workflow: - scripts/format-facts.mjs — converts a facts.json sidecar into a compact block (~300-500 tokens) for prompt injection or a markdown report for human review; groups by domain, reconstructs pricing tuples from shared excerpts - scripts/pricing-diff.mjs — builds a multi-source markdown pricing comparison table from two or more facts.json packs; includes Review Signals table and Coverage Notes; supports --dir discovery mode - scripts/classify-request.mjs — pure pattern-matcher that classifies a query into pricing/competitive/market-size/acquisition/funding/reviews/general and returns suggestedMode, priorityFacts, and collectorHints; no network calls Skills updated: - adversarial-review: Pre-Check step runs validate-artifact.mjs before manual review - artifact-quality-audit: Step 1b pre-pass floors Correctness dimension scoring - pricing-strategy Step 5: pricing-diff.mjs replaces manual table construction - competitive-landscape Step 3: pricing-diff.mjs populates pricing fields - shipwright-research-brief: classify-request at Step 1, format-facts at Step 3, pricing-diff for multi-competitor requests Tests: 121 passing (25 classify-request, 21 format-facts, 29 pricing-diff, plus 46 pre-existing across collect-research, source-adapters, validate-artifact) Co-Authored-By: Claude Sonnet 4.6 --- .../skills/shipwright-research-brief/SKILL.md | 23 +- docs/deterministic-helpers-roadmap.md | 59 +++ scripts/classify-request.mjs | 315 ++++++++++++++ scripts/format-facts.mjs | 392 ++++++++++++++++++ scripts/pricing-diff.mjs | 330 +++++++++++++++ .../discovery/competitive-landscape/SKILL.md | 10 +- .../artifact-quality-audit/SKILL.md | 10 + skills/pricing/pricing-strategy/SKILL.md | 11 + skills/technical/adversarial-review/SKILL.md | 10 + tests/classify-request.test.mjs | 198 +++++++++ tests/format-facts.test.mjs | 225 ++++++++++ tests/pricing-diff.test.mjs | 228 ++++++++++ 12 files changed, 1807 insertions(+), 4 deletions(-) create mode 100644 scripts/classify-request.mjs create mode 100644 scripts/format-facts.mjs create mode 100644 scripts/pricing-diff.mjs create mode 100644 tests/classify-request.test.mjs create mode 100644 tests/format-facts.test.mjs create mode 100644 tests/pricing-diff.test.mjs diff --git a/.codex/skills/shipwright-research-brief/SKILL.md b/.codex/skills/shipwright-research-brief/SKILL.md index be60b09..2ffa7d3 100644 --- a/.codex/skills/shipwright-research-brief/SKILL.md +++ b/.codex/skills/shipwright-research-brief/SKILL.md @@ -17,23 +17,40 @@ Use this skill for web-heavy product, market, and business research in Codex. ## Retrieval Workflow -1. Translate the user's request into one primary query. +1. **Classify the request** to configure retrieval before collecting: + - `node scripts/classify-request.mjs ""` + - The output names a `requestType` (pricing, competitive, market-size, acquisition, funding, reviews, general), a `suggestedMode` for the collector, and `priorityFacts` to watch for in the facts sidecar. + - Use `suggestedMode` as the `--mode` value in step 2 when it is not `general`. + 2. Run the local collector first: - - `node .codex/scripts/collect-research.mjs --query "" --mode auto` + - `node .codex/scripts/collect-research.mjs --query "" --mode ` - otherwise `node scripts/collect-research.mjs --query "" --mode auto` - otherwise `node .claude/scripts/collect-research.mjs --query "" --mode auto` -3. If `facts.json` exists alongside the evidence pack, read it first and use it as a structured shortcut before synthesizing from the full evidence pack. + +3. **Format the facts sidecar** if `facts.json` exists: + - `node scripts/format-facts.mjs path/to/facts.json` + - The compact block output (~300-500 tokens) is ready to inject directly into a synthesis prompt as structured evidence. Read it before the full evidence pack — if `priorityFacts` fields are all present at high confidence, the full pack may only be needed for context. + 4. Interpret `facts.json` by `confidence_hint`: - `high`: use directly for structured fields, tables, and summaries, while keeping source attribution. - `medium`: treat as provisional; verify against the cited evidence pack entry before relying on it for a material claim, comparison, or recommendation. - `low`: treat as a lead only; do not present it as settled unless corroborated from the evidence pack or another source. - if `confidence_hint` is missing, treat it as `medium`. + 5. Read the generated evidence pack. 6. If the evidence pack is usable, synthesize from it without starting a broad `Web Search` pass. 7. Only if the evidence pack still has material gaps should you use interactive browsing. 8. Keep any follow-up browsing gap-focused rather than restarting the whole search pass. 9. Treat raw `Web Search` as fallback, not first-pass retrieval, whenever the local collector is available. +**For multi-competitor pricing requests:** run the collector once per competitor, then generate the comparison table: +```bash +node scripts/pricing-diff.mjs \ + .shipwright/research/comp-a/facts.json \ + .shipwright/research/comp-b/facts.json +``` +Paste the table directly into the brief's pricing evidence section. + ## Synthesis Rules - Keep the work bounded to one primary deliverable. diff --git a/docs/deterministic-helpers-roadmap.md b/docs/deterministic-helpers-roadmap.md index cb1000d..894771e 100644 --- a/docs/deterministic-helpers-roadmap.md +++ b/docs/deterministic-helpers-roadmap.md @@ -64,6 +64,58 @@ Citation shortcut: if the document has a `## Sources` / `## References` / CLI: `node scripts/validate-artifact.mjs path/to/artifact.md --expect-sections "Sources,Risks"` +Wired into: +- `adversarial-review/SKILL.md` — Pre-Check step before manual review begins +- `artifact-quality-audit/SKILL.md` — Step 1b pre-pass to floor Correctness scoring + +### Facts formatter (`scripts/format-facts.mjs`) + +Converts a `facts.json` sidecar into a compact structured block (~300-500 tokens) +suitable for direct prompt injection, or a markdown report for human review. + +``` +node scripts/format-facts.mjs path/to/facts.json +node scripts/format-facts.mjs path/to/facts.json --format markdown +``` + +Groups facts by source domain, reconstructs pricing tuples from shared excerpts, +resolves product identity, and annotates adapter-sourced fields with `[schema]`. + +Wired into: +- `shipwright-research-brief/SKILL.md` — Step 3 of the retrieval workflow + +### Pricing diff (`scripts/pricing-diff.mjs`) + +Builds a deterministic markdown comparison table from two or more `facts.json` packs. +Produces: Competitor × Plan × Price × Billing × Free Tier × Confidence table, +optional Review Signals table, and per-source Coverage Notes. + +``` +node scripts/pricing-diff.mjs facts-a.json facts-b.json +node scripts/pricing-diff.mjs --dir .shipwright/research/ +``` + +Wired into: +- `pricing-strategy/SKILL.md` — Step 5 (Competitive Pricing Analysis) +- `competitive-landscape/SKILL.md` — Step 3 (Positioning Analysis) +- `shipwright-research-brief/SKILL.md` — multi-competitor pricing requests + +### Request classifier (`scripts/classify-request.mjs`) + +Pattern-matches a query string to a request type and returns collector hints. +No network calls. Returns: `requestType`, `suggestedMode`, `priorityFacts`, +`collectorHints`, `matchedSignals`, `confidence`. + +Request types: `pricing`, `competitive`, `market-size`, `acquisition`, `funding`, `reviews`, `general` + +``` +node scripts/classify-request.mjs "how does Stripe pricing compare to Paddle?" +node scripts/classify-request.mjs --json "Series B funding in developer tools" +``` + +Wired into: +- `shipwright-research-brief/SKILL.md` — Step 1 of the retrieval workflow + ## Remaining backlog If Shipwright continues investing in deterministic acceleration, the next @@ -88,6 +140,13 @@ highest-value additions are: identified; the adapter pattern is already in place. 5. **`marketplace_last_updated` field** — useful for currency-checking cached listing data; add once a stable adapter target is confirmed. +6. **Preflight classifier wiring into collect-research** — `classify-request.mjs` + currently produces hints for the caller to use manually. A future iteration + could have `collect-research.mjs` call it internally and auto-select `--mode` + when none is specified, reducing required arguments for common queries. +7. **Pricing diff HTML output** — `pricing-diff.mjs` currently outputs markdown. + An `--html` flag could produce a self-contained table for pasting into + slide decks or Notion pages. ## Workflow-specific candidates diff --git a/scripts/classify-request.mjs b/scripts/classify-request.mjs new file mode 100644 index 0000000..0ad45ab --- /dev/null +++ b/scripts/classify-request.mjs @@ -0,0 +1,315 @@ +#!/usr/bin/env node + +/** + * Shipwright request classifier. + * + * Classifies a research query string into a request type and returns + * collector hints. Pure pattern-matcher — no network calls, no AI. + * + * Usage (CLI): + * node scripts/classify-request.mjs "how does Stripe pricing compare to Paddle?" + * node scripts/classify-request.mjs --json "Series B funding in developer tools" + * + * Usage (programmatic): + * import { classifyRequest } from './classify-request.mjs'; + * const classification = classifyRequest("Stripe vs Paddle pricing"); + * + * Integration: + * Run at the start of a research workflow to configure the collector + * for the right extraction mode. Feed the returned `collectorHints` + * into collect-research.mjs mode/provider selection, and use + * `priorityFacts` to set expected fields in the facts sidecar. + * + * Example: + * const { requestType, suggestedMode, collectorHints } = classifyRequest(query); + * // suggestedMode → 'pricing' | 'research' | 'news' | 'general' + */ + +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * @typedef {'pricing'|'competitive'|'market-size'|'acquisition'|'funding'|'reviews'|'general'} RequestType + */ + +/** + * @typedef {object} Classification + * @property {RequestType} requestType + * @property {number} confidence - 0–1, how strongly this type was matched + * @property {string} suggestedMode - collector mode hint: 'pricing'|'research'|'news'|'general' + * @property {string[]} priorityFacts - fact fields most likely to be useful + * @property {object} collectorHints - key/value config hints for the collector + * @property {string[]} matchedSignals - which patterns fired (for debugging) + */ + +// --------------------------------------------------------------------------- +// Pattern definitions +// --------------------------------------------------------------------------- + +// Each rule: { type, signals, mode, priorityFacts, collectorHints } +// Signals are arrays of regex patterns — a rule fires if ≥1 signal matches. +// Rules are evaluated in order; first match wins for type assignment, but all +// matching signals are accumulated for the returned matchedSignals array. + +const RULES = [ + { + type: 'pricing', + mode: 'pricing', + priorityFacts: ['plan_name', 'price', 'currency', 'billing_period', 'product_name'], + collectorHints: { preferPricingPages: true, includeJsonLd: true }, + signals: [ + /\bpric(?:e|es|ing|ed)\b/i, + /\bplan[s]?\b.*\b(cost|price|monthly|annual|tier)/i, + /\b(tier|tiers|pricing tier)\b/i, + /\b(per seat|per user|per month|per year|\/mo|\/yr|monthly|annually)\b/i, + /\b(free tier|freemium|free plan|paid plan|enterprise plan|starter plan|pro plan)\b/i, + /\b(cost[s]?|costs? how much|how much does)\b/i, + /\bvs\.?\s+\w+.*pric/i, + /\bpric.*vs\.?\s+\w+/i, + /\b(subscription|license fee|licensing cost)\b/i, + ], + }, + { + type: 'competitive', + mode: 'research', + priorityFacts: ['product_name', 'company', 'plan_name', 'price', 'star_rating', 'review_count'], + collectorHints: { includeJsonLd: true, includeReviews: true }, + signals: [ + /\bvs\.?\s+\w+/i, + /\bcompet(?:itor|itive|ition|es|ing)\b/i, + /\balternative[s]?\b/i, + /\bcompare[sd]?\b/i, + /\b(landscape|market map|competitive analysis|feature comparison)\b/i, + /\b(rival[s]?|challenger[s]?|incumbent[s]?)\b/i, + /vs\s+/i, + ], + }, + { + type: 'market-size', + mode: 'research', + priorityFacts: ['company', 'product_name', 'published_or_observed_date'], + collectorHints: { preferAnalystReports: true }, + signals: [ + /\b(tam|sam|som)\b/i, + /\bmarket size\b/i, + /\btotal addressable market\b/i, + /\bmarket (share|opportunity|growth|forecast)\b/i, + /\b(billion|trillion|million)[\s\-]dollar market\b/i, + /\b(cagr|compound annual growth)\b/i, + /\bmarket research\b/i, + /\bindustry (size|report|analysis|forecast)\b/i, + ], + }, + { + type: 'acquisition', + mode: 'news', + priorityFacts: ['acquisition_event', 'acquisition_date', 'acquirer', 'acquired_company', 'company'], + collectorHints: { preferNewsPages: true, preferRecentResults: true }, + signals: [ + /\bacquir(?:ed|es|ing|ition|itions)\b/i, + /\bbought by\b/i, + /\bmerger[s]?\b/i, + /\bm&a\b/i, + /\btakeover[s]?\b/i, + /\b(acquisition history|who acquired|was acquired)\b/i, + ], + }, + { + type: 'funding', + mode: 'news', + priorityFacts: ['funding_event', 'company', 'product_name', 'acquisition_event'], + collectorHints: { preferNewsPages: true, preferRecentResults: true }, + signals: [ + /\b(series [a-f])\b/i, + /\b(seed (round|funding)|seed stage)\b/i, + /\braised\s+\$[\d.]+/i, + /\bfunding (round|history|raise|announcement)\b/i, + /\bventure (capital|funding|backed)\b/i, + /\b(vc[- ]backed|investor|investors)\b/i, + /\b(ipo|spac|went public)\b/i, + /\bvaluation\b/i, + ], + }, + { + type: 'reviews', + mode: 'research', + priorityFacts: ['star_rating', 'review_count', 'product_name', 'company'], + collectorHints: { includeJsonLd: true, preferReviewSites: true }, + signals: [ + /\breview[s]?\b/i, + /\brating[s]?\b/i, + /\bstar[s]?\s+rating\b/i, + /\b(g2|capterra|trustpilot|product hunt)\b/i, + /\bwhat (users?|customers?|people) (say|think|feel)\b/i, + /\b(sentiment|nps|satisfaction score)\b/i, + /\buser feedback\b/i, + ], + }, +]; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Classify a research query string and return collector hints. + * + * @param {string} query + * @returns {Classification} + */ +export function classifyRequest(query) { + if (!query || typeof query !== 'string') { + return buildResult('general', 0, []); + } + + const q = query.trim(); + + // Accumulate hits per rule type + const hits = new Map(); // type → { rule, matchedSignals: string[] } + + for (const rule of RULES) { + const matched = []; + for (const signal of rule.signals) { + if (signal.test(q)) { + matched.push(signal.source); + } + } + if (matched.length > 0) { + hits.set(rule.type, { rule, matchedSignals: matched }); + } + } + + if (hits.size === 0) { + return buildResult('general', 0, []); + } + + // Pick the type with the most matched signals; break ties by rule order + let bestType = null; + let bestCount = 0; + + for (const rule of RULES) { + if (!hits.has(rule.type)) continue; + const count = hits.get(rule.type).matchedSignals.length; + if (count > bestCount) { + bestCount = count; + bestType = rule.type; + } + } + + const { rule, matchedSignals } = hits.get(bestType); + + // Confidence: 1 match = 0.6, 2+ = 0.8, 3+ = 1.0 (capped) + const confidence = Math.min(1.0, 0.5 + bestCount * 0.17); + + return buildResult(bestType, confidence, matchedSignals, rule); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function buildResult(type, confidence, matchedSignals, rule = null) { + const defaults = { + pricing: { + mode: 'pricing', + priorityFacts: ['plan_name', 'price', 'currency', 'billing_period', 'product_name'], + collectorHints: { preferPricingPages: true, includeJsonLd: true }, + }, + competitive: { + mode: 'research', + priorityFacts: ['product_name', 'company', 'plan_name', 'price', 'star_rating', 'review_count'], + collectorHints: { includeJsonLd: true, includeReviews: true }, + }, + 'market-size': { + mode: 'research', + priorityFacts: ['company', 'product_name', 'published_or_observed_date'], + collectorHints: { preferAnalystReports: true }, + }, + acquisition: { + mode: 'news', + priorityFacts: ['acquisition_event', 'acquisition_date', 'acquirer', 'acquired_company'], + collectorHints: { preferNewsPages: true, preferRecentResults: true }, + }, + funding: { + mode: 'news', + priorityFacts: ['funding_event', 'company', 'product_name'], + collectorHints: { preferNewsPages: true, preferRecentResults: true }, + }, + reviews: { + mode: 'research', + priorityFacts: ['star_rating', 'review_count', 'product_name', 'company'], + collectorHints: { includeJsonLd: true, preferReviewSites: true }, + }, + general: { + mode: 'general', + priorityFacts: ['company', 'product_name', 'published_or_observed_date'], + collectorHints: {}, + }, + }; + + const config = rule + ? { + mode: rule.mode, + priorityFacts: rule.priorityFacts, + collectorHints: rule.collectorHints, + } + : defaults[type] || defaults.general; + + return { + requestType: type, + confidence, + suggestedMode: config.mode, + priorityFacts: config.priorityFacts, + collectorHints: config.collectorHints, + matchedSignals, + }; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main(argv = process.argv.slice(2)) { + const jsonFlag = argv.includes('--json'); + const query = argv.filter((a) => !a.startsWith('--')).join(' '); + + if (!query) { + console.error('Usage: node scripts/classify-request.mjs [--json] ""'); + process.exitCode = 1; + return; + } + + const result = classifyRequest(query); + + if (jsonFlag) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Request type: ${result.requestType}`); + console.log(`Confidence: ${(result.confidence * 100).toFixed(0)}%`); + console.log(`Suggested mode: ${result.suggestedMode}`); + console.log(`Priority facts: ${result.priorityFacts.join(', ')}`); + if (Object.keys(result.collectorHints).length > 0) { + console.log(`Collector hints: ${JSON.stringify(result.collectorHints)}`); + } + if (result.matchedSignals.length > 0) { + console.log(`Matched signals: ${result.matchedSignals.length}`); + } +} + +function isDirectRun() { + if (!process.argv[1]) return false; + return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +} + +if (isDirectRun()) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/format-facts.mjs b/scripts/format-facts.mjs new file mode 100644 index 0000000..262615d --- /dev/null +++ b/scripts/format-facts.mjs @@ -0,0 +1,392 @@ +#!/usr/bin/env node + +/** + * Shipwright facts formatter. + * + * Converts a facts.json pack into a compact structured block suitable for + * direct prompt injection or human review. Groups facts by source domain, + * reconstructs pricing tuples from shared excerpts, and produces a concise + * summary that fits in roughly 300-500 tokens for a typical research run. + * + * Usage (CLI): + * node scripts/format-facts.mjs path/to/facts.json + * node scripts/format-facts.mjs path/to/facts.json --format block + * node scripts/format-facts.mjs path/to/facts.json --format markdown + * + * Usage (programmatic): + * import { formatFactsBlock } from './format-facts.mjs'; + * const block = formatFactsBlock(factsPack, { format: 'block' }); + * + * Integration: + * After running collect-research.mjs, pipe the output facts.json through + * this formatter to get a compact evidence block for synthesis prompts. + * The block replaces the full evidence.md for structured-data-heavy queries. + * + * Example agent prompt prefix: + * "Here is a structured facts summary from programmatic research. + * Use it as your primary evidence source, supplementing with the + * full evidence.md only for context the facts block does not cover: + * + * [block output here]" + */ + +import { readFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Format a facts pack into a compact structured block. + * + * @param {object} factsPack - Parsed facts.json content + * @param {object} [options] + * @param {'block'|'markdown'} [options.format='block'] + * 'block' — compact plaintext, optimized for prompt injection + * 'markdown' — structured markdown with headers, for human review + * @returns {string} + */ +export function formatFactsBlock(factsPack, options = {}) { + const { format = 'block' } = options; + + if (!factsPack || typeof factsPack !== 'object') { + return format === 'markdown' + ? '## Facts Summary\n\nNo facts pack provided.\n' + : 'Facts: (no facts pack provided)'; + } + + const facts = Array.isArray(factsPack.facts) ? factsPack.facts : []; + const meta = factsPack.meta || {}; + + const groups = groupFactsByDomain(facts); + + return format === 'markdown' + ? renderMarkdown(meta, groups, facts) + : renderBlock(meta, groups, facts); +} + +// --------------------------------------------------------------------------- +// Grouping and reconstruction +// --------------------------------------------------------------------------- + +/** + * Group facts by source domain. Within each group, reconstruct pricing + * tuples by matching facts that share the same excerpt (they came from + * the same extracted text line). + */ +function groupFactsByDomain(facts) { + const domainMap = new Map(); + + for (const fact of facts) { + if (!fact?.source_url) continue; + const domain = extractDomain(fact.source_url); + + if (!domainMap.has(domain)) { + domainMap.set(domain, { + domain, + representativeUrl: fact.source_url, + facts: [], + }); + } + domainMap.get(domain).facts.push(fact); + } + + return Array.from(domainMap.values()); +} + +/** + * Reconstruct pricing tuples from a set of facts by grouping + * plan_name / price / currency / billing_period facts that share + * the same excerpt (i.e., were extracted from the same source line). + * + * Returns an array of tuples: { plan_name?, price?, currency?, billing_period?, confidence } + */ +function reconstructPricingTuples(domainFacts) { + const PRICING_FIELDS = new Set(['plan_name', 'price', 'currency', 'billing_period']); + const priceFacts = domainFacts.filter((f) => PRICING_FIELDS.has(f.field)); + + const byExcerpt = new Map(); + for (const fact of priceFacts) { + const key = fact.excerpt || '__no_excerpt__'; + if (!byExcerpt.has(key)) byExcerpt.set(key, { _confidence: 'high' }); + const group = byExcerpt.get(key); + group[fact.field] = fact.value; + // Downgrade confidence to medium if any field is medium + if (fact.confidence_hint === 'medium') group._confidence = 'medium'; + } + + return Array.from(byExcerpt.values()) + .filter((g) => g.price) // only include tuples that have a price + .map(({ plan_name, price, currency, billing_period, _confidence }) => ({ + plan_name, + price, + currency, + billing_period, + confidence: _confidence, + })); +} + +/** + * Get the best available identity string for a domain group. + * Prefers: product_name > product > company > domain + */ +function resolveIdentity(domainFacts, domain) { + const get = (field) => domainFacts.find((f) => f.field === field)?.value; + return get('product_name') || get('product') || get('company') || domain; +} + +/** + * Get company name if distinct from product identity. + */ +function resolveCompany(domainFacts) { + return domainFacts.find((f) => f.field === 'company')?.value || ''; +} + +// --------------------------------------------------------------------------- +// Renderers +// --------------------------------------------------------------------------- + +function renderBlock(meta, groups, allFacts) { + const lines = []; + + // Header + const query = meta.query ? `"${meta.query}"` : '(no query)'; + lines.push(`Facts — ${query}`); + + const coverageParts = []; + if (meta.coverageHint) coverageParts.push(meta.coverageHint); + if (meta.extractedAt) coverageParts.push(`extracted ${meta.extractedAt.slice(0, 10)}`); + if (coverageParts.length > 0) lines.push(coverageParts.join(' | ')); + + if (allFacts.length === 0) { + lines.push('(no facts extracted)'); + if (meta.notes?.length > 0) lines.push(`Note: ${meta.notes.join(' ')}`); + return lines.join('\n'); + } + + lines.push(''); + + // Per-domain groups + for (const group of groups) { + const { domain, representativeUrl, facts: domainFacts } = group; + const identity = resolveIdentity(domainFacts, domain); + const company = resolveCompany(domainFacts); + + // Domain header + const adapterNote = detectAdapterNote(domainFacts); + lines.push(`${domain}${adapterNote}`); + + // Identity line + const identityParts = []; + if (company && company !== identity) identityParts.push(`company: ${company}`); + if (identity) identityParts.push(`product: ${identity}`); + if (identityParts.length > 0) lines.push(` ${identityParts.join(' | ')}`); + + // Pricing tuples + const tuples = reconstructPricingTuples(domainFacts); + for (const tuple of tuples) { + const label = tuple.plan_name ? `${tuple.plan_name}: ` : ''; + const symbol = currencySymbol(tuple.currency); + const price = `${symbol}${tuple.price}`; + const billing = tuple.billing_period ? `/${tuple.billing_period}` : ''; + const conf = tuple.confidence === 'medium' ? ' [medium]' : ''; + lines.push(` ${label}${price}${billing}${conf}`); + } + + // Review facts + const rating = domainFacts.find((f) => f.field === 'star_rating'); + const reviewCount = domainFacts.find((f) => f.field === 'review_count'); + if (rating || reviewCount) { + const parts = []; + if (rating) parts.push(`${rating.value}★`); + if (reviewCount) parts.push(`${formatNumber(reviewCount.value)} reviews`); + const ratingConf = [rating, reviewCount].some((f) => f?.confidence_hint === 'medium') ? ' [medium]' : ''; + lines.push(` rating: ${parts.join(' ')}${ratingConf}`); + } + + // Other notable facts + for (const fact of domainFacts) { + if (isAlreadyRendered(fact.field)) continue; + const conf = fact.confidence_hint === 'medium' ? ' [medium]' : ''; + lines.push(` ${fact.field}: ${fact.value}${conf}`); + } + + lines.push(''); + } + + // Notes + if (meta.notes?.length > 0) { + lines.push(`Note: ${meta.notes.join(' ')}`); + } + + return lines.join('\n').trimEnd(); +} + +function renderMarkdown(meta, groups, allFacts) { + const lines = []; + const query = meta.query ? `"${meta.query}"` : '(no query)'; + + lines.push(`## Structured Facts`); + lines.push(''); + lines.push(`**Query:** ${query} `); + if (meta.coverageHint) lines.push(`**Coverage:** ${meta.coverageHint} `); + if (meta.extractedAt) lines.push(`**Extracted:** ${meta.extractedAt.slice(0, 10)} `); + lines.push(''); + + if (allFacts.length === 0) { + lines.push('*No facts extracted from this evidence pack.*'); + if (meta.notes?.length > 0) { + lines.push(''); + lines.push(`**Notes:** ${meta.notes.join(' ')}`); + } + return `${lines.join('\n')}\n`; + } + + for (const group of groups) { + const { domain, facts: domainFacts } = group; + const identity = resolveIdentity(domainFacts, domain); + const company = resolveCompany(domainFacts); + const adapterNote = detectAdapterNote(domainFacts); + + lines.push(`### ${identity} (${domain}${adapterNote})`); + lines.push(''); + + // Identity + if (company && company !== identity) { + lines.push(`- **Company:** ${company}`); + } + + // Pricing + const tuples = reconstructPricingTuples(domainFacts); + for (const tuple of tuples) { + const label = tuple.plan_name || 'Plan'; + const symbol = currencySymbol(tuple.currency); + const price = `${symbol}${tuple.price}`; + const billing = tuple.billing_period ? `/${tuple.billing_period}` : ''; + const conf = tuple.confidence === 'medium' ? ' *(medium confidence)*' : ''; + lines.push(`- **${label}:** ${price}${billing}${conf}`); + } + + // Reviews + const rating = domainFacts.find((f) => f.field === 'star_rating'); + const reviewCount = domainFacts.find((f) => f.field === 'review_count'); + if (rating || reviewCount) { + const parts = []; + if (rating) parts.push(`${rating.value}★`); + if (reviewCount) parts.push(`${formatNumber(reviewCount.value)} reviews`); + lines.push(`- **Rating:** ${parts.join(', ')}`); + } + + // Other + for (const fact of domainFacts) { + if (isAlreadyRendered(fact.field)) continue; + const conf = fact.confidence_hint === 'medium' ? ' *(medium)*' : ''; + lines.push(`- **${formatFieldLabel(fact.field)}:** ${fact.value}${conf}`); + } + + lines.push(''); + } + + if (meta.notes?.length > 0) { + lines.push(`> **Notes:** ${meta.notes.join(' ')}`); + lines.push(''); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +const RENDERED_FIELDS = new Set([ + 'company', 'product', 'product_name', + 'plan_name', 'price', 'currency', 'billing_period', + 'star_rating', 'review_count', +]); + +function isAlreadyRendered(field) { + return RENDERED_FIELDS.has(field); +} + +function extractDomain(url) { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } +} + +function currencySymbol(code) { + if (!code) return '$'; + if (code === 'USD') return '$'; + if (code === 'EUR') return '€'; + if (code === 'GBP') return '£'; + return `${code} `; +} + +function formatNumber(value) { + const n = parseInt(String(value).replace(/,/g, ''), 10); + if (!Number.isFinite(n)) return value; + return n.toLocaleString('en-US'); +} + +function formatFieldLabel(field) { + return field + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +function detectAdapterNote(domainFacts) { + // Check if any fact came from an adapter (excerpt starts with "Structured data extracted by") + const hasAdapter = domainFacts.some((f) => + (f.excerpt || '').startsWith('Structured data extracted by'), + ); + return hasAdapter ? ' [schema]' : ''; +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main(argv = process.argv.slice(2)) { + const filePath = argv.find((a) => !a.startsWith('--')); + if (!filePath) { + console.error('Usage: node scripts/format-facts.mjs [--format block|markdown]'); + process.exitCode = 1; + return; + } + + let format = 'block'; + const fmtIdx = argv.indexOf('--format'); + if (fmtIdx !== -1 && argv[fmtIdx + 1]) { + format = argv[fmtIdx + 1]; + } + + let factsPack; + try { + const text = await readFile(path.resolve(filePath), 'utf8'); + factsPack = JSON.parse(text); + } catch (error) { + console.error(`Cannot read facts file: ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + + console.log(formatFactsBlock(factsPack, { format })); +} + +function isDirectRun() { + if (!process.argv[1]) return false; + return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +} + +if (isDirectRun()) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/scripts/pricing-diff.mjs b/scripts/pricing-diff.mjs new file mode 100644 index 0000000..976bff4 --- /dev/null +++ b/scripts/pricing-diff.mjs @@ -0,0 +1,330 @@ +#!/usr/bin/env node + +/** + * Shipwright pricing diff. + * + * Produces a deterministic pricing comparison table from two or more + * facts.json files. Each file represents research on one competitor or + * product. The output is a markdown table the AI can cite directly, + * plus a summary of review signals and coverage notes. + * + * Usage (CLI): + * node scripts/pricing-diff.mjs facts-a.json facts-b.json [facts-c.json ...] + * node scripts/pricing-diff.mjs --dir .shipwright/research/ + * + * Usage (programmatic): + * import { buildPricingDiff } from './pricing-diff.mjs'; + * const table = buildPricingDiff([factsPackA, factsPackB]); + * + * Integration: + * Run after collecting research on each competitor individually. + * Feed the output table into pricing-strategy Step 5 or + * competitive-landscape Step 3 as pre-structured evidence. + * + * Example: + * node scripts/pricing-diff.mjs \ + * .shipwright/research/acme-pricing/facts.json \ + * .shipwright/research/rival-pricing/facts.json + */ + +import { readFile, readdir } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import path from 'node:path'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build a pricing comparison from an array of facts packs. + * + * @param {object[]} factsPacks - Array of parsed facts.json objects + * @returns {string} Markdown pricing comparison table + */ +export function buildPricingDiff(factsPacks) { + if (!Array.isArray(factsPacks) || factsPacks.length === 0) { + return '## Pricing Comparison\n\nNo facts packs provided.\n'; + } + + const sources = factsPacks + .map(extractSourceSummary) + .filter((s) => s !== null); + + if (sources.length === 0) { + return '## Pricing Comparison\n\nNo pricing facts found in the provided evidence.\n'; + } + + const lines = []; + lines.push('## Pricing Comparison'); + lines.push(''); + lines.push('*Extracted from structured facts — verify against source evidence before citing.*'); + lines.push(''); + + // Main pricing table + const hasBilling = sources.some((s) => s.plans.some((p) => p.billing_period)); + const hasFree = sources.some((s) => s.hasFreeEntry); + + lines.push(buildPricingTable(sources, { hasBilling, hasFree })); + lines.push(''); + + // Review signals table (only if at least one source has ratings) + const sourcesWithRatings = sources.filter((s) => s.starRating || s.reviewCount); + if (sourcesWithRatings.length > 0) { + lines.push('### Review Signals'); + lines.push(''); + lines.push(buildReviewTable(sources)); + lines.push(''); + } + + // Coverage notes + lines.push('### Coverage Notes'); + lines.push(''); + for (const source of sources) { + const planCount = source.plans.length; + const confNote = source.plans.some((p) => p.confidence === 'medium') + ? ' (some medium confidence)' + : ''; + const adapterNote = source.hasAdapterData ? ' via schema' : ''; + lines.push(`- **${source.label}**: ${planCount} plan(s) extracted${adapterNote}${confNote}`); + if (source.query) lines.push(` - Research query: "${source.query}"`); + if (source.extractedAt) lines.push(` - Extracted: ${source.extractedAt.slice(0, 10)}`); + } + + return lines.join('\n'); +} + +// --------------------------------------------------------------------------- +// Source extraction +// --------------------------------------------------------------------------- + +/** + * Extract a structured summary from one facts pack. + * Returns null if no pricing data is found. + */ +function extractSourceSummary(factsPack) { + if (!factsPack || !Array.isArray(factsPack.facts)) return null; + + const facts = factsPack.facts; + const meta = factsPack.meta || {}; + + // Determine label: prefer company/product name, fall back to domain from first fact + const label = resolveLabel(facts, meta); + + // Reconstruct pricing tuples + const plans = reconstructPricingTuples(facts); + + // Check for free entry (price = 0 or explicit "free" plan name) + const hasFreeEntry = plans.some( + (p) => p.price === '0' || /\bfree\b/i.test(p.plan_name || ''), + ); + + // Review signals + const ratingFact = facts.find((f) => f.field === 'star_rating'); + const reviewFact = facts.find((f) => f.field === 'review_count'); + + // Adapter data presence + const hasAdapterData = facts.some((f) => + (f.excerpt || '').startsWith('Structured data extracted by'), + ); + + return { + label, + query: meta.query || '', + extractedAt: meta.extractedAt || '', + plans, + hasFreeEntry, + starRating: ratingFact?.value || null, + reviewCount: reviewFact?.value || null, + hasAdapterData, + }; +} + +function resolveLabel(facts, meta) { + const get = (field) => facts.find((f) => f.field === field)?.value; + const name = get('product_name') || get('product') || get('company'); + if (name) return name; + + // Fall back to domain from the first fact URL + const firstUrl = facts[0]?.source_url; + if (firstUrl) { + try { + return new URL(firstUrl).hostname.replace(/^www\./, ''); + } catch { /* ignore */ } + } + + return meta.query ? `"${meta.query.slice(0, 30)}"` : 'Unknown'; +} + +// --------------------------------------------------------------------------- +// Pricing tuple reconstruction (same logic as format-facts.mjs) +// --------------------------------------------------------------------------- + +function reconstructPricingTuples(facts) { + const PRICING_FIELDS = new Set(['plan_name', 'price', 'currency', 'billing_period']); + const priceFacts = facts.filter((f) => PRICING_FIELDS.has(f.field)); + + const byExcerpt = new Map(); + for (const fact of priceFacts) { + const key = fact.excerpt || '__no_excerpt__'; + if (!byExcerpt.has(key)) byExcerpt.set(key, { _confidence: 'high' }); + const group = byExcerpt.get(key); + group[fact.field] = fact.value; + if (fact.confidence_hint === 'medium') group._confidence = 'medium'; + } + + return Array.from(byExcerpt.values()) + .filter((g) => g.price) + .map(({ plan_name, price, currency, billing_period, _confidence }) => ({ + plan_name: plan_name || '', + price, + currency: currency || '', + billing_period: billing_period || '', + confidence: _confidence, + })); +} + +// --------------------------------------------------------------------------- +// Table builders +// --------------------------------------------------------------------------- + +function buildPricingTable(sources, { hasBilling, hasFree }) { + const headers = ['Competitor', 'Plan', 'Price']; + if (hasBilling) headers.push('Billing'); + if (hasFree) headers.push('Free Tier'); + headers.push('Confidence'); + + const separator = headers.map(() => '---'); + const rows = []; + + for (const source of sources) { + if (source.plans.length === 0) { + const row = [source.label, '—', '—']; + if (hasBilling) row.push('—'); + if (hasFree) row.push(source.hasFreeEntry ? 'Yes' : '—'); + row.push('—'); + rows.push(row); + continue; + } + + for (let i = 0; i < source.plans.length; i += 1) { + const plan = source.plans[i]; + const label = i === 0 ? source.label : ''; + const symbol = currencySymbol(plan.currency); + const price = `${symbol}${plan.price}`; + const row = [label, plan.plan_name || '—', price]; + if (hasBilling) row.push(plan.billing_period || '—'); + if (hasFree) { + if (i === 0) { + row.push(source.hasFreeEntry ? 'Yes' : 'No'); + } else { + row.push(''); + } + } + row.push(plan.confidence === 'medium' ? 'medium' : 'high'); + rows.push(row); + } + } + + return [ + `| ${headers.join(' | ')} |`, + `| ${separator.join(' | ')} |`, + ...rows.map((r) => `| ${r.join(' | ')} |`), + ].join('\n'); +} + +function buildReviewTable(sources) { + const rows = sources.map((s) => [ + s.label, + s.starRating ? `${s.starRating}★` : '—', + s.reviewCount ? formatNumber(s.reviewCount) : '—', + ]); + + return [ + '| Competitor | Rating | Reviews |', + '| --- | --- | --- |', + ...rows.map((r) => `| ${r.join(' | ')} |`), + ].join('\n'); +} + +// --------------------------------------------------------------------------- +// Utilities +// --------------------------------------------------------------------------- + +function currencySymbol(code) { + if (!code) return '$'; + if (code === 'USD') return '$'; + if (code === 'EUR') return '€'; + if (code === 'GBP') return '£'; + return `${code} `; +} + +function formatNumber(value) { + const n = parseInt(String(value).replace(/,/g, ''), 10); + if (!Number.isFinite(n)) return value; + return n.toLocaleString('en-US'); +} + +// --------------------------------------------------------------------------- +// CLI entry point +// --------------------------------------------------------------------------- + +async function main(argv = process.argv.slice(2)) { + // Collect file paths (args that don't start with --) + let filePaths = argv.filter((a) => !a.startsWith('--')); + + // --dir mode: discover all facts.json files in immediate subdirs + const dirIdx = argv.indexOf('--dir'); + if (dirIdx !== -1 && argv[dirIdx + 1]) { + const dir = path.resolve(argv[dirIdx + 1]); + try { + const entries = await readdir(dir, { withFileTypes: true }); + const discovered = entries + .filter((e) => e.isDirectory()) + .map((e) => path.join(dir, e.name, 'facts.json')); + filePaths = [...filePaths, ...discovered]; + } catch (error) { + console.error(`Cannot read directory: ${argv[dirIdx + 1]}: ${error instanceof Error ? error.message : String(error)}`); + process.exitCode = 1; + return; + } + } + + if (filePaths.length < 1) { + console.error( + 'Usage: node scripts/pricing-diff.mjs [facts2.json ...]\n' + + ' node scripts/pricing-diff.mjs --dir .shipwright/research/', + ); + process.exitCode = 1; + return; + } + + const factsPacks = []; + for (const filePath of filePaths) { + try { + const text = await readFile(path.resolve(filePath), 'utf8'); + factsPacks.push(JSON.parse(text)); + } catch (error) { + console.warn(`Skipping ${filePath}: ${error instanceof Error ? error.message : String(error)}`); + } + } + + if (factsPacks.length === 0) { + console.error('No valid facts files could be loaded.'); + process.exitCode = 1; + return; + } + + console.log(buildPricingDiff(factsPacks)); +} + +function isDirectRun() { + if (!process.argv[1]) return false; + return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +} + +if (isDirectRun()) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/skills/discovery/competitive-landscape/SKILL.md b/skills/discovery/competitive-landscape/SKILL.md index 9e6c6d6..f0d9c71 100644 --- a/skills/discovery/competitive-landscape/SKILL.md +++ b/skills/discovery/competitive-landscape/SKILL.md @@ -66,6 +66,14 @@ Build a comparison grid across the capabilities that matter to your target buyer ### Step 3: Positioning Analysis +If structured research facts are available from a prior `collect-research.mjs` run, generate a structured pricing comparison before filling out the positioning map: + +```bash +node scripts/pricing-diff.mjs --dir .shipwright/research/ +``` + +Use the output table to populate the Pricing model and Price range fields below rather than estimating. If facts files are not available, fill those fields from public pricing pages. + For each competitor, map: ```markdown @@ -74,7 +82,7 @@ For each competitor, map: **Target persona:** [Who they sell to] **Key differentiator:** [What they claim is unique] **Pricing model:** [Free / Freemium / Usage / Seat / Enterprise] -**Price range:** [Estimated] +**Price range:** [Estimated — or see pricing-diff table above] **Sales motion:** [PLG / Sales-led / Hybrid] **Messaging themes:** [What they emphasize in marketing] ``` diff --git a/skills/measurement/artifact-quality-audit/SKILL.md b/skills/measurement/artifact-quality-audit/SKILL.md index f9e500b..ef99bfc 100644 --- a/skills/measurement/artifact-quality-audit/SKILL.md +++ b/skills/measurement/artifact-quality-audit/SKILL.md @@ -48,6 +48,16 @@ Identify which artifacts to score: If the PM has not specified artifacts, offer to review the last 3-5 from the current project. Confirm the list before scoring. +### Step 1b: Run the Deterministic Pre-Pass + +Before scoring manually, run the postflight validator on each artifact file: + +```bash +node scripts/validate-artifact.mjs path/to/artifact.md +``` + +The validator produces machine-generated issue counts for unsupported claims and missing sections. Use these counts as a floor for the **Correctness** dimension — an artifact flagged for multiple unsupported dollar figures cannot score above 6 on Correctness without PM-reviewed justification. If the artifact has a `## Sources` / `## References` / `## Evidence` section, citation checks are skipped and that penalty does not apply. Record the validator output alongside each artifact in the audit set. + ### Step 2: Score Each Artifact Apply the universal 4 dimensions from `/evals/rubric.md`: diff --git a/skills/pricing/pricing-strategy/SKILL.md b/skills/pricing/pricing-strategy/SKILL.md index 753025e..874742c 100644 --- a/skills/pricing/pricing-strategy/SKILL.md +++ b/skills/pricing/pricing-strategy/SKILL.md @@ -139,6 +139,17 @@ Test 5-7 price points to build a demand curve. ### Step 5: Competitive Pricing Analysis +If structured research facts are available from a prior `collect-research.mjs` run, generate the comparison table deterministically instead of by hand: + +```bash +node scripts/pricing-diff.mjs \ + .shipwright/research/competitor-a/facts.json \ + .shipwright/research/competitor-b/facts.json +# or: node scripts/pricing-diff.mjs --dir .shipwright/research/ +``` + +The output is a ready-to-cite markdown table covering plan names, prices, billing periods, free tier presence, and confidence levels. Paste it directly into the Competitive Pricing Landscape section. If facts files are not available, build the table manually below. + ```markdown ## Competitive Pricing Landscape diff --git a/skills/technical/adversarial-review/SKILL.md b/skills/technical/adversarial-review/SKILL.md index aac1a05..3b8ba51 100644 --- a/skills/technical/adversarial-review/SKILL.md +++ b/skills/technical/adversarial-review/SKILL.md @@ -33,6 +33,16 @@ This skill produces a **Challenge Report**. A good Challenge Report creates usef ## Framework +### Pre-Check: Run the Deterministic Validator + +Before reviewing manually, run the postflight validator if the artifact is available as a file: + +```bash +node scripts/validate-artifact.mjs path/to/artifact.md +``` + +The validator flags unsupported dollar figures and numeric claims without nearby citation markers, and checks for missing expected sections. Any flagged issues are automatically Evidence Integrity candidates — include them in the findings table in Step 3 rather than re-deriving them by hand. If the validator finds no issues, note that and proceed. This step is optional but saves review time on high-volume citation work. + ### Step 1: Define the Review Scope Start by naming what is under review and what standard it should be held to. diff --git a/tests/classify-request.test.mjs b/tests/classify-request.test.mjs new file mode 100644 index 0000000..8e91a00 --- /dev/null +++ b/tests/classify-request.test.mjs @@ -0,0 +1,198 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { classifyRequest } from '../scripts/classify-request.mjs'; + +// --------------------------------------------------------------------------- +// Basic type detection +// --------------------------------------------------------------------------- + +test('classifyRequest identifies pricing queries', { concurrency: false }, () => { + const result = classifyRequest('Stripe pricing plans'); + assert.equal(result.requestType, 'pricing'); + assert.ok(result.confidence > 0); + assert.ok(result.priorityFacts.includes('price')); + assert.ok(result.priorityFacts.includes('plan_name')); +}); + +test('classifyRequest identifies pricing query with per-seat language', { concurrency: false }, () => { + const result = classifyRequest('how much does Notion cost per user per month'); + assert.equal(result.requestType, 'pricing'); +}); + +test('classifyRequest identifies competitive queries', { concurrency: false }, () => { + const result = classifyRequest('Figma vs Sketch comparison'); + assert.equal(result.requestType, 'competitive'); + assert.ok(result.priorityFacts.includes('product_name')); +}); + +test('classifyRequest identifies competitive query with "alternatives" language', { concurrency: false }, () => { + const result = classifyRequest('Linear alternatives for project management'); + assert.equal(result.requestType, 'competitive'); +}); + +test('classifyRequest identifies market-size queries', { concurrency: false }, () => { + const result = classifyRequest('total addressable market for developer tools'); + assert.equal(result.requestType, 'market-size'); + assert.equal(result.suggestedMode, 'research'); +}); + +test('classifyRequest identifies market-size query with TAM acronym', { concurrency: false }, () => { + const result = classifyRequest('TAM SAM SOM for B2B SaaS'); + assert.equal(result.requestType, 'market-size'); +}); + +test('classifyRequest identifies acquisition queries', { concurrency: false }, () => { + const result = classifyRequest('who acquired Figma'); + assert.equal(result.requestType, 'acquisition'); + assert.equal(result.suggestedMode, 'news'); + assert.ok(result.priorityFacts.includes('acquirer')); + assert.ok(result.priorityFacts.includes('acquired_company')); +}); + +test('classifyRequest identifies acquisition query with "merger" language', { concurrency: false }, () => { + const result = classifyRequest('GitHub merger and acquisition history'); + assert.equal(result.requestType, 'acquisition'); +}); + +test('classifyRequest identifies funding queries', { concurrency: false }, () => { + const result = classifyRequest('Linear raised Series B funding'); + assert.equal(result.requestType, 'funding'); + assert.equal(result.suggestedMode, 'news'); + assert.ok(result.priorityFacts.includes('funding_event')); +}); + +test('classifyRequest identifies funding query with series language', { concurrency: false }, () => { + const result = classifyRequest('Vercel Series C valuation'); + assert.equal(result.requestType, 'funding'); +}); + +test('classifyRequest identifies review queries', { concurrency: false }, () => { + const result = classifyRequest('Notion user reviews and ratings'); + assert.equal(result.requestType, 'reviews'); + assert.ok(result.priorityFacts.includes('star_rating')); + assert.ok(result.priorityFacts.includes('review_count')); +}); + +test('classifyRequest identifies G2 review query', { concurrency: false }, () => { + const result = classifyRequest('G2 reviews for Salesforce'); + assert.equal(result.requestType, 'reviews'); +}); + +// --------------------------------------------------------------------------- +// Mode hints +// --------------------------------------------------------------------------- + +test('classifyRequest returns pricing suggestedMode for pricing queries', { concurrency: false }, () => { + const result = classifyRequest('Stripe pricing tiers'); + assert.equal(result.suggestedMode, 'pricing'); +}); + +test('classifyRequest returns news suggestedMode for acquisition queries', { concurrency: false }, () => { + const result = classifyRequest('Adobe acquired Figma deal'); + assert.equal(result.suggestedMode, 'news'); +}); + +test('classifyRequest returns research suggestedMode for market-size queries', { concurrency: false }, () => { + const result = classifyRequest('CAGR for cloud infrastructure market forecast'); + assert.equal(result.suggestedMode, 'research'); +}); + +// --------------------------------------------------------------------------- +// Collector hints +// --------------------------------------------------------------------------- + +test('classifyRequest includes includeJsonLd hint for pricing', { concurrency: false }, () => { + const result = classifyRequest('annual subscription cost for GitHub Enterprise'); + assert.equal(result.requestType, 'pricing'); + assert.ok(result.collectorHints.includeJsonLd === true); +}); + +test('classifyRequest includes preferNewsPages hint for funding', { concurrency: false }, () => { + const result = classifyRequest('vc-backed startup Series A raise'); + assert.equal(result.requestType, 'funding'); + assert.ok(result.collectorHints.preferNewsPages === true); +}); + +// --------------------------------------------------------------------------- +// Confidence scoring +// --------------------------------------------------------------------------- + +test('classifyRequest returns higher confidence with more matched signals', { concurrency: false }, () => { + const weak = classifyRequest('pricing'); + const strong = classifyRequest('Stripe pricing plans per seat monthly subscription tiers'); + assert.ok(strong.confidence >= weak.confidence); +}); + +test('classifyRequest confidence is between 0 and 1', { concurrency: false }, () => { + const queries = [ + 'pricing', + 'what did Adobe acquire', + 'total addressable market CAGR billion', + 'user reviews and ratings on G2', + 'seed round Series B venture capital IPO valuation', + ]; + for (const q of queries) { + const { confidence } = classifyRequest(q); + assert.ok(confidence >= 0 && confidence <= 1, `confidence out of range for "${q}": ${confidence}`); + } +}); + +// --------------------------------------------------------------------------- +// General fallback +// --------------------------------------------------------------------------- + +test('classifyRequest falls back to general for unrecognized queries', { concurrency: false }, () => { + const result = classifyRequest('hello world'); + assert.equal(result.requestType, 'general'); + assert.equal(result.confidence, 0); + assert.equal(result.suggestedMode, 'general'); +}); + +// --------------------------------------------------------------------------- +// Edge cases +// --------------------------------------------------------------------------- + +test('classifyRequest handles empty string gracefully', { concurrency: false }, () => { + const result = classifyRequest(''); + assert.equal(result.requestType, 'general'); + assert.equal(result.confidence, 0); + assert.deepEqual(result.matchedSignals, []); +}); + +test('classifyRequest handles null gracefully', { concurrency: false }, () => { + const result = classifyRequest(null); + assert.equal(result.requestType, 'general'); + assert.equal(result.confidence, 0); +}); + +test('classifyRequest returns matchedSignals array', { concurrency: false }, () => { + const result = classifyRequest('Stripe pricing plans'); + assert.ok(Array.isArray(result.matchedSignals)); + assert.ok(result.matchedSignals.length > 0); +}); + +test('classifyRequest prefers strongest match when query has multiple signals', { concurrency: false }, () => { + // "pricing vs" — both pricing and competitive signals fire; pricing has more signals + const result = classifyRequest('compare pricing plans: Stripe vs Paddle per seat monthly'); + // Both competitive (vs, compare) and pricing (pricing, plans, per seat, monthly) signals match + // Pricing has more signal patterns matched + assert.ok(['pricing', 'competitive'].includes(result.requestType)); +}); + +test('classifyRequest includes requestType field in all results', { concurrency: false }, () => { + const VALID_TYPES = new Set(['pricing', 'competitive', 'market-size', 'acquisition', 'funding', 'reviews', 'general']); + const queries = [ + 'Stripe pricing', + 'Figma vs Sketch', + 'TAM for SaaS', + 'Adobe acquired Figma', + 'Series B funding raise', + 'G2 reviews', + 'something completely unrelated', + ]; + for (const q of queries) { + const { requestType } = classifyRequest(q); + assert.ok(VALID_TYPES.has(requestType), `unknown requestType "${requestType}" for query "${q}"`); + } +}); diff --git a/tests/format-facts.test.mjs b/tests/format-facts.test.mjs new file mode 100644 index 0000000..1435281 --- /dev/null +++ b/tests/format-facts.test.mjs @@ -0,0 +1,225 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { formatFactsBlock } from '../scripts/format-facts.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeFact(overrides) { + return { + field: 'price', + value: '29', + source_url: 'https://example.com/pricing', + excerpt: 'Starter: $29/month', + confidence_hint: 'high', + ...overrides, + }; +} + +function makePricingPack(plans = []) { + const facts = []; + for (const plan of plans) { + const excerpt = `${plan.name}: $${plan.price}/${plan.billing || 'month'}`; + facts.push({ field: 'plan_name', value: plan.name, source_url: plan.url || 'https://example.com/pricing', excerpt, confidence_hint: plan.conf || 'high' }); + facts.push({ field: 'price', value: plan.price, source_url: plan.url || 'https://example.com/pricing', excerpt, confidence_hint: plan.conf || 'high' }); + facts.push({ field: 'currency', value: plan.currency || 'USD', source_url: plan.url || 'https://example.com/pricing', excerpt, confidence_hint: plan.conf || 'high' }); + if (plan.billing) facts.push({ field: 'billing_period', value: plan.billing, source_url: plan.url || 'https://example.com/pricing', excerpt, confidence_hint: plan.conf || 'high' }); + } + return { meta: { query: 'test pricing' }, facts }; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +test('formatFactsBlock handles null input gracefully', { concurrency: false }, () => { + const result = formatFactsBlock(null); + assert.ok(typeof result === 'string'); + assert.ok(result.includes('no facts pack')); +}); + +test('formatFactsBlock handles null input in markdown mode', { concurrency: false }, () => { + const result = formatFactsBlock(null, { format: 'markdown' }); + assert.ok(result.includes('No facts pack')); +}); + +test('formatFactsBlock handles empty facts array', { concurrency: false }, () => { + const result = formatFactsBlock({ meta: { query: 'test' }, facts: [] }); + assert.ok(typeof result === 'string'); + assert.ok(result.includes('no facts extracted')); +}); + +// --------------------------------------------------------------------------- +// Block format — basic structure +// --------------------------------------------------------------------------- + +test('formatFactsBlock block includes query in header', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Starter', price: '29', billing: 'month' }]); + pack.meta.query = 'acme pricing'; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('"acme pricing"')); +}); + +test('formatFactsBlock block includes domain header', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Starter', price: '29', billing: 'month' }]); + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('example.com')); +}); + +test('formatFactsBlock block includes price line', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Pro', price: '99', billing: 'month' }]); + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('$99')); + assert.ok(result.includes('Pro')); +}); + +// --------------------------------------------------------------------------- +// Pricing tuple reconstruction +// --------------------------------------------------------------------------- + +test('formatFactsBlock reconstructs pricing tuples from shared excerpt', { concurrency: false }, () => { + const pack = makePricingPack([ + { name: 'Starter', price: '29', billing: 'month' }, + { name: 'Pro', price: '99', billing: 'month' }, + ]); + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('Starter')); + assert.ok(result.includes('Pro')); + assert.ok(result.includes('$29')); + assert.ok(result.includes('$99')); +}); + +test('formatFactsBlock does not emit tuples without a price', { concurrency: false }, () => { + const pack = { + meta: { query: 'test' }, + facts: [ + { field: 'plan_name', value: 'Enterprise', source_url: 'https://example.com/pricing', excerpt: 'Enterprise: contact us', confidence_hint: 'high' }, + // No price fact — should not produce a tuple + ], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + // Should not crash or emit "Enterprise: $" + assert.ok(!result.includes('Enterprise: $')); +}); + +test('formatFactsBlock renders billing period', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Annual', price: '199', billing: 'year' }]); + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('/year')); +}); + +test('formatFactsBlock renders medium confidence annotation', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Starter', price: '29', conf: 'medium' }]); + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('[medium]')); +}); + +// --------------------------------------------------------------------------- +// Identity resolution +// --------------------------------------------------------------------------- + +test('formatFactsBlock uses product_name when present', { concurrency: false }, () => { + const pack = { + meta: { query: 'test' }, + facts: [ + { field: 'product_name', value: 'Acme SaaS', source_url: 'https://acmesaas.io/pricing', excerpt: '', confidence_hint: 'high' }, + { field: 'price', value: '49', source_url: 'https://acmesaas.io/pricing', excerpt: 'Pro: $49', confidence_hint: 'high' }, + ], + }; + const result = formatFactsBlock(pack, { format: 'markdown' }); + assert.ok(result.includes('Acme SaaS')); +}); + +// --------------------------------------------------------------------------- +// Review facts rendering +// --------------------------------------------------------------------------- + +test('formatFactsBlock renders star rating and review count', { concurrency: false }, () => { + const pack = { + meta: { query: 'test reviews' }, + facts: [ + { field: 'star_rating', value: '4.5', source_url: 'https://example.com/reviews', excerpt: 'Rated 4.5 out of 5', confidence_hint: 'high' }, + { field: 'review_count', value: '1200', source_url: 'https://example.com/reviews', excerpt: '1,200 reviews', confidence_hint: 'high' }, + ], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('4.5★')); + assert.ok(result.includes('1,200') || result.includes('1200')); +}); + +// --------------------------------------------------------------------------- +// Adapter note +// --------------------------------------------------------------------------- + +test('formatFactsBlock appends [schema] when adapter data present', { concurrency: false }, () => { + const pack = { + meta: { query: 'test' }, + facts: [ + { field: 'product_name', value: 'TestApp', source_url: 'https://testapp.com', excerpt: 'Structured data extracted by json-ld-adapter', confidence_hint: 'high' }, + { field: 'price', value: '49', source_url: 'https://testapp.com', excerpt: 'Structured data extracted by json-ld-adapter', confidence_hint: 'high' }, + ], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('[schema]')); +}); + +// --------------------------------------------------------------------------- +// Markdown format +// --------------------------------------------------------------------------- + +test('formatFactsBlock markdown includes section headers', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Starter', price: '29' }]); + const result = formatFactsBlock(pack, { format: 'markdown' }); + assert.ok(result.includes('## Structured Facts')); + assert.ok(result.includes('###')); +}); + +test('formatFactsBlock markdown renders price as bold list item', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Pro', price: '79', billing: 'month' }]); + const result = formatFactsBlock(pack, { format: 'markdown' }); + assert.ok(result.includes('**Pro:**')); + assert.ok(result.includes('$79')); +}); + +test('formatFactsBlock markdown renders coverage info', { concurrency: false }, () => { + const pack = makePricingPack([{ name: 'Starter', price: '29' }]); + pack.meta.extractedAt = '2026-03-15T12:00:00Z'; + pack.meta.coverageHint = 'pricing page'; + const result = formatFactsBlock(pack, { format: 'markdown' }); + assert.ok(result.includes('2026-03-15')); + assert.ok(result.includes('pricing page')); +}); + +// --------------------------------------------------------------------------- +// Multi-domain grouping +// --------------------------------------------------------------------------- + +test('formatFactsBlock groups facts from different domains separately', { concurrency: false }, () => { + const pack = { + meta: { query: 'compare pricing' }, + facts: [ + { field: 'price', value: '29', source_url: 'https://site-a.com/pricing', excerpt: 'Starter: $29', confidence_hint: 'high' }, + { field: 'price', value: '49', source_url: 'https://site-b.com/pricing', excerpt: 'Basic: $49', confidence_hint: 'high' }, + ], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('site-a.com')); + assert.ok(result.includes('site-b.com')); + assert.ok(result.includes('$29')); + assert.ok(result.includes('$49')); +}); + +// --------------------------------------------------------------------------- +// Notes +// --------------------------------------------------------------------------- + +test('formatFactsBlock renders meta notes', { concurrency: false }, () => { + const pack = { + meta: { query: 'test', notes: ['Pricing page gated behind login.'] }, + facts: [], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('Pricing page gated behind login.')); +}); diff --git a/tests/pricing-diff.test.mjs b/tests/pricing-diff.test.mjs new file mode 100644 index 0000000..ba13dfa --- /dev/null +++ b/tests/pricing-diff.test.mjs @@ -0,0 +1,228 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { buildPricingDiff } from '../scripts/pricing-diff.mjs'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePack(label, plans = [], opts = {}) { + const facts = []; + + // Identity + if (label) { + facts.push({ field: 'product_name', value: label, source_url: `https://${label.toLowerCase().replace(/\s+/g, '')}.com/pricing`, excerpt: '', confidence_hint: 'high' }); + } + + for (const plan of plans) { + const url = opts.url || `https://${label.toLowerCase().replace(/\s+/g, '')}.com/pricing`; + const excerpt = `${plan.name}: $${plan.price}`; + if (plan.name) facts.push({ field: 'plan_name', value: plan.name, source_url: url, excerpt, confidence_hint: plan.conf || 'high' }); + facts.push({ field: 'price', value: plan.price, source_url: url, excerpt, confidence_hint: plan.conf || 'high' }); + if (plan.currency) facts.push({ field: 'currency', value: plan.currency, source_url: url, excerpt, confidence_hint: plan.conf || 'high' }); + if (plan.billing) facts.push({ field: 'billing_period', value: plan.billing, source_url: url, excerpt, confidence_hint: plan.conf || 'high' }); + } + + if (opts.starRating) { + facts.push({ field: 'star_rating', value: opts.starRating, source_url: `https://${label.toLowerCase().replace(/\s+/g, '')}.com`, excerpt: 'Rating', confidence_hint: 'high' }); + } + if (opts.reviewCount) { + facts.push({ field: 'review_count', value: opts.reviewCount, source_url: `https://${label.toLowerCase().replace(/\s+/g, '')}.com`, excerpt: 'Reviews', confidence_hint: 'high' }); + } + + return { + meta: { query: opts.query || label, extractedAt: opts.extractedAt || '' }, + facts, + }; +} + +// --------------------------------------------------------------------------- +// Input validation +// --------------------------------------------------------------------------- + +test('buildPricingDiff handles empty array', { concurrency: false }, () => { + const result = buildPricingDiff([]); + assert.ok(result.includes('## Pricing Comparison')); + assert.ok(result.includes('No facts packs provided')); +}); + +test('buildPricingDiff handles null', { concurrency: false }, () => { + const result = buildPricingDiff(null); + assert.ok(result.includes('## Pricing Comparison')); + assert.ok(result.includes('No facts packs provided')); +}); + +test('buildPricingDiff handles packs with no pricing facts', { concurrency: false }, () => { + const pack = { meta: { query: 'test' }, facts: [{ field: 'company', value: 'Acme', source_url: 'https://acme.com', excerpt: 'Acme Corp', confidence_hint: 'high' }] }; + const result = buildPricingDiff([pack]); + // A source with no pricing plans still emits a table row with dash placeholders + assert.ok(result.includes('## Pricing Comparison')); + assert.ok(result.includes('| --- |')); + // The source label or dash should appear + assert.ok(result.includes('—') || result.includes('Acme')); +}); + +// --------------------------------------------------------------------------- +// Single source +// --------------------------------------------------------------------------- + +test('buildPricingDiff renders header and disclaimer', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('## Pricing Comparison')); + assert.ok(result.includes('verify against source evidence')); +}); + +test('buildPricingDiff renders competitor name in table', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Acme')); +}); + +test('buildPricingDiff renders plan name and price', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Pro', price: '99', currency: 'USD' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Pro')); + assert.ok(result.includes('$99')); +}); + +test('buildPricingDiff renders billing period column when present', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29', billing: 'month' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Billing')); + assert.ok(result.includes('month')); +}); + +test('buildPricingDiff omits billing column when not present', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const result = buildPricingDiff([pack]); + assert.ok(!result.includes('| Billing |')); +}); + +// --------------------------------------------------------------------------- +// Free tier column +// --------------------------------------------------------------------------- + +test('buildPricingDiff includes Free Tier column when free plan exists', { concurrency: false }, () => { + const pack = makePack('Acme', [ + { name: 'Free', price: '0' }, + { name: 'Pro', price: '99' }, + ]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Free Tier')); + assert.ok(result.includes('Yes')); +}); + +test('buildPricingDiff marks No free tier for paid-only products', { concurrency: false }, () => { + const packA = makePack('Acme', [{ name: 'Free', price: '0' }]); + const packB = makePack('Rival', [{ name: 'Basic', price: '49' }]); + const result = buildPricingDiff([packA, packB]); + assert.ok(result.includes('Yes')); + assert.ok(result.includes('No')); +}); + +// --------------------------------------------------------------------------- +// Multi-source comparison +// --------------------------------------------------------------------------- + +test('buildPricingDiff renders multiple competitors', { concurrency: false }, () => { + const packA = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const packB = makePack('Rival', [{ name: 'Basic', price: '49' }]); + const result = buildPricingDiff([packA, packB]); + assert.ok(result.includes('Acme')); + assert.ok(result.includes('Rival')); + assert.ok(result.includes('$29')); + assert.ok(result.includes('$49')); +}); + +test('buildPricingDiff renders coverage notes for each source', { concurrency: false }, () => { + const packA = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const packB = makePack('Rival', [{ name: 'Basic', price: '49' }]); + const result = buildPricingDiff([packA, packB]); + assert.ok(result.includes('### Coverage Notes')); + assert.ok(result.includes('**Acme**')); + assert.ok(result.includes('**Rival**')); +}); + +test('buildPricingDiff renders plan count in coverage notes', { concurrency: false }, () => { + const pack = makePack('Acme', [ + { name: 'Starter', price: '29' }, + { name: 'Pro', price: '99' }, + ]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('2 plan(s)')); +}); + +// --------------------------------------------------------------------------- +// Confidence +// --------------------------------------------------------------------------- + +test('buildPricingDiff renders confidence column', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Confidence')); + assert.ok(result.includes('high')); +}); + +test('buildPricingDiff renders medium confidence for medium-confidence plans', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Enterprise', price: '299', conf: 'medium' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('medium')); +}); + +test('buildPricingDiff notes medium confidence in coverage notes', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Enterprise', price: '299', conf: 'medium' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('some medium confidence')); +}); + +// --------------------------------------------------------------------------- +// Review signals +// --------------------------------------------------------------------------- + +test('buildPricingDiff renders review signals table when ratings present', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Pro', price: '99' }], { starRating: '4.5', reviewCount: '1200' }); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('### Review Signals')); + assert.ok(result.includes('4.5★')); + assert.ok(result.includes('1,200') || result.includes('1200')); +}); + +test('buildPricingDiff omits review signals table when no ratings', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Pro', price: '99' }]); + const result = buildPricingDiff([pack]); + assert.ok(!result.includes('### Review Signals')); +}); + +// --------------------------------------------------------------------------- +// Currency symbols +// --------------------------------------------------------------------------- + +test('buildPricingDiff uses EUR symbol for EUR currency', { concurrency: false }, () => { + const pack = makePack('EuroApp', [{ name: 'Basic', price: '25', currency: 'EUR' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('€25')); +}); + +test('buildPricingDiff uses GBP symbol for GBP currency', { concurrency: false }, () => { + const pack = makePack('UKApp', [{ name: 'Basic', price: '20', currency: 'GBP' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('£20')); +}); + +// --------------------------------------------------------------------------- +// Coverage notes metadata +// --------------------------------------------------------------------------- + +test('buildPricingDiff includes extractedAt date in coverage notes', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Pro', price: '99' }], { extractedAt: '2026-03-15T12:00:00Z' }); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('2026-03-15')); +}); + +test('buildPricingDiff includes research query in coverage notes', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Pro', price: '99' }], { query: 'Acme pricing research' }); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('Acme pricing research')); +}); From d0fbad08e182dac6a6992e2c469888fb0d31ca9b Mon Sep 17 00:00:00 2001 From: Edgecaser Date: Wed, 1 Apr 2026 19:11:02 -0700 Subject: [PATCH 2/2] Fix collector-compatibility and currency assumptions in deterministic layer - classify-request: suggestedMode now returns collector-compatible values (auto/standard/deep) instead of internal labels (pricing/research/news), so output can be passed directly to collect-research.mjs --mode - format-facts + pricing-diff: remove silent USD default when currency field is absent; bare price is emitted instead of fabricating a $ symbol - pricing-diff --dir: fix directory argument mis-parse that caused the dir path itself to be treated as a facts file - collect-research: accept previous mode alias values as backward-compatible fallbacks so existing callers aren't broken by the mode rename - Skills: prefer repo-level scripts/collect-research.mjs over .codex/.claude copies because only the repo-level collector writes facts.json; .codex and .claude copies noted as fallbacks that may only emit evidence.md - Tests: 4 new tests (CLI --dir coverage, no-USD-default for both helpers, suggestedMode collector-compatibility invariant); 125 passing Co-Authored-By: Claude Sonnet 4.6 --- .codex/skills/shipwright-concierge/SKILL.md | 5 +- .../skills/shipwright-research-brief/SKILL.md | 14 +-- docs/deterministic-helpers-roadmap.md | 5 +- scripts/classify-request.mjs | 32 +++--- scripts/collect-research.mjs | 16 ++- scripts/format-facts.mjs | 12 ++- scripts/pricing-diff.mjs | 100 ++++++++++++++---- .../discovery/competitive-landscape/SKILL.md | 6 +- skills/pricing/pricing-strategy/SKILL.md | 3 +- tests/classify-request.test.mjs | 36 +++++-- tests/format-facts.test.mjs | 15 +++ tests/pricing-diff.test.mjs | 46 +++++++- 12 files changed, 226 insertions(+), 64 deletions(-) diff --git a/.codex/skills/shipwright-concierge/SKILL.md b/.codex/skills/shipwright-concierge/SKILL.md index 9220378..25cc5ec 100644 --- a/.codex/skills/shipwright-concierge/SKILL.md +++ b/.codex/skills/shipwright-concierge/SKILL.md @@ -41,9 +41,10 @@ This skill gives Codex a conversational Shipwright entry point. Use it when the If fresh public-web evidence is needed, follow this protocol in order: 1. First run the local research collector if available: - - `node .codex/scripts/collect-research.mjs --query "" --mode auto` - - otherwise `node scripts/collect-research.mjs --query "" --mode auto` + - `node scripts/collect-research.mjs --query "" --mode auto` + - otherwise `node .codex/scripts/collect-research.mjs --query "" --mode auto` - otherwise `node .claude/scripts/collect-research.mjs --query "" --mode auto` + - Prefer the repo-level `scripts/collect-research.mjs` when available because it writes `facts.json`; the `.codex` and `.claude` copies are fallback collectors and may only emit the evidence pack. 2. If `facts.json` exists alongside the evidence pack, read it first and use it as a structured shortcut before synthesizing from the full evidence pack. 3. Interpret `facts.json` by `confidence_hint`: - `high`: use directly for structured fields, tables, and summaries, while keeping source attribution. diff --git a/.codex/skills/shipwright-research-brief/SKILL.md b/.codex/skills/shipwright-research-brief/SKILL.md index 2ffa7d3..e609593 100644 --- a/.codex/skills/shipwright-research-brief/SKILL.md +++ b/.codex/skills/shipwright-research-brief/SKILL.md @@ -19,13 +19,14 @@ Use this skill for web-heavy product, market, and business research in Codex. 1. **Classify the request** to configure retrieval before collecting: - `node scripts/classify-request.mjs ""` - - The output names a `requestType` (pricing, competitive, market-size, acquisition, funding, reviews, general), a `suggestedMode` for the collector, and `priorityFacts` to watch for in the facts sidecar. - - Use `suggestedMode` as the `--mode` value in step 2 when it is not `general`. + - The output names a `requestType` (pricing, competitive, market-size, acquisition, funding, reviews, general), a collector-compatible `suggestedMode` (`standard`, `auto`, or `deep`), and `priorityFacts` to watch for in the facts sidecar. + - Use `suggestedMode` as the `--mode` value in step 2. 2. Run the local collector first: - - `node .codex/scripts/collect-research.mjs --query "" --mode ` - - otherwise `node scripts/collect-research.mjs --query "" --mode auto` - - otherwise `node .claude/scripts/collect-research.mjs --query "" --mode auto` + - `node scripts/collect-research.mjs --query "" --mode ` + - otherwise `node .codex/scripts/collect-research.mjs --query "" --mode ` + - otherwise `node .claude/scripts/collect-research.mjs --query "" --mode ` + - Prefer the repo-level `scripts/collect-research.mjs` when available because it writes `facts.json` for the deterministic pre-flight. The `.codex` and `.claude` copies are fallback collectors and may only emit `evidence.json`/`evidence.md`. 3. **Format the facts sidecar** if `facts.json` exists: - `node scripts/format-facts.mjs path/to/facts.json` @@ -43,12 +44,13 @@ Use this skill for web-heavy product, market, and business research in Codex. 8. Keep any follow-up browsing gap-focused rather than restarting the whole search pass. 9. Treat raw `Web Search` as fallback, not first-pass retrieval, whenever the local collector is available. -**For multi-competitor pricing requests:** run the collector once per competitor, then generate the comparison table: +**For multi-competitor pricing requests:** run the collector once per competitor, then generate the comparison table from the specific facts packs you intend to compare: ```bash node scripts/pricing-diff.mjs \ .shipwright/research/comp-a/facts.json \ .shipwright/research/comp-b/facts.json ``` +If you staged the intended packs into a comparison-only directory, you can also use `node scripts/pricing-diff.mjs --dir path/to/comparison-set/`. Paste the table directly into the brief's pricing evidence section. ## Synthesis Rules diff --git a/docs/deterministic-helpers-roadmap.md b/docs/deterministic-helpers-roadmap.md index 894771e..a407e0d 100644 --- a/docs/deterministic-helpers-roadmap.md +++ b/docs/deterministic-helpers-roadmap.md @@ -92,7 +92,7 @@ optional Review Signals table, and per-source Coverage Notes. ``` node scripts/pricing-diff.mjs facts-a.json facts-b.json -node scripts/pricing-diff.mjs --dir .shipwright/research/ +node scripts/pricing-diff.mjs --dir path/to/comparison-set/ ``` Wired into: @@ -103,7 +103,8 @@ Wired into: ### Request classifier (`scripts/classify-request.mjs`) Pattern-matches a query string to a request type and returns collector hints. -No network calls. Returns: `requestType`, `suggestedMode`, `priorityFacts`, +No network calls. Returns: `requestType`, collector-compatible `suggestedMode` +(`standard`, `auto`, or `deep`), `priorityFacts`, `collectorHints`, `matchedSignals`, `confidence`. Request types: `pricing`, `competitive`, `market-size`, `acquisition`, `funding`, `reviews`, `general` diff --git a/scripts/classify-request.mjs b/scripts/classify-request.mjs index 0ad45ab..53e7fed 100644 --- a/scripts/classify-request.mjs +++ b/scripts/classify-request.mjs @@ -22,7 +22,7 @@ * * Example: * const { requestType, suggestedMode, collectorHints } = classifyRequest(query); - * // suggestedMode → 'pricing' | 'research' | 'news' | 'general' + * // suggestedMode → 'standard' | 'auto' | 'deep' */ import { pathToFileURL } from 'node:url'; @@ -40,7 +40,7 @@ import path from 'node:path'; * @typedef {object} Classification * @property {RequestType} requestType * @property {number} confidence - 0–1, how strongly this type was matched - * @property {string} suggestedMode - collector mode hint: 'pricing'|'research'|'news'|'general' + * @property {string} suggestedMode - collector mode hint: 'standard'|'auto'|'deep' * @property {string[]} priorityFacts - fact fields most likely to be useful * @property {object} collectorHints - key/value config hints for the collector * @property {string[]} matchedSignals - which patterns fired (for debugging) @@ -58,7 +58,7 @@ import path from 'node:path'; const RULES = [ { type: 'pricing', - mode: 'pricing', + mode: 'auto', priorityFacts: ['plan_name', 'price', 'currency', 'billing_period', 'product_name'], collectorHints: { preferPricingPages: true, includeJsonLd: true }, signals: [ @@ -75,7 +75,7 @@ const RULES = [ }, { type: 'competitive', - mode: 'research', + mode: 'deep', priorityFacts: ['product_name', 'company', 'plan_name', 'price', 'star_rating', 'review_count'], collectorHints: { includeJsonLd: true, includeReviews: true }, signals: [ @@ -90,7 +90,7 @@ const RULES = [ }, { type: 'market-size', - mode: 'research', + mode: 'deep', priorityFacts: ['company', 'product_name', 'published_or_observed_date'], collectorHints: { preferAnalystReports: true }, signals: [ @@ -106,7 +106,7 @@ const RULES = [ }, { type: 'acquisition', - mode: 'news', + mode: 'auto', priorityFacts: ['acquisition_event', 'acquisition_date', 'acquirer', 'acquired_company', 'company'], collectorHints: { preferNewsPages: true, preferRecentResults: true }, signals: [ @@ -120,7 +120,7 @@ const RULES = [ }, { type: 'funding', - mode: 'news', + mode: 'auto', priorityFacts: ['funding_event', 'company', 'product_name', 'acquisition_event'], collectorHints: { preferNewsPages: true, preferRecentResults: true }, signals: [ @@ -136,7 +136,7 @@ const RULES = [ }, { type: 'reviews', - mode: 'research', + mode: 'auto', priorityFacts: ['star_rating', 'review_count', 'product_name', 'company'], collectorHints: { includeJsonLd: true, preferReviewSites: true }, signals: [ @@ -202,7 +202,7 @@ export function classifyRequest(query) { const { rule, matchedSignals } = hits.get(bestType); - // Confidence: 1 match = 0.6, 2+ = 0.8, 3+ = 1.0 (capped) + // Confidence rises with additional matched signals and caps at 1.0. const confidence = Math.min(1.0, 0.5 + bestCount * 0.17); return buildResult(bestType, confidence, matchedSignals, rule); @@ -215,37 +215,37 @@ export function classifyRequest(query) { function buildResult(type, confidence, matchedSignals, rule = null) { const defaults = { pricing: { - mode: 'pricing', + mode: 'auto', priorityFacts: ['plan_name', 'price', 'currency', 'billing_period', 'product_name'], collectorHints: { preferPricingPages: true, includeJsonLd: true }, }, competitive: { - mode: 'research', + mode: 'deep', priorityFacts: ['product_name', 'company', 'plan_name', 'price', 'star_rating', 'review_count'], collectorHints: { includeJsonLd: true, includeReviews: true }, }, 'market-size': { - mode: 'research', + mode: 'deep', priorityFacts: ['company', 'product_name', 'published_or_observed_date'], collectorHints: { preferAnalystReports: true }, }, acquisition: { - mode: 'news', + mode: 'auto', priorityFacts: ['acquisition_event', 'acquisition_date', 'acquirer', 'acquired_company'], collectorHints: { preferNewsPages: true, preferRecentResults: true }, }, funding: { - mode: 'news', + mode: 'auto', priorityFacts: ['funding_event', 'company', 'product_name'], collectorHints: { preferNewsPages: true, preferRecentResults: true }, }, reviews: { - mode: 'research', + mode: 'auto', priorityFacts: ['star_rating', 'review_count', 'product_name', 'company'], collectorHints: { includeJsonLd: true, preferReviewSites: true }, }, general: { - mode: 'general', + mode: 'auto', priorityFacts: ['company', 'product_name', 'published_or_observed_date'], collectorHints: {}, }, diff --git a/scripts/collect-research.mjs b/scripts/collect-research.mjs index 929ffa8..be9481f 100644 --- a/scripts/collect-research.mjs +++ b/scripts/collect-research.mjs @@ -835,10 +835,22 @@ function parseArgs(argv) { } function parseMode(value) { - if (!['standard', 'auto', 'deep'].includes(value || '')) { + const normalized = normalizeModeAlias(value); + if (!['standard', 'auto', 'deep'].includes(normalized || '')) { throw new Error(`Invalid --mode: ${value}. Expected standard, auto, or deep.`); } - return value; + return normalized; +} + +function normalizeModeAlias(value) { + const normalized = String(value || '').trim().toLowerCase(); + if (!normalized) return normalized; + + if (['pricing', 'research', 'news', 'general'].includes(normalized)) { + return 'auto'; + } + + return normalized; } function parseNumber(value, flagName) { diff --git a/scripts/format-facts.mjs b/scripts/format-facts.mjs index 262615d..2f06f0a 100644 --- a/scripts/format-facts.mjs +++ b/scripts/format-facts.mjs @@ -188,8 +188,7 @@ function renderBlock(meta, groups, allFacts) { const tuples = reconstructPricingTuples(domainFacts); for (const tuple of tuples) { const label = tuple.plan_name ? `${tuple.plan_name}: ` : ''; - const symbol = currencySymbol(tuple.currency); - const price = `${symbol}${tuple.price}`; + const price = formatPrice(tuple.price, tuple.currency); const billing = tuple.billing_period ? `/${tuple.billing_period}` : ''; const conf = tuple.confidence === 'medium' ? ' [medium]' : ''; lines.push(` ${label}${price}${billing}${conf}`); @@ -262,8 +261,7 @@ function renderMarkdown(meta, groups, allFacts) { const tuples = reconstructPricingTuples(domainFacts); for (const tuple of tuples) { const label = tuple.plan_name || 'Plan'; - const symbol = currencySymbol(tuple.currency); - const price = `${symbol}${tuple.price}`; + const price = formatPrice(tuple.price, tuple.currency); const billing = tuple.billing_period ? `/${tuple.billing_period}` : ''; const conf = tuple.confidence === 'medium' ? ' *(medium confidence)*' : ''; lines.push(`- **${label}:** ${price}${billing}${conf}`); @@ -320,13 +318,17 @@ function extractDomain(url) { } function currencySymbol(code) { - if (!code) return '$'; + if (!code) return ''; if (code === 'USD') return '$'; if (code === 'EUR') return '€'; if (code === 'GBP') return '£'; return `${code} `; } +function formatPrice(value, currency) { + return `${currencySymbol(currency)}${value}`; +} + function formatNumber(value) { const n = parseInt(String(value).replace(/,/g, ''), 10); if (!Number.isFinite(n)) return value; diff --git a/scripts/pricing-diff.mjs b/scripts/pricing-diff.mjs index 976bff4..0edc048 100644 --- a/scripts/pricing-diff.mjs +++ b/scripts/pricing-diff.mjs @@ -10,7 +10,7 @@ * * Usage (CLI): * node scripts/pricing-diff.mjs facts-a.json facts-b.json [facts-c.json ...] - * node scripts/pricing-diff.mjs --dir .shipwright/research/ + * node scripts/pricing-diff.mjs --dir path/to/comparison-set/ * * Usage (programmatic): * import { buildPricingDiff } from './pricing-diff.mjs'; @@ -27,7 +27,7 @@ * .shipwright/research/rival-pricing/facts.json */ -import { readFile, readdir } from 'node:fs/promises'; +import { access, readFile, readdir } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import path from 'node:path'; @@ -99,7 +99,8 @@ export function buildPricingDiff(factsPacks) { /** * Extract a structured summary from one facts pack. - * Returns null if no pricing data is found. + * Returns a source summary even when no pricing rows are found so callers + * can still see coverage gaps in the final output. */ function extractSourceSummary(factsPack) { if (!factsPack || !Array.isArray(factsPack.facts)) return null; @@ -209,8 +210,7 @@ function buildPricingTable(sources, { hasBilling, hasFree }) { for (let i = 0; i < source.plans.length; i += 1) { const plan = source.plans[i]; const label = i === 0 ? source.label : ''; - const symbol = currencySymbol(plan.currency); - const price = `${symbol}${plan.price}`; + const price = formatPrice(plan.price, plan.currency); const row = [label, plan.plan_name || '—', price]; if (hasBilling) row.push(plan.billing_period || '—'); if (hasFree) { @@ -251,13 +251,17 @@ function buildReviewTable(sources) { // --------------------------------------------------------------------------- function currencySymbol(code) { - if (!code) return '$'; + if (!code) return ''; if (code === 'USD') return '$'; if (code === 'EUR') return '€'; if (code === 'GBP') return '£'; return `${code} `; } +function formatPrice(value, currency) { + return `${currencySymbol(currency)}${value}`; +} + function formatNumber(value) { const n = parseInt(String(value).replace(/,/g, ''), 10); if (!Number.isFinite(n)) return value; @@ -269,21 +273,23 @@ function formatNumber(value) { // --------------------------------------------------------------------------- async function main(argv = process.argv.slice(2)) { - // Collect file paths (args that don't start with --) - let filePaths = argv.filter((a) => !a.startsWith('--')); + let filePaths; + let dir; + + try { + ({ filePaths, dir } = parseCliArgs(argv)); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + return; + } - // --dir mode: discover all facts.json files in immediate subdirs - const dirIdx = argv.indexOf('--dir'); - if (dirIdx !== -1 && argv[dirIdx + 1]) { - const dir = path.resolve(argv[dirIdx + 1]); + if (dir) { try { - const entries = await readdir(dir, { withFileTypes: true }); - const discovered = entries - .filter((e) => e.isDirectory()) - .map((e) => path.join(dir, e.name, 'facts.json')); + const discovered = await discoverFactsPaths(dir); filePaths = [...filePaths, ...discovered]; } catch (error) { - console.error(`Cannot read directory: ${argv[dirIdx + 1]}: ${error instanceof Error ? error.message : String(error)}`); + console.error(`Cannot read directory: ${dir}: ${error instanceof Error ? error.message : String(error)}`); process.exitCode = 1; return; } @@ -292,7 +298,7 @@ async function main(argv = process.argv.slice(2)) { if (filePaths.length < 1) { console.error( 'Usage: node scripts/pricing-diff.mjs [facts2.json ...]\n' + - ' node scripts/pricing-diff.mjs --dir .shipwright/research/', + ' node scripts/pricing-diff.mjs --dir path/to/comparison-set/', ); process.exitCode = 1; return; @@ -317,6 +323,64 @@ async function main(argv = process.argv.slice(2)) { console.log(buildPricingDiff(factsPacks)); } +function parseCliArgs(argv) { + const filePaths = []; + let dir = ''; + + for (let i = 0; i < argv.length; i += 1) { + const token = argv[i]; + + if (token === '--dir') { + dir = argv[i + 1] || ''; + if (!dir) { + throw new Error('Missing value for --dir.'); + } + i += 1; + continue; + } + + if (token.startsWith('--')) { + throw new Error(`Unknown argument: ${token}`); + } + + filePaths.push(token); + } + + return { filePaths, dir }; +} + +async function discoverFactsPaths(dir) { + const resolvedDir = path.resolve(dir); + const discovered = []; + const directFactsPath = path.join(resolvedDir, 'facts.json'); + + if (await pathExists(directFactsPath)) { + discovered.push(directFactsPath); + } + + const entries = await readdir(resolvedDir, { withFileTypes: true }); + const sortedEntries = entries.sort((a, b) => a.name.localeCompare(b.name)); + + for (const entry of sortedEntries) { + if (!entry.isDirectory()) continue; + const factsPath = path.join(resolvedDir, entry.name, 'facts.json'); + if (await pathExists(factsPath)) { + discovered.push(factsPath); + } + } + + return discovered; +} + +async function pathExists(targetPath) { + try { + await access(targetPath); + return true; + } catch { + return false; + } +} + function isDirectRun() { if (!process.argv[1]) return false; return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; diff --git a/skills/discovery/competitive-landscape/SKILL.md b/skills/discovery/competitive-landscape/SKILL.md index f0d9c71..263b11b 100644 --- a/skills/discovery/competitive-landscape/SKILL.md +++ b/skills/discovery/competitive-landscape/SKILL.md @@ -69,9 +69,13 @@ Build a comparison grid across the capabilities that matter to your target buyer If structured research facts are available from a prior `collect-research.mjs` run, generate a structured pricing comparison before filling out the positioning map: ```bash -node scripts/pricing-diff.mjs --dir .shipwright/research/ +node scripts/pricing-diff.mjs \ + .shipwright/research/competitor-a/facts.json \ + .shipwright/research/competitor-b/facts.json ``` +If you staged only the intended packs into a comparison-specific directory, you can also use `node scripts/pricing-diff.mjs --dir path/to/comparison-set/`. + Use the output table to populate the Pricing model and Price range fields below rather than estimating. If facts files are not available, fill those fields from public pricing pages. For each competitor, map: diff --git a/skills/pricing/pricing-strategy/SKILL.md b/skills/pricing/pricing-strategy/SKILL.md index 874742c..9ae8817 100644 --- a/skills/pricing/pricing-strategy/SKILL.md +++ b/skills/pricing/pricing-strategy/SKILL.md @@ -145,7 +145,8 @@ If structured research facts are available from a prior `collect-research.mjs` r node scripts/pricing-diff.mjs \ .shipwright/research/competitor-a/facts.json \ .shipwright/research/competitor-b/facts.json -# or: node scripts/pricing-diff.mjs --dir .shipwright/research/ +# or, if you staged only the intended packs together: +# node scripts/pricing-diff.mjs --dir path/to/comparison-set/ ``` The output is a ready-to-cite markdown table covering plan names, prices, billing periods, free tier presence, and confidence levels. Paste it directly into the Competitive Pricing Landscape section. If facts files are not available, build the table manually below. diff --git a/tests/classify-request.test.mjs b/tests/classify-request.test.mjs index 8e91a00..3c7a3d1 100644 --- a/tests/classify-request.test.mjs +++ b/tests/classify-request.test.mjs @@ -34,7 +34,7 @@ test('classifyRequest identifies competitive query with "alternatives" language' test('classifyRequest identifies market-size queries', { concurrency: false }, () => { const result = classifyRequest('total addressable market for developer tools'); assert.equal(result.requestType, 'market-size'); - assert.equal(result.suggestedMode, 'research'); + assert.equal(result.suggestedMode, 'deep'); }); test('classifyRequest identifies market-size query with TAM acronym', { concurrency: false }, () => { @@ -45,7 +45,7 @@ test('classifyRequest identifies market-size query with TAM acronym', { concurre test('classifyRequest identifies acquisition queries', { concurrency: false }, () => { const result = classifyRequest('who acquired Figma'); assert.equal(result.requestType, 'acquisition'); - assert.equal(result.suggestedMode, 'news'); + assert.equal(result.suggestedMode, 'auto'); assert.ok(result.priorityFacts.includes('acquirer')); assert.ok(result.priorityFacts.includes('acquired_company')); }); @@ -58,7 +58,7 @@ test('classifyRequest identifies acquisition query with "merger" language', { co test('classifyRequest identifies funding queries', { concurrency: false }, () => { const result = classifyRequest('Linear raised Series B funding'); assert.equal(result.requestType, 'funding'); - assert.equal(result.suggestedMode, 'news'); + assert.equal(result.suggestedMode, 'auto'); assert.ok(result.priorityFacts.includes('funding_event')); }); @@ -85,17 +85,17 @@ test('classifyRequest identifies G2 review query', { concurrency: false }, () => test('classifyRequest returns pricing suggestedMode for pricing queries', { concurrency: false }, () => { const result = classifyRequest('Stripe pricing tiers'); - assert.equal(result.suggestedMode, 'pricing'); + assert.equal(result.suggestedMode, 'auto'); }); -test('classifyRequest returns news suggestedMode for acquisition queries', { concurrency: false }, () => { +test('classifyRequest returns auto suggestedMode for acquisition queries', { concurrency: false }, () => { const result = classifyRequest('Adobe acquired Figma deal'); - assert.equal(result.suggestedMode, 'news'); + assert.equal(result.suggestedMode, 'auto'); }); -test('classifyRequest returns research suggestedMode for market-size queries', { concurrency: false }, () => { +test('classifyRequest returns deep suggestedMode for market-size queries', { concurrency: false }, () => { const result = classifyRequest('CAGR for cloud infrastructure market forecast'); - assert.equal(result.suggestedMode, 'research'); + assert.equal(result.suggestedMode, 'deep'); }); // --------------------------------------------------------------------------- @@ -146,7 +146,7 @@ test('classifyRequest falls back to general for unrecognized queries', { concurr const result = classifyRequest('hello world'); assert.equal(result.requestType, 'general'); assert.equal(result.confidence, 0); - assert.equal(result.suggestedMode, 'general'); + assert.equal(result.suggestedMode, 'auto'); }); // --------------------------------------------------------------------------- @@ -196,3 +196,21 @@ test('classifyRequest includes requestType field in all results', { concurrency: assert.ok(VALID_TYPES.has(requestType), `unknown requestType "${requestType}" for query "${q}"`); } }); + +test('classifyRequest suggestedMode stays collector-compatible', { concurrency: false }, () => { + const VALID_MODES = new Set(['standard', 'auto', 'deep']); + const queries = [ + 'Stripe pricing tiers', + 'Figma vs Sketch comparison', + 'TAM for developer tools', + 'who acquired Figma', + 'Series B funding in developer tools', + 'G2 reviews for Salesforce', + 'hello world', + ]; + + for (const query of queries) { + const { suggestedMode } = classifyRequest(query); + assert.ok(VALID_MODES.has(suggestedMode), `invalid suggestedMode "${suggestedMode}" for "${query}"`); + } +}); diff --git a/tests/format-facts.test.mjs b/tests/format-facts.test.mjs index 1435281..bd5547e 100644 --- a/tests/format-facts.test.mjs +++ b/tests/format-facts.test.mjs @@ -116,6 +116,19 @@ test('formatFactsBlock renders medium confidence annotation', { concurrency: fal assert.ok(result.includes('[medium]')); }); +test('formatFactsBlock does not imply USD when currency is missing', { concurrency: false }, () => { + const pack = { + meta: { query: 'test' }, + facts: [ + { field: 'plan_name', value: 'Starter', source_url: 'https://example.com/pricing', excerpt: 'Starter 29/month', confidence_hint: 'high' }, + { field: 'price', value: '29', source_url: 'https://example.com/pricing', excerpt: 'Starter 29/month', confidence_hint: 'high' }, + ], + }; + const result = formatFactsBlock(pack, { format: 'block' }); + assert.ok(result.includes('Starter: 29')); + assert.ok(!result.includes('Starter: $29')); +}); + // --------------------------------------------------------------------------- // Identity resolution // --------------------------------------------------------------------------- @@ -201,7 +214,9 @@ test('formatFactsBlock groups facts from different domains separately', { concur meta: { query: 'compare pricing' }, facts: [ { field: 'price', value: '29', source_url: 'https://site-a.com/pricing', excerpt: 'Starter: $29', confidence_hint: 'high' }, + { field: 'currency', value: 'USD', source_url: 'https://site-a.com/pricing', excerpt: 'Starter: $29', confidence_hint: 'high' }, { field: 'price', value: '49', source_url: 'https://site-b.com/pricing', excerpt: 'Basic: $49', confidence_hint: 'high' }, + { field: 'currency', value: 'USD', source_url: 'https://site-b.com/pricing', excerpt: 'Basic: $49', confidence_hint: 'high' }, ], }; const result = formatFactsBlock(pack, { format: 'block' }); diff --git a/tests/pricing-diff.test.mjs b/tests/pricing-diff.test.mjs index ba13dfa..b9234c1 100644 --- a/tests/pricing-diff.test.mjs +++ b/tests/pricing-diff.test.mjs @@ -1,4 +1,8 @@ import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import test from 'node:test'; import { buildPricingDiff } from '../scripts/pricing-diff.mjs'; @@ -127,8 +131,8 @@ test('buildPricingDiff marks No free tier for paid-only products', { concurrency // --------------------------------------------------------------------------- test('buildPricingDiff renders multiple competitors', { concurrency: false }, () => { - const packA = makePack('Acme', [{ name: 'Starter', price: '29' }]); - const packB = makePack('Rival', [{ name: 'Basic', price: '49' }]); + const packA = makePack('Acme', [{ name: 'Starter', price: '29', currency: 'USD' }]); + const packB = makePack('Rival', [{ name: 'Basic', price: '49', currency: 'USD' }]); const result = buildPricingDiff([packA, packB]); assert.ok(result.includes('Acme')); assert.ok(result.includes('Rival')); @@ -211,6 +215,13 @@ test('buildPricingDiff uses GBP symbol for GBP currency', { concurrency: false } assert.ok(result.includes('£20')); }); +test('buildPricingDiff does not imply USD when currency is missing', { concurrency: false }, () => { + const pack = makePack('Acme', [{ name: 'Starter', price: '29' }]); + const result = buildPricingDiff([pack]); + assert.ok(result.includes('| Acme | Starter | 29 | high |')); + assert.ok(!result.includes('| Acme | Starter | $29 | high |')); +}); + // --------------------------------------------------------------------------- // Coverage notes metadata // --------------------------------------------------------------------------- @@ -226,3 +237,34 @@ test('buildPricingDiff includes research query in coverage notes', { concurrency const result = buildPricingDiff([pack]); assert.ok(result.includes('Acme pricing research')); }); + +// --------------------------------------------------------------------------- +// CLI directory discovery +// --------------------------------------------------------------------------- + +test('pricing-diff --dir only loads discovered facts files', { concurrency: false }, async (t) => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'shipwright-pricing-diff-')); + t.after(async () => { + await rm(dir, { recursive: true, force: true }); + }); + + const acmeDir = path.join(dir, 'acme'); + const rivalDir = path.join(dir, 'rival'); + const emptyDir = path.join(dir, 'empty'); + await mkdir(acmeDir, { recursive: true }); + await mkdir(rivalDir, { recursive: true }); + await mkdir(emptyDir, { recursive: true }); + await writeFile(path.join(acmeDir, 'facts.json'), `${JSON.stringify(makePack('Acme', [{ name: 'Starter', price: '29', currency: 'USD' }]), null, 2)}\n`, 'utf8'); + await writeFile(path.join(rivalDir, 'facts.json'), `${JSON.stringify(makePack('Rival', [{ name: 'Basic', price: '49', currency: 'USD' }]), null, 2)}\n`, 'utf8'); + + const result = spawnSync(process.execPath, ['scripts/pricing-diff.mjs', '--dir', dir], { + cwd: '/Users/ianbrillembourg/Documents/GitHub/shipwright', + encoding: 'utf8', + }); + + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /Acme/); + assert.match(result.stdout, /Rival/); + assert.doesNotMatch(result.stderr, /EISDIR/); + assert.doesNotMatch(result.stderr, /Skipping/); +});