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({