Skip to content
Go back

Building a Sentiment Analysis Workflow with LangChain

Edit page

Introduction

A language model can generate text from a prompt. Most real applications need more than one call.

For example:

Managing these workflows manually quickly becomes messy. The effectiveness of an AI system often depends less on the model itself and more on how well the workflow is designed.

LangChain is a framework for building LLM-powered workflows by connecting prompts, models, tools, parsers, and external systems together. Retrievers, memory, and vector stores fit into the same composition model — not just chaining multiple language models.

What we’re building here is a workflow: a LangChain pipeline with a fixed sequence of steps. It is not an autonomous agent.

Agents are a separate idea. There, the model chooses tools and next steps at runtime. We are building a fixed pipeline first, because that is the foundation everything else sits on.

In this article, we’ll build a small review-analysis workflow using LangChain and learn:

Workflow overview

The pipeline has four stages, in order:

  1. Ingest reviews — read rows from a CSV.
  2. Summarize — condense each review to one sentence (Meta Llama 4 via Groq).
  3. Classify sentiment — map the summary to positive, neutral, or negative.
  4. Generate a response — draft a short reply that matches the verdict.

Each stage feeds the next. Nothing here decides its own path.

Setup

We need three things before writing pipeline code: an inference API, a CSV of reviews, and the LangChain packages.

  • Inference: Groq hosts the model we call. Create an API key and put it in .env as GROQ_API_KEY. langchain_groq reads it automatically.
  • Data: I used Matiks app reviews in a CSV (Review column). Any similar export works — Kaggle datasets are fine too.
  • Packages: langchain_core for templates and chains; langchain_groq for the Groq chat model; pandas to read the CSV.
pip install langchain_core
pip install langchain_groq
pip install pandas

Model and data

We instantiate the Groq client, then load reviews into a list.

from langchain_groq import ChatGroq

dotenv.load_dotenv()     # To load the GROQ API key

feedback_llm = ChatGroq(model="meta-llama/llama-4-scout-17b-16e-instruct")
review_dataset : pd.DataFrame = pd.read_csv("/Users/sg/build/vectors/matiks_reviews.csv")

reviews = []

def load_reviews_in_list() -> list[str]:
    for idx, element in review_dataset.iterrows():
        reviews.append(element["Review"])
    return reviews

Prompt templates

Embedding full prompts inside f-strings works once. It does not scale when we reuse the same pattern across many reviews.

PromptTemplate keeps the instruction text separate from the Python that calls it.

For this section we only need two fields:

We’ll attach an output_parser later, when we need structured sentiment.

from langchain_core.prompts import PromptTemplate

parsed_prompt_template = PromptTemplate(input_variables=["raw_feedback"], template="Read the feedback and remove the unnecessary parts for the key information.\n Feedback: {raw_feedback}")

summary_prompt_template = PromptTemplate(input_variables=["parsed_feedback"], template="Take the parsed feedback, analyze the content and summarize this into one simple and concise sentence.\nFeedback: {parsed_feedback}")

We defined two prompt templates:

The flow is two .invoke() calls:

  1. Call .invoke() on the template to build the prompt.
  2. Pass that prompt to the LLM with another .invoke().

In LangChain, the invoke method executes a chain or component, passing an input and receiving the resulting output.

First chain by hand: parse, then summarize

Before LCEL, it helps to run the steps manually once. That makes the data flow obvious.

parsed_prompt = parsed_prompt_template.invoke({"raw_feedback": "It's kinda like Duolingo with daily streaks but it's very fun to play especially the mind snap duels 1000/10"})

parsed_result = feedback_llm.invoke(parsed_prompt)

print("parsed result: \n", parsed_result.content, "\n")

print("------------------------------------------\n")

summary_prompt = summary_prompt_template.invoke({"parsed_feedback": parsed_result.content})

summary_result = feedback_llm.invoke(summary_prompt)

print("summary result: \n", summary_result.content, "\n")

The key names in .invoke({...}) must match input_variables on the template. Our summary template expects parsed_feedback, so we pass {"parsed_feedback": ...}.

Output:

parsed result:
 The key information is:

* It's similar to Duolingo with daily streaks
* The app is fun to play, especially the "Mind Snap" duels.

I removed the rating ("1000/10") as it's not essential to understanding the feedback, and also the casual tone ("kinda", "very fun to play") to make it more concise. Let me know if you'd like me to help with anything else!

------------------------------------------

summary result:
 Here's a simple and concise summary sentence:

The app is a fun, Duolingo-like experience with engaging features like daily streaks and "Mind Snap" duels.

We now have a parsed review and a one-line summary.

Structured outputs

So far, every LLM step returned plain text. That worked for printing summaries. It will not work once we need code to make decisions.

Why plain strings are unreliable

Models answer in natural language. The wording changes every run.

If we ask for sentiment, we might get:

All three mean the same thing to a human. To Python, they are three different strings. Branching logic (if verdict == "positive") becomes fragile fast.

Why structured output matters

We want a predictable object we can trust in the next step — especially before conditional routing (thank-you vs apology templates).

For sentiment, we need:

Step 1: Define the shape with Pydantic

First, we describe that shape as a Pydantic model. This is our contract for what “valid output” looks like.

from pydantic import BaseModel
from pydantic import Field

class Feedback(BaseModel):
    verdict: str = Field(description="Single word: positive, neutral, or negative")
    description: str = Field(description="Short analysis behind the verdict")

Field(description=...) helps the model understand what belongs in each slot.

Step 2: Attach a parser

Next, we create a PydanticOutputParser bound to that model. The parser does two jobs later: tell the model what format to emit, and convert the raw reply into a Feedback instance.

from langchain_core.output_parsers import PydanticOutputParser

feedback_obj = PydanticOutputParser(pydantic_object=Feedback)

LangChain has other parsers too (StrOutputParser, comma-separated lists, and more — see the docs). For this pipeline, Pydantic is the right fit.

Step 3: Add format instructions to the prompt

The model will not guess our JSON shape reliably unless we show it the rules.

get_format_instructions() returns that rules text. We inject it into the prompt via partial_variables — values fixed when the template is built, separate from the review text we pass at runtime.

sentiment_prompt_template = PromptTemplate(
    input_variables=["summary_feedback"],
    template=(
        "Read the feedback. Follow these format instructions:\n"
        "{format_instruction}\n\n"
        "Classify sentiment for a mental math app (Matiks). "
        "Verdict must be one word: positive, negative, or neutral.\n\n"
        "{summary_feedback}"
    ),
    partial_variables={"format_instruction": feedback_obj.get_format_instructions()},
    output_parser=feedback_obj,
)

After the LLM responds, we can call feedback_obj.parse(...) to get a typed Feedback object. More on parse() here.

Step 4: Run it

We pass our summary string in, invoke the LLM, and inspect the result.

sentiment_prompt = sentiment_prompt_template.invoke({"summary_feedback": summary_result.content})

sentiment_res = feedback_llm.invoke(sentiment_prompt)

print("Sentiment Result:\n", sentiment_res.content, "\n")

Output:

Sentiment Result:
 Based on the provided feedback, I analyzed the sentiment and here is the output in the required JSON format:

{
  "verdict": "positive",
  "description": "The feedback is positive because it mentions the app is 'fun and engaging' and highlights a unique feature 'Mind Snap' duels and motivating daily streaks, indicating a favorable impression."
}

We now have structured sentiment we can feed into the routing step later.

Chaining with LCEL

So far, each step meant manual glue code: save one output, reshape it, pass it to the next template, invoke again. That gets tiring fast as steps grow.

Each intermediate step followed the same pattern: read .content, build the next dict, call .invoke() again. LangChain Expression Language (LCEL) expresses that same flow as one composed pipeline.

A runnable is any step with an input and an output — a template, an LLM, a parser, or a small Python function wrapped for the chain.

The mental model matches a Unix pipe: output from stage n becomes input to stage n + 1. In LCEL we write that with |.

Before chaining, the manual flow looked like this:

  1. Run parsed_prompt_template → get a prompt.
  2. Send it to the LLM → get parsed text.
  3. Feed that into summary_prompt_template → get another prompt.
  4. Send it to the LLM again → get a summary string.
  5. Later, run the sentiment template → parse into a Feedback model.

LCEL collapses that into one pipeline. Below is our summary chain:

parse_to_summary_runnable : RunnableLambda = RunnableLambda(lambda output: {"parsed_feedback": output.content})

summary_chain = parsed_prompt_template | feedback_llm | parse_to_summary_runnable | summary_prompt_template | feedback_llm | StrOutputParser()

summary_chain_result : str = summary_chain.invoke({"raw_feedback": "It's kinda like Duolingo with daily streaks but it's very fun to play especially the mind snap duels 1000/10"})

print("Summary: \n", summary_chain_result, "\n")

The LLM returns an object, but the summary template expects parsed_feedback. We bridge that gap with parse_to_summary_runnable — a RunnableLambda that reshapes the output.

RunnableLambda wraps a plain Python function so it can sit in a chain. We use it when LangChain’s built-in steps do not match the shape we need — here, renaming output to parsed_feedback, and later parsing JSON into Feedback.

For sentiment, we need another bridge: parse LLM text into a Feedback object. Plain Python functions do not plug into chains directly — we wrap them in RunnableLambda.

Outputs:

Summary:
 The app is a fun, Duolingo-like platform with daily streaks and a highlight feature called "Mind Snap" duels.

We give sentiment its own chain for readability. We could merge it into the summary chain, but splitting keeps each pipeline easy to follow.

feedback_runnable = RunnableLambda(lambda output: feedback_obj.parse(output.content))

sentiment_chain = sentiment_prompt_template | feedback_llm | feedback_runnable

sentiment_chain_result : Feedback = sentiment_chain.invoke({"summary_feedback": summary_chain_result})

print("Sentiment: \n", sentiment_chain_result.verdict, "\n")

Output:

Summary:
 The app is a fun, Duolingo-like experience with daily streaks and engaging "Mind Snap" duels.

Sentiment:
 positive

Conditional routing

Now that sentiment is structured, we can route the workflow based on the verdict. The last stage uses it to pick a reply template.

We define three prompts:

A small Python function reads feedback.verdict and returns the template to use. That function becomes another runnable in the chain.

thank_you_template = PromptTemplate(template="Read the feedback, and construct a small personalized thank you message to the user.\nfeedback: {feedback}", input_variables=["feedback"])

neutral_template = PromptTemplate(template="Read the feedback, and construct a small message to the user thanking them for the feedback, assuring them to work on making the product better.\nfeedback: {feedback}", input_variables=["feedback"])

apologies_template = PromptTemplate(template="Read the feedback, and construct a small message to the user apologizing for the negative experience, assuring them we'll take it up and fix it as soon as possible.\nfeedback: {feedback}", input_variables=["feedback"])

def feedback_message_selector(feedback: Feedback):
    print("Feedback verdict: \n", feedback.verdict, "\n")

    if feedback.verdict.lower() == "negative":
        selected_template = apologies_template
    elif feedback.verdict.lower() == "positive":
        selected_template = thank_you_template
    else:
        selected_template = neutral_template

    return selected_template

We update the sentiment chain to:

sentiment_chain = sentiment_prompt_template | feedback_llm | feedback_runnable | feedback_message_selector | feedback_llm | StrOutputParser()

sentiment_chain_result = sentiment_chain.invoke({"summary_feedback": summary_chain_result})

print("Review response: \n", sentiment_chain_result, "\n")

Output:

Summary:
 The app is a fun, Duolingo-like platform with daily streaks and engaging features like "Mind Snap" duels.

Feedback verdict:
 positive

Review response:
 Here's a personalized thank you message:

"Thank you so much for taking the time to share your thoughts! We're thrilled to hear that you're enjoying our app and finding it engaging. We're glad we could provide a great experience that's comparable to other popular language learning platforms like Duolingo. Your feedback means a lot to us, and we're excited to keep improving and bringing you more fun and interactive content!"

Running the pipeline on every review

The pieces are in place. The last step is a loop: one raw review in, summary and reply out.

review_dataset : pd.DataFrame = pd.read_csv("/Users/sg/build/vectors/matiks_reviews.csv")

reviews = []

def load_reviews_in_list() -> list[str]:
    for idx, element in review_dataset.iterrows():
        reviews.append(element["Review"])
    return reviews

load_reviews_in_list()

for feedback in reviews:
    print("------------------------------------------")
    summary_chain_result : str = summary_chain.invoke({"raw_feedback": feedback})
    print("summary chain: \n", summary_chain_result, "\n")
    sentiment_analysis_result = sentiment_chain.invoke({"summary_feedback": summary_chain_result})
    print("sentiment response: \n", sentiment_analysis_result, "\n")
    print("------------------------------------------")

Output:

------------------------------------------
summary chain:
 The user provided feedback on a mobile app after playing two games, enjoying the puzzle game, but noted they had only explored a small portion of the app's content.

Feedback verdict:
 positive

sentiment response:
 Here is a small personalized thank you message:

"Thank you so much for your wonderful feedback! We're thrilled to hear that you found Matiks useful and enjoyable - it means the world to us! We appreciate your positive experience and look forward to helping you again in the future."

------------------------------------------
------------------------------------------
summary chain:
 The app is a fun, Duolingo-like platform with daily streaks and engaging features, particularly the "Mind Snap" duels.

Feedback verdict:
 positive

sentiment response:
 Here's a personalized thank you message:

"Thank you so much for your wonderful feedback! We're thrilled to hear that you find Matiks fun and beneficial for sharpening your mental math skills. We're passionate about helping people like you combat brainrot and build a stronger math foundation. Keep practicing and we're glad to have you on board!"

------------------------------------------
------------------------------------------
summary chain:
 The game makes a boring day feel exciting and is adrenaline-inducing.

Feedback verdict:
 positive

sentiment response:
 Here's a personalized thank you message:

"Thank you so much for taking the time to share your thoughts! We're thrilled to hear that our app has had a significant impact on your mental math skills and that you're enjoying using it. Your feedback means the world to us, and we're grateful to have users like you who appreciate our efforts. Keep practicing and we're excited to see your continued progress!"

------------------------------------------
...

What we covered

Walking through one review by hand, then composing chains, mirrors how most LangChain projects grow:

  1. Prompt templates — separate instructions from Python control flow.
  2. Structured outputs — Pydantic + parsers when free text is not enough for downstream code.
  3. LCEL chains — one .invoke() for a multi-step pipeline.
  4. RunnableLambda — small adapters when step boundaries do not line up.

None of this requires an autonomous agent loop. It is sequential data processing with an LLM in the middle — which is still how many production features are built.

If you’d like a deeper dive into any section, feel free to share feedback.

Thanks for reading, and see you in the next one.

— Saksham


Edit page
Share this post on:

Next Post
Creating Vector Embeddings for Textual Data Using BERT From Scratch