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¶
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"
interest_signals_detector— Detects explicit booking signals,cumulative_scoreincreases (may cross threshold)skill_selector(LLM) — Suggestsdemo_offerbecauseshould_propose_demo=Trueskill_applier— Rule_rule_defer_demo_for_cta_blocking()fires:- Checks
has_unresolved_cta_blocking_fields(profiling_state)→True(no fields collected yet) - Replaces
demo_offerwithcontext_gathering - Injects
qualification_gathering_rulesinto prompt
- Checks
- 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)"
profile_extractor— No answer to extract yet
Turn 2: Visitor answers Q1¶
"I'm exploring an idea"
profile_extractor— Extracts answer, maps to field IDwhat_stage_is_your_company_atskill_applier— Still blocked (secondblocks_ctafield uncollected)- Answer writer — Asks second qualification question: > "👉 Are you a business or technical profile?"
Turn 3: Visitor answers Q2¶
"Technical"
profile_extractor— Extracts answer, maps to field IDare_you_a_business_or_technical_profileskill_applier— Allblocks_ctafields now collected →cta_blocked=Falsedemo_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:
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 |
Related¶
- Interest Signals — How cumulative scoring triggers demo proposals
- Content Gating — Email collection flow using 💌 marker
- E2E Evaluation: History Reconstruction — How multi-turn qualification flows are tested