This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/camel-jbang-examples.git
The following commit(s) were added to refs/heads/main by this push:
new 480f752 ai/ticket-classifier - a local model sorts support tickets,
and the route checks its answer (#119)
480f752 is described below
commit 480f752d14984677a92f1f95317b8cc4406eff3c
Author: Claus Ibsen <[email protected]>
AuthorDate: Tue Oct 20 09:02:23 2026 +0200
ai/ticket-classifier - a local model sorts support tickets, and the route
checks its answer (#119)
A local model names the category of each support ticket, shipping,
billing or account; the prompt goes from a variable and the answer to one,
so the ticket stays the body. The route normalizes the answer, files
anything else under unknown, and a circuit breaker with a timeout does the
same when the model is slow or down. Second in the ai group, with a
Citrus YAML test on the camel infra Ollama (ciSkip, as langchain4j-chat).
Closes #124
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
---
README.md | 1 +
ai/README.md | 1 +
ai/docling-langchain4j-rag/metadata.json | 2 +-
ai/mcp-server/metadata.json | 2 +-
ai/openai-pii-redaction/metadata.json | 2 +-
ai/ticket-classifier/README.md | 93 ++++++++++++++++++++++
ai/ticket-classifier/application.properties | 23 ++++++
ai/ticket-classifier/metadata.json | 43 ++++++++++
.../test/ticket-classifier.citrus.it.yaml | 59 ++++++++++++++
ai/ticket-classifier/ticket-classifier.camel.yaml | 85 ++++++++++++++++++++
ai/ticket-classifier/tickets/T-1.json | 1 +
ai/ticket-classifier/tickets/T-2.json | 1 +
ai/ticket-classifier/tickets/T-3.json | 1 +
camel-jbang-example-catalog.json | 62 ++++++++++++++-
14 files changed, 370 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index cfe351d..a568ec3 100644
--- a/README.md
+++ b/README.md
@@ -127,6 +127,7 @@ A local model writing text, routes exposed as MCP tools,
RAG over documents, PII
| Example | What you will see | Needs |
|---|---|---|
| [LangChain4j chat](ai/langchain4j-chat/) | A local Ollama model started with
camel infra writes the shipping notification for each of the three orders; the
chat model is a bean built from properties, the prompt comes from the order,
and the log shows the reply with its token counts. | `camel infra run ollama`,
a local model: `ollama pull granite4:3b` |
+| [Ticket classifier](ai/ticket-classifier/) | A local model reads each of
three support tickets and names its category, shipping, billing or account; the
route checks the answer and files the ticket in tickets-out/<category>/, and an
answer outside the three, or no answer within the circuit breaker's timeout,
files it under unknown. | `camel infra run ollama`, a local model: `ollama pull
granite4:3b` |
| [MCP server](ai/mcp-server/) | Two routes are exposed as MCP tools,
stock_level by SKU and order_status by order id, on http://localhost:8080/mcp
with nothing but properties to switch the server on; any MCP client, a coding
agent included, can list and call them, and the log shows each call. | nothing |
| [OpenAI PII Redaction](ai/openai-pii-redaction/) | Text typed on standard
input is sent to an OpenAI-compatible model with a JSON schema that asks for
the personal identifiers redacted, and the redacted text is printed on standard
output. | an OpenAI-compatible API: set `OPENAI_API_KEY` |
| [Document Analysis with Docling and LangChain4j
RAG](ai/docling-langchain4j-rag/) | Documents dropped in a directory are
converted to Markdown by a Docling service, analysed by a local Ollama model
through langchain4j-chat, and written as a report to an output directory; an
HTTP endpoint answers questions against the latest document. | `camel infra run
docling ollama`, a local model |
diff --git a/ai/README.md b/ai/README.md
index 04d6ac5..bdccb88 100644
--- a/ai/README.md
+++ b/ai/README.md
@@ -6,6 +6,7 @@ A local model writing text, routes exposed as MCP tools, RAG
over documents, PII
| Example | What you will see | Needs |
|---|---|---|
| [LangChain4j chat](langchain4j-chat/) | A local Ollama model started with
camel infra writes the shipping notification for each of the three orders; the
chat model is a bean built from properties, the prompt comes from the order,
and the log shows the reply with its token counts. | `camel infra run ollama`,
a local model: `ollama pull granite4:3b` |
+| [Ticket classifier](ticket-classifier/) | A local model reads each of three
support tickets and names its category, shipping, billing or account; the route
checks the answer and files the ticket in tickets-out/<category>/, and an
answer outside the three, or no answer within the circuit breaker's timeout,
files it under unknown. | `camel infra run ollama`, a local model: `ollama pull
granite4:3b` |
| [MCP server](mcp-server/) | Two routes are exposed as MCP tools, stock_level
by SKU and order_status by order id, on http://localhost:8080/mcp with nothing
but properties to switch the server on; any MCP client, a coding agent
included, can list and call them, and the log shows each call. | nothing |
| [OpenAI PII Redaction](openai-pii-redaction/) | Text typed on standard input
is sent to an OpenAI-compatible model with a JSON schema that asks for the
personal identifiers redacted, and the redacted text is printed on standard
output. | an OpenAI-compatible API: set `OPENAI_API_KEY` |
| [Document Analysis with Docling and LangChain4j
RAG](docling-langchain4j-rag/) | Documents dropped in a directory are converted
to Markdown by a Docling service, analysed by a local Ollama model through
langchain4j-chat, and written as a report to an output directory; an HTTP
endpoint answers questions against the latest document. | `camel infra run
docling ollama`, a local model |
diff --git a/ai/docling-langchain4j-rag/metadata.json
b/ai/docling-langchain4j-rag/metadata.json
index 12a130b..3a39cc1 100644
--- a/ai/docling-langchain4j-rag/metadata.json
+++ b/ai/docling-langchain4j-rag/metadata.json
@@ -2,7 +2,7 @@
"title": "Document Analysis with Docling and LangChain4j RAG",
"description": "Documents dropped in a directory are converted to Markdown
by a Docling service, analysed by a local Ollama model through
langchain4j-chat, and written as a report to an output directory; an HTTP
endpoint answers questions against the latest document.",
"level": "ai",
- "order": 4,
+ "order": 5,
"teaches": {
"components": [
"file",
diff --git a/ai/mcp-server/metadata.json b/ai/mcp-server/metadata.json
index 3ccae52..321d3c1 100644
--- a/ai/mcp-server/metadata.json
+++ b/ai/mcp-server/metadata.json
@@ -2,7 +2,7 @@
"title": "MCP server",
"description": "Two routes are exposed as MCP tools, stock_level by SKU
and order_status by order id, on http://localhost:8080/mcp with nothing but
properties to switch the server on; any MCP client, a coding agent included,
can list and call them, and the log shows each call.",
"level": "ai",
- "order": 2,
+ "order": 3,
"teaches": {
"components": [
"ai-tool",
diff --git a/ai/openai-pii-redaction/metadata.json
b/ai/openai-pii-redaction/metadata.json
index 7553b07..6a45e1e 100644
--- a/ai/openai-pii-redaction/metadata.json
+++ b/ai/openai-pii-redaction/metadata.json
@@ -2,7 +2,7 @@
"title": "OpenAI PII Redaction",
"description": "Text typed on standard input is sent to an
OpenAI-compatible model with a JSON schema that asks for the personal
identifiers redacted, and the redacted text is printed on standard output.",
"level": "ai",
- "order": 3,
+ "order": 4,
"teaches": {
"components": [
"stream",
diff --git a/ai/ticket-classifier/README.md b/ai/ticket-classifier/README.md
new file mode 100644
index 0000000..6f87bb9
--- /dev/null
+++ b/ai/ticket-classifier/README.md
@@ -0,0 +1,93 @@
+# Ticket classifier
+
+A local model sorts the support tickets of the shop. It names the category of
each ticket, shipping, billing or
+account, and the route files the ticket in that folder. The route does not
trust the answer: anything else, or no
+answer in time, files the ticket under `unknown`.
+
+## What you will see
+
+```text
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-1: My order ORD-1001 has
not arrived yet. It is two weeks now, where is my parcel?
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-1 is shipping
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-2: I was charged twice for
order ORD-1002, please refund one payment.
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-2 is billing
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-3: How do I change the
email address and the password of my account?
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-3 is account
+```
+
+and the tickets in `tickets-out/shipping/T-1.json`,
`tickets-out/billing/T-2.json` and
+`tickets-out/account/T-3.json`. A model can answer differently, so your
categories may differ.
+
+## Install Camel CLI
+
+Install [JBang](https://www.jbang.dev/download/) and the Camel CLI as
described in the
+[root README](../../README.md#install-the-camel-cli); `camel --version`
confirms the install.
+
+## Run it
+
+The example needs a local model. Either start Ollama in a container with the
Camel CLI (Docker or Podman must be
+running), which pulls the model `granite4:3b` after it starts:
+
+```shell
+camel infra run ollama
+```
+
+or use an Ollama installed on the machine, with `ollama pull granite4:3b`
once. Both answer at
+http://localhost:11434, as `application.properties` says. Then:
+
+```shell
+camel run *
+```
+
+Stop the example with `ctrl` + `c`, and the container with `camel infra stop
ollama`.
+
+## How it works
+
+- The `beans` entry builds the chat model, an `OllamaChatModel`, from
`application.properties`. Temperature 0 makes
+ the answers as steady as the model allows.
+- The route builds the prompt in the variable `prompt` and calls
`langchain4j-chat` with `variableSend: prompt` and
+ `variableReceive: answer`. The ticket stays the body.
+- The answer is normalized to lower-case letters only, so "Shipping." becomes
`shipping`.
+- `circuitBreaker` puts a timeout of 60 seconds around the call. `onFallback`
sets the category to `unknown`, so a
+ slow or stopped model does not stop the tickets.
+- The `filter` treats any category other than the three as `unknown`: a model
can answer anything.
+- `CamelFileName` with a folder,
`${variable.category}/${body[ticketId]}.json`, makes the file producer write the
+ ticket into the folder of its category.
+
+## Build it step by step
+
+Ask your assistant, or type it yourself, one step at a time, and run after
each:
+
+1. A file route on `tickets` that unmarshals each ticket and logs its id and
text.
+2. Ask the local model for the category of each ticket (shipping, billing or
account) with `langchain4j-chat`,
+ keep the answer in a variable, and log it.
+3. File each ticket in `tickets-out/<category>/`, and under `unknown` when the
answer is not one of the three.
+4. Put the model call in a circuit breaker with a timeout, and file the ticket
under `unknown` on fallback.
+
+## Try changing
+
+- Add a ticket that fits none of the categories, such as a question about the
shop's opening hours.
+- Stop Ollama while the example runs and drop in a new ticket: it is filed
under `unknown` after the timeout.
+- Try another model with `ollama.model` and compare the categories.
+
+## Integration testing
+
+The example comes with a test in the [Citrus](https://citrusframework.org/)
YAML DSL,
+`test/ticket-classifier.citrus.it.yaml`, which the Camel CLI runs:
+
+```shell
+camel test run test/ticket-classifier.citrus.it.yaml
+```
+
+The test starts Ollama in a container on a side port, waits until the model is
pulled, runs the route and checks
+that the tickets are filed. It needs a local model, so CI skips it.
+
+## Help and contributions
+
+If you hit any problem using Camel or have some feedback, then please
+[let us know](https://camel.apache.org/community/support/).
+
+We also love contributors, so
+[get involved](https://camel.apache.org/community/contributing/) :-)
+
+The Camel riders!
diff --git a/ai/ticket-classifier/application.properties
b/ai/ticket-classifier/application.properties
new file mode 100644
index 0000000..c2c5399
--- /dev/null
+++ b/ai/ticket-classifier/application.properties
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The local model: camel infra run ollama, or an Ollama installed on the
machine
+ollama.base.url=http://localhost:11434
+ollama.model=granite4:3b
+
+# tickets-out/ is written by the example: dev mode does not watch it
+camel.jbang.dataDir=tickets-out
diff --git a/ai/ticket-classifier/metadata.json
b/ai/ticket-classifier/metadata.json
new file mode 100644
index 0000000..e8e0ccd
--- /dev/null
+++ b/ai/ticket-classifier/metadata.json
@@ -0,0 +1,43 @@
+{
+ "title": "Ticket classifier",
+ "description": "A local model reads each of three support tickets and
names its category, shipping, billing or account; the route checks the answer
and files the ticket in tickets-out/<category>/, and an answer outside the
three, or no answer within the circuit breaker's timeout, files it under
unknown.",
+ "level": "ai",
+ "order": 2,
+ "teaches": {
+ "components": [
+ "langchain4j-chat",
+ "file",
+ "log"
+ ],
+ "eips": [
+ "circuitBreaker",
+ "filter",
+ "setVariable",
+ "setHeader",
+ "unmarshal",
+ "marshal"
+ ],
+ "languages": [
+ "simple",
+ "constant"
+ ],
+ "dataformats": [
+ "json"
+ ]
+ },
+ "tags": [
+ "shop",
+ "ai",
+ "llm",
+ "ollama",
+ "langchain4j",
+ "classification"
+ ],
+ "infraServices": [
+ "ollama"
+ ],
+ "needs": "a local model: `ollama pull granite4:3b`",
+ "ciSkip": true,
+ "requiresDocker": true,
+ "bundled": false
+}
diff --git a/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml
b/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml
new file mode 100644
index 0000000..24c0562
--- /dev/null
+++ b/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml
@@ -0,0 +1,59 @@
+name: ticket-classifier-test
+description: A local model sorts the support tickets
+actions:
+ - camel:
+ infra:
+ run:
+ service: ollama
+ # a side port, so an Ollama already installed on the machine does
not get in the way
+ port: 11435
+ # the container pulls the model after it starts; wait until Ollama lists it
+ - repeatOnError:
+ until: i >= 60
+ index: i
+ autoSleep: 5000
+ actions:
+ - http:
+ client: "http://localhost:11435"
+ sendRequest:
+ GET:
+ path: "/api/tags"
+ - http:
+ client: "http://localhost:11435"
+ receiveResponse:
+ response:
+ status: "200"
+ validate:
+ - jsonPath:
+ - expression: "$.models[?(@.name=='granite4:3b')].name"
+ value: "granite4:3b"
+ - camel:
+ jbang:
+ run:
+ integration:
+ name: "ticket-classifier"
+ file: "../ticket-classifier.camel.yaml"
+ # the properties file would win over an inline property, so all of
them are given here
+ systemProperties:
+ properties:
+ - name: ollama.base.url
+ value: http://localhost:11435
+ - name: ollama.model
+ value: granite4:3b
+ - name: camel.jbang.dataDir
+ value: tickets-out
+ resources:
+ - "tickets/T-1.json"
+ - "tickets/T-2.json"
+ - "tickets/T-3.json"
+ # the model's answer varies: the test checks that every ticket is filed, not
the category
+ - camel:
+ jbang:
+ verify:
+ integration: "ticket-classifier"
+ logMessage: "Ticket T-1 is "
+ - camel:
+ jbang:
+ verify:
+ integration: "ticket-classifier"
+ logMessage: "Ticket T-3 is "
diff --git a/ai/ticket-classifier/ticket-classifier.camel.yaml
b/ai/ticket-classifier/ticket-classifier.camel.yaml
new file mode 100644
index 0000000..d5904a1
--- /dev/null
+++ b/ai/ticket-classifier/ticket-classifier.camel.yaml
@@ -0,0 +1,85 @@
+# A local model sorts the support tickets of the shop: it names the category
of each ticket, shipping, billing or
+# account, and the ticket is filed in that folder. Any other answer, or no
answer in time, files it under unknown.
+# Start the model with: camel infra run ollama
+- beans:
+ - name: chatModel
+ type: dev.langchain4j.model.ollama.OllamaChatModel
+ builderClass:
dev.langchain4j.model.ollama.OllamaChatModel$OllamaChatModelBuilder
+ builderMethod: build
+ properties:
+ baseUrl: "{{ollama.base.url}}"
+ modelName: "{{ollama.model}}"
+ temperature: 0.0
+
+- route:
+ id: classify-tickets
+ from:
+ uri: file
+ parameters:
+ directoryName: tickets
+ noop: true
+ sortBy: "file:name"
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Ticket ${body[ticketId]}: ${body[text]}"
+ # the model call; when the model is slow or down, the fallback files
the ticket under unknown
+ - circuitBreaker:
+ resilience4jConfiguration:
+ timeoutEnabled: true
+ timeoutDuration: 60000
+ steps:
+ - setVariable:
+ name: prompt
+ expression:
+ simple:
+ expression: >-
+ You sort the support tickets of a web shop. Answer
with one word only, the category of the
+ ticket: shipping, billing or account. Ticket:
${body[text]}
+ # the prompt is sent from a variable and the answer goes to one,
so the ticket stays the body
+ - to:
+ uri: langchain4j-chat
+ parameters:
+ chatId: tickets
+ chatModel: "#chatModel"
+ variableSend: prompt
+ variableReceive: answer
+ - setVariable:
+ name: category
+ expression:
+ simple:
+ expression:
"${variable.answer.toLowerCase().replaceAll('[^a-z]', '')}"
+ onFallback:
+ steps:
+ - setVariable:
+ name: category
+ expression:
+ constant:
+ expression: unknown
+ # the model may answer anything: only the three categories are folders
+ - filter:
+ expression:
+ simple:
+ expression: "${variable.category} not in
'shipping,billing,account'"
+ steps:
+ - setVariable:
+ name: category
+ expression:
+ constant:
+ expression: unknown
+ - log:
+ message: "Ticket ${body[ticketId]} is ${variable.category}"
+ - setHeader:
+ name: CamelFileName
+ expression:
+ simple:
+ expression: "${variable.category}/${body[ticketId]}.json"
+ - marshal:
+ json:
+ library: Jackson
+ - to:
+ uri: file
+ parameters:
+ directoryName: tickets-out
diff --git a/ai/ticket-classifier/tickets/T-1.json
b/ai/ticket-classifier/tickets/T-1.json
new file mode 100644
index 0000000..48609de
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-1.json
@@ -0,0 +1 @@
+{"ticketId": "T-1", "customer": "C-482", "text": "My order ORD-1001 has not
arrived yet. It is two weeks now, where is my parcel?"}
diff --git a/ai/ticket-classifier/tickets/T-2.json
b/ai/ticket-classifier/tickets/T-2.json
new file mode 100644
index 0000000..d741186
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-2.json
@@ -0,0 +1 @@
+{"ticketId": "T-2", "customer": "C-207", "text": "I was charged twice for
order ORD-1002, please refund one payment."}
diff --git a/ai/ticket-classifier/tickets/T-3.json
b/ai/ticket-classifier/tickets/T-3.json
new file mode 100644
index 0000000..0a6f313
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-3.json
@@ -0,0 +1 @@
+{"ticketId": "T-3", "customer": "C-134", "text": "How do I change the email
address and the password of my account?"}
diff --git a/camel-jbang-example-catalog.json b/camel-jbang-example-catalog.json
index 3c7904d..63ecd5c 100644
--- a/camel-jbang-example-catalog.json
+++ b/camel-jbang-example-catalog.json
@@ -44,7 +44,7 @@
],
"needs": "a local model",
"ciSkip": true,
- "order": 4
+ "order": 5
},
{
"name": "ai/langchain4j-chat",
@@ -138,7 +138,7 @@
"orders/order-1003.json",
"stock.json"
],
- "order": 2
+ "order": 3
},
{
"name": "ai/openai-pii-redaction",
@@ -169,7 +169,63 @@
],
"needs": "an OpenAI-compatible API: set `OPENAI_API_KEY`",
"ciSkip": true,
- "order": 3
+ "order": 4
+ },
+ {
+ "name": "ai/ticket-classifier",
+ "title": "Ticket classifier",
+ "description": "A local model reads each of three support tickets and
names its category, shipping, billing or account; the route checks the answer
and files the ticket in tickets-out/<category>/, and an answer outside the
three, or no answer within the circuit breaker's timeout, files it under
unknown.",
+ "level": "ai",
+ "teaches": {
+ "components": [
+ "langchain4j-chat",
+ "file",
+ "log"
+ ],
+ "eips": [
+ "circuitBreaker",
+ "filter",
+ "setVariable",
+ "setHeader",
+ "unmarshal",
+ "marshal"
+ ],
+ "languages": [
+ "simple",
+ "constant"
+ ],
+ "dataformats": [
+ "json"
+ ]
+ },
+ "tags": [
+ "shop",
+ "ai",
+ "llm",
+ "ollama",
+ "langchain4j",
+ "classification"
+ ],
+ "bundled": false,
+ "requiresDocker": true,
+ "hasCitrusTests": true,
+ "files": [
+ "README.md",
+ "application.properties",
+ "ticket-classifier.camel.yaml",
+ "tickets-out/account/T-3.json",
+ "tickets-out/billing/T-2.json",
+ "tickets-out/shipping/T-1.json",
+ "tickets/T-1.json",
+ "tickets/T-2.json",
+ "tickets/T-3.json"
+ ],
+ "infraServices": [
+ "ollama"
+ ],
+ "needs": "a local model: `ollama pull granite4:3b`",
+ "ciSkip": true,
+ "order": 2
},
{
"name": "cloud/aws-sqs",