add and apply llm intent classifier
Browse files- Dockerfile +3 -0
- classifier/classifier.py +69 -0
- classifier/requirements.txt +10 -0
- config.yml +60 -1
- custom_components/llm_intent_classifier_client.py +114 -0
- requirements.txt +4 -6
Dockerfile
CHANGED
@@ -19,6 +19,9 @@ RUN rasa train
|
|
19 |
COPY requirements.txt /app/requirements.txt
|
20 |
RUN pip install --no-cache-dir -r requirements.txt
|
21 |
|
|
|
|
|
|
|
22 |
# 11) Copy your Gradio wrapper
|
23 |
COPY app.py /app/app.py
|
24 |
|
|
|
19 |
COPY requirements.txt /app/requirements.txt
|
20 |
RUN pip install --no-cache-dir -r requirements.txt
|
21 |
|
22 |
+
RUN pip install --no-cache-dir numpy==1.19.5
|
23 |
+
RUN pip show numpy
|
24 |
+
|
25 |
# 11) Copy your Gradio wrapper
|
26 |
COPY app.py /app/app.py
|
27 |
|
classifier/classifier.py
ADDED
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
from fastapi import FastAPI
|
3 |
+
from pydantic import BaseModel
|
4 |
+
from typing import List
|
5 |
+
import os
|
6 |
+
from string import Formatter
|
7 |
+
|
8 |
+
import os
|
9 |
+
|
10 |
+
import outlines
|
11 |
+
from outlines.models import openai
|
12 |
+
from outlines.generate import choice
|
13 |
+
|
14 |
+
# Configure logger
|
15 |
+
tools = logging.getLogger("classifier")
|
16 |
+
tools.setLevel(logging.DEBUG)
|
17 |
+
ch = logging.StreamHandler()
|
18 |
+
ch.setLevel(logging.DEBUG)
|
19 |
+
formatter = logging.Formatter("%(levelname)s:%(name)s:%(message)s")
|
20 |
+
ch.setFormatter(formatter)
|
21 |
+
tools.addHandler(ch)
|
22 |
+
|
23 |
+
# Configure logger
|
24 |
+
logging.basicConfig(
|
25 |
+
format="%(asctime)s %(levelname)s:%(name)s: %(message)s",
|
26 |
+
level=logging.DEBUG,
|
27 |
+
)
|
28 |
+
logger = logging.getLogger("classifier")
|
29 |
+
|
30 |
+
app = FastAPI()
|
31 |
+
|
32 |
+
# Pydantic model for incoming requests; prompt_template added
|
33 |
+
class Req(BaseModel):
|
34 |
+
message: str
|
35 |
+
model_name: str
|
36 |
+
base_url: str
|
37 |
+
class_set: List[str]
|
38 |
+
prompt_template: str # template with {message} placeholder
|
39 |
+
|
40 |
+
class Resp(BaseModel):
|
41 |
+
result: str
|
42 |
+
|
43 |
+
# Helper for safe formatting of {message} only
|
44 |
+
class SafeFormatDict(dict):
|
45 |
+
def __missing__(self, key):
|
46 |
+
return '{' + key + '}'
|
47 |
+
|
48 |
+
@app.post("/classify", response_model=Resp)
|
49 |
+
def classify(req: Req):
|
50 |
+
logger.debug(f"Received request args: {req.dict()}")
|
51 |
+
|
52 |
+
prompt = req.prompt_template.replace("{message}", req.message)
|
53 |
+
logger.debug(f"Rendered prompt: {prompt!r}")
|
54 |
+
|
55 |
+
api_key = os.getenv("TOGETHERAI_API_KEY")
|
56 |
+
logger.debug(f"Using API_KEY: {'set' if api_key else 'missing'}")
|
57 |
+
llm = openai(req.model_name, api_key=api_key, base_url=req.base_url)
|
58 |
+
clf = choice(llm, req.class_set)
|
59 |
+
logger.debug(f"Choice classifier created with labels: {req.class_set}")
|
60 |
+
|
61 |
+
try:
|
62 |
+
result = clf(prompt)
|
63 |
+
# If it's a coroutine, run it; otherwise use result
|
64 |
+
logger.debug(f"Classifier returned: {result}")
|
65 |
+
except Exception as e:
|
66 |
+
result = req.class_set[-1]
|
67 |
+
logger.error(f"Classification error: {e}. Falling back to: {result}")
|
68 |
+
|
69 |
+
return Resp(result=result)
|
classifier/requirements.txt
ADDED
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
fastapi
|
2 |
+
uvicorn
|
3 |
+
outlines==0.2.1
|
4 |
+
|
5 |
+
# Force CPU-only torch
|
6 |
+
torch==2.0.1+cpu
|
7 |
+
# (and any other torch-based libs you need, also +cpu)
|
8 |
+
openai
|
9 |
+
# Tell pip where to find the +cpu variants
|
10 |
+
--extra-index-url https://download.pytorch.org/whl/cpu
|
config.yml
CHANGED
@@ -6,7 +6,66 @@ pipeline:
|
|
6 |
# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model.
|
7 |
# # If you'd like to customize it, uncomment and adjust the pipeline.
|
8 |
# # See https://rasa.com/docs/rasa/tuning-your-model for more information.
|
9 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
10 |
- name: FallbackClassifier
|
11 |
threshold: 0.7
|
12 |
|
|
|
6 |
# # No configuration for the NLU pipeline was provided. The following default pipeline was used to train your model.
|
7 |
# # If you'd like to customize it, uncomment and adjust the pipeline.
|
8 |
# # See https://rasa.com/docs/rasa/tuning-your-model for more information.
|
9 |
+
#- name: "KeywordIntentClassifier"
|
10 |
+
- name: custom_components.llm_intent_classifier_client.LlmIntentClassifier
|
11 |
+
# these two match the `defaults` keys in your component
|
12 |
+
model_name: "Qwen/Qwen2.5-7B-Instruct-Turbo"
|
13 |
+
base_url: "https://api.together.xyz/v1"
|
14 |
+
class_set:
|
15 |
+
- ReversePhoneLookup
|
16 |
+
- CallReservationHotline
|
17 |
+
- out_of_scope
|
18 |
+
# Override the built-in prompt template:
|
19 |
+
prompt_template: |
|
20 |
+
你是一個專門分類醫院電話總機使用者訊息的代理,需將訊息分為「ReversePhoneLookup」、「CallReservationHotline」或「out_of_scope」三類。
|
21 |
+
|
22 |
+
**定義**:
|
23 |
+
- 「ReversePhoneLookup」:病人詢問未接來電相關的訊息,通常是醫院曾撥打電話給病人但病人未接到,病人回電詢問來電者身份或來電目的。包含「未接」「錯過」「漏接」「誰打來」「什麼事」等關鍵詞。
|
24 |
+
|
25 |
+
- 「CallReservationHotline」:病人需要進行掛號預約或取消看診相關的訊息,包含預約門診、取消預約、更改看診時間、掛號等醫療預約相關事宜。
|
26 |
+
|
27 |
+
- 「out_of_scope」:不屬於上述兩類的訊息,包括:
|
28 |
+
- 詢問醫院一般資訊(營業時間、地址、科別等)
|
29 |
+
- 查詢檢查報告
|
30 |
+
- 轉接特定科別或醫師
|
31 |
+
- 其他非預約且非未接來電查詢的事宜
|
32 |
+
|
33 |
+
**示例**:
|
34 |
+
- ReversePhoneLookup:
|
35 |
+
- 「我剛才有未接來電,想知道是誰打來的?」
|
36 |
+
- 「請問剛才是醫院打電話給我嗎?有什麼事嗎?」
|
37 |
+
- 「我錯過了一通電話,可以告訴我是什麼事情嗎?」
|
38 |
+
- 「有人打電話給我但我沒接到,請問是關於什麼的?」
|
39 |
+
- 「我看到有未接來電,請問找我有什麼事?」
|
40 |
+
|
41 |
+
- CallReservationHotline:
|
42 |
+
- 「我想要預約心臟科的門診。」
|
43 |
+
- 「請幫我掛號看眼科。」
|
44 |
+
- 「我要取消明天下午的預約。」
|
45 |
+
- 「可以幫我更改看診時間嗎?」
|
46 |
+
- 「我想預約健康檢查。」
|
47 |
+
- 「需要重新安排我的復診時間。」
|
48 |
+
|
49 |
+
- out_of_scope:
|
50 |
+
- 「請問醫院的營業時間是什麼時候?」
|
51 |
+
- 「我想查詢我的檢查報告結果。」
|
52 |
+
- 「請幫我轉接腸胃科。」
|
53 |
+
- 「請問醫院地址在哪裡?」
|
54 |
+
- 「我想詢問住院相關事宜。」
|
55 |
+
- 「請問有哪些科別?」
|
56 |
+
|
57 |
+
**指令**:
|
58 |
+
- 請專注於訊息的主要意圖
|
59 |
+
- 若訊息涉及未接來電、錯過來電或詢問來電者身份,歸類為「ReversePhoneLookup」
|
60 |
+
- 若訊息涉及預約、掛號、取消或更改看診時間,歸類為「CallReservationHotline」
|
61 |
+
- 其他查詢、轉接或一般資訊需求歸類為「out_of_scope」
|
62 |
+
|
63 |
+
你必須僅回傳以下三種 JSON 物件之一:
|
64 |
+
{{"result": "ReversePhoneLookup"}} 或 {{"result": "CallReservationHotline"}} 或 {{"result": "out_of_scope"}}
|
65 |
+
|
66 |
+
請勿添加任何解釋或其他內容。
|
67 |
+
|
68 |
+
訊息:{message}
|
69 |
- name: FallbackClassifier
|
70 |
threshold: 0.7
|
71 |
|
custom_components/llm_intent_classifier_client.py
ADDED
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import logging
|
2 |
+
import requests
|
3 |
+
from typing import Any, Dict, List, Optional, Text
|
4 |
+
|
5 |
+
from rasa.nlu.classifiers.classifier import IntentClassifier
|
6 |
+
from rasa.shared.nlu.constants import TEXT, INTENT
|
7 |
+
from rasa.nlu.config import RasaNLUModelConfig
|
8 |
+
from rasa.shared.nlu.training_data.training_data import TrainingData
|
9 |
+
from rasa.shared.nlu.training_data.message import Message
|
10 |
+
from rasa.nlu.model import Metadata
|
11 |
+
|
12 |
+
logger = logging.getLogger(__name__)
|
13 |
+
|
14 |
+
|
15 |
+
class LlmIntentClassifier(IntentClassifier):
|
16 |
+
"""Delegates intent classification to an external HTTP micro-service."""
|
17 |
+
|
18 |
+
name = "LlmIntentClassifier"
|
19 |
+
defaults = {
|
20 |
+
"classifier_url": "http://classifier:8000/classify",
|
21 |
+
"timeout": 5.0,
|
22 |
+
"model_name": None,
|
23 |
+
"base_url": None,
|
24 |
+
"class_set": [],
|
25 |
+
"prompt_template": None,
|
26 |
+
}
|
27 |
+
|
28 |
+
def __init__(
|
29 |
+
self,
|
30 |
+
component_config: Optional[Dict[Text, Any]] = None,
|
31 |
+
) -> None:
|
32 |
+
super().__init__(component_config or {})
|
33 |
+
self.url: str = self.component_config.get("classifier_url")
|
34 |
+
self.timeout: float = float(self.component_config.get("timeout"))
|
35 |
+
self.model_name: Optional[Text] = self.component_config.get("model_name")
|
36 |
+
self.base_url: Optional[Text] = self.component_config.get("base_url")
|
37 |
+
self.class_set: List[Text] = self.component_config.get("class_set", [])
|
38 |
+
self.prompt_template: Optional[Text] = self.component_config.get("prompt_template")
|
39 |
+
|
40 |
+
# Validate required configuration
|
41 |
+
missing: List[str] = []
|
42 |
+
if not self.model_name:
|
43 |
+
missing.append("model_name")
|
44 |
+
if not self.base_url:
|
45 |
+
missing.append("base_url")
|
46 |
+
if not self.class_set:
|
47 |
+
missing.append("class_set")
|
48 |
+
if not self.prompt_template:
|
49 |
+
missing.append("prompt_template")
|
50 |
+
if missing:
|
51 |
+
raise ValueError(
|
52 |
+
f"Missing configuration for {', '.join(missing)} in LlmIntentClassifier"
|
53 |
+
)
|
54 |
+
|
55 |
+
def train(
|
56 |
+
self,
|
57 |
+
training_data: TrainingData,
|
58 |
+
config: Optional[RasaNLUModelConfig] = None,
|
59 |
+
**kwargs: Any,
|
60 |
+
) -> None:
|
61 |
+
# No local training; this uses a remote service
|
62 |
+
pass
|
63 |
+
|
64 |
+
def process(self, message: Message, **kwargs: Any) -> None:
|
65 |
+
text: Optional[Text] = message.get(TEXT)
|
66 |
+
intent_name: Optional[Text] = None
|
67 |
+
confidence: float = 0.0
|
68 |
+
|
69 |
+
if text:
|
70 |
+
payload: Dict[str, Any] = {
|
71 |
+
"message": text,
|
72 |
+
"model_name": self.model_name,
|
73 |
+
"base_url": self.base_url,
|
74 |
+
"class_set": self.class_set,
|
75 |
+
"prompt_template": self.prompt_template,
|
76 |
+
}
|
77 |
+
try:
|
78 |
+
resp = requests.post(self.url, json=payload, timeout=self.timeout)
|
79 |
+
resp.raise_for_status()
|
80 |
+
result = resp.json().get("result")
|
81 |
+
if isinstance(result, str):
|
82 |
+
intent_name = result
|
83 |
+
confidence = 1.0
|
84 |
+
except Exception as e:
|
85 |
+
logger.warning(f"LlmIntentClassifier HTTP error: {e}")
|
86 |
+
|
87 |
+
message.set(INTENT, {"name": intent_name, "confidence": confidence}, add_to_output=True)
|
88 |
+
|
89 |
+
def persist(
|
90 |
+
self,
|
91 |
+
file_name: Text,
|
92 |
+
model_dir: Text,
|
93 |
+
) -> Optional[Dict[Text, Any]]:
|
94 |
+
# Save configuration so it can be reloaded
|
95 |
+
return {
|
96 |
+
"classifier_url": self.url,
|
97 |
+
"timeout": self.timeout,
|
98 |
+
"model_name": self.model_name,
|
99 |
+
"base_url": self.base_url,
|
100 |
+
"class_set": self.class_set,
|
101 |
+
"prompt_template": self.prompt_template,
|
102 |
+
}
|
103 |
+
|
104 |
+
@classmethod
|
105 |
+
def load(
|
106 |
+
cls,
|
107 |
+
meta: Dict[Text, Any],
|
108 |
+
model_dir: Text,
|
109 |
+
model_metadata: Metadata = None,
|
110 |
+
cached_component: Optional["LlmIntentClassifier"] = None,
|
111 |
+
**kwargs: Any,
|
112 |
+
) -> "LlmIntentClassifier":
|
113 |
+
# meta contains saved configuration
|
114 |
+
return cls(meta)
|
requirements.txt
CHANGED
@@ -1,7 +1,5 @@
|
|
1 |
# Core HTTP & UI dependencies
|
2 |
-
|
3 |
-
|
4 |
-
|
5 |
-
|
6 |
-
tiktoken
|
7 |
-
openai
|
|
|
1 |
# Core HTTP & UI dependencies
|
2 |
+
pandas==1.1.5
|
3 |
+
pydantic<2.0
|
4 |
+
gradio==3.39.0
|
5 |
+
requests>=2.28
|
|
|
|