P PoriFyx - Create AI-powered chat docs

Run your workspace

Lead scoring & trajectory

Every conversation gets a lead score on a 0โ€“100 scale derived from observable signals: which pages the visitor browsed, how deeply they engaged in chat, and whether they shared contact info. The score appears as a coloured pill on the conversations list, the leads list, the inbox, and inside each conversation's right rail โ€” so sales can prioritise the hot prospects without re-reading every transcript. Webhook consumers also get the score in the lead.captured payload, so a CRM can route hot leads to a different queue automatically.

How the score is computed

The LeadScoringEngine sums four capped weights, clamps the total to [0, 100], then maps the result onto three buckets. Every number below is a default: each workspace can change them in Settings โ€บ Leads (see Change the points and thresholds).

SignalDefault weightDefault cap
Unique pages visited +5 per distinct URL 25
Intent pages (URL contains pricing, price, demo, contact, buy, signup, sign-up, checkout, cart, purchase, order) +15 per matching page view 30
Chat engagement (messages exchanged) +2 per message 30
Lead captured (email present) +15 flat 15

Default bucket thresholds: Hot โ‰ฅ 70 (green), Warm 40โ€“69 (amber), Cold 0โ€“39 (grey). A visitor who lands once, sends two messages, and disappears scores 9 (Cold). A visitor who views four pages including /pricing (20 + 15), exchanges 10 messages (20) and submits their email (15) scores 70 (Hot).

Change the points and thresholds

Owners and Admins open Settings โ€บ Leads (/settings/leads). The Lead scoring panel lists the four signals with a short explanation each, and for each one the points per page, visit or message and the maximum it can add. It also holds the intent keywords (comma separated, matched against the page address, upper and lower case alike) and where Hot and Warm start โ€” anything below Warm is Cold.

  • Points and maximums are whole numbers from 0 to 100. A signal worth 0 points is switched off and no longer appears in the "why this score" list.
  • Warm starts at 1 or more, and Hot must start above Warm.
  • An empty keyword list switches intent pages off.
  • The panel shows the highest score your points allow, and warns when it is below the Hot threshold โ€” then nobody can become Hot.
  • Restore defaults refills the form with the values above; nothing changes until you save.

Settings are stored per workspace in workspaces.settings.lead_scoring and cached; saving replaces the cached copy, so the next page view, message or lead capture is scored with the new values. A workspace that never saves keeps scoring exactly as described above.

Recalculate scores

Saved changes apply to new activity. To re-score existing conversations, press Recalculate scores under the form. It queues a recompute for the workspace's conversations that started in the last 90 days (playground sessions excluded, newest first, at most 20,000), one RecomputeLeadScoreJob per conversation on the analytics queue โ€” the visitor hot path is never involved. The button waits two minutes between runs. Older conversations keep their score until their next page view, message or lead capture, or until you run php artisan porifyx-chat:recompute-lead-scores (which uses each workspace's own settings too).

How it stays fresh

Recompute is queued โ€” never on the SSE hot path. The RecomputeLeadScoreJob fires from three places:

  • New page view โ€” /api/v1/widget/init writes a visitor_page_views row each time the widget boots on a fresh page (same-page reloads within 2 minutes are deduped), then enqueues a recompute.
  • New turn persisted โ€” PersistTurnJob appends every assistant + user turn to the messages table and enqueues a recompute on its way out.
  • Lead captured โ€” when the visitor submits the lead form the controller runs the recompute synchronously before dispatching RouteLeadJob, so the webhook payload sees the up-to-date score.

Visitor trajectory

Behind the score sits a per-visitor browsing trail. Every page the widget mounts on records a row in visitor_page_views:

id           bigserial
workspace_id uuid       -- tenancy scope
agent_id     uuid
visitor_id   uuid
conversation_id uuid    -- nullable (set when the page view happens
                        --           inside an active conversation)
url          varchar(500)
title        varchar(200) nullable
referrer     varchar(500) nullable
viewed_at    timestamptz
created_at   timestamptz

The conversation detail page (/app/conversations/{id}) renders the last 50 page views in chronological order in the right rail โ€” pages visited during the conversation get a green border so the operator can tell mid-chat browsing from earlier visits.

Webhook payload

The lead.captured event now ships with three extra fields:

{
  "event": "lead.captured",
  "lead": { "id": "01hโ€ฆ", "email": "[email protected]", "status": "new", "status_label": "New", โ€ฆ },
  "agent_id": "01hโ€ฆ",
  "score": 75,
  "score_bucket": "high",
  "trajectory": [
    {
      "url": "https://example.com/pricing",
      "title": "Pricing โ€” Plans",
      "referrer": "https://google.com",
      "viewed_at": "2026-05-18T10:14:02+00:00"
    },
    โ€ฆ
  ]
}

The trajectory is capped at the 10 most recent views to keep payloads small. score_bucket is one of low, medium, high.

Why this score? (reasons array)

Every recompute also persists the line-by-line breakdown into conversations.lead_score_reasons (JSON). Each row is a human-readable string the engine emitted on the way to the total โ€” e.g. "Visited 3 unique pages (+15)", "Visited 2 intent pages (pricing, demo) (+30)", "Sent / received 5 messages (+10)", "Contact info captured (+15)". The conversation detail page renders the list under the Lead score section so the operator knows exactly which signals fired; the badge itself shows the same lines in its tooltip on hover. When a signal contributes 0 it is omitted โ€” a cold lead with no intent matches simply lists fewer items.

Backfill existing conversations

The score column defaults to 0 for every conversation that existed before this feature shipped. To bulk-recompute against history, run:

php artisan porifyx-chat:recompute-lead-scores

Useful flags:

  • --workspace={uuid} โ€” restrict to one workspace
  • --agent={uuid} โ€” restrict to one agent (overrides --workspace)
  • --queue โ€” dispatch one RecomputeLeadScoreJob per conversation instead of running inline. Use this for very large workspaces (millions of conversations) where the analytics worker should soak up the work in the background.
  • --chunk=500 โ€” row batch size for the chunkById iterator (default 500). Drop to 100 on memory-constrained hosts.

The command is idempotent: re-running it just overwrites the score + reasons with the freshest values. Playground conversations are always skipped.

Zero-score conversations

A brand-new conversation with no page view, no messages, and no captured contact info scores 0 and falls into the Cold bucket. The UI hides the badge entirely when the score is 0 โ€” avoids a row of grey "Cold ยท 0" pills on every drive-by widget load that never triggered a turn. Conversations only start showing a badge after the first scoring signal fires (usually a page view via /init on a non-blank page URL).

Filtering the inbox / leads list

Both /app/inbox and each agent's /app/agents/{id}/leads page expose a Hot only toggle in the toolbar โ€” when on, the list is constrained to leads whose conversation is in the Hot bucket (scored at or above the workspace's Hot threshold, 70 by default), so the list always agrees with the badges. After changing the thresholds, use Recalculate scores to move existing conversations into their new buckets. The toggle is bookmarkable: the URL gets ?hot=1 and survives refresh.

Tuning

Weights, caps, intent keywords and thresholds are per-workspace settings โ€” see Change the points and thresholds; no code change is needed. Their defaults live in App\Services\Scoring\LeadScoringSettings::defaults() (the default keyword list is LeadScoringEngine::INTENT_KEYWORDS).

To add a new signal, give LeadScoringSettings a points (and cap) field whose default keeps today's behaviour, add it to the Settings โ€บ Leads form, then write a private scoreFoo() method in app/Services/Scoring/LeadScoringEngine.php that returns ['score' => int, 'reasons' => string[]] and add it to the score() parts array. Keep the sum of caps reasonable โ€” total clamps to 100 either way, but over-weighting a single signal makes the bucket boundaries meaningless.

The engine caps page-view history at the most recent 200 rows per visitor (LeadScoringEngine::MAX_PAGE_VIEWS). Beyond that the weights saturate anyway, and the cap keeps the query cheap for long-lived returning visitors.