Docs menu

Cookbook

Recipe: read sentiment and stance

Rate the sentiment of a review, pick the writer's stance on a price change and check for a competitor, then feed a dashboard and alert the pricing team when the text and the stars agree.

View as Markdown

A subscriber writes a two-star review after a price rise. One request rates the sentiment on your five-level scale, reads the writer's stance on the price change and checks whether a competitor is named. A dashboard gets the rating, and the pricing team gets an alert when the words and the stars agree.

The request

  • State. The review with its product and star rating. The questions point at {{review.text}}, but the model reads the whole state, so the 2 stars sit next to the words as a fact.
  • sentiment is a rate in beta on five levels, from Very negative to Very positive. On one model version the same text always gets the same rating, so a dashboard built on it is comparable over time.
  • price_stance is a pick of four stances, with not_mentioned as the way out for reviews that say nothing about the price.
  • mentions_competitor is a check: one yes or no fact about the text.
  • min_confidence on every question your code acts on.
{
  "state": {
    "review": {
      "product": "Monthly coffee subscription",
      "stars": 2,
      "text": "The beans are still good, but after the price went up from 18 to 24 euros a month I expected more than the same two bags. BrewBox gives you three bags for less. Cancelling at the end of the month unless something changes."
    }
  },
  "questions": {
    "sentiment": {
      "type": "rate",
      "instructions": "What is the overall sentiment of {{review.text}}?",
      "levels": ["Very negative", "Negative", "Neutral", "Positive", "Very positive"],
      "min_confidence": 0.3
    },
    "price_stance": {
      "type": "pick",
      "instructions": "What is the writer's stance on the price change in {{review.text}}?",
      "options": {
        "for": "Accepts or supports the new price",
        "against": "Objects to the new price",
        "neutral": "Mentions the price without taking a side",
        "not_mentioned": "Does not mention the price change"
      },
      "min_confidence": 0.5
    },
    "mentions_competitor": {
      "type": "check",
      "instructions": "Does {{review.text}} mention a competing product or company?",
      "min_confidence": 0.6
    }
  }
}

Run it

Put a test key in DEX_API_KEY (see the Quickstart). Save the Python code as sentiment.py and run python sentiment.py. Save the TypeScript code as sentiment.mts and run npx tsx sentiment.mts: the code uses await at the top level, and the .mts ending makes the file an ES module. Install the SDKs from Downloads.

curl https://api.thinqit.ai/v1/decide \
  -H "authorization: Bearer $DEX_API_KEY" \
  -H "content-type: application/json" \
  --data-binary @- <<'DEX_REQUEST'
{
  "state": {
    "review": {
      "product": "Monthly coffee subscription",
      "stars": 2,
      "text": "The beans are still good, but after the price went up from 18 to 24 euros a month I expected more than the same two bags. BrewBox gives you three bags for less. Cancelling at the end of the month unless something changes."
    }
  },
  "questions": {
    "sentiment": {
      "type": "rate",
      "instructions": "What is the overall sentiment of {{review.text}}?",
      "levels": ["Very negative", "Negative", "Neutral", "Positive", "Very positive"],
      "min_confidence": 0.3
    },
    "price_stance": {
      "type": "pick",
      "instructions": "What is the writer's stance on the price change in {{review.text}}?",
      "options": {
        "for": "Accepts or supports the new price",
        "against": "Objects to the new price",
        "neutral": "Mentions the price without taking a side",
        "not_mentioned": "Does not mention the price change"
      },
      "min_confidence": 0.5
    },
    "mentions_competitor": {
      "type": "check",
      "instructions": "Does {{review.text}} mention a competing product or company?",
      "min_confidence": 0.6
    }
  }
}
DEX_REQUEST
# Save as dex_request.py, then run: python dex_request.py
import json

from thinqit_dex import Client

client = Client()  # reads DEX_API_KEY from the environment

request = json.loads(r'''
{
  "state": {
    "review": {
      "product": "Monthly coffee subscription",
      "stars": 2,
      "text": "The beans are still good, but after the price went up from 18 to 24 euros a month I expected more than the same two bags. BrewBox gives you three bags for less. Cancelling at the end of the month unless something changes."
    }
  },
  "questions": {
    "sentiment": {
      "type": "rate",
      "instructions": "What is the overall sentiment of {{review.text}}?",
      "levels": ["Very negative", "Negative", "Neutral", "Positive", "Very positive"],
      "min_confidence": 0.3
    },
    "price_stance": {
      "type": "pick",
      "instructions": "What is the writer's stance on the price change in {{review.text}}?",
      "options": {
        "for": "Accepts or supports the new price",
        "against": "Objects to the new price",
        "neutral": "Mentions the price without taking a side",
        "not_mentioned": "Does not mention the price change"
      },
      "min_confidence": 0.5
    },
    "mentions_competitor": {
      "type": "check",
      "instructions": "Does {{review.text}} mention a competing product or company?",
      "min_confidence": 0.6
    }
  }
}
''')

decision = client.decide(
    request["state"],
    request["questions"],
)
for question_id, answer in decision.answers.items():
    print(question_id, answer)
// Save as dex-request.mts, then run: npx tsx dex-request.mts (Node.js 18 or newer)
import { Client, parseRequest } from "@thinqit/dex";

const client = new Client(); // reads DEX_API_KEY from the environment

// parseRequest keeps the key order of the text (JSON.parse would move labels such as "1" to the front).
const request = parseRequest(`{
  "state": {
    "review": {
      "product": "Monthly coffee subscription",
      "stars": 2,
      "text": "The beans are still good, but after the price went up from 18 to 24 euros a month I expected more than the same two bags. BrewBox gives you three bags for less. Cancelling at the end of the month unless something changes."
    }
  },
  "questions": {
    "sentiment": {
      "type": "rate",
      "instructions": "What is the overall sentiment of {{review.text}}?",
      "levels": ["Very negative", "Negative", "Neutral", "Positive", "Very positive"],
      "min_confidence": 0.3
    },
    "price_stance": {
      "type": "pick",
      "instructions": "What is the writer's stance on the price change in {{review.text}}?",
      "options": {
        "for": "Accepts or supports the new price",
        "against": "Objects to the new price",
        "neutral": "Mentions the price without taking a side",
        "not_mentioned": "Does not mention the price change"
      },
      "min_confidence": 0.5
    },
    "mentions_competitor": {
      "type": "check",
      "instructions": "Does {{review.text}} mention a competing product or company?",
      "min_confidence": 0.6
    }
  }
}`);

const decision = await client.decide(request);
console.log(decision.answers);

Expected output

{
  "id": "req_01M3JE28W1X5MHKHQB8HH39P96",
  "object": "decision",
  "created": 1790546355,
  "model": "dex-1.0.1",
  "served_by": "gpu",
  "calibration": "cal-20260926-1",
  "answers": {
    "sentiment": {
      "type": "rate",
      "rating": 1.1423,
      "levels": ["Very negative", "Negative", "Neutral", "Positive", "Very positive"],
      "probabilities": [0.0494, 0.8349, 0.0586, 0.0384, 0.0187],
      "confidence": 0.6798,
      "abstained": false
    },
    "price_stance": {
      "type": "pick",
      "choice": "against",
      "probabilities": {
        "for": 0.0334,
        "against": 0.9066,
        "neutral": 0.0357,
        "not_mentioned": 0.0243
      },
      "confidence": 0.8709,
      "abstained": false
    },
    "mentions_competitor": {
      "type": "check",
      "probability": 0.903,
      "confidence": 0.806,
      "abstained": false
    }
  },
  "usage": {
    "input_tokens": 172,
    "state_tokens": 72,
    "question_tokens": 100,
    "allowance_tokens": 0,
    "paid_tokens": 0,
    "charge_micro_cents": 0,
    "unit_price_micro_cents": 0,
    "tier": "test"
  }
}

Captured from the live API on 2026-09-27 with a test key: model dex-1.0.1, calibration cal-20260926-1, served_by: gpu, 172 input tokens (72 for the state, 100 for the questions). A test key is charged nothing, so tier is test and the charge is 0. On this exact version the same request always returns these answers.

Question Type Answer Confidence min_confidence Abstained
sentiment rate rating 1.1423, most likely Negative (0.8349) 0.6798 0.3 no
price_stance pick against (0.9066) 0.8709 0.5 no
mentions_competitor check yes with probability 0.903 0.806 0.6 no

The review is Negative (0.8349), with a rating of 1.1423 on a scale from 0 to 4. The writer objects to the new price (0.9066) and names a competitor (0.903). No answer abstained.

Act on it

review_id, stars and the functions record_sentiment, alert_pricing_team and add_tag stand for your own code; stars is the star rating from your own record. The alert needs two signals that agree: the stance read from the words and the stars the customer gave. Here both hold, so the rating goes to the dashboard, the pricing team gets an alert and the review is tagged competitor.

sentiment = decision.rate("sentiment")
if not sentiment.abstained:
    record_sentiment(review_id, sentiment.rating)  # 0 is very negative, 4 very positive

stance = decision.pick("price_stance")
if not stance.abstained and stance.choice == "against" and stars <= 2:
    alert_pricing_team(review_id)  # the words and the stars agree

competitor = decision.check("mentions_competitor")
if not competitor.abstained and competitor.probability >= 0.5:
    add_tag(review_id, "competitor")
const { sentiment, price_stance: stance, mentions_competitor: competitor } = decision.answers;

if (sentiment?.type === "rate" && !sentiment.abstained) {
  recordSentiment(reviewId, sentiment.rating); // 0 is very negative, 4 very positive
}
if (stance?.type === "pick" && !stance.abstained && stance.choice === "against" && stars <= 2) {
  alertPricingTeam(reviewId); // the words and the stars agree
}
if (competitor?.type === "check" && !competitor.abstained && competitor.probability >= 0.5) addTag(reviewId, "competitor");

As in the support recipe, the TypeScript answers have the general Answer type, so the code narrows each one on type.

Adapt it

  • Do not act on sentiment alone. Sarcasm and irony are often read literally, especially in Dutch. Combine the answer with facts such as the stars, a refund request or a cancellation, or send strong answers from irony-prone channels to a person. See Known limits.
  • Pin a version for trends. When the alias moves to a new version, ratings can shift a little. Pin the version your dashboard started on and move the pin on purpose: see Pin versions in CI.
  • Your own topic. Point the stance pick at the change you track, such as a new delivery policy, and keep a way out for texts that do not mention it.
  • Which competitor. When you need the name, ask a pick with your competitors as options, plus none and other.