Skip to content
Merged
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
5 changes: 5 additions & 0 deletions backend/app/core/config/visibility_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ def onboarding_portfolio_system_prompt() -> str:
"Return roughly two to ten meaningful buyer-need topics when supported, "
"without padding, fixed prompt counts, stage quotas, or generic "
"navigation labels. "
"Cover each materially distinct offering or buyer need supported by the "
"evidence. Give each topic at least one unbranded core question, and add "
"different decision questions where the evidence supports them. Do not "
"collapse several offerings into one broad topic or stop after one "
"question when the evidence supports more. "
"Core prompts are unbranded buyer queries. Diagnostic prompts name the brand; "
"comparison prompts name the brand and a supplied competitor, and link "
"to an intent whose decision_intent is compare. "
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,6 @@ def _admit(
brand_name: str,
brand_terms: list[str],
competitor_terms: list[str],
category_terms: list[str],
known_refs: set[str],
) -> PortfolioResult:
intents = _validate_links(envelope, known_refs)
Expand All @@ -255,7 +254,6 @@ def _admit(
],
known_refs=known_refs,
forbidden_terms=[brand_name, *competitor_terms],
business_terms=category_terms,
)
admission = _Admission(
intents=intents,
Expand Down Expand Up @@ -313,10 +311,6 @@ def validate(envelope: PortfolioEnvelope) -> None:
brand_name=brand_name,
brand_terms=brand_terms,
competitor_terms=competitor_terms,
category_terms=[
profile.get("category") or "",
*(profile.get("category_aliases") or []),
],
known_refs=known_refs,
)

Expand Down
26 changes: 4 additions & 22 deletions backend/app/domain/projects/onboarding/topic_admission.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,6 @@ def _is_unsplit_bundle(name: str) -> bool:
return any(f" {connector} " in normalized for connector in TOPIC_BUNDLE_CONNECTORS)


def _restates_business(name: str, *, business_terms: list[str]) -> bool:
key = _key(name)
return bool(key) and any(key == _key(term) for term in business_terms if term)


def _structural_failure(
*,
name: str,
Expand Down Expand Up @@ -152,18 +147,12 @@ def admit_topics(
*,
known_refs: set[str],
forbidden_terms: list[str],
business_terms: list[str],
) -> list[DiscoveryTopic]:
"""Admit distinct, evidence-backed topics that name what customers want.

``forbidden_terms`` are the brand, its aliases and confirmed competitors;
``business_terms`` are the resolved category, its aliases and the sector.

The business-restatement rule is deliberately SOFT -- it is skipped when
applying it would leave no topics. A business that genuinely sells one
thing, a mattress brand whose category is "mattresses", must be allowed to
keep it. The provider-phrase rule is unconditional: nobody shops for those
under any circumstances.
``forbidden_terms`` are the brand, its aliases and confirmed competitors.
An exact category match can still be a distinct buyer need: a mattress
business selling pillows should retain Mattresses as well as Pillows.
"""
structural = [
row
Expand All @@ -178,14 +167,7 @@ def admit_topics(
is not None
]

strict = [
row
for row in structural
if not _restates_business(row[0], business_terms=business_terms)
]
retained = strict or structural

return _distinct_topics(retained)
return _distinct_topics(structural)


def confirmed_offering_topics(offerings: list[str]) -> list[DiscoveryTopic]:
Expand Down
8 changes: 5 additions & 3 deletions backend/tests/component/test_prompt_generation_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,17 +1007,18 @@ async def complete_structured_json(
fallback_discriminator=self._discriminator,
).complete_json(system=system, user=user)

async def _run(topic: str, n: int) -> None:
async def _run(topic: str, n: int) -> int:
async with session_factory() as session:
await generate_prompts(
inserted, _, _ = await generate_prompts(
session,
workspace_id=workspace_id,
prompt_set_id=uuid.UUID(prompt_set_id),
payload=PromptGenerateRequest(count=n, confirm_send_evidence=True),
agent=cast(DefaultAgentClient, _CountingAgent(topic, n)),
)
return len(inserted)

await asyncio.gather(_run("Alpha", 15), _run("Beta", 15))
alpha_count, beta_count = await asyncio.gather(_run("Alpha", 15), _run("Beta", 15))

async with session_factory() as session:
active = (
Expand All @@ -1034,6 +1035,7 @@ async def _run(topic: str, n: int) -> None:
)
texts = [prompt.text for prompt in active]
# Both portfolios survive, and neither run's rows collide with the other's.
assert len(active) == alpha_count + beta_count
assert len(texts) == len(set(texts))
assert any("Alpha" in text for text in texts)
assert any("Beta" in text for text in texts)
Expand Down
18 changes: 6 additions & 12 deletions backend/tests/unit/test_brand_discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,6 @@ def _admit(names: list[str], **kwargs) -> list[str]:
[_candidate(name) for name in names],
known_refs=kwargs.get("known_refs", {"nav-1"}),
forbidden_terms=kwargs.get("forbidden_terms", ["Acme"]),
business_terms=kwargs.get("business_terms", []),
)
return [topic.name for topic in topics]

Expand Down Expand Up @@ -235,7 +234,6 @@ def test_topics_require_supplied_evidence_references() -> None:
],
known_refs=set(),
forbidden_terms=[],
business_terms=[],
)
== []
)
Expand All @@ -257,20 +255,16 @@ def test_admission_drops_brand_and_unbound_evidence_without_padding() -> None:
],
known_refs={"nav-1"},
forbidden_terms=[],
business_terms=[],
)
assert [topic.name for topic in topics] == ["Bags", "Hats"]


def test_category_restatement_rule_yields_to_a_single_offering_business() -> None:
"""A mattress brand whose category IS mattresses must keep the topic."""
assert _admit(["Mattresses"], business_terms=["mattresses"]) == ["Mattresses"]
# When a specific topic survives, the provider-category restatement drops.
names = _admit(
["Mattresses", "Pillows", "Bed Frames"],
business_terms=["mattresses"],
)
assert "Mattresses" not in names
def test_category_offering_remains_when_other_offerings_exist() -> None:
assert _admit(["Mattresses", "Pillows", "Bed Frames"]) == [
"Mattresses",
"Pillows",
"Bed Frames",
]


def test_confirmed_offerings_are_a_simple_provenanced_recovery_path() -> None:
Expand Down
12 changes: 11 additions & 1 deletion backend/tests/unit/test_onboarding_portfolio_topics.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,6 @@ def _admit(response: dict) -> pg.PortfolioResult:
brand_name="Acme",
brand_terms=["Acme"],
competitor_terms=["Beta"],
category_terms=["analytics software"],
known_refs={pg.CONFIRMED_CONTEXT_REF, "research-1"},
)

Expand All @@ -99,6 +98,17 @@ def test_intent_topic_prompt_links_and_evidence_are_admitted() -> None:
assert all(item["topic_id"] for item in result.prompts if item["cohort"] == "core")


def test_category_named_offer_keeps_its_core_prompt_beside_other_offers() -> None:
response = _response()
response["topics"][0]["name"] = "Analytics Software"
result = _admit(response)
assert {topic.name for topic in result.topics} == {
"Analytics Software",
"Process Mining",
}
assert len([prompt for prompt in result.prompts if prompt["cohort"] == "core"]) == 2


@pytest.mark.parametrize(
"change",
[
Expand Down
4 changes: 2 additions & 2 deletions docs/visibility-prompt.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ Topic distinctness uses singular-normalized token identity, not character
similarity: men's and women's departments must not merge because their spellings
are close. The provider-restatement rule rejects a name only when every token
is provider vocabulary; containment would incorrectly reject School Uniforms.
Category-restatement filtering is soft when it would remove all supported
topics. Configuration and [onboarding topic admission](../backend/app/domain/projects/onboarding/topic_admission.py)
An offer matching the business category stays eligible alongside other offers;
provider-only labels are rejected. Configuration and [onboarding topic admission](../backend/app/domain/projects/onboarding/topic_admission.py)
own these decisions, not a copied vocabulary list in documentation.

Generation assigns canonical topics and short slot IDs, requested count and
Expand Down
22 changes: 22 additions & 0 deletions frontend/components/analytics/google-analytics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,28 @@ describe('GoogleAnalytics', () => {
]);
});

it('preserves other consent defaults when revoking a pending tag', async () => {
writeConsent('accepted');
render(<GoogleAnalytics measurementId="G-TEST" />);
const dataLayer = (window as Window & { dataLayer?: unknown[] }).dataLayer ?? [];
const ownDefault = dataLayer[0] as unknown[];
ownDefault[2] = { analytics_storage: 'granted', ad_storage: 'denied' };
const otherDefault = [
'consent',
'default',
{ analytics_storage: 'granted', ad_storage: 'granted' },
];
const adOnlyDefault = ['consent', 'default', { ad_storage: 'granted' }];
const pendingEvent = ['event', 'page_view'];
dataLayer.push(otherDefault, adOnlyDefault, pendingEvent);

await act(async () => writeConsent('rejected'));

expect(ownDefault[2]).toEqual({ analytics_storage: 'denied', ad_storage: 'denied' });
expect(otherDefault[2]).toEqual({ analytics_storage: 'denied', ad_storage: 'granted' });
expect(adOnlyDefault[2]).toEqual({ ad_storage: 'granted' });
});

it('reuses the document tag after remount without configuring twice', () => {
writeConsent('accepted');
const first = render(<GoogleAnalytics measurementId="G-TEST" />);
Expand Down
21 changes: 16 additions & 5 deletions frontend/components/analytics/google-analytics.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ type GtagWindow = Window & {

const SCRIPT_ID = 'citeladder-google-analytics';

function denyPendingAnalyticsDefaults(dataLayer: unknown[] | undefined) {
for (const entry of dataLayer ?? []) {
if (!Array.isArray(entry) || entry[0] !== 'consent' || entry[1] !== 'default') continue;
const fields = entry[2];
if (
!fields ||
typeof fields !== 'object' ||
Array.isArray(fields) ||
!('analytics_storage' in fields)
)
continue;
entry[2] = { ...fields, analytics_storage: 'denied' };
}
}

function configureLoadedTag(script: HTMLScriptElement, measurementId: string) {
if (script.dataset.loaded !== 'true' || script.dataset.configured || !hasAnalyticsConsent())
return;
Expand All @@ -35,11 +50,7 @@ export function GoogleAnalytics({ measurementId }: Readonly<{ measurementId: str
if (!existing) return;
// A pending tag must never replay an old granted default on load.
if (existing.dataset.loaded !== 'true') {
for (const entry of withGtag.dataLayer ?? []) {
if (Array.isArray(entry) && entry[0] === 'consent' && entry[1] === 'default') {
entry[2] = { analytics_storage: 'denied' };
}
}
denyPendingAnalyticsDefaults(withGtag.dataLayer);
}
withGtag.gtag?.('consent', 'update', { analytics_storage: 'denied' });
return;
Expand Down
76 changes: 74 additions & 2 deletions frontend/components/marketing/chrome/cookie-banner.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,44 @@
import { beforeEach, describe, expect, it } from 'vite-plus/test';
import { beforeEach, describe, expect, it, vi } from 'vite-plus/test';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import { COOKIE_CONSENT_STORAGE_KEY, hasAnalyticsConsent } from '@/lib/consent/cookie-consent';
import {
COOKIE_CONSENT_STORAGE_KEY,
hasAnalyticsConsent,
readConsent,
writeConsent,
} from '@/lib/consent/cookie-consent';

import { CookieBanner } from './cookie-banner';

function failStorageWrites() {
const descriptor = Object.getOwnPropertyDescriptor(window, 'localStorage');
const storage = window.localStorage;
const setItem = vi.fn(() => {
throw new DOMException('Storage unavailable');
});
Object.defineProperty(window, 'localStorage', {
configurable: true,
value: {
getItem: storage.getItem.bind(storage),
setItem,
removeItem: storage.removeItem.bind(storage),
clear: storage.clear.bind(storage),
key: storage.key.bind(storage),
get length() {
return storage.length;
},
} satisfies Storage,
});
return {
setItem,
restore: () => {
if (descriptor) Object.defineProperty(window, 'localStorage', descriptor);
else Reflect.deleteProperty(window, 'localStorage');
},
};
}

/**
* What is worth pinning is the consent contract, not the layout: the banner
* appears only while undecided, both answers are one click, and — the part a
Expand Down Expand Up @@ -54,6 +87,45 @@ describe('CookieBanner', () => {
expect(hasAnalyticsConsent()).toBe(false);
});

it.each(['Accept', 'Reject'] as const)(
'keeps the %s decision for this page when storage writes fail',
async (choice) => {
const user = userEvent.setup();
const failingStorage = failStorageWrites();
try {
render(<CookieBanner />);
await user.click(await screen.findByRole('button', { name: choice }));
expect(failingStorage.setItem).toHaveBeenCalledWith(
COOKIE_CONSENT_STORAGE_KEY,
choice === 'Accept' ? 'accepted' : 'rejected',
);
expect(screen.queryByRole('region', { name: 'Cookie consent' })).not.toBeInTheDocument();
expect(readConsent()).toBe(choice === 'Accept' ? 'accepted' : 'rejected');
expect(hasAnalyticsConsent()).toBe(choice === 'Accept');
} finally {
failingStorage.restore();
writeConsent('rejected');
}
},
);

it('clears prior acceptance when persisting rejection fails', async () => {
window.localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, 'accepted');
const failingStorage = failStorageWrites();
try {
writeConsent('rejected');
expect(failingStorage.setItem).toHaveBeenCalledWith(COOKIE_CONSENT_STORAGE_KEY, 'rejected');
expect(readConsent()).toBe('rejected');
expect(window.localStorage.getItem(COOKIE_CONSENT_STORAGE_KEY)).toBeNull();
vi.resetModules();
const reloaded = await import('@/lib/consent/cookie-consent');
expect(reloaded.hasAnalyticsConsent()).toBe(false);
} finally {
failingStorage.restore();
writeConsent('rejected');
}
});

it('stays hidden once the visitor has already answered', () => {
window.localStorage.setItem(COOKIE_CONSENT_STORAGE_KEY, 'accepted');

Expand Down
11 changes: 11 additions & 0 deletions frontend/components/ui/command-palette.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,20 @@ describe('CommandPalette', () => {
renderPalette(<Palette />);
const trigger = screen.getByRole('button', { name: /search or jump to/i });
expect(trigger).toHaveAttribute('aria-keyshortcuts', 'Meta+K Control+K');
expect(screen.getByText('Ctrl K')).toBeInTheDocument();
expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});

it('shows the Mac shortcut on macOS', () => {
const platform = vi.spyOn(window.navigator, 'platform', 'get').mockReturnValue('MacIntel');
try {
renderPalette(<CommandPaletteTrigger />);
expect(screen.getByText('⌘K')).toBeInTheDocument();
} finally {
platform.mockRestore();
}
});

it('opens on Ctrl+K and closes on a second press', async () => {
const user = userEvent.setup();
renderPalette(<Palette />);
Expand Down
Loading
Loading