Skip to content

Qualification Flow & CTA Blocking

Overview

The qualification flow gates CTA proposals (demo offers) behind mandatory profiling questions. When a site configures profiling fields with blocks_cta: true, the system forces the visitor through a qualification sequence before proposing any CTA — regardless of interest score.

This mechanism interacts with several graph nodes: skill_selector, skill_applier, profile_extractor, interest_signals_detector, and dialog_state_extractor.

How It Works

flowchart TD A[Visitor shows interest / says 'I want to apply'] --> B[interest_signals_detector] B --> C{cumulative_score >= threshold?} C -->|Yes| D[should_propose_demo = True] C -->|No| H[No CTA this turn] D --> E[skill_applier checks CTA blocking] E --> F{All blocks_cta fields collected?} F -->|No: cta_blocked=True| G["Replace demo_offer → context_gathering\nBot asks qualification question with 👉"] F -->|Yes: cta_blocked=False| I["demo_offer triggers\nBot proposes CTA with 👇"] G --> J[profile_extractor extracts answer] J --> K[profiling_state.collected_values updated] K --> E

Configuration

Qualification fields are configured per site in the qualification config slug:

{
  "profiling": {
    "forms": {
      "default": {
        "fields": [
          {
            "question_template": "What stage is your company at?",
            "options": [
              {"label": "Pre-idea / exploring"},
              {"label": "Have a team or an idea"},
              {"label": "Have revenue / traction"}
            ],
            "blocks_cta": true
          },
          {
            "question_template": "Are you a business or technical profile?",
            "options": [
              {"label": "Business"},
              {"label": "Technical"}
            ],
            "blocks_cta": true
          }
        ]
      }
    }
  }
}

Key fields on each profiling field:

Property Type Description
question_template str The question text (also used to auto-generate id if not set)
options list[{label, value?}] Single-choice options shown inline with the question
blocks_cta bool When true, CTA is blocked until this field is collected
id str? Explicit field ID. Auto-generated from question_template via slugification if omitted

Emoji Markers

The system uses Unicode emoji as machine-readable markers in LLM output. These are invisible to visitors (stripped by the frontend) but detected by dialog_state_extractor:

Emoji Unicode Meaning Detected by
👉 U+1F449 Qualification question asked profile_extractor, E2E evaluator
👇 U+1F447 CTA / demo proposed dialog_state_extractor
💌 U+1F48C Email asked (content gating) dialog_state_extractor

Step-by-Step Execution

Turn 1: Visitor expresses intent

"I want to apply"

  1. interest_signals_detector — Detects explicit booking signals, cumulative_score increases (may cross threshold)
  2. skill_selector (LLM) — Suggests demo_offer because should_propose_demo=True
  3. skill_applier — Rule _rule_defer_demo_for_cta_blocking() fires:
    • Checks has_unresolved_cta_blocking_fields(profiling_state)True (no fields collected yet)
    • Replaces demo_offer with context_gathering
    • Injects qualification_gathering_rules into prompt
  4. Answer writer — Asks first qualification question: > "👉 What stage is your company at? (Options: Pre-idea / exploring, Have a team or an idea, Have revenue / traction)"
  5. profile_extractor — No answer to extract yet

Turn 2: Visitor answers Q1

"I'm exploring an idea"

  1. profile_extractor — Extracts answer, maps to field ID what_stage_is_your_company_at
  2. skill_applier — Still blocked (second blocks_cta field uncollected)
  3. Answer writer — Asks second qualification question: > "👉 Are you a business or technical profile?"

Turn 3: Visitor answers Q2

"Technical"

  1. profile_extractor — Extracts answer, maps to field ID are_you_a_business_or_technical_profile
  2. skill_applier — All blocks_cta fields now collected → cta_blocked=False
  3. demo_offer — Now allowed. Bot proposes CTA: > "👇 You can apply using the button below."

CTA Blocking Logic

AgentConfigResolver (agent_config.py)

@property
def has_cta_blocking_fields(self) -> bool:
    """True if any profiling field has blocks_cta=True."""

def has_unresolved_cta_blocking_fields(self, profiling_state: ProfilingState | None) -> bool:
    """Check if any blocks_cta field is not yet in profiling_state.collected_values."""

Skill Applier Rule (Priority 2)

def _rule_defer_demo_for_cta_blocking(self):
    if "demo_offer" in skills and context.cta_blocked:
        # Replace demo_offer with context_gathering
        # Inject qualification_gathering_rules into prompt

Score Reset Exception

When a demo is proposed (👇 detected), the system normally resets cumulative_score to 0. But for sites with CTA-blocking fields, the reset is skipped — this ensures the demo can be re-proposed on the next turn after qualification completes, without needing new interest signals.

# dialog_state_extractor.py
if demo_was_proposed and has_cta_blocking_fields:
    # Skip score reset — keep score to re-propose demo

Field ID Generation

Field IDs are auto-generated from question_template when not explicitly set:

"What stage is your company at?" → "what_stage_is_your_company_at"

The logic lives in ProfilingField.auto_generate_id() and is called via AgentConfigResolver._effective_field_id(). Both paths use the same slugification: lowercase, replace non-alphanumeric with _, strip leading/trailing _, truncate to 50 chars.

Key Files

File Purpose
ixchat/utils/agent_config.py AgentConfigResolver — CTA blocking checks, qualification rules
ixchat/nodes/skill_applier.py _rule_defer_demo_for_cta_blocking() — post-processing rule
ixchat/nodes/skill_selector.py Passes cta_blocked flag to LLM context
ixchat/nodes/dialog_state_extractor.py Emoji detection, score reset logic
ixchat/pydantic_models/profiling.py ProfilingState, ProfilingField models