diff --git a/examples/README.md b/examples/README.md index 567b849e..4fc8f567 100644 --- a/examples/README.md +++ b/examples/README.md @@ -19,6 +19,7 @@ service keys. | [Build a multi-modal product classifier with embeddings](./taxonomy-classification) | Evaluating text, image, NLI, and reranking approaches for hierarchical product taxonomy classification | `extract`, `encode`, `score` | SIE endpoint, Shopify dataset prep via `uv run` scripts, standalone `uv` project | Runnable evaluation example | | [Swap an OCR model with one identifier change](./document-ocr) | Driving recognition (VLM-OCR), structured extraction (Donut), and zero-shot NER (GLiNER) through the same `extract` call by swapping the model ID | `extract` | Docker Compose plus Node UI, no API key required, hosted version on [Hugging Face Spaces](https://huggingface.co/spaces/superlinked/document-ocr) | Runnable demo | | [A Stripe Link checkout with an SIE fraud-risk gate](./stripe-link-fraud) | Wiring all three SIE primitives into a pre-authorization fraud-risk gate that runs in the same round-trip as the Stripe PaymentIntent | `extract`, `encode`, `score` | Docker Compose plus Node UI; Stripe test-mode keys optional (runs in mock mode without them) | Runnable demo | +| [Turn account signals into one score with SIE ranking](./account-signal-scoring) | Rolling a pile of CRM signals into one churn/expansion score, then ranking each account's story against a corpus of past-outcome playbooks to pick the recommended play | `extract`, `encode`, `score`, `chat/completions` | Docker Compose plus Node UI, CPU-only by default; `SIE_CHAT_MODEL` optional for the LLM brief | Runnable demo | | [Vision-first document RAG](./vision-doc-rag) | Retrieving and answering questions over a multi-tenant page corpus by looking at page images — including scanned drawings — with OCR kept out of the score path | `encode`, `chat/completions`, `score` (optional) | GPU SIE deployment required: ColQwen2.5 retriever + Qwen3.5-4B answer model (runs on the generation bundle) | Runnable demo | | [Multi-model contract review with the OpenAI Agents SDK](./contract-review-agent) | Running an OpenAI Agents SDK agent whose every model call — triage, orchestration, vision, OCR, embeddings, rerank, entity extraction, text-to-SQL, reasoning, and a safety guardrail — is served by one SIE cluster, each step on the right catalog model, with per-model observability | `chat/completions`, `encode`, `score`, `extract` | GPU SIE deployment required; standalone `uv` project; real contracts fetched from CUAD (CC BY 4.0) | Runnable demo | diff --git a/examples/account-signal-scoring/.env.example b/examples/account-signal-scoring/.env.example new file mode 100644 index 00000000..5ca5b09a --- /dev/null +++ b/examples/account-signal-scoring/.env.example @@ -0,0 +1,14 @@ +# SIE +SIE_URL=http://localhost:8080 +SIE_API_KEY= + +# Optional: LLM account brief. +# Leave blank and the demo writes a deterministic brief from the account's +# signals (CPU-only, no generation model needed). Set SIE_CHAT_MODEL to a +# generation model your SIE cluster serves (e.g. Qwen/Qwen3-4B-Instruct-2507, +# which needs the generation bundle / a GPU) to have SIE draft the brief via +# the OpenAI-compatible /v1/chat/completions endpoint instead. +SIE_CHAT_MODEL= + +# UI server +PORT=3044 diff --git a/examples/account-signal-scoring/.gitignore b/examples/account-signal-scoring/.gitignore new file mode 100644 index 00000000..003e63ec --- /dev/null +++ b/examples/account-signal-scoring/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +.env +*.log +data/playbook_index.json diff --git a/examples/account-signal-scoring/README.md b/examples/account-signal-scoring/README.md new file mode 100644 index 00000000..17b0d679 --- /dev/null +++ b/examples/account-signal-scoring/README.md @@ -0,0 +1,177 @@ +# account-signal-scoring + +Turn a pile of account signals into **one score a team can act on** — with SIE +doing the ranking. + +A CSM starts the day staring at a mess of signals: a Stripe downgrade here, an +API-usage cliff there, a seat cap about to hit somewhere else. This demo +collapses that into a single triage board. Each account's signals roll up into +one 0-100 score, and SIE ranks the account's *story* against a corpus of +past-outcome **playbooks** to pick the recommended play — then optionally drafts +the account brief with an LLM. + +It uses three SIE primitives — `extract`, `encode`, and `score` — in one +round-trip, plus an optional `chat/completions` call for the brief. Everything +runs locally on CPU with `docker compose`; the LLM brief is opt-in. + +> Contributed from a `{Tech: Europe}` London AI Hackathon project (codename +> "Rick") by the Attio-integration team. A cleaned-up, self-contained slice of a +> larger CRM churn-rescue + expansion product. + +## What this demo actually shows + +1. **Signals → one score (deterministic).** Each signal has a direction (risk + vs. opportunity) and a severity weight. They roll up into a single score and + a red/amber/green band. This part is plain, auditable arithmetic — you always + know *why* an account is where it is. +2. **The account's story → the right play (SIE).** The score alone doesn't tell + you what to *do*. So the account context is written as prose, `encode`d, and + ranked (cosine → cross-encoder `score`) against a corpus of past outcomes + ("champion left → renewed with new champion", "seat cap → expansion signed"). + The top-matched playbook drives the recommended play. +3. **A brief a human can send (optional LLM).** With `SIE_CHAT_MODEL` set, SIE + drafts a summary / drivers / recommended-play brief via the OpenAI-compatible + `/v1/chat/completions` endpoint, grounded in the matched playbook. Without it, + a deterministic brief is written from the account's own data — so the board is + always populated. + +The UI streams every stage over SSE so you can watch extract → encode → score → +brief happen live. + +## Run it locally + +Requires Docker and Node 22+. + +```bash +cd examples/account-signal-scoring +npm install +cp .env.example .env # optional; defaults work out of the box +npm start # docker compose up -d + the UI server +``` + +`npm start` boots a local SIE server (CPU image, ~440 MB of models) and opens +the UI on . The first request builds the playbook index +(`data/playbook_index.json`) by encoding the corpus once; subsequent runs reuse +it. + +Prefer the terminal? Rank every account into one board without the UI: + +```bash +npm run score # score all accounts, print the ranked boards +npm run score helix # score a single account, verbose +``` + +### Turning on the LLM brief + +The brief is deterministic by default so the demo stays CPU-only. To have SIE +draft it instead, point `SIE_CHAT_MODEL` at a generation model your cluster +serves (this needs the SIE generation bundle / a GPU — it is *not* in the +CPU compose file): + +```bash +# .env +SIE_CHAT_MODEL=Qwen/Qwen3-4B-Instruct-2507 +``` + +The account panel header shows which mode you're in (`brief: deterministic` vs. +`brief: LLM (...)`). + +## Specific things to try in the UI + +| Account | Signals | Expected | +|---|---|---| +| **Pemberton & Co** | Stripe downgrade staged + NPS drop | **red**, top of risk board (cancellation short-circuits to 100) | +| **CloudScale Systems** | API usage down 41% + stale escalations | **red**, matches `usage_cliff_core_feature` | +| **Helix Robotics** | champion left + seats down 33% | **red/amber**, matches `champion_departed` | +| **Global Peak Inc** | 94% seat capacity, hard cap in ~8 days | **red** on the *expansion* board, matches `seat_capacity_expansion` | +| **Kestrel Bank** | renewed early + CSAT 9.6 | **green**, matches `early_renewal_advocate` | + +Watch how two accounts with a similar *number* can get different plays: the +reranker matches each one to the playbook whose story fits. + +## What the numbers in the UI mean + +- **Signal score (0-100).** The deterministic roll-up. Risk signals add, + opportunity signals subtract, each weighted by severity; a `usage_drop` is + additionally scaled by its magnitude. An active `stripe_cancellation` + short-circuits to 100 (red). +- **Reranker score (per playbook).** The cross-encoder (`BGE-reranker-base`) + relevance of this account's summary to each shortlisted playbook. The top-3 + cosine candidates are reranked; the highest-scoring one (highlighted) drives + the play. +- **ARR at stake.** The account's ARR — what a save or an expansion is worth. + +## Model lineup + +| Stage | Model | Size | Role | +|---|---|---|---| +| Extract | `urchade/gliner_multi-v2.1` | 280 MB | zero-shot NER on the account summary (display) | +| Encode | `sentence-transformers/all-MiniLM-L6-v2` | 80 MB | 384-dim dense encoder for cosine retrieval | +| Score | `BAAI/bge-reranker-base` | 280 MB | cross-encoder reranker on the top-K playbooks | +| Chat (optional) | e.g. `Qwen/Qwen3-4B-Instruct-2507` | — | drafts the account brief via `/v1/chat/completions` | + +The first three live in the `default` CPU bundle and are preloaded by +`compose.yml`. The chat model is opt-in and needs the generation bundle / a GPU. + +## SIE features used + +- **`extract`** — GLiNER zero-shot NER surfaces typed entities from the account + context. +- **`encode`** — MiniLM turns the account story into a dense vector; the playbook + corpus is pre-encoded once (`npm run index`). +- **`score`** — the cross-encoder reranks the cosine shortlist so the *right* + playbook wins, not just the lexically closest one. +- **`chat/completions`** *(optional)* — OpenAI-compatible endpoint drafts the + brief, grounded in the matched playbook. + +One cluster, one round-trip, four model roles — no separate model server per +task. + +## What's in the box + +``` +account-signal-scoring/ +├── compose.yml # local SIE server (CPU, 3 models preloaded) +├── data/ +│ ├── accounts.json # 8 sample accounts with signals +│ └── playbooks.json # 8 past-outcome playbooks (the corpus) +├── src/ +│ ├── config.ts # models, thresholds, paths +│ ├── types.ts # Account, Signal, Playbook, AccountBrief +│ ├── signals.ts # signal catalog + deterministic score roll-up +│ ├── score.ts # extract → encode → cosine → rerank → brief +│ ├── brief.ts # LLM brief (chat/completions) + deterministic fallback +│ ├── events.ts # typed SSE events +│ ├── index-build.ts # one-time playbook-corpus encode +│ └── cli.ts # headless "rank the whole book" scorer +└── web/ + ├── server.ts # tiny HTTP + SSE server + └── public/ # vanilla-JS UI (no build step) +``` + +## Extend it + +- **Bring your own signals.** Edit `data/accounts.json`, or wire the loader to + your CRM (the original project pulled from Attio + Stripe). The signal catalog + and weights live in `src/signals.ts`. +- **Grow the corpus.** Add rows to `data/playbooks.json` and re-run + `npm run index`. More outcomes → sharper play matching. +- **Swap models.** Change `src/config.ts` — any encoder/reranker in the SIE + catalog works with the same code. + +## Honest scope and known limits + +- The sample data is synthetic and small; the reranker signal is meaningful but + the corpus is a demo corpus, not a trained ranker. +- The deterministic score is intentionally simple and tunable — it's a starting + point, not a churn model. The point of the demo is the *ranking + play* + matching that SIE adds on top. +- The LLM brief is best-effort: any error falls back to the deterministic writer + so the board never breaks. + +## Built with + +- Original hackathon project ("Rick") by the Attio-integration team from the + `{Tech: Europe}` London AI Hackathon. +- [SIE](https://github.com/superlinked/sie) — self-hosted inference for agents. +- Apache 2.0. diff --git a/examples/account-signal-scoring/compose.yml b/examples/account-signal-scoring/compose.yml new file mode 100644 index 00000000..87dc1e3b --- /dev/null +++ b/examples/account-signal-scoring/compose.yml @@ -0,0 +1,30 @@ +services: + sie: + image: ghcr.io/superlinked/sie-server:latest-cpu-default + platform: linux/amd64 + command: + - serve + - --port + - "8080" + # All three models live in the `default` SIE bundle. Combined ~440 MB. + # The optional LLM brief uses a generation model (SIE_CHAT_MODEL) that is + # NOT preloaded here — it needs the generation bundle / a GPU. Without it + # the demo falls back to a deterministic brief, so this stays CPU-only. + - --preload + - urchade/gliner_multi-v2.1,sentence-transformers/all-MiniLM-L6-v2,BAAI/bge-reranker-base + ports: + - "8080:8080" + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-fsS", "http://localhost:8080/healthz"] + interval: 5s + timeout: 3s + retries: 60 + start_period: 240s + volumes: + - sie-cache:/app/.cache/huggingface + +volumes: + sie-cache: + name: sie-cache + external: false diff --git a/examples/account-signal-scoring/data/accounts.json b/examples/account-signal-scoring/data/accounts.json new file mode 100644 index 00000000..f4d1ab70 --- /dev/null +++ b/examples/account-signal-scoring/data/accounts.json @@ -0,0 +1,120 @@ +[ + { + "id": "pemberton", + "name": "Pemberton & Co", + "domain": "pemberton.co", + "owner": "Rae Whitlock", + "arr": 142000, + "seats": 132, + "seatsUsed": 120, + "renewalDays": 9, + "contact": { "name": "James Pemberton", "title": "VP Operations" }, + "signals": [ + { "type": "stripe_cancellation", "note": "Plan downgrade staged in Stripe for next cycle", "detected": "2d ago" }, + { "type": "negative_support_ticket", "note": "Exec NPS dropped from 4 to 1 after a billing incident", "detected": "5d ago" } + ] + }, + { + "id": "cloudscale", + "name": "CloudScale Systems", + "domain": "cloudscale.io", + "owner": "Marcus Vela", + "arr": 180000, + "seats": 240, + "seatsUsed": 150, + "renewalDays": 14, + "contact": { "name": "Dana Reyes", "title": "Director of Engineering" }, + "signals": [ + { "type": "usage_drop", "note": "Core API calls down 41% over 21 days", "detected": "3d ago", "value": 41 }, + { "type": "negative_support_ticket", "note": "Two escalations unresolved past 72h", "detected": "6d ago" } + ] + }, + { + "id": "helix", + "name": "Helix Robotics", + "domain": "helixrobotics.com", + "owner": "Anika Roth", + "arr": 96000, + "seats": 80, + "seatsUsed": 52, + "renewalDays": 31, + "contact": { "name": "Priya Nair", "title": "Head of Platform" }, + "signals": [ + { "type": "usage_drop", "note": "Active seats down 33% since the champion left", "detected": "8d ago", "value": 33 }, + { "type": "negative_support_ticket", "note": "Renewal sponsor departed the company", "detected": "11d ago" } + ] + }, + { + "id": "atlaspay", + "name": "Atlas Pay", + "domain": "atlaspay.com", + "owner": "Priya Sundar", + "arr": 320000, + "seats": 500, + "seatsUsed": 470, + "renewalDays": 47, + "contact": { "name": "Sofia Marenco", "title": "Chief Operating Officer" }, + "signals": [ + { "type": "usage_drop", "note": "Daily actives flat for 14 days, no growth", "detected": "4d ago", "value": 12 } + ] + }, + { + "id": "kestrel", + "name": "Kestrel Bank", + "domain": "kestrelbank.com", + "owner": "Priya Sundar", + "arr": 525000, + "seats": 600, + "seatsUsed": 540, + "renewalDays": 340, + "contact": { "name": "Margaret Lowe", "title": "Head of Digital" }, + "signals": [ + { "type": "renewal_approaching", "note": "Renewed 11 months early", "detected": "20d ago" }, + { "type": "positive_support_ticket", "note": "CSAT 9.6 across the last 10 tickets", "detected": "9d ago" } + ] + }, + { + "id": "lumen", + "name": "Lumen Health", + "domain": "lumenhealth.io", + "owner": "Rae Whitlock", + "arr": 410000, + "seats": 450, + "seatsUsed": 423, + "renewalDays": 188, + "contact": { "name": "Aaron Vance", "title": "Chief Technology Officer" }, + "signals": [ + { "type": "usage_near_limit", "note": "94% seat capacity, up 22% month-over-month", "detected": "1d ago", "value": 94 }, + { "type": "positive_support_ticket", "note": "Champion referred two internal teams", "detected": "12d ago" } + ] + }, + { + "id": "globalpeak", + "name": "Global Peak Inc", + "domain": "globalpeak.com", + "owner": "Marcus Vela", + "arr": 290000, + "seats": 320, + "seatsUsed": 301, + "renewalDays": 210, + "contact": { "name": "Nina Kovac", "title": "VP Revenue" }, + "signals": [ + { "type": "usage_near_limit", "note": "94% seat capacity, hard cap in about 8 days", "detected": "2d ago", "value": 94 }, + { "type": "positive_support_ticket", "note": "NPS 72 this quarter", "detected": "15d ago" } + ] + }, + { + "id": "sandhill", + "name": "Sandhill Capital", + "domain": "sandhill.fund", + "owner": "Anika Roth", + "arr": 96000, + "seats": 110, + "seatsUsed": 104, + "renewalDays": 96, + "contact": { "name": "Grace Okonkwo", "title": "Partner" }, + "signals": [ + { "type": "usage_near_limit", "note": "95% seat capacity with steady growth", "detected": "3d ago", "value": 95 } + ] + } +] diff --git a/examples/account-signal-scoring/data/playbooks.json b/examples/account-signal-scoring/data/playbooks.json new file mode 100644 index 00000000..ef9135ed --- /dev/null +++ b/examples/account-signal-scoring/data/playbooks.json @@ -0,0 +1,66 @@ +[ + { + "id": "pb_billing_downgrade", + "direction": "risk", + "label": "billing_downgrade_before_renewal", + "summary": "A downgrade or cancellation is staged in Stripe ahead of an imminent renewal, usually after a billing dispute or a champion losing budget. Revenue walks unless an owner intervenes directly.", + "outcome": "saved_by_exec_call", + "play": "Direct call from the account owner before the cycle closes; lead with a concrete credit or make-good, then re-commit the roadmap." + }, + { + "id": "pb_usage_cliff", + "direction": "risk", + "label": "usage_cliff_core_feature", + "summary": "Core product usage (API calls, active seats, key workflows) drops sharply over two to three weeks. The account has effectively stopped getting value and is coasting to churn.", + "outcome": "recovered_with_health_check", + "play": "Book a technical health check with the power user or engineering sponsor this week to unblock adoption before renewal." + }, + { + "id": "pb_champion_left", + "direction": "risk", + "label": "champion_departed", + "summary": "The internal champion or renewal sponsor has left the company, active usage is sliding, and no replacement owner has been identified. Renewal risk is high even if the numbers looked fine last quarter.", + "outcome": "renewed_with_new_champion", + "play": "Map and multi-thread a new champion now; re-introduce the platform's value to the surviving team before the renewal conversation." + }, + { + "id": "pb_support_friction", + "direction": "risk", + "label": "support_escalation_backlog", + "summary": "Support tickets are piling up past SLA and executive sentiment (NPS/CSAT) is falling. Trust is eroding faster than the product problem itself.", + "outcome": "stabilized_after_resolution", + "play": "Resolve the open escalations, then follow up within 24 hours to rebuild trust and confirm the fix landed." + }, + { + "id": "pb_flat_no_owner", + "direction": "risk", + "label": "flat_usage_unclear_owner", + "summary": "A large account has flat usage and no clear executive owner for the upcoming renewal. Nothing is on fire, but nobody is driving the buying committee either.", + "outcome": "escalated_to_human_csm", + "play": "Escalate to a human CSM to map the buying committee and confirm the economic owner well ahead of the renewal date." + }, + { + "id": "pb_seat_cap", + "direction": "opportunity", + "label": "seat_capacity_expansion", + "summary": "Seat or usage capacity is above 90% and still climbing, so the account is about to hit a hard cap. This is a growth signal and a natural moment to expand the contract.", + "outcome": "seat_expansion_signed", + "play": "Offer a proactive same-rate seat expansion before the cap is hit so no user gets locked out; send the paperwork the same day." + }, + { + "id": "pb_champion_referral", + "direction": "opportunity", + "label": "champion_referral_multi_team", + "summary": "A happy champion is referring new internal teams and month-over-month usage is climbing ahead of an early renewal. High intent to expand.", + "outcome": "expansion_at_early_renewal", + "play": "Lock in an expanded, multi-team plan ahead of the renewal date while momentum and internal advocacy are high." + }, + { + "id": "pb_early_renewal_advocate", + "direction": "opportunity", + "label": "early_renewal_advocate", + "summary": "The account renewed early, CSAT is excellent, and the champion is publicly advocating. Low risk, high trust, and receptive to a bigger commitment.", + "outcome": "expansion_closed", + "play": "Thank the champion, then present a tier upgrade or additional module while goodwill and trust are at their peak." + } +] diff --git a/examples/account-signal-scoring/package-lock.json b/examples/account-signal-scoring/package-lock.json new file mode 100644 index 00000000..d16b8b11 --- /dev/null +++ b/examples/account-signal-scoring/package-lock.json @@ -0,0 +1,593 @@ +{ + "name": "account-signal-scoring", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "account-signal-scoring", + "version": "0.1.0", + "dependencies": { + "@superlinked/sie-sdk": "^0.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + }, + "engines": { + "node": ">=22" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.3.tgz", + "integrity": "sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@superlinked/sie-sdk": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@superlinked/sie-sdk/-/sie-sdk-0.3.4.tgz", + "integrity": "sha512-VA0OZ1IcsjVWNKsHAa/gate9dYXVM1nrAdhIb6guLmfEzzu2XeTZOntqnr9I1CYVJRNKNeOlV1UQsY+5e//CqQ==", + "license": "MIT", + "dependencies": { + "@msgpack/msgpack": "^3.0.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/tsx": { + "version": "4.23.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", + "integrity": "sha512-eUdUIaCr963q2h5u3+QwvYp0+eqPvn+egeqZUm0hwERCqqx1E3kK5ehbGCvqSE5MQAULr67ww0cA3jKc3YkM1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.28.0" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/examples/account-signal-scoring/package.json b/examples/account-signal-scoring/package.json new file mode 100644 index 00000000..05111115 --- /dev/null +++ b/examples/account-signal-scoring/package.json @@ -0,0 +1,24 @@ +{ + "name": "account-signal-scoring", + "version": "0.1.0", + "private": true, + "type": "module", + "engines": { + "node": ">=22" + }, + "scripts": { + "start": "docker compose up -d && tsx web/server.ts", + "ui": "tsx web/server.ts", + "index": "tsx src/index-build.ts", + "score": "tsx src/cli.ts", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "@superlinked/sie-sdk": "^0.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "tsx": "^4.21.0", + "typescript": "^5.7.2" + } +} diff --git a/examples/account-signal-scoring/src/brief.ts b/examples/account-signal-scoring/src/brief.ts new file mode 100644 index 00000000..4a193750 --- /dev/null +++ b/examples/account-signal-scoring/src/brief.ts @@ -0,0 +1,101 @@ +// Account brief generation. +// +// If SIE_CHAT_MODEL is set, SIE drafts the brief through the OpenAI-compatible +// /v1/chat/completions endpoint, grounded in the top-matched playbook. If it is +// not set (the CPU-only default) or the call fails, we fall back to a +// deterministic brief so the triage board is always populated. This mirrors the +// original Attio project, which raced the LLM against a deterministic writer. +// +// The published SIE SDK (0.3.x) exposes encode/score/extract; chat/completions +// is the OpenAI-compatible HTTP surface, so we call it directly with fetch — +// exactly as the original project did against the SIE gateway. + +import { config } from "./config.js"; +import { arrAtStake, scoreSignals } from "./signals.js"; +import type { AccountBrief, Account, Playbook } from "./types.js"; + +const money = (n: number | null) => (n == null ? "n/a" : `$${Math.round(n).toLocaleString()}`); + +/** Whether the LLM brief path is configured. */ +export function chatEnabled(): boolean { + return Boolean(config.chatModel); +} + +const SYSTEM_PROMPT = + "You are the Head of Customer Success. Given a customer account context and the " + + "closest-matching historical playbook, produce a concise account brief. Respond " + + 'ONLY with JSON: {"summary": string, "drivers": string, "recommendedPlay": string}.'; + +/** Deterministic brief assembled from the account's own data. */ +export function deterministicBrief(account: Account, topPlaybook?: Playbook): AccountBrief { + const s = scoreSignals(account.signals); + const lead = s.direction === "risk" ? "at risk" : "an expansion opportunity"; + return { + summary: + `${account.name} is ${lead} (${s.band.toUpperCase()}, signal score ${Math.round(s.score)}). ` + + `${s.reason}. ARR ${money(account.arr)}, renewal in ${account.renewalDays} days.`, + drivers: s.reason, + recommendedPlay: topPlaybook?.play ?? "Review the account and decide on the next play.", + arrAtStake: arrAtStake(account), + source: "deterministic", + }; +} + +interface ChatResponse { + choices?: { message?: { content?: string } }[]; +} + +/** + * Draft the brief via SIE chat/completions, grounded in the matched playbook. + * Falls back to the deterministic brief on any error. + */ +export async function generateBrief( + account: Account, + topPlaybook: Playbook | undefined, + summary: string, +): Promise { + if (!chatEnabled()) return deterministicBrief(account, topPlaybook); + + const userContent = [ + `Account context:\n${summary}`, + topPlaybook + ? `Closest historical playbook: ${topPlaybook.label} — ${topPlaybook.summary} Recommended play: ${topPlaybook.play}` + : "No close historical playbook matched.", + ].join("\n\n"); + + try { + const res = await fetch(`${config.sieUrl}/v1/chat/completions`, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(config.sieApiKey ? { Authorization: `Bearer ${config.sieApiKey}` } : {}), + }, + body: JSON.stringify({ + model: config.chatModel, + messages: [ + { role: "system", content: SYSTEM_PROMPT }, + { role: "user", content: userContent }, + ], + response_format: { type: "json_object" }, + max_tokens: 512, + }), + signal: AbortSignal.timeout(120_000), + }); + if (!res.ok) throw new Error(`chat/completions ${res.status}: ${await res.text()}`); + const json = (await res.json()) as ChatResponse; + const content = json.choices?.[0]?.message?.content; + if (typeof content !== "string" || content.length === 0) { + throw new Error("chat/completions returned no content"); + } + const parsed = JSON.parse(content) as Partial; + return { + summary: parsed.summary ?? "", + drivers: parsed.drivers ?? "", + recommendedPlay: parsed.recommendedPlay ?? topPlaybook?.play ?? "", + arrAtStake: arrAtStake(account), + source: "sie-chat", + }; + } catch { + return deterministicBrief(account, topPlaybook); + } +} diff --git a/examples/account-signal-scoring/src/cli.ts b/examples/account-signal-scoring/src/cli.ts new file mode 100644 index 00000000..5a5e7bd3 --- /dev/null +++ b/examples/account-signal-scoring/src/cli.ts @@ -0,0 +1,84 @@ +// Headless scorer: rank every sample account into one board a team can act on. +// +// npm run score # score all accounts, print the ranked board +// npm run score helix # score a single account by id +// +// Requires SIE running (see compose.yml) and the playbook index built +// (`npm run index`, or the web server builds it on first run). +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { SIEClient } from "@superlinked/sie-sdk"; +import { config } from "./config.js"; +import type { ScoreEvent } from "./events.js"; +import { loadAccounts, runScore } from "./score.js"; +import { scoreSignals } from "./signals.js"; +import type { AccountScore } from "./signals.js"; +import type { Account } from "./types.js"; + +interface ScoredRow extends AccountScore { + account: Account; + play: string; + briefLine: string; +} + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); + +function ensureIndex(): void { + if (fs.existsSync(path.join(ROOT, config.paths.index))) return; + console.log("building playbook index..."); + const r = spawnSync(process.execPath, [path.join(ROOT, "node_modules/.bin/tsx"), path.join(ROOT, "src/index-build.ts")], { + cwd: ROOT, + stdio: "inherit", + }); + if (r.status !== 0) throw new Error("index-build failed"); +} + +async function scoreOne(client: SIEClient, account: Account, verbose: boolean) { + let play = ""; + let briefLine = ""; + const emit = (e: ScoreEvent) => { + if (e.type === "scored") play = e.data.hits[0]?.play ?? ""; + if (e.type === "brief") briefLine = e.data.summary; + if (verbose && "data" in e) console.log(` ${e.type}`, JSON.stringify(e.data)); + }; + await runScore(account, { client, emit }); + const s = scoreSignals(account.signals); + return { account, ...s, play, briefLine }; +} + +async function main(): Promise { + const id = process.argv[2]; + const accounts = loadAccounts(); + const targets = id ? accounts.filter((a) => a.id === id) : accounts; + if (targets.length === 0) throw new Error(`unknown account id: ${id}`); + + ensureIndex(); + const client = new SIEClient(config.sieUrl, { + apiKey: config.sieApiKey, + timeout: 600_000, + waitForCapacity: true, + provisionTimeout: 900_000, + }); + + const rows: ScoredRow[] = []; + for (const a of targets) rows.push(await scoreOne(client, a, Boolean(id))); + + // Risk board first (descending urgency), then opportunity board. + const risk = rows.filter((r) => r.direction === "risk").sort((a, b) => b.score - a.score); + const opp = rows.filter((r) => r.direction === "opportunity").sort((a, b) => b.score - a.score); + + const line = (r: (typeof rows)[number]) => + ` [${r.band.toUpperCase().padEnd(5)}] ${String(r.score).padStart(3)} ${r.account.name.padEnd(20)} $${r.account.arr.toLocaleString().padStart(8)} → ${r.play}`; + + console.log("\n=== CHURN-RISK BOARD ==="); + for (const r of risk) console.log(line(r)); + console.log("\n=== EXPANSION BOARD ==="); + for (const r of opp) console.log(line(r)); + console.log(""); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/account-signal-scoring/src/config.ts b/examples/account-signal-scoring/src/config.ts new file mode 100644 index 00000000..bde48e3c --- /dev/null +++ b/examples/account-signal-scoring/src/config.ts @@ -0,0 +1,30 @@ +export const config = { + sieUrl: process.env.SIE_URL ?? "http://localhost:8080", + sieApiKey: process.env.SIE_API_KEY, + + models: { + extractor: "urchade/gliner_multi-v2.1", + encoder: "sentence-transformers/all-MiniLM-L6-v2", + reranker: "BAAI/bge-reranker-base", + }, + + // Optional generation model for the LLM brief (via /v1/chat/completions). + // Empty -> deterministic brief, so the demo stays CPU-only by default. + chatModel: process.env.SIE_CHAT_MODEL ?? "", + + // Entity types GLiNER surfaces from the account context (display only). + extractLabels: ["company", "person", "job_title", "product_metric", "money"], + + rerank: { + // How many playbooks to shortlist by cosine before the cross-encoder reranks. + topK: 3, + }, + + paths: { + accounts: "data/accounts.json", + playbooks: "data/playbooks.json", + index: "data/playbook_index.json", + }, + + port: Number(process.env.PORT ?? 3044), +} as const; diff --git a/examples/account-signal-scoring/src/events.ts b/examples/account-signal-scoring/src/events.ts new file mode 100644 index 00000000..a846b844 --- /dev/null +++ b/examples/account-signal-scoring/src/events.ts @@ -0,0 +1,45 @@ +// Typed SSE events streamed to the browser during a scoring run. + +import type { ScoreBand } from "./signals.js"; +import type { SignalDirection } from "./types.js"; + +export interface ExtractedEntity { + label: string; + text: string; + score: number; +} + +export interface PlaybookHit { + id: string; + label: string; + direction: SignalDirection; + summary: string; + outcome: string; + play: string; + score: number; +} + +export interface BriefData { + summary: string; + drivers: string; + recommendedPlay: string; + arrAtStake: number | null; + source: string; +} + +export type ScoreEvent = + | { type: "models"; data: { encoder: string; reranker: string; extractor: string; chat: string } } + | { + type: "signals"; + data: { score: number; band: ScoreBand; direction: SignalDirection; reason: string }; + } + | { type: "extracting" } + | { type: "extracted"; data: { entities: ExtractedEntity[]; ms: number } } + | { type: "encoding" } + | { type: "encoded"; data: { dim: number; ms: number } } + | { type: "scoring" } + | { type: "scored"; data: { hits: PlaybookHit[]; topPlaybook: string; ms: number } } + | { type: "briefing" } + | { type: "brief"; data: BriefData & { ms: number } } + | { type: "done"; data: { totalMs: number } } + | { type: "error"; data: { stage: string; message: string } }; diff --git a/examples/account-signal-scoring/src/index-build.ts b/examples/account-signal-scoring/src/index-build.ts new file mode 100644 index 00000000..06990b87 --- /dev/null +++ b/examples/account-signal-scoring/src/index-build.ts @@ -0,0 +1,41 @@ +// One-time index build: encode the outcome-playbook corpus into dense vectors +// and dump to data/playbook_index.json. Re-run with `npm run index` after +// editing data/playbooks.json. +import fs from "node:fs"; +import path from "node:path"; +import { SIEClient } from "@superlinked/sie-sdk"; +import { config } from "./config.js"; +import type { IndexRecord, Playbook } from "./types.js"; + +async function main(): Promise { + const root = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); + const inPath = path.join(root, config.paths.playbooks); + const outPath = path.join(root, config.paths.index); + + const playbooks: Playbook[] = JSON.parse(fs.readFileSync(inPath, "utf8")); + console.log(`encoding ${playbooks.length} playbooks`); + + const client = new SIEClient(config.sieUrl, { + apiKey: config.sieApiKey, + timeout: 600_000, + waitForCapacity: true, + provisionTimeout: 900_000, + }); + + const items = playbooks.map((p) => ({ id: p.id, text: `${p.label}. ${p.summary} ${p.play}` })); + const results = await client.encode(config.models.encoder, items); + + const records: IndexRecord[] = playbooks.map((p, i) => { + const single = results[i]; + if (!single?.dense) throw new Error(`encoder returned no dense vector for ${p.id}`); + return { id: p.id, vector: Array.from(single.dense) }; + }); + + fs.writeFileSync(outPath, JSON.stringify(records, null, 2)); + console.log(`wrote ${outPath} (${records.length} vectors, dim=${records[0]?.vector.length})`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/examples/account-signal-scoring/src/score.ts b/examples/account-signal-scoring/src/score.ts new file mode 100644 index 00000000..36d57c57 --- /dev/null +++ b/examples/account-signal-scoring/src/score.ts @@ -0,0 +1,140 @@ +// SIE-driven account scoring: turn a pile of signals into one score and a play. +// +// Pipeline: +// 0. Roll the account's signals into one deterministic 0-100 score + band. +// 1. Build a natural-language account context summary. +// 2. Extract entities with GLiNER (surfaced to the UI; not fed to the score). +// 3. Encode the summary with a small dense encoder (MiniLM). +// 4. Cosine-rank against a pre-encoded corpus of past-outcome playbooks. +// 5. Rerank the top K with a cross-encoder to pick the play that fits. +// 6. Draft the account brief (SIE chat/completions, else deterministic). + +import fs from "node:fs"; +import path from "node:path"; +import { SIEClient } from "@superlinked/sie-sdk"; +import { generateBrief } from "./brief.js"; +import { config } from "./config.js"; +import type { ScoreEvent } from "./events.js"; +import { buildAccountSummary, scoreSignals } from "./signals.js"; +import type { Account, IndexRecord, Playbook } from "./types.js"; + +const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), ".."); + +export function loadJson(rel: string): T { + return JSON.parse(fs.readFileSync(path.join(ROOT, rel), "utf8")) as T; +} + +export function loadAccounts(): Account[] { + return loadJson(config.paths.accounts); +} + +export function loadPlaybooks(): Playbook[] { + return loadJson(config.paths.playbooks); +} + +function cosine(a: number[], b: number[]): number { + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + const x = a[i]!; + const y = b[i]!; + dot += x * y; + na += x * x; + nb += y * y; + } + const denom = Math.sqrt(na) * Math.sqrt(nb); + return denom === 0 ? 0 : dot / denom; +} + +export interface ScoreRunDeps { + client: SIEClient; + emit: (event: ScoreEvent) => void; +} + +export async function runScore(account: Account, deps: ScoreRunDeps): Promise { + const { client, emit } = deps; + const start = Date.now(); + + emit({ + type: "models", + data: { + encoder: config.models.encoder, + reranker: config.models.reranker, + extractor: config.models.extractor, + chat: config.chatModel || "(deterministic fallback)", + }, + }); + + // 0. Deterministic signal roll-up. + const signalScore = scoreSignals(account.signals); + emit({ type: "signals", data: signalScore }); + + const summary = buildAccountSummary(account); + + // 1. Extract entities (surface to the UI; not fed into the score). + emit({ type: "extracting" }); + const tEx = Date.now(); + const extractOut = await client.extract( + config.models.extractor, + { text: summary }, + { labels: [...config.extractLabels] }, + ); + const entities = (extractOut.entities ?? []).map((e) => ({ + label: e.label ?? "", + text: e.text ?? "", + score: Number(e.score ?? 0), + })); + emit({ type: "extracted", data: { entities, ms: Date.now() - tEx } }); + + // 2. Encode the account summary. + emit({ type: "encoding" }); + const tEn = Date.now(); + const enc = await client.encode(config.models.encoder, { text: summary }); + const queryVec = enc.dense; + if (!queryVec) throw new Error("encoder returned no dense vector for the account"); + emit({ type: "encoded", data: { dim: queryVec.length, ms: Date.now() - tEn } }); + + // 3. Cosine-rank the playbook corpus to shortlist the top-K candidates. + const playbooks = loadPlaybooks(); + const index: IndexRecord[] = loadJson(config.paths.index); + const byId = new Map(playbooks.map((p) => [p.id, p])); + const shortlist = index + .map((rec) => ({ id: rec.id, score: cosine(Array.from(queryVec), rec.vector) })) + .sort((a, b) => b.score - a.score) + .slice(0, config.rerank.topK); + + // 4. Rerank the shortlist with the cross-encoder for the sharper final order. + emit({ type: "scoring" }); + const tSc = Date.now(); + const docs = shortlist.map((c) => { + const p = byId.get(c.id); + return { id: c.id, text: p ? `${p.label}. ${p.summary} ${p.play}` : "" }; + }); + const scored = await client.score(config.models.reranker, { text: summary }, docs); + const rerankById = new Map(); + for (const s of scored.scores ?? []) rerankById.set(s.itemId, s.score); + const hits = shortlist.map((c) => { + const p = byId.get(c.id)!; + return { + id: p.id, + label: p.label, + direction: p.direction, + summary: p.summary, + outcome: p.outcome, + play: p.play, + score: rerankById.get(c.id) ?? 0, + }; + }); + hits.sort((a, b) => b.score - a.score); + const topPlaybook = hits[0] ? byId.get(hits[0].id) : undefined; + emit({ type: "scored", data: { hits, topPlaybook: topPlaybook?.label ?? "", ms: Date.now() - tSc } }); + + // 5. Draft the account brief. + emit({ type: "briefing" }); + const tBr = Date.now(); + const brief = await generateBrief(account, topPlaybook, summary); + emit({ type: "brief", data: { ...brief, ms: Date.now() - tBr } }); + + emit({ type: "done", data: { totalMs: Date.now() - start } }); +} diff --git a/examples/account-signal-scoring/src/signals.ts b/examples/account-signal-scoring/src/signals.ts new file mode 100644 index 00000000..89166994 --- /dev/null +++ b/examples/account-signal-scoring/src/signals.ts @@ -0,0 +1,120 @@ +// Turn a pile of account signals into one score a team can act on. +// +// This is the deterministic, auditable half of the pipeline: each signal has a +// direction (risk vs. opportunity) and a severity weight, and the weights roll +// up into a single 0-100 score plus a red/amber/green band. SIE then does the +// *ranking* — matching the account's story against a corpus of past outcomes to +// pick the play (see score.ts). + +import type { Account, Signal, SignalDirection, SignalSeverity, SignalType } from "./types.js"; + +/** Numeric weight per severity. */ +export const SEVERITY_WEIGHT: Record = { + major: 10, + medium: 5, + minor: 2, +}; + +/** Default direction + severity for each known signal type. */ +export const SIGNAL_CATALOG: Record< + SignalType, + { direction: SignalDirection; severity: SignalSeverity; label: string } +> = { + stripe_cancellation: { direction: "risk", severity: "major", label: "Stripe cancellation / downgrade" }, + usage_drop: { direction: "risk", severity: "medium", label: "Usage drop" }, + negative_support_ticket: { direction: "risk", severity: "minor", label: "Negative support ticket" }, + usage_near_limit: { direction: "opportunity", severity: "major", label: "Usage near limit" }, + renewal_approaching: { direction: "opportunity", severity: "medium", label: "Renewal approaching" }, + positive_support_ticket: { direction: "opportunity", severity: "minor", label: "Positive support ticket" }, +}; + +export type ScoreBand = "red" | "amber" | "green"; + +export interface AccountScore { + /** 0-100. Higher = more urgent (churn risk or expansion pull). */ + score: number; + band: ScoreBand; + /** Net lean of the signal set. */ + direction: SignalDirection; + /** Human-readable reason string built from the active signals. */ + reason: string; +} + +const clamp = (n: number) => Math.max(0, Math.min(100, Math.round(n))); + +/** Map a 0-100 score to a band. >40 red, 10-40 amber, <10 green. */ +export function bandForScore(score: number): ScoreBand { + if (score > 40) return "red"; + if (score >= 10) return "amber"; + return "green"; +} + +/** + * Roll a set of signals up into one score. + * + * Stripe cancellation is a hard short-circuit to the top of the risk board + * (an active cancellation is unambiguous). Otherwise risk signals add and + * opportunity signals subtract, each weighted by severity; a usage_drop is + * additionally scaled by its magnitude when provided. + */ +export function scoreSignals(signals: Signal[]): AccountScore { + if (signals.some((s) => s.type === "stripe_cancellation")) { + return { + score: 100, + band: "red", + direction: "risk", + reason: "Stripe subscription cancellation / downgrade staged", + }; + } + + let risk = 0; + let opportunity = 0; + const reasons: string[] = []; + + for (const s of signals) { + const meta = SIGNAL_CATALOG[s.type]; + if (!meta) continue; + let weight = SEVERITY_WEIGHT[meta.severity]; + // Scale a usage drop by how bad it is (a 41% drop should sting more). + if (s.type === "usage_drop" && typeof s.value === "number") { + weight = Math.max(weight, s.value * 0.6); + } + if (meta.direction === "risk") risk += weight; + else opportunity += weight; + if (s.note) reasons.push(s.note); + else reasons.push(meta.label); + } + + const direction: SignalDirection = risk >= opportunity ? "risk" : "opportunity"; + const score = clamp(direction === "risk" ? risk : opportunity); + return { + score, + band: bandForScore(score), + direction, + reason: reasons.join("; ") || "No active signals", + }; +} + +/** + * Build the natural-language account context that gets embedded and passed to + * the extractor and the LLM. Keeping this as prose (not a feature vector) is + * what lets a small dense encoder match it against the outcome playbooks. + */ +export function buildAccountSummary(account: Account): string { + const { score, direction } = scoreSignals(account.signals); + const seatPct = Math.round((account.seatsUsed / account.seats) * 100); + const signalText = account.signals + .map((s) => `${SIGNAL_CATALOG[s.type]?.label ?? s.type}${s.note ? ` (${s.note})` : ""}`) + .join("; "); + return [ + `${account.name} (${account.domain}) is a ${direction} account with a signal score of ${score}.`, + `ARR $${account.arr.toLocaleString()}, ${account.seatsUsed}/${account.seats} seats used (${seatPct}%), renewal in ${account.renewalDays} days.`, + `Primary contact: ${account.contact.name}, ${account.contact.title}. Account owner: ${account.owner}.`, + `Active signals: ${signalText || "none"}.`, + ].join(" "); +} + +/** ARR exposed by the account (used as "at stake" in the brief). */ +export function arrAtStake(account: Account): number | null { + return account.arr ?? null; +} diff --git a/examples/account-signal-scoring/src/types.ts b/examples/account-signal-scoring/src/types.ts new file mode 100644 index 00000000..904a1038 --- /dev/null +++ b/examples/account-signal-scoring/src/types.ts @@ -0,0 +1,65 @@ +export type SignalDirection = "risk" | "opportunity"; +export type SignalSeverity = "major" | "medium" | "minor"; + +export type SignalType = + // risk + | "stripe_cancellation" + | "usage_drop" + | "negative_support_ticket" + // opportunity + | "usage_near_limit" + | "renewal_approaching" + | "positive_support_ticket"; + +export interface Signal { + type: SignalType; + /** Free-form context surfaced in the brief and to the extractor. */ + note?: string; + /** When it was detected (display string). */ + detected?: string; + /** Optional magnitude, e.g. usage-drop percentage (0-100). */ + value?: number | null; +} + +export interface Contact { + name: string; + title: string; +} + +export interface Account { + id: string; + name: string; + domain: string; + owner: string; + arr: number; + seats: number; + seatsUsed: number; + renewalDays: number; + contact: Contact; + signals: Signal[]; +} + +/** A past-outcome pattern the live account is ranked against. */ +export interface Playbook { + id: string; + direction: SignalDirection; + label: string; + summary: string; + outcome: string; + play: string; +} + +export interface IndexRecord { + id: string; + vector: number[]; +} + +/** The account brief written back to the CRM / triage board. */ +export interface AccountBrief { + summary: string; + drivers: string; + recommendedPlay: string; + arrAtStake: number | null; + /** "sie-chat" when drafted by the LLM, "deterministic" otherwise. */ + source: string; +} diff --git a/examples/account-signal-scoring/tsconfig.json b/examples/account-signal-scoring/tsconfig.json new file mode 100644 index 00000000..73e0dbb7 --- /dev/null +++ b/examples/account-signal-scoring/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "noEmit": true, + "lib": ["ES2022", "DOM"], + "types": ["node"] + }, + "include": ["src/**/*.ts", "web/**/*.ts"] +} diff --git a/examples/account-signal-scoring/web/public/app.js b/examples/account-signal-scoring/web/public/app.js new file mode 100644 index 00000000..5ecb3be1 --- /dev/null +++ b/examples/account-signal-scoring/web/public/app.js @@ -0,0 +1,301 @@ +// account-signal-scoring frontend: SSE-driven scoring pipeline + account brief. +// No build step; vanilla JS. + +const els = { + badge: document.getElementById("badge"), + sieState: document.getElementById("sie-state"), + briefState: document.getElementById("brief-state"), + sieUrl: document.getElementById("sie-url"), + accounts: document.getElementById("accounts"), + pipeline: document.getElementById("pipeline"), + pipelineMeta: document.getElementById("pipeline-meta"), + brief: document.getElementById("brief"), + briefMeta: document.getElementById("brief-meta"), + timings: document.getElementById("timings"), +}; + +let activeAccount = null; + +function setBadge(text, cls) { + els.badge.textContent = text; + els.badge.className = "badge" + (cls ? " " + cls : ""); +} + +function fmtMs(ms) { + if (ms < 1000) return `${ms} ms`; + return `${(ms / 1000).toFixed(2)} s`; +} + +function fmtUsd(n) { + if (n == null) return "n/a"; + return `$${n.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; +} + +function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderAccounts(accounts) { + if (!accounts?.length) { + els.accounts.innerHTML = '

no accounts

'; + return; + } + const groups = { risk: [], opportunity: [] }; + for (const a of accounts) groups[a.direction]?.push(a); + + const card = (a) => ` + `; + + const section = (key, title, rows) => + rows.length + ? `
${title} · ${rows.length}
${rows.map(card).join("")}` + : ""; + + els.accounts.innerHTML = + section("risk", "Churn-risk board", groups.risk) + + section("opportunity", "Expansion board", groups.opportunity); + + for (const node of els.accounts.querySelectorAll(".account")) { + node.addEventListener("click", () => { + for (const o of els.accounts.querySelectorAll(".account")) o.classList.remove("active"); + node.classList.add("active"); + const id = node.dataset.id; + activeAccount = accounts.find((a) => a.id === id); + runScore(id); + }); + } +} + +function renderPipelineStart() { + els.pipeline.innerHTML = ` +
+
extract
+
encode
+
score
+
brief
+
+
`; + els.pipelineMeta.textContent = ""; + els.timings.textContent = ""; +} + +function setStage(key, state) { + const node = els.pipeline.querySelector(`.stage[data-key="${key}"]`); + if (node) node.dataset.state = state; +} + +function renderEntities(entities) { + if (!entities?.length) return '

no entities extracted

'; + return entities + .map( + (e) => ` + + ${escapeHtml(e.label)} + ${escapeHtml(e.text)} + ${e.score.toFixed(2)} + `, + ) + .join(""); +} + +function renderBand(signals) { + const bandWhy = { + green: "under 10 — healthy. No action required, monitor.", + amber: "10 - 40 — worth a proactive touch this week.", + red: "over 40 — act now, this is at the top of the board.", + }[signals.band]; + return ` +
+
Signal score
+
${signals.score}
+
${signals.direction} · ${signals.band.toUpperCase()}
+
+

${bandWhy}

`; +} + +function renderHits(hits) { + return ` +
+ Each row is a past-outcome playbook. The number on the right is the + cross-encoder reranker score for this account against that playbook. + The top match (highlighted) drives the recommended play. +
+
+ ${hits + .map( + (h, i) => ` +
+
+ ${escapeHtml(h.label)} + ${h.score.toFixed(3)} +
+
${escapeHtml(h.summary)}
+
▸ ${escapeHtml(h.play)}
+
+ ${h.direction} + outcome: ${escapeHtml(h.outcome)} +
+
`, + ) + .join("")} +
`; +} + +function renderBrief(data) { + const isLlm = data.source === "sie-chat"; + els.brief.innerHTML = ` +
+ ${isLlm ? "SIE LLM" : "deterministic"} + +
+
Summary
+
${escapeHtml(data.summary)}
+
+
+
Drivers
+
${escapeHtml(data.drivers)}
+
+
+
Recommended play
+
${escapeHtml(data.recommendedPlay)}
+
+
+
+ ARR at stake + ${fmtUsd(data.arrAtStake)} +
`; + els.briefMeta.textContent = isLlm ? `llm · ${fmtMs(data.ms)}` : `deterministic · ${fmtMs(data.ms)}`; +} + +function runScore(id) { + setBadge("running", "running"); + renderPipelineStart(); + els.brief.innerHTML = '

Scoring pipeline running...

'; + const evt = new EventSource(`/api/run?id=${encodeURIComponent(id)}`); + const body = els.pipeline.querySelector(".risk-body"); + const ts = { extract: 0, encode: 0, score: 0, brief: 0, total: 0 }; + + evt.addEventListener("signals", (e) => { + const data = JSON.parse(e.data); + body.insertAdjacentHTML( + "beforeend", + `
+
Signal roll-up (deterministic)
+ ${renderBand(data)} +
`, + ); + }); + evt.addEventListener("extracting", () => setStage("extract", "running")); + evt.addEventListener("extracted", (e) => { + setStage("extract", "done"); + const data = JSON.parse(e.data); + ts.extract = data.ms; + body.insertAdjacentHTML( + "beforeend", + `
+
Extracted entities · ${fmtMs(data.ms)}
+
+ GLiNER zero-shot NER pulls typed entities out of the account summary. +
+
${renderEntities(data.entities)}
+
`, + ); + }); + evt.addEventListener("encoding", () => setStage("encode", "running")); + evt.addEventListener("encoded", (e) => { + setStage("encode", "done"); + const data = JSON.parse(e.data); + ts.encode = data.ms; + body.insertAdjacentHTML( + "beforeend", + `
+
Encoded account context · ${fmtMs(data.ms)}
+
+ MiniLM-L6 turns the account summary into a dense ${data.dim}-dimensional vector. + Cosine similarity against the pre-encoded playbook corpus picks the top candidates for reranking. +
+
`, + ); + }); + evt.addEventListener("scoring", () => setStage("score", "running")); + evt.addEventListener("scored", (e) => { + setStage("score", "done"); + const data = JSON.parse(e.data); + ts.score = data.ms; + body.insertAdjacentHTML( + "beforeend", + `
+
Reranked playbooks · ${fmtMs(data.ms)}
+ ${renderHits(data.hits)} +
`, + ); + }); + evt.addEventListener("briefing", () => setStage("brief", "running")); + evt.addEventListener("brief", (e) => { + setStage("brief", "done"); + const data = JSON.parse(e.data); + ts.brief = data.ms; + renderBrief(data); + }); + evt.addEventListener("done", (e) => { + const data = JSON.parse(e.data); + ts.total = data.totalMs; + setBadge("done", "green"); + els.timings.textContent = `extract ${fmtMs(ts.extract)} · encode ${fmtMs(ts.encode)} · score ${fmtMs(ts.score)} · brief ${fmtMs(ts.brief)} · total ${fmtMs(ts.total)}`; + evt.close(); + }); + evt.addEventListener("error", (e) => { + setBadge("error", "red"); + let msg = "stream error"; + try { + const data = JSON.parse(e.data); + msg = `${data.stage}: ${data.message}`; + } catch { + /* network error event has no payload */ + } + body.insertAdjacentHTML("beforeend", `
${escapeHtml(msg)}
`); + evt.close(); + }); +} + +async function init() { + els.sieUrl.textContent = "..."; + try { + const r = await fetch("/api/health"); + const j = await r.json(); + els.sieUrl.textContent = j.sieUrl; + els.sieState.textContent = j.sie + ? `SIE healthy · ${j.registeredModels} models` + : "SIE not reachable yet"; + els.briefState.textContent = + j.brief === "llm" ? `brief: LLM (${j.chatModel})` : "brief: deterministic"; + } catch { + els.sieState.textContent = "could not reach the local server"; + } + try { + const r = await fetch("/api/accounts"); + renderAccounts(await r.json()); + } catch { + els.accounts.innerHTML = '

failed to load accounts

'; + } +} + +init(); diff --git a/examples/account-signal-scoring/web/public/architecture.svg b/examples/account-signal-scoring/web/public/architecture.svg new file mode 100644 index 00000000..ebe7af04 --- /dev/null +++ b/examples/account-signal-scoring/web/public/architecture.svg @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + account signals + Stripe cancel · usage drop + support · seat cap · renewal + → one 0-100 score + + + + + + + + + one SIE server + extract · encode · score (+ chat) + + + + + extract + GLiNER + entities + + + + encode + MiniLM-L6 + dense 384-d + + + + score + BGE-rerank + top-K + + + + chat + optional LLM + brief + + + + + + playbooks.json + 8 past outcomes, pre-encoded + cosine top-3 → reranker → play + + + + + + + + + score + play + + + + + green + score < 10 + + + + amber + 10 - 40 + + + + red + > 40 + + + → triage board (risk/upsell) + → recommended play + brief + + + + + + + + + account-signal-scoring + turn a pile of account signals into one score a team can act on + Apache 2.0 · runs locally with docker compose · CPU-only by default + diff --git a/examples/account-signal-scoring/web/public/index.html b/examples/account-signal-scoring/web/public/index.html new file mode 100644 index 00000000..d55ed06d --- /dev/null +++ b/examples/account-signal-scoring/web/public/index.html @@ -0,0 +1,81 @@ + + + + + + account-signal-scoring + + + + + +
+
+ +

account-signal-scoring

+ idle +
+
checking SIE...
+
brief: ...
+ +
+ +
+
+

+ A CSM opens the day to a pile of account signals — a Stripe + downgrade, an API-usage cliff, a seat cap about to hit. SIE turns + them into one score a team can act on: + extract the entities, encode the account's story, + and score it against a corpus of past outcomes to pick the + play. Click an account on the left to watch the pipeline run and draft + the brief. +

+
+
+ account-signal-scoring architecture +
+
+ +
+
+

Accounts & signals

+
loading...
+
+ +
+
+

SIE scoring pipeline

+ +
+
+

Click an account on the left.

+
+
+ +
+
+

Account brief

+ +
+
+

The recommended play and account brief appear here once a score is computed.

+
+
+
+ +
+ SIE on http://localhost:8080 + +
+ + + + diff --git a/examples/account-signal-scoring/web/public/style.css b/examples/account-signal-scoring/web/public/style.css new file mode 100644 index 00000000..b927ac6f --- /dev/null +++ b/examples/account-signal-scoring/web/public/style.css @@ -0,0 +1,301 @@ +:root { + --background: hsl(240 10% 3.9%); + --foreground: hsl(0 0% 98%); + --card: hsl(240 8% 6%); + --card-elevated: hsl(240 8% 7.5%); + --border: hsl(240 3.7% 15.9%); + --border-strong: hsl(240 4% 22%); + --secondary: hsl(240 3.7% 15.9%); + --muted-foreground: hsl(240 5% 64.9%); + --accent: hsl(245 60% 65%); + --accent-subtle: hsl(245 60% 65% / 0.12); + --accent-strong: hsl(245 75% 75%); + --info: hsl(208 80% 65%); + --info-subtle: hsl(208 80% 65% / 0.14); + --magenta: hsl(275 75% 75%); + --success: hsl(142 65% 60%); + --success-subtle: hsl(142 65% 60% / 0.15); + --warn: hsl(38 90% 62%); + --warn-subtle: hsl(38 90% 62% / 0.15); + --destructive: hsl(0 75% 65%); + --destructive-subtle: hsl(0 75% 65% / 0.15); + --radius: 0.625rem; + --radius-sm: 0.375rem; + --shadow-sm: 0 1px 2px 0 hsl(0 0% 0% / 0.25); + --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --font-mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, monospace; +} + +* { box-sizing: border-box; } +html, body { + margin: 0; padding: 0; + background: var(--background); color: var(--foreground); + font-family: var(--font-sans); + font-size: 14px; line-height: 1.5; + min-height: 100%; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} +body { display: flex; flex-direction: column; min-height: 100vh; } + +header { + display: flex; align-items: center; gap: 16px; + padding: 14px 24px; + border-bottom: 1px solid var(--border); + flex-wrap: wrap; +} +.title { display: flex; align-items: center; gap: 12px; } +.logo { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; + border-radius: var(--radius-sm); + background: var(--accent-subtle); + color: var(--accent-strong); + font-size: 15px; +} +h1 { + font-size: 15px; margin: 0; font-weight: 600; + font-family: var(--font-mono); +} +.badge { + display: inline-flex; align-items: center; gap: 6px; + padding: 3px 9px; border-radius: 999px; + background: var(--secondary); + color: var(--muted-foreground); + font-size: 11px; font-weight: 500; + border: 1px solid var(--border); +} +.badge::before { + content: ""; width: 5px; height: 5px; border-radius: 999px; + background: currentColor; opacity: 0.7; +} +.badge.running { background: var(--info-subtle); color: var(--info); border-color: transparent; } +.badge.green { background: var(--success-subtle); color: var(--success); border-color: transparent; } +.badge.red { background: var(--destructive-subtle); color: var(--destructive); border-color: transparent; } + +.meta { color: var(--muted-foreground); font-size: 12px; } +.meta code { font-family: var(--font-mono); } + +.cta-row { margin-left: auto; display: flex; gap: 8px; } +.cta { + display: inline-flex; align-items: center; gap: 6px; + padding: 5px 10px; border-radius: var(--radius-sm); + background: var(--secondary); color: var(--info); + font-size: 11px; text-decoration: none; + border: 1px solid transparent; + transition: border-color 0.1s; +} +.cta:hover { border-color: var(--border-strong); } + +.hero { + padding: 18px 24px; + background: radial-gradient(60% 80% at 20% 0%, var(--accent-subtle), transparent 70%); + border-bottom: 1px solid var(--border); + display: grid; grid-template-columns: 1fr 1.4fr; gap: 24px; align-items: center; +} +.hero-text p { margin: 0; color: var(--muted-foreground); line-height: 1.65; } +.hero-text strong { color: var(--foreground); } +.hero-text em { color: var(--accent-strong); font-style: normal; font-weight: 600; } + +.diagram-svg { + display: block; width: 100%; height: auto; + border-radius: var(--radius-sm); + border: 1px solid var(--border); + background: var(--card); +} + +main { + flex: 1; display: grid; + grid-template-columns: 1fr 1.25fr 1.1fr; + gap: 16px; padding: 16px 24px 20px; overflow: hidden; + min-height: 520px; +} + +.panel { + display: flex; flex-direction: column; + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; min-height: 0; + box-shadow: var(--shadow-sm); +} +.panel header { + padding: 12px 16px; + background: var(--card-elevated); + border-bottom: 1px solid var(--border); + display: flex; justify-content: space-between; align-items: center; +} +.panel h2 { + font-size: 12px; font-weight: 600; margin: 0; color: var(--foreground); +} +#panel-accounts h2::before, #panel-pipeline h2::before, #panel-brief h2::before { + content: ""; display: inline-block; width: 6px; height: 6px; + border-radius: 999px; margin-right: 8px; vertical-align: middle; +} +#panel-accounts h2::before { background: var(--accent); } +#panel-pipeline h2::before { background: var(--info); } +#panel-brief h2::before { background: var(--magenta); } + +.hint { color: var(--muted-foreground); font-size: 12px; } + +.list, .risk, .checkout { flex: 1; overflow: auto; padding: 12px; margin: 0; } + +.board-head { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted-foreground); margin: 4px 2px 8px; font-weight: 600; +} +.board-head.risk { color: var(--destructive); } +.board-head.opportunity { color: var(--success); } + +.account { + padding: 12px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + margin-bottom: 8px; cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + background: var(--card); + display: grid; grid-template-columns: 42px 1fr; gap: 12px; align-items: center; +} +.account:hover { border-color: var(--border-strong); background: var(--card-elevated); } +.account.active { border-color: var(--accent); background: var(--accent-subtle); } +.account-score { + width: 42px; height: 42px; border-radius: var(--radius-sm); + display: flex; align-items: center; justify-content: center; + font-family: var(--font-mono); font-weight: 700; font-size: 16px; + background: var(--secondary); color: var(--muted-foreground); +} +.account-score.band-red { background: var(--destructive-subtle); color: var(--destructive); } +.account-score.band-amber { background: var(--warn-subtle); color: var(--warn); } +.account-score.band-green { background: var(--success-subtle); color: var(--success); } +.account-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } +.account-name { font-weight: 600; } +.account-arr { font-family: var(--font-mono); color: var(--success); font-size: 12px; } +.account-reason { color: var(--muted-foreground); font-size: 12px; margin-top: 3px; line-height: 1.5; } +.account-meta { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + +.pill { + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 500; + background: var(--secondary); + color: var(--muted-foreground); + border: 1px solid var(--border); +} +.pill.risk { background: var(--destructive-subtle); color: var(--destructive); border-color: transparent; } +.pill.opportunity { background: var(--success-subtle); color: var(--success); border-color: transparent; } + +.risk-stages { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; } +.stage { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; + background: var(--secondary); border: 1px solid var(--border); + color: var(--muted-foreground); font-size: 11px; +} +.stage-dot { width: 6px; height: 6px; border-radius: 999px; background: var(--muted-foreground); } +.stage[data-state="running"] { color: var(--info); border-color: var(--info); background: var(--info-subtle); } +.stage[data-state="running"] .stage-dot { background: var(--info); animation: pulse 1s ease-in-out infinite; } +.stage[data-state="done"] { color: var(--success); border-color: transparent; background: var(--success-subtle); } +.stage[data-state="done"] .stage-dot { background: var(--success); } +@keyframes pulse { 0%,100% { opacity: 0.4; } 50% { opacity: 1; } } + +.risk-section { margin-bottom: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); } +.risk-section:last-child { border-bottom: 0; margin-bottom: 0; padding-bottom: 0; } +.risk-section-head { + font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted-foreground); margin-bottom: 8px; font-weight: 500; +} +.entities { display: flex; flex-wrap: wrap; gap: 6px; } +.entity-group { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 9px; border-radius: var(--radius-sm); + background: var(--secondary); border: 1px solid var(--border); + font-size: 11.5px; font-family: var(--font-mono); +} +.entity-group .ekey { text-transform: uppercase; color: var(--muted-foreground); font-size: 10px; font-weight: 600; } +.entity-group .eval { color: var(--info); } +.entity-group .econf { color: var(--muted-foreground); font-size: 10px; padding: 0 4px; border-left: 1px solid var(--border); margin-left: 2px; } + +.risk-band { + display: grid; grid-template-columns: auto auto auto; gap: 8px 16px; + align-items: baseline; + padding: 12px 14px; border-radius: var(--radius-sm); + margin-bottom: 12px; + border: 1px solid var(--border); +} +.risk-band .band-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted-foreground); } +.risk-band .band-value { font-size: 22px; font-weight: 700; letter-spacing: -0.01em; font-family: var(--font-mono); } +.risk-band .band-score { font-size: 11px; color: var(--muted-foreground); } +.risk-band.band-green { background: var(--success-subtle); border-color: transparent; } +.risk-band.band-green .band-value { color: var(--success); } +.risk-band.band-amber { background: var(--warn-subtle); border-color: transparent; } +.risk-band.band-amber .band-value { color: var(--warn); } +.risk-band.band-red { background: var(--destructive-subtle); border-color: transparent; } +.risk-band.band-red .band-value { color: var(--destructive); } + +.band-why { margin: 0 0 12px 0; font-size: 12px; line-height: 1.55; color: var(--muted-foreground); } +.score-legend { + margin-bottom: 10px; padding: 8px 10px; border-radius: var(--radius-sm); + background: var(--card-elevated); border: 1px solid var(--border); + font-size: 11.5px; line-height: 1.5; color: var(--muted-foreground); +} +.score-legend strong { color: var(--foreground); font-weight: 600; } + +.hits { display: flex; flex-direction: column; gap: 8px; } +.hit { padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--card); } +.hit.top { border-color: var(--accent); background: var(--accent-subtle); } +.hit-head { display: flex; justify-content: space-between; align-items: baseline; } +.hit-label { font-weight: 600; font-size: 12.5px; } +.hit-score { color: var(--info); font-size: 11.5px; font-family: var(--font-mono); } +.hit-summary { margin-top: 4px; color: var(--muted-foreground); font-size: 12px; line-height: 1.55; } +.hit-play { margin-top: 6px; font-size: 12px; line-height: 1.55; color: var(--foreground); } +.hit-meta { display: flex; gap: 6px; margin-top: 8px; flex-wrap: wrap; } + +/* Brief panel */ +.brief-card { + padding: 14px; border-radius: var(--radius-sm); + background: var(--card-elevated); border: 1px solid var(--border); + margin-bottom: 12px; +} +.brief-account { font-weight: 600; font-size: 14px; } +.brief-source { + float: right; font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; + padding: 2px 8px; border-radius: 999px; font-weight: 600; + background: var(--secondary); color: var(--muted-foreground); border: 1px solid var(--border); +} +.brief-source.llm { background: var(--accent-subtle); color: var(--accent-strong); border-color: transparent; } +.brief-field { margin-top: 12px; } +.brief-field .k { + font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; + color: var(--muted-foreground); font-weight: 600; margin-bottom: 4px; +} +.brief-field .v { font-size: 13px; line-height: 1.6; } +.brief-play .v { color: var(--accent-strong); font-weight: 500; } +.arr-stake { + display: flex; justify-content: space-between; align-items: baseline; + padding: 10px 12px; border-radius: var(--radius-sm); + background: var(--card-elevated); border: 1px solid var(--border); +} +.arr-stake .k { color: var(--muted-foreground); font-size: 12px; } +.arr-stake .v { font-family: var(--font-mono); font-weight: 700; font-size: 18px; color: var(--warn); } + +.error { + padding: 10px 12px; border-radius: var(--radius-sm); + background: var(--destructive-subtle); border: 1px solid hsl(0 75% 65% / 0.3); + color: var(--destructive); font-size: 12px; line-height: 1.5; +} + +footer { + padding: 12px 24px; border-top: 1px solid var(--border); + color: var(--muted-foreground); font-size: 12px; + display: flex; justify-content: space-between; gap: 12px; +} +footer code { font-family: var(--font-mono); } +code { + background: var(--secondary); padding: 1px 7px; border-radius: var(--radius-sm); + font-family: var(--font-mono); font-size: 0.92em; +} + +@media (max-width: 1000px) { + .hero { grid-template-columns: 1fr; } + main { grid-template-columns: 1fr; } + .cta-row { margin-left: 0; } +} diff --git a/examples/account-signal-scoring/web/server.ts b/examples/account-signal-scoring/web/server.ts new file mode 100644 index 00000000..cbee01d8 --- /dev/null +++ b/examples/account-signal-scoring/web/server.ts @@ -0,0 +1,193 @@ +import fs from "node:fs"; +import http from "node:http"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import { SIEClient } from "@superlinked/sie-sdk"; +import { config } from "../src/config.js"; +import type { ScoreEvent } from "../src/events.js"; +import { loadAccounts, runScore } from "../src/score.js"; +import { scoreSignals } from "../src/signals.js"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const ROOT = path.resolve(__dirname, ".."); +const PUBLIC_DIR = path.join(ROOT, "web", "public"); +const INDEX_PATH = path.join(ROOT, config.paths.index); + +function send( + res: http.ServerResponse, + status: number, + body: string | Buffer, + contentType = "text/plain", +): void { + res.writeHead(status, { "content-type": contentType }); + res.end(body); +} + +function serveFile(res: http.ServerResponse, file: string): void { + if (!fs.existsSync(file)) return send(res, 404, "not found"); + const ext = path.extname(file).toLowerCase(); + const ct = + { + ".html": "text/html", + ".css": "text/css", + ".js": "text/javascript", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + }[ext] ?? "application/octet-stream"; + res.writeHead(200, { "content-type": ct }); + fs.createReadStream(file).pipe(res); +} + +function setupSse(res: http.ServerResponse) { + res.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + return (event: ScoreEvent) => { + res.write(`event: ${event.type}\n`); + const payload = "data" in event ? event.data : null; + res.write(`data: ${JSON.stringify(payload)}\n\n`); + }; +} + +function ensureIndex(): void { + if (fs.existsSync(INDEX_PATH)) return; + console.log("building playbook index..."); + const result = spawnSync( + process.execPath, + [path.join(ROOT, "node_modules/.bin/tsx"), path.join(ROOT, "src/index-build.ts")], + { cwd: ROOT, encoding: "utf8", stdio: "inherit" }, + ); + if (result.status !== 0) throw new Error("index-build failed"); +} + +async function checkSie(): Promise { + try { + const r = await fetch(`${config.sieUrl}/healthz`, { signal: AbortSignal.timeout(2000) }); + return r.ok; + } catch { + return false; + } +} + +async function fetchRegistered(): Promise<{ ok: boolean; names: string[] }> { + try { + const r = await fetch(`${config.sieUrl}/v1/models`, { signal: AbortSignal.timeout(3000) }); + if (!r.ok) return { ok: false, names: [] }; + const json = (await r.json()) as { models?: { name: string }[] }; + return { ok: true, names: (json.models ?? []).map((m) => m.name) }; + } catch { + return { ok: false, names: [] }; + } +} + +async function handleRun(res: http.ServerResponse, accountId: string): Promise { + const push = setupSse(res); + const account = loadAccounts().find((a) => a.id === accountId); + if (!account) { + push({ type: "error", data: { stage: "lookup", message: `unknown account id: ${accountId}` } }); + res.end(); + return; + } + + if (!(await checkSie())) { + push({ type: "error", data: { stage: "sie", message: `SIE not reachable at ${config.sieUrl}` } }); + res.end(); + return; + } + + try { + ensureIndex(); + } catch (e) { + push({ type: "error", data: { stage: "index", message: e instanceof Error ? e.message : String(e) } }); + res.end(); + return; + } + + const client = new SIEClient(config.sieUrl, { + apiKey: config.sieApiKey, + timeout: 600_000, + waitForCapacity: true, + provisionTimeout: 900_000, + }); + + try { + await runScore(account, { client, emit: push }); + } catch (e) { + push({ type: "error", data: { stage: "pipeline", message: e instanceof Error ? e.message : String(e) } }); + } finally { + res.end(); + } +} + +/** The pre-SIE board: accounts split into risk/opportunity by the raw score. */ +function boardPayload() { + return loadAccounts() + .map((a) => ({ account: a, ...scoreSignals(a.signals) })) + .sort((a, b) => b.score - a.score) + .map((r) => ({ + id: r.account.id, + name: r.account.name, + domain: r.account.domain, + owner: r.account.owner, + arr: r.account.arr, + renewalDays: r.account.renewalDays, + contact: r.account.contact, + signals: r.account.signals, + score: r.score, + band: r.band, + direction: r.direction, + reason: r.reason, + })); +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url ?? "/", `http://${req.headers.host}`); + const p = url.pathname; + + if (p === "/" || p === "/index.html") return serveFile(res, path.join(PUBLIC_DIR, "index.html")); + if (p.startsWith("/static/")) return serveFile(res, path.join(PUBLIC_DIR, p.slice("/static/".length))); + + if (p === "/api/health") { + const { ok, names } = await fetchRegistered(); + return send( + res, + 200, + JSON.stringify({ + sie: ok, + sieUrl: config.sieUrl, + registeredModels: names.length, + registered: names, + chatModel: config.chatModel || null, + brief: config.chatModel ? "llm" : "deterministic", + }), + "application/json", + ); + } + + if (p === "/api/accounts") { + return send(res, 200, JSON.stringify(boardPayload()), "application/json"); + } + + if (p === "/api/run") { + const id = url.searchParams.get("id"); + if (!id) return send(res, 400, "missing id"); + return handleRun(res, id); + } + + return send(res, 404, "not found"); +}); + +server.listen(config.port, () => { + const url = `http://localhost:${config.port}`; + console.log(`account-signal-scoring ui: ${url}`); + console.log(`brief mode: ${config.chatModel ? `llm (${config.chatModel})` : "deterministic (set SIE_CHAT_MODEL to use an LLM)"}`); + if (process.env.OPEN_BROWSER !== "0") { + const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"; + spawnSync(opener, [url], { stdio: "ignore" }); + } +});