Luigi commited on
Commit
0d2a25f
·
1 Parent(s): 5316395

add and apply llm intent classifier

Browse files
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
- - name: "KeywordIntentClassifier"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- gradio==4.44.1
3
- requests
4
- jsonschema>=4.0.0
5
- # Tokenizers & LLM
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