Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name> 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
Expand Down
85 changes: 42 additions & 43 deletions src/agents/brand-profile/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
207 changes: 207 additions & 0 deletions src/agents/brand-profile/services/brand-resolver.js
Original file line number Diff line number Diff line change
@@ -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 <title>/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');
}
Loading
Loading