From 4ef4f1296e46c945cffde19770fb140536b6cb36 Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Tue, 28 Jul 2026 17:07:45 +0200 Subject: [PATCH 1/5] fix(brand-profile): bind Wikipedia/Wikidata lookups to a site-validated entity (LLMO-6580) The brand-profile products extractor could attach a foreign (often harmful) entity's product catalogue to a real customer. Root cause: when Wikidata SPARQL returned fewer than the threshold, the code ran a decoupled opensearch for `${brandName} company` and blindly took titles[0] with no check that the article belonged to the resolved entity. A bare 2-3 letter acronym (or a dev/www subdomain label) produced by the old domain-based brand-name fallback then fuzzy-matched famous same-initials articles (d*->"D-Company", e*->"E Company"). Fix: - New services/brand-resolver.js resolves a brand name with a confidence signal and the site's registrable domain (hand-rolled multi-part-TLD table, no new runtime dependency). It never emits a bare acronym or a stop-label subdomain as a high-confidence name, and keeps a best-effort, short-timeout homepage title fetch that fails safe to null. - wikipedia.js gains getWikidataEntity, validateEntityAgainstSite, findValidatedWikidataEntity, fetchWikipediaExtractByTitle and fetchValidatedSummary. Every fetch is bound to an entity validated against the site (strong P856 official-website host match, or a weak label match only for non-low-confidence names). Low-confidence acronyms require P856. The decoupled by-name opensearch is gone from the product and competitor paths; the old findWikidataId/fetchWikipediaFullText are kept but marked @deprecated. - product-extractor.js extractProducts takes an options object bound to the site identity, produces no products when nothing validates, and applies a content-safety backstop: harmful categories are hard-dropped from unvalidated (label-only) sources but kept-and-flagged (sensitive_category) for P856-validated entities and the customer's own sitemap. - index.js rewires the brand-name, competitor-summary and product call sites, adds the BRAND_PROFILE_ENABLE_WIKI_PRODUCTS kill-switch (default off), and adds a persist() guard that never overwrites a manual-curated product catalogue. Adds unit tests (brand-resolver, entity validation, d*/e* regression fixtures asserting no products and no by-name opensearch, manual-curated persist guard); lint clean, full suite green, branch coverage 96.5% (>= 95% gate). Co-Authored-By: Claude Opus 4.8 --- README.md | 16 + src/agents/brand-profile/index.js | 85 +- .../brand-profile/services/brand-resolver.js | 207 +++ .../services/product-extractor.js | 234 +++- .../brand-profile/services/wikipedia.js | 330 +++++ test/agents/brand-profile/index.test.js | 407 +++--- .../services/brand-resolver.test.js | 265 ++++ .../services/product-extractor.test.js | 1161 +++++++---------- .../brand-profile/services/wikipedia.test.js | 589 +++++++++ 9 files changed, 2335 insertions(+), 959 deletions(-) create mode 100644 src/agents/brand-profile/services/brand-resolver.js create mode 100644 test/agents/brand-profile/services/brand-resolver.test.js diff --git a/README.md b/README.md index ac70e437..ae940278 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,22 @@ The `agent-executor` (and the provided brand-profile agent) rely on the Azure Op | `AZURE_COMPLETION_DEPLOYMENT` | Deployment/model name (e.g., `gpt-4o`) | When invoking the integration test, you can also set `BRAND_PROFILE_TEST_BASE_URL` to control which site is analyzed and `BRAND_PROFILE_IT_FULL=1` to print the complete agent response (otherwise the preview is truncated for readability). + +#### Brand-profile entity validation (LLMO-6580) + +The brand-profile product and competitor-summary paths bind every Wikipedia/Wikidata lookup to an entity that is validated against the customer's site, so a foreign entity's catalogue can never be attached to a customer. + +- **Brand-name resolution** (`services/brand-resolver.js`) never emits a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label as a high-confidence brand name. It returns a `confidence` signal (`high`/`medium`/`low`); low-confidence acronyms may only proceed if an entity validates by a strong P856 (official-website host) match against the site's registrable domain. +- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` keeps a Wikidata candidate only if its official-website host (claim P856) shares the site's registrable domain, or — for non-low-confidence names — its label/aliases overlap the brand name. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If nothing validates, the pipeline produces **no** products. + +| Variable | Default | Purpose | +| --- | --- | --- | +| `BRAND_PROFILE_ENABLE_WIKI_PRODUCTS` | `false` | Kill-switch for the entire Wikipedia/Wikidata product + competitor-summary path. When `false`, `extractProducts` returns an empty result (`products_metadata.source = "disabled"`) and no validated summary is fetched; sitemap-based product extraction and the rest of the profile still run. Ship `false` for net-new runs until the P0-a scrub and P2 backfill complete, then flip to `true`. Read from Vault per-service config (`dx_mysticat/{env}/task-processor`). | + +`products_metadata.source` terminal values: `sitemap`, `wikidata`, `hybrid`, `wikipedia_llm`, `disabled`, `skipped_low_confidence` (low-confidence name with no P856-validated entity), `none_no_validated_entity`, and the pre-existing `none`/`sitemap_*` states. Additive provenance fields: `source_entity_label`, `source_wikipedia_title`, `validation` (`p856`|`label`), `safety_filtered` (harmful content dropped from an unvalidated source), and `sensitive_category` (sensitive content kept from a validated/own-site source, flagged for human review). + +**Persist guard:** `persist()` never overwrites a stored brand profile whose `products_metadata.source == "manual-curated"` — the curated `products`/`products_metadata` are preserved while all other fields update. This protects the hand-curated blocks during the P2 regeneration sweep. + - To lint code: ```sh npm run lint diff --git a/src/agents/brand-profile/index.js b/src/agents/brand-profile/index.js index 0deea808..a5789441 100644 --- a/src/agents/brand-profile/index.js +++ b/src/agents/brand-profile/index.js @@ -26,6 +26,7 @@ import { createCompetitorInferenceService } from './services/competitor-inferenc import { createPersonaInferenceService } from './services/persona-inference.js'; import { createProductExtractorService } from './services/product-extractor.js'; import { createWikipediaService } from './services/wikipedia.js'; +import { resolveBrandName } from './services/brand-resolver.js'; /** * Call the model with system and user prompts. @@ -49,40 +50,6 @@ async function callModel({ } } -/** - * Extract brand name from base profile or URL. - * @param {object} baseProfile - Base profile from initial LLM call - * @param {string} baseURL - Site base URL - * @returns {string} Brand name - */ -function extractBrandName(baseProfile, baseURL) { - // Try to get brand name from profile - if (baseProfile?.main_profile?.brand_name) { - return baseProfile.main_profile.brand_name; - } - - // Try competitive_context - if (baseProfile?.competitive_context?.brand_name) { - return baseProfile.competitive_context.brand_name; - } - - // Fall back to domain extraction - try { - const url = new URL(baseURL); - const parts = url.hostname.split('.'); - // Remove www and TLD - const domainParts = parts.filter((p) => p !== 'www' && p.length > 2); - if (domainParts.length > 0) { - return domainParts[0].charAt(0).toUpperCase() + domainParts[0].slice(1); - } - /* c8 ignore next 3 */ - } catch { - // Ignore URL parse errors - } - - return 'Unknown Brand'; -} - /** * Extract industry from base profile. * @param {object} baseProfile - Base profile from initial LLM call @@ -152,11 +119,19 @@ async function run(context, env, log) { } // Extract key fields from base profile for enhanced inference - const brandName = extractBrandName(baseProfile, baseURL); + const { + name: brandName, + confidence: brandConfidence, + registrableDomain, + } = await resolveBrandName(baseProfile, baseURL, log); const industry = extractIndustry(baseProfile); const targetAudience = extractTargetAudience(baseProfile); - log.info(`brand-profile: enhancing profile for "${brandName}" in "${industry}"`); + // LLMO-6580 kill-switch: the entire Wikipedia/Wikidata product + competitor-summary + // path stays OFF unless explicitly enabled, until the P2 backfill is validated. + const enableWikiProducts = env.BRAND_PROFILE_ENABLE_WIKI_PRODUCTS === 'true'; + + log.info(`brand-profile: enhancing profile for "${brandName}" (confidence=${brandConfidence}) in "${industry}"`); // Initialize services const regionalService = createRegionalContextService(env, log); @@ -200,9 +175,17 @@ async function run(context, env, log) { competitorsSource = 'llmo'; } else { log.info('brand-profile: inferring competitors'); - // Optionally fetch Wikipedia summary for better competitor inference - const wikiResult = await wikipediaService.fetchSummary(`${brandName} company`); - const wikiSummary = wikiResult?.summary || ''; + // Optionally fetch a VALIDATED Wikipedia summary (entity bound to the site) for + // better competitor inference. Gated by the kill-switch; null degrades gracefully. + let wikiSummary = ''; + if (enableWikiProducts) { + const wikiResult = await wikipediaService.fetchValidatedSummary({ + brandName, + brandConfidence, + registrableDomain, + }); + wikiSummary = wikiResult?.summary || ''; + } const competitorResult = await competitorService.inferCompetitors({ brandName, @@ -232,9 +215,14 @@ async function run(context, env, log) { log.info(`brand-profile: using sitemap for product extraction: ${sitemapUrl}`); productsResult = await productService.extractFromSitemap(sitemapUrl, brandName); } else { - // Use Wikipedia/Wikidata extraction - const wikiText = await wikipediaService.fetchFullText(`${brandName} company`, 12000); - productsResult = await productService.extractProducts(brandName, wikiText); + // Entity-bound Wikipedia/Wikidata extraction. The fetch now happens inside + // extractProducts, bound to an entity validated against the site. + productsResult = await productService.extractProducts({ + brandName, + brandConfidence, + registrableDomain, + enableWikiProducts, + }); } // Assemble the enhanced profile @@ -311,7 +299,18 @@ async function persist(message, context, result) { const baseURL = site.getBaseURL(); const before = cfg.getBrandProfile?.() || {}; const beforeHash = before?.contentHash || null; - cfg.updateBrandProfile(result); + + // LLMO-6580: never overwrite a hand-curated product catalogue. Phase-1 wrote ~20 + // `products_metadata.source == "manual-curated"` blocks in prod; the fixed pipeline + // and the P2 backfill MUST preserve them. Everything else still updates. + const curated = before?.products_metadata?.source === 'manual-curated'; + const toPersist = curated + ? { ...result, products: before.products, products_metadata: before.products_metadata } + : result; + if (curated) { + log.info('brand-profile persist: preserving manual-curated products', { siteId }); + } + cfg.updateBrandProfile(toPersist); const after = cfg.getBrandProfile?.() || {}; const afterHash = after?.contentHash || null; const changed = beforeHash !== afterHash; diff --git a/src/agents/brand-profile/services/brand-resolver.js b/src/agents/brand-profile/services/brand-resolver.js new file mode 100644 index 00000000..cc4e4794 --- /dev/null +++ b/src/agents/brand-profile/services/brand-resolver.js @@ -0,0 +1,207 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * Brand-name resolution for the brand-profile agent (LLMO-6580). + * + * Turns a base profile + site URL into a best-effort display name plus a + * confidence signal and the site's registrable domain. The confidence signal + * gates the downstream Wikipedia/Wikidata entity validation: a low-confidence + * acronym (e.g. `dnp`, `edb`) is never allowed to drive a fuzzy by-name lookup; + * it may only proceed if an entity strongly validates against the site domain + * (P856 official-website host match). + */ + +import { load } from 'cheerio'; +import { hasText } from '@adobe/spacecat-shared-utils'; + +const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; +const HOMEPAGE_FETCH_TIMEOUT_MS = 5000; + +/** + * Labels that must never become the brand name (subdomains / env prefixes / sections). + */ +export const STOP_LABELS = new Set([ + 'www', 'www2', 'dev', 'stage', 'staging', 'test', 'qa', 'preview', 'demo', + 'store', 'shop', 'support', 'help', 'faq', 'blog', 'news', 'press', 'careers', + 'account', 'accounts', 'login', 'my', 'portal', 'app', 'apps', 'm', 'mobile', + 'en', 'us', 'uk', 'eu', 'go', 'get', 'about', +]); + +/** + * Minimal public-suffix awareness for the multi-part TLDs that broke the audit set. + * Hand-rolled table (no runtime dependency) covering the common ccTLD second levels. + */ +export const MULTI_PART_TLDS = new Set([ + 'co.jp', 'co.uk', 'com.au', 'co.nz', 'gov.sg', 'com.sg', 'com.br', 'co.in', + 'com.mx', 'gov.uk', 'ac.uk', 'org.uk', 'co.za', 'com.cn', 'com.hk', 'co.kr', + 'ne.jp', 'or.jp', 'com.tw', 'co.id', 'com.tr', 'gov.au', 'edu.au', +]); + +/** + * Split a hostname into its subdomain labels, apex label, and registrable domain, + * honouring the minimal multi-part TLD table. + * @param {string} hostname - Hostname (e.g. "dev.amrize.com", "dnp.co.jp") + * @returns {{subdomainLabels: string[], apexLabel: string, registrableDomain: string}} + */ +export function splitHost(hostname) { + const host = String(hostname || '').toLowerCase().replace(/\.$/, '').trim(); + const labels = host.split('.').filter(Boolean); + + if (labels.length <= 1) { + return { subdomainLabels: [], apexLabel: labels[0] || '', registrableDomain: host }; + } + + let registrableLabelCount = 2; + const lastTwo = labels.slice(-2).join('.'); + if (MULTI_PART_TLDS.has(lastTwo) && labels.length >= 3) { + registrableLabelCount = 3; + } + + const registrableLabels = labels.slice(-registrableLabelCount); + const registrableDomain = registrableLabels.join('.'); + const apexLabel = registrableLabels[0]; + const subdomainLabels = labels.slice(0, labels.length - registrableLabelCount); + + return { subdomainLabels, apexLabel, registrableDomain }; +} + +/** + * Is this label too weak to use as a brand name on its own? + * True for stop labels (subdomains/sections) and short (<=3 char) acronyms. + * Short/acronym brands (IBM, HP) are still allowed downstream via P856 validation. + * @param {string} label - Candidate label + * @returns {boolean} + */ +export function isLowConfidenceLabel(label) { + const l = String(label || '').toLowerCase().trim(); + if (!l) { + return true; + } + if (STOP_LABELS.has(l)) { + return true; + } + return l.length <= 3; +} + +/** + * Clean a raw /og:site_name into a brand-like token. + * "Page | Brand" or "Brand - Tagline" -> first non-generic segment. + * @param {string} raw - Raw title string + * @returns {string|null} Cleaned name or null + */ +function cleanTitle(raw) { + const t = String(raw || '').trim(); + if (!t) { + return null; + } + const parts = t.split(/\s+[|\-–—:·]\s+/).map((p) => p.trim()).filter(Boolean); + const generic = /^(home|homepage|official site|official website|welcome)$/i; + const meaningful = parts.filter((p) => !generic.test(p)); + return meaningful[0] || parts[0]; +} + +/** + * Best-effort fetch of the site's display name from og:site_name or <title>. + * Never throws; returns null on any failure (network, timeout, non-HTML, bot-block). + * @param {string} baseURL - Site base URL + * @param {object} log - Logger instance + * @returns {Promise<string|null>} Cleaned site name or null + */ +export async function fetchSiteName(baseURL, log) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), HOMEPAGE_FETCH_TIMEOUT_MS); + try { + const resp = await fetch(baseURL, { + headers: { 'User-Agent': USER_AGENT }, + signal: controller.signal, + }); + if (!resp.ok) { + log.info(`brand-resolver: homepage fetch not ok (${resp.status}) for ${baseURL}`); + return null; + } + const contentType = resp.headers?.get?.('content-type') || ''; + if (contentType && !contentType.toLowerCase().includes('html')) { + return null; + } + const html = await resp.text(); + const $ = load(html); + const ogName = cleanTitle($('meta[property="og:site_name"]').attr('content')); + if (ogName) { + return ogName; + } + return cleanTitle($('title').first().text()); + } catch (e) { + log.info(`brand-resolver: homepage fetch failed for ${baseURL}: ${e.message}`); + return null; + } finally { + clearTimeout(timer); + } +} + +/** + * Resolve a brand name with a confidence signal and the site's registrable domain. + * + * Precedence (high -> low): + * 1. base_profile.main_profile.brand_name -> high / base_profile + * 2. competitive_context.brand_name -> high / competitive_context + * 3. og:site_name / cleaned <title> -> high / site_title + * 4. apex domain label (not low-confidence) -> medium/ apex_domain + * 5. apex domain label (short/acronym) -> low / apex_acronym + * 6. nothing usable -> low / none ("Unknown Brand") + * + * @param {object} baseProfile - Base profile from the initial LLM call + * @param {string} baseURL - Site base URL + * @param {object} log - Logger instance + * @returns {Promise<{name: string, confidence: string, source: string, + * siteHost: string, registrableDomain: string}>} + */ +export async function resolveBrandName(baseProfile, baseURL, log) { + let siteHost = ''; + try { + siteHost = new URL(baseURL).hostname; + } catch { + // baseURL is validated upstream; keep empty host on parse failure. + siteHost = ''; + } + + const { apexLabel, registrableDomain } = splitHost(siteHost); + + const build = (name, confidence, source) => ({ + name, confidence, source, siteHost, registrableDomain, + }); + + const mpName = baseProfile?.main_profile?.brand_name; + if (hasText(mpName)) { + return build(mpName, 'high', 'base_profile'); + } + + const ccName = baseProfile?.competitive_context?.brand_name; + if (hasText(ccName)) { + return build(ccName, 'high', 'competitive_context'); + } + + const siteName = await fetchSiteName(baseURL, log); + if (hasText(siteName) && !isLowConfidenceLabel(siteName)) { + return build(siteName, 'high', 'site_title'); + } + + if (hasText(apexLabel)) { + const display = apexLabel.charAt(0).toUpperCase() + apexLabel.slice(1); + if (!isLowConfidenceLabel(apexLabel)) { + return build(display, 'medium', 'apex_domain'); + } + return build(display, 'low', 'apex_acronym'); + } + + return build('Unknown Brand', 'low', 'none'); +} diff --git a/src/agents/brand-profile/services/product-extractor.js b/src/agents/brand-profile/services/product-extractor.js index 6cce9065..cb8707f1 100644 --- a/src/agents/brand-profile/services/product-extractor.js +++ b/src/agents/brand-profile/services/product-extractor.js @@ -23,12 +23,28 @@ import { AzureOpenAIClient } from '@adobe/spacecat-shared-gpt-client'; import { readPromptFile, renderTemplate } from '../../base.js'; -import { findWikidataId, fetchWikipediaFullText } from './wikipedia.js'; +import { findValidatedWikidataEntity, fetchWikipediaExtractByTitle } from './wikipedia.js'; const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql'; const MIN_PRODUCTS_THRESHOLD = 3; +// Harm denylist (LLMO-6580 / AI-ethics Tier-2). Word-boundary matched against product/ +// service/sub-brand names and categories. Deliberate stems (terror, smuggl, insurgen) +// avoid false hits on words like "armature" or "Churchill". +const HARM_PATTERNS = [ + // crime / terror + /\bterror/i, /\bsmuggl/i, /\binsurgen/i, /\bcartel/i, /\bmafia/i, /\bcriminal/i, /\bnarco/i, + // weapons / military + /\bweapon/i, /\bfirearm/i, /\bammunition/i, /\bmissile/i, /\bwarhead/i, /\bexplosive/i, + // adult / sexual + /\bpornograph/i, /\bescort\b/i, + // drugs + /\bnarcotic/i, /\bheroin\b/i, /\bcocaine\b/i, /\bmethamphetamine\b/i, + // hate / extremism + /\bextremis/i, /\bneo-?nazi/i, /\bjihad/i, +]; + // Generic SPARQL query - works for any industry const PRODUCTS_SPARQL = ` SELECT DISTINCT ?item ?itemLabel ?typeLabel ?inception ?discontinued WHERE { @@ -291,51 +307,112 @@ function normalizeResults(result) { * @param {object} secondary - Secondary results (usually Wikipedia) * @returns {object} Merged result */ -/* c8 ignore start */ function mergeResults(primary, secondary) { - const existingProductNames = new Set( - (primary.products || []).map((p) => (p.name || '').toLowerCase()), - ); - const existingServiceNames = new Set( - (primary.services || []).map((s) => (s.name || '').toLowerCase()), - ); - const existingSubBrands = new Set(primary.sub_brands || []); - const existingDiscontinued = new Set( - (primary.discontinued || []).map((d) => (d.name || '').toLowerCase()), - ); - - // Add new products from secondary - const newProducts = (secondary.products || []).filter((product) => { + // `primary` is always the well-formed result object (four arrays; Wikidata product + // names are non-empty by construction). `secondary` is extractFromWikipedia output + // (four arrays, but LLM entries may have empty names). + const existingProductNames = new Set(primary.products.map((p) => p.name.toLowerCase())); + const existingServiceNames = new Set(primary.services.map((s) => s.name.toLowerCase())); + const existingSubBrands = new Set(primary.sub_brands); + const existingDiscontinued = new Set(primary.discontinued.map((d) => d.name.toLowerCase())); + + const newProducts = secondary.products.filter((product) => { const nameLower = (product.name || '').toLowerCase(); return nameLower && !existingProductNames.has(nameLower); }); - // Add new services from secondary - const newServices = (secondary.services || []).filter((service) => { + const newServices = secondary.services.filter((service) => { const nameLower = (service.name || '').toLowerCase(); return nameLower && !existingServiceNames.has(nameLower); }); - // Add sub-brands (merge unique) - const newSubBrands = (secondary.sub_brands || []).filter( - (sub) => !existingSubBrands.has(sub), - ); + const newSubBrands = secondary.sub_brands.filter((sub) => !existingSubBrands.has(sub)); - // Add discontinued (merge unique) - const newDiscontinued = (secondary.discontinued || []).filter((disc) => { + const newDiscontinued = secondary.discontinued.filter((disc) => { const nameLower = (disc.name || '').toLowerCase(); return nameLower && !existingDiscontinued.has(nameLower); }); return { ...primary, - products: [...(primary.products || []), ...newProducts], - services: [...(primary.services || []), ...newServices], - sub_brands: [...(primary.sub_brands || []), ...newSubBrands], - discontinued: [...(primary.discontinued || []), ...newDiscontinued], + products: [...primary.products, ...newProducts], + services: [...primary.services, ...newServices], + sub_brands: [...primary.sub_brands, ...newSubBrands], + discontinued: [...primary.discontinued, ...newDiscontinued], + }; +} + +/** + * Does a string trip the harm denylist? + * @param {string} text - Text to scan + * @returns {boolean} + */ +function hitsHarm(text) { + const t = String(text || ''); + return HARM_PATTERNS.some((re) => re.test(t)); +} + +/** + * Does a normalized product/service item ({ name, category }) trip the harm denylist? + * @param {object} item - Item to scan + * @returns {boolean} + */ +function itemHitsHarm(item) { + return hitsHarm(item.name) || hitsHarm(item.category); +} + +/** + * Content-safety / plausibility backstop (LLMO-6580 ask 4, defence in depth). + * + * Provenance rule: + * - When the content came from a strongly (P856) validated entity — or the + * customer's own sitemap (`own_site`) — a real defense/pharma/gaming customer + * may legitimately list sensitive products: KEEP the content and set + * `metadata.sensitive_category` for human review. + * - When the source is unvalidated or only weakly (label) matched, HARD-DROP any + * harmful item/service/sub-brand and set `metadata.safety_filtered`. + * + * @param {object} result - Extraction result (mutated defensively via copy) + * @param {object} opts - { entityValidated: 'p856'|'label'|'own_site'|null } + * @param {object} log - Logger instance + * @returns {object} Possibly-filtered result + */ +function applyContentSafetyGate(result, { entityValidated }, log) { + // Strong provenance = a P856-validated Wikidata entity or the customer's own sitemap. + const strongProvenance = entityValidated === 'p856' || entityValidated === 'own_site'; + + // `result` always carries the four arrays (initialized by every caller). + const dropped = [ + ...result.products.filter(itemHitsHarm).map((p) => p.name), + ...result.services.filter(itemHitsHarm).map((s) => s.name), + ...result.sub_brands.filter(hitsHarm), + ...result.discontinued.filter(itemHitsHarm).map((d) => d.name), + ]; + + if (dropped.length === 0) { + return result; + } + + if (strongProvenance) { + // Keep legitimate sensitive content (defense/pharma/gaming), flag for review. + log.warn(`Sensitive categories from validated source kept for review: ${dropped.join(', ')}`); + return { + ...result, + metadata: { ...result.metadata, sensitive_category: true }, + }; + } + + // Weak/no provenance: hard-drop harmful content. + log.warn(`Dropping harmful content from unvalidated source: ${dropped.join(', ')}`); + return { + ...result, + products: result.products.filter((it) => !itemHitsHarm(it)), + services: result.services.filter((it) => !itemHitsHarm(it)), + sub_brands: result.sub_brands.filter((s) => !hitsHarm(s)), + discontinued: result.discontinued.filter((it) => !itemHitsHarm(it)), + metadata: { ...result.metadata, safety_filtered: true }, }; } -/* c8 ignore stop */ /** * Extract current products from sitemap URLs using LLM. @@ -418,7 +495,10 @@ export async function extractFromSitemap(sitemapUrl, brandName, gpt, log) { return result; } - return normalizeResults(result); + // Content-safety backstop. The sitemap is the customer's OWN site, so treat it as + // strong (`own_site`) provenance: keep legitimate sensitive content but flag it. + const gated = applyContentSafetyGate(result, { entityValidated: 'own_site' }, log); + return normalizeResults(gated); } /** @@ -469,15 +549,30 @@ async function extractFromWikipedia(brandName, wikipediaText, gpt, log) { } /** - * Extract products using Wikidata + Wikipedia fallback. - * @param {string} brandName - Brand/company name - * @param {string} [wikipediaSummary] - Optional Wikipedia text for fallback + * Extract products bound to a VALIDATED Wikidata entity (LLMO-6580). + * + * Every Wikipedia/Wikidata fetch is bound to an entity that validates against the + * customer's site (P856 host match, or a weak label match for non-low-confidence + * names). If nothing validates, we produce NO products rather than guessing. + * + * @param {object} options - Options + * @param {string} options.brandName - Brand/company name + * @param {string} [options.brandConfidence='medium'] - 'high' | 'medium' | 'low' + * @param {string} [options.registrableDomain=''] - Site registrable domain + * @param {string} [options.wikipediaSummary=null] - Optional pre-fetched fallback text + * @param {boolean} [options.enableWikiProducts=true] - Kill-switch for the entire path * @param {object} gpt - AzureOpenAIClient instance * @param {object} log - Logger instance * @returns {Promise<object>} Extraction result */ -export async function extractProducts(brandName, wikipediaSummary, gpt, log) { - log.info(`Extracting products for brand: ${brandName}`); +export async function extractProducts({ + brandName, + brandConfidence = 'medium', + registrableDomain = '', + wikipediaSummary = null, + enableWikiProducts = true, +}, gpt, log) { + log.info(`Extracting products for brand: ${brandName} (confidence=${brandConfidence})`); const result = { products: [], @@ -492,49 +587,62 @@ export async function extractProducts(brandName, wikipediaSummary, gpt, log) { }, }; - // Step 1: Find brand's Wikidata ID - const wikidataId = await findWikidataId(brandName, log); - - if (wikidataId) { - result.metadata.brand_wikidata_id = wikidataId; - log.info(`Found Wikidata ID for ${brandName}: ${wikidataId}`); + // Kill-switch: entire Wikipedia/Wikidata product path disabled. + if (!enableWikiProducts) { + log.info('brand-profile: Wikipedia/Wikidata product extraction disabled by flag'); + result.metadata.source = 'disabled'; + return normalizeResults(result); + } - // Step 2: Query Wikidata for products - const wikidataProducts = await queryWikidataProducts(wikidataId, log); + // Step 1: Resolve+validate the entity. A bare low-confidence acronym only validates + // via a strong P856 host match; otherwise findValidatedWikidataEntity returns null. + const entity = await findValidatedWikidataEntity({ + brandName, brandConfidence, registrableDomain, + }, log); + + if (!entity) { + log.info(`No validated Wikidata entity for ${brandName}; producing no products`); + result.metadata.source = brandConfidence === 'low' + ? 'skipped_low_confidence' + : 'none_no_validated_entity'; + result.metadata.rejected = true; + return normalizeResults(result); + } - if (wikidataProducts.length > 0) { - result.products = wikidataProducts; - result.metadata.source = 'wikidata'; - result.metadata.count = wikidataProducts.length; - log.info(`Found ${wikidataProducts.length} products from Wikidata`); - } + result.metadata.brand_wikidata_id = entity.id; + result.metadata.source_entity_label = entity.label; + result.metadata.validation = entity.validation; + + // Step 2: Query Wikidata SPARQL for products (inherently entity-bound, safe). + const wikidataProducts = await queryWikidataProducts(entity.id, log); + if (wikidataProducts.length > 0) { + result.products = wikidataProducts; + result.metadata.source = 'wikidata'; + result.metadata.count = wikidataProducts.length; + log.info(`Found ${wikidataProducts.length} products from Wikidata`); } - // Step 3: Fallback/augment with Wikipedia if insufficient + // Step 3: Fallback/augment with the validated entity's OWN enwiki article only. if (result.products.length < MIN_PRODUCTS_THRESHOLD) { - log.info(`Wikidata returned ${result.products.length} products (threshold: ${MIN_PRODUCTS_THRESHOLD}), trying Wikipedia fallback`); + log.info(`Wikidata returned ${result.products.length} products (threshold: ${MIN_PRODUCTS_THRESHOLD}), trying entity-bound Wikipedia fallback`); - // Fetch Wikipedia text if not provided let wikiText = wikipediaSummary; - if (!wikiText) { - wikiText = await fetchWikipediaFullText(`${brandName} company`, 12000, log); + if (!wikiText && entity.enwikiTitle) { + wikiText = await fetchWikipediaExtractByTitle(entity.enwikiTitle, 12000, log); + result.metadata.source_wikipedia_title = entity.enwikiTitle; } const wikiResult = await extractFromWikipedia(brandName, wikiText, gpt, log); - if (wikiResult) { const merged = mergeResults(result, wikiResult); Object.assign(result, merged); - - if (result.metadata.source === 'wikidata') { - result.metadata.source = 'hybrid'; - } else { - result.metadata.source = 'wikipedia_llm'; - } + result.metadata.source = result.metadata.source === 'wikidata' ? 'hybrid' : 'wikipedia_llm'; } } - return normalizeResults(result); + // Step 4: Content-safety backstop, gated by entity provenance. + const gated = applyContentSafetyGate(result, { entityValidated: entity.validation }, log); + return normalizeResults(gated); } /** @@ -591,9 +699,7 @@ export function createProductExtractorService(env, log) { extractFromSitemap: (sitemapUrl, brandName) => ( extractFromSitemap(sitemapUrl, brandName, gpt, log) ), - extractProducts: (brandName, wikipediaSummary) => ( - extractProducts(brandName, wikipediaSummary, gpt, log) - ), + extractProducts: (options) => extractProducts(options, gpt, log), formatProductsForPrompt, }; } diff --git a/src/agents/brand-profile/services/wikipedia.js b/src/agents/brand-profile/services/wikipedia.js index c9b290f3..654bdc9b 100644 --- a/src/agents/brand-profile/services/wikipedia.js +++ b/src/agents/brand-profile/services/wikipedia.js @@ -14,10 +14,15 @@ * Wikipedia/Wikidata client for fetching brand information. */ +import { splitHost } from './brand-resolver.js'; + const WIKIPEDIA_API_BASE = 'https://en.wikipedia.org/w/api.php'; const WIKIDATA_API = 'https://www.wikidata.org/w/api.php'; const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; +// Corporate suffixes stripped before comparing an entity label to a brand name. +const CORP_SUFFIXES = /\b(inc|corp|corporation|co|ltd|limited|llc|gmbh|ag|sa|plc|nv|kk|group|holdings?|company)\b/gi; + /** * Fetch Wikipedia summary for a brand. * @param {string} searchQuery - Search query (e.g., "Swiss Life company") @@ -105,6 +110,9 @@ export async function fetchWikipediaSummary(searchQuery, log) { /** * Fetch full Wikipedia article text for deeper extraction. + * @deprecated LLMO-6580: this does an unbound `opensearch` by name and blindly takes + * `titles[0]`, which let acronyms fuzzy-match foreign articles (d*->"D-Company"). + * Use {@link fetchWikipediaExtractByTitle} with a validated entity's exact enwiki title. * @param {string} searchQuery - Search query * @param {number} [maxChars=12000] - Maximum characters to return * @param {object} log - Logger instance @@ -183,6 +191,8 @@ export async function fetchWikipediaFullText(searchQuery, maxChars, log) { /** * Find a brand's Wikidata ID by name. + * @deprecated LLMO-6580: returns an entity by fuzzy name match with no validation + * against the customer's site. Use {@link findValidatedWikidataEntity} instead. * @param {string} brandName - Brand name to search for * @param {object} log - Logger instance * @returns {Promise<string|null>} Wikidata entity ID (e.g., "Q217994") or null @@ -241,6 +251,322 @@ export async function findWikidataId(brandName, log) { } } +/** + * Fetch a Wikidata entity's ground truth: its English label/aliases, its own + * English Wikipedia article title, and the hosts of its official website (P856). + * @param {string} entityId - Wikidata entity ID (e.g., "Q489815") + * @param {object} log - Logger instance + * @returns {Promise<object|null>} { id, label, aliases, enwikiTitle, officialWebsiteHosts } or null + */ +export async function getWikidataEntity(entityId, log) { + log.info(`Fetching Wikidata entity: ${entityId}`); + + try { + const params = new URLSearchParams({ + action: 'wbgetentities', + ids: entityId, + props: 'labels|aliases|sitelinks|claims', + languages: 'en', + sitefilter: 'enwiki', + format: 'json', + }); + + const url = `${WIKIDATA_API}?${params}`; + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!resp.ok) { + throw new Error(`Wikidata entity fetch failed: ${resp.status}`); + } + + const data = await resp.json(); + const entity = data.entities?.[entityId]; + if (!entity) { + log.info(`No Wikidata entity data for: ${entityId}`); + return null; + } + + const label = entity.labels?.en?.value || null; + const aliases = (entity.aliases?.en || []).map((a) => a.value).filter(Boolean); + const enwikiTitle = entity.sitelinks?.enwiki?.title || null; + + const officialWebsiteHosts = (entity.claims?.P856 || []) + .map((claim) => claim?.mainsnak?.datavalue?.value) + .filter(Boolean) + .map((websiteUrl) => { + try { + return new URL(websiteUrl).hostname; + } catch { + return null; + } + }) + .filter(Boolean); + + return { + id: entityId, label, aliases, enwikiTitle, officialWebsiteHosts, + }; + } catch (e) { + log.error(`Error fetching Wikidata entity ${entityId}: ${e.message}`); + return null; + } +} + +/** + * Normalize a company name for weak (label) comparison: lower-case, drop corporate + * suffixes and punctuation, collapse whitespace. + * @param {string} value - Raw name + * @returns {string} Normalized name + */ +function normalizeName(value) { + return String(value || '') + .toLowerCase() + .replace(/&/g, ' and ') + .replace(CORP_SUFFIXES, ' ') + .replace(/[^a-z0-9\s]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +/** + * Validate a Wikidata entity against the customer's site. + * + * - Strong (`p856`): any official-website host's registrable domain equals the + * site's registrable domain. Decisive signal (DHL->dhl.com, DNP->dnp.co.jp). + * - Weak (`label`): entity label/alias token-overlap with the brand name. + * - Low-confidence brand names accept ONLY `p856` (never the weak label match). + * + * @param {object} params - Parameters + * @param {object} params.entity - Entity from {@link getWikidataEntity} + * @param {string} params.brandName - Resolved brand name + * @param {string} params.brandConfidence - 'high' | 'medium' | 'low' + * @param {string} params.registrableDomain - Site registrable domain + * @returns {{ok: boolean, method: (string|null), reason: string}} + */ +export function validateEntityAgainstSite({ + entity, brandName, brandConfidence, registrableDomain, +}) { + if (!entity) { + return { ok: false, method: null, reason: 'no_entity' }; + } + + // Strong P856 match: entity's own official website registrable domain == site's. + const hosts = entity.officialWebsiteHosts || []; + for (const host of hosts) { + const { registrableDomain: entityRegDomain } = splitHost(host); + if (entityRegDomain && registrableDomain && entityRegDomain === registrableDomain) { + return { ok: true, method: 'p856', reason: `P856 host ${host} matches site ${registrableDomain}` }; + } + } + + // Low-confidence acronyms may proceed only via P856 (already checked above). + if (brandConfidence === 'low') { + return { ok: false, method: null, reason: 'low_confidence_requires_p856' }; + } + + // Weak label/alias token-overlap match. + const brandTokens = new Set(normalizeName(brandName).split(' ').filter(Boolean)); + if (brandTokens.size > 0) { + const candidates = [entity.label, ...(entity.aliases || [])].filter(Boolean); + for (const candidate of candidates) { + const candTokens = normalizeName(candidate).split(' ').filter(Boolean); + if (candTokens.length > 0) { + const overlap = candTokens.filter((t) => brandTokens.has(t)).length; + const ratio = overlap / Math.max(brandTokens.size, candTokens.length); + if (ratio >= 0.5) { + return { ok: true, method: 'label', reason: `label match "${candidate}"` }; + } + } + } + } + + return { ok: false, method: null, reason: 'no_match' }; +} + +/** + * Search Wikidata for candidate entity IDs by name (keeps ALL candidates). + * @param {string} brandName - Brand name to search for + * @param {object} log - Logger instance + * @returns {Promise<string[]>} Candidate entity IDs (order preserved) + */ +async function searchWikidataCandidates(brandName, log) { + try { + const params = new URLSearchParams({ + action: 'wbsearchentities', + search: brandName, + language: 'en', + limit: '5', + format: 'json', + }); + + const url = `${WIKIDATA_API}?${params}`; + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!resp.ok) { + throw new Error(`Wikidata search failed: ${resp.status}`); + } + + const data = await resp.json(); + return (data.search || []).map((e) => e.id).filter(Boolean); + } catch (e) { + log.error(`Error searching Wikidata candidates: ${e.message}`); + return []; + } +} + +/** + * Find the first Wikidata entity that VALIDATES against the site. + * Prefers a strong P856 match; falls back to the first weak label match + * (only for non-low-confidence brand names). Returns null if nothing validates. + * + * @param {object} params - { brandName, brandConfidence, registrableDomain } + * @param {object} log - Logger instance + * @returns {Promise<object|null>} Entity (+ `validation` method) or null + */ +export async function findValidatedWikidataEntity({ + brandName, brandConfidence, registrableDomain, +}, log) { + const candidateIds = await searchWikidataCandidates(brandName, log); + if (candidateIds.length === 0) { + log.info(`No Wikidata candidates for: ${brandName}`); + return null; + } + + let labelMatch = null; + for (const id of candidateIds) { + // eslint-disable-next-line no-await-in-loop + const entity = await getWikidataEntity(id, log); + const validation = entity + ? validateEntityAgainstSite({ + entity, brandName, brandConfidence, registrableDomain, + }) + : { ok: false, method: null, reason: 'entity_fetch_failed' }; + + if (validation.ok && validation.method === 'p856') { + log.info(`Validated Wikidata entity ${id} for "${brandName}" via P856`); + return { ...entity, validation: 'p856' }; + } + if (validation.ok && validation.method === 'label' && !labelMatch) { + labelMatch = { ...entity, validation: 'label' }; + } else { + log.info(`Rejected Wikidata candidate ${id} for "${brandName}": ${validation.reason}`); + } + } + + if (labelMatch) { + log.info(`Using label-validated Wikidata entity ${labelMatch.id} for "${brandName}"`); + } + return labelMatch; +} + +/** + * Fetch a Wikipedia extract for an EXACT enwiki title (no opensearch, no by-name + * search). This is the entity-bound replacement for {@link fetchWikipediaFullText}. + * @param {string} title - Exact enwiki article title (from a validated entity sitelink) + * @param {number} [maxChars=12000] - Maximum characters to return + * @param {object} log - Logger instance + * @returns {Promise<string|null>} Article extract or null + */ +export async function fetchWikipediaExtractByTitle(title, maxChars, log) { + const limit = maxChars || 12000; + if (!title) { + return null; + } + log.info(`Fetching Wikipedia extract for exact title "${title}" (max ${limit} chars)`); + + try { + const params = new URLSearchParams({ + action: 'query', + titles: title, + prop: 'extracts', + explaintext: 'true', + format: 'json', + }); + + const url = `${WIKIPEDIA_API_BASE}?${params}`; + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!resp.ok) { + throw new Error(`Wikipedia extract fetch failed: ${resp.status}`); + } + + const data = await resp.json(); + const pages = data.query?.pages || {}; + const pageId = Object.keys(pages)[0]; + + if (!pageId || pageId === '-1') { + return null; + } + + const extract = pages[pageId].extract || ''; + return extract.slice(0, limit); + } catch (e) { + log.error(`Error fetching Wikipedia extract by title: ${e.message}`); + return null; + } +} + +/** + * Fetch a validated intro summary: resolve+validate the entity, then fetch the + * intro extract for that entity's EXACT enwiki title. Returns null when nothing + * validates or the entity has no English Wikipedia article. + * @param {object} params - { brandName, brandConfidence, registrableDomain } + * @param {object} log - Logger instance + * @returns {Promise<object|null>} { title, summary, entityId } or null + */ +export async function fetchValidatedSummary({ + brandName, brandConfidence, registrableDomain, +}, log) { + const entity = await findValidatedWikidataEntity({ + brandName, brandConfidence, registrableDomain, + }, log); + + if (!entity || !entity.enwikiTitle) { + return null; + } + + try { + const params = new URLSearchParams({ + action: 'query', + titles: entity.enwikiTitle, + prop: 'extracts', + exintro: 'true', + explaintext: 'true', + format: 'json', + }); + + const url = `${WIKIPEDIA_API_BASE}?${params}`; + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!resp.ok) { + throw new Error(`Wikipedia validated summary fetch failed: ${resp.status}`); + } + + const data = await resp.json(); + const pages = data.query?.pages || {}; + const pageId = Object.keys(pages)[0]; + + if (!pageId || pageId === '-1') { + return null; + } + + return { + title: entity.enwikiTitle, + summary: pages[pageId].extract || '', + entityId: entity.id, + }; + } catch (e) { + log.error(`Error fetching validated summary: ${e.message}`); + return null; + } +} + /** * Create a Wikipedia service instance. * @param {object} log - Logger instance @@ -251,5 +577,9 @@ export function createWikipediaService(log) { fetchSummary: (searchQuery) => fetchWikipediaSummary(searchQuery, log), fetchFullText: (searchQuery, maxChars) => fetchWikipediaFullText(searchQuery, maxChars, log), findWikidataId: (brandName) => findWikidataId(brandName, log), + getWikidataEntity: (entityId) => getWikidataEntity(entityId, log), + findValidatedWikidataEntity: (params) => findValidatedWikidataEntity(params, log), + fetchExtractByTitle: (title, maxChars) => fetchWikipediaExtractByTitle(title, maxChars, log), + fetchValidatedSummary: (params) => fetchValidatedSummary(params, log), }; } diff --git a/test/agents/brand-profile/index.test.js b/test/agents/brand-profile/index.test.js index af6dc84a..a8c6e56d 100644 --- a/test/agents/brand-profile/index.test.js +++ b/test/agents/brand-profile/index.test.js @@ -19,6 +19,8 @@ import esmock from 'esmock'; use(sinonChai); use(chaiAsPromised); +const RESOLVER_PATH = '../../../src/agents/brand-profile/services/brand-resolver.js'; + describe('agents/brand-profile', () => { let sandbox; let context; @@ -26,7 +28,7 @@ describe('agents/brand-profile', () => { let log; // Mock service creators - paths relative to src/agents/brand-profile/index.js - const createMockServices = (sb) => ({ + const createMockServices = (sb, resolverOverride = {}) => ({ '../../../src/agents/brand-profile/services/regional-context.js': { createRegionalContextService: () => ({ inferRegionFromUrl: sb.stub().resolves({ @@ -84,6 +86,17 @@ describe('agents/brand-profile', () => { createWikipediaService: () => ({ fetchSummary: sb.stub().resolves(null), fetchFullText: sb.stub().resolves(null), + fetchValidatedSummary: sb.stub().resolves(null), + }), + }, + [RESOLVER_PATH]: { + resolveBrandName: sb.stub().resolves({ + name: 'MockBrand', + confidence: 'medium', + source: 'apex_domain', + siteHost: 'example.com', + registrableDomain: 'example.com', + ...resolverOverride, }), }, }); @@ -156,12 +169,8 @@ describe('agents/brand-profile', () => { choices: [{ message: { content: JSON.stringify({ - main_profile: { - target_audience: 'Consumers', - }, - competitive_context: { - industry: 'Technology', - }, + main_profile: { target_audience: 'Consumers' }, + competitive_context: { industry: 'Technology' }, }), }, }], @@ -213,10 +222,17 @@ describe('agents/brand-profile', () => { }; const mockWikipediaService = { - fetchSummary: sandbox.stub().resolves({ summary: 'Company summary' }), - fetchFullText: sandbox.stub().resolves('Full text'), + fetchValidatedSummary: sandbox.stub().resolves({ summary: 'Company summary' }), }; + const resolveBrandName = sandbox.stub().resolves({ + name: 'Swisslife', + confidence: 'high', + source: 'site_title', + siteHost: 'swisslife.ch', + registrableDomain: 'swisslife.ch', + }); + const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -240,22 +256,37 @@ describe('agents/brand-profile', () => { '../../../src/agents/brand-profile/services/wikipedia.js': { createWikipediaService: () => mockWikipediaService, }, + [RESOLVER_PATH]: { resolveBrandName }, }); const result = await mod.default.run( { baseURL: 'https://swisslife.ch', params: { enhance: true } }, - env, + { BRAND_PROFILE_ENABLE_WIKI_PRODUCTS: 'true' }, log, ); - // Verify all services were called + expect(resolveBrandName).to.have.been.called; expect(mockRegionalService.inferRegionFromUrl).to.have.been.called; expect(mockRegionalService.inferRegionalContext).to.have.been.called; expect(mockCompetitorService.inferCompetitors).to.have.been.called; expect(mockPersonaService.inferPersonas).to.have.been.called; expect(mockProductService.extractProducts).to.have.been.called; - // Verify result includes enhanced data + // Competitor path used the VALIDATED summary (entity-bound), not a by-name lookup. + expect(mockWikipediaService.fetchValidatedSummary).to.have.been.calledWithExactly({ + brandName: 'Swisslife', + brandConfidence: 'high', + registrableDomain: 'swisslife.ch', + }); + + // Product path forwarded the options object with the resolved identity + flag. + expect(mockProductService.extractProducts).to.have.been.calledWithExactly({ + brandName: 'Swisslife', + brandConfidence: 'high', + registrableDomain: 'swisslife.ch', + enableWikiProducts: true, + }); + expect(result.country_code).to.equal('CH'); expect(result.languages).to.deep.equal(['de-CH', 'fr-CH']); expect(result.currency).to.equal('CHF'); @@ -265,12 +296,12 @@ describe('agents/brand-profile', () => { expect(result.products.items).to.have.length(1); }); - it('run() uses sitemapUrl when provided for product extraction', async () => { + it('run() does NOT fetch a validated summary when the kill-switch is off (default)', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { content: JSON.stringify({ - main_profile: { brand_name: 'TestBrand' }, + main_profile: {}, competitive_context: { industry: 'Tech' }, }), }, @@ -278,19 +309,8 @@ describe('agents/brand-profile', () => { }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); - const mockProductService = { - extractFromSitemap: sandbox.stub().resolves({ - products: [{ name: 'SitemapProduct' }], - services: [], - sub_brands: [], - discontinued: [], - metadata: { source: 'sitemap', count: 1 }, - }), - extractProducts: sandbox.stub().resolves({ - products: [], - metadata: {}, - }), - }; + const fetchValidatedSummary = sandbox.stub().resolves({ summary: 'should not be used' }); + const extractProducts = sandbox.stub().resolves({ products: [], metadata: {} }); const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { @@ -300,68 +320,41 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - '../../../src/agents/brand-profile/services/regional-context.js': { - createRegionalContextService: () => ({ - inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), - inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), - }), - }, - '../../../src/agents/brand-profile/services/competitor-inference.js': { - createCompetitorInferenceService: () => ({ - inferCompetitors: sandbox.stub().resolves({ competitors: [] }), - }), - }, - '../../../src/agents/brand-profile/services/persona-inference.js': { - createPersonaInferenceService: () => ({ - inferPersonas: sandbox.stub().resolves({ personas: [] }), - }), - }, + ...createMockServices(sandbox), '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => mockProductService, + createProductExtractorService: () => ({ extractProducts }), }, '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ - fetchSummary: sandbox.stub().resolves(null), - fetchFullText: sandbox.stub().resolves(null), - }), + createWikipediaService: () => ({ fetchValidatedSummary }), }, }); - const result = await mod.default.run( - { - baseURL: 'https://example.com', - params: { - enhance: true, - sitemapUrl: 'https://example.com/sitemap.xml', - }, - }, + await mod.default.run( + { baseURL: 'https://example.com', params: { enhance: true } }, env, log, ); - // extractFromSitemap should be called instead of extractProducts - expect(mockProductService.extractFromSitemap).to.have.been.calledWith( - 'https://example.com/sitemap.xml', - 'TestBrand', + expect(fetchValidatedSummary).to.not.have.been.called; + // extractProducts still runs, but with the flag off. + expect(extractProducts).to.have.been.calledWithExactly( + sinon.match({ enableWikiProducts: false }), ); - expect(mockProductService.extractProducts).to.not.have.been.called; - expect(result.products.items).to.have.length(1); - expect(result.products.items[0].name).to.equal('SitemapProduct'); }); - it('run() extracts brand name from competitive_context when main_profile missing', async () => { + it('run() tolerates a null validated summary (flag on) and infers with an empty overview', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { - content: JSON.stringify({ - main_profile: {}, - competitive_context: { brand_name: 'ContextBrand', industry: 'Tech' }, - }), + content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), }, }], }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); + const fetchValidatedSummary = sandbox.stub().resolves(null); + const inferCompetitors = sandbox.stub().resolves({ competitors: [], source: 'llm_inferred' }); + const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -370,51 +363,31 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - '../../../src/agents/brand-profile/services/regional-context.js': { - createRegionalContextService: () => ({ - inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), - inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), - }), - }, + ...createMockServices(sandbox, { name: 'Amrize', confidence: 'high', registrableDomain: 'amrize.com' }), '../../../src/agents/brand-profile/services/competitor-inference.js': { - createCompetitorInferenceService: () => ({ - inferCompetitors: sandbox.stub().resolves({ competitors: [] }), - }), - }, - '../../../src/agents/brand-profile/services/persona-inference.js': { - createPersonaInferenceService: () => ({ - inferPersonas: sandbox.stub().resolves({ personas: [] }), - }), - }, - '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => ({ - extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), - }), + createCompetitorInferenceService: () => ({ inferCompetitors }), }, '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ - fetchSummary: sandbox.stub().resolves(null), - fetchFullText: sandbox.stub().resolves(null), - }), + createWikipediaService: () => ({ fetchValidatedSummary }), }, }); await mod.default.run( - { baseURL: 'https://example.com', params: { enhance: true } }, - env, + { baseURL: 'https://amrize.com', params: { enhance: true } }, + { BRAND_PROFILE_ENABLE_WIKI_PRODUCTS: 'true' }, log, ); - // The log should show "ContextBrand" as the extracted brand name - expect(log.info).to.have.been.calledWithMatch('ContextBrand'); + expect(fetchValidatedSummary).to.have.been.called; + expect(inferCompetitors).to.have.been.calledWithExactly(sinon.match({ wikipediaSummary: '' })); }); - it('run() falls back to domain name when no brand name in profile', async () => { + it('run() uses sitemapUrl when provided for product extraction', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { content: JSON.stringify({ - main_profile: {}, + main_profile: { brand_name: 'TestBrand' }, competitive_context: { industry: 'Tech' }, }), }, @@ -422,6 +395,17 @@ describe('agents/brand-profile', () => { }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); + const mockProductService = { + extractFromSitemap: sandbox.stub().resolves({ + products: [{ name: 'SitemapProduct' }], + services: [], + sub_brands: [], + discontinued: [], + metadata: { source: 'sitemap', count: 1 }, + }), + extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), + }; + const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -430,33 +414,52 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - '../../../src/agents/brand-profile/services/regional-context.js': { - createRegionalContextService: () => ({ - inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), - inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), - }), - }, - '../../../src/agents/brand-profile/services/competitor-inference.js': { - createCompetitorInferenceService: () => ({ - inferCompetitors: sandbox.stub().resolves({ competitors: [] }), - }), + ...createMockServices(sandbox, { name: 'TestBrand', confidence: 'high' }), + '../../../src/agents/brand-profile/services/product-extractor.js': { + createProductExtractorService: () => mockProductService, }, - '../../../src/agents/brand-profile/services/persona-inference.js': { - createPersonaInferenceService: () => ({ - inferPersonas: sandbox.stub().resolves({ personas: [] }), - }), + }); + + const result = await mod.default.run( + { + baseURL: 'https://example.com', + params: { + enhance: true, + sitemapUrl: 'https://example.com/sitemap.xml', + }, }, - '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => ({ - extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), - }), + env, + log, + ); + + expect(mockProductService.extractFromSitemap).to.have.been.calledWith( + 'https://example.com/sitemap.xml', + 'TestBrand', + ); + expect(mockProductService.extractProducts).to.not.have.been.called; + expect(result.products.items).to.have.length(1); + expect(result.products.items[0].name).to.equal('SitemapProduct'); + }); + + it('run() logs the resolved brand name (domain-derived)', async () => { + const fetchChatCompletion = sandbox.stub().resolves({ + choices: [{ + message: { + content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), + }, + }], + }); + const createFrom = sandbox.stub().returns({ fetchChatCompletion }); + + const mod = await esmock('../../../src/agents/brand-profile/index.js', { + '@adobe/spacecat-shared-gpt-client': { + AzureOpenAIClient: { createFrom }, }, - '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ - fetchSummary: sandbox.stub().resolves(null), - fetchFullText: sandbox.stub().resolves(null), - }), + '../../../src/agents/base.js': { + readPromptFile: sandbox.stub().returns('PROMPT'), + renderTemplate: sandbox.stub().returns('RENDERED'), }, + ...createMockServices(sandbox, { name: 'Testcompany', confidence: 'medium', registrableDomain: 'testcompany.com' }), }); await mod.default.run( @@ -465,18 +468,14 @@ describe('agents/brand-profile', () => { log, ); - // Should extract "Testcompany" from the domain expect(log.info).to.have.been.calledWithMatch('Testcompany'); }); - it('run() uses "Unknown Brand" when URL has only short domain parts', async () => { + it('run() logs the "Unknown Brand" sentinel from the resolver', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { - content: JSON.stringify({ - main_profile: {}, - competitive_context: { industry: 'Tech' }, - }), + content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), }, }], }); @@ -490,33 +489,7 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - '../../../src/agents/brand-profile/services/regional-context.js': { - createRegionalContextService: () => ({ - inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), - inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), - }), - }, - '../../../src/agents/brand-profile/services/competitor-inference.js': { - createCompetitorInferenceService: () => ({ - inferCompetitors: sandbox.stub().resolves({ competitors: [] }), - }), - }, - '../../../src/agents/brand-profile/services/persona-inference.js': { - createPersonaInferenceService: () => ({ - inferPersonas: sandbox.stub().resolves({ personas: [] }), - }), - }, - '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => ({ - extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), - }), - }, - '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ - fetchSummary: sandbox.stub().resolves(null), - fetchFullText: sandbox.stub().resolves(null), - }), - }, + ...createMockServices(sandbox, { name: 'Unknown Brand', confidence: 'low', source: 'none' }), }); await mod.default.run( @@ -525,7 +498,6 @@ describe('agents/brand-profile', () => { log, ); - // Should use "Unknown Brand" since all domain parts are short expect(log.info).to.have.been.calledWithMatch('Unknown Brand'); }); @@ -554,31 +526,10 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - '../../../src/agents/brand-profile/services/regional-context.js': { - createRegionalContextService: () => ({ - inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), - inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), - }), - }, + ...createMockServices(sandbox), '../../../src/agents/brand-profile/services/competitor-inference.js': { createCompetitorInferenceService: () => mockCompetitorService, }, - '../../../src/agents/brand-profile/services/persona-inference.js': { - createPersonaInferenceService: () => ({ - inferPersonas: sandbox.stub().resolves({ personas: [] }), - }), - }, - '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => ({ - extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), - }), - }, - '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ - fetchSummary: sandbox.stub().resolves(null), - fetchFullText: sandbox.stub().resolves(null), - }), - }, }); const result = await mod.default.run( @@ -593,7 +544,6 @@ describe('agents/brand-profile', () => { log, ); - // inferCompetitors should NOT be called when LLMO competitors provided expect(mockCompetitorService.inferCompetitors).to.not.have.been.called; expect(result.competitors_source).to.equal('llmo'); expect(result.competitors).to.have.length(2); @@ -733,7 +683,7 @@ describe('agents/brand-profile', () => { const profile = { contentHash: 'same', version: 5 }; const cfg = { getBrandProfile: () => profile, - updateBrandProfile: sinon.stub(), // leaves hash unchanged + updateBrandProfile: sinon.stub(), }; const setConfig = sinon.stub(); const save = sinon.stub().resolves(); @@ -771,7 +721,6 @@ describe('agents/brand-profile', () => { it('persist() handles configs without getBrandProfile implementation', async () => { const cfg = { updateBrandProfile: sinon.stub(), - // getBrandProfile intentionally undefined to hit fallback branches }; const setConfig = sinon.stub(); const save = sinon.stub().resolves(); @@ -805,6 +754,104 @@ describe('agents/brand-profile', () => { ); }); + it('persist() preserves a manual-curated product catalogue (LLMO-6580 guard)', async () => { + const before = { + contentHash: 'old', + version: 3, + products: { items: [{ name: 'HandCurated' }] }, + products_metadata: { source: 'manual-curated', count: 1 }, + }; + let received; + let currentProfile = before; + const cfg = { + getBrandProfile: () => currentProfile, + updateBrandProfile: (p) => { + received = p; + currentProfile = { ...p, contentHash: 'new', version: 4 }; + }, + }; + const setConfig = sinon.stub(); + const save = sinon.stub().resolves(); + const findById = sandbox.stub().resolves({ + getConfig: () => cfg, + setConfig, + save, + getBaseURL: () => 'https://curated.com', + }); + context.dataAccess.Site = { findById }; + + const toDynamoItem = sandbox.stub().callsFake((c) => c); + const mod = await esmock('../../../src/agents/brand-profile/index.js', { + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem }, + }, + }); + + await mod.default.persist( + { siteId: '123e4567-e89b-12d3-a456-426614174000' }, + context, + { + main_profile: { communication_style: 'new voice' }, + products: { items: [{ name: 'FabricatedProduct' }] }, + products_metadata: { source: 'wikipedia_llm', count: 1 }, + }, + ); + + // Non-product fields update, but the curated products/metadata are preserved. + expect(received.main_profile.communication_style).to.equal('new voice'); + expect(received.products).to.deep.equal(before.products); + expect(received.products_metadata).to.deep.equal(before.products_metadata); + expect(log.info).to.have.been.calledWithMatch('preserving manual-curated products'); + }); + + it('persist() overwrites products when the stored source is NOT manual-curated', async () => { + const before = { + contentHash: 'old', + version: 3, + products: { items: [{ name: 'OldFabricated' }] }, + products_metadata: { source: 'wikipedia_llm', count: 1 }, + }; + let received; + let currentProfile = before; + const cfg = { + getBrandProfile: () => currentProfile, + updateBrandProfile: (p) => { + received = p; + currentProfile = { ...p, contentHash: 'new', version: 4 }; + }, + }; + const setConfig = sinon.stub(); + const save = sinon.stub().resolves(); + const findById = sandbox.stub().resolves({ + getConfig: () => cfg, + setConfig, + save, + getBaseURL: () => 'https://example.com', + }); + context.dataAccess.Site = { findById }; + + const toDynamoItem = sandbox.stub().callsFake((c) => c); + const mod = await esmock('../../../src/agents/brand-profile/index.js', { + '@adobe/spacecat-shared-data-access/src/models/site/config.js': { + Config: { toDynamoItem }, + }, + }); + + const result = { + products: { items: [] }, + products_metadata: { source: 'none_no_validated_entity', count: 0 }, + }; + await mod.default.persist( + { siteId: '123e4567-e89b-12d3-a456-426614174000' }, + context, + result, + ); + + expect(received.products_metadata.source).to.equal('none_no_validated_entity'); + expect(received.products).to.deep.equal(result.products); + expect(log.info).to.not.have.been.calledWithMatch('preserving manual-curated products'); + }); + it('persist() includes highlight blocks when main profile data is present', async () => { let currentProfile = { version: 1, contentHash: 'old' }; const cfg = { @@ -828,7 +875,6 @@ describe('agents/brand-profile', () => { '@adobe/spacecat-shared-data-access/src/models/site/config.js': { Config: { toDynamoItem }, }, - ...createMockServices(sandbox), }); const result = await mod.default.persist( @@ -875,7 +921,6 @@ describe('agents/brand-profile', () => { '@adobe/spacecat-shared-data-access/src/models/site/config.js': { Config: { toDynamoItem }, }, - ...createMockServices(sandbox), }); const result = await mod.default.persist( diff --git a/test/agents/brand-profile/services/brand-resolver.test.js b/test/agents/brand-profile/services/brand-resolver.test.js new file mode 100644 index 00000000..33597237 --- /dev/null +++ b/test/agents/brand-profile/services/brand-resolver.test.js @@ -0,0 +1,265 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import { expect, use } from 'chai'; +import chaiAsPromised from 'chai-as-promised'; +import sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { + splitHost, + isLowConfidenceLabel, + fetchSiteName, + resolveBrandName, +} from '../../../../src/agents/brand-profile/services/brand-resolver.js'; + +use(sinonChai); +use(chaiAsPromised); + +const htmlResponse = (html) => ({ + ok: true, + headers: { get: () => 'text/html; charset=utf-8' }, + text: () => Promise.resolve(html), +}); + +describe('services/brand-resolver', () => { + let sandbox; + let log; + let fetchStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + log = { + debug: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + error: sandbox.stub(), + }; + fetchStub = sandbox.stub(globalThis, 'fetch'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('splitHost', () => { + it('strips a subdomain to the apex label', () => { + expect(splitHost('dev.amrize.com')).to.deep.equal({ + subdomainLabels: ['dev'], + apexLabel: 'amrize', + registrableDomain: 'amrize.com', + }); + }); + + it('handles multi-part ccTLDs (co.jp)', () => { + expect(splitHost('dnp.co.jp')).to.deep.equal({ + subdomainLabels: [], + apexLabel: 'dnp', + registrableDomain: 'dnp.co.jp', + }); + }); + + it('handles multi-part ccTLDs with a subdomain (gov.sg)', () => { + expect(splitHost('www.edb.gov.sg')).to.deep.equal({ + subdomainLabels: ['www'], + apexLabel: 'edb', + registrableDomain: 'edb.gov.sg', + }); + }); + + it('strips a section subdomain (store)', () => { + const { apexLabel } = splitHost('store.example.com'); + expect(apexLabel).to.equal('example'); + }); + + it('handles a plain apex domain', () => { + expect(splitHost('www.ab.co')).to.deep.equal({ + subdomainLabels: ['www'], + apexLabel: 'ab', + registrableDomain: 'ab.co', + }); + }); + + it('handles a single-label host', () => { + expect(splitHost('localhost')).to.deep.equal({ + subdomainLabels: [], + apexLabel: 'localhost', + registrableDomain: 'localhost', + }); + }); + + it('handles an empty host', () => { + expect(splitHost('')).to.deep.equal({ + subdomainLabels: [], + apexLabel: '', + registrableDomain: '', + }); + }); + + it('lowercases and trims a trailing dot', () => { + expect(splitHost('Amrize.COM.')).to.deep.equal({ + subdomainLabels: [], + apexLabel: 'amrize', + registrableDomain: 'amrize.com', + }); + }); + }); + + describe('isLowConfidenceLabel', () => { + it('flags short acronyms', () => { + expect(isLowConfidenceLabel('dnp')).to.equal(true); + expect(isLowConfidenceLabel('edb')).to.equal(true); + expect(isLowConfidenceLabel('dnb')).to.equal(true); + expect(isLowConfidenceLabel('IBM')).to.equal(true); // short: relies on P856 downstream + }); + + it('flags stop labels', () => { + expect(isLowConfidenceLabel('dev')).to.equal(true); + expect(isLowConfidenceLabel('www')).to.equal(true); + expect(isLowConfidenceLabel('store')).to.equal(true); + }); + + it('accepts real multi-character brand tokens', () => { + expect(isLowConfidenceLabel('amrize')).to.equal(false); + expect(isLowConfidenceLabel('testcompany')).to.equal(false); + }); + + it('flags empty/nullish labels', () => { + expect(isLowConfidenceLabel('')).to.equal(true); + expect(isLowConfidenceLabel(null)).to.equal(true); + }); + }); + + describe('fetchSiteName', () => { + it('returns og:site_name when present', async () => { + fetchStub.resolves(htmlResponse('<html><head><meta property="og:site_name" content="Amrize"></head></html>')); + const result = await fetchSiteName('https://amrize.com', log); + expect(result).to.equal('Amrize'); + }); + + it('falls back to a cleaned <title>', async () => { + fetchStub.resolves(htmlResponse('<html><head><title>Acme Corporation | Home')); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.equal('Acme Corporation'); + }); + + it('falls back to the first segment when every title segment is generic', async () => { + fetchStub.resolves(htmlResponse('')); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.equal('Home'); + }); + + it('returns null when neither og:site_name nor title present', async () => { + fetchStub.resolves(htmlResponse('hi')); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.be.null; + }); + + it('returns null for non-HTML content types', async () => { + fetchStub.resolves({ + ok: true, + headers: { get: () => 'application/pdf' }, + text: () => Promise.resolve('%PDF-1.4'), + }); + const result = await fetchSiteName('https://acme.com/file.pdf', log); + expect(result).to.be.null; + }); + + it('returns null when the response is not ok', async () => { + fetchStub.resolves({ ok: false, status: 403, headers: { get: () => 'text/html' } }); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.be.null; + }); + + it('returns null on network error (never throws)', async () => { + fetchStub.rejects(new Error('ECONNRESET')); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.be.null; + expect(log.info).to.have.been.calledWithMatch('homepage fetch failed'); + }); + + it('tolerates a response without a headers object', async () => { + fetchStub.resolves({ + ok: true, + text: () => Promise.resolve(''), + }); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.equal('NoHeaders'); + }); + + it('tolerates a headers object without a get method', async () => { + fetchStub.resolves({ + ok: true, + headers: {}, + text: () => Promise.resolve('HasHeadersNoGet'), + }); + const result = await fetchSiteName('https://acme.com', log); + expect(result).to.equal('HasHeadersNoGet'); + }); + }); + + describe('resolveBrandName', () => { + it('uses main_profile.brand_name (high confidence, no fetch)', async () => { + const result = await resolveBrandName( + { main_profile: { brand_name: 'Adobe' } }, + 'https://adobe.com', + log, + ); + expect(result).to.include({ + name: 'Adobe', confidence: 'high', source: 'base_profile', registrableDomain: 'adobe.com', + }); + expect(fetchStub).to.not.have.been.called; + }); + + it('uses competitive_context.brand_name when main_profile missing', async () => { + const result = await resolveBrandName( + { main_profile: {}, competitive_context: { brand_name: 'ContextBrand' } }, + 'https://example.com', + log, + ); + expect(result).to.include({ name: 'ContextBrand', confidence: 'high', source: 'competitive_context' }); + }); + + it('uses a real site title as high confidence', async () => { + fetchStub.resolves(htmlResponse('')); + const result = await resolveBrandName({ main_profile: {} }, 'https://dev.amrize.com', log); + expect(result).to.include({ name: 'Amrize', confidence: 'high', source: 'site_title' }); + }); + + it('falls back to the apex label as medium confidence', async () => { + fetchStub.resolves({ ok: false, status: 404, headers: { get: () => 'text/html' } }); + const result = await resolveBrandName({ main_profile: {} }, 'https://testcompany.com', log); + expect(result).to.include({ name: 'Testcompany', confidence: 'medium', source: 'apex_domain' }); + }); + + it('REGRESSION: a bare acronym apex stays LOW confidence, never high', async () => { + fetchStub.rejects(new Error('bot-blocked')); + const result = await resolveBrandName({ main_profile: {} }, 'https://dnp.co.jp', log); + expect(result).to.include({ + name: 'Dnp', confidence: 'low', source: 'apex_acronym', registrableDomain: 'dnp.co.jp', + }); + }); + + it('skips a low-confidence site title and falls through to apex', async () => { + fetchStub.resolves(htmlResponse('ab')); + const result = await resolveBrandName({ main_profile: {} }, 'https://amrize.com', log); + expect(result).to.include({ name: 'Amrize', confidence: 'medium', source: 'apex_domain' }); + }); + + it('returns the Unknown Brand sentinel when the URL cannot be parsed', async () => { + fetchStub.rejects(new Error('bad url')); + const result = await resolveBrandName({ main_profile: {} }, 'not-a-url', log); + expect(result).to.include({ + name: 'Unknown Brand', confidence: 'low', source: 'none', siteHost: '', + }); + }); + }); +}); diff --git a/test/agents/brand-profile/services/product-extractor.test.js b/test/agents/brand-profile/services/product-extractor.test.js index e4d2d695..1511a5ad 100644 --- a/test/agents/brand-profile/services/product-extractor.test.js +++ b/test/agents/brand-profile/services/product-extractor.test.js @@ -25,6 +25,49 @@ import { use(sinonChai); use(chaiAsPromised); +// --- helpers for the entity-bound extractProducts flow (LLMO-6580) ----------- + +const searchResp = (ids) => ({ + ok: true, + json: () => Promise.resolve({ search: ids.map((id) => ({ id })) }), +}); + +const entityResp = (id, { + label, enwikiTitle, hosts = [], aliases = [], +}) => ({ + ok: true, + json: () => Promise.resolve({ + entities: { + [id]: { + labels: label ? { en: { value: label } } : {}, + aliases: { en: aliases.map((value) => ({ value })) }, + sitelinks: enwikiTitle ? { enwiki: { title: enwikiTitle } } : {}, + claims: hosts.length + ? { P856: hosts.map((h) => ({ mainsnak: { datavalue: { value: `https://${h}` } } })) } + : {}, + }, + }, + }), +}); + +const sparqlResp = (bindings) => ({ + ok: true, + json: () => Promise.resolve({ results: { bindings } }), +}); + +const extractResp = (extract) => ({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: { extract } } } }), +}); + +const llmResp = (payload) => ({ + choices: [{ message: { content: JSON.stringify(payload) } }], +}); + +const noOpenSearchIssued = (fetchStub) => fetchStub.getCalls().every( + (c) => !String(c.args[0]).includes('opensearch'), +); + describe('services/product-extractor', () => { let sandbox; let log; @@ -51,7 +94,6 @@ describe('services/product-extractor', () => { describe('extractFromSitemap', () => { it('extracts products from sitemap URLs using LLM', async () => { - // Mock sitemap fetch fetchStub.onFirstCall().resolves({ ok: true, text: () => Promise.resolve(` @@ -62,24 +104,17 @@ describe('services/product-extractor', () => { `), }); - // Mock LLM response - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [ - { name: 'Widget Pro', category: 'Software', variants: [] }, - { name: 'Widget Lite', category: 'Software', variants: [] }, - ], - services: [], - sub_brands: [], - discontinued: [], - confidence: 'high', - notes: 'Extracted from product URLs', - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: 'Widget Pro', category: 'Software', variants: [] }, + { name: 'Widget Lite', category: 'Software', variants: [] }, + ], + services: [], + sub_brands: [], + discontinued: [], + confidence: 'high', + notes: 'Extracted from product URLs', + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -194,18 +229,12 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: ['Widget Pro', 'Widget Lite'], - services: ['Support Service'], - sub_brands: [], - discontinued: ['Old Widget'], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: ['Widget Pro', 'Widget Lite'], + services: ['Support Service'], + sub_brands: [], + discontinued: ['Old Widget'], + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -229,9 +258,7 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [], // Empty choices array - }); + gpt.fetchChatCompletion.resolves({ choices: [] }); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -240,7 +267,6 @@ describe('services/product-extractor', () => { log, ); - // Should use '{}' fallback and return empty arrays expect(result.products).to.deep.equal([]); expect(result.metadata.confidence).to.equal('unknown'); }); @@ -266,7 +292,6 @@ describe('services/product-extractor', () => { log, ); - // Should use '{}' fallback expect(result.products).to.deep.equal([]); }); @@ -280,16 +305,9 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'Widget' }], - // Missing: sub_brands, confidence, notes - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'Widget' }], + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -303,541 +321,475 @@ describe('services/product-extractor', () => { expect(result.metadata.confidence).to.equal('unknown'); expect(result.metadata.notes).to.equal(''); }); - }); - describe('extractProducts', () => { - it('extracts products using Wikidata when available', async () => { - // Mock Wikidata ID search - fetchStub.onFirstCall().resolves({ + it('keeps and flags sensitive own-site content (harm gate backstop)', async () => { + fetchStub.resolves({ ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'American company' }], - }), + text: () => Promise.resolve(` + + https://beretta.com/products/pistols + + `), }); - // Mock SPARQL query - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { - itemLabel: { value: 'Photoshop' }, - item: { value: 'http://wikidata.org/Q34567' }, - typeLabel: { value: 'software' }, - }, - { - itemLabel: { value: 'Illustrator' }, - item: { value: 'http://wikidata.org/Q45678' }, - typeLabel: { value: 'software' }, - }, - { - itemLabel: { value: 'Premiere Pro' }, - item: { value: 'http://wikidata.org/Q56789' }, - typeLabel: { value: 'software' }, - }, - ], - }, - }), - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: '92FS', category: 'Firearm' }, + { name: 'Accessories', category: 'Gear' }, + ], + services: [], + sub_brands: [], + discontinued: [], + })); - const result = await extractProducts('Adobe', null, gpt, log); + const result = await extractFromSitemap( + 'https://beretta.com/sitemap.xml', + 'Beretta', + gpt, + log, + ); - expect(result.products).to.have.length(3); - expect(result.metadata.source).to.equal('wikidata'); + // Own-site provenance: legitimate sensitive content is kept, not dropped. + expect(result.products).to.have.length(2); + expect(result.metadata.sensitive_category).to.equal(true); + expect(result.metadata.safety_filtered).to.be.undefined; }); + }); - it('returns empty when wikidata has no results', async () => { - // Mock Wikidata ID search - no results - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [], - }), - }); - - // LLM will be called for Wikipedia fallback - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [], - services: [], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + describe('extractProducts (entity-bound)', () => { + it('returns validated Wikidata products (happy path, P856 match)', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['www.dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'Express' }, item: { value: 'http://wikidata.org/Q11' }, typeLabel: { value: 'service' } }, + { itemLabel: { value: 'Freight' }, item: { value: 'http://wikidata.org/Q12' }, typeLabel: { value: 'service' } }, + { itemLabel: { value: 'Parcel' }, item: { value: 'http://wikidata.org/Q13' }, typeLabel: { value: 'service' } }, + ])); - const result = await extractProducts('UnknownBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Should not find products from Wikidata - expect(result.metadata.brand_wikidata_id).to.be.null; + expect(result.products).to.have.length(3); + expect(result.metadata.source).to.equal('wikidata'); + expect(result.metadata.brand_wikidata_id).to.equal('Q1'); + expect(result.metadata.validation).to.equal('p856'); + expect(noOpenSearchIssued(fetchStub)).to.equal(true); }); - it('uses Wikipedia fallback when wikidata returns fewer than threshold', async () => { - // Mock Wikidata ID search - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); - - // Mock SPARQL query - returns only 1 product (below threshold of 3) - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { itemLabel: { value: 'Product1' }, item: { value: 'http://wikidata.org/Q1' } }, - ], - }, - }), - }); + it('REGRESSION: d*->D-Company yields NO products and NO by-name opensearch', async () => { + // Search returns a same-initials article that does NOT own dnp.co.jp. + fetchStub.onCall(0).resolves(searchResp(['Q111'])); + fetchStub.onCall(1).resolves(entityResp('Q111', { + label: 'D-Company', enwikiTitle: 'D-Company', hosts: [], + })); - // Mock Wikipedia search for fallback - fetchStub.onCall(2).resolves({ - ok: true, - json: () => Promise.resolve(['Brand', ['Brand Company'], [], []]), - }); + const result = await extractProducts( + { brandName: 'Dnp', brandConfidence: 'low', registrableDomain: 'dnp.co.jp' }, + gpt, + log, + ); - // Mock Wikipedia content fetch - fetchStub.onCall(3).resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { extract: 'Company makes Product2 and Product3.' }, - }, - }, - }), - }); + expect(result.products).to.have.length(0); + expect(result.metadata.source).to.equal('skipped_low_confidence'); + expect(result.metadata.rejected).to.equal(true); + // The decoupled `opensearch "Dnp company"` fetch must never be issued. + expect(noOpenSearchIssued(fetchStub)).to.equal(true); + expect(gpt.fetchChatCompletion).to.not.have.been.called; + }); - // Mock LLM response for Wikipedia extraction - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'Product2' }, { name: 'Product3' }], - services: [], - sub_brands: ['SubBrand1'], - discontinued: [], - }), - }, - }], - }); + it('REGRESSION: e*->E-Company yields NO products and NO by-name opensearch', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q222'])); + fetchStub.onCall(1).resolves(entityResp('Q222', { + label: 'E Company, 506th Infantry Regiment', enwikiTitle: 'E Company', hosts: [], + })); - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'Edb', brandConfidence: 'low', registrableDomain: 'edb.gov.sg' }, + gpt, + log, + ); - expect(result.metadata.source).to.equal('hybrid'); - expect(result.products.length).to.be.greaterThan(1); + expect(result.products).to.have.length(0); + expect(result.metadata.source).to.equal('skipped_low_confidence'); + expect(noOpenSearchIssued(fetchStub)).to.equal(true); }); - it('uses provided wikipediaSummary instead of fetching', async () => { - // Mock Wikidata ID search - no results to trigger fallback - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); - - // Mock LLM response - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'ExtractedProduct' }], - services: [], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + it('returns none_no_validated_entity for a non-low-confidence name with no match', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q9'])); + fetchStub.onCall(1).resolves(entityResp('Q9', { label: 'Totally Different', hosts: ['other.example'] })); const result = await extractProducts( - 'TestBrand', - 'Company makes ExtractedProduct.', + { brandName: 'Amrize', brandConfidence: 'medium', registrableDomain: 'amrize.com' }, gpt, log, ); - expect(result.metadata.source).to.equal('wikipedia_llm'); - expect(result.products).to.have.length(1); + expect(result.products).to.have.length(0); + expect(result.metadata.source).to.equal('none_no_validated_entity'); + expect(result.metadata.rejected).to.equal(true); }); - it('handles SPARQL query failure gracefully', async () => { - // Mock Wikidata ID search - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); + it('uses the validated entity enwiki title (sitelink, not a by-name search) for the fallback', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL Group', hosts: ['www.dhl.com'] })); + // SPARQL below threshold -> triggers entity-bound Wikipedia fallback. + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'Express' }, item: { value: 'http://wikidata.org/Q11' } }, + ])); + fetchStub.onCall(3).resolves(extractResp('DHL Group is a logistics company making Freight and Parcel.')); + + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'Freight' }, { name: 'Parcel' }], + services: [], + sub_brands: ['DHL Express'], + discontinued: [], + })); - // Mock SPARQL query failure - fetchStub.onSecondCall().resolves({ - ok: false, - status: 500, - }); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Mock Wikipedia search for fallback - fetchStub.onCall(2).resolves({ - ok: true, - json: () => Promise.resolve(['Brand', ['Brand'], [], []]), - }); + expect(result.metadata.source).to.equal('hybrid'); + expect(result.metadata.source_wikipedia_title).to.equal('DHL Group'); + // The extract call used the sitelink title, not a by-name search. + const extractUrl = fetchStub.getCall(3).args[0]; + expect(extractUrl).to.include('titles=DHL+Group'); + expect(noOpenSearchIssued(fetchStub)).to.equal(true); + expect(result.products.length).to.be.greaterThan(1); + }); - fetchStub.onCall(3).resolves({ - ok: true, - json: () => Promise.resolve({ - query: { pages: { 123: { extract: 'Company info' } } }, - }), - }); + it('produces a wikipedia_llm result when SPARQL is empty but the entity validates (label)', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Amrize', enwikiTitle: 'Amrize', hosts: [] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('Amrize makes Cement and Aggregates.')); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'FallbackProduct' }], - services: [], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'Cement' }, { name: 'Aggregates' }], + services: [], + sub_brands: [], + discontinued: [], + })); - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'Amrize', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, + gpt, + log, + ); - // Should still return result via fallback - expect(result).to.have.property('products'); + expect(result.metadata.source).to.equal('wikipedia_llm'); + expect(result.metadata.validation).to.equal('label'); + expect(result.products).to.have.length(2); }); - it('handles Wikipedia extraction error gracefully', async () => { - // Mock Wikidata ID search - no results - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); - - // Mock LLM error - gpt.fetchChatCompletion.rejects(new Error('LLM failed')); + it('skips the text fallback when the validated entity has no enwiki article', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: null, hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'Express' }, item: { value: 'http://wikidata.org/Q11' } }, + ])); - const result = await extractProducts('TestBrand', 'Some text', gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Should return empty result without error - expect(result.products).to.have.length(0); + // SPARQL-only result stands; no LLM call because there is no fallback text. + expect(result.products).to.have.length(1); + expect(gpt.fetchChatCompletion).to.not.have.been.called; + expect(result.metadata.source).to.equal('wikidata'); }); - it('handles LLM response with empty choices in Wikipedia extraction', async () => { - // Mock Wikidata ID search - no results to trigger Wikipedia fallback - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); - - // Mock LLM returning empty choices (triggers '{}' fallback) - gpt.fetchChatCompletion.resolves({ - choices: [], - }); - - const result = await extractProducts('TestBrand', 'Some Wikipedia text', gpt, log); + it('is a hard no-op when the wiki-products kill-switch is off', async () => { + const result = await extractProducts( + { + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', enableWikiProducts: false, + }, + gpt, + log, + ); - // Should return empty arrays from the '{}' fallback + expect(result.metadata.source).to.equal('disabled'); expect(result.products).to.have.length(0); - expect(result.services).to.have.length(0); + expect(fetchStub).to.not.have.been.called; }); - it('handles LLM response with null message content in Wikipedia extraction', async () => { - // Mock Wikidata ID search - no results to trigger Wikipedia fallback - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); + it('accepts a provided wikipediaSummary without re-fetching the article', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Amrize', enwikiTitle: 'Amrize', hosts: ['amrize.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([])); - // Mock LLM returning null content (triggers '{}' fallback) - gpt.fetchChatCompletion.resolves({ - choices: [{ message: { content: null } }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'ProvidedProduct' }], + services: [], + sub_brands: [], + discontinued: [], + })); - const result = await extractProducts('TestBrand', 'Some Wikipedia text', gpt, log); + const result = await extractProducts( + { + brandName: 'Amrize', + brandConfidence: 'low', + registrableDomain: 'amrize.com', + wikipediaSummary: 'Amrize makes ProvidedProduct.', + }, + gpt, + log, + ); - // Should return empty arrays from the '{}' fallback - expect(result.products).to.have.length(0); + expect(result.metadata.source).to.equal('wikipedia_llm'); + expect(result.products).to.have.length(1); + // Only search + entity + SPARQL fetches; NO extract-by-title fetch. + expect(fetchStub.callCount).to.equal(3); }); - it('handles LLM response with missing sub_brands in Wikipedia extraction', async () => { - // Mock Wikidata ID search - no results to trigger Wikipedia fallback - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); + it('hard-drops harmful content from a weakly (label) validated entity', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Acme', enwikiTitle: 'Acme', hosts: [] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('Acme is a company.')); - // Mock LLM returning result without sub_brands (triggers '|| []' fallback) - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'Product1' }], - services: [], - // sub_brands is missing - should fallback to [] - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: 'Assault Rifle', category: 'Weapon' }, + { name: 'Notebook', category: 'Stationery' }, + ], + services: [], + sub_brands: ['Terror Cell'], + discontinued: [], + })); - const result = await extractProducts('TestBrand', 'Some Wikipedia text', gpt, log); + const result = await extractProducts( + { brandName: 'Acme', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, + gpt, + log, + ); - expect(result.products).to.have.length(1); + expect(result.metadata.validation).to.equal('label'); + expect(result.metadata.safety_filtered).to.equal(true); + expect(result.products.map((p) => p.name)).to.deep.equal(['Notebook']); expect(result.sub_brands).to.deep.equal([]); }); - it('handles null Wikipedia text in fallback', async () => { - // Mock Wikidata ID search - no results - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); + it('keeps but flags harmful content from a strongly (P856) validated entity', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Beretta', enwikiTitle: 'Beretta', hosts: ['www.beretta.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: '92FS' }, item: { value: 'http://wikidata.org/Q11' }, typeLabel: { value: 'firearm' } }, + { itemLabel: { value: 'M9' }, item: { value: 'http://wikidata.org/Q12' }, typeLabel: { value: 'weapon' } }, + { itemLabel: { value: 'Holster' }, item: { value: 'http://wikidata.org/Q13' }, typeLabel: { value: 'accessory' } }, + ])); - // Mock Wikipedia search - no results - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve(['Brand', [], [], []]), - }); - - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'Beretta', brandConfidence: 'low', registrableDomain: 'beretta.com' }, + gpt, + log, + ); - // Should return empty result - expect(result.products).to.have.length(0); - expect(gpt.fetchChatCompletion).not.to.have.been.called; + expect(result.metadata.validation).to.equal('p856'); + expect(result.metadata.sensitive_category).to.equal(true); + expect(result.metadata.safety_filtered).to.be.undefined; + // Legit defense customer's products are preserved. + expect(result.products).to.have.length(3); }); - it('skips Wikidata IDs that appear as labels', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); + it('merges hybrid results and de-duplicates overlaps', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'Product1' }, item: { value: 'http://wikidata.org/Q11' } }, + ])); + fetchStub.onCall(3).resolves(extractResp('DHL info')); - // SPARQL returns item with Q-ID as label (should be filtered) - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { itemLabel: { value: 'Q99999' }, item: { value: 'http://wikidata.org/Q99999' } }, - { itemLabel: { value: 'ValidProduct' }, item: { value: 'http://wikidata.org/Q1' } }, - { itemLabel: { value: 'ValidProduct' }, item: { value: 'http://wikidata.org/Q2' } }, - { itemLabel: { value: 'Product3' }, item: { value: 'http://wikidata.org/Q3' } }, - ], - }, - }), - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: 'Product1' }, // duplicate + { name: 'Product2' }, + { name: '' }, // filtered + ], + services: [ + { name: 'Service1' }, + { name: '' }, + ], + sub_brands: ['SubBrand1', 'SubBrand1'], + discontinued: [ + { name: 'OldProduct' }, + { name: '' }, + ], + })); - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Should filter out Q99999 and dedupe ValidProduct - expect(result.products.find((p) => p.name === 'Q99999')).to.be.undefined; + expect(result.products.filter((p) => p.name === 'Product1')).to.have.length(1); + expect(result.products.find((p) => p.name === '')).to.be.undefined; + expect(result.services.find((s) => s.name === '')).to.be.undefined; }); - it('truncates long Wikipedia text before LLM extraction', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); + it('handles a SPARQL query failure gracefully via the fallback', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves({ ok: false, status: 500 }); + fetchStub.onCall(3).resolves(extractResp('DHL info')); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [], - services: [], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'FallbackProduct' }], + services: [], + sub_brands: [], + discontinued: [], + })); - const longText = 'A'.repeat(10000); - await extractProducts('TestBrand', longText, gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // LLM should have been called with truncated text - expect(gpt.fetchChatCompletion).to.have.been.called; + expect(result).to.have.property('products'); + expect(result.metadata.source).to.equal('wikipedia_llm'); }); - it('merges results with overlapping products (deduplication)', async () => { - // Mock Wikidata ID search - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); + it('handles a Wikipedia LLM extraction error gracefully', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('DHL info')); - // Mock SPARQL - returns 1 product (below threshold) - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { itemLabel: { value: 'Product1' }, item: { value: 'http://wikidata.org/Q1' } }, - ], - }, - }), - }); + gpt.fetchChatCompletion.rejects(new Error('LLM failed')); - // Mock Wikipedia search for fallback - fetchStub.onCall(2).resolves({ - ok: true, - json: () => Promise.resolve(['Brand', ['Brand'], [], []]), - }); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - fetchStub.onCall(3).resolves({ - ok: true, - json: () => Promise.resolve({ - query: { pages: { 123: { extract: 'Company info' } } }, - }), - }); + expect(result.products).to.have.length(0); + }); - // LLM returns same product + additional ones - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [ - { name: 'Product1' }, // Duplicate - { name: 'Product2' }, - { name: '' }, // Empty name - should be filtered - ], - services: [ - { name: 'Service1' }, - { name: '' }, // Empty name - ], - sub_brands: ['SubBrand1', 'SubBrand1'], // Duplicate - discontinued: [ - { name: 'OldProduct' }, - { name: '' }, // Empty name - ], - }), - }, - }], - }); + it('truncates a long fallback article before the LLM call', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('A'.repeat(9000))); - const result = await extractProducts('TestBrand', null, gpt, log); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'FromLongText' }], + services: [], + sub_brands: [], + discontinued: [], + })); - // Product1 should not be duplicated - const product1Count = result.products.filter((p) => p.name === 'Product1').length; - expect(product1Count).to.equal(1); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Empty names should be filtered - expect(result.products.find((p) => p.name === '')).to.be.undefined; - expect(result.services.find((s) => s.name === '')).to.be.undefined; + expect(gpt.fetchChatCompletion).to.have.been.called; + expect(result.products).to.have.length(1); }); - it('merges results with missing properties in primary', async () => { - // Mock Wikidata ID search - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); + it('handles an empty-choices LLM response in the fallback (\'{}\' fallback)', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('DHL info')); - // Mock SPARQL - returns empty results (below threshold) - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { bindings: [] }, - }), - }); + gpt.fetchChatCompletion.resolves({ choices: [] }); - // Mock Wikipedia search for fallback - fetchStub.onCall(2).resolves({ - ok: true, - json: () => Promise.resolve(['Brand', ['Brand'], [], []]), - }); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - fetchStub.onCall(3).resolves({ - ok: true, - json: () => Promise.resolve({ - query: { pages: { 123: { extract: 'Company info' } } }, - }), - }); + expect(result.products).to.have.length(0); + expect(result.services).to.have.length(0); + }); - // LLM returns products with items that have missing name property - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [ - { category: 'Software' }, // No name - { name: null, category: 'Software' }, // Null name - { name: 'ValidProduct' }, - ], - services: [ - { description: 'Service description' }, // No name - ], - sub_brands: ['Brand1'], - discontinued: [ - { reason: 'obsolete' }, // No name - ], - }), - }, - }], - }); + it('skips Wikidata IDs that appear as labels', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'Q99999' }, item: { value: 'http://wikidata.org/Q99999' } }, + { itemLabel: { value: 'ValidProduct' }, item: { value: 'http://wikidata.org/Q1a' } }, + { itemLabel: { value: 'ValidProduct' }, item: { value: 'http://wikidata.org/Q2a' } }, + { itemLabel: { value: 'Product3' }, item: { value: 'http://wikidata.org/Q3a' } }, + ])); - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); - // Products with missing/null names should be filtered out - expect(result.products).to.have.length(1); - expect(result.products[0].name).to.equal('ValidProduct'); + expect(result.products.find((p) => p.name === 'Q99999')).to.be.undefined; }); it('handles wikidata returning discontinued products', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { + itemLabel: { value: 'CurrentProduct' }, + item: { value: 'http://wikidata.org/Q11' }, + inception: { value: '2020-01-01T00:00:00Z' }, + }, + { + itemLabel: { value: 'OldProduct' }, + item: { value: 'http://wikidata.org/Q12' }, + inception: { value: '1990-01-01T00:00:00Z' }, + discontinued: { value: '2010-01-01T00:00:00Z' }, + }, + { + itemLabel: { value: 'Product3' }, + item: { value: 'http://wikidata.org/Q13' }, + typeLabel: { value: 'software_product' }, + }, + ])); - // SPARQL returns products with discontinuation dates - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { - itemLabel: { value: 'CurrentProduct' }, - item: { value: 'http://wikidata.org/Q1' }, - inception: { value: '2020-01-01T00:00:00Z' }, - }, - { - itemLabel: { value: 'OldProduct' }, - item: { value: 'http://wikidata.org/Q2' }, - inception: { value: '1990-01-01T00:00:00Z' }, - discontinued: { value: '2010-01-01T00:00:00Z' }, - }, - { - itemLabel: { value: 'Product3' }, - item: { value: 'http://wikidata.org/Q3' }, - typeLabel: { value: 'software_product' }, - }, - ], - }, - }), - }); - - const result = await extractProducts('TestBrand', null, gpt, log); + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); expect(result.products).to.have.length(3); const discontinued = result.products.find((p) => p.name === 'OldProduct'); expect(discontinued.status).to.equal('discontinued'); }); + + it('parses inception dates (with and without T, empty, missing)', async () => { + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); + fetchStub.onCall(2).resolves(sparqlResp([ + { itemLabel: { value: 'P1' }, item: { value: 'http://wikidata.org/Q11' }, inception: { value: '1995' } }, + { itemLabel: { value: 'P2' }, item: { value: 'http://wikidata.org/Q12' }, inception: { value: '' } }, + { itemLabel: { value: 'P3' }, item: { value: 'http://wikidata.org/Q13' } }, + { itemLabel: { value: 'P4' }, item: { value: 'http://wikidata.org/Q14' }, inception: { value: '2020-05-15T00:00:00Z' } }, + { itemLabel: { value: 'P5' }, item: { value: 'http://wikidata.org/Q15' }, inception: null }, + ])); + + const result = await extractProducts( + { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + gpt, + log, + ); + + expect(result.products).to.have.length(5); + expect(result.products[0].inception_year).to.equal(1995); + expect(result.products[3].inception_year).to.equal(2020); + expect(result.products[2].inception_year).to.be.null; + }); }); describe('formatProductsForPrompt', () => { @@ -888,14 +840,14 @@ describe('services/product-extractor', () => { }); describe('createProductExtractorService', () => { + const env = { + AZURE_OPENAI_ENDPOINT: 'https://example.openai.azure.com', + AZURE_OPENAI_KEY: 'test-key', + AZURE_API_VERSION: '2023-05-15', + AZURE_COMPLETION_DEPLOYMENT: 'gpt-4', + }; + it('creates service with bound methods', () => { - // Provide required env vars for Azure client - const env = { - AZURE_OPENAI_ENDPOINT: 'https://example.openai.azure.com', - AZURE_OPENAI_KEY: 'test-key', - AZURE_API_VERSION: '2023-05-15', - AZURE_COMPLETION_DEPLOYMENT: 'gpt-4', - }; const service = createProductExtractorService(env, log); expect(service).to.have.property('extractFromSitemap'); @@ -903,46 +855,25 @@ describe('services/product-extractor', () => { expect(service).to.have.property('formatProductsForPrompt'); }); - it('service methods can be called', async () => { - const env = { - AZURE_OPENAI_ENDPOINT: 'https://example.openai.azure.com', - AZURE_OPENAI_KEY: 'test-key', - AZURE_API_VERSION: '2023-05-15', - AZURE_COMPLETION_DEPLOYMENT: 'gpt-4', - }; - - // Mock fetch for sitemap + it('extractFromSitemap service method can be called', async () => { fetchStub.resolves({ ok: true, text: () => Promise.resolve(''), }); const service = createProductExtractorService(env, log); - - // Call extractFromSitemap through service const result = await service.extractFromSitemap('https://example.com/sitemap.xml', 'Test'); expect(result).to.have.property('metadata'); }); - it('extractProducts service method can be called', async () => { - const env = { - AZURE_OPENAI_ENDPOINT: 'https://example.openai.azure.com', - AZURE_OPENAI_KEY: 'test-key', - AZURE_API_VERSION: '2023-05-15', - AZURE_COMPLETION_DEPLOYMENT: 'gpt-4', - }; - - // Mock Wikidata search - no results - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); + it('extractProducts service method forwards the options object', async () => { + // No candidates -> none_no_validated_entity + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); const service = createProductExtractorService(env, log); - - // Call extractProducts through service - const result = await service.extractProducts('TestBrand', null); + const result = await service.extractProducts({ brandName: 'TestBrand', registrableDomain: 'test.com' }); expect(result).to.have.property('metadata'); + expect(result.metadata.source).to.equal('none_no_validated_entity'); }); }); @@ -964,91 +895,6 @@ describe('services/product-extractor', () => { }); }); - describe('extractProducts date parsing', () => { - it('handles date strings without T separator', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); - - // SPARQL returns products with date in non-ISO format - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { - itemLabel: { value: 'Product1' }, - item: { value: 'http://wikidata.org/Q1' }, - inception: { value: '1995' }, // No T separator - }, - { - itemLabel: { value: 'Product2' }, - item: { value: 'http://wikidata.org/Q2' }, - inception: { value: '' }, // Empty - }, - { - itemLabel: { value: 'Product3' }, - item: { value: 'http://wikidata.org/Q3' }, - // No inception at all - }, - ], - }, - }), - }); - - const result = await extractProducts('TestBrand', null, gpt, log); - - expect(result.products).to.have.length(3); - expect(result.products[0].inception_year).to.equal(1995); - }); - - it('handles non-string date values gracefully (error catch)', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve({ - search: [{ id: 'Q12345', description: 'company' }], - }), - }); - - // SPARQL returns products with inception as a non-standard value - // The .value is what the code extracts - simulating edge case where type is wrong - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - results: { - bindings: [ - { - itemLabel: { value: 'Product1' }, - item: { value: 'http://wikidata.org/Q1' }, - inception: { value: '2020-05-15T00:00:00Z' }, // Normal date with T - }, - { - itemLabel: { value: 'Product2' }, - item: { value: 'http://wikidata.org/Q2' }, - // inception is completely missing (undefined) - }, - { - itemLabel: { value: 'Product3' }, - item: { value: 'http://wikidata.org/Q3' }, - inception: null, // inception object is null - }, - ], - }, - }), - }); - - const result = await extractProducts('TestBrand', null, gpt, log); - - expect(result.products).to.have.length(3); - expect(result.products[0].inception_year).to.equal(2020); - expect(result.products[1].inception_year).to.be.null; - expect(result.products[2].inception_year).to.be.null; - }); - }); - describe('extractFromSitemap URL filtering', () => { it('includes URLs matching product name pattern', async () => { fetchStub.resolves({ @@ -1062,18 +908,12 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [{ name: 'Widget Pro' }], - services: [], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [{ name: 'Widget Pro' }], + services: [], + sub_brands: [], + discontinued: [], + })); await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -1082,7 +922,6 @@ describe('services/product-extractor', () => { log, ); - // Should have called LLM with filtered URLs expect(gpt.fetchChatCompletion).to.have.been.called; }); }); @@ -1098,18 +937,12 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: null, // Non-array - services: 'not-an-array', // Non-array - sub_brands: [], - discontinued: undefined, - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: null, + services: 'not-an-array', + sub_brands: [], + discontinued: undefined, + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -1132,26 +965,20 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [ - { name: 'Widget' }, - { name: 'widget' }, // Duplicate (case insensitive) - { name: 'WIDGET' }, // Another duplicate - { name: 'Other Product' }, - ], - services: [ - { name: 'Service' }, - { name: 'service' }, // Duplicate - ], - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: 'Widget' }, + { name: 'widget' }, + { name: 'WIDGET' }, + { name: 'Other Product' }, + ], + services: [ + { name: 'Service' }, + { name: 'service' }, + ], + sub_brands: [], + discontinued: [], + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -1160,7 +987,6 @@ describe('services/product-extractor', () => { log, ); - // Should deduplicate expect(result.products).to.have.length(2); expect(result.services).to.have.length(1); }); @@ -1175,22 +1001,16 @@ describe('services/product-extractor', () => { `), }); - gpt.fetchChatCompletion.resolves({ - choices: [{ - message: { - content: JSON.stringify({ - products: [ - { name: '' }, // Empty name - { name: 'Valid Product' }, - { name: null }, // Null name - ], - services: [{ name: '' }], // Empty name - sub_brands: [], - discontinued: [], - }), - }, - }], - }); + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: '' }, + { name: 'Valid Product' }, + { name: null }, + ], + services: [{ name: '' }], + sub_brands: [], + discontinued: [], + })); const result = await extractFromSitemap( 'https://example.com/sitemap.xml', @@ -1199,7 +1019,6 @@ describe('services/product-extractor', () => { log, ); - // Should filter out empty names expect(result.products).to.have.length(1); expect(result.products[0].name).to.equal('Valid Product'); expect(result.services).to.have.length(0); diff --git a/test/agents/brand-profile/services/wikipedia.test.js b/test/agents/brand-profile/services/wikipedia.test.js index 19553719..6495e0f5 100644 --- a/test/agents/brand-profile/services/wikipedia.test.js +++ b/test/agents/brand-profile/services/wikipedia.test.js @@ -485,6 +485,10 @@ describe('services/wikipedia', () => { expect(service).to.have.property('fetchSummary'); expect(service).to.have.property('fetchFullText'); expect(service).to.have.property('findWikidataId'); + expect(service).to.have.property('getWikidataEntity'); + expect(service).to.have.property('findValidatedWikidataEntity'); + expect(service).to.have.property('fetchExtractByTitle'); + expect(service).to.have.property('fetchValidatedSummary'); }); it('service methods can be called', async () => { @@ -505,6 +509,591 @@ describe('services/wikipedia', () => { }); }); + describe('getWikidataEntity', () => { + const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + + it('parses label, aliases, enwiki title and P856 hosts', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q489815: { + labels: { en: { value: 'DHL' } }, + aliases: { en: [{ value: 'DHL Express' }] }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { + P856: [ + { mainsnak: { datavalue: { value: 'https://www.dhl.com/' } } }, + ], + }, + }, + }, + }), + }); + + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q489815', log); + + expect(entity.id).to.equal('Q489815'); + expect(entity.label).to.equal('DHL'); + expect(entity.aliases).to.deep.equal(['DHL Express']); + expect(entity.enwikiTitle).to.equal('DHL'); + expect(entity.officialWebsiteHosts).to.deep.equal(['www.dhl.com']); + }); + + it('handles missing claims and missing sitelink and invalid P856 URLs', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q1: { + labels: { en: { value: 'NoWiki' } }, + claims: { + P856: [ + { mainsnak: { datavalue: { value: 'not a url' } } }, + ], + }, + }, + }, + }), + }); + + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q1', log); + + expect(entity.enwikiTitle).to.be.null; + expect(entity.aliases).to.deep.equal([]); + expect(entity.officialWebsiteHosts).to.deep.equal([]); + }); + + it('returns null when the entity is absent from the response', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ entities: {} }), + }); + + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q404', log); + expect(entity).to.be.null; + }); + + it('handles an entity with no labels (label null)', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ entities: { Q1: { claims: {} } } }), + }); + + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q1', log); + expect(entity.label).to.be.null; + expect(entity.aliases).to.deep.equal([]); + expect(entity.enwikiTitle).to.be.null; + }); + + it('returns null when response is not ok', async () => { + fetchStub.resolves({ ok: false, status: 500 }); + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q1', log); + expect(entity).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikidata entity fetch failed'); + }); + + it('returns null on fetch error', async () => { + fetchStub.rejects(new Error('boom')); + const mod = await importMod(); + const entity = await mod.getWikidataEntity('Q1', log); + expect(entity).to.be.null; + }); + }); + + describe('validateEntityAgainstSite', () => { + const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + + it('accepts a P856 host whose registrable domain matches the site (co.jp)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Dai Nippon Printing', aliases: [], officialWebsiteHosts: ['www.dnp.co.jp'] }, + brandName: 'Dnp', + brandConfidence: 'low', + registrableDomain: 'dnp.co.jp', + }); + expect(result.ok).to.equal(true); + expect(result.method).to.equal('p856'); + }); + + it('rejects a P856 host on a different registrable domain (dnb.de vs dnb.com)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'German National Library', aliases: [], officialWebsiteHosts: ['www.dnb.de'] }, + brandName: 'Dnb', + brandConfidence: 'low', + registrableDomain: 'dnb.com', + }); + expect(result.ok).to.equal(false); + expect(result.reason).to.equal('low_confidence_requires_p856'); + }); + + it('accepts a label token-overlap match for a high-confidence name', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Dun & Bradstreet Inc', aliases: [], officialWebsiteHosts: [] }, + brandName: 'Dun & Bradstreet', + brandConfidence: 'high', + registrableDomain: 'dnb.com', + }); + expect(result.ok).to.equal(true); + expect(result.method).to.equal('label'); + }); + + it('rejects a label match for a low-confidence name (acronym safety rule)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'D-Company', aliases: [], officialWebsiteHosts: [] }, + brandName: 'Dnp', + brandConfidence: 'low', + registrableDomain: 'dnp.co.jp', + }); + expect(result.ok).to.equal(false); + }); + + it('returns false for a null entity', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: null, brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + }); + expect(result).to.deep.equal({ ok: false, method: null, reason: 'no_entity' }); + }); + + it('returns no_match when nothing overlaps for a high-confidence name', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Totally Different Org', aliases: [], officialWebsiteHosts: ['other.example'] }, + brandName: 'Amrize', + brandConfidence: 'medium', + registrableDomain: 'amrize.com', + }); + expect(result.ok).to.equal(false); + expect(result.reason).to.equal('no_match'); + }); + + it('tolerates an entity with no hosts/aliases keys (label match)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Amrize' }, + brandName: 'Amrize', + brandConfidence: 'high', + registrableDomain: 'somethingelse.com', + }); + expect(result.ok).to.equal(true); + expect(result.method).to.equal('label'); + }); + + it('tolerates an empty brand name (no tokens to match)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Amrize' }, + brandName: '', + brandConfidence: 'high', + registrableDomain: 'somethingelse.com', + }); + expect(result.ok).to.equal(false); + }); + }); + + describe('findValidatedWikidataEntity', () => { + const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + + it('returns the first P856-validated candidate', async () => { + // wbsearchentities candidates + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q1' }, { id: 'Q2' }] }), + }); + // getWikidataEntity Q1 -> no p856 match, label mismatch + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { Q1: { labels: { en: { value: 'Other' } }, claims: {} } }, + }), + }); + // getWikidataEntity Q2 -> p856 match + fetchStub.onCall(2).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://www.dhl.com' } } }] }, + }, + }, + }), + }); + + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + + expect(entity.id).to.equal('Q2'); + expect(entity.validation).to.equal('p856'); + }); + + it('REGRESSION: low-confidence acronym with no P856 match returns null', async () => { + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q111' }] }), + }); + // "D-Company" style article: label overlaps but no P856 to the site + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q111: { + labels: { en: { value: 'D-Company' } }, + sitelinks: { enwiki: { title: 'D-Company' } }, + claims: {}, + }, + }, + }), + }); + + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'Dnp', brandConfidence: 'low', registrableDomain: 'dnp.co.jp', + }, log); + + expect(entity).to.be.null; + }); + + it('falls back to the first label match for a non-low-confidence name', async () => { + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q1' }, { id: 'Q2' }] }), + }); + // Q1 label match + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { Q1: { labels: { en: { value: 'Amrize' } }, sitelinks: { enwiki: { title: 'Amrize' } }, claims: {} } }, + }), + }); + // Q2 also label match (second one -> rejected path) + fetchStub.onCall(2).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { Q2: { labels: { en: { value: 'Amrize Holdings' } }, claims: {} } }, + }), + }); + + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'Amrize', brandConfidence: 'medium', registrableDomain: 'somethingelse.com', + }, log); + + expect(entity.id).to.equal('Q1'); + expect(entity.validation).to.equal('label'); + }); + + it('returns null when there are no candidates', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'Nope', brandConfidence: 'high', registrableDomain: 'nope.com', + }, log); + expect(entity).to.be.null; + }); + + it('handles a candidate whose entity fetch fails', async () => { + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q1' }] }), + }); + fetchStub.onCall(1).resolves({ ok: false, status: 500 }); + + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + }, log); + expect(entity).to.be.null; + }); + + it('returns [] candidates when the search request is not ok', async () => { + fetchStub.resolves({ ok: false, status: 503 }); + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + }, log); + expect(entity).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Error searching Wikidata candidates'); + }); + + it('treats a search response without a search array as no candidates', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({}) }); + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + }, log); + expect(entity).to.be.null; + }); + }); + + describe('fetchWikipediaExtractByTitle', () => { + const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + + it('issues exactly one query with the exact title and NO opensearch', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: { extract: 'DHL is a logistics company.' } } } }), + }); + + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('DHL', 12000, log); + + expect(text).to.equal('DHL is a logistics company.'); + expect(fetchStub).to.have.been.calledOnce; + const calledUrl = fetchStub.firstCall.args[0]; + expect(calledUrl).to.include('titles=DHL'); + expect(calledUrl).to.not.include('opensearch'); + }); + + it('returns null for a missing title without issuing a request', async () => { + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle(null, 12000, log); + expect(text).to.be.null; + expect(fetchStub).to.not.have.been.called; + }); + + it('truncates to maxChars', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: { extract: 'A'.repeat(5000) } } } }), + }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', 100, log); + expect(text.length).to.equal(100); + }); + + it('uses the default maxChars when not provided', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: { extract: 'short' } } } }), + }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', null, log); + expect(text).to.equal('short'); + }); + + it('returns null when the page is missing (-1)', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { '-1': { missing: true } } } }), + }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); + expect(text).to.be.null; + }); + + it('returns null when the response carries no pages', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: {} }), + }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); + expect(text).to.be.null; + }); + + it('returns null when response is not ok', async () => { + fetchStub.resolves({ ok: false, status: 500 }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); + expect(text).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Error fetching Wikipedia extract by title'); + }); + + it('handles an empty extract', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: {} } } }), + }); + const mod = await importMod(); + const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); + expect(text).to.equal(''); + }); + }); + + describe('fetchValidatedSummary', () => { + const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + + it('returns the intro summary of the validated entity enwiki title', async () => { + // search + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q2' }] }), + }); + // getWikidataEntity Q2 -> p856 + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://www.dhl.com' } } }] }, + }, + }, + }), + }); + // intro extract + fetchStub.onCall(2).resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: { extract: 'DHL intro.' } } } }), + }); + + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + + expect(result).to.deep.equal({ title: 'DHL', summary: 'DHL intro.', entityId: 'Q2' }); + const introUrl = fetchStub.getCall(2).args[0]; + expect(introUrl).to.include('exintro=true'); + expect(introUrl).to.not.include('opensearch'); + }); + + it('returns null when no validated entity', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + }, log); + expect(result).to.be.null; + }); + + it('returns null when the validated entity has no enwiki title', async () => { + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q9' }] }), + }); + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q9: { + labels: { en: { value: 'NoWiki' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://x.com' } } }] }, + }, + }, + }), + }); + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'X', brandConfidence: 'low', registrableDomain: 'x.com', + }, log); + expect(result).to.be.null; + }); + + it('returns null when the intro fetch is not ok', async () => { + fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q2' }] }) }); + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://dhl.com' } } }] }, + }, + }, + }), + }); + fetchStub.onCall(2).resolves({ ok: false, status: 500 }); + + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Error fetching validated summary'); + }); + + it('returns null when the intro page is missing (-1)', async () => { + fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q2' }] }) }); + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://dhl.com' } } }] }, + }, + }, + }), + }); + fetchStub.onCall(2).resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { '-1': {} } } }), + }); + + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + expect(result).to.be.null; + }); + + it('returns null when the intro response carries no pages', async () => { + fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q2' }] }) }); + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://dhl.com' } } }] }, + }, + }, + }), + }); + fetchStub.onCall(2).resolves({ ok: true, json: () => Promise.resolve({ query: {} }) }); + + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + expect(result).to.be.null; + }); + + it('returns an empty summary when the intro page has no extract', async () => { + fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q2' }] }) }); + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q2: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://dhl.com' } } }] }, + }, + }, + }), + }); + fetchStub.onCall(2).resolves({ + ok: true, + json: () => Promise.resolve({ query: { pages: { 42: {} } } }), + }); + + const mod = await importMod(); + const result = await mod.fetchValidatedSummary({ + brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + }, log); + expect(result).to.deep.equal({ title: 'DHL', summary: '', entityId: 'Q2' }); + }); + }); + describe('edge cases', () => { it('fetchWikipediaSummary handles page without wikibase_item', async () => { fetchStub.onFirstCall().resolves({ From 0b9cfbe22910508c813b0c4ecbb00c4ffebf53b2 Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Sun, 9 Aug 2026 22:58:41 +0200 Subject: [PATCH 2/5] fix(brand-profile): address review findings on entity-binding (LLMO-6580) Applies local review-kit PR review fixes to the entity-binding change: - brand-resolver: generalize splitHost to treat any .<2-char-ccTLD> (com.my, co.th, gov.in, or.kr, ...) as a two-label public suffix, so an unlisted ccTLD can no longer collapse the registrable domain to the bare suffix and produce a false-positive P856 "strong" match. - wikipedia: replace the ratio>=0.5 weak label match with bidirectional token containment so two distinct names sharing one token (Swiss Life / Swiss Re) no longer validate; guard P856 against a bare-suffix site domain; add fetch timeouts (AbortController) to all Wikidata/Wikipedia calls; stop logging a valid second label candidate as "Rejected". - product-extractor: add SPARQL/sitemap fetch timeouts; guard the SPARQL query against a malformed Wikidata id; catch the plural "escorts" in the harm denylist. - README: correct the brand-name confidence and label-match wording. - tests: cover generalized ccTLD splitHost, sibling-name and bare-suffix rejection, harm-denylist false-positive/plural handling, and the SPARQL id guard. --- README.md | 27 ++++-- .../brand-profile/services/brand-resolver.js | 21 ++++- .../services/product-extractor.js | 31 ++++++- .../brand-profile/services/wikipedia.js | 89 ++++++++++++++----- .../services/brand-resolver.test.js | 29 ++++++ .../services/product-extractor.test.js | 57 ++++++++++++ .../brand-profile/services/wikipedia.test.js | 24 +++++ 7 files changed, 246 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index ae940278..a6f3857b 100644 --- a/README.md +++ b/README.md @@ -5,37 +5,47 @@ SpaceCat Task Processor is a Node.js service that processes messages from the AWS SQS queue `SPACECAT-TASK-PROCESSOR-JOBS`. Based on the `type` field in each message, it dispatches the message to the appropriate handler for processing various site-related tasks. ## Features + - Receives and processes messages from SQS - Supports multiple task types via modular handlers - Built-in handlers for audit status, demo URL preparation, generic agent execution, and Slack notifications - Extensible and easy to add new handlers ## Handlers + - **opportunity-status-processor**: Checks and reports status audits for a site - **demo-url-processor**: Prepares and shares a demo URL for a site - **agent-executor**: Runs registered AI/LLM agents (e.g., the brand-profile agent) asynchronously after onboarding flows - **slack-notify**: Sends Slack notifications (text or block messages) from workflows ## Setup + 1. Clone the repository 2. Install dependencies: + ```sh npm install ``` + 3. Configure AWS credentials and environment variables as needed ## Usage + - The service is designed to run as a serverless function or background worker. - It can be invoked in two ways: - **SQS mode:** listens to the `SPACECAT-TASK-PROCESSOR-JOBS` queue and processes messages automatically (default path for existing workflows). - **Direct mode:** the Lambda entrypoint auto-detects single-message payloads (e.g., from AWS Step Functions) and executes the corresponding handler synchronously. This is used by the new agent workflows to obtain immediate results before triggering follow-up actions. ## Development + - To run tests: + ```sh npm test ``` + - To run the optional brand-profile integration test (requires Azure OpenAI env variables): + ```sh npm run test:brand-profile-it ``` @@ -57,8 +67,8 @@ When invoking the integration test, you can also set `BRAND_PROFILE_TEST_BASE_UR The brand-profile product and competitor-summary paths bind every Wikipedia/Wikidata lookup to an entity that is validated against the customer's site, so a foreign entity's catalogue can never be attached to a customer. -- **Brand-name resolution** (`services/brand-resolver.js`) never emits a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label as a high-confidence brand name. It returns a `confidence` signal (`high`/`medium`/`low`); low-confidence acronyms may only proceed if an entity validates by a strong P856 (official-website host) match against the site's registrable domain. -- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` keeps a Wikidata candidate only if its official-website host (claim P856) shares the site's registrable domain, or — for non-low-confidence names — its label/aliases overlap the brand name. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If nothing validates, the pipeline produces **no** products. +- **Brand-name resolution** (`services/brand-resolver.js`) never *derives* a high-confidence brand name from a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label: domain- and page-title-derived names that are bare acronyms or stop-labels are demoted to `low` confidence (an explicit `brand_name` supplied by the base profile is trusted as given). It returns a `confidence` signal (`high`/`medium`/`low`); low-confidence names may only proceed if an entity validates by a strong P856 (official-website host) match against the site's registrable domain. +- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` keeps a Wikidata candidate only if its official-website host (claim P856) shares the site's registrable domain, or — for non-low-confidence names — its label/aliases fully contain (or are contained by) the brand name — a single shared token is not enough. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If nothing validates, the pipeline produces **no** products. | Variable | Default | Purpose | | --- | --- | --- | @@ -69,12 +79,15 @@ The brand-profile product and competitor-summary paths bind every Wikipedia/Wiki **Persist guard:** `persist()` never overwrites a stored brand profile whose `products_metadata.source == "manual-curated"` — the curated `products`/`products_metadata` are preserved while all other fields update. This protects the hand-curated blocks during the P2 regeneration sweep. - To lint code: + ```sh npm run lint ``` ## Extending + To add a new handler: + 1. Create a new folder in `src/` for your handler. 2. Export your handler function. 3. Add it to the handler mapping in `src/index.js`. @@ -83,6 +96,7 @@ To add a new handler: For more details, see the documentation in `src/README.md`. ## Status + [![codecov](https://img.shields.io/codecov/c/github/adobe-rnd/spacecat-task-processor.svg)](https://codecov.io/gh/adobe-rnd/spacecat-task-processor) [![CircleCI](https://img.shields.io/circleci/project/github/adobe-rnd/spacecat-audit-worker.svg)](https://circleci.com/gh/adobe-rnd/spacecat-task-processor) [![GitHub license](https://img.shields.io/github/license/adobe-rnd/spacecat-task-processor.svg)](https://github.com/adobe-rnd/spacecat-task-processor/blob/master/LICENSE.txt) @@ -93,7 +107,7 @@ For more details, see the documentation in `src/README.md`. ## Installation ```bash -$ npm install @adobe/spacecat-task-processor +npm install @adobe/spacecat-task-processor ``` ## Usage @@ -105,19 +119,19 @@ See the [API documentation](docs/API.md). ### Build ```bash -$ npm install +npm install ``` ### Test ```bash -$ npm test +npm test ``` ### Lint ```bash -$ npm run lint +npm run lint ``` ## Message Body Formats @@ -157,6 +171,7 @@ When the AWS Step Functions Agent Workflow invokes the Lambda directly, it sends ``` Field descriptions: + - `agentId` *(required)* – must match a registered agent (e.g., `brand-profile`). - `siteId` *(required)* – kept at the envelope level for logging/metrics. Agents can still read it from the message passed into `agent.persist`. - `context` *(required)* – forwarded to `agent.run`. At minimum it must include `baseURL`; additional agent-specific params live here. diff --git a/src/agents/brand-profile/services/brand-resolver.js b/src/agents/brand-profile/services/brand-resolver.js index cc4e4794..49fde02f 100644 --- a/src/agents/brand-profile/services/brand-resolver.js +++ b/src/agents/brand-profile/services/brand-resolver.js @@ -47,6 +47,17 @@ export const MULTI_PART_TLDS = new Set([ 'ne.jp', 'or.jp', 'com.tw', 'co.id', 'com.tr', 'gov.au', 'edu.au', ]); +/** + * Generic second-level labels that form a two-label public suffix when paired with a + * 2-character ccTLD (e.g. `com.my`, `co.th`, `gov.in`, `or.kr`). Generalising the + * `.` shape means an unlisted ccTLD cannot collapse the registrable domain + * down to the bare public suffix, which was the residual LLMO-6580 false-positive vector: + * a bare-suffix registrable domain P856-matches any foreign entity on the same suffix. + */ +export const GENERIC_SECOND_LEVELS = new Set([ + 'com', 'co', 'org', 'net', 'gov', 'edu', 'ac', 'mil', 'ne', 'or', 'go', 'gob', 'gouv', +]); + /** * Split a hostname into its subdomain labels, apex label, and registrable domain, * honouring the minimal multi-part TLD table. @@ -63,7 +74,15 @@ export function splitHost(hostname) { let registrableLabelCount = 2; const lastTwo = labels.slice(-2).join('.'); - if (MULTI_PART_TLDS.has(lastTwo) && labels.length >= 3) { + const tld = labels.at(-1); + const secondLevel = labels.at(-2); + // Explicit multi-part TLD, OR the general `.<2-char-ccTLD>` shape + // (co.uk, com.my, co.th, gov.in, or.kr, ...). Both are two-label public suffixes, so the + // registrable domain keeps a real label in front of them instead of collapsing to the + // bare suffix (LLMO-6580: a bare-suffix registrable domain yields false P856 matches). + if (labels.length >= 3 + && (MULTI_PART_TLDS.has(lastTwo) + || (tld.length === 2 && GENERIC_SECOND_LEVELS.has(secondLevel)))) { registrableLabelCount = 3; } diff --git a/src/agents/brand-profile/services/product-extractor.js b/src/agents/brand-profile/services/product-extractor.js index cb8707f1..025ec1bb 100644 --- a/src/agents/brand-profile/services/product-extractor.js +++ b/src/agents/brand-profile/services/product-extractor.js @@ -28,6 +28,24 @@ import { findValidatedWikidataEntity, fetchWikipediaExtractByTitle } from './wik const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql'; const MIN_PRODUCTS_THRESHOLD = 3; +// Upper bound on any single sitemap/SPARQL round trip so a hung upstream cannot stall the task. +const EXTERNAL_FETCH_TIMEOUT_MS = 10000; + +/** + * fetch() with an AbortController timeout so a hung upstream cannot stall the task. + * @param {string} url - Request URL + * @param {object} [options] - fetch options (headers, etc.) + * @returns {Promise} + */ +async function timedFetch(url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), EXTERNAL_FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} // Harm denylist (LLMO-6580 / AI-ethics Tier-2). Word-boundary matched against product/ // service/sub-brand names and categories. Deliberate stems (terror, smuggl, insurgen) @@ -38,7 +56,7 @@ const HARM_PATTERNS = [ // weapons / military /\bweapon/i, /\bfirearm/i, /\bammunition/i, /\bmissile/i, /\bwarhead/i, /\bexplosive/i, // adult / sexual - /\bpornograph/i, /\bescort\b/i, + /\bpornograph/i, /\bescorts?\b/i, // drugs /\bnarcotic/i, /\bheroin\b/i, /\bcocaine\b/i, /\bmethamphetamine\b/i, // hate / extremism @@ -155,7 +173,7 @@ function filterProductUrls(urls) { async function fetchSitemapUrls(sitemapUrl, log) { log.info(`Fetching sitemap: ${sitemapUrl}`); - const resp = await fetch(sitemapUrl, { + const resp = await timedFetch(sitemapUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -179,11 +197,18 @@ async function fetchSitemapUrls(sitemapUrl, log) { async function queryWikidataProducts(wikidataId, log) { log.info(`Querying Wikidata products for: ${wikidataId}`); + // Guard: only substitute a well-formed Wikidata entity id into the SPARQL template + // (defense-in-depth against SPARQL injection, even though ids originate from Wikidata). + if (!/^Q\d+$/.test(String(wikidataId || ''))) { + log.warn(`Refusing SPARQL query for malformed Wikidata id: ${wikidataId}`); + return []; + } + const query = PRODUCTS_SPARQL.replace(/{wikidata_id}/g, wikidataId); const url = `${WIKIDATA_SPARQL}?query=${encodeURIComponent(query)}`; try { - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/sparql-results+json', diff --git a/src/agents/brand-profile/services/wikipedia.js b/src/agents/brand-profile/services/wikipedia.js index 654bdc9b..ed032aa5 100644 --- a/src/agents/brand-profile/services/wikipedia.js +++ b/src/agents/brand-profile/services/wikipedia.js @@ -14,15 +14,48 @@ * Wikipedia/Wikidata client for fetching brand information. */ -import { splitHost } from './brand-resolver.js'; +import { splitHost, MULTI_PART_TLDS } from './brand-resolver.js'; const WIKIPEDIA_API_BASE = 'https://en.wikipedia.org/w/api.php'; const WIKIDATA_API = 'https://www.wikidata.org/w/api.php'; const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; +// Upper bound on any single Wikipedia/Wikidata/SPARQL round trip. findValidatedWikidataEntity +// issues several serial calls, so an unbounded hang on any one would stall the whole task. +const EXTERNAL_FETCH_TIMEOUT_MS = 10000; + // Corporate suffixes stripped before comparing an entity label to a brand name. const CORP_SUFFIXES = /\b(inc|corp|corporation|co|ltd|limited|llc|gmbh|ag|sa|plc|nv|kk|group|holdings?|company)\b/gi; +/** + * fetch() with an AbortController timeout so a hung upstream cannot stall the task. + * Mirrors the pattern in brand-resolver.fetchSiteName. + * @param {string} url - Request URL + * @param {object} [options] - fetch options (headers, etc.) + * @returns {Promise} + */ +async function timedFetch(url, options = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), EXTERNAL_FETCH_TIMEOUT_MS); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +/** + * Is this registrable domain actually a bare public suffix (no registrable label in front)? + * Such a domain must never produce a P856 "strong" match, or any foreign entity on the same + * suffix would validate against the site (LLMO-6580). + * @param {string} domain - Registrable domain + * @returns {boolean} + */ +function isBareSuffix(domain) { + const labels = String(domain || '').toLowerCase().split('.').filter(Boolean); + return labels.length < 2 || MULTI_PART_TLDS.has(labels.join('.')); +} + /** * Fetch Wikipedia summary for a brand. * @param {string} searchQuery - Search query (e.g., "Swiss Life company") @@ -43,7 +76,7 @@ export async function fetchWikipediaSummary(searchQuery, log) { }); const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; - const searchResp = await fetch(searchUrl, { + const searchResp = await timedFetch(searchUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -74,7 +107,7 @@ export async function fetchWikipediaSummary(searchQuery, log) { }); const summaryUrl = `${WIKIPEDIA_API_BASE}?${summaryParams}`; - const summaryResp = await fetch(summaryUrl, { + const summaryResp = await timedFetch(summaryUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -133,7 +166,7 @@ export async function fetchWikipediaFullText(searchQuery, maxChars, log) { }); const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; - const searchResp = await fetch(searchUrl, { + const searchResp = await timedFetch(searchUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -161,7 +194,7 @@ export async function fetchWikipediaFullText(searchQuery, maxChars, log) { }); const contentUrl = `${WIKIPEDIA_API_BASE}?${contentParams}`; - const contentResp = await fetch(contentUrl, { + const contentResp = await timedFetch(contentUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -210,7 +243,7 @@ export async function findWikidataId(brandName, log) { }); const url = `${WIKIDATA_API}?${params}`; - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -272,7 +305,7 @@ export async function getWikidataEntity(entityId, log) { }); const url = `${WIKIDATA_API}?${params}`; - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -351,11 +384,15 @@ export function validateEntityAgainstSite({ } // Strong P856 match: entity's own official website registrable domain == site's. + // Never accept when the site's registrable domain is a bare public suffix (e.g. `co.uk`), + // or any foreign entity on the same suffix would falsely validate (LLMO-6580). const hosts = entity.officialWebsiteHosts || []; - for (const host of hosts) { - const { registrableDomain: entityRegDomain } = splitHost(host); - if (entityRegDomain && registrableDomain && entityRegDomain === registrableDomain) { - return { ok: true, method: 'p856', reason: `P856 host ${host} matches site ${registrableDomain}` }; + if (!isBareSuffix(registrableDomain)) { + for (const host of hosts) { + const { registrableDomain: entityRegDomain } = splitHost(host); + if (entityRegDomain && registrableDomain && entityRegDomain === registrableDomain) { + return { ok: true, method: 'p856', reason: `P856 host ${host} matches site ${registrableDomain}` }; + } } } @@ -364,16 +401,21 @@ export function validateEntityAgainstSite({ return { ok: false, method: null, reason: 'low_confidence_requires_p856' }; } - // Weak label/alias token-overlap match. + // Weak label/alias containment match. Require one token set to FULLY contain the other + // (after corp-suffix stripping) so a qualifier variant ("Amrize" / "Amrize Holdings", + // "The Home Depot" / "Home Depot") matches, but two distinct names that merely share a + // token ("Swiss Life" / "Swiss Re", "Bank of America" / "Bank of Scotland") do NOT — a + // shared single token was the residual sibling-entity contamination vector (LLMO-6580). const brandTokens = new Set(normalizeName(brandName).split(' ').filter(Boolean)); if (brandTokens.size > 0) { const candidates = [entity.label, ...(entity.aliases || [])].filter(Boolean); for (const candidate of candidates) { - const candTokens = normalizeName(candidate).split(' ').filter(Boolean); - if (candTokens.length > 0) { - const overlap = candTokens.filter((t) => brandTokens.has(t)).length; - const ratio = overlap / Math.max(brandTokens.size, candTokens.length); - if (ratio >= 0.5) { + const candTokens = new Set(normalizeName(candidate).split(' ').filter(Boolean)); + if (candTokens.size > 0) { + const [smaller, larger] = brandTokens.size <= candTokens.size + ? [brandTokens, candTokens] : [candTokens, brandTokens]; + const contained = [...smaller].every((t) => larger.has(t)); + if (contained) { return { ok: true, method: 'label', reason: `label match "${candidate}"` }; } } @@ -400,7 +442,7 @@ async function searchWikidataCandidates(brandName, log) { }); const url = `${WIKIDATA_API}?${params}`; - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -448,8 +490,11 @@ export async function findValidatedWikidataEntity({ log.info(`Validated Wikidata entity ${id} for "${brandName}" via P856`); return { ...entity, validation: 'p856' }; } - if (validation.ok && validation.method === 'label' && !labelMatch) { - labelMatch = { ...entity, validation: 'label' }; + if (validation.ok && validation.method === 'label') { + // Keep the FIRST label match as a fallback; a later P856 match still wins. + if (!labelMatch) { + labelMatch = { ...entity, validation: 'label' }; + } } else { log.info(`Rejected Wikidata candidate ${id} for "${brandName}": ${validation.reason}`); } @@ -486,7 +531,7 @@ export async function fetchWikipediaExtractByTitle(title, maxChars, log) { }); const url = `${WIKIPEDIA_API_BASE}?${params}`; - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -540,7 +585,7 @@ export async function fetchValidatedSummary({ }); const url = `${WIKIPEDIA_API_BASE}?${params}`; - const resp = await fetch(url, { + const resp = await timedFetch(url, { headers: { 'User-Agent': USER_AGENT }, }); diff --git a/test/agents/brand-profile/services/brand-resolver.test.js b/test/agents/brand-profile/services/brand-resolver.test.js index 33597237..f440c90d 100644 --- a/test/agents/brand-profile/services/brand-resolver.test.js +++ b/test/agents/brand-profile/services/brand-resolver.test.js @@ -75,6 +75,35 @@ describe('services/brand-resolver', () => { }); }); + it('generalizes unlisted . suffixes so they never collapse to the bare suffix (LLMO-6580)', () => { + // None of these ccTLD second-levels are in MULTI_PART_TLDS; the generic-second-level + // rule must still keep a registrable label in front of the suffix. + expect(splitHost('maybank.com.my')).to.deep.equal({ + subdomainLabels: [], + apexLabel: 'maybank', + registrableDomain: 'maybank.com.my', + }); + expect(splitHost('www.pttep.co.th')).to.deep.equal({ + subdomainLabels: ['www'], + apexLabel: 'pttep', + registrableDomain: 'pttep.co.th', + }); + expect(splitHost('nic.gov.in')).to.deep.equal({ + subdomainLabels: [], + apexLabel: 'nic', + registrableDomain: 'nic.gov.in', + }); + }); + + it('does not treat a non-generic second-level before a ccTLD as multi-part (ab.co)', () => { + // 'ab' is not a generic second-level, so 'ab.co' stays the registrable domain. + expect(splitHost('sub.ab.co')).to.deep.equal({ + subdomainLabels: ['sub'], + apexLabel: 'ab', + registrableDomain: 'ab.co', + }); + }); + it('strips a section subdomain (store)', () => { const { apexLabel } = splitHost('store.example.com'); expect(apexLabel).to.equal('example'); diff --git a/test/agents/brand-profile/services/product-extractor.test.js b/test/agents/brand-profile/services/product-extractor.test.js index 1511a5ad..d62167fa 100644 --- a/test/agents/brand-profile/services/product-extractor.test.js +++ b/test/agents/brand-profile/services/product-extractor.test.js @@ -577,6 +577,63 @@ describe('services/product-extractor', () => { expect(result.sub_brands).to.deep.equal([]); }); + it('keeps benign names that merely contain a denylist substring, and catches plural forms', async () => { + // Weak (label) provenance => harmful items are hard-dropped. Benign names whose + // substrings look like a stem must survive (the denylist claims to avoid false hits); + // the plural 'Escorts' must be caught by the word-boundary pattern. + fetchStub.onCall(0).resolves(searchResp(['Q1'])); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Acme', enwikiTitle: 'Acme', hosts: [] })); + fetchStub.onCall(2).resolves(sparqlResp([])); + fetchStub.onCall(3).resolves(extractResp('Acme is a company.')); + + gpt.fetchChatCompletion.resolves(llmResp({ + products: [ + { name: 'Armature Motor', category: 'Component' }, + { name: 'Churchill Series', category: 'Model' }, + { name: 'Escorts', category: 'Service' }, + ], + services: [], + sub_brands: [], + discontinued: [], + })); + + const result = await extractProducts( + { brandName: 'Acme', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, + gpt, + log, + ); + + expect(result.metadata.validation).to.equal('label'); + expect(result.metadata.safety_filtered).to.equal(true); + // Benign near-misses kept; only the true (plural) hit dropped. + expect(result.products.map((p) => p.name)).to.deep.equal(['Armature Motor', 'Churchill Series']); + }); + + it('refuses a SPARQL query when the resolved entity id is malformed (injection guard)', async () => { + // Entity validates via P856 but carries a non-Q id; the SPARQL template substitution + // must be refused rather than issued. + fetchStub.onCall(0).resolves(searchResp(['QABC'])); + fetchStub.onCall(1).resolves(entityResp('QABC', { label: 'Acme', enwikiTitle: 'Acme', hosts: ['acme.com'] })); + fetchStub.onCall(2).resolves(extractResp('Acme is a company.')); + + gpt.fetchChatCompletion.resolves(llmResp({ + products: [], services: [], sub_brands: [], discontinued: [], + })); + + const result = await extractProducts( + { brandName: 'Acme', brandConfidence: 'low', registrableDomain: 'acme.com' }, + gpt, + log, + ); + + expect(result.metadata.brand_wikidata_id).to.equal('QABC'); + expect(result.products).to.have.length(0); + expect(log.warn).to.have.been.calledWithMatch('Refusing SPARQL query for malformed Wikidata id'); + // No SPARQL request was issued (search + entity + wiki-extract only). + const sparqlIssued = fetchStub.getCalls().some((c) => String(c.args[0]).includes('sparql')); + expect(sparqlIssued).to.equal(false); + }); + it('keeps but flags harmful content from a strongly (P856) validated entity', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Beretta', enwikiTitle: 'Beretta', hosts: ['www.beretta.com'] })); diff --git a/test/agents/brand-profile/services/wikipedia.test.js b/test/agents/brand-profile/services/wikipedia.test.js index 6495e0f5..e969c623 100644 --- a/test/agents/brand-profile/services/wikipedia.test.js +++ b/test/agents/brand-profile/services/wikipedia.test.js @@ -645,6 +645,30 @@ describe('services/wikipedia', () => { expect(result.method).to.equal('label'); }); + it('rejects a sibling entity that only shares one token (Swiss Life vs Swiss Re)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Swiss Re', aliases: [], officialWebsiteHosts: ['www.swissre.com'] }, + brandName: 'Swiss Life', + brandConfidence: 'high', + registrableDomain: 'swisslife.ch', + }); + expect(result.ok).to.equal(false); + expect(result.reason).to.equal('no_match'); + }); + + it('rejects a P856 match when the site registrable domain is a bare public suffix', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { label: 'Some Foreign Org', aliases: [], officialWebsiteHosts: ['co.uk'] }, + brandName: 'Whatever', + brandConfidence: 'high', + registrableDomain: 'co.uk', + }); + expect(result.ok).to.equal(false); + expect(result.method).to.be.null; + }); + it('rejects a label match for a low-confidence name (acronym safety rule)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ From 5a418059c4284bbe2551a3197cad6b0236af24bc Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Mon, 10 Aug 2026 01:06:47 +0200 Subject: [PATCH 3/5] refactor(brand-profile): keep entity grounding P856-only (LLMO-6580) Make the brand-profile agent's Wikipedia/Wikidata grounding accept an entity only when its P856 official-website registrable domain matches the customer's registrable domain. Search still runs by resolved brand name, but a search result is never trusted until P856 validates it; if nothing validates, the pipeline produces no Wikipedia/Wikidata products. Removed behavior: - Weak label/alias matching, normalizeName, and corporate-suffix matching in wikipedia.js, plus the deprecated by-name functions (fetchWikipediaSummary, fetchWikipediaFullText, findWikidataId) and their service methods. - The content-safety denylist and harmful-content filtering in product-extractor.js (HARM_PATTERNS, hitsHarm, itemHitsHarm, applyContentSafetyGate) and the sensitive_category / safety_filtered metadata. This is an intentional product decision: after P856-only binding, sensitive products belonging to the customer's own validated entity may flow through without sensitive_category / safety_filtered metadata. - brandConfidence plumbing through extractProducts / fetchValidatedSummary and the skipped_low_confidence terminal source value; a no-match now always reports products_metadata.source = "none_no_validated_entity". Retained safeguards: - BRAND_PROFILE_ENABLE_WIKI_PRODUCTS kill switch (default off). - products_metadata.source === "manual-curated" persistence guard. - Fetch timeouts, malformed Wikidata-ID SPARQL guard, exact-enwiki-title extraction (no opensearch), and generalized ccTLD / bare-public-suffix rejection (isBareSuffix now mirrors splitHost's public-suffix logic). Tests updated to the P856-only contract; README documents the P856-only validation and drops label validation, sensitive_category, safety_filtered, and skipped_low_confidence. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 +- src/agents/brand-profile/index.js | 5 +- .../brand-profile/services/brand-resolver.js | 13 +- .../services/product-extractor.js | 115 +-- .../brand-profile/services/wikipedia.js | 427 ++------- test/agents/brand-profile/index.test.js | 8 +- .../services/product-extractor.test.js | 182 +--- .../brand-profile/services/wikipedia.test.js | 847 ++---------------- 8 files changed, 232 insertions(+), 1373 deletions(-) diff --git a/README.md b/README.md index a6f3857b..9bb68ddd 100644 --- a/README.md +++ b/README.md @@ -65,16 +65,16 @@ When invoking the integration test, you can also set `BRAND_PROFILE_TEST_BASE_UR #### Brand-profile entity validation (LLMO-6580) -The brand-profile product and competitor-summary paths bind every Wikipedia/Wikidata lookup to an entity that is validated against the customer's site, so a foreign entity's catalogue can never be attached to a customer. +The brand-profile product and competitor-summary paths bind every Wikipedia/Wikidata lookup to an entity that is validated against the customer's site by a strong P856 (official-website host) match, so a foreign entity's catalogue can never be attached to a customer. -- **Brand-name resolution** (`services/brand-resolver.js`) never *derives* a high-confidence brand name from a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label: domain- and page-title-derived names that are bare acronyms or stop-labels are demoted to `low` confidence (an explicit `brand_name` supplied by the base profile is trusted as given). It returns a `confidence` signal (`high`/`medium`/`low`); low-confidence names may only proceed if an entity validates by a strong P856 (official-website host) match against the site's registrable domain. -- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` keeps a Wikidata candidate only if its official-website host (claim P856) shares the site's registrable domain, or — for non-low-confidence names — its label/aliases fully contain (or are contained by) the brand name — a single shared token is not enough. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If nothing validates, the pipeline produces **no** products. +- **Brand-name resolution** (`services/brand-resolver.js`) turns the base profile and site URL into a display name plus the site's registrable domain. It never *derives* a brand name from a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label as high confidence (an explicit `brand_name` supplied by the base profile is trusted as given). The registrable domain is the signal the entity validation compares against. +- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` searches Wikidata by name but keeps a candidate **only** if its official-website host (claim P856) shares the site's registrable domain — this is the sole accepted signal; there is no by-name / label / alias fallback, and a bare public-suffix registrable domain (e.g. `co.uk`) never matches. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If no candidate's P856 host matches, the pipeline produces **no** Wikipedia/Wikidata products. | Variable | Default | Purpose | | --- | --- | --- | | `BRAND_PROFILE_ENABLE_WIKI_PRODUCTS` | `false` | Kill-switch for the entire Wikipedia/Wikidata product + competitor-summary path. When `false`, `extractProducts` returns an empty result (`products_metadata.source = "disabled"`) and no validated summary is fetched; sitemap-based product extraction and the rest of the profile still run. Ship `false` for net-new runs until the P0-a scrub and P2 backfill complete, then flip to `true`. Read from Vault per-service config (`dx_mysticat/{env}/task-processor`). | -`products_metadata.source` terminal values: `sitemap`, `wikidata`, `hybrid`, `wikipedia_llm`, `disabled`, `skipped_low_confidence` (low-confidence name with no P856-validated entity), `none_no_validated_entity`, and the pre-existing `none`/`sitemap_*` states. Additive provenance fields: `source_entity_label`, `source_wikipedia_title`, `validation` (`p856`|`label`), `safety_filtered` (harmful content dropped from an unvalidated source), and `sensitive_category` (sensitive content kept from a validated/own-site source, flagged for human review). +`products_metadata.source` terminal values: `sitemap`, `wikidata`, `hybrid`, `wikipedia_llm`, `disabled`, `none_no_validated_entity` (no P856-validated entity), and the pre-existing `none`/`sitemap_*` states. Additive provenance fields: `source_entity_label`, `source_wikipedia_title`, and `validation` (`p856`). **Persist guard:** `persist()` never overwrites a stored brand profile whose `products_metadata.source == "manual-curated"` — the curated `products`/`products_metadata` are preserved while all other fields update. This protects the hand-curated blocks during the P2 regeneration sweep. diff --git a/src/agents/brand-profile/index.js b/src/agents/brand-profile/index.js index a5789441..915462d9 100644 --- a/src/agents/brand-profile/index.js +++ b/src/agents/brand-profile/index.js @@ -121,7 +121,6 @@ async function run(context, env, log) { // Extract key fields from base profile for enhanced inference const { name: brandName, - confidence: brandConfidence, registrableDomain, } = await resolveBrandName(baseProfile, baseURL, log); const industry = extractIndustry(baseProfile); @@ -131,7 +130,7 @@ async function run(context, env, log) { // path stays OFF unless explicitly enabled, until the P2 backfill is validated. const enableWikiProducts = env.BRAND_PROFILE_ENABLE_WIKI_PRODUCTS === 'true'; - log.info(`brand-profile: enhancing profile for "${brandName}" (confidence=${brandConfidence}) in "${industry}"`); + log.info(`brand-profile: enhancing profile for "${brandName}" in "${industry}"`); // Initialize services const regionalService = createRegionalContextService(env, log); @@ -181,7 +180,6 @@ async function run(context, env, log) { if (enableWikiProducts) { const wikiResult = await wikipediaService.fetchValidatedSummary({ brandName, - brandConfidence, registrableDomain, }); wikiSummary = wikiResult?.summary || ''; @@ -219,7 +217,6 @@ async function run(context, env, log) { // extractProducts, bound to an entity validated against the site. productsResult = await productService.extractProducts({ brandName, - brandConfidence, registrableDomain, enableWikiProducts, }); diff --git a/src/agents/brand-profile/services/brand-resolver.js b/src/agents/brand-profile/services/brand-resolver.js index 49fde02f..193fa154 100644 --- a/src/agents/brand-profile/services/brand-resolver.js +++ b/src/agents/brand-profile/services/brand-resolver.js @@ -13,12 +13,13 @@ /** * Brand-name resolution for the brand-profile agent (LLMO-6580). * - * Turns a base profile + site URL into a best-effort display name plus a - * confidence signal and the site's registrable domain. The confidence signal - * gates the downstream Wikipedia/Wikidata entity validation: a low-confidence - * acronym (e.g. `dnp`, `edb`) is never allowed to drive a fuzzy by-name lookup; - * it may only proceed if an entity strongly validates against the site domain - * (P856 official-website host match). + * Turns a base profile + site URL into a best-effort display name, a coarse + * confidence signal (retained for logging/observability), and the site's + * registrable domain. Downstream Wikipedia/Wikidata entity validation is + * P856-only and keyed on the registrable domain — not on the confidence signal: + * a candidate entity is accepted only when its official-website host (claim + * P856) shares the site's registrable domain, so a bare acronym (e.g. `dnp`, + * `edb`) can never drive a fuzzy by-name lookup. */ import { load } from 'cheerio'; diff --git a/src/agents/brand-profile/services/product-extractor.js b/src/agents/brand-profile/services/product-extractor.js index 025ec1bb..55072834 100644 --- a/src/agents/brand-profile/services/product-extractor.js +++ b/src/agents/brand-profile/services/product-extractor.js @@ -47,22 +47,6 @@ async function timedFetch(url, options = {}) { } } -// Harm denylist (LLMO-6580 / AI-ethics Tier-2). Word-boundary matched against product/ -// service/sub-brand names and categories. Deliberate stems (terror, smuggl, insurgen) -// avoid false hits on words like "armature" or "Churchill". -const HARM_PATTERNS = [ - // crime / terror - /\bterror/i, /\bsmuggl/i, /\binsurgen/i, /\bcartel/i, /\bmafia/i, /\bcriminal/i, /\bnarco/i, - // weapons / military - /\bweapon/i, /\bfirearm/i, /\bammunition/i, /\bmissile/i, /\bwarhead/i, /\bexplosive/i, - // adult / sexual - /\bpornograph/i, /\bescorts?\b/i, - // drugs - /\bnarcotic/i, /\bheroin\b/i, /\bcocaine\b/i, /\bmethamphetamine\b/i, - // hate / extremism - /\bextremis/i, /\bneo-?nazi/i, /\bjihad/i, -]; - // Generic SPARQL query - works for any industry const PRODUCTS_SPARQL = ` SELECT DISTINCT ?item ?itemLabel ?typeLabel ?inception ?discontinued WHERE { @@ -367,78 +351,6 @@ function mergeResults(primary, secondary) { }; } -/** - * Does a string trip the harm denylist? - * @param {string} text - Text to scan - * @returns {boolean} - */ -function hitsHarm(text) { - const t = String(text || ''); - return HARM_PATTERNS.some((re) => re.test(t)); -} - -/** - * Does a normalized product/service item ({ name, category }) trip the harm denylist? - * @param {object} item - Item to scan - * @returns {boolean} - */ -function itemHitsHarm(item) { - return hitsHarm(item.name) || hitsHarm(item.category); -} - -/** - * Content-safety / plausibility backstop (LLMO-6580 ask 4, defence in depth). - * - * Provenance rule: - * - When the content came from a strongly (P856) validated entity — or the - * customer's own sitemap (`own_site`) — a real defense/pharma/gaming customer - * may legitimately list sensitive products: KEEP the content and set - * `metadata.sensitive_category` for human review. - * - When the source is unvalidated or only weakly (label) matched, HARD-DROP any - * harmful item/service/sub-brand and set `metadata.safety_filtered`. - * - * @param {object} result - Extraction result (mutated defensively via copy) - * @param {object} opts - { entityValidated: 'p856'|'label'|'own_site'|null } - * @param {object} log - Logger instance - * @returns {object} Possibly-filtered result - */ -function applyContentSafetyGate(result, { entityValidated }, log) { - // Strong provenance = a P856-validated Wikidata entity or the customer's own sitemap. - const strongProvenance = entityValidated === 'p856' || entityValidated === 'own_site'; - - // `result` always carries the four arrays (initialized by every caller). - const dropped = [ - ...result.products.filter(itemHitsHarm).map((p) => p.name), - ...result.services.filter(itemHitsHarm).map((s) => s.name), - ...result.sub_brands.filter(hitsHarm), - ...result.discontinued.filter(itemHitsHarm).map((d) => d.name), - ]; - - if (dropped.length === 0) { - return result; - } - - if (strongProvenance) { - // Keep legitimate sensitive content (defense/pharma/gaming), flag for review. - log.warn(`Sensitive categories from validated source kept for review: ${dropped.join(', ')}`); - return { - ...result, - metadata: { ...result.metadata, sensitive_category: true }, - }; - } - - // Weak/no provenance: hard-drop harmful content. - log.warn(`Dropping harmful content from unvalidated source: ${dropped.join(', ')}`); - return { - ...result, - products: result.products.filter((it) => !itemHitsHarm(it)), - services: result.services.filter((it) => !itemHitsHarm(it)), - sub_brands: result.sub_brands.filter((s) => !hitsHarm(s)), - discontinued: result.discontinued.filter((it) => !itemHitsHarm(it)), - metadata: { ...result.metadata, safety_filtered: true }, - }; -} - /** * Extract current products from sitemap URLs using LLM. * @param {string} sitemapUrl - URL of the brand's sitemap.xml @@ -520,10 +432,7 @@ export async function extractFromSitemap(sitemapUrl, brandName, gpt, log) { return result; } - // Content-safety backstop. The sitemap is the customer's OWN site, so treat it as - // strong (`own_site`) provenance: keep legitimate sensitive content but flag it. - const gated = applyContentSafetyGate(result, { entityValidated: 'own_site' }, log); - return normalizeResults(gated); + return normalizeResults(result); } /** @@ -577,12 +486,11 @@ async function extractFromWikipedia(brandName, wikipediaText, gpt, log) { * Extract products bound to a VALIDATED Wikidata entity (LLMO-6580). * * Every Wikipedia/Wikidata fetch is bound to an entity that validates against the - * customer's site (P856 host match, or a weak label match for non-low-confidence - * names). If nothing validates, we produce NO products rather than guessing. + * customer's site by a strong P856 (official-website host) match. If nothing + * validates, we produce NO products rather than guessing. * * @param {object} options - Options * @param {string} options.brandName - Brand/company name - * @param {string} [options.brandConfidence='medium'] - 'high' | 'medium' | 'low' * @param {string} [options.registrableDomain=''] - Site registrable domain * @param {string} [options.wikipediaSummary=null] - Optional pre-fetched fallback text * @param {boolean} [options.enableWikiProducts=true] - Kill-switch for the entire path @@ -592,12 +500,11 @@ async function extractFromWikipedia(brandName, wikipediaText, gpt, log) { */ export async function extractProducts({ brandName, - brandConfidence = 'medium', registrableDomain = '', wikipediaSummary = null, enableWikiProducts = true, }, gpt, log) { - log.info(`Extracting products for brand: ${brandName} (confidence=${brandConfidence})`); + log.info(`Extracting products for brand: ${brandName}`); const result = { products: [], @@ -619,17 +526,15 @@ export async function extractProducts({ return normalizeResults(result); } - // Step 1: Resolve+validate the entity. A bare low-confidence acronym only validates - // via a strong P856 host match; otherwise findValidatedWikidataEntity returns null. + // Step 1: Resolve+validate the entity. A candidate is only accepted via a strong P856 + // host match against the site; otherwise findValidatedWikidataEntity returns null. const entity = await findValidatedWikidataEntity({ - brandName, brandConfidence, registrableDomain, + brandName, registrableDomain, }, log); if (!entity) { log.info(`No validated Wikidata entity for ${brandName}; producing no products`); - result.metadata.source = brandConfidence === 'low' - ? 'skipped_low_confidence' - : 'none_no_validated_entity'; + result.metadata.source = 'none_no_validated_entity'; result.metadata.rejected = true; return normalizeResults(result); } @@ -665,9 +570,7 @@ export async function extractProducts({ } } - // Step 4: Content-safety backstop, gated by entity provenance. - const gated = applyContentSafetyGate(result, { entityValidated: entity.validation }, log); - return normalizeResults(gated); + return normalizeResults(result); } /** diff --git a/src/agents/brand-profile/services/wikipedia.js b/src/agents/brand-profile/services/wikipedia.js index ed032aa5..1e0419ba 100644 --- a/src/agents/brand-profile/services/wikipedia.js +++ b/src/agents/brand-profile/services/wikipedia.js @@ -11,22 +11,23 @@ */ /** - * Wikipedia/Wikidata client for fetching brand information. + * Wikidata/Wikipedia client, bound to a site-validated entity (LLMO-6580). + * + * The only accepted validation signal is a strong P856 (official-website) host match + * against the site's registrable domain. There is deliberately no by-name / fuzzy path: + * if no candidate's official website matches the site, the pipeline produces no products. */ -import { splitHost, MULTI_PART_TLDS } from './brand-resolver.js'; +import { splitHost, MULTI_PART_TLDS, GENERIC_SECOND_LEVELS } from './brand-resolver.js'; const WIKIPEDIA_API_BASE = 'https://en.wikipedia.org/w/api.php'; const WIKIDATA_API = 'https://www.wikidata.org/w/api.php'; const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; -// Upper bound on any single Wikipedia/Wikidata/SPARQL round trip. findValidatedWikidataEntity +// Upper bound on any single Wikidata/Wikipedia round trip. findValidatedWikidataEntity // issues several serial calls, so an unbounded hang on any one would stall the whole task. const EXTERNAL_FETCH_TIMEOUT_MS = 10000; -// Corporate suffixes stripped before comparing an entity label to a brand name. -const CORP_SUFFIXES = /\b(inc|corp|corporation|co|ltd|limited|llc|gmbh|ag|sa|plc|nv|kk|group|holdings?|company)\b/gi; - /** * fetch() with an AbortController timeout so a hung upstream cannot stall the task. * Mirrors the pattern in brand-resolver.fetchSiteName. @@ -46,250 +47,39 @@ async function timedFetch(url, options = {}) { /** * Is this registrable domain actually a bare public suffix (no registrable label in front)? - * Such a domain must never produce a P856 "strong" match, or any foreign entity on the same - * suffix would validate against the site (LLMO-6580). + * Such a domain must never produce a P856 match, or any foreign entity on the same suffix + * would validate against the site (LLMO-6580). Mirrors the public-suffix logic in + * `splitHost`: an explicit multi-part TLD (`co.uk`) OR the generalized two-label + * `.<2-char ccTLD>` shape (`com.my`, `co.th`, `gov.in`) is a bare suffix. * @param {string} domain - Registrable domain * @returns {boolean} */ function isBareSuffix(domain) { - const labels = String(domain || '').toLowerCase().split('.').filter(Boolean); - return labels.length < 2 || MULTI_PART_TLDS.has(labels.join('.')); -} - -/** - * Fetch Wikipedia summary for a brand. - * @param {string} searchQuery - Search query (e.g., "Swiss Life company") - * @param {object} log - Logger instance - * @returns {Promise} Wikipedia result with title, summary, and pageId - */ -export async function fetchWikipediaSummary(searchQuery, log) { - log.info(`Fetching Wikipedia summary for: ${searchQuery}`); - - try { - // First, search for the page - const searchParams = new URLSearchParams({ - action: 'opensearch', - search: searchQuery, - limit: '5', - namespace: '0', - format: 'json', - }); - - const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; - const searchResp = await timedFetch(searchUrl, { - headers: { 'User-Agent': USER_AGENT }, - }); - - if (!searchResp.ok) { - throw new Error(`Wikipedia search failed: ${searchResp.status}`); - } - - const searchData = await searchResp.json(); - const titles = searchData[1] || []; - - if (titles.length === 0) { - log.info(`No Wikipedia results found for: ${searchQuery}`); - return null; - } - - // Use the first result - const title = titles[0]; - - // Now fetch the summary - const summaryParams = new URLSearchParams({ - action: 'query', - titles: title, - prop: 'extracts|pageprops', - exintro: 'true', - explaintext: 'true', - ppprop: 'wikibase_item', - format: 'json', - }); - - const summaryUrl = `${WIKIPEDIA_API_BASE}?${summaryParams}`; - const summaryResp = await timedFetch(summaryUrl, { - headers: { 'User-Agent': USER_AGENT }, - }); - - if (!summaryResp.ok) { - throw new Error(`Wikipedia summary fetch failed: ${summaryResp.status}`); - } - - const summaryData = await summaryResp.json(); - const pages = summaryData.query?.pages || {}; - const pageId = Object.keys(pages)[0]; - - if (!pageId || pageId === '-1') { - log.info(`Wikipedia page not found for: ${title}`); - return null; - } - - const page = pages[pageId]; - const wikidataId = page.pageprops?.wikibase_item || null; - - log.info(`Found Wikipedia summary for "${title}" (wikidata: ${wikidataId})`); - - return { - title: page.title, - summary: page.extract || '', - pageId: parseInt(pageId, 10), - wikidataId, - }; - } catch (e) { - log.error(`Error fetching Wikipedia summary: ${e.message}`); - return null; + const labels = String(domain || '') + .toLowerCase() + .split('.') + .filter(Boolean); + if (labels.length < 2) { + return true; } -} - -/** - * Fetch full Wikipedia article text for deeper extraction. - * @deprecated LLMO-6580: this does an unbound `opensearch` by name and blindly takes - * `titles[0]`, which let acronyms fuzzy-match foreign articles (d*->"D-Company"). - * Use {@link fetchWikipediaExtractByTitle} with a validated entity's exact enwiki title. - * @param {string} searchQuery - Search query - * @param {number} [maxChars=12000] - Maximum characters to return - * @param {object} log - Logger instance - * @returns {Promise} Article text or null - */ -export async function fetchWikipediaFullText(searchQuery, maxChars, log) { - const limit = maxChars || 12000; - log.info(`Fetching full Wikipedia text for: ${searchQuery} (max ${limit} chars)`); - - try { - // Search for the page first - const searchParams = new URLSearchParams({ - action: 'opensearch', - search: searchQuery, - limit: '1', - namespace: '0', - format: 'json', - }); - - const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; - const searchResp = await timedFetch(searchUrl, { - headers: { 'User-Agent': USER_AGENT }, - }); - - if (!searchResp.ok) { - throw new Error(`Wikipedia search failed: ${searchResp.status}`); - } - - const searchData = await searchResp.json(); - const titles = searchData[1] || []; - - if (titles.length === 0) { - log.info(`No Wikipedia results found for: ${searchQuery}`); - return null; - } - - const title = titles[0]; - - // Fetch full extract - const contentParams = new URLSearchParams({ - action: 'query', - titles: title, - prop: 'extracts', - explaintext: 'true', - format: 'json', - }); - - const contentUrl = `${WIKIPEDIA_API_BASE}?${contentParams}`; - const contentResp = await timedFetch(contentUrl, { - headers: { 'User-Agent': USER_AGENT }, - }); - - if (!contentResp.ok) { - throw new Error(`Wikipedia content fetch failed: ${contentResp.status}`); - } - - const contentData = await contentResp.json(); - const pages = contentData.query?.pages || {}; - const pageId = Object.keys(pages)[0]; - - if (!pageId || pageId === '-1') { - return null; - } - - const extract = pages[pageId].extract || ''; - const truncated = extract.slice(0, limit); - - log.info(`Fetched ${truncated.length} chars of Wikipedia text for "${title}"`); - - return truncated; - } catch (e) { - log.error(`Error fetching Wikipedia full text: ${e.message}`); - return null; + if (MULTI_PART_TLDS.has(labels.join('.'))) { + return true; } -} - -/** - * Find a brand's Wikidata ID by name. - * @deprecated LLMO-6580: returns an entity by fuzzy name match with no validation - * against the customer's site. Use {@link findValidatedWikidataEntity} instead. - * @param {string} brandName - Brand name to search for - * @param {object} log - Logger instance - * @returns {Promise} Wikidata entity ID (e.g., "Q217994") or null - */ -export async function findWikidataId(brandName, log) { - log.info(`Searching Wikidata for: ${brandName}`); - - try { - const params = new URLSearchParams({ - action: 'wbsearchentities', - search: brandName, - language: 'en', - limit: '5', - format: 'json', - }); - - const url = `${WIKIDATA_API}?${params}`; - const resp = await timedFetch(url, { - headers: { 'User-Agent': USER_AGENT }, - }); - - if (!resp.ok) { - throw new Error(`Wikidata search failed: ${resp.status}`); + if (labels.length === 2) { + const [secondLevel, tld] = labels; + if (tld.length === 2 && GENERIC_SECOND_LEVELS.has(secondLevel)) { + return true; } - - const data = await resp.json(); - const results = data.search || []; - - if (results.length === 0) { - log.info(`No Wikidata entity found for: ${brandName}`); - return null; - } - - // Look for the best match (company/brand/organization) - const companyTerms = [ - 'company', 'brand', 'manufacturer', 'corporation', - 'automaker', 'enterprise', 'business', 'organization', - 'subsidiary', 'division', - ]; - - for (const entity of results) { - const description = (entity.description || '').toLowerCase(); - if (companyTerms.some((term) => description.includes(term))) { - log.info(`Found Wikidata entity: ${entity.id} - ${description}`); - return entity.id; - } - } - - // If no company found, return the first result - const firstResult = results[0].id; - log.info(`Using first Wikidata result: ${firstResult}`); - return firstResult; - } catch (e) { - log.error(`Error searching Wikidata: ${e.message}`); - return null; } + return false; } /** - * Fetch a Wikidata entity's ground truth: its English label/aliases, its own - * English Wikipedia article title, and the hosts of its official website (P856). + * Fetch a Wikidata entity's ground truth: its English label, its own English Wikipedia + * article title, and the hosts of its official website (P856). * @param {string} entityId - Wikidata entity ID (e.g., "Q489815") * @param {object} log - Logger instance - * @returns {Promise} { id, label, aliases, enwikiTitle, officialWebsiteHosts } or null + * @returns {Promise} { id, label, enwikiTitle, officialWebsiteHosts } or null */ export async function getWikidataEntity(entityId, log) { log.info(`Fetching Wikidata entity: ${entityId}`); @@ -298,7 +88,7 @@ export async function getWikidataEntity(entityId, log) { const params = new URLSearchParams({ action: 'wbgetentities', ids: entityId, - props: 'labels|aliases|sitelinks|claims', + props: 'labels|sitelinks|claims', languages: 'en', sitefilter: 'enwiki', format: 'json', @@ -321,7 +111,6 @@ export async function getWikidataEntity(entityId, log) { } const label = entity.labels?.en?.value || null; - const aliases = (entity.aliases?.en || []).map((a) => a.value).filter(Boolean); const enwikiTitle = entity.sitelinks?.enwiki?.title || null; const officialWebsiteHosts = (entity.claims?.P856 || []) @@ -337,7 +126,10 @@ export async function getWikidataEntity(entityId, log) { .filter(Boolean); return { - id: entityId, label, aliases, enwikiTitle, officialWebsiteHosts, + id: entityId, + label, + enwikiTitle, + officialWebsiteHosts, }; } catch (e) { log.error(`Error fetching Wikidata entity ${entityId}: ${e.message}`); @@ -346,79 +138,38 @@ export async function getWikidataEntity(entityId, log) { } /** - * Normalize a company name for weak (label) comparison: lower-case, drop corporate - * suffixes and punctuation, collapse whitespace. - * @param {string} value - Raw name - * @returns {string} Normalized name - */ -function normalizeName(value) { - return String(value || '') - .toLowerCase() - .replace(/&/g, ' and ') - .replace(CORP_SUFFIXES, ' ') - .replace(/[^a-z0-9\s]/g, ' ') - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * Validate a Wikidata entity against the customer's site. - * - * - Strong (`p856`): any official-website host's registrable domain equals the - * site's registrable domain. Decisive signal (DHL->dhl.com, DNP->dnp.co.jp). - * - Weak (`label`): entity label/alias token-overlap with the brand name. - * - Low-confidence brand names accept ONLY `p856` (never the weak label match). + * Validate a Wikidata entity against the customer's site via a strong P856 match: + * any official-website host's registrable domain equals the site's registrable domain + * (DHL->dhl.com, DNP->dnp.co.jp). This is the only accepted signal; a bare public-suffix + * site domain (e.g. `co.uk`) never matches. No by-name / label fallback (LLMO-6580). * * @param {object} params - Parameters * @param {object} params.entity - Entity from {@link getWikidataEntity} - * @param {string} params.brandName - Resolved brand name - * @param {string} params.brandConfidence - 'high' | 'medium' | 'low' * @param {string} params.registrableDomain - Site registrable domain * @returns {{ok: boolean, method: (string|null), reason: string}} */ -export function validateEntityAgainstSite({ - entity, brandName, brandConfidence, registrableDomain, -}) { +export function validateEntityAgainstSite({ entity, registrableDomain }) { if (!entity) { return { ok: false, method: null, reason: 'no_entity' }; } - // Strong P856 match: entity's own official website registrable domain == site's. - // Never accept when the site's registrable domain is a bare public suffix (e.g. `co.uk`), - // or any foreign entity on the same suffix would falsely validate (LLMO-6580). - const hosts = entity.officialWebsiteHosts || []; - if (!isBareSuffix(registrableDomain)) { - for (const host of hosts) { - const { registrableDomain: entityRegDomain } = splitHost(host); - if (entityRegDomain && registrableDomain && entityRegDomain === registrableDomain) { - return { ok: true, method: 'p856', reason: `P856 host ${host} matches site ${registrableDomain}` }; - } - } - } - - // Low-confidence acronyms may proceed only via P856 (already checked above). - if (brandConfidence === 'low') { - return { ok: false, method: null, reason: 'low_confidence_requires_p856' }; + if (isBareSuffix(registrableDomain)) { + return { ok: false, method: null, reason: 'no_match' }; } - // Weak label/alias containment match. Require one token set to FULLY contain the other - // (after corp-suffix stripping) so a qualifier variant ("Amrize" / "Amrize Holdings", - // "The Home Depot" / "Home Depot") matches, but two distinct names that merely share a - // token ("Swiss Life" / "Swiss Re", "Bank of America" / "Bank of Scotland") do NOT — a - // shared single token was the residual sibling-entity contamination vector (LLMO-6580). - const brandTokens = new Set(normalizeName(brandName).split(' ').filter(Boolean)); - if (brandTokens.size > 0) { - const candidates = [entity.label, ...(entity.aliases || [])].filter(Boolean); - for (const candidate of candidates) { - const candTokens = new Set(normalizeName(candidate).split(' ').filter(Boolean)); - if (candTokens.size > 0) { - const [smaller, larger] = brandTokens.size <= candTokens.size - ? [brandTokens, candTokens] : [candTokens, brandTokens]; - const contained = [...smaller].every((t) => larger.has(t)); - if (contained) { - return { ok: true, method: 'label', reason: `label match "${candidate}"` }; - } - } + const hosts = entity.officialWebsiteHosts || []; + for (const host of hosts) { + const { registrableDomain: entityRegDomain } = splitHost(host); + if ( + entityRegDomain + && registrableDomain + && entityRegDomain === registrableDomain + ) { + return { + ok: true, + method: 'p856', + reason: `P856 host ${host} matches site ${registrableDomain}`, + }; } } @@ -426,7 +177,7 @@ export function validateEntityAgainstSite({ } /** - * Search Wikidata for candidate entity IDs by name (keeps ALL candidates). + * Search Wikidata for candidate entity IDs by name (keeps ALL candidates for validation). * @param {string} brandName - Brand name to search for * @param {object} log - Logger instance * @returns {Promise} Candidate entity IDs (order preserved) @@ -459,56 +210,45 @@ async function searchWikidataCandidates(brandName, log) { } /** - * Find the first Wikidata entity that VALIDATES against the site. - * Prefers a strong P856 match; falls back to the first weak label match - * (only for non-low-confidence brand names). Returns null if nothing validates. + * Find the first Wikidata entity that VALIDATES against the site by a strong P856 match. + * Returns null if nothing validates. * - * @param {object} params - { brandName, brandConfidence, registrableDomain } + * @param {object} params - { brandName, registrableDomain } * @param {object} log - Logger instance - * @returns {Promise} Entity (+ `validation` method) or null + * @returns {Promise} Entity (+ `validation: 'p856'`) or null */ -export async function findValidatedWikidataEntity({ - brandName, brandConfidence, registrableDomain, -}, log) { +export async function findValidatedWikidataEntity( + { brandName, registrableDomain }, + log, +) { const candidateIds = await searchWikidataCandidates(brandName, log); if (candidateIds.length === 0) { log.info(`No Wikidata candidates for: ${brandName}`); return null; } - let labelMatch = null; for (const id of candidateIds) { // eslint-disable-next-line no-await-in-loop const entity = await getWikidataEntity(id, log); const validation = entity - ? validateEntityAgainstSite({ - entity, brandName, brandConfidence, registrableDomain, - }) + ? validateEntityAgainstSite({ entity, registrableDomain }) : { ok: false, method: null, reason: 'entity_fetch_failed' }; if (validation.ok && validation.method === 'p856') { log.info(`Validated Wikidata entity ${id} for "${brandName}" via P856`); return { ...entity, validation: 'p856' }; } - if (validation.ok && validation.method === 'label') { - // Keep the FIRST label match as a fallback; a later P856 match still wins. - if (!labelMatch) { - labelMatch = { ...entity, validation: 'label' }; - } - } else { - log.info(`Rejected Wikidata candidate ${id} for "${brandName}": ${validation.reason}`); - } + log.info( + `Rejected Wikidata candidate ${id} for "${brandName}": ${validation.reason}`, + ); } - if (labelMatch) { - log.info(`Using label-validated Wikidata entity ${labelMatch.id} for "${brandName}"`); - } - return labelMatch; + return null; } /** - * Fetch a Wikipedia extract for an EXACT enwiki title (no opensearch, no by-name - * search). This is the entity-bound replacement for {@link fetchWikipediaFullText}. + * Fetch a Wikipedia extract for an EXACT enwiki title (no opensearch, no by-name search). + * The title comes from a validated entity's sitelink. * @param {string} title - Exact enwiki article title (from a validated entity sitelink) * @param {number} [maxChars=12000] - Maximum characters to return * @param {object} log - Logger instance @@ -519,7 +259,9 @@ export async function fetchWikipediaExtractByTitle(title, maxChars, log) { if (!title) { return null; } - log.info(`Fetching Wikipedia extract for exact title "${title}" (max ${limit} chars)`); + log.info( + `Fetching Wikipedia extract for exact title "${title}" (max ${limit} chars)`, + ); try { const params = new URLSearchParams({ @@ -556,19 +298,21 @@ export async function fetchWikipediaExtractByTitle(title, maxChars, log) { } /** - * Fetch a validated intro summary: resolve+validate the entity, then fetch the - * intro extract for that entity's EXACT enwiki title. Returns null when nothing - * validates or the entity has no English Wikipedia article. - * @param {object} params - { brandName, brandConfidence, registrableDomain } + * Fetch a validated intro summary: resolve+validate the entity, then fetch the intro + * extract for that entity's EXACT enwiki title. Returns null when nothing validates or + * the entity has no English Wikipedia article. + * @param {object} params - { brandName, registrableDomain } * @param {object} log - Logger instance * @returns {Promise} { title, summary, entityId } or null */ -export async function fetchValidatedSummary({ - brandName, brandConfidence, registrableDomain, -}, log) { - const entity = await findValidatedWikidataEntity({ - brandName, brandConfidence, registrableDomain, - }, log); +export async function fetchValidatedSummary( + { brandName, registrableDomain }, + log, +) { + const entity = await findValidatedWikidataEntity( + { brandName, registrableDomain }, + log, + ); if (!entity || !entity.enwikiTitle) { return null; @@ -613,15 +357,12 @@ export async function fetchValidatedSummary({ } /** - * Create a Wikipedia service instance. + * Create a Wikipedia service instance (entity-bound methods only). * @param {object} log - Logger instance * @returns {object} Service instance with bound methods */ export function createWikipediaService(log) { return { - fetchSummary: (searchQuery) => fetchWikipediaSummary(searchQuery, log), - fetchFullText: (searchQuery, maxChars) => fetchWikipediaFullText(searchQuery, maxChars, log), - findWikidataId: (brandName) => findWikidataId(brandName, log), getWikidataEntity: (entityId) => getWikidataEntity(entityId, log), findValidatedWikidataEntity: (params) => findValidatedWikidataEntity(params, log), fetchExtractByTitle: (title, maxChars) => fetchWikipediaExtractByTitle(title, maxChars, log), diff --git a/test/agents/brand-profile/index.test.js b/test/agents/brand-profile/index.test.js index a8c6e56d..83c0c752 100644 --- a/test/agents/brand-profile/index.test.js +++ b/test/agents/brand-profile/index.test.js @@ -84,8 +84,6 @@ describe('agents/brand-profile', () => { }, '../../../src/agents/brand-profile/services/wikipedia.js': { createWikipediaService: () => ({ - fetchSummary: sb.stub().resolves(null), - fetchFullText: sb.stub().resolves(null), fetchValidatedSummary: sb.stub().resolves(null), }), }, @@ -275,14 +273,16 @@ describe('agents/brand-profile', () => { // Competitor path used the VALIDATED summary (entity-bound), not a by-name lookup. expect(mockWikipediaService.fetchValidatedSummary).to.have.been.calledWithExactly({ brandName: 'Swisslife', - brandConfidence: 'high', registrableDomain: 'swisslife.ch', }); + // ...and forwarded that summary text into competitor inference. + expect(mockCompetitorService.inferCompetitors).to.have.been.calledWithExactly( + sinon.match({ wikipediaSummary: 'Company summary' }), + ); // Product path forwarded the options object with the resolved identity + flag. expect(mockProductService.extractProducts).to.have.been.calledWithExactly({ brandName: 'Swisslife', - brandConfidence: 'high', registrableDomain: 'swisslife.ch', enableWikiProducts: true, }); diff --git a/test/agents/brand-profile/services/product-extractor.test.js b/test/agents/brand-profile/services/product-extractor.test.js index d62167fa..26803d21 100644 --- a/test/agents/brand-profile/services/product-extractor.test.js +++ b/test/agents/brand-profile/services/product-extractor.test.js @@ -32,15 +32,12 @@ const searchResp = (ids) => ({ json: () => Promise.resolve({ search: ids.map((id) => ({ id })) }), }); -const entityResp = (id, { - label, enwikiTitle, hosts = [], aliases = [], -}) => ({ +const entityResp = (id, { label, enwikiTitle, hosts = [] }) => ({ ok: true, json: () => Promise.resolve({ entities: { [id]: { labels: label ? { en: { value: label } } : {}, - aliases: { en: aliases.map((value) => ({ value })) }, sitelinks: enwikiTitle ? { enwiki: { title: enwikiTitle } } : {}, claims: hosts.length ? { P856: hosts.map((h) => ({ mainsnak: { datavalue: { value: `https://${h}` } } })) } @@ -321,42 +318,9 @@ describe('services/product-extractor', () => { expect(result.metadata.confidence).to.equal('unknown'); expect(result.metadata.notes).to.equal(''); }); - - it('keeps and flags sensitive own-site content (harm gate backstop)', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(` - - https://beretta.com/products/pistols - - `), - }); - - gpt.fetchChatCompletion.resolves(llmResp({ - products: [ - { name: '92FS', category: 'Firearm' }, - { name: 'Accessories', category: 'Gear' }, - ], - services: [], - sub_brands: [], - discontinued: [], - })); - - const result = await extractFromSitemap( - 'https://beretta.com/sitemap.xml', - 'Beretta', - gpt, - log, - ); - - // Own-site provenance: legitimate sensitive content is kept, not dropped. - expect(result.products).to.have.length(2); - expect(result.metadata.sensitive_category).to.equal(true); - expect(result.metadata.safety_filtered).to.be.undefined; - }); }); - describe('extractProducts (entity-bound)', () => { + describe('extractProducts (entity-bound, P856-only)', () => { it('returns validated Wikidata products (happy path, P856 match)', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['www.dhl.com'] })); @@ -367,7 +331,7 @@ describe('services/product-extractor', () => { ])); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -387,13 +351,13 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'Dnp', brandConfidence: 'low', registrableDomain: 'dnp.co.jp' }, + { brandName: 'Dnp', registrableDomain: 'dnp.co.jp' }, gpt, log, ); expect(result.products).to.have.length(0); - expect(result.metadata.source).to.equal('skipped_low_confidence'); + expect(result.metadata.source).to.equal('none_no_validated_entity'); expect(result.metadata.rejected).to.equal(true); // The decoupled `opensearch "Dnp company"` fetch must never be issued. expect(noOpenSearchIssued(fetchStub)).to.equal(true); @@ -407,22 +371,22 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'Edb', brandConfidence: 'low', registrableDomain: 'edb.gov.sg' }, + { brandName: 'Edb', registrableDomain: 'edb.gov.sg' }, gpt, log, ); expect(result.products).to.have.length(0); - expect(result.metadata.source).to.equal('skipped_low_confidence'); + expect(result.metadata.source).to.equal('none_no_validated_entity'); expect(noOpenSearchIssued(fetchStub)).to.equal(true); }); - it('returns none_no_validated_entity for a non-low-confidence name with no match', async () => { + it('returns none_no_validated_entity when a candidate has a non-matching P856 host', async () => { fetchStub.onCall(0).resolves(searchResp(['Q9'])); fetchStub.onCall(1).resolves(entityResp('Q9', { label: 'Totally Different', hosts: ['other.example'] })); const result = await extractProducts( - { brandName: 'Amrize', brandConfidence: 'medium', registrableDomain: 'amrize.com' }, + { brandName: 'Amrize', registrableDomain: 'amrize.com' }, gpt, log, ); @@ -449,7 +413,7 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -463,9 +427,9 @@ describe('services/product-extractor', () => { expect(result.products.length).to.be.greaterThan(1); }); - it('produces a wikipedia_llm result when SPARQL is empty but the entity validates (label)', async () => { + it('produces a wikipedia_llm result when SPARQL is empty but the entity validates (P856)', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); - fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Amrize', enwikiTitle: 'Amrize', hosts: [] })); + fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Amrize', enwikiTitle: 'Amrize', hosts: ['amrize.com'] })); fetchStub.onCall(2).resolves(sparqlResp([])); fetchStub.onCall(3).resolves(extractResp('Amrize makes Cement and Aggregates.')); @@ -477,13 +441,13 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'Amrize', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, + { brandName: 'Amrize', registrableDomain: 'amrize.com' }, gpt, log, ); expect(result.metadata.source).to.equal('wikipedia_llm'); - expect(result.metadata.validation).to.equal('label'); + expect(result.metadata.validation).to.equal('p856'); expect(result.products).to.have.length(2); }); @@ -495,7 +459,7 @@ describe('services/product-extractor', () => { ])); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -509,7 +473,7 @@ describe('services/product-extractor', () => { it('is a hard no-op when the wiki-products kill-switch is off', async () => { const result = await extractProducts( { - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', enableWikiProducts: false, + brandName: 'DHL', registrableDomain: 'dhl.com', enableWikiProducts: false, }, gpt, log, @@ -535,7 +499,6 @@ describe('services/product-extractor', () => { const result = await extractProducts( { brandName: 'Amrize', - brandConfidence: 'low', registrableDomain: 'amrize.com', wikipediaSummary: 'Amrize makes ProvidedProduct.', }, @@ -549,66 +512,6 @@ describe('services/product-extractor', () => { expect(fetchStub.callCount).to.equal(3); }); - it('hard-drops harmful content from a weakly (label) validated entity', async () => { - fetchStub.onCall(0).resolves(searchResp(['Q1'])); - fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Acme', enwikiTitle: 'Acme', hosts: [] })); - fetchStub.onCall(2).resolves(sparqlResp([])); - fetchStub.onCall(3).resolves(extractResp('Acme is a company.')); - - gpt.fetchChatCompletion.resolves(llmResp({ - products: [ - { name: 'Assault Rifle', category: 'Weapon' }, - { name: 'Notebook', category: 'Stationery' }, - ], - services: [], - sub_brands: ['Terror Cell'], - discontinued: [], - })); - - const result = await extractProducts( - { brandName: 'Acme', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, - gpt, - log, - ); - - expect(result.metadata.validation).to.equal('label'); - expect(result.metadata.safety_filtered).to.equal(true); - expect(result.products.map((p) => p.name)).to.deep.equal(['Notebook']); - expect(result.sub_brands).to.deep.equal([]); - }); - - it('keeps benign names that merely contain a denylist substring, and catches plural forms', async () => { - // Weak (label) provenance => harmful items are hard-dropped. Benign names whose - // substrings look like a stem must survive (the denylist claims to avoid false hits); - // the plural 'Escorts' must be caught by the word-boundary pattern. - fetchStub.onCall(0).resolves(searchResp(['Q1'])); - fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Acme', enwikiTitle: 'Acme', hosts: [] })); - fetchStub.onCall(2).resolves(sparqlResp([])); - fetchStub.onCall(3).resolves(extractResp('Acme is a company.')); - - gpt.fetchChatCompletion.resolves(llmResp({ - products: [ - { name: 'Armature Motor', category: 'Component' }, - { name: 'Churchill Series', category: 'Model' }, - { name: 'Escorts', category: 'Service' }, - ], - services: [], - sub_brands: [], - discontinued: [], - })); - - const result = await extractProducts( - { brandName: 'Acme', brandConfidence: 'high', registrableDomain: 'somethingelse.com' }, - gpt, - log, - ); - - expect(result.metadata.validation).to.equal('label'); - expect(result.metadata.safety_filtered).to.equal(true); - // Benign near-misses kept; only the true (plural) hit dropped. - expect(result.products.map((p) => p.name)).to.deep.equal(['Armature Motor', 'Churchill Series']); - }); - it('refuses a SPARQL query when the resolved entity id is malformed (injection guard)', async () => { // Entity validates via P856 but carries a non-Q id; the SPARQL template substitution // must be refused rather than issued. @@ -621,7 +524,7 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'Acme', brandConfidence: 'low', registrableDomain: 'acme.com' }, + { brandName: 'Acme', registrableDomain: 'acme.com' }, gpt, log, ); @@ -634,28 +537,6 @@ describe('services/product-extractor', () => { expect(sparqlIssued).to.equal(false); }); - it('keeps but flags harmful content from a strongly (P856) validated entity', async () => { - fetchStub.onCall(0).resolves(searchResp(['Q1'])); - fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Beretta', enwikiTitle: 'Beretta', hosts: ['www.beretta.com'] })); - fetchStub.onCall(2).resolves(sparqlResp([ - { itemLabel: { value: '92FS' }, item: { value: 'http://wikidata.org/Q11' }, typeLabel: { value: 'firearm' } }, - { itemLabel: { value: 'M9' }, item: { value: 'http://wikidata.org/Q12' }, typeLabel: { value: 'weapon' } }, - { itemLabel: { value: 'Holster' }, item: { value: 'http://wikidata.org/Q13' }, typeLabel: { value: 'accessory' } }, - ])); - - const result = await extractProducts( - { brandName: 'Beretta', brandConfidence: 'low', registrableDomain: 'beretta.com' }, - gpt, - log, - ); - - expect(result.metadata.validation).to.equal('p856'); - expect(result.metadata.sensitive_category).to.equal(true); - expect(result.metadata.safety_filtered).to.be.undefined; - // Legit defense customer's products are preserved. - expect(result.products).to.have.length(3); - }); - it('merges hybrid results and de-duplicates overlaps', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); @@ -682,7 +563,7 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -706,7 +587,7 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -724,7 +605,7 @@ describe('services/product-extractor', () => { gpt.fetchChatCompletion.rejects(new Error('LLM failed')); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -746,12 +627,17 @@ describe('services/product-extractor', () => { })); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); - expect(gpt.fetchChatCompletion).to.have.been.called; + // The 9000-char extract must be truncated to 8000 chars + ellipsis before the LLM + // sees it. Assert on the rendered prompt so removing the truncation fails the test. + const prompt = gpt.fetchChatCompletion.firstCall.args[0]; + expect(prompt).to.include('...'); + expect(prompt).to.include('A'.repeat(8000)); + expect(prompt).to.not.include('A'.repeat(9000)); expect(result.products).to.have.length(1); }); @@ -764,7 +650,7 @@ describe('services/product-extractor', () => { gpt.fetchChatCompletion.resolves({ choices: [] }); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -784,7 +670,7 @@ describe('services/product-extractor', () => { ])); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -815,7 +701,7 @@ describe('services/product-extractor', () => { ])); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -837,7 +723,7 @@ describe('services/product-extractor', () => { ])); const result = await extractProducts( - { brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com' }, + { brandName: 'DHL', registrableDomain: 'dhl.com' }, gpt, log, ); @@ -979,7 +865,11 @@ describe('services/product-extractor', () => { log, ); - expect(gpt.fetchChatCompletion).to.have.been.called; + // A product-name-pattern URL is kept while an excluded section is dropped: assert on + // the rendered prompt so a broken filterProductUrls would fail the test. + const prompt = gpt.fetchChatCompletion.firstCall.args[0]; + expect(prompt).to.include('example.com/widget-pro'); + expect(prompt).to.not.include('example.com/about'); }); }); diff --git a/test/agents/brand-profile/services/wikipedia.test.js b/test/agents/brand-profile/services/wikipedia.test.js index e969c623..ec843d86 100644 --- a/test/agents/brand-profile/services/wikipedia.test.js +++ b/test/agents/brand-profile/services/wikipedia.test.js @@ -19,6 +19,8 @@ import esmock from 'esmock'; use(sinonChai); use(chaiAsPromised); +const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); + describe('services/wikipedia', () => { let sandbox; let log; @@ -39,487 +41,14 @@ describe('services/wikipedia', () => { sandbox.restore(); }); - describe('fetchWikipediaSummary', () => { - it('fetches and returns Wikipedia summary', async () => { - // Mock search response - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve([ - 'Swiss Life', - ['Swiss Life'], - [''], - ['https://en.wikipedia.org/wiki/Swiss_Life'], - ]), - }); - - // Mock summary response - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - title: 'Swiss Life', - extract: 'Swiss Life is a Swiss insurance company...', - pageprops: { wikibase_item: 'Q680290' }, - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Swiss Life company', log); - - expect(result.title).to.equal('Swiss Life'); - expect(result.summary).to.include('Swiss insurance company'); - expect(result.wikidataId).to.equal('Q680290'); - }); - - it('returns null when no search results', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve(['Swiss Life', [], [], []]), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Unknown Company', log); - - expect(result).to.be.null; - }); - - it('returns null on fetch error', async () => { - fetchStub.rejects(new Error('Network error')); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result).to.be.null; - expect(log.error).to.have.been.called; - }); - - it('throws when search response is not ok', async () => { - fetchStub.resolves({ - ok: false, - status: 500, - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result).to.be.null; - expect(log.error).to.have.been.calledWithMatch('Wikipedia search failed'); - }); - - it('throws when summary response is not ok', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: false, - status: 503, - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result).to.be.null; - expect(log.error).to.have.been.calledWithMatch('Wikipedia summary fetch failed'); - }); - - it('returns null when page not found (pageId is -1)', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - '-1': { missing: true }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result).to.be.null; - }); - }); - - describe('fetchWikipediaFullText', () => { - it('fetches full Wikipedia article text', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Swiss Life', ['Swiss Life'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - extract: 'Full article content...', - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Swiss Life company', 12000, log); - - expect(result).to.equal('Full article content...'); - }); - - it('truncates content to maxChars', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test'], [], []]), - }); - - const longText = 'A'.repeat(20000); - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - extract: longText, - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 1000, log); - - expect(result.length).to.equal(1000); - }); - - it('returns null when no search results', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve(['Test', [], [], []]), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Unknown', 12000, log); - - expect(result).to.be.null; - }); - - it('returns null when search response not ok', async () => { - fetchStub.resolves({ - ok: false, - status: 500, - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - expect(result).to.be.null; - expect(log.error).to.have.been.calledWithMatch('Wikipedia search failed'); - }); - - it('returns null when content response not ok', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: false, - status: 503, - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - expect(result).to.be.null; - expect(log.error).to.have.been.calledWithMatch('Wikipedia content fetch failed'); - }); - - it('returns null when page not found (pageId is -1)', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - '-1': { missing: true }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - expect(result).to.be.null; - }); - - it('returns null on fetch error', async () => { - fetchStub.rejects(new Error('Network error')); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - expect(result).to.be.null; - expect(log.error).to.have.been.called; - }); - - it('uses default maxChars when not provided', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - extract: 'Short content', - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', null, log); - - expect(result).to.equal('Short content'); - }); - }); - - describe('findWikidataId', () => { - it('finds Wikidata ID for a brand', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ - search: [ - { id: 'Q12345', description: 'American technology company' }, - { id: 'Q67890', description: 'unrelated' }, - ], - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Adobe', log); - - expect(result).to.equal('Q12345'); - }); - - it('returns first result if no company match', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ - search: [ - { id: 'Q99999', description: 'Something else' }, - ], - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Unknown', log); - - expect(result).to.equal('Q99999'); - }); - - it('returns null when no results', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ search: [] }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('NonexistentBrand', log); - - expect(result).to.be.null; - }); - - it('returns null when response not ok', async () => { - fetchStub.resolves({ - ok: false, - status: 500, - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Test', log); - - expect(result).to.be.null; - expect(log.error).to.have.been.calledWithMatch('Wikidata search failed'); - }); - - it('returns null on fetch error', async () => { - fetchStub.rejects(new Error('Network error')); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Test', log); - - expect(result).to.be.null; - expect(log.error).to.have.been.called; - }); - - it('handles entity with no description', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve({ - search: [ - { id: 'Q11111' }, - ], - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Test', log); - - expect(result).to.equal('Q11111'); - }); - }); - - describe('createWikipediaService', () => { - it('creates service with bound methods', async () => { - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const service = mod.createWikipediaService(log); - - expect(service).to.have.property('fetchSummary'); - expect(service).to.have.property('fetchFullText'); - expect(service).to.have.property('findWikidataId'); - expect(service).to.have.property('getWikidataEntity'); - expect(service).to.have.property('findValidatedWikidataEntity'); - expect(service).to.have.property('fetchExtractByTitle'); - expect(service).to.have.property('fetchValidatedSummary'); - }); - - it('service methods can be called', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve(['Test', [], [], []]), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const service = mod.createWikipediaService(log); - const result = await service.fetchSummary('Test'); - - expect(result).to.be.null; - }); - }); - describe('getWikidataEntity', () => { - const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); - - it('parses label, aliases, enwiki title and P856 hosts', async () => { + it('parses label, enwiki title and P856 hosts', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ entities: { Q489815: { labels: { en: { value: 'DHL' } }, - aliases: { en: [{ value: 'DHL Express' }] }, sitelinks: { enwiki: { title: 'DHL' } }, claims: { P856: [ @@ -536,9 +65,10 @@ describe('services/wikipedia', () => { expect(entity.id).to.equal('Q489815'); expect(entity.label).to.equal('DHL'); - expect(entity.aliases).to.deep.equal(['DHL Express']); expect(entity.enwikiTitle).to.equal('DHL'); expect(entity.officialWebsiteHosts).to.deep.equal(['www.dhl.com']); + // Aliases are no longer parsed (P856-only design). + expect(entity).to.not.have.property('aliases'); }); it('handles missing claims and missing sitelink and invalid P856 URLs', async () => { @@ -561,8 +91,8 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.getWikidataEntity('Q1', log); + expect(entity.label).to.equal('NoWiki'); expect(entity.enwikiTitle).to.be.null; - expect(entity.aliases).to.deep.equal([]); expect(entity.officialWebsiteHosts).to.deep.equal([]); }); @@ -586,8 +116,8 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.getWikidataEntity('Q1', log); expect(entity.label).to.be.null; - expect(entity.aliases).to.deep.equal([]); expect(entity.enwikiTitle).to.be.null; + expect(entity.officialWebsiteHosts).to.deep.equal([]); }); it('returns null when response is not ok', async () => { @@ -606,15 +136,11 @@ describe('services/wikipedia', () => { }); }); - describe('validateEntityAgainstSite', () => { - const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); - + describe('validateEntityAgainstSite (P856-only)', () => { it('accepts a P856 host whose registrable domain matches the site (co.jp)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'Dai Nippon Printing', aliases: [], officialWebsiteHosts: ['www.dnp.co.jp'] }, - brandName: 'Dnp', - brandConfidence: 'low', + entity: { label: 'Dai Nippon Printing', officialWebsiteHosts: ['www.dnp.co.jp'] }, registrableDomain: 'dnp.co.jp', }); expect(result.ok).to.equal(true); @@ -624,35 +150,20 @@ describe('services/wikipedia', () => { it('rejects a P856 host on a different registrable domain (dnb.de vs dnb.com)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'German National Library', aliases: [], officialWebsiteHosts: ['www.dnb.de'] }, - brandName: 'Dnb', - brandConfidence: 'low', + entity: { label: 'German National Library', officialWebsiteHosts: ['www.dnb.de'] }, registrableDomain: 'dnb.com', }); expect(result.ok).to.equal(false); - expect(result.reason).to.equal('low_confidence_requires_p856'); + expect(result.method).to.be.null; + expect(result.reason).to.equal('no_match'); }); - it('accepts a label token-overlap match for a high-confidence name', async () => { + it('rejects an entity with no P856 host (no by-name / label fallback)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'Dun & Bradstreet Inc', aliases: [], officialWebsiteHosts: [] }, - brandName: 'Dun & Bradstreet', - brandConfidence: 'high', + entity: { label: 'Dun & Bradstreet Inc', officialWebsiteHosts: [] }, registrableDomain: 'dnb.com', }); - expect(result.ok).to.equal(true); - expect(result.method).to.equal('label'); - }); - - it('rejects a sibling entity that only shares one token (Swiss Life vs Swiss Re)', async () => { - const mod = await importMod(); - const result = mod.validateEntityAgainstSite({ - entity: { label: 'Swiss Re', aliases: [], officialWebsiteHosts: ['www.swissre.com'] }, - brandName: 'Swiss Life', - brandConfidence: 'high', - registrableDomain: 'swisslife.ch', - }); expect(result.ok).to.equal(false); expect(result.reason).to.equal('no_match'); }); @@ -660,87 +171,66 @@ describe('services/wikipedia', () => { it('rejects a P856 match when the site registrable domain is a bare public suffix', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'Some Foreign Org', aliases: [], officialWebsiteHosts: ['co.uk'] }, - brandName: 'Whatever', - brandConfidence: 'high', + entity: { label: 'Some Foreign Org', officialWebsiteHosts: ['co.uk'] }, registrableDomain: 'co.uk', }); expect(result.ok).to.equal(false); expect(result.method).to.be.null; }); - it('rejects a label match for a low-confidence name (acronym safety rule)', async () => { + it('rejects a generalized . bare public suffix (com.my)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'D-Company', aliases: [], officialWebsiteHosts: [] }, - brandName: 'Dnp', - brandConfidence: 'low', - registrableDomain: 'dnp.co.jp', + entity: { label: 'Some Foreign Org', officialWebsiteHosts: ['com.my'] }, + registrableDomain: 'com.my', }); expect(result.ok).to.equal(false); + expect(result.method).to.be.null; }); - it('returns false for a null entity', async () => { - const mod = await importMod(); - const result = mod.validateEntityAgainstSite({ - entity: null, brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', - }); - expect(result).to.deep.equal({ ok: false, method: null, reason: 'no_entity' }); - }); - - it('returns no_match when nothing overlaps for a high-confidence name', async () => { + it('rejects a single-label / empty registrable domain (bare suffix guard)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'Totally Different Org', aliases: [], officialWebsiteHosts: ['other.example'] }, - brandName: 'Amrize', - brandConfidence: 'medium', - registrableDomain: 'amrize.com', + entity: { label: 'X', officialWebsiteHosts: ['x.com'] }, + registrableDomain: 'localhost', }); expect(result.ok).to.equal(false); - expect(result.reason).to.equal('no_match'); }); - it('tolerates an entity with no hosts/aliases keys (label match)', async () => { + it('returns false for a null entity', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ - entity: { label: 'Amrize' }, - brandName: 'Amrize', - brandConfidence: 'high', - registrableDomain: 'somethingelse.com', + entity: null, registrableDomain: 'x.com', }); - expect(result.ok).to.equal(true); - expect(result.method).to.equal('label'); + expect(result).to.deep.equal({ ok: false, method: null, reason: 'no_entity' }); }); - it('tolerates an empty brand name (no tokens to match)', async () => { + it('tolerates an entity with no officialWebsiteHosts key', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Amrize' }, - brandName: '', - brandConfidence: 'high', - registrableDomain: 'somethingelse.com', + registrableDomain: 'amrize.com', }); expect(result.ok).to.equal(false); + expect(result.reason).to.equal('no_match'); }); }); describe('findValidatedWikidataEntity', () => { - const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); - - it('returns the first P856-validated candidate', async () => { + it('scans candidates and returns the first P856-validated one', async () => { // wbsearchentities candidates fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q1' }, { id: 'Q2' }] }), }); - // getWikidataEntity Q1 -> no p856 match, label mismatch + // getWikidataEntity Q1 -> no P856 match fetchStub.onCall(1).resolves({ ok: true, json: () => Promise.resolve({ entities: { Q1: { labels: { en: { value: 'Other' } }, claims: {} } }, }), }); - // getWikidataEntity Q2 -> p856 match + // getWikidataEntity Q2 -> P856 match fetchStub.onCall(2).resolves({ ok: true, json: () => Promise.resolve({ @@ -756,14 +246,14 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(entity.id).to.equal('Q2'); expect(entity.validation).to.equal('p856'); }); - it('REGRESSION: low-confidence acronym with no P856 match returns null', async () => { + it('REGRESSION: same-initials article with no P856 match returns null', async () => { fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q111' }] }), @@ -784,25 +274,23 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'Dnp', brandConfidence: 'low', registrableDomain: 'dnp.co.jp', + brandName: 'Dnp', registrableDomain: 'dnp.co.jp', }, log); expect(entity).to.be.null; }); - it('falls back to the first label match for a non-low-confidence name', async () => { + it('returns null when no candidate has a matching P856 host', async () => { fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q1' }, { id: 'Q2' }] }), }); - // Q1 label match fetchStub.onCall(1).resolves({ ok: true, json: () => Promise.resolve({ - entities: { Q1: { labels: { en: { value: 'Amrize' } }, sitelinks: { enwiki: { title: 'Amrize' } }, claims: {} } }, + entities: { Q1: { labels: { en: { value: 'Amrize' } }, claims: {} } }, }), }); - // Q2 also label match (second one -> rejected path) fetchStub.onCall(2).resolves({ ok: true, json: () => Promise.resolve({ @@ -812,18 +300,17 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'Amrize', brandConfidence: 'medium', registrableDomain: 'somethingelse.com', + brandName: 'Amrize', registrableDomain: 'somethingelse.com', }, log); - expect(entity.id).to.equal('Q1'); - expect(entity.validation).to.equal('label'); + expect(entity).to.be.null; }); it('returns null when there are no candidates', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'Nope', brandConfidence: 'high', registrableDomain: 'nope.com', + brandName: 'Nope', registrableDomain: 'nope.com', }, log); expect(entity).to.be.null; }); @@ -837,16 +324,16 @@ describe('services/wikipedia', () => { const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + brandName: 'X', registrableDomain: 'x.com', }, log); expect(entity).to.be.null; }); - it('returns [] candidates when the search request is not ok', async () => { + it('returns null when the candidate search request is not ok', async () => { fetchStub.resolves({ ok: false, status: 503 }); const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + brandName: 'X', registrableDomain: 'x.com', }, log); expect(entity).to.be.null; expect(log.error).to.have.been.calledWithMatch('Error searching Wikidata candidates'); @@ -856,15 +343,13 @@ describe('services/wikipedia', () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({}) }); const mod = await importMod(); const entity = await mod.findValidatedWikidataEntity({ - brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + brandName: 'X', registrableDomain: 'x.com', }, log); expect(entity).to.be.null; }); }); describe('fetchWikipediaExtractByTitle', () => { - const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); - it('issues exactly one query with the exact title and NO opensearch', async () => { fetchStub.resolves({ ok: true, @@ -948,15 +433,13 @@ describe('services/wikipedia', () => { }); describe('fetchValidatedSummary', () => { - const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); - it('returns the intro summary of the validated entity enwiki title', async () => { // search fetchStub.onCall(0).resolves({ ok: true, json: () => Promise.resolve({ search: [{ id: 'Q2' }] }), }); - // getWikidataEntity Q2 -> p856 + // getWikidataEntity Q2 -> P856 fetchStub.onCall(1).resolves({ ok: true, json: () => Promise.resolve({ @@ -977,7 +460,7 @@ describe('services/wikipedia', () => { const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(result).to.deep.equal({ title: 'DHL', summary: 'DHL intro.', entityId: 'Q2' }); @@ -990,7 +473,7 @@ describe('services/wikipedia', () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'X', brandConfidence: 'high', registrableDomain: 'x.com', + brandName: 'X', registrableDomain: 'x.com', }, log); expect(result).to.be.null; }); @@ -1013,7 +496,7 @@ describe('services/wikipedia', () => { }); const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'X', brandConfidence: 'low', registrableDomain: 'x.com', + brandName: 'X', registrableDomain: 'x.com', }, log); expect(result).to.be.null; }); @@ -1036,7 +519,7 @@ describe('services/wikipedia', () => { const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(result).to.be.null; expect(log.error).to.have.been.calledWithMatch('Error fetching validated summary'); @@ -1063,7 +546,7 @@ describe('services/wikipedia', () => { const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(result).to.be.null; }); @@ -1086,7 +569,7 @@ describe('services/wikipedia', () => { const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(result).to.be.null; }); @@ -1112,227 +595,71 @@ describe('services/wikipedia', () => { const mod = await importMod(); const result = await mod.fetchValidatedSummary({ - brandName: 'DHL', brandConfidence: 'low', registrableDomain: 'dhl.com', + brandName: 'DHL', registrableDomain: 'dhl.com', }, log); expect(result).to.deep.equal({ title: 'DHL', summary: '', entityId: 'Q2' }); }); }); - describe('edge cases', () => { - it('fetchWikipediaSummary handles page without wikibase_item', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - title: 'Test Title', - extract: 'Summary text', - pageprops: {}, - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result.title).to.equal('Test Title'); - expect(result.wikidataId).to.be.null; - }); - - it('fetchWikipediaFullText handles page with empty extract', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { extract: '' }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - expect(result).to.equal(''); - }); - - it('fetchWikipediaSummary handles page without extract', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - pages: { - 12345: { - title: 'Test Title', - // No extract field at all - pageprops: { wikibase_item: 'Q12345' }, - }, - }, - }, - }), - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result.title).to.equal('Test Title'); - expect(result.summary).to.equal(''); - }); - - it('fetchWikipediaFullText handles missing searchData[1] (titles)', async () => { - fetchStub.resolves({ - ok: true, - json: () => Promise.resolve(['Test']), // Missing titles array at index 1 - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); + describe('createWikipediaService', () => { + it('exposes only entity-bound methods (no by-name lookups)', async () => { + const mod = await importMod(); + const service = mod.createWikipediaService(log); - expect(result).to.be.null; + expect(service).to.have.property('getWikidataEntity'); + expect(service).to.have.property('findValidatedWikidataEntity'); + expect(service).to.have.property('fetchExtractByTitle'); + expect(service).to.have.property('fetchValidatedSummary'); + // Deprecated by-name methods must not be exposed. + expect(service).to.not.have.property('fetchSummary'); + expect(service).to.not.have.property('fetchFullText'); + expect(service).to.not.have.property('findWikidataId'); }); - it('fetchWikipediaFullText handles missing query.pages', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); + it('binds the logger to service methods', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - // No pages field - }, - }), + const mod = await importMod(); + const service = mod.createWikipediaService(log); + const result = await service.findValidatedWikidataEntity({ + brandName: 'Test', registrableDomain: 'test.com', }); - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaFullText('Test', 12000, log); - - // Should return null because pageId would be undefined expect(result).to.be.null; }); - it('findWikidataId handles missing search array in response', async () => { + it('fetchExtractByTitle service method forwards title and maxChars', async () => { fetchStub.resolves({ ok: true, - json: () => Promise.resolve({ - // No search field - }), + json: () => Promise.resolve({ query: { pages: { 42: { extract: 'bound' } } } }), }); - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.findWikidataId('Test', log); - - expect(result).to.be.null; + const mod = await importMod(); + const service = mod.createWikipediaService(log); + const text = await service.fetchExtractByTitle('DHL', 100); + expect(text).to.equal('bound'); }); - it('fetchWikipediaSummary handles missing searchData[1] (titles)', async () => { + it('getWikidataEntity service method forwards the id', async () => { fetchStub.resolves({ ok: true, - json: () => Promise.resolve(['Search']), // Missing titles array at index 1 - }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - expect(result).to.be.null; - }); - - it('fetchWikipediaSummary handles missing query.pages in summary response', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); - - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - query: { - // No pages field - should use fallback {} - }, - }), + json: () => Promise.resolve({ entities: { Q7: { labels: { en: { value: 'Bound' } }, claims: {} } } }), }); - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - // Should return null because pageId would be undefined - expect(result).to.be.null; + const mod = await importMod(); + const service = mod.createWikipediaService(log); + const entity = await service.getWikidataEntity('Q7'); + expect(entity.id).to.equal('Q7'); }); - it('fetchWikipediaSummary handles missing query entirely in response', async () => { - fetchStub.onFirstCall().resolves({ - ok: true, - json: () => Promise.resolve(['Test', ['Test Title'], [], []]), - }); + it('fetchValidatedSummary service method forwards params', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); - fetchStub.onSecondCall().resolves({ - ok: true, - json: () => Promise.resolve({ - // No query field at all - }), + const mod = await importMod(); + const service = mod.createWikipediaService(log); + const result = await service.fetchValidatedSummary({ + brandName: 'Test', registrableDomain: 'test.com', }); - - const mod = await esmock( - '../../../../src/agents/brand-profile/services/wikipedia.js', - {}, - ); - - const result = await mod.fetchWikipediaSummary('Test', log); - - // Should return null because pages would be {} expect(result).to.be.null; }); }); From a705acc4e4f13a8a31433a9e50642b9ad7891acd Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Mon, 10 Aug 2026 01:24:48 +0200 Subject: [PATCH 4/5] test(brand-profile): harden P856 review-loop findings (LLMO-6580) Address local review-kit findings that are in scope for the P856-only refactor: - brand-resolver.js: correct the resolveBrandName docstring, which claimed the confidence signal is "retained for logging/observability" although nothing logs it; it is retained for callers/tests and is not consumed by the P856 validation path. - wikipedia.test.js: add coverage for two previously unexercised branches of the core entity guard - validateEntityAgainstSite accepting a match found after a non-matching P856 host (multi-host loop), and findValidatedWikidataEntity stopping the candidate scan once the first candidate validates (asserted via fetch call count). - product-extractor.test.js: drop a manufactured duplicate sub_brand from the hybrid-merge fixture that the test never asserted on, so the test no longer implies coverage of within-source sub_brand deduplication that the code does not perform. Deferred as out of scope for this refactor (tracked for follow-up before the kill switch is enabled): adopting a maintained public-suffix list to close the fail-open gap for unlisted suffixes, deduplicating the validated-entity resolution across the competitor and product paths, and flipping the extractProducts enableWikiProducts default to false. Co-Authored-By: Claude Opus 4.8 --- .../brand-profile/services/brand-resolver.js | 5 ++- .../services/product-extractor.test.js | 2 +- .../brand-profile/services/wikipedia.test.js | 42 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/agents/brand-profile/services/brand-resolver.js b/src/agents/brand-profile/services/brand-resolver.js index 193fa154..796df94b 100644 --- a/src/agents/brand-profile/services/brand-resolver.js +++ b/src/agents/brand-profile/services/brand-resolver.js @@ -14,8 +14,9 @@ * Brand-name resolution for the brand-profile agent (LLMO-6580). * * Turns a base profile + site URL into a best-effort display name, a coarse - * confidence signal (retained for logging/observability), and the site's - * registrable domain. Downstream Wikipedia/Wikidata entity validation is + * confidence signal (retained for callers/tests; not consumed by the P856 + * validation path), and the site's registrable domain. Downstream + * Wikipedia/Wikidata entity validation is * P856-only and keyed on the registrable domain — not on the confidence signal: * a candidate entity is accepted only when its official-website host (claim * P856) shares the site's registrable domain, so a bare acronym (e.g. `dnp`, diff --git a/test/agents/brand-profile/services/product-extractor.test.js b/test/agents/brand-profile/services/product-extractor.test.js index 26803d21..7efa9cad 100644 --- a/test/agents/brand-profile/services/product-extractor.test.js +++ b/test/agents/brand-profile/services/product-extractor.test.js @@ -555,7 +555,7 @@ describe('services/product-extractor', () => { { name: 'Service1' }, { name: '' }, ], - sub_brands: ['SubBrand1', 'SubBrand1'], + sub_brands: ['SubBrand1'], discontinued: [ { name: 'OldProduct' }, { name: '' }, diff --git a/test/agents/brand-profile/services/wikipedia.test.js b/test/agents/brand-profile/services/wikipedia.test.js index ec843d86..8164b895 100644 --- a/test/agents/brand-profile/services/wikipedia.test.js +++ b/test/agents/brand-profile/services/wikipedia.test.js @@ -158,6 +158,19 @@ describe('services/wikipedia', () => { expect(result.reason).to.equal('no_match'); }); + it('accepts a match found after a non-matching P856 host (multi-host loop)', async () => { + const mod = await importMod(); + const result = mod.validateEntityAgainstSite({ + entity: { + label: 'DHL', + officialWebsiteHosts: ['other.example', 'www.dhl.com'], + }, + registrableDomain: 'dhl.com', + }); + expect(result.ok).to.equal(true); + expect(result.method).to.equal('p856'); + }); + it('rejects an entity with no P856 host (no by-name / label fallback)', async () => { const mod = await importMod(); const result = mod.validateEntityAgainstSite({ @@ -253,6 +266,35 @@ describe('services/wikipedia', () => { expect(entity.validation).to.equal('p856'); }); + it('stops scanning once a candidate validates (does not fetch later candidates)', async () => { + fetchStub.onCall(0).resolves({ + ok: true, + json: () => Promise.resolve({ search: [{ id: 'Q1' }, { id: 'Q2' }] }), + }); + // Q1 validates via P856 -> Q2 must never be fetched. + fetchStub.onCall(1).resolves({ + ok: true, + json: () => Promise.resolve({ + entities: { + Q1: { + labels: { en: { value: 'DHL' } }, + sitelinks: { enwiki: { title: 'DHL' } }, + claims: { P856: [{ mainsnak: { datavalue: { value: 'https://www.dhl.com' } } }] }, + }, + }, + }), + }); + + const mod = await importMod(); + const entity = await mod.findValidatedWikidataEntity({ + brandName: 'DHL', registrableDomain: 'dhl.com', + }, log); + + expect(entity.id).to.equal('Q1'); + // Exactly two round trips: 1 search + 1 entity fetch. Q2 was never requested. + expect(fetchStub.callCount).to.equal(2); + }); + it('REGRESSION: same-initials article with no P856 match returns null', async () => { fetchStub.onCall(0).resolves({ ok: true, From 4bf55a6beaef7486addaaa3acb553aa923c133a6 Mon Sep 17 00:00:00 2001 From: Christopher Wisse Date: Tue, 11 Aug 2026 15:32:33 +0200 Subject: [PATCH 5/5] refactor(brand-profile): reduce entity-binding fix to lean P856 forward-guard (LLMO-6580) Replace the earlier large in-place rewrite of the brand-profile agent with a minimal, additive forward-guard that targets the actual defect: the agent resolved the wrong Wikidata/Wikipedia entity via fuzzy name/acronym matching with no domain validation, then fetched a by-name Wikipedia article (opensearch " company"). For d* acronyms this hit the "D-Company" (Dawood Ibrahim) article and the LLM extracted criminal "services" into real organizations' brand profiles. Fix, layered onto origin/main without removing existing exports: - Validate the resolved entity by Wikidata P856 (official website) against the site's registrable domain before extracting products. - Bind Wikipedia by the validated entity's exact enwiki sitelink title instead of a fuzzy by-name search. - extractProducts now requires a validated entity; no validated entity yields source:'none' with empty products (existing enum, no new metadata states). Reductions versus the previous revision on this branch: - Delete services/brand-resolver.js (the homepage-name/confidence resolver was not part of the fix); index.js keeps origin/main extractBrandName. - Drop the BRAND_PROFILE_ENABLE_WIKI_PRODUCTS kill-switch and the 'disabled' source. Wiki extraction keeps origin/main's on-by-default posture, now gated by P856 validation. - Leave the competitor-inference summary path as origin/main; it is not the products vulnerability. - Revert README to origin/main. Preserved: - persist() manual-curated guard: never overwrite rows whose products_metadata.source == 'manual-curated', so hand-curated catalogues and the P2 backfill remain safe. Tests reduced to the lean contract: 466 passing, coverage >= 95% for lines, branches, and statements. Co-Authored-By: Claude Opus 4.8 --- README.md | 39 +- src/agents/brand-profile/index.js | 71 +- .../brand-profile/services/brand-resolver.js | 228 ------ .../services/product-extractor.js | 118 ++- .../brand-profile/services/wikipedia.js | 327 +++++++- test/agents/brand-profile/index.test.js | 451 ++++++----- .../services/brand-resolver.test.js | 294 ------- .../services/product-extractor.test.js | 55 +- .../brand-profile/services/wikipedia.test.js | 761 ++++++++++++++++-- 9 files changed, 1334 insertions(+), 1010 deletions(-) delete mode 100644 src/agents/brand-profile/services/brand-resolver.js delete mode 100644 test/agents/brand-profile/services/brand-resolver.test.js diff --git a/README.md b/README.md index 9bb68ddd..ac70e437 100644 --- a/README.md +++ b/README.md @@ -5,47 +5,37 @@ SpaceCat Task Processor is a Node.js service that processes messages from the AWS SQS queue `SPACECAT-TASK-PROCESSOR-JOBS`. Based on the `type` field in each message, it dispatches the message to the appropriate handler for processing various site-related tasks. ## Features - - Receives and processes messages from SQS - Supports multiple task types via modular handlers - Built-in handlers for audit status, demo URL preparation, generic agent execution, and Slack notifications - Extensible and easy to add new handlers ## Handlers - - **opportunity-status-processor**: Checks and reports status audits for a site - **demo-url-processor**: Prepares and shares a demo URL for a site - **agent-executor**: Runs registered AI/LLM agents (e.g., the brand-profile agent) asynchronously after onboarding flows - **slack-notify**: Sends Slack notifications (text or block messages) from workflows ## Setup - 1. Clone the repository 2. Install dependencies: - ```sh npm install ``` - 3. Configure AWS credentials and environment variables as needed ## Usage - - The service is designed to run as a serverless function or background worker. - It can be invoked in two ways: - **SQS mode:** listens to the `SPACECAT-TASK-PROCESSOR-JOBS` queue and processes messages automatically (default path for existing workflows). - **Direct mode:** the Lambda entrypoint auto-detects single-message payloads (e.g., from AWS Step Functions) and executes the corresponding handler synchronously. This is used by the new agent workflows to obtain immediate results before triggering follow-up actions. ## Development - - To run tests: - ```sh npm test ``` - - To run the optional brand-profile integration test (requires Azure OpenAI env variables): - ```sh npm run test:brand-profile-it ``` @@ -62,32 +52,13 @@ The `agent-executor` (and the provided brand-profile agent) rely on the Azure Op | `AZURE_COMPLETION_DEPLOYMENT` | Deployment/model name (e.g., `gpt-4o`) | When invoking the integration test, you can also set `BRAND_PROFILE_TEST_BASE_URL` to control which site is analyzed and `BRAND_PROFILE_IT_FULL=1` to print the complete agent response (otherwise the preview is truncated for readability). - -#### Brand-profile entity validation (LLMO-6580) - -The brand-profile product and competitor-summary paths bind every Wikipedia/Wikidata lookup to an entity that is validated against the customer's site by a strong P856 (official-website host) match, so a foreign entity's catalogue can never be attached to a customer. - -- **Brand-name resolution** (`services/brand-resolver.js`) turns the base profile and site URL into a display name plus the site's registrable domain. It never *derives* a brand name from a bare 2-3 letter acronym or a `dev`/`www`/`store`/`support` subdomain label as high confidence (an explicit `brand_name` supplied by the base profile is trusted as given). The registrable domain is the signal the entity validation compares against. -- **Entity binding** (`services/wikipedia.js`): `findValidatedWikidataEntity` searches Wikidata by name but keeps a candidate **only** if its official-website host (claim P856) shares the site's registrable domain — this is the sole accepted signal; there is no by-name / label / alias fallback, and a bare public-suffix registrable domain (e.g. `co.uk`) never matches. Fallback article text is fetched by the validated entity's **exact** English Wikipedia sitelink title (`fetchWikipediaExtractByTitle`), never by a decoupled `opensearch " company"` query. If no candidate's P856 host matches, the pipeline produces **no** Wikipedia/Wikidata products. - -| Variable | Default | Purpose | -| --- | --- | --- | -| `BRAND_PROFILE_ENABLE_WIKI_PRODUCTS` | `false` | Kill-switch for the entire Wikipedia/Wikidata product + competitor-summary path. When `false`, `extractProducts` returns an empty result (`products_metadata.source = "disabled"`) and no validated summary is fetched; sitemap-based product extraction and the rest of the profile still run. Ship `false` for net-new runs until the P0-a scrub and P2 backfill complete, then flip to `true`. Read from Vault per-service config (`dx_mysticat/{env}/task-processor`). | - -`products_metadata.source` terminal values: `sitemap`, `wikidata`, `hybrid`, `wikipedia_llm`, `disabled`, `none_no_validated_entity` (no P856-validated entity), and the pre-existing `none`/`sitemap_*` states. Additive provenance fields: `source_entity_label`, `source_wikipedia_title`, and `validation` (`p856`). - -**Persist guard:** `persist()` never overwrites a stored brand profile whose `products_metadata.source == "manual-curated"` — the curated `products`/`products_metadata` are preserved while all other fields update. This protects the hand-curated blocks during the P2 regeneration sweep. - - To lint code: - ```sh npm run lint ``` ## Extending - To add a new handler: - 1. Create a new folder in `src/` for your handler. 2. Export your handler function. 3. Add it to the handler mapping in `src/index.js`. @@ -96,7 +67,6 @@ To add a new handler: For more details, see the documentation in `src/README.md`. ## Status - [![codecov](https://img.shields.io/codecov/c/github/adobe-rnd/spacecat-task-processor.svg)](https://codecov.io/gh/adobe-rnd/spacecat-task-processor) [![CircleCI](https://img.shields.io/circleci/project/github/adobe-rnd/spacecat-audit-worker.svg)](https://circleci.com/gh/adobe-rnd/spacecat-task-processor) [![GitHub license](https://img.shields.io/github/license/adobe-rnd/spacecat-task-processor.svg)](https://github.com/adobe-rnd/spacecat-task-processor/blob/master/LICENSE.txt) @@ -107,7 +77,7 @@ For more details, see the documentation in `src/README.md`. ## Installation ```bash -npm install @adobe/spacecat-task-processor +$ npm install @adobe/spacecat-task-processor ``` ## Usage @@ -119,19 +89,19 @@ See the [API documentation](docs/API.md). ### Build ```bash -npm install +$ npm install ``` ### Test ```bash -npm test +$ npm test ``` ### Lint ```bash -npm run lint +$ npm run lint ``` ## Message Body Formats @@ -171,7 +141,6 @@ When the AWS Step Functions Agent Workflow invokes the Lambda directly, it sends ``` Field descriptions: - - `agentId` *(required)* – must match a registered agent (e.g., `brand-profile`). - `siteId` *(required)* – kept at the envelope level for logging/metrics. Agents can still read it from the message passed into `agent.persist`. - `context` *(required)* – forwarded to `agent.run`. At minimum it must include `baseURL`; additional agent-specific params live here. diff --git a/src/agents/brand-profile/index.js b/src/agents/brand-profile/index.js index 915462d9..ef9ddda7 100644 --- a/src/agents/brand-profile/index.js +++ b/src/agents/brand-profile/index.js @@ -25,8 +25,7 @@ import { createRegionalContextService } from './services/regional-context.js'; import { createCompetitorInferenceService } from './services/competitor-inference.js'; import { createPersonaInferenceService } from './services/persona-inference.js'; import { createProductExtractorService } from './services/product-extractor.js'; -import { createWikipediaService } from './services/wikipedia.js'; -import { resolveBrandName } from './services/brand-resolver.js'; +import { createWikipediaService, splitHost } from './services/wikipedia.js'; /** * Call the model with system and user prompts. @@ -50,6 +49,40 @@ async function callModel({ } } +/** + * Extract brand name from base profile or URL. + * @param {object} baseProfile - Base profile from initial LLM call + * @param {string} baseURL - Site base URL + * @returns {string} Brand name + */ +function extractBrandName(baseProfile, baseURL) { + // Try to get brand name from profile + if (baseProfile?.main_profile?.brand_name) { + return baseProfile.main_profile.brand_name; + } + + // Try competitive_context + if (baseProfile?.competitive_context?.brand_name) { + return baseProfile.competitive_context.brand_name; + } + + // Fall back to domain extraction + try { + const url = new URL(baseURL); + const parts = url.hostname.split('.'); + // Remove www and TLD + const domainParts = parts.filter((p) => p !== 'www' && p.length > 2); + if (domainParts.length > 0) { + return domainParts[0].charAt(0).toUpperCase() + domainParts[0].slice(1); + } + /* c8 ignore next 3 */ + } catch { + // Ignore URL parse errors + } + + return 'Unknown Brand'; +} + /** * Extract industry from base profile. * @param {object} baseProfile - Base profile from initial LLM call @@ -119,17 +152,10 @@ async function run(context, env, log) { } // Extract key fields from base profile for enhanced inference - const { - name: brandName, - registrableDomain, - } = await resolveBrandName(baseProfile, baseURL, log); + const brandName = extractBrandName(baseProfile, baseURL); const industry = extractIndustry(baseProfile); const targetAudience = extractTargetAudience(baseProfile); - // LLMO-6580 kill-switch: the entire Wikipedia/Wikidata product + competitor-summary - // path stays OFF unless explicitly enabled, until the P2 backfill is validated. - const enableWikiProducts = env.BRAND_PROFILE_ENABLE_WIKI_PRODUCTS === 'true'; - log.info(`brand-profile: enhancing profile for "${brandName}" in "${industry}"`); // Initialize services @@ -174,16 +200,9 @@ async function run(context, env, log) { competitorsSource = 'llmo'; } else { log.info('brand-profile: inferring competitors'); - // Optionally fetch a VALIDATED Wikipedia summary (entity bound to the site) for - // better competitor inference. Gated by the kill-switch; null degrades gracefully. - let wikiSummary = ''; - if (enableWikiProducts) { - const wikiResult = await wikipediaService.fetchValidatedSummary({ - brandName, - registrableDomain, - }); - wikiSummary = wikiResult?.summary || ''; - } + // Optionally fetch Wikipedia summary for better competitor inference + const wikiResult = await wikipediaService.fetchSummary(`${brandName} company`); + const wikiSummary = wikiResult?.summary || ''; const competitorResult = await competitorService.inferCompetitors({ brandName, @@ -213,13 +232,11 @@ async function run(context, env, log) { log.info(`brand-profile: using sitemap for product extraction: ${sitemapUrl}`); productsResult = await productService.extractFromSitemap(sitemapUrl, brandName); } else { - // Entity-bound Wikipedia/Wikidata extraction. The fetch now happens inside - // extractProducts, bound to an entity validated against the site. - productsResult = await productService.extractProducts({ - brandName, - registrableDomain, - enableWikiProducts, - }); + // Entity-bound Wikipedia/Wikidata extraction (LLMO-6580): products come only from a + // Wikidata entity validated against the site via a strong P856 (official-website) + // host match. Nothing validates => extractProducts returns source 'none' (no products). + const { registrableDomain } = splitHost(new URL(baseURL).hostname); + productsResult = await productService.extractProducts({ brandName, registrableDomain }); } // Assemble the enhanced profile diff --git a/src/agents/brand-profile/services/brand-resolver.js b/src/agents/brand-profile/services/brand-resolver.js deleted file mode 100644 index 796df94b..00000000 --- a/src/agents/brand-profile/services/brand-resolver.js +++ /dev/null @@ -1,228 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -/** - * Brand-name resolution for the brand-profile agent (LLMO-6580). - * - * Turns a base profile + site URL into a best-effort display name, a coarse - * confidence signal (retained for callers/tests; not consumed by the P856 - * validation path), and the site's registrable domain. Downstream - * Wikipedia/Wikidata entity validation is - * P856-only and keyed on the registrable domain — not on the confidence signal: - * a candidate entity is accepted only when its official-website host (claim - * P856) shares the site's registrable domain, so a bare acronym (e.g. `dnp`, - * `edb`) can never drive a fuzzy by-name lookup. - */ - -import { load } from 'cheerio'; -import { hasText } from '@adobe/spacecat-shared-utils'; - -const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; -const HOMEPAGE_FETCH_TIMEOUT_MS = 5000; - -/** - * Labels that must never become the brand name (subdomains / env prefixes / sections). - */ -export const STOP_LABELS = new Set([ - 'www', 'www2', 'dev', 'stage', 'staging', 'test', 'qa', 'preview', 'demo', - 'store', 'shop', 'support', 'help', 'faq', 'blog', 'news', 'press', 'careers', - 'account', 'accounts', 'login', 'my', 'portal', 'app', 'apps', 'm', 'mobile', - 'en', 'us', 'uk', 'eu', 'go', 'get', 'about', -]); - -/** - * Minimal public-suffix awareness for the multi-part TLDs that broke the audit set. - * Hand-rolled table (no runtime dependency) covering the common ccTLD second levels. - */ -export const MULTI_PART_TLDS = new Set([ - 'co.jp', 'co.uk', 'com.au', 'co.nz', 'gov.sg', 'com.sg', 'com.br', 'co.in', - 'com.mx', 'gov.uk', 'ac.uk', 'org.uk', 'co.za', 'com.cn', 'com.hk', 'co.kr', - 'ne.jp', 'or.jp', 'com.tw', 'co.id', 'com.tr', 'gov.au', 'edu.au', -]); - -/** - * Generic second-level labels that form a two-label public suffix when paired with a - * 2-character ccTLD (e.g. `com.my`, `co.th`, `gov.in`, `or.kr`). Generalising the - * `.` shape means an unlisted ccTLD cannot collapse the registrable domain - * down to the bare public suffix, which was the residual LLMO-6580 false-positive vector: - * a bare-suffix registrable domain P856-matches any foreign entity on the same suffix. - */ -export const GENERIC_SECOND_LEVELS = new Set([ - 'com', 'co', 'org', 'net', 'gov', 'edu', 'ac', 'mil', 'ne', 'or', 'go', 'gob', 'gouv', -]); - -/** - * Split a hostname into its subdomain labels, apex label, and registrable domain, - * honouring the minimal multi-part TLD table. - * @param {string} hostname - Hostname (e.g. "dev.amrize.com", "dnp.co.jp") - * @returns {{subdomainLabels: string[], apexLabel: string, registrableDomain: string}} - */ -export function splitHost(hostname) { - const host = String(hostname || '').toLowerCase().replace(/\.$/, '').trim(); - const labels = host.split('.').filter(Boolean); - - if (labels.length <= 1) { - return { subdomainLabels: [], apexLabel: labels[0] || '', registrableDomain: host }; - } - - let registrableLabelCount = 2; - const lastTwo = labels.slice(-2).join('.'); - const tld = labels.at(-1); - const secondLevel = labels.at(-2); - // Explicit multi-part TLD, OR the general `.<2-char-ccTLD>` shape - // (co.uk, com.my, co.th, gov.in, or.kr, ...). Both are two-label public suffixes, so the - // registrable domain keeps a real label in front of them instead of collapsing to the - // bare suffix (LLMO-6580: a bare-suffix registrable domain yields false P856 matches). - if (labels.length >= 3 - && (MULTI_PART_TLDS.has(lastTwo) - || (tld.length === 2 && GENERIC_SECOND_LEVELS.has(secondLevel)))) { - registrableLabelCount = 3; - } - - const registrableLabels = labels.slice(-registrableLabelCount); - const registrableDomain = registrableLabels.join('.'); - const apexLabel = registrableLabels[0]; - const subdomainLabels = labels.slice(0, labels.length - registrableLabelCount); - - return { subdomainLabels, apexLabel, registrableDomain }; -} - -/** - * Is this label too weak to use as a brand name on its own? - * True for stop labels (subdomains/sections) and short (<=3 char) acronyms. - * Short/acronym brands (IBM, HP) are still allowed downstream via P856 validation. - * @param {string} label - Candidate label - * @returns {boolean} - */ -export function isLowConfidenceLabel(label) { - const l = String(label || '').toLowerCase().trim(); - if (!l) { - return true; - } - if (STOP_LABELS.has(l)) { - return true; - } - return l.length <= 3; -} - -/** - * Clean a raw /og:site_name into a brand-like token. - * "Page | Brand" or "Brand - Tagline" -> first non-generic segment. - * @param {string} raw - Raw title string - * @returns {string|null} Cleaned name or null - */ -function cleanTitle(raw) { - const t = String(raw || '').trim(); - if (!t) { - return null; - } - const parts = t.split(/\s+[|\-–—:·]\s+/).map((p) => p.trim()).filter(Boolean); - const generic = /^(home|homepage|official site|official website|welcome)$/i; - const meaningful = parts.filter((p) => !generic.test(p)); - return meaningful[0] || parts[0]; -} - -/** - * Best-effort fetch of the site's display name from og:site_name or <title>. - * Never throws; returns null on any failure (network, timeout, non-HTML, bot-block). - * @param {string} baseURL - Site base URL - * @param {object} log - Logger instance - * @returns {Promise<string|null>} Cleaned site name or null - */ -export async function fetchSiteName(baseURL, log) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), HOMEPAGE_FETCH_TIMEOUT_MS); - try { - const resp = await fetch(baseURL, { - headers: { 'User-Agent': USER_AGENT }, - signal: controller.signal, - }); - if (!resp.ok) { - log.info(`brand-resolver: homepage fetch not ok (${resp.status}) for ${baseURL}`); - return null; - } - const contentType = resp.headers?.get?.('content-type') || ''; - if (contentType && !contentType.toLowerCase().includes('html')) { - return null; - } - const html = await resp.text(); - const $ = load(html); - const ogName = cleanTitle($('meta[property="og:site_name"]').attr('content')); - if (ogName) { - return ogName; - } - return cleanTitle($('title').first().text()); - } catch (e) { - log.info(`brand-resolver: homepage fetch failed for ${baseURL}: ${e.message}`); - return null; - } finally { - clearTimeout(timer); - } -} - -/** - * Resolve a brand name with a confidence signal and the site's registrable domain. - * - * Precedence (high -> low): - * 1. base_profile.main_profile.brand_name -> high / base_profile - * 2. competitive_context.brand_name -> high / competitive_context - * 3. og:site_name / cleaned <title> -> high / site_title - * 4. apex domain label (not low-confidence) -> medium/ apex_domain - * 5. apex domain label (short/acronym) -> low / apex_acronym - * 6. nothing usable -> low / none ("Unknown Brand") - * - * @param {object} baseProfile - Base profile from the initial LLM call - * @param {string} baseURL - Site base URL - * @param {object} log - Logger instance - * @returns {Promise<{name: string, confidence: string, source: string, - * siteHost: string, registrableDomain: string}>} - */ -export async function resolveBrandName(baseProfile, baseURL, log) { - let siteHost = ''; - try { - siteHost = new URL(baseURL).hostname; - } catch { - // baseURL is validated upstream; keep empty host on parse failure. - siteHost = ''; - } - - const { apexLabel, registrableDomain } = splitHost(siteHost); - - const build = (name, confidence, source) => ({ - name, confidence, source, siteHost, registrableDomain, - }); - - const mpName = baseProfile?.main_profile?.brand_name; - if (hasText(mpName)) { - return build(mpName, 'high', 'base_profile'); - } - - const ccName = baseProfile?.competitive_context?.brand_name; - if (hasText(ccName)) { - return build(ccName, 'high', 'competitive_context'); - } - - const siteName = await fetchSiteName(baseURL, log); - if (hasText(siteName) && !isLowConfidenceLabel(siteName)) { - return build(siteName, 'high', 'site_title'); - } - - if (hasText(apexLabel)) { - const display = apexLabel.charAt(0).toUpperCase() + apexLabel.slice(1); - if (!isLowConfidenceLabel(apexLabel)) { - return build(display, 'medium', 'apex_domain'); - } - return build(display, 'low', 'apex_acronym'); - } - - return build('Unknown Brand', 'low', 'none'); -} diff --git a/src/agents/brand-profile/services/product-extractor.js b/src/agents/brand-profile/services/product-extractor.js index 55072834..e71e8dec 100644 --- a/src/agents/brand-profile/services/product-extractor.js +++ b/src/agents/brand-profile/services/product-extractor.js @@ -28,24 +28,6 @@ import { findValidatedWikidataEntity, fetchWikipediaExtractByTitle } from './wik const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql'; const MIN_PRODUCTS_THRESHOLD = 3; -// Upper bound on any single sitemap/SPARQL round trip so a hung upstream cannot stall the task. -const EXTERNAL_FETCH_TIMEOUT_MS = 10000; - -/** - * fetch() with an AbortController timeout so a hung upstream cannot stall the task. - * @param {string} url - Request URL - * @param {object} [options] - fetch options (headers, etc.) - * @returns {Promise<Response>} - */ -async function timedFetch(url, options = {}) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), EXTERNAL_FETCH_TIMEOUT_MS); - try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - clearTimeout(timer); - } -} // Generic SPARQL query - works for any industry const PRODUCTS_SPARQL = ` @@ -157,7 +139,7 @@ function filterProductUrls(urls) { async function fetchSitemapUrls(sitemapUrl, log) { log.info(`Fetching sitemap: ${sitemapUrl}`); - const resp = await timedFetch(sitemapUrl, { + const resp = await fetch(sitemapUrl, { headers: { 'User-Agent': USER_AGENT }, }); @@ -181,18 +163,11 @@ async function fetchSitemapUrls(sitemapUrl, log) { async function queryWikidataProducts(wikidataId, log) { log.info(`Querying Wikidata products for: ${wikidataId}`); - // Guard: only substitute a well-formed Wikidata entity id into the SPARQL template - // (defense-in-depth against SPARQL injection, even though ids originate from Wikidata). - if (!/^Q\d+$/.test(String(wikidataId || ''))) { - log.warn(`Refusing SPARQL query for malformed Wikidata id: ${wikidataId}`); - return []; - } - const query = PRODUCTS_SPARQL.replace(/{wikidata_id}/g, wikidataId); const url = `${WIKIDATA_SPARQL}?query=${encodeURIComponent(query)}`; try { - const resp = await timedFetch(url, { + const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'application/sparql-results+json', @@ -316,40 +291,51 @@ function normalizeResults(result) { * @param {object} secondary - Secondary results (usually Wikipedia) * @returns {object} Merged result */ +/* c8 ignore start */ function mergeResults(primary, secondary) { - // `primary` is always the well-formed result object (four arrays; Wikidata product - // names are non-empty by construction). `secondary` is extractFromWikipedia output - // (four arrays, but LLM entries may have empty names). - const existingProductNames = new Set(primary.products.map((p) => p.name.toLowerCase())); - const existingServiceNames = new Set(primary.services.map((s) => s.name.toLowerCase())); - const existingSubBrands = new Set(primary.sub_brands); - const existingDiscontinued = new Set(primary.discontinued.map((d) => d.name.toLowerCase())); - - const newProducts = secondary.products.filter((product) => { + const existingProductNames = new Set( + (primary.products || []).map((p) => (p.name || '').toLowerCase()), + ); + const existingServiceNames = new Set( + (primary.services || []).map((s) => (s.name || '').toLowerCase()), + ); + const existingSubBrands = new Set(primary.sub_brands || []); + const existingDiscontinued = new Set( + (primary.discontinued || []).map((d) => (d.name || '').toLowerCase()), + ); + + // Add new products from secondary + const newProducts = (secondary.products || []).filter((product) => { const nameLower = (product.name || '').toLowerCase(); return nameLower && !existingProductNames.has(nameLower); }); - const newServices = secondary.services.filter((service) => { + // Add new services from secondary + const newServices = (secondary.services || []).filter((service) => { const nameLower = (service.name || '').toLowerCase(); return nameLower && !existingServiceNames.has(nameLower); }); - const newSubBrands = secondary.sub_brands.filter((sub) => !existingSubBrands.has(sub)); + // Add sub-brands (merge unique) + const newSubBrands = (secondary.sub_brands || []).filter( + (sub) => !existingSubBrands.has(sub), + ); - const newDiscontinued = secondary.discontinued.filter((disc) => { + // Add discontinued (merge unique) + const newDiscontinued = (secondary.discontinued || []).filter((disc) => { const nameLower = (disc.name || '').toLowerCase(); return nameLower && !existingDiscontinued.has(nameLower); }); return { ...primary, - products: [...primary.products, ...newProducts], - services: [...primary.services, ...newServices], - sub_brands: [...primary.sub_brands, ...newSubBrands], - discontinued: [...primary.discontinued, ...newDiscontinued], + products: [...(primary.products || []), ...newProducts], + services: [...(primary.services || []), ...newServices], + sub_brands: [...(primary.sub_brands || []), ...newSubBrands], + discontinued: [...(primary.discontinued || []), ...newDiscontinued], }; } +/* c8 ignore stop */ /** * Extract current products from sitemap URLs using LLM. @@ -483,17 +469,19 @@ async function extractFromWikipedia(brandName, wikipediaText, gpt, log) { } /** - * Extract products bound to a VALIDATED Wikidata entity (LLMO-6580). + * Extract products bound to a Wikidata entity VALIDATED against the site (LLMO-6580). * - * Every Wikipedia/Wikidata fetch is bound to an entity that validates against the - * customer's site by a strong P856 (official-website host) match. If nothing - * validates, we produce NO products rather than guessing. + * Resolution is P856-only: {@link findValidatedWikidataEntity} accepts a candidate only + * when its official-website host shares the site's registrable domain. When nothing + * validates we produce NO products (metadata.source stays `'none'`) rather than falling + * back to a fuzzy by-name lookup that could bind the profile to a foreign entity. Every + * subsequent fetch is bound to that validated entity: SPARQL by its id, and the Wikipedia + * fallback by its exact `enwiki` sitelink title. * * @param {object} options - Options * @param {string} options.brandName - Brand/company name * @param {string} [options.registrableDomain=''] - Site registrable domain * @param {string} [options.wikipediaSummary=null] - Optional pre-fetched fallback text - * @param {boolean} [options.enableWikiProducts=true] - Kill-switch for the entire path * @param {object} gpt - AzureOpenAIClient instance * @param {object} log - Logger instance * @returns {Promise<object>} Extraction result @@ -502,7 +490,6 @@ export async function extractProducts({ brandName, registrableDomain = '', wikipediaSummary = null, - enableWikiProducts = true, }, gpt, log) { log.info(`Extracting products for brand: ${brandName}`); @@ -519,32 +506,23 @@ export async function extractProducts({ }, }; - // Kill-switch: entire Wikipedia/Wikidata product path disabled. - if (!enableWikiProducts) { - log.info('brand-profile: Wikipedia/Wikidata product extraction disabled by flag'); - result.metadata.source = 'disabled'; - return normalizeResults(result); - } - - // Step 1: Resolve+validate the entity. A candidate is only accepted via a strong P856 - // host match against the site; otherwise findValidatedWikidataEntity returns null. - const entity = await findValidatedWikidataEntity({ - brandName, registrableDomain, - }, log); + // Step 1: Resolve+validate the entity via a strong P856 (official-website) host match + // against the site's registrable domain. Nothing validates => no products; metadata + // stays source 'none'. This is the guard that keeps a fuzzy by-name match from pulling + // a foreign entity's catalogue into the profile. + const entity = await findValidatedWikidataEntity({ brandName, registrableDomain }, log); if (!entity) { log.info(`No validated Wikidata entity for ${brandName}; producing no products`); - result.metadata.source = 'none_no_validated_entity'; - result.metadata.rejected = true; return normalizeResults(result); } result.metadata.brand_wikidata_id = entity.id; - result.metadata.source_entity_label = entity.label; result.metadata.validation = entity.validation; - // Step 2: Query Wikidata SPARQL for products (inherently entity-bound, safe). + // Step 2: Query Wikidata for products (bound to the validated entity id). const wikidataProducts = await queryWikidataProducts(entity.id, log); + if (wikidataProducts.length > 0) { result.products = wikidataProducts; result.metadata.source = 'wikidata'; @@ -556,17 +534,23 @@ export async function extractProducts({ if (result.products.length < MIN_PRODUCTS_THRESHOLD) { log.info(`Wikidata returned ${result.products.length} products (threshold: ${MIN_PRODUCTS_THRESHOLD}), trying entity-bound Wikipedia fallback`); + // Use pre-fetched text if provided; otherwise read the validated entity's own article. let wikiText = wikipediaSummary; if (!wikiText && entity.enwikiTitle) { wikiText = await fetchWikipediaExtractByTitle(entity.enwikiTitle, 12000, log); - result.metadata.source_wikipedia_title = entity.enwikiTitle; } const wikiResult = await extractFromWikipedia(brandName, wikiText, gpt, log); + if (wikiResult) { const merged = mergeResults(result, wikiResult); Object.assign(result, merged); - result.metadata.source = result.metadata.source === 'wikidata' ? 'hybrid' : 'wikipedia_llm'; + + if (result.metadata.source === 'wikidata') { + result.metadata.source = 'hybrid'; + } else { + result.metadata.source = 'wikipedia_llm'; + } } } diff --git a/src/agents/brand-profile/services/wikipedia.js b/src/agents/brand-profile/services/wikipedia.js index 1e0419ba..a19d6a32 100644 --- a/src/agents/brand-profile/services/wikipedia.js +++ b/src/agents/brand-profile/services/wikipedia.js @@ -11,45 +11,314 @@ */ /** - * Wikidata/Wikipedia client, bound to a site-validated entity (LLMO-6580). + * Wikipedia/Wikidata client for fetching brand information. * - * The only accepted validation signal is a strong P856 (official-website) host match - * against the site's registrable domain. There is deliberately no by-name / fuzzy path: - * if no candidate's official website matches the site, the pipeline produces no products. + * LLMO-6580: in addition to the original by-name helpers, this module exposes an + * entity-binding path. A Wikidata entity is only trusted once its official-website + * claim (P856) resolves to the site's registrable domain; Wikipedia is then read from + * that validated entity's exact `enwiki` sitelink title rather than a fuzzy by-name + * search. This prevents a same-initials article (e.g. a `d*` acronym resolving to the + * "D-Company" organised-crime article) from being mistaken for the customer's brand. */ -import { splitHost, MULTI_PART_TLDS, GENERIC_SECOND_LEVELS } from './brand-resolver.js'; - const WIKIPEDIA_API_BASE = 'https://en.wikipedia.org/w/api.php'; const WIKIDATA_API = 'https://www.wikidata.org/w/api.php'; const USER_AGENT = 'SpaceCat/1.0 (https://github.com/adobe/spacecat; spacecat@adobe.com)'; -// Upper bound on any single Wikidata/Wikipedia round trip. findValidatedWikidataEntity -// issues several serial calls, so an unbounded hang on any one would stall the whole task. -const EXTERNAL_FETCH_TIMEOUT_MS = 10000; +/** + * Fetch Wikipedia summary for a brand. + * @param {string} searchQuery - Search query (e.g., "Swiss Life company") + * @param {object} log - Logger instance + * @returns {Promise<object>} Wikipedia result with title, summary, and pageId + */ +export async function fetchWikipediaSummary(searchQuery, log) { + log.info(`Fetching Wikipedia summary for: ${searchQuery}`); + + try { + // First, search for the page + const searchParams = new URLSearchParams({ + action: 'opensearch', + search: searchQuery, + limit: '5', + namespace: '0', + format: 'json', + }); + + const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; + const searchResp = await fetch(searchUrl, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!searchResp.ok) { + throw new Error(`Wikipedia search failed: ${searchResp.status}`); + } + + const searchData = await searchResp.json(); + const titles = searchData[1] || []; + + if (titles.length === 0) { + log.info(`No Wikipedia results found for: ${searchQuery}`); + return null; + } + + // Use the first result + const title = titles[0]; + + // Now fetch the summary + const summaryParams = new URLSearchParams({ + action: 'query', + titles: title, + prop: 'extracts|pageprops', + exintro: 'true', + explaintext: 'true', + ppprop: 'wikibase_item', + format: 'json', + }); + + const summaryUrl = `${WIKIPEDIA_API_BASE}?${summaryParams}`; + const summaryResp = await fetch(summaryUrl, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!summaryResp.ok) { + throw new Error(`Wikipedia summary fetch failed: ${summaryResp.status}`); + } + + const summaryData = await summaryResp.json(); + const pages = summaryData.query?.pages || {}; + const pageId = Object.keys(pages)[0]; + + if (!pageId || pageId === '-1') { + log.info(`Wikipedia page not found for: ${title}`); + return null; + } + + const page = pages[pageId]; + const wikidataId = page.pageprops?.wikibase_item || null; + + log.info(`Found Wikipedia summary for "${title}" (wikidata: ${wikidataId})`); + + return { + title: page.title, + summary: page.extract || '', + pageId: parseInt(pageId, 10), + wikidataId, + }; + } catch (e) { + log.error(`Error fetching Wikipedia summary: ${e.message}`); + return null; + } +} /** - * fetch() with an AbortController timeout so a hung upstream cannot stall the task. - * Mirrors the pattern in brand-resolver.fetchSiteName. - * @param {string} url - Request URL - * @param {object} [options] - fetch options (headers, etc.) - * @returns {Promise<Response>} + * Fetch full Wikipedia article text for deeper extraction. + * @param {string} searchQuery - Search query + * @param {number} [maxChars=12000] - Maximum characters to return + * @param {object} log - Logger instance + * @returns {Promise<string|null>} Article text or null */ -async function timedFetch(url, options = {}) { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), EXTERNAL_FETCH_TIMEOUT_MS); +export async function fetchWikipediaFullText(searchQuery, maxChars, log) { + const limit = maxChars || 12000; + log.info(`Fetching full Wikipedia text for: ${searchQuery} (max ${limit} chars)`); + try { - return await fetch(url, { ...options, signal: controller.signal }); - } finally { - clearTimeout(timer); + // Search for the page first + const searchParams = new URLSearchParams({ + action: 'opensearch', + search: searchQuery, + limit: '1', + namespace: '0', + format: 'json', + }); + + const searchUrl = `${WIKIPEDIA_API_BASE}?${searchParams}`; + const searchResp = await fetch(searchUrl, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!searchResp.ok) { + throw new Error(`Wikipedia search failed: ${searchResp.status}`); + } + + const searchData = await searchResp.json(); + const titles = searchData[1] || []; + + if (titles.length === 0) { + log.info(`No Wikipedia results found for: ${searchQuery}`); + return null; + } + + const title = titles[0]; + + // Fetch full extract + const contentParams = new URLSearchParams({ + action: 'query', + titles: title, + prop: 'extracts', + explaintext: 'true', + format: 'json', + }); + + const contentUrl = `${WIKIPEDIA_API_BASE}?${contentParams}`; + const contentResp = await fetch(contentUrl, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!contentResp.ok) { + throw new Error(`Wikipedia content fetch failed: ${contentResp.status}`); + } + + const contentData = await contentResp.json(); + const pages = contentData.query?.pages || {}; + const pageId = Object.keys(pages)[0]; + + if (!pageId || pageId === '-1') { + return null; + } + + const extract = pages[pageId].extract || ''; + const truncated = extract.slice(0, limit); + + log.info(`Fetched ${truncated.length} chars of Wikipedia text for "${title}"`); + + return truncated; + } catch (e) { + log.error(`Error fetching Wikipedia full text: ${e.message}`); + return null; } } +/** + * Find a brand's Wikidata ID by name. + * @param {string} brandName - Brand name to search for + * @param {object} log - Logger instance + * @returns {Promise<string|null>} Wikidata entity ID (e.g., "Q217994") or null + */ +export async function findWikidataId(brandName, log) { + log.info(`Searching Wikidata for: ${brandName}`); + + try { + const params = new URLSearchParams({ + action: 'wbsearchentities', + search: brandName, + language: 'en', + limit: '5', + format: 'json', + }); + + const url = `${WIKIDATA_API}?${params}`; + const resp = await fetch(url, { + headers: { 'User-Agent': USER_AGENT }, + }); + + if (!resp.ok) { + throw new Error(`Wikidata search failed: ${resp.status}`); + } + + const data = await resp.json(); + const results = data.search || []; + + if (results.length === 0) { + log.info(`No Wikidata entity found for: ${brandName}`); + return null; + } + + // Look for the best match (company/brand/organization) + const companyTerms = [ + 'company', 'brand', 'manufacturer', 'corporation', + 'automaker', 'enterprise', 'business', 'organization', + 'subsidiary', 'division', + ]; + + for (const entity of results) { + const description = (entity.description || '').toLowerCase(); + if (companyTerms.some((term) => description.includes(term))) { + log.info(`Found Wikidata entity: ${entity.id} - ${description}`); + return entity.id; + } + } + + // If no company found, return the first result + const firstResult = results[0].id; + log.info(`Using first Wikidata result: ${firstResult}`); + return firstResult; + } catch (e) { + log.error(`Error searching Wikidata: ${e.message}`); + return null; + } +} + +/* + * --- Entity-binding path (LLMO-6580) --------------------------------------- + * Everything below resolves and validates a Wikidata entity against the site's + * registrable domain via a strong P856 (official-website) host match, then reads + * Wikipedia by the validated entity's exact enwiki sitelink title. There is no + * by-name / fuzzy fallback: if no candidate's official website matches the site, + * the caller gets null and produces no brand data. + */ + +/** + * Minimal public-suffix awareness for the multi-part TLDs that broke the audit set. + * Hand-rolled table (no runtime dependency) covering the common ccTLD second levels. + */ +const MULTI_PART_TLDS = new Set([ + 'co.jp', 'co.uk', 'com.au', 'co.nz', 'gov.sg', 'com.sg', 'com.br', 'co.in', + 'com.mx', 'gov.uk', 'ac.uk', 'org.uk', 'co.za', 'com.cn', 'com.hk', 'co.kr', + 'ne.jp', 'or.jp', 'com.tw', 'co.id', 'com.tr', 'gov.au', 'edu.au', +]); + +/** + * Generic second-level labels that form a two-label public suffix when paired with a + * 2-character ccTLD (e.g. `com.my`, `co.th`, `gov.in`, `or.kr`). Generalising the + * `<generic>.<cc>` shape means an unlisted ccTLD cannot collapse the registrable domain + * down to the bare public suffix, which is the residual LLMO-6580 false-positive vector: + * a bare-suffix registrable domain P856-matches any foreign entity on the same suffix. + */ +const GENERIC_SECOND_LEVELS = new Set([ + 'com', 'co', 'org', 'net', 'gov', 'edu', 'ac', 'mil', 'ne', 'or', 'go', 'gob', 'gouv', +]); + +/** + * Split a hostname into its subdomain labels, apex label, and registrable domain, + * honouring the minimal multi-part TLD table. + * @param {string} hostname - Hostname (e.g. "dev.amrize.com", "dnp.co.jp") + * @returns {{subdomainLabels: string[], apexLabel: string, registrableDomain: string}} + */ +export function splitHost(hostname) { + const host = String(hostname || '').toLowerCase().replace(/\.$/, '').trim(); + const labels = host.split('.').filter(Boolean); + + if (labels.length <= 1) { + return { subdomainLabels: [], apexLabel: labels[0] || '', registrableDomain: host }; + } + + let registrableLabelCount = 2; + const lastTwo = labels.slice(-2).join('.'); + const tld = labels.at(-1); + const secondLevel = labels.at(-2); + // Explicit multi-part TLD, OR the general `<generic-second-level>.<2-char-ccTLD>` shape + // (co.uk, com.my, co.th, gov.in, or.kr, ...). Both are two-label public suffixes, so the + // registrable domain keeps a real label in front of them instead of collapsing to the + // bare suffix (LLMO-6580: a bare-suffix registrable domain yields false P856 matches). + if (labels.length >= 3 + && (MULTI_PART_TLDS.has(lastTwo) + || (tld.length === 2 && GENERIC_SECOND_LEVELS.has(secondLevel)))) { + registrableLabelCount = 3; + } + + const registrableLabels = labels.slice(-registrableLabelCount); + const registrableDomain = registrableLabels.join('.'); + const apexLabel = registrableLabels[0]; + const subdomainLabels = labels.slice(0, labels.length - registrableLabelCount); + + return { subdomainLabels, apexLabel, registrableDomain }; +} + /** * Is this registrable domain actually a bare public suffix (no registrable label in front)? * Such a domain must never produce a P856 match, or any foreign entity on the same suffix * would validate against the site (LLMO-6580). Mirrors the public-suffix logic in - * `splitHost`: an explicit multi-part TLD (`co.uk`) OR the generalized two-label + * {@link splitHost}: an explicit multi-part TLD (`co.uk`) OR the generalized two-label * `<generic>.<2-char ccTLD>` shape (`com.my`, `co.th`, `gov.in`) is a bare suffix. * @param {string} domain - Registrable domain * @returns {boolean} @@ -95,7 +364,7 @@ export async function getWikidataEntity(entityId, log) { }); const url = `${WIKIDATA_API}?${params}`; - const resp = await timedFetch(url, { + const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -141,7 +410,7 @@ export async function getWikidataEntity(entityId, log) { * Validate a Wikidata entity against the customer's site via a strong P856 match: * any official-website host's registrable domain equals the site's registrable domain * (DHL->dhl.com, DNP->dnp.co.jp). This is the only accepted signal; a bare public-suffix - * site domain (e.g. `co.uk`) never matches. No by-name / label fallback (LLMO-6580). + * site domain (e.g. `co.uk`) never matches. There is no by-name / label fallback. * * @param {object} params - Parameters * @param {object} params.entity - Entity from {@link getWikidataEntity} @@ -193,7 +462,7 @@ async function searchWikidataCandidates(brandName, log) { }); const url = `${WIKIDATA_API}?${params}`; - const resp = await timedFetch(url, { + const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -273,7 +542,7 @@ export async function fetchWikipediaExtractByTitle(title, maxChars, log) { }); const url = `${WIKIPEDIA_API_BASE}?${params}`; - const resp = await timedFetch(url, { + const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -329,7 +598,7 @@ export async function fetchValidatedSummary( }); const url = `${WIKIPEDIA_API_BASE}?${params}`; - const resp = await timedFetch(url, { + const resp = await fetch(url, { headers: { 'User-Agent': USER_AGENT }, }); @@ -357,12 +626,16 @@ export async function fetchValidatedSummary( } /** - * Create a Wikipedia service instance (entity-bound methods only). + * Create a Wikipedia service instance. * @param {object} log - Logger instance * @returns {object} Service instance with bound methods */ export function createWikipediaService(log) { return { + fetchSummary: (searchQuery) => fetchWikipediaSummary(searchQuery, log), + fetchFullText: (searchQuery, maxChars) => fetchWikipediaFullText(searchQuery, maxChars, log), + findWikidataId: (brandName) => findWikidataId(brandName, log), + // Entity-binding path (LLMO-6580). getWikidataEntity: (entityId) => getWikidataEntity(entityId, log), findValidatedWikidataEntity: (params) => findValidatedWikidataEntity(params, log), fetchExtractByTitle: (title, maxChars) => fetchWikipediaExtractByTitle(title, maxChars, log), diff --git a/test/agents/brand-profile/index.test.js b/test/agents/brand-profile/index.test.js index 83c0c752..d694d67f 100644 --- a/test/agents/brand-profile/index.test.js +++ b/test/agents/brand-profile/index.test.js @@ -19,8 +19,6 @@ import esmock from 'esmock'; use(sinonChai); use(chaiAsPromised); -const RESOLVER_PATH = '../../../src/agents/brand-profile/services/brand-resolver.js'; - describe('agents/brand-profile', () => { let sandbox; let context; @@ -28,7 +26,7 @@ describe('agents/brand-profile', () => { let log; // Mock service creators - paths relative to src/agents/brand-profile/index.js - const createMockServices = (sb, resolverOverride = {}) => ({ + const createMockServices = (sb) => ({ '../../../src/agents/brand-profile/services/regional-context.js': { createRegionalContextService: () => ({ inferRegionFromUrl: sb.stub().resolves({ @@ -84,17 +82,8 @@ describe('agents/brand-profile', () => { }, '../../../src/agents/brand-profile/services/wikipedia.js': { createWikipediaService: () => ({ - fetchValidatedSummary: sb.stub().resolves(null), - }), - }, - [RESOLVER_PATH]: { - resolveBrandName: sb.stub().resolves({ - name: 'MockBrand', - confidence: 'medium', - source: 'apex_domain', - siteHost: 'example.com', - registrableDomain: 'example.com', - ...resolverOverride, + fetchSummary: sb.stub().resolves(null), + fetchFullText: sb.stub().resolves(null), }), }, }); @@ -167,8 +156,12 @@ describe('agents/brand-profile', () => { choices: [{ message: { content: JSON.stringify({ - main_profile: { target_audience: 'Consumers' }, - competitive_context: { industry: 'Technology' }, + main_profile: { + target_audience: 'Consumers', + }, + competitive_context: { + industry: 'Technology', + }, }), }, }], @@ -220,17 +213,10 @@ describe('agents/brand-profile', () => { }; const mockWikipediaService = { - fetchValidatedSummary: sandbox.stub().resolves({ summary: 'Company summary' }), + fetchSummary: sandbox.stub().resolves({ summary: 'Company summary' }), + fetchFullText: sandbox.stub().resolves('Full text'), }; - const resolveBrandName = sandbox.stub().resolves({ - name: 'Swisslife', - confidence: 'high', - source: 'site_title', - siteHost: 'swisslife.ch', - registrableDomain: 'swisslife.ch', - }); - const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -254,39 +240,22 @@ describe('agents/brand-profile', () => { '../../../src/agents/brand-profile/services/wikipedia.js': { createWikipediaService: () => mockWikipediaService, }, - [RESOLVER_PATH]: { resolveBrandName }, }); const result = await mod.default.run( { baseURL: 'https://swisslife.ch', params: { enhance: true } }, - { BRAND_PROFILE_ENABLE_WIKI_PRODUCTS: 'true' }, + env, log, ); - expect(resolveBrandName).to.have.been.called; + // Verify all services were called expect(mockRegionalService.inferRegionFromUrl).to.have.been.called; expect(mockRegionalService.inferRegionalContext).to.have.been.called; expect(mockCompetitorService.inferCompetitors).to.have.been.called; expect(mockPersonaService.inferPersonas).to.have.been.called; expect(mockProductService.extractProducts).to.have.been.called; - // Competitor path used the VALIDATED summary (entity-bound), not a by-name lookup. - expect(mockWikipediaService.fetchValidatedSummary).to.have.been.calledWithExactly({ - brandName: 'Swisslife', - registrableDomain: 'swisslife.ch', - }); - // ...and forwarded that summary text into competitor inference. - expect(mockCompetitorService.inferCompetitors).to.have.been.calledWithExactly( - sinon.match({ wikipediaSummary: 'Company summary' }), - ); - - // Product path forwarded the options object with the resolved identity + flag. - expect(mockProductService.extractProducts).to.have.been.calledWithExactly({ - brandName: 'Swisslife', - registrableDomain: 'swisslife.ch', - enableWikiProducts: true, - }); - + // Verify result includes enhanced data expect(result.country_code).to.equal('CH'); expect(result.languages).to.deep.equal(['de-CH', 'fr-CH']); expect(result.currency).to.equal('CHF'); @@ -296,12 +265,12 @@ describe('agents/brand-profile', () => { expect(result.products.items).to.have.length(1); }); - it('run() does NOT fetch a validated summary when the kill-switch is off (default)', async () => { + it('run() uses sitemapUrl when provided for product extraction', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { content: JSON.stringify({ - main_profile: {}, + main_profile: { brand_name: 'TestBrand' }, competitive_context: { industry: 'Tech' }, }), }, @@ -309,8 +278,19 @@ describe('agents/brand-profile', () => { }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); - const fetchValidatedSummary = sandbox.stub().resolves({ summary: 'should not be used' }); - const extractProducts = sandbox.stub().resolves({ products: [], metadata: {} }); + const mockProductService = { + extractFromSitemap: sandbox.stub().resolves({ + products: [{ name: 'SitemapProduct' }], + services: [], + sub_brands: [], + discontinued: [], + metadata: { source: 'sitemap', count: 1 }, + }), + extractProducts: sandbox.stub().resolves({ + products: [], + metadata: {}, + }), + }; const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { @@ -320,41 +300,68 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - ...createMockServices(sandbox), + '../../../src/agents/brand-profile/services/regional-context.js': { + createRegionalContextService: () => ({ + inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), + inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), + }), + }, + '../../../src/agents/brand-profile/services/competitor-inference.js': { + createCompetitorInferenceService: () => ({ + inferCompetitors: sandbox.stub().resolves({ competitors: [] }), + }), + }, + '../../../src/agents/brand-profile/services/persona-inference.js': { + createPersonaInferenceService: () => ({ + inferPersonas: sandbox.stub().resolves({ personas: [] }), + }), + }, '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => ({ extractProducts }), + createProductExtractorService: () => mockProductService, }, '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ fetchValidatedSummary }), + createWikipediaService: () => ({ + fetchSummary: sandbox.stub().resolves(null), + fetchFullText: sandbox.stub().resolves(null), + }), }, }); - await mod.default.run( - { baseURL: 'https://example.com', params: { enhance: true } }, + const result = await mod.default.run( + { + baseURL: 'https://example.com', + params: { + enhance: true, + sitemapUrl: 'https://example.com/sitemap.xml', + }, + }, env, log, ); - expect(fetchValidatedSummary).to.not.have.been.called; - // extractProducts still runs, but with the flag off. - expect(extractProducts).to.have.been.calledWithExactly( - sinon.match({ enableWikiProducts: false }), + // extractFromSitemap should be called instead of extractProducts + expect(mockProductService.extractFromSitemap).to.have.been.calledWith( + 'https://example.com/sitemap.xml', + 'TestBrand', ); + expect(mockProductService.extractProducts).to.not.have.been.called; + expect(result.products.items).to.have.length(1); + expect(result.products.items[0].name).to.equal('SitemapProduct'); }); - it('run() tolerates a null validated summary (flag on) and infers with an empty overview', async () => { + it('run() extracts brand name from competitive_context when main_profile missing', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { - content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), + content: JSON.stringify({ + main_profile: {}, + competitive_context: { brand_name: 'ContextBrand', industry: 'Tech' }, + }), }, }], }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); - const fetchValidatedSummary = sandbox.stub().resolves(null); - const inferCompetitors = sandbox.stub().resolves({ competitors: [], source: 'llm_inferred' }); - const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -363,31 +370,51 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - ...createMockServices(sandbox, { name: 'Amrize', confidence: 'high', registrableDomain: 'amrize.com' }), + '../../../src/agents/brand-profile/services/regional-context.js': { + createRegionalContextService: () => ({ + inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), + inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), + }), + }, '../../../src/agents/brand-profile/services/competitor-inference.js': { - createCompetitorInferenceService: () => ({ inferCompetitors }), + createCompetitorInferenceService: () => ({ + inferCompetitors: sandbox.stub().resolves({ competitors: [] }), + }), + }, + '../../../src/agents/brand-profile/services/persona-inference.js': { + createPersonaInferenceService: () => ({ + inferPersonas: sandbox.stub().resolves({ personas: [] }), + }), + }, + '../../../src/agents/brand-profile/services/product-extractor.js': { + createProductExtractorService: () => ({ + extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), + }), }, '../../../src/agents/brand-profile/services/wikipedia.js': { - createWikipediaService: () => ({ fetchValidatedSummary }), + createWikipediaService: () => ({ + fetchSummary: sandbox.stub().resolves(null), + fetchFullText: sandbox.stub().resolves(null), + }), }, }); await mod.default.run( - { baseURL: 'https://amrize.com', params: { enhance: true } }, - { BRAND_PROFILE_ENABLE_WIKI_PRODUCTS: 'true' }, + { baseURL: 'https://example.com', params: { enhance: true } }, + env, log, ); - expect(fetchValidatedSummary).to.have.been.called; - expect(inferCompetitors).to.have.been.calledWithExactly(sinon.match({ wikipediaSummary: '' })); + // The log should show "ContextBrand" as the extracted brand name + expect(log.info).to.have.been.calledWithMatch('ContextBrand'); }); - it('run() uses sitemapUrl when provided for product extraction', async () => { + it('run() falls back to domain name when no brand name in profile', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { content: JSON.stringify({ - main_profile: { brand_name: 'TestBrand' }, + main_profile: {}, competitive_context: { industry: 'Tech' }, }), }, @@ -395,17 +422,6 @@ describe('agents/brand-profile', () => { }); const createFrom = sandbox.stub().returns({ fetchChatCompletion }); - const mockProductService = { - extractFromSitemap: sandbox.stub().resolves({ - products: [{ name: 'SitemapProduct' }], - services: [], - sub_brands: [], - discontinued: [], - metadata: { source: 'sitemap', count: 1 }, - }), - extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), - }; - const mod = await esmock('../../../src/agents/brand-profile/index.js', { '@adobe/spacecat-shared-gpt-client': { AzureOpenAIClient: { createFrom }, @@ -414,52 +430,33 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - ...createMockServices(sandbox, { name: 'TestBrand', confidence: 'high' }), - '../../../src/agents/brand-profile/services/product-extractor.js': { - createProductExtractorService: () => mockProductService, + '../../../src/agents/brand-profile/services/regional-context.js': { + createRegionalContextService: () => ({ + inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), + inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), + }), }, - }); - - const result = await mod.default.run( - { - baseURL: 'https://example.com', - params: { - enhance: true, - sitemapUrl: 'https://example.com/sitemap.xml', - }, + '../../../src/agents/brand-profile/services/competitor-inference.js': { + createCompetitorInferenceService: () => ({ + inferCompetitors: sandbox.stub().resolves({ competitors: [] }), + }), }, - env, - log, - ); - - expect(mockProductService.extractFromSitemap).to.have.been.calledWith( - 'https://example.com/sitemap.xml', - 'TestBrand', - ); - expect(mockProductService.extractProducts).to.not.have.been.called; - expect(result.products.items).to.have.length(1); - expect(result.products.items[0].name).to.equal('SitemapProduct'); - }); - - it('run() logs the resolved brand name (domain-derived)', async () => { - const fetchChatCompletion = sandbox.stub().resolves({ - choices: [{ - message: { - content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), - }, - }], - }); - const createFrom = sandbox.stub().returns({ fetchChatCompletion }); - - const mod = await esmock('../../../src/agents/brand-profile/index.js', { - '@adobe/spacecat-shared-gpt-client': { - AzureOpenAIClient: { createFrom }, + '../../../src/agents/brand-profile/services/persona-inference.js': { + createPersonaInferenceService: () => ({ + inferPersonas: sandbox.stub().resolves({ personas: [] }), + }), }, - '../../../src/agents/base.js': { - readPromptFile: sandbox.stub().returns('PROMPT'), - renderTemplate: sandbox.stub().returns('RENDERED'), + '../../../src/agents/brand-profile/services/product-extractor.js': { + createProductExtractorService: () => ({ + extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), + }), + }, + '../../../src/agents/brand-profile/services/wikipedia.js': { + createWikipediaService: () => ({ + fetchSummary: sandbox.stub().resolves(null), + fetchFullText: sandbox.stub().resolves(null), + }), }, - ...createMockServices(sandbox, { name: 'Testcompany', confidence: 'medium', registrableDomain: 'testcompany.com' }), }); await mod.default.run( @@ -468,14 +465,18 @@ describe('agents/brand-profile', () => { log, ); + // Should extract "Testcompany" from the domain expect(log.info).to.have.been.calledWithMatch('Testcompany'); }); - it('run() logs the "Unknown Brand" sentinel from the resolver', async () => { + it('run() uses "Unknown Brand" when URL has only short domain parts', async () => { const fetchChatCompletion = sandbox.stub().resolves({ choices: [{ message: { - content: JSON.stringify({ main_profile: {}, competitive_context: { industry: 'Tech' } }), + content: JSON.stringify({ + main_profile: {}, + competitive_context: { industry: 'Tech' }, + }), }, }], }); @@ -489,7 +490,33 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - ...createMockServices(sandbox, { name: 'Unknown Brand', confidence: 'low', source: 'none' }), + '../../../src/agents/brand-profile/services/regional-context.js': { + createRegionalContextService: () => ({ + inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), + inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), + }), + }, + '../../../src/agents/brand-profile/services/competitor-inference.js': { + createCompetitorInferenceService: () => ({ + inferCompetitors: sandbox.stub().resolves({ competitors: [] }), + }), + }, + '../../../src/agents/brand-profile/services/persona-inference.js': { + createPersonaInferenceService: () => ({ + inferPersonas: sandbox.stub().resolves({ personas: [] }), + }), + }, + '../../../src/agents/brand-profile/services/product-extractor.js': { + createProductExtractorService: () => ({ + extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), + }), + }, + '../../../src/agents/brand-profile/services/wikipedia.js': { + createWikipediaService: () => ({ + fetchSummary: sandbox.stub().resolves(null), + fetchFullText: sandbox.stub().resolves(null), + }), + }, }); await mod.default.run( @@ -498,6 +525,7 @@ describe('agents/brand-profile', () => { log, ); + // Should use "Unknown Brand" since all domain parts are short expect(log.info).to.have.been.calledWithMatch('Unknown Brand'); }); @@ -526,10 +554,31 @@ describe('agents/brand-profile', () => { readPromptFile: sandbox.stub().returns('PROMPT'), renderTemplate: sandbox.stub().returns('RENDERED'), }, - ...createMockServices(sandbox), + '../../../src/agents/brand-profile/services/regional-context.js': { + createRegionalContextService: () => ({ + inferRegionFromUrl: sandbox.stub().resolves({ country_code: 'US' }), + inferRegionalContext: sandbox.stub().resolves({ languages: ['en-US'] }), + }), + }, '../../../src/agents/brand-profile/services/competitor-inference.js': { createCompetitorInferenceService: () => mockCompetitorService, }, + '../../../src/agents/brand-profile/services/persona-inference.js': { + createPersonaInferenceService: () => ({ + inferPersonas: sandbox.stub().resolves({ personas: [] }), + }), + }, + '../../../src/agents/brand-profile/services/product-extractor.js': { + createProductExtractorService: () => ({ + extractProducts: sandbox.stub().resolves({ products: [], metadata: {} }), + }), + }, + '../../../src/agents/brand-profile/services/wikipedia.js': { + createWikipediaService: () => ({ + fetchSummary: sandbox.stub().resolves(null), + fetchFullText: sandbox.stub().resolves(null), + }), + }, }); const result = await mod.default.run( @@ -544,6 +593,7 @@ describe('agents/brand-profile', () => { log, ); + // inferCompetitors should NOT be called when LLMO competitors provided expect(mockCompetitorService.inferCompetitors).to.not.have.been.called; expect(result.competitors_source).to.equal('llmo'); expect(result.competitors).to.have.length(2); @@ -679,18 +729,28 @@ describe('agents/brand-profile', () => { expect(log.info).to.have.been.calledWithMatch('brand-profile persist:'); }); - it('persist() logs unchanged summary when content hash is same', async () => { - const profile = { contentHash: 'same', version: 5 }; + it('persist() preserves manual-curated products and does not overwrite them', async () => { + const curatedProducts = { items: [{ name: 'Hand-curated widget' }] }; + const curatedMetadata = { source: 'manual-curated', curated_by: 'ops', count: 1 }; + const beforeProfile = { + contentHash: 'old', + version: 1, + products: curatedProducts, + products_metadata: curatedMetadata, + }; + let currentProfile = beforeProfile; + let persisted; const cfg = { - getBrandProfile: () => profile, - updateBrandProfile: sinon.stub(), + getBrandProfile: () => currentProfile, + updateBrandProfile: (p) => { + persisted = p; + currentProfile = { ...p, contentHash: 'new', version: 2 }; + }, }; - const setConfig = sinon.stub(); - const save = sinon.stub().resolves(); const findById = sandbox.stub().resolves({ getConfig: () => cfg, - setConfig, - save, + setConfig: sinon.stub(), + save: sinon.stub().resolves(), getBaseURL: () => 'https://example.com', }); context.dataAccess.Site = { findById }; @@ -705,22 +765,29 @@ describe('agents/brand-profile', () => { await mod.default.persist( { siteId: '123e4567-e89b-12d3-a456-426614174000', baseURL: 'https://example.com' }, context, - { unchanged: true }, + { + main_profile: { tone_attributes: {} }, + products: { items: [{ name: 'Agent-generated widget' }] }, + products_metadata: { source: 'none', count: 0 }, + }, ); - expect(findById).to.have.been.calledOnce; - expect(toDynamoItem).to.have.been.calledOnceWithExactly(cfg); - expect(setConfig).to.have.been.calledOnce; - expect(save).to.have.been.calledOnce; + // Incoming agent products/metadata must be discarded; curated blocks kept verbatim. + expect(persisted.products).to.equal(curatedProducts); + expect(persisted.products_metadata).to.equal(curatedMetadata); + // Non-product fields from the incoming result still flow through. + expect(persisted.main_profile).to.deep.equal({ tone_attributes: {} }); expect(log.info).to.have.been.calledWith( - 'brand-profile persist:', - sinon.match.has('summary', sinon.match(':information_source: Brand profile already up to date (v5) for https://example.com')), + 'brand-profile persist: preserving manual-curated products', + sinon.match.object, ); }); - it('persist() handles configs without getBrandProfile implementation', async () => { + it('persist() logs unchanged summary when content hash is same', async () => { + const profile = { contentHash: 'same', version: 5 }; const cfg = { - updateBrandProfile: sinon.stub(), + getBrandProfile: () => profile, + updateBrandProfile: sinon.stub(), // leaves hash unchanged }; const setConfig = sinon.stub(); const save = sinon.stub().resolves(); @@ -740,85 +807,25 @@ describe('agents/brand-profile', () => { }); await mod.default.persist( - { siteId: '123e4567-e89b-12d3-a456-426614174000' }, + { siteId: '123e4567-e89b-12d3-a456-426614174000', baseURL: 'https://example.com' }, context, - { foo: 'bar' }, + { unchanged: true }, ); + expect(findById).to.have.been.calledOnce; expect(toDynamoItem).to.have.been.calledOnceWithExactly(cfg); expect(setConfig).to.have.been.calledOnce; expect(save).to.have.been.calledOnce; expect(log.info).to.have.been.calledWith( 'brand-profile persist:', - sinon.match.object, - ); - }); - - it('persist() preserves a manual-curated product catalogue (LLMO-6580 guard)', async () => { - const before = { - contentHash: 'old', - version: 3, - products: { items: [{ name: 'HandCurated' }] }, - products_metadata: { source: 'manual-curated', count: 1 }, - }; - let received; - let currentProfile = before; - const cfg = { - getBrandProfile: () => currentProfile, - updateBrandProfile: (p) => { - received = p; - currentProfile = { ...p, contentHash: 'new', version: 4 }; - }, - }; - const setConfig = sinon.stub(); - const save = sinon.stub().resolves(); - const findById = sandbox.stub().resolves({ - getConfig: () => cfg, - setConfig, - save, - getBaseURL: () => 'https://curated.com', - }); - context.dataAccess.Site = { findById }; - - const toDynamoItem = sandbox.stub().callsFake((c) => c); - const mod = await esmock('../../../src/agents/brand-profile/index.js', { - '@adobe/spacecat-shared-data-access/src/models/site/config.js': { - Config: { toDynamoItem }, - }, - }); - - await mod.default.persist( - { siteId: '123e4567-e89b-12d3-a456-426614174000' }, - context, - { - main_profile: { communication_style: 'new voice' }, - products: { items: [{ name: 'FabricatedProduct' }] }, - products_metadata: { source: 'wikipedia_llm', count: 1 }, - }, + sinon.match.has('summary', sinon.match(':information_source: Brand profile already up to date (v5) for https://example.com')), ); - - // Non-product fields update, but the curated products/metadata are preserved. - expect(received.main_profile.communication_style).to.equal('new voice'); - expect(received.products).to.deep.equal(before.products); - expect(received.products_metadata).to.deep.equal(before.products_metadata); - expect(log.info).to.have.been.calledWithMatch('preserving manual-curated products'); }); - it('persist() overwrites products when the stored source is NOT manual-curated', async () => { - const before = { - contentHash: 'old', - version: 3, - products: { items: [{ name: 'OldFabricated' }] }, - products_metadata: { source: 'wikipedia_llm', count: 1 }, - }; - let received; - let currentProfile = before; + it('persist() handles configs without getBrandProfile implementation', async () => { const cfg = { - getBrandProfile: () => currentProfile, - updateBrandProfile: (p) => { - received = p; - currentProfile = { ...p, contentHash: 'new', version: 4 }; - }, + updateBrandProfile: sinon.stub(), + // getBrandProfile intentionally undefined to hit fallback branches }; const setConfig = sinon.stub(); const save = sinon.stub().resolves(); @@ -837,19 +844,19 @@ describe('agents/brand-profile', () => { }, }); - const result = { - products: { items: [] }, - products_metadata: { source: 'none_no_validated_entity', count: 0 }, - }; await mod.default.persist( { siteId: '123e4567-e89b-12d3-a456-426614174000' }, context, - result, + { foo: 'bar' }, ); - expect(received.products_metadata.source).to.equal('none_no_validated_entity'); - expect(received.products).to.deep.equal(result.products); - expect(log.info).to.not.have.been.calledWithMatch('preserving manual-curated products'); + expect(toDynamoItem).to.have.been.calledOnceWithExactly(cfg); + expect(setConfig).to.have.been.calledOnce; + expect(save).to.have.been.calledOnce; + expect(log.info).to.have.been.calledWith( + 'brand-profile persist:', + sinon.match.object, + ); }); it('persist() includes highlight blocks when main profile data is present', async () => { @@ -875,6 +882,7 @@ describe('agents/brand-profile', () => { '@adobe/spacecat-shared-data-access/src/models/site/config.js': { Config: { toDynamoItem }, }, + ...createMockServices(sandbox), }); const result = await mod.default.persist( @@ -921,6 +929,7 @@ describe('agents/brand-profile', () => { '@adobe/spacecat-shared-data-access/src/models/site/config.js': { Config: { toDynamoItem }, }, + ...createMockServices(sandbox), }); const result = await mod.default.persist( diff --git a/test/agents/brand-profile/services/brand-resolver.test.js b/test/agents/brand-profile/services/brand-resolver.test.js deleted file mode 100644 index f440c90d..00000000 --- a/test/agents/brand-profile/services/brand-resolver.test.js +++ /dev/null @@ -1,294 +0,0 @@ -/* - * Copyright 2025 Adobe. All rights reserved. - * This file is licensed to you under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. You may obtain a copy - * of the License at http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software distributed under - * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS - * OF ANY KIND, either express or implied. See the License for the specific language - * governing permissions and limitations under the License. - */ - -import { expect, use } from 'chai'; -import chaiAsPromised from 'chai-as-promised'; -import sinon from 'sinon'; -import sinonChai from 'sinon-chai'; -import { - splitHost, - isLowConfidenceLabel, - fetchSiteName, - resolveBrandName, -} from '../../../../src/agents/brand-profile/services/brand-resolver.js'; - -use(sinonChai); -use(chaiAsPromised); - -const htmlResponse = (html) => ({ - ok: true, - headers: { get: () => 'text/html; charset=utf-8' }, - text: () => Promise.resolve(html), -}); - -describe('services/brand-resolver', () => { - let sandbox; - let log; - let fetchStub; - - beforeEach(() => { - sandbox = sinon.createSandbox(); - log = { - debug: sandbox.stub(), - info: sandbox.stub(), - warn: sandbox.stub(), - error: sandbox.stub(), - }; - fetchStub = sandbox.stub(globalThis, 'fetch'); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('splitHost', () => { - it('strips a subdomain to the apex label', () => { - expect(splitHost('dev.amrize.com')).to.deep.equal({ - subdomainLabels: ['dev'], - apexLabel: 'amrize', - registrableDomain: 'amrize.com', - }); - }); - - it('handles multi-part ccTLDs (co.jp)', () => { - expect(splitHost('dnp.co.jp')).to.deep.equal({ - subdomainLabels: [], - apexLabel: 'dnp', - registrableDomain: 'dnp.co.jp', - }); - }); - - it('handles multi-part ccTLDs with a subdomain (gov.sg)', () => { - expect(splitHost('www.edb.gov.sg')).to.deep.equal({ - subdomainLabels: ['www'], - apexLabel: 'edb', - registrableDomain: 'edb.gov.sg', - }); - }); - - it('generalizes unlisted <generic>.<ccTLD> suffixes so they never collapse to the bare suffix (LLMO-6580)', () => { - // None of these ccTLD second-levels are in MULTI_PART_TLDS; the generic-second-level - // rule must still keep a registrable label in front of the suffix. - expect(splitHost('maybank.com.my')).to.deep.equal({ - subdomainLabels: [], - apexLabel: 'maybank', - registrableDomain: 'maybank.com.my', - }); - expect(splitHost('www.pttep.co.th')).to.deep.equal({ - subdomainLabels: ['www'], - apexLabel: 'pttep', - registrableDomain: 'pttep.co.th', - }); - expect(splitHost('nic.gov.in')).to.deep.equal({ - subdomainLabels: [], - apexLabel: 'nic', - registrableDomain: 'nic.gov.in', - }); - }); - - it('does not treat a non-generic second-level before a ccTLD as multi-part (ab.co)', () => { - // 'ab' is not a generic second-level, so 'ab.co' stays the registrable domain. - expect(splitHost('sub.ab.co')).to.deep.equal({ - subdomainLabels: ['sub'], - apexLabel: 'ab', - registrableDomain: 'ab.co', - }); - }); - - it('strips a section subdomain (store)', () => { - const { apexLabel } = splitHost('store.example.com'); - expect(apexLabel).to.equal('example'); - }); - - it('handles a plain apex domain', () => { - expect(splitHost('www.ab.co')).to.deep.equal({ - subdomainLabels: ['www'], - apexLabel: 'ab', - registrableDomain: 'ab.co', - }); - }); - - it('handles a single-label host', () => { - expect(splitHost('localhost')).to.deep.equal({ - subdomainLabels: [], - apexLabel: 'localhost', - registrableDomain: 'localhost', - }); - }); - - it('handles an empty host', () => { - expect(splitHost('')).to.deep.equal({ - subdomainLabels: [], - apexLabel: '', - registrableDomain: '', - }); - }); - - it('lowercases and trims a trailing dot', () => { - expect(splitHost('Amrize.COM.')).to.deep.equal({ - subdomainLabels: [], - apexLabel: 'amrize', - registrableDomain: 'amrize.com', - }); - }); - }); - - describe('isLowConfidenceLabel', () => { - it('flags short acronyms', () => { - expect(isLowConfidenceLabel('dnp')).to.equal(true); - expect(isLowConfidenceLabel('edb')).to.equal(true); - expect(isLowConfidenceLabel('dnb')).to.equal(true); - expect(isLowConfidenceLabel('IBM')).to.equal(true); // short: relies on P856 downstream - }); - - it('flags stop labels', () => { - expect(isLowConfidenceLabel('dev')).to.equal(true); - expect(isLowConfidenceLabel('www')).to.equal(true); - expect(isLowConfidenceLabel('store')).to.equal(true); - }); - - it('accepts real multi-character brand tokens', () => { - expect(isLowConfidenceLabel('amrize')).to.equal(false); - expect(isLowConfidenceLabel('testcompany')).to.equal(false); - }); - - it('flags empty/nullish labels', () => { - expect(isLowConfidenceLabel('')).to.equal(true); - expect(isLowConfidenceLabel(null)).to.equal(true); - }); - }); - - describe('fetchSiteName', () => { - it('returns og:site_name when present', async () => { - fetchStub.resolves(htmlResponse('<html><head><meta property="og:site_name" content="Amrize"></head></html>')); - const result = await fetchSiteName('https://amrize.com', log); - expect(result).to.equal('Amrize'); - }); - - it('falls back to a cleaned <title>', async () => { - fetchStub.resolves(htmlResponse('<html><head><title>Acme Corporation | Home')); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.equal('Acme Corporation'); - }); - - it('falls back to the first segment when every title segment is generic', async () => { - fetchStub.resolves(htmlResponse('')); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.equal('Home'); - }); - - it('returns null when neither og:site_name nor title present', async () => { - fetchStub.resolves(htmlResponse('hi')); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.be.null; - }); - - it('returns null for non-HTML content types', async () => { - fetchStub.resolves({ - ok: true, - headers: { get: () => 'application/pdf' }, - text: () => Promise.resolve('%PDF-1.4'), - }); - const result = await fetchSiteName('https://acme.com/file.pdf', log); - expect(result).to.be.null; - }); - - it('returns null when the response is not ok', async () => { - fetchStub.resolves({ ok: false, status: 403, headers: { get: () => 'text/html' } }); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.be.null; - }); - - it('returns null on network error (never throws)', async () => { - fetchStub.rejects(new Error('ECONNRESET')); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.be.null; - expect(log.info).to.have.been.calledWithMatch('homepage fetch failed'); - }); - - it('tolerates a response without a headers object', async () => { - fetchStub.resolves({ - ok: true, - text: () => Promise.resolve(''), - }); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.equal('NoHeaders'); - }); - - it('tolerates a headers object without a get method', async () => { - fetchStub.resolves({ - ok: true, - headers: {}, - text: () => Promise.resolve('HasHeadersNoGet'), - }); - const result = await fetchSiteName('https://acme.com', log); - expect(result).to.equal('HasHeadersNoGet'); - }); - }); - - describe('resolveBrandName', () => { - it('uses main_profile.brand_name (high confidence, no fetch)', async () => { - const result = await resolveBrandName( - { main_profile: { brand_name: 'Adobe' } }, - 'https://adobe.com', - log, - ); - expect(result).to.include({ - name: 'Adobe', confidence: 'high', source: 'base_profile', registrableDomain: 'adobe.com', - }); - expect(fetchStub).to.not.have.been.called; - }); - - it('uses competitive_context.brand_name when main_profile missing', async () => { - const result = await resolveBrandName( - { main_profile: {}, competitive_context: { brand_name: 'ContextBrand' } }, - 'https://example.com', - log, - ); - expect(result).to.include({ name: 'ContextBrand', confidence: 'high', source: 'competitive_context' }); - }); - - it('uses a real site title as high confidence', async () => { - fetchStub.resolves(htmlResponse('')); - const result = await resolveBrandName({ main_profile: {} }, 'https://dev.amrize.com', log); - expect(result).to.include({ name: 'Amrize', confidence: 'high', source: 'site_title' }); - }); - - it('falls back to the apex label as medium confidence', async () => { - fetchStub.resolves({ ok: false, status: 404, headers: { get: () => 'text/html' } }); - const result = await resolveBrandName({ main_profile: {} }, 'https://testcompany.com', log); - expect(result).to.include({ name: 'Testcompany', confidence: 'medium', source: 'apex_domain' }); - }); - - it('REGRESSION: a bare acronym apex stays LOW confidence, never high', async () => { - fetchStub.rejects(new Error('bot-blocked')); - const result = await resolveBrandName({ main_profile: {} }, 'https://dnp.co.jp', log); - expect(result).to.include({ - name: 'Dnp', confidence: 'low', source: 'apex_acronym', registrableDomain: 'dnp.co.jp', - }); - }); - - it('skips a low-confidence site title and falls through to apex', async () => { - fetchStub.resolves(htmlResponse('ab')); - const result = await resolveBrandName({ main_profile: {} }, 'https://amrize.com', log); - expect(result).to.include({ name: 'Amrize', confidence: 'medium', source: 'apex_domain' }); - }); - - it('returns the Unknown Brand sentinel when the URL cannot be parsed', async () => { - fetchStub.rejects(new Error('bad url')); - const result = await resolveBrandName({ main_profile: {} }, 'not-a-url', log); - expect(result).to.include({ - name: 'Unknown Brand', confidence: 'low', source: 'none', siteHost: '', - }); - }); - }); -}); diff --git a/test/agents/brand-profile/services/product-extractor.test.js b/test/agents/brand-profile/services/product-extractor.test.js index 7efa9cad..ea23f1b4 100644 --- a/test/agents/brand-profile/services/product-extractor.test.js +++ b/test/agents/brand-profile/services/product-extractor.test.js @@ -357,8 +357,7 @@ describe('services/product-extractor', () => { ); expect(result.products).to.have.length(0); - expect(result.metadata.source).to.equal('none_no_validated_entity'); - expect(result.metadata.rejected).to.equal(true); + expect(result.metadata.source).to.equal('none'); // The decoupled `opensearch "Dnp company"` fetch must never be issued. expect(noOpenSearchIssued(fetchStub)).to.equal(true); expect(gpt.fetchChatCompletion).to.not.have.been.called; @@ -377,11 +376,11 @@ describe('services/product-extractor', () => { ); expect(result.products).to.have.length(0); - expect(result.metadata.source).to.equal('none_no_validated_entity'); + expect(result.metadata.source).to.equal('none'); expect(noOpenSearchIssued(fetchStub)).to.equal(true); }); - it('returns none_no_validated_entity when a candidate has a non-matching P856 host', async () => { + it('returns source none when a candidate has a non-matching P856 host', async () => { fetchStub.onCall(0).resolves(searchResp(['Q9'])); fetchStub.onCall(1).resolves(entityResp('Q9', { label: 'Totally Different', hosts: ['other.example'] })); @@ -392,8 +391,8 @@ describe('services/product-extractor', () => { ); expect(result.products).to.have.length(0); - expect(result.metadata.source).to.equal('none_no_validated_entity'); - expect(result.metadata.rejected).to.equal(true); + expect(result.metadata.source).to.equal('none'); + expect(result.metadata.brand_wikidata_id).to.be.null; }); it('uses the validated entity enwiki title (sitelink, not a by-name search) for the fallback', async () => { @@ -419,7 +418,6 @@ describe('services/product-extractor', () => { ); expect(result.metadata.source).to.equal('hybrid'); - expect(result.metadata.source_wikipedia_title).to.equal('DHL Group'); // The extract call used the sitelink title, not a by-name search. const extractUrl = fetchStub.getCall(3).args[0]; expect(extractUrl).to.include('titles=DHL+Group'); @@ -470,20 +468,6 @@ describe('services/product-extractor', () => { expect(result.metadata.source).to.equal('wikidata'); }); - it('is a hard no-op when the wiki-products kill-switch is off', async () => { - const result = await extractProducts( - { - brandName: 'DHL', registrableDomain: 'dhl.com', enableWikiProducts: false, - }, - gpt, - log, - ); - - expect(result.metadata.source).to.equal('disabled'); - expect(result.products).to.have.length(0); - expect(fetchStub).to.not.have.been.called; - }); - it('accepts a provided wikipediaSummary without re-fetching the article', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'Amrize', enwikiTitle: 'Amrize', hosts: ['amrize.com'] })); @@ -512,31 +496,6 @@ describe('services/product-extractor', () => { expect(fetchStub.callCount).to.equal(3); }); - it('refuses a SPARQL query when the resolved entity id is malformed (injection guard)', async () => { - // Entity validates via P856 but carries a non-Q id; the SPARQL template substitution - // must be refused rather than issued. - fetchStub.onCall(0).resolves(searchResp(['QABC'])); - fetchStub.onCall(1).resolves(entityResp('QABC', { label: 'Acme', enwikiTitle: 'Acme', hosts: ['acme.com'] })); - fetchStub.onCall(2).resolves(extractResp('Acme is a company.')); - - gpt.fetchChatCompletion.resolves(llmResp({ - products: [], services: [], sub_brands: [], discontinued: [], - })); - - const result = await extractProducts( - { brandName: 'Acme', registrableDomain: 'acme.com' }, - gpt, - log, - ); - - expect(result.metadata.brand_wikidata_id).to.equal('QABC'); - expect(result.products).to.have.length(0); - expect(log.warn).to.have.been.calledWithMatch('Refusing SPARQL query for malformed Wikidata id'); - // No SPARQL request was issued (search + entity + wiki-extract only). - const sparqlIssued = fetchStub.getCalls().some((c) => String(c.args[0]).includes('sparql')); - expect(sparqlIssued).to.equal(false); - }); - it('merges hybrid results and de-duplicates overlaps', async () => { fetchStub.onCall(0).resolves(searchResp(['Q1'])); fetchStub.onCall(1).resolves(entityResp('Q1', { label: 'DHL', enwikiTitle: 'DHL', hosts: ['dhl.com'] })); @@ -810,13 +769,13 @@ describe('services/product-extractor', () => { }); it('extractProducts service method forwards the options object', async () => { - // No candidates -> none_no_validated_entity + // No candidates -> no validated entity -> source none fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); const service = createProductExtractorService(env, log); const result = await service.extractProducts({ brandName: 'TestBrand', registrableDomain: 'test.com' }); expect(result).to.have.property('metadata'); - expect(result.metadata.source).to.equal('none_no_validated_entity'); + expect(result.metadata.source).to.equal('none'); }); }); diff --git a/test/agents/brand-profile/services/wikipedia.test.js b/test/agents/brand-profile/services/wikipedia.test.js index 8164b895..d509258b 100644 --- a/test/agents/brand-profile/services/wikipedia.test.js +++ b/test/agents/brand-profile/services/wikipedia.test.js @@ -19,7 +19,8 @@ import esmock from 'esmock'; use(sinonChai); use(chaiAsPromised); -const importMod = () => esmock('../../../../src/agents/brand-profile/services/wikipedia.js', {}); +const WIKI_PATH = '../../../../src/agents/brand-profile/services/wikipedia.js'; +const importWiki = () => esmock(WIKI_PATH, {}); describe('services/wikipedia', () => { let sandbox; @@ -41,6 +42,380 @@ describe('services/wikipedia', () => { sandbox.restore(); }); + describe('fetchWikipediaSummary', () => { + it('fetches and returns Wikipedia summary', async () => { + // Mock search response + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve([ + 'Swiss Life', + ['Swiss Life'], + [''], + ['https://en.wikipedia.org/wiki/Swiss_Life'], + ]), + }); + + // Mock summary response + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + title: 'Swiss Life', + extract: 'Swiss Life is a Swiss insurance company...', + pageprops: { wikibase_item: 'Q680290' }, + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Swiss Life company', log); + + expect(result.title).to.equal('Swiss Life'); + expect(result.summary).to.include('Swiss insurance company'); + expect(result.wikidataId).to.equal('Q680290'); + }); + + it('returns null when no search results', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve(['Swiss Life', [], [], []]), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Unknown Company', log); + + expect(result).to.be.null; + }); + + it('returns null on fetch error', async () => { + fetchStub.rejects(new Error('Network error')); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result).to.be.null; + expect(log.error).to.have.been.called; + }); + + it('throws when search response is not ok', async () => { + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikipedia search failed'); + }); + + it('throws when summary response is not ok', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: false, + status: 503, + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikipedia summary fetch failed'); + }); + + it('returns null when page not found (pageId is -1)', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + '-1': { missing: true }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result).to.be.null; + }); + }); + + describe('fetchWikipediaFullText', () => { + it('fetches full Wikipedia article text', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Swiss Life', ['Swiss Life'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + extract: 'Full article content...', + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Swiss Life company', 12000, log); + + expect(result).to.equal('Full article content...'); + }); + + it('truncates content to maxChars', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test'], [], []]), + }); + + const longText = 'A'.repeat(20000); + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + extract: longText, + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 1000, log); + + expect(result.length).to.equal(1000); + }); + + it('returns null when no search results', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve(['Test', [], [], []]), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Unknown', 12000, log); + + expect(result).to.be.null; + }); + + it('returns null when search response not ok', async () => { + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikipedia search failed'); + }); + + it('returns null when content response not ok', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: false, + status: 503, + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikipedia content fetch failed'); + }); + + it('returns null when page not found (pageId is -1)', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + '-1': { missing: true }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.be.null; + }); + + it('returns null on fetch error', async () => { + fetchStub.rejects(new Error('Network error')); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.be.null; + expect(log.error).to.have.been.called; + }); + + it('uses default maxChars when not provided', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + extract: 'Short content', + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', null, log); + + expect(result).to.equal('Short content'); + }); + }); + + describe('findWikidataId', () => { + it('finds Wikidata ID for a brand', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + search: [ + { id: 'Q12345', description: 'American technology company' }, + { id: 'Q67890', description: 'unrelated' }, + ], + }), + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Adobe', log); + + expect(result).to.equal('Q12345'); + }); + + it('returns first result if no company match', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + search: [ + { id: 'Q99999', description: 'Something else' }, + ], + }), + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Unknown', log); + + expect(result).to.equal('Q99999'); + }); + + it('returns null when no results', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ search: [] }), + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('NonexistentBrand', log); + + expect(result).to.be.null; + }); + + it('returns null when response not ok', async () => { + fetchStub.resolves({ + ok: false, + status: 500, + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Test', log); + + expect(result).to.be.null; + expect(log.error).to.have.been.calledWithMatch('Wikidata search failed'); + }); + + it('returns null on fetch error', async () => { + fetchStub.rejects(new Error('Network error')); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Test', log); + + expect(result).to.be.null; + expect(log.error).to.have.been.called; + }); + + it('handles entity with no description', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + search: [ + { id: 'Q11111' }, + ], + }), + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Test', log); + + expect(result).to.equal('Q11111'); + }); + }); + describe('getWikidataEntity', () => { it('parses label, enwiki title and P856 hosts', async () => { fetchStub.resolves({ @@ -60,15 +435,13 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q489815', log); expect(entity.id).to.equal('Q489815'); expect(entity.label).to.equal('DHL'); expect(entity.enwikiTitle).to.equal('DHL'); expect(entity.officialWebsiteHosts).to.deep.equal(['www.dhl.com']); - // Aliases are no longer parsed (P856-only design). - expect(entity).to.not.have.property('aliases'); }); it('handles missing claims and missing sitelink and invalid P856 URLs', async () => { @@ -88,7 +461,7 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q1', log); expect(entity.label).to.equal('NoWiki'); @@ -102,7 +475,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ entities: {} }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q404', log); expect(entity).to.be.null; }); @@ -113,7 +486,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ entities: { Q1: { claims: {} } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q1', log); expect(entity.label).to.be.null; expect(entity.enwikiTitle).to.be.null; @@ -122,7 +495,7 @@ describe('services/wikipedia', () => { it('returns null when response is not ok', async () => { fetchStub.resolves({ ok: false, status: 500 }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q1', log); expect(entity).to.be.null; expect(log.error).to.have.been.calledWithMatch('Wikidata entity fetch failed'); @@ -130,7 +503,7 @@ describe('services/wikipedia', () => { it('returns null on fetch error', async () => { fetchStub.rejects(new Error('boom')); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.getWikidataEntity('Q1', log); expect(entity).to.be.null; }); @@ -138,7 +511,7 @@ describe('services/wikipedia', () => { describe('validateEntityAgainstSite (P856-only)', () => { it('accepts a P856 host whose registrable domain matches the site (co.jp)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Dai Nippon Printing', officialWebsiteHosts: ['www.dnp.co.jp'] }, registrableDomain: 'dnp.co.jp', @@ -148,7 +521,7 @@ describe('services/wikipedia', () => { }); it('rejects a P856 host on a different registrable domain (dnb.de vs dnb.com)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'German National Library', officialWebsiteHosts: ['www.dnb.de'] }, registrableDomain: 'dnb.com', @@ -159,7 +532,7 @@ describe('services/wikipedia', () => { }); it('accepts a match found after a non-matching P856 host (multi-host loop)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'DHL', @@ -172,7 +545,7 @@ describe('services/wikipedia', () => { }); it('rejects an entity with no P856 host (no by-name / label fallback)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Dun & Bradstreet Inc', officialWebsiteHosts: [] }, registrableDomain: 'dnb.com', @@ -182,7 +555,7 @@ describe('services/wikipedia', () => { }); it('rejects a P856 match when the site registrable domain is a bare public suffix', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Some Foreign Org', officialWebsiteHosts: ['co.uk'] }, registrableDomain: 'co.uk', @@ -192,7 +565,7 @@ describe('services/wikipedia', () => { }); it('rejects a generalized . bare public suffix (com.my)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Some Foreign Org', officialWebsiteHosts: ['com.my'] }, registrableDomain: 'com.my', @@ -202,7 +575,7 @@ describe('services/wikipedia', () => { }); it('rejects a single-label / empty registrable domain (bare suffix guard)', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'X', officialWebsiteHosts: ['x.com'] }, registrableDomain: 'localhost', @@ -211,7 +584,7 @@ describe('services/wikipedia', () => { }); it('returns false for a null entity', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: null, registrableDomain: 'x.com', }); @@ -219,7 +592,7 @@ describe('services/wikipedia', () => { }); it('tolerates an entity with no officialWebsiteHosts key', async () => { - const mod = await importMod(); + const mod = await importWiki(); const result = mod.validateEntityAgainstSite({ entity: { label: 'Amrize' }, registrableDomain: 'amrize.com', @@ -257,7 +630,7 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -285,7 +658,7 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -314,7 +687,7 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'Dnp', registrableDomain: 'dnp.co.jp', }, log); @@ -340,7 +713,7 @@ describe('services/wikipedia', () => { }), }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'Amrize', registrableDomain: 'somethingelse.com', }, log); @@ -350,7 +723,7 @@ describe('services/wikipedia', () => { it('returns null when there are no candidates', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'Nope', registrableDomain: 'nope.com', }, log); @@ -364,7 +737,7 @@ describe('services/wikipedia', () => { }); fetchStub.onCall(1).resolves({ ok: false, status: 500 }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'X', registrableDomain: 'x.com', }, log); @@ -373,7 +746,7 @@ describe('services/wikipedia', () => { it('returns null when the candidate search request is not ok', async () => { fetchStub.resolves({ ok: false, status: 503 }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'X', registrableDomain: 'x.com', }, log); @@ -383,7 +756,7 @@ describe('services/wikipedia', () => { it('treats a search response without a search array as no candidates', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({}) }); - const mod = await importMod(); + const mod = await importWiki(); const entity = await mod.findValidatedWikidataEntity({ brandName: 'X', registrableDomain: 'x.com', }, log); @@ -398,7 +771,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ query: { pages: { 42: { extract: 'DHL is a logistics company.' } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('DHL', 12000, log); expect(text).to.equal('DHL is a logistics company.'); @@ -409,7 +782,7 @@ describe('services/wikipedia', () => { }); it('returns null for a missing title without issuing a request', async () => { - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle(null, 12000, log); expect(text).to.be.null; expect(fetchStub).to.not.have.been.called; @@ -420,7 +793,7 @@ describe('services/wikipedia', () => { ok: true, json: () => Promise.resolve({ query: { pages: { 42: { extract: 'A'.repeat(5000) } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', 100, log); expect(text.length).to.equal(100); }); @@ -430,7 +803,7 @@ describe('services/wikipedia', () => { ok: true, json: () => Promise.resolve({ query: { pages: { 42: { extract: 'short' } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', null, log); expect(text).to.equal('short'); }); @@ -440,7 +813,7 @@ describe('services/wikipedia', () => { ok: true, json: () => Promise.resolve({ query: { pages: { '-1': { missing: true } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); expect(text).to.be.null; }); @@ -450,14 +823,14 @@ describe('services/wikipedia', () => { ok: true, json: () => Promise.resolve({ query: {} }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); expect(text).to.be.null; }); it('returns null when response is not ok', async () => { fetchStub.resolves({ ok: false, status: 500 }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); expect(text).to.be.null; expect(log.error).to.have.been.calledWithMatch('Error fetching Wikipedia extract by title'); @@ -468,7 +841,7 @@ describe('services/wikipedia', () => { ok: true, json: () => Promise.resolve({ query: { pages: { 42: {} } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const text = await mod.fetchWikipediaExtractByTitle('X', 12000, log); expect(text).to.equal(''); }); @@ -500,7 +873,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ query: { pages: { 42: { extract: 'DHL intro.' } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -513,7 +886,7 @@ describe('services/wikipedia', () => { it('returns null when no validated entity', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'X', registrableDomain: 'x.com', }, log); @@ -536,7 +909,7 @@ describe('services/wikipedia', () => { }, }), }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'X', registrableDomain: 'x.com', }, log); @@ -559,7 +932,7 @@ describe('services/wikipedia', () => { }); fetchStub.onCall(2).resolves({ ok: false, status: 500 }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -586,7 +959,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ query: { pages: { '-1': {} } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -609,7 +982,7 @@ describe('services/wikipedia', () => { }); fetchStub.onCall(2).resolves({ ok: true, json: () => Promise.resolve({ query: {} }) }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -635,7 +1008,7 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ query: { pages: { 42: {} } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const result = await mod.fetchValidatedSummary({ brandName: 'DHL', registrableDomain: 'dhl.com', }, log); @@ -643,30 +1016,112 @@ describe('services/wikipedia', () => { }); }); + describe('splitHost', () => { + it('returns the registrable domain for a plain apex', async () => { + const mod = await importWiki(); + expect(mod.splitHost('dhl.com')).to.deep.equal({ + subdomainLabels: [], apexLabel: 'dhl', registrableDomain: 'dhl.com', + }); + }); + + it('strips a subdomain label', async () => { + const mod = await importWiki(); + const r = mod.splitHost('dev.amrize.com'); + expect(r.registrableDomain).to.equal('amrize.com'); + expect(r.subdomainLabels).to.deep.equal(['dev']); + }); + + it('honours a multi-part TLD (co.jp)', async () => { + const mod = await importWiki(); + expect(mod.splitHost('dnp.co.jp').registrableDomain).to.equal('dnp.co.jp'); + }); + + it('honours a generalized . suffix (com.my)', async () => { + const mod = await importWiki(); + expect(mod.splitHost('shop.company.com.my').registrableDomain).to.equal('company.com.my'); + }); + + it('does not treat a non-generic 2-char ccTLD second level as a suffix (sony.jp)', async () => { + const mod = await importWiki(); + expect(mod.splitHost('shop.sony.jp').registrableDomain).to.equal('sony.jp'); + }); + + it('returns a single label unchanged', async () => { + const mod = await importWiki(); + expect(mod.splitHost('localhost')).to.deep.equal({ + subdomainLabels: [], apexLabel: 'localhost', registrableDomain: 'localhost', + }); + }); + + it('handles an empty hostname', async () => { + const mod = await importWiki(); + expect(mod.splitHost('')).to.deep.equal({ + subdomainLabels: [], apexLabel: '', registrableDomain: '', + }); + }); + }); + describe('createWikipediaService', () => { - it('exposes only entity-bound methods (no by-name lookups)', async () => { - const mod = await importMod(); + it('creates service with bound methods', async () => { + const mod = await importWiki(); + + const service = mod.createWikipediaService(log); + + expect(service).to.have.property('fetchSummary'); + expect(service).to.have.property('fetchFullText'); + expect(service).to.have.property('findWikidataId'); + }); + + it('exposes the entity-binding methods as well', async () => { + const mod = await importWiki(); const service = mod.createWikipediaService(log); expect(service).to.have.property('getWikidataEntity'); expect(service).to.have.property('findValidatedWikidataEntity'); expect(service).to.have.property('fetchExtractByTitle'); expect(service).to.have.property('fetchValidatedSummary'); - // Deprecated by-name methods must not be exposed. - expect(service).to.not.have.property('fetchSummary'); - expect(service).to.not.have.property('fetchFullText'); - expect(service).to.not.have.property('findWikidataId'); }); - it('binds the logger to service methods', async () => { + it('service methods can be called', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve(['Test', [], [], []]), + }); + + const mod = await importWiki(); + + const service = mod.createWikipediaService(log); + expect(await service.fetchSummary('Test')).to.be.null; + expect(await service.fetchFullText('Test')).to.be.null; + }); + + it('findWikidataId service method forwards the brand name', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); + const mod = await importWiki(); + const service = mod.createWikipediaService(log); + expect(await service.findWikidataId('Test')).to.be.null; + }); + + it('getWikidataEntity service method forwards the id', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ entities: { Q7: { labels: { en: { value: 'Bound' } }, claims: {} } } }), + }); + + const mod = await importWiki(); + const service = mod.createWikipediaService(log); + const entity = await service.getWikidataEntity('Q7'); + expect(entity.id).to.equal('Q7'); + }); + + it('findValidatedWikidataEntity service method binds the logger', async () => { fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); - const mod = await importMod(); + const mod = await importWiki(); const service = mod.createWikipediaService(log); const result = await service.findValidatedWikidataEntity({ brandName: 'Test', registrableDomain: 'test.com', }); - expect(result).to.be.null; }); @@ -676,32 +1131,212 @@ describe('services/wikipedia', () => { json: () => Promise.resolve({ query: { pages: { 42: { extract: 'bound' } } } }), }); - const mod = await importMod(); + const mod = await importWiki(); const service = mod.createWikipediaService(log); const text = await service.fetchExtractByTitle('DHL', 100); expect(text).to.equal('bound'); }); - it('getWikidataEntity service method forwards the id', async () => { + it('fetchValidatedSummary service method forwards params', async () => { + fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); + + const mod = await importWiki(); + const service = mod.createWikipediaService(log); + const result = await service.fetchValidatedSummary({ + brandName: 'Test', registrableDomain: 'test.com', + }); + expect(result).to.be.null; + }); + }); + + describe('edge cases', () => { + it('fetchWikipediaSummary handles page without wikibase_item', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + title: 'Test Title', + extract: 'Summary text', + pageprops: {}, + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result.title).to.equal('Test Title'); + expect(result.wikidataId).to.be.null; + }); + + it('fetchWikipediaFullText handles page with empty extract', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { extract: '' }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.equal(''); + }); + + it('fetchWikipediaSummary handles page without extract', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + pages: { + 12345: { + title: 'Test Title', + // No extract field at all + pageprops: { wikibase_item: 'Q12345' }, + }, + }, + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result.title).to.equal('Test Title'); + expect(result.summary).to.equal(''); + }); + + it('fetchWikipediaFullText handles missing searchData[1] (titles)', async () => { fetchStub.resolves({ ok: true, - json: () => Promise.resolve({ entities: { Q7: { labels: { en: { value: 'Bound' } }, claims: {} } } }), + json: () => Promise.resolve(['Test']), // Missing titles array at index 1 }); - const mod = await importMod(); - const service = mod.createWikipediaService(log); - const entity = await service.getWikidataEntity('Q7'); - expect(entity.id).to.equal('Q7'); + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + expect(result).to.be.null; }); - it('fetchValidatedSummary service method forwards params', async () => { - fetchStub.resolves({ ok: true, json: () => Promise.resolve({ search: [] }) }); + it('fetchWikipediaFullText handles missing query.pages', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); - const mod = await importMod(); - const service = mod.createWikipediaService(log); - const result = await service.fetchValidatedSummary({ - brandName: 'Test', registrableDomain: 'test.com', + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + // No pages field + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaFullText('Test', 12000, log); + + // Should return null because pageId would be undefined + expect(result).to.be.null; + }); + + it('findWikidataId handles missing search array in response', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve({ + // No search field + }), + }); + + const mod = await importWiki(); + + const result = await mod.findWikidataId('Test', log); + + expect(result).to.be.null; + }); + + it('fetchWikipediaSummary handles missing searchData[1] (titles)', async () => { + fetchStub.resolves({ + ok: true, + json: () => Promise.resolve(['Search']), // Missing titles array at index 1 + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + expect(result).to.be.null; + }); + + it('fetchWikipediaSummary handles missing query.pages in summary response', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + query: { + // No pages field - should use fallback {} + }, + }), + }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + // Should return null because pageId would be undefined + expect(result).to.be.null; + }); + + it('fetchWikipediaSummary handles missing query entirely in response', async () => { + fetchStub.onFirstCall().resolves({ + ok: true, + json: () => Promise.resolve(['Test', ['Test Title'], [], []]), + }); + + fetchStub.onSecondCall().resolves({ + ok: true, + json: () => Promise.resolve({ + // No query field at all + }), }); + + const mod = await importWiki(); + + const result = await mod.fetchWikipediaSummary('Test', log); + + // Should return null because pages would be {} expect(result).to.be.null; }); });