Skip to content

fix(brand-profile): bind Wikipedia/Wikidata lookups to a site-validated entity (LLMO-6580) - #339

Open
cwjwisse wants to merge 5 commits into
mainfrom
fix/llmo-6580-brand-profile-entity-binding
Open

fix(brand-profile): bind Wikipedia/Wikidata lookups to a site-validated entity (LLMO-6580)#339
cwjwisse wants to merge 5 commits into
mainfrom
fix/llmo-6580-brand-profile-entity-binding

Conversation

@cwjwisse

@cwjwisse cwjwisse commented Jul 28, 2026

Copy link
Copy Markdown

1. Abstract

Binds the brand-profile agent's Wikipedia/Wikidata product lookups to a site-validated entity, replacing the decoupled by-name search that allowed one organisation's article to be extracted as another organisation's product catalogue.

2. Reasoning

LLMO-6580: the brand-profile agent fabricated defamatory product catalogues for real customers and government agencies — e.g. DHL, Dai Nippon Printing, and Dun & Bradstreet tagged as the "D-Company" (Dawood Ibrahim) organised-crime syndicate, and the Singapore Economic Development Board and Export Development Canada tagged with military/weapons content. Root cause: when a site had no brand name, the agent derived one from the domain and collapsed it to a 2–3 letter acronym; both the Wikidata entity search and a separate Wikipedia opensearch then fuzzy-matched a famous same-initials article, whose contents the LLM extracted as the customer's products. The failure was reproduced deterministically against the public APIs.

3. High-level overview of the changes

  • Brand-name resolution never uses a bare 2–3 letter acronym, or a dev/www/store/support subdomain label, as a high-confidence brand. The resolver prefers the base profile's brand name, the site's own page title, and the registrable apex domain, and returns the site's registrable domain, which is the signal the entity validation compares against.
  • Every Wikipedia/Wikidata fetch is bound to an entity validated against the site by a strong P856 (official-website host) match: the entity's P856 host must share the site's registrable domain. This is the sole accepted signal — there is no by-name / label / alias fallback, and a bare public-suffix registrable domain never matches. If no candidate validates, the agent produces no Wikipedia/Wikidata products rather than guessing. The decoupled opensearch "<name> company" calls are removed from both the product-extraction and competitor-inference paths; article text is fetched via the validated entity's exact Wikipedia sitelink title.
  • The earlier content-safety backstop (a harm denylist plus sensitive_category / safety_filtered metadata) is intentionally removed as a product decision. With P856-only binding, extracted products come from the customer's own validated entity, so this filtering is no longer the control that keeps a foreign catalogue off a customer. The accepted trade-off: sensitive products that legitimately belong to the customer's own validated entity may now flow through without sensitive_category / safety_filtered metadata.
  • Persist now refuses to overwrite a brand profile whose products_metadata.source is manual-curated, protecting the hand-verified catalogues already written to production under this ticket.
  • The Wikipedia-products fallback is gated behind a kill-switch environment flag (BRAND_PROFILE_ENABLE_WIKI_PRODUCTS) that defaults OFF, so deploying this change is behaviourally inert until the flag is deliberately enabled.

4. Required information

6. Additional information outside the code

  • The root cause was reproduced directly against the public Wikipedia/Wikidata APIs this session: an opensearch for a d*-acronym brand ("Dnp/Dhl/Dnb company") returns the "D-Company" (Dawood Ibrahim) article, and for an e*-acronym ("Edb/Edc/Eon company") returns "E Company, 506th Infantry Regiment" — the two fabrication clusters observed in production.
  • The immediate data remediation for this ticket (ask 1) has already been applied to production and independently verified. It wrote 20 hand-curated product catalogues tagged products_metadata.source = manual-curated; the persist guard added in this PR exists to keep the pipeline from overwriting those rows.
  • Known follow-ups (tracked separately, to be addressed before the kill-switch is enabled): the registrable-domain / bare-public-suffix check uses a hand-maintained public-suffix table and can fail open on unlisted suffixes (e.g. github.io, me.uk, non-generic ccTLD second levels) — adopt a maintained public-suffix list to close this. Additionally the validated entity is resolved twice (competitor-summary and product paths) when the flag is on; resolve once and thread it through.

7. Test plan

  • Local: no end-to-end pipeline run was performed this session — the change is gated OFF by default and is behaviourally inert until enabled (see section 8). The root-cause reproduction is described in section 6. Unit tests cover P856 accept/reject, bare-public-suffix rejection, candidate scan-and-early-exit, no-match → none_no_validated_entity, exact-title extraction (no opensearch), the kill-switch, the manual-curated persist guard, and the malformed-Wikidata-ID SPARQL guard.
  • dev / stage: with the flag off (default), confirm the agent performs no by-name Wikipedia fallback and that an unvalidated entity yields no products. Then set BRAND_PROFILE_ENABLE_WIKI_PRODUCTS=true in Vault (dx_mysticat/{env}/task-processor) and run the agent against a short-acronym site (e.g. a d* domain): confirm it no longer fabricates an unrelated organisation's catalogue, and that a correctly-resolved brand yields its real products.
  • prod: keep the flag OFF until the ask-1 scrub and the P2 backfill are complete; confirm the persist guard leaves manual-curated rows untouched.

8. Deployment & merge order

  • Related: LLMO-6580 ask-1 production data remediation (already applied this session; not a PR).
  • Related: P2 backfill regeneration of the ~976 wikipedia_llm/hybrid sites (separate follow-up) — must run only after this ships and the flag is enabled.
  • Sequence: merge & deploy this PR (flag OFF, inert) → validate on dev/stage → enable BRAND_PROFILE_ENABLE_WIKI_PRODUCTS in Vault → run the P2 backfill.

🤖 Generated with Claude Code

…ed entity (LLMO-6580)

The brand-profile products extractor could attach a foreign (often harmful)
entity's product catalogue to a real customer. Root cause: when Wikidata SPARQL
returned fewer than the threshold, the code ran a decoupled opensearch for
`${brandName} company` and blindly took titles[0] with no check that the article
belonged to the resolved entity. A bare 2-3 letter acronym (or a dev/www
subdomain label) produced by the old domain-based brand-name fallback then
fuzzy-matched famous same-initials articles (d*->"D-Company", e*->"E Company").

Fix:
- New services/brand-resolver.js resolves a brand name with a confidence signal
  and the site's registrable domain (hand-rolled multi-part-TLD table, no new
  runtime dependency). It never emits a bare acronym or a stop-label subdomain as
  a high-confidence name, and keeps a best-effort, short-timeout homepage title
  fetch that fails safe to null.
- wikipedia.js gains getWikidataEntity, validateEntityAgainstSite,
  findValidatedWikidataEntity, fetchWikipediaExtractByTitle and
  fetchValidatedSummary. Every fetch is bound to an entity validated against the
  site (strong P856 official-website host match, or a weak label match only for
  non-low-confidence names). Low-confidence acronyms require P856. The decoupled
  by-name opensearch is gone from the product and competitor paths; the old
  findWikidataId/fetchWikipediaFullText are kept but marked @deprecated.
- product-extractor.js extractProducts takes an options object bound to the site
  identity, produces no products when nothing validates, and applies a
  content-safety backstop: harmful categories are hard-dropped from unvalidated
  (label-only) sources but kept-and-flagged (sensitive_category) for
  P856-validated entities and the customer's own sitemap.
- index.js rewires the brand-name, competitor-summary and product call sites,
  adds the BRAND_PROFILE_ENABLE_WIKI_PRODUCTS kill-switch (default off), and adds
  a persist() guard that never overwrites a manual-curated product catalogue.

Adds unit tests (brand-resolver, entity validation, d*/e* regression fixtures
asserting no products and no by-name opensearch, manual-curated persist guard);
lint clean, full suite green, branch coverage 96.5% (>= 95% gate).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@cwjwisse
cwjwisse requested a review from MysticatBot July 28, 2026 20:48
@MysticatBot

Copy link
Copy Markdown

Mysticat review failed: Claude CLI crashed (exit 1): stderr= stdout={"type":"result","subtype":"error_max_budget_usd","duration_ms":5,"duration_api_ms":1456609,"is_error":true,"num_turns":1,"stop_reason":null,"session_id":"591d73c7-53df-4796-bf41-6e7cc38b91bf","total_cost_usd":11.888550450000004,"usage":{"input_tokens":0,"cache_creation_input_tokens":0,"cache_read_i

…580)

Applies local review-kit PR review fixes to the entity-binding change:

- brand-resolver: generalize splitHost to treat any <generic>.<2-char-ccTLD>
  (com.my, co.th, gov.in, or.kr, ...) as a two-label public suffix, so an
  unlisted ccTLD can no longer collapse the registrable domain to the bare
  suffix and produce a false-positive P856 "strong" match.
- wikipedia: replace the ratio>=0.5 weak label match with bidirectional token
  containment so two distinct names sharing one token (Swiss Life / Swiss Re)
  no longer validate; guard P856 against a bare-suffix site domain; add fetch
  timeouts (AbortController) to all Wikidata/Wikipedia calls; stop logging a
  valid second label candidate as "Rejected".
- product-extractor: add SPARQL/sitemap fetch timeouts; guard the SPARQL query
  against a malformed Wikidata id; catch the plural "escorts" in the harm
  denylist.
- README: correct the brand-name confidence and label-match wording.
- tests: cover generalized ccTLD splitHost, sibling-name and bare-suffix
  rejection, harm-denylist false-positive/plural handling, and the SPARQL id
  guard.
@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown

This PR will trigger a patch release when merged.

Make the brand-profile agent's Wikipedia/Wikidata grounding accept an entity
only when its P856 official-website registrable domain matches the customer's
registrable domain. Search still runs by resolved brand name, but a search
result is never trusted until P856 validates it; if nothing validates, the
pipeline produces no Wikipedia/Wikidata products.

Removed behavior:
- Weak label/alias matching, normalizeName, and corporate-suffix matching in
  wikipedia.js, plus the deprecated by-name functions (fetchWikipediaSummary,
  fetchWikipediaFullText, findWikidataId) and their service methods.
- The content-safety denylist and harmful-content filtering in
  product-extractor.js (HARM_PATTERNS, hitsHarm, itemHitsHarm,
  applyContentSafetyGate) and the sensitive_category / safety_filtered metadata.
  This is an intentional product decision: after P856-only binding, sensitive
  products belonging to the customer's own validated entity may flow through
  without sensitive_category / safety_filtered metadata.
- brandConfidence plumbing through extractProducts / fetchValidatedSummary and
  the skipped_low_confidence terminal source value; a no-match now always
  reports products_metadata.source = "none_no_validated_entity".

Retained safeguards:
- BRAND_PROFILE_ENABLE_WIKI_PRODUCTS kill switch (default off).
- products_metadata.source === "manual-curated" persistence guard.
- Fetch timeouts, malformed Wikidata-ID SPARQL guard, exact-enwiki-title
  extraction (no opensearch), and generalized ccTLD / bare-public-suffix
  rejection (isBareSuffix now mirrors splitHost's public-suffix logic).

Tests updated to the P856-only contract; README documents the P856-only
validation and drops label validation, sensitive_category, safety_filtered,
and skipped_low_confidence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address local review-kit findings that are in scope for the P856-only refactor:

- brand-resolver.js: correct the resolveBrandName docstring, which claimed the
  confidence signal is "retained for logging/observability" although nothing
  logs it; it is retained for callers/tests and is not consumed by the P856
  validation path.
- wikipedia.test.js: add coverage for two previously unexercised branches of the
  core entity guard - validateEntityAgainstSite accepting a match found after a
  non-matching P856 host (multi-host loop), and findValidatedWikidataEntity
  stopping the candidate scan once the first candidate validates (asserted via
  fetch call count).
- product-extractor.test.js: drop a manufactured duplicate sub_brand from the
  hybrid-merge fixture that the test never asserted on, so the test no longer
  implies coverage of within-source sub_brand deduplication that the code does
  not perform.

Deferred as out of scope for this refactor (tracked for follow-up before the
kill switch is enabled): adopting a maintained public-suffix list to close the
fail-open gap for unlisted suffixes, deduplicating the validated-entity
resolution across the competitor and product paths, and flipping the
extractProducts enableWikiProducts default to false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cwjwisse
cwjwisse deployed to dev-branches August 9, 2026 23:26 — with GitHub Actions Active
@cwjwisse
cwjwisse requested review from MysticatBot and removed request for MysticatBot August 9, 2026 23:52

@MysticatBot MysticatBot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @cwjwisse,

Verdict: Approve - well-designed fix for a serious data integrity issue; ships inert behind a kill-switch.
Complexity: HIGH - large diff; new service module + complete rewrite of the Wikipedia/Wikidata integration path.
Changes: Binds all Wikipedia/Wikidata product lookups to a P856 (official-website) validated entity, replacing the decoupled by-name search that allowed unrelated entities to be attached to customer sites (9 files).

Non-blocking (5): minor issues and suggestions
  • suggestion: resolve entity once at orchestrator level - both the competitor-summary path (fetchValidatedSummary) and the product-extraction path (extractProducts) call findValidatedWikidataEntity independently, doubling Wikidata round-trips when the flag is ON. Resolve once in index.js and pass the entity down. - src/agents/brand-profile/index.js:174
  • suggestion: batch entity fetch with wbgetentities&ids=Q1|Q2|... instead of sequential one-by-one fetches in findValidatedWikidataEntity - cuts worst-case wall time from 50s to ~10s for the P2 backfill. - src/agents/brand-profile/services/wikipedia.js:153
  • suggestion: add a response body size cap in fetchSiteName - resp.text() without a limit could buffer large homepages (SPAs inlining bundles); consider reading only the first ~64KB since you only need <head> content. - src/agents/brand-profile/services/brand-resolver.js:166
  • nit: timedFetch is duplicated identically in wikipedia.js and product-extractor.js (and the same pattern is inlined in brand-resolver.js) - extract to a shared utility before the pattern drifts. - src/agents/brand-profile/services/wikipedia.js:35
  • nit: PR body (Section 3) mentions a "content-safety backstop" harm denylist that is not present in this diff - clarify whether it ships in a follow-up PR or was accidentally omitted.

Skill: pr-review | Model: us.anthropic.claude-opus-4-6-v1[1m] | Duration: 1m 14s | Cost: $8.23 | Commit: a705acc4e4f13a8a31433a9e50642b9ad7891acd
If this code review was useful, please react with 👍. Otherwise, react with 👎.

@MysticatBot MysticatBot added ai-reviewed Reviewed by AI complexity:high AI-assessed PR complexity: HIGH labels Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-reviewed Reviewed by AI complexity:high AI-assessed PR complexity: HIGH

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants