ui-copilot / backend /rules /conversion_rules.py
ksri77's picture
chore: deploy from CI
8c816e3 verified
Raw
History Blame Contribute Delete
12.6 kB
"""
Conversion Optimization rules β€” detect patterns that hurt business outcomes
regardless of visual polish: weak CTAs, excessive form friction, missing trust
signals, unclear hero messaging, and absent pricing clarity.
"""
from __future__ import annotations
from backend.models.issue import Category, Issue, Severity
_CAT = Category.CONVERSION_OPTIMIZATION
_WEAK_CTA_PHRASES = frozenset({
"click here", "click", "here", "submit", "learn more", "read more",
"more", "go", "ok", "button", "see more", "view more", "find out",
"find out more", "enter",
})
_ACTION_VERBS = frozenset({
"start", "get", "try", "sign", "join", "create", "build", "launch",
"download", "buy", "order", "book", "schedule", "request", "apply",
"explore", "discover", "watch", "play", "upgrade", "activate",
})
def analyze(parsed_page: dict, thresholds: dict) -> list[Issue]:
t = thresholds.get("conversion", {})
issues: list[Issue] = []
_check_weak_cta(parsed_page, t, issues)
_check_form_friction(parsed_page, t, issues)
_check_trust_signals(parsed_page, t, issues)
_check_hero_clarity(parsed_page, t, issues)
_check_pricing_cta(parsed_page, issues)
return issues
# ── CV1 β€” weak CTA text ───────────────────────────────────────────────────────
def _check_weak_cta(parsed_page: dict, t: dict, issues: list[Issue]) -> None:
cta_texts = parsed_page.get("cta_texts", [])
if not cta_texts:
return
weak = [
txt for txt in cta_texts
if txt.lower().strip() in _WEAK_CTA_PHRASES
]
# Check if ANY CTA uses a strong action verb
has_strong = any(
any(verb in txt.lower() for verb in _ACTION_VERBS)
for txt in cta_texts
)
if weak and not has_strong:
issues.append(Issue(
rule_id="CV1_weak_cta_text",
category=_CAT,
severity=Severity.HIGH,
confidence=0.80,
message=(
f"{len(weak)} CTA(s) use generic text "
f"({', '.join(repr(w) for w in weak[:3])}) with no strong action verb found."
),
recommendation=(
"Replace passive CTA text with outcome-oriented action verbs: "
"'Start Free Trial', 'Get My Report', 'Join 10,000 Users'. "
"Specific CTAs outperform generic ones by 90% on average."
),
evidence=f"weak_ctas={weak[:5]}",
estimated_time="15 minutes",
why=(
"CTA copy is the last thing a user reads before deciding to convert. "
"Generic phrases like 'Submit' or 'Learn More' create uncertainty β€” "
"the user doesn't know what happens next. Specific action verbs set clear "
"expectations and increase click-through rates by 20–90% in A/B tests."
),
references=["Copyhackers", "HubSpot CTA Research", "Nielsen Norman Group"],
))
elif weak:
issues.append(Issue(
rule_id="CV1_weak_cta_text",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.75,
message=f"{len(weak)} CTA(s) use generic text: {', '.join(repr(w) for w in weak[:3])}.",
recommendation=(
"Audit each generic CTA and rewrite with the user's desired outcome: "
"'Get Access', 'Download the Guide', 'See Pricing'."
),
evidence=f"weak_ctas={weak[:5]}",
estimated_time="20 minutes",
why=(
"Even when strong CTAs exist on the page, weak CTAs in secondary positions "
"reduce overall conversion confidence. Every button should feel intentional."
),
references=["Copyhackers", "ConversionXL"],
))
# ── CV2 β€” form friction ───────────────────────────────────────────────────────
def _check_form_friction(parsed_page: dict, t: dict, issues: list[Issue]) -> None:
max_fields = t.get("max_form_fields", 6)
counts = parsed_page.get("form_field_counts", [])
overloaded = [c for c in counts if c > max_fields]
if overloaded:
worst = max(overloaded)
issues.append(Issue(
rule_id="CV2_form_friction",
category=_CAT,
severity=Severity.HIGH,
confidence=0.85,
message=(
f"{len(overloaded)} form(s) have more than {max_fields} fields "
f"(worst: {worst} fields) β€” high friction reduces sign-ups by 50%+."
),
recommendation=(
f"Reduce to {max_fields} or fewer fields for initial sign-up/checkout. "
"Defer optional fields (phone, company size) to onboarding or profile setup. "
"Use progressive disclosure for multi-step flows."
),
evidence=f"form_field_counts={counts}",
estimated_time="2 hours",
why=(
"Every additional field in a sign-up form reduces conversion rate by ~4–10%. "
"Experian found that reducing from 11 to 4 fields increased conversions 120%. "
"Collect only what's needed to get the user started β€” gather the rest later."
),
references=["Experian Form Study", "Baymard Institute", "HubSpot Blog"],
))
# ── CV3 β€” missing trust signals ───────────────────────────────────────────────
def _check_trust_signals(parsed_page: dict, t: dict, issues: list[Issue]) -> None:
min_signals = t.get("min_trust_signals", 1)
count = parsed_page.get("trust_signal_count", 0)
has_testimonials = parsed_page.get("has_testimonials", False)
if count < min_signals and not has_testimonials:
issues.append(Issue(
rule_id="CV3_missing_trust_signals",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.70,
message=(
"No trust signals detected β€” no testimonials, security badges, "
"review counts, or social proof elements found."
),
recommendation=(
"Add near every primary CTA: customer count ('10,000+ users'), "
"a testimonial quote with attribution, star ratings, "
"security badges (SSL, SOC2), or recognisable logos of customers/partners."
),
evidence=f"trust_signal_count={count}, has_testimonials={has_testimonials}",
estimated_time="3 hours",
why=(
"Trust signals reduce purchase anxiety. 88% of consumers trust online "
"reviews as much as personal recommendations (BrightLocal). "
"A single testimonial near a CTA can increase conversion by 34% "
"(Trustpilot case studies). First-time visitors need social proof "
"before committing β€” without it, they leave to search for reviews elsewhere."
),
references=["BrightLocal Consumer Review Survey", "Trustpilot", "Nielsen Trust Study"],
))
# ── CV4 β€” hero clarity ────────────────────────────────────────────────────────
def _check_hero_clarity(parsed_page: dict, t: dict, issues: list[Issue]) -> None:
max_words = t.get("hero_max_words", 150)
has_hero = parsed_page.get("has_hero", False)
word_count = parsed_page.get("hero_word_count", 0)
headings = parsed_page.get("headings", [])
has_h1 = any(h["level"] == 1 for h in headings)
if not has_h1:
issues.append(Issue(
rule_id="CV4_missing_value_proposition",
category=_CAT,
severity=Severity.HIGH,
confidence=0.90,
message="No <h1> found β€” the page has no primary value proposition headline.",
recommendation=(
"Add a clear <h1> that answers 'What does this do and why should I care?' "
"in one sentence. E.g. 'The fastest way to turn UI audits into shipped fixes.'"
),
evidence="headings_h1_count=0",
estimated_time="30 minutes",
why=(
"Users scan pages in 3–5 seconds. If there's no clear headline explaining "
"the value, 70% will bounce before reading a single paragraph. "
"The H1 is the first thing screen readers and search engines encounter β€” "
"it's the most high-leverage copy on the page."
),
references=["Nielsen Norman Group", "Google Search Central", "Copyhackers"],
))
elif has_hero and word_count > max_words:
issues.append(Issue(
rule_id="CV4_hero_word_overload",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.75,
message=(
f"Hero section contains {word_count} words β€” "
f"above the {max_words}-word threshold for scannable above-the-fold content."
),
recommendation=(
"Cut hero copy to a headline (8–12 words) + subheadline (1–2 sentences). "
"Move supporting detail below the fold or into a 'learn more' section. "
"Every word above the fold costs attention."
),
evidence=f"hero_word_count={word_count}",
estimated_time="1 hour",
why=(
"F-pattern eye-tracking studies show users read the hero heading and "
"the first few words of the subheadline before deciding whether to scroll. "
"Dense hero copy signals 'this will take effort to understand' and drives bounce."
),
references=["Nielsen NNG Eye-Tracking", "Copyhackers", "ConversionXL"],
))
elif not has_hero and word_count == 0:
issues.append(Issue(
rule_id="CV4_no_hero_section",
category=_CAT,
severity=Severity.LOW,
confidence=0.60,
message="No hero or introductory section detected β€” consider adding an above-the-fold value statement.",
recommendation=(
"Add a <section class='hero'> with a headline, subheadline, and primary CTA "
"in the first viewport. Users should immediately understand what this page offers."
),
evidence="has_hero=False, hero_word_count=0",
estimated_time="2 hours",
why=(
"Without a clear introductory section, new visitors must hunt for context. "
"Structured hero sections improve time-on-page and reduce bounce for cold traffic."
),
references=["Nielsen NNG", "Smashing Magazine"],
))
# ── CV5 β€” pricing CTA alignment ───────────────────────────────────────────────
def _check_pricing_cta(parsed_page: dict, issues: list[Issue]) -> None:
has_pricing = parsed_page.get("has_pricing_section", False)
buttons = parsed_page.get("buttons", [])
if has_pricing and not buttons:
issues.append(Issue(
rule_id="CV5_pricing_no_cta",
category=_CAT,
severity=Severity.HIGH,
confidence=0.80,
message="Pricing section detected but no CTA buttons found on the page.",
recommendation=(
"Add a clear CTA button inside or directly below the pricing section: "
"'Get Started', 'Choose Plan', or 'Start Free Trial'. "
"Pricing without a CTA is a dead end."
),
evidence="has_pricing_section=True, buttons=[]",
estimated_time="30 minutes",
why=(
"A pricing page without an adjacent CTA forces the user to scroll back "
"to find where to sign up β€” every extra scroll is a drop-off risk. "
"Baymard Institute found 69% of checkout abandonments happen because "
"the path to purchase was unclear."
),
references=["Baymard Institute Checkout Research", "ConversionXL"],
))