Summarize Support Tickets with Scikit-LLM

Make short support-ticket summaries with Scikit-LLM and check every claim against the source.

Oct 25, 202613 min readFollow

Topics You Will Master

Tell a short summary from a correct summary apart.
Use GPTSummarizer on a synthetic support conversation.
Check issue, date, amount and requested action against the source.
Send unsupported or missing facts for human review.

A support agent may need to read a long thread before helping a student. A short summary saves that reading time. But a smooth summary can also add a fact that never happened, such as a refund nobody approved.

In this blog, we will learn how to ask Scikit-LLM's GPTSummarizer for a short summary of a billing thread, using Qwen 3.8 27B on our own machine. Then we will find out how to check each important claim in that summary against the thread it came from.

The figure below shows the whole plan. The thread goes to the summarizer, and each source fact is then looked for in the draft before a person approves it.

How a Summary Is Checked for the Day 10 support-ticket example

Decide Which Facts Must Survive

Before we ask for a summary, we write down the facts it must keep. We do this first on purpose. If we read the model's answer first, it is easy to excuse a missing detail afterward.

Our thread has two messages. The student reports a problem, and the agent replies. The student gives four facts: the issue, the amount, the date and the request.

Four Source Facts for the Day 10 support-ticket example

The agent adds a fifth fact: the team will check the payment record. We keep this promise on our list because a summary can easily turn "we will check" into "refund approved". Let me tabulate the five facts for your better understanding.

Fact to keep Source speaker What the source supports
Two charges for one course Student A duplicate charge was reported.
499 rupees per charge Student The amount is 499 for each charge.
Noticed on 14 September Student This is when the student noticed it.
Request to investigate Student The student asked for a check.
Payment record will be checked Agent The agent promised a check, not a refund.

Here, we can see that each row keeps a relation, not just a word. "Noticed on 14 September" is not the same as "charged on 14 September". The thread never says when the charges happened. In the same way, "two charges of 499 rupees" is not "a total charge of 499 rupees".

A summary may use different words and still be correct. It must only keep the meaning of these five rows.

Advertisement

Setup

Every model call in this series goes through the same setup block. Day 2 explains each line. We repeat it here so this article runs on its own:

PYTHON
import os

from skllm.config import SKLLMConfig

# Default: Qwen 3.8 27B running on our own machine through Ollama
SKLLMConfig.set_gpt_url("http://localhost:11434/v1")
SKLLMConfig.set_gpt_key("ollama")
MODEL = "custom_url::qwen3.8:27b"

# Option: the free Qwen 3.8 27B on OpenRouter (uncomment these lines)
# SKLLMConfig.set_gpt_url("https://openrouter.ai/api/v1")
# SKLLMConfig.set_gpt_key(os.environ["OPENROUTER_API_KEY"])
# MODEL = "custom_url::qwen/qwen3.8-27b:free"

With Ollama, the model runs on our own computer. There is no API bill, and the key "ollama" is only a placeholder. The one-time download is ollama pull qwen3.8:27b, which is 16.5 GB.

Note

Qwen 3.8 thinks before it answers, so each call can take a while on a home machine. The thinking stays out of the text we get back. If Ollama is not running, Scikit-LLM tries 3 times and then raises a RuntimeError that says "Could not complete the operation after 3 retries".

Tip

On the OpenRouter option, the free model allows 20 requests per minute and 50 per day. Keep the key in the OPENROUTER_API_KEY environment variable and never print it.

Create the Synthetic Thread

Now, we write the thread as one string inside a list. GPTSummarizer works on a collection of texts, so even one thread goes in a list. The thread is invented for teaching, so no real student's data leaves our machine.

PYTHON
thread = [
    "Student: I bought one course, but my bank shows two charges of "
    "499 rupees. I noticed this on 14 September. Please investigate. "
    "Agent: Thanks for reporting this. We will check the payment record."
]

The agent reply promises a check. It does not promise a refund. That small difference is the main fact we will guard.

Advertisement

Ask for a Short Summary

GPTSummarizer turns each text into a shorter text. Its max_words setting goes into the prompt as "in at most 35 words". In simple words, it is a request to the model, not a hard limit. The library docstring calls it a soft limit, and nothing in the code trims the reply. The default is 15 words, so we ask for 35 to leave room for all five facts.

The 35-Word Request for the Day 10 support-ticket example

Let's see the code as below:

PYTHON
from skllm.models.gpt.text2text.summarization import GPTSummarizer

summarizer = GPTSummarizer(model=MODEL, max_words=35)

fit does nothing for a summarizer, so fit_transform() simply sends one request per text. It returns a list of summaries, and we take the first one. We also count its words, because the model may go over the limit:

PYTHON
summary = summarizer.fit_transform(thread)[0]
print(summary)
print("Word count:", len(summary.split()))

This prints the model's summary, then a line such as Word count: followed by the real count. A progress bar also appears while the request runs. The count can be above 35, since the library only asked the model to stay near it.

Check the Actual Word Count for the Day 10 support-ticket example

summary.split() splits on spaces. That is fine for this lesson. It is a count of words on the screen, not the model's token count.

Warning

Do not cut a long summary at word 35 to make it fit. The cut could remove "not yet approved" and turn a careful sentence into a false one. If a screen needs a hard limit, shorten by hand and check the meaning again.

Advertisement

Check Claims Against the Source

A string check can answer one narrow question, such as "does 499 appear?". Let's write two small checks for the amount and the date. A date can be written day first or month first, so the date check accepts both orders:

PYTHON
def amount_mentioned(text):
    return "499" in text


def date_mentioned(text):
    text = text.lower()
    forms = ["14 sep", "sep 14", "sept 14", "september 14"]
    return any(form in text for form in forms)

Before we trust these checks, we test them on four drafts we wrote by hand. Draft 3 leaves out the date. Draft 4 invents a refund:

PYTHON
test_drafts = [
    "Student reports two 499-rupee charges, noticed on 14 September.",
    "Student noticed two charges of 499 rupees on September 14.",
    "Student reports two 499-rupee charges and asks for a check.",
    "A student was refunded 499 rupees on 14 September.",
]
for draft in test_drafts:
    print(amount_mentioned(draft), date_mentioned(draft), "|", draft)
OUTPUT
True True | Student reports two 499-rupee charges, noticed on 14 September.
True True | Student noticed two charges of 499 rupees on September 14.
True False | Student reports two 499-rupee charges and asks for a check.
True True | A student was refunded 499 rupees on 14 September.

Here, we can see that both date orders pass, and the draft without a date fails. But the refund draft passes both checks. The string checks cannot tell a charge from a refund. Other forms, such as "14th September" or "14/09", would also fail the date check and need a person.

A String Check Has Limits for the Day 10 support-ticket example

Now, we run the same checks on the real summary:

PYTHON
print("499 mentioned:", amount_mentioned(summary))
print("date mentioned:", date_mentioned(summary))

Each line prints True or False. A False flags a missing fact for review. A True only means the letters are there.

So a person still reads the summary beside the thread. The reviewer checks who reported the issue, whether two charges were described, and what the student asked for. Most of all, the reviewer checks what the agent really promised.

Investigation Is Not Approval for the Day 10 support-ticket example

If the summary says a refund was approved, the reviewer looks for a source sentence that supports it. There is none, so the claim is removed or the summary is rejected. A matching amount does not rescue it.

Reject an Unsupported Refund for the Day 10 support-ticket example

Separate Missing Facts From Made-Up Claims

Our test drafts show two different errors. Draft 3 is incomplete: it leaves out the date, but every claim it makes is true. Draft 4 is wrong: it states a refund that the thread never mentions. These errors need different review notes.

So the review runs in two directions. First, we take each source fact and mark it present or missing in the draft. This catches left-out details. Then, we take each claim in the draft and point to the source sentence that supports it. This catches invented details.

Match Claims to Source for the Day 10 support-ticket example

A useful review record keeps the draft, the message IDs, the fact list and the reviewer's decision. The purpose of the summary decides which facts are required. An inbox preview may not need the date, but a payment case does. If an agent edits the draft, we save the edited version separately from the model's original.

Advertisement

Handle Long Threads in Stages

A model can read only a limited amount of text at once. A very long thread may not fit in one request. Our thread is short enough for one call, but real threads can grow.

One option is a staged summary. We split the thread into groups of messages and keep each message's ID and order. We summarize each group, then combine the parts into one draft.

Keep Message IDs in Chunks for the Day 10 support-ticket example

Every stage can lose a fact, so the final check still goes back to the full thread. Order matters too. Let's say an early message asks for a refund, and a later one says the second charge was only a bank hold. A draft built without order may repeat the old request as the current state.

Summarize an Updated Thread

A new message can change what a summary is allowed to say. Let's add a second agent reply and summarize again. Before we run it, we update our fact list: the agent found both payment entries and is still checking.

PYTHON
updated_thread = [
    thread[0] + " Agent: We found both payment entries and are still checking."
]
new_summary = summarizer.transform(updated_thread)[0]
print(new_summary)

The new summary may mention two entries and an open check. It may not say the charges were confirmed as duplicates or that a refund was issued. The word "found" can sound more final in a short summary than it did in the thread.

Check the New Draft Again for the Day 10 support-ticket example

If the new draft says a refund was issued, we point to the missing source sentence and reject that claim. We reject it even when the amount and date are correct.

Conclusion

This is how checking a Scikit-LLM summary works. We wrote the five source facts first, then asked GPTSummarizer for a 35-word draft from the local Qwen model. Finally, we tested simple string checks and saw where they stop, so a person reviews each claim against the thread.

  • max_words asks the model for a length. It does not enforce it.
  • A summary can be short and still invent an action, such as a refund.
  • String checks catch missing numbers and dates, but not a changed meaning.
  • Review in two directions: source facts present, and draft claims supported.

Next steps:

Found this useful? Keep building with me.

New tutorials every week on YouTube: or go deeper with a full structured course.

Find this tutorial useful?

Subscribe to our YouTube channels for more practical production walk-throughs.

Discussion & Comments