- Expanso + Jev
- Example 10 of 10
A feedback miner that tells the account team before the customer leaves.
Every feedback item gets topic, sentiment and churn risk — at-risk accounts alert their team, bugs flow to eng, everything lands in analytics.
Where the record goes
Every stage on the left is Expanso, and it is deterministic. The record crosses to Jev once, for the one question a rule cannot answer, and comes straight back. Line numbers link to the YAML below.
POST /feedback- Receive
- Shape
- Ask Jev
- Gate
- Route
topicsentimentchurn_risk
alertenganalytics
- Receivelines 11 to 16
An HTTP server input accepts feedback on
POST /feedback. - Shapelines 17 to 26
Parses the POST body if it arrived as a string, then stamps
received_at. It applies the same fixed rules every time, with no model involved; the timestamp is the one value that differs. - Ask Jevlines 27 to 84
Expanso packs the whole record and the typed questions into one request. Jev answers. If the call fails, a
catchsubstitutes empty answers markedjev-unavailable, and the pipeline keeps going.topicchoicesentimentscorechurn_risknoul
- Gatelines 85 to 99
Fixed thresholds over Jev’s answers choose alert, eng, or analytics.
- Routelines 100 to 123
A
switchoutput writes to one file per decision.
What Jev is asked
Jev, judgmentThe pipeline sends the record with 3 typed questions. Jev answers each one with a value the pipeline can compare against a number.
topicchoiceWhat is this feedback about?
bug · feature_request · praise · pricing · support_experience · other
sentimentscoreWhat is the sentiment of this feedback?
churn_risknoulDoes this feedback indicate the customer may churn or cancel?
What Expanso does with the answers
Expanso, deterministicFixed thresholds, checked in order. The first rule that matches sets the route. These are the expressions in the pipeline, not a summary of them.
- $churn >= 0.75alert · Likely to churn.
- $topic == "bug" && $topic_conf >= 0.7eng · Confidently a bug report.
- elseanalytics · Recorded with Jev’s answers attached.
If Jev is unreachable: analytics
With no answers, churn risk defaults to 0 and the topic to other, so feedback is recorded but no one is alerted while Jev is unreachable.
The pipeline
This is the example's own pipeline file, unmodified. Violet marks the lines Expanso runs on its own. Orange marks the handoff, and the darker orange band is the HTTP call to Jev itself.
name: jev-feedback-miner
type: pipeline
description: Feedback miner with Jev — every review and NPS verbatim gets topic, sentiment and churn-risk judged inline. Bugs route to eng, churn risks ping the account team, everything feeds analytics.
namespace: production
labels:
category: integrations
pattern: ai-decision
model: jev
config:
input:
http_server:
address: "0.0.0.0:8080"
path: /feedback
allowed_verbs: ["POST"]
pipeline:
processors:
- mapping: |
# http_server already parses JSON bodies; only parse raw strings
root = if this.type() == "string" { this.parse_json() } else { this }
- mapping: |
root = this
root.received_at = now()
# ── Ask Jev ──
- mutation: |
meta jev_start = timestamp_unix_milli()
- branch:
request_map: |
root = {
"state": this.string(),
"model": "jev-latest",
"questions": {
"topic": {
"type": "choice",
"instructions": "What is this feedback about?",
"criteria": {
"bug": "Something is broken or not working",
"feature_request": "Asking for new functionality",
"praise": "Compliments, positive experience",
"pricing": "Cost, plans, billing concerns",
"support_experience": "About the support interaction itself",
"other": "None of the above"
}
},
"sentiment": {
"type": "score",
"instructions": "What is the sentiment of this feedback?",
"criteria": [
"Very negative",
"Negative",
"Neutral",
"Positive",
"Very positive"
]
},
"churn_risk": {
"type": "noul",
"instructions": "Does this feedback indicate the customer may churn or cancel?"
}
}
}
processors:
- http:
url: "${JEV_API_URL:https://api.typesafe.ai/v1/systemone}"
verb: POST
headers:
Content-Type: application/json
Authorization: "Bearer ${TYPESAFE_API_KEY}"
timeout: 2s
retries: 1
- catch:
- mapping: |
root = {"answers": {}, "model": "jev-unavailable"}
result_map: |
root.jev = {
"answers": this.answers,
"model": this.model.or("jev-latest"),
"ms": timestamp_unix_milli() - metadata("jev_start")
}
# ── Confidence-gated cascade ──
- mapping: |
root = this
let topic = this.jev.answers.topic.choice.or("other")
let topic_conf = this.jev.answers.topic.confidence.or(0)
let churn = this.jev.answers.churn_risk.noul.or(0)
root.jev_decision = if $churn >= 0.75 {
"alert"
} else if $topic == "bug" && $topic_conf >= 0.7 {
"eng"
} else {
"analytics"
}
output:
broker:
pattern: fan_out
outputs:
- stdout:
codec: lines
- switch:
cases:
# Production: swap for http_client -> account-team alerting webhook
- check: this.jev_decision == "alert"
output:
file:
path: ./data/jev-feedback-miner/alert-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
- check: this.jev_decision == "eng"
output:
file:
path: ./data/jev-feedback-miner/eng-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
- check: "true"
output:
file:
path: ./data/jev-feedback-miner/analytics-${! now().ts_format("2006-01-02") }.jsonl
codec: lines
123 lines. Copy and Download both give you the file byte for byte.
What you need
- Expanso Edge installed, to validate and run the pipeline.
- A Jev endpoint. The pipeline posts to
JEV_API_URL, and falls back tohttps://api.typesafe.ai/v1/systemonewhen that variable is unset. - A key for that endpoint in
TYPESAFE_API_KEY. The pipeline sends it as a bearer token and has no default for it.
What it proves
- Input. POST /feedback on port 8080.
- Output. Local files under
./data/jev-feedback-miner/. A comment in the YAML marks where an account-team alerting webhook replaces the alert file in production. - Scope. This example ships as a pipeline file and sample records. Its README marks the live runtime (event generator and dashboard) as coming next, so what is published here is the pipeline itself.
- Revision. The file shown is the example as of commit
517c38fof its repository, which is still being developed.
Sample records
The first two of the records that ship with this example. Download all of them.
{"feedback_id":"f-001","customer":"acme-corp","rating":2,"text":"The new dashboard is completely broken for us — widgets won't load since Tuesday's update. This is blocking our weekly reporting."}{"feedback_id":"f-002","customer":"globex","rating":5,"text":"Support was fantastic, Maya resolved our SSO issue in under an hour. Best support experience we've had with any vendor."}Questions about this example.
receive, shape, gate, route. Each of those stages applies the same fixed rules every time, with no model involved. Expanso also sets the thresholds that turn Jev's answers into a route.
Jev answers 3 typed questions about each record: topic, sentiment, churn_risk. It does not choose the route. The pipeline's gate does that from Jev's answers.
The pipeline catches the failed call, marks the record jev-unavailable, and the gate resolves to "analytics". With no answers, churn risk defaults to 0 and the topic to other, so feedback is recorded but no one is alerted while Jev is unreachable.
Run the deterministic half on your own nodes.
Expanso Edge runs these pipelines where the data is created. The first five nodes are free.