Skip to content

Sentiment Analysis

View as Markdown

This example analyzes customer feedback in bulk with nua.list().

You receive hundreds of customer reviews daily and need to:

  1. Classify each review’s sentiment
  2. Extract key topics mentioned
  3. Identify actionable feedback
  4. Prioritize responses
import { Nua, z } from 'nuabase';
import { findLocalAgent } from 'nuabase/local-agent';
const nua = Nua.direct({ localAgent: await findLocalAgent() });
// Define the analysis schema
const SentimentSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
score: z.number().min(-1).max(1),
topics: z.array(z.string()).max(3),
isActionable: z.boolean(),
actionRequired: z.string().optional(),
responseUrgency: z.enum(['none', 'low', 'medium', 'high']),
});
// Sample reviews
const reviews = [
{
id: 'rev-001',
text: 'Absolutely love this product! The customer service was exceptional. Already recommended to friends.',
source: 'app-store',
date: '2024-01-15'
},
{
id: 'rev-002',
text: 'App crashes every time I try to export. Very frustrating. Been waiting 3 days for support response.',
source: 'support-ticket',
date: '2024-01-15'
},
{
id: 'rev-003',
text: 'It works fine for basic tasks. Nothing special but gets the job done.',
source: 'app-store',
date: '2024-01-14'
},
{
id: 'rev-004',
text: 'The new update broke my workflow. I rely on this for my business. Please fix ASAP!',
source: 'twitter',
date: '2024-01-15'
},
{
id: 'rev-005',
text: 'Great value for the price. The team feature is a game changer for our agency.',
source: 'g2-review',
date: '2024-01-13'
}
];
async function analyzeReviews() {
const result = await nua.list(
`Analyze each customer review for sentiment and actionability.
For each review:
- Determine overall sentiment (positive/neutral/negative)
- Assign a score from -1 (very negative) to 1 (very positive)
- Extract up to 3 main topics mentioned
- Determine if action is required (bug report, complaint needing response, etc.)
- If actionable, describe what action is needed
- Assess response urgency based on sentiment and business impact`,
{
input: reviews,
primaryKey: 'id',
output: { name: 'analysis', schema: SentimentSchema }
}
);
if (!result.success) {
console.error('Analysis failed:', result.error);
return;
}
// Rows may come back in any order, so look each one up by its primary key
const reviewsById = new Map(reviews.map(r => [r.id, r]));
// Group by urgency
const urgent = result.data.filter(r => r.analysis.responseUrgency === 'high');
const actionable = result.data.filter(r => r.analysis.isActionable);
const positive = result.data.filter(r => r.analysis.sentiment === 'positive');
const negative = result.data.filter(r => r.analysis.sentiment === 'negative');
// Summary report
console.log('Customer Feedback Analysis');
console.log('='.repeat(50));
console.log(`Total reviews: ${reviews.length}`);
console.log(`Positive: ${positive.length} | Neutral: ${result.data.length - positive.length - negative.length} | Negative: ${negative.length}`);
console.log(`Urgent responses needed: ${urgent.length}`);
console.log(`Actionable feedback: ${actionable.length}`);
// Urgent items
if (urgent.length > 0) {
console.log('\n--- URGENT: Requires Immediate Response ---\n');
for (const row of urgent) {
const review = reviewsById.get(row.id)!;
console.log(`[${row.id}] ${review.source}`);
console.log(` "${review.text.slice(0, 80)}..."`);
console.log(` Action: ${row.analysis.actionRequired}`);
console.log();
}
}
// Actionable items
console.log('\n--- All Actionable Feedback ---\n');
for (const row of actionable) {
const review = reviewsById.get(row.id)!;
const a = row.analysis;
console.log(`[${row.id}] ${a.sentiment} (${a.score.toFixed(2)}) - Urgency: ${a.responseUrgency}`);
console.log(` Topics: ${a.topics.join(', ')}`);
console.log(` Action: ${a.actionRequired}`);
console.log();
}
// Stats
console.log('='.repeat(50));
console.log(`Tokens: ${result.usage.totalTokens} | Latency: ${result.latencyMs}ms`);
}
analyzeReviews();

From a run on Claude Code, with the default logger’s [INFO] lines left out. The wording of free-text fields such as the next step varies from run to run:

Customer Feedback Analysis
==================================================
Total reviews: 5
Positive: 2 | Neutral: 1 | Negative: 2
Urgent responses needed: 2
Actionable feedback: 2
--- URGENT: Requires Immediate Response ---
[rev-002] support-ticket
"App crashes every time I try to export. Very frustrating. Been waiting 3 days fo..."
Action: Investigate and fix the crash on export; respond to the customer's support ticket right away, since they have waited 3 days.
[rev-004] twitter
"The new update broke my workflow. I rely on this for my business. Please fix ASA..."
Action: Reply publicly on Twitter, find out which workflow the update broke, and prioritize a fix or rollback for this business-critical user.
--- All Actionable Feedback ---
[rev-002] negative (-0.85) - Urgency: high
Topics: app crash, export feature, support response time
Action: Investigate and fix the crash on export; respond to the customer's support ticket right away, since they have waited 3 days.
[rev-004] negative (-0.80) - Urgency: high
Topics: update regression, workflow disruption, business reliance
Action: Reply publicly on Twitter, find out which workflow the update broke, and prioritize a fix or rollback for this business-critical user.
==================================================
Tokens: 3104 | Latency: 6125ms

When you analyze reviews every day, keep the prompt string constant and pass each day’s reviews as input. Nuabase puts the prompt and schema at the start of the request, so the provider can serve that part from its prompt cache:

const result = await nua.list(ANALYZE_REVIEWS_PROMPT, {
input: todaysReviews,
primaryKey: 'id',
output: { name: 'analysis', schema: SentimentSchema },
});
if (result.success) {
const { promptTokens, promptCacheReadTokens } = result.usage;
if (promptCacheReadTokens !== undefined) {
console.log(`${promptCacheReadTokens} of ${promptTokens} prompt tokens read from the provider's cache`);
}
}

The cache fields are present only when the provider reports them. For very large batches, split the reviews into chunks; see Large arrays.

For basic sentiment without detailed analysis:

const SimpleSentimentSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
confidence: z.number().min(0).max(1),
});
const result = await nua.list("Classify sentiment", {
input: reviews,
primaryKey: 'id',
output: { name: 'sentiment', schema: SimpleSentimentSchema }
});

Analyze different aspects separately:

const AspectSentimentSchema = z.object({
overall: z.enum(['positive', 'neutral', 'negative']),
aspects: z.object({
product: z.enum(['positive', 'neutral', 'negative', 'not-mentioned']),
support: z.enum(['positive', 'neutral', 'negative', 'not-mentioned']),
pricing: z.enum(['positive', 'neutral', 'negative', 'not-mentioned']),
usability: z.enum(['positive', 'neutral', 'negative', 'not-mentioned']),
}),
});

Track competitor mentions in feedback:

const CompetitorSchema = z.object({
sentiment: z.enum(['positive', 'neutral', 'negative']),
mentionsCompetitor: z.boolean(),
competitors: z.array(z.string()).optional(),
comparisonSentiment: z.enum(['favorable', 'unfavorable', 'neutral']).optional(),
});