Skip to content

Qualification Flow & CTA Blocking

Overview

The qualification flow gates CTA proposals (demo offers) behind profiling questions. When a site configures profiling fields with blocks_cta: true, the system first tries to collect those fields before proposing a CTA, regardless of interest score.

This gate is not absolute anymore: if the context-gathering question window is saturated, the system releases the CTA gate and lets demo_offer through to avoid a dead end where the visitor gets neither more questions nor a CTA.

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{Question window has room?} G -->|Yes| H["Replace demo_offer → context_gathering\nBot asks qualification question with 👉"] G -->|No: window saturated| I["Release CTA gate\nBot proposes CTA with 👇"] F -->|Yes: cta_blocked=False| I["demo_offer triggers\nBot proposes CTA with 👇"] H --> 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": {
    "field_definitions": {
      "company_stage": {
        "question_template": "What stage is your company at?",
        "type": "predefined",
        "options": [
          {"label": "Pre-idea / exploring"},
          {"label": "Have a team or an idea"},
          {"label": "Have revenue / traction"}
        ]
      },
      "business_or_technical_profile": {
        "question_template": "Are you a business or technical profile?",
        "type": "predefined",
        "options": [
          {"label": "Business"},
          {"label": "Technical"}
        ]
      }
    },
    "forms": {
      "default": {
        "type": "pre_conversion",
        "cta_id": "apply-to-start",
        "field_groups": [
          {
            "label": "Qualification",
            "fields": ["company_stage", "business_or_technical_profile"]
          }
        ],
        "cta_blocking_fields": ["company_stage", "business_or_technical_profile"]
      }
    }
  }
}

Key config pieces:

Property Type Description
field_definitions.<field_id>.question_template str The question text shown to the LLM
field_definitions.<field_id>.options list[{label, value?}] Single-choice options shown inline with the question
forms.<form_id>.field_groups[*].fields list[str] Ordered field IDs to ask
forms.<form_id>.cta_blocking_fields list[str] Field IDs the system tries to collect before the CTA is shown; if the question window is saturated, the CTA gate is released

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 company_stage
  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 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."

Saturated Window Fallback

If demo_offer is selected while cta_blocked=True, but context_gathering is already forbidden because the recent-turn window is full, the system does not keep deferring forever.

Instead, skill_applier releases the CTA gate for that turn and allows demo_offer through:

  1. interest_signals_detectorshould_propose_demo=True
  2. skill_applier — Sees unresolved blocks_cta fields, but also sees context_gathering_availability == "forbidden"
  3. CTA gate releasedemo_offer is kept instead of being replaced with context_gathering
  4. Answer writer — Proposes the CTA with 👇 even though some blocks_cta fields are still missing

CTA Blocking Logic

Config Helpers (agent_config.py)

def has_cta_blocking_fields(config: ConfigResolver | None) -> bool:
    """True if any form configures cta_blocking_fields."""

def has_unresolved_cta_blocking_fields(
    config: ConfigResolver | None,
    profiling_state: ProfilingState | None,
) -> bool:
    """Check if any cta_blocking_fields entry is missing from 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:
        # If the question window is saturated, release the CTA gate
        # Otherwise replace demo_offer with context_gathering

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

Unified profiling configs normally use explicit field IDs like company_stage and business_or_technical_profile. If a standalone ProfilingField is created without an ID, ProfilingField.auto_generate_id() still applies the same slugification:

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

The slugification logic lives in ProfilingField.auto_generate_id(): lowercase, replace non-alphanumeric with _, strip leading/trailing _, truncate to 50 chars.

Key Files

File Purpose
ixchat/utils/agent_config.py ConfigResolver helper functions — CTA blocking checks, qualification rules
ixchat/nodes/skill_applier_rules.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