NeonCharlie-24 commited on
Commit
6baf4bd
·
unverified ·
1 Parent(s): 860cd53

Feat/improve clarification check (#57)

Browse files

* implemented needs_clarification_improved to use LLM inference to determine if clarification is needed.

* added unit tests for needs_clarification_improved.

* minor clean up of docstring and potential await error.

multi_llm_chatbot_backend/app/api/routes/chat.py CHANGED
@@ -101,7 +101,7 @@ async def chat_stream(
101
  "content": message.user_input,
102
  })
103
 
104
- if chat_orchestrator.needs_clarification(session, message.user_input):
105
  clar = await chat_orchestrator.generate_contextual_clarification(message.user_input)
106
  yield ChatStreamLine(
107
  type="clarification",
 
101
  "content": message.user_input,
102
  })
103
 
104
+ if await chat_orchestrator.needs_clarification_improved(session, message.user_input):
105
  clar = await chat_orchestrator.generate_contextual_clarification(message.user_input)
106
  yield ChatStreamLine(
107
  type="clarification",
multi_llm_chatbot_backend/app/core/improved_orchestrator.py CHANGED
@@ -79,6 +79,7 @@ class ImprovedChatOrchestrator:
79
  tool_executor=tool_executor,
80
  )
81
 
 
82
  async def process_message(self,
83
  user_input: str,
84
  session_id: Optional[str] = None,
@@ -234,7 +235,86 @@ class ImprovedChatOrchestrator:
234
 
235
  logger.info("CLARIFICATION TRIGGERED: short input (%d words) without specific keywords", word_count)
236
  return True
237
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
238
  async def generate_contextual_clarification(self, user_input: str) -> Dict[str, Any]:
239
  """
240
  Use the LLM to produce a clarification question and clickable
 
79
  tool_executor=tool_executor,
80
  )
81
 
82
+ # TODO: Investigate if this method is still needed and remove if not.
83
  async def process_message(self,
84
  user_input: str,
85
  session_id: Optional[str] = None,
 
235
 
236
  logger.info("CLARIFICATION TRIGGERED: short input (%d words) without specific keywords", word_count)
237
  return True
238
+
239
+ async def needs_clarification_improved(self, session: ConversationContext, user_input: str) -> bool:
240
+ """
241
+ Use an LLM call to determine whether the user's input is too vague
242
+ to route to the advisor panel. Falls back to the legacy rule-based
243
+ method if the LLM call fails.
244
+ """
245
+ user_messages = [msg for msg in session.messages if msg.get('role') == 'user']
246
+ if len(user_messages) > 1:
247
+ logger.info("Skipping clarification: session already has %d user message(s)", len(user_messages))
248
+ return False
249
+
250
+ app_cfg = get_settings().app
251
+ orch_cfg = get_settings().orchestrator
252
+ advisor_descriptions = ", ".join(
253
+ f"{p.name} ({p.id})" for p in self.personas.values()
254
+ )
255
+ domain_keywords = ", ".join(orch_cfg.specific_keywords)
256
+
257
+ system_prompt = (
258
+ "You are a routing classifier for an AI advisory application.\n\n"
259
+ f"Application: {app_cfg.title} — {app_cfg.subtitle}\n"
260
+ f"Available advisors: {advisor_descriptions}\n"
261
+ f"Domain-relevant topics: {domain_keywords}\n\n"
262
+ "Your task: decide whether the user's FIRST message contains enough "
263
+ "substance to send to the advisors, or whether it is too vague and "
264
+ "requires a clarifying follow-up before the advisors can help.\n\n"
265
+ "A message NEEDS CLARIFICATION when it:\n"
266
+ "- Expresses confusion or uncertainty without a concrete topic\n"
267
+ "- Is a single generic request like 'help' or 'advice'\n"
268
+ "- Contains no identifiable subject the advisors could address\n\n"
269
+ "A message is CLEAR ENOUGH when it:\n"
270
+ "- Mentions a specific topic, question, or problem area\n"
271
+ "- Provides enough context for at least one advisor to respond usefully\n"
272
+ "- Even a short message is fine if the intent is unambiguous "
273
+ "(e.g. 'explain transformers' is clear)\n"
274
+ "- Messages mentioning domain-relevant topics are likely clear enough "
275
+ "to route directly, even if brief\n\n"
276
+ "Respond ONLY with valid JSON:\n"
277
+ '{"needs_clarification": true or false, "reason": "one sentence explanation"}'
278
+ )
279
+
280
+ user_prompt = f'User message: "{user_input}"'
281
+
282
+ raw = None
283
+
284
+ try:
285
+ llm = next(iter(self.personas.values())).llm
286
+ raw = await llm.generate(
287
+ system_prompt=system_prompt,
288
+ context=[{"role": "user", "content": user_prompt}],
289
+ temperature=0.0,
290
+ max_tokens=128,
291
+ response_mime_type="application/json",
292
+ )
293
+
294
+ parsed = json.loads(raw.strip())
295
+ value = parsed.get("needs_clarification")
296
+ if not isinstance(value, bool):
297
+ raise TypeError(
298
+ f"needs_clarification must be a boolean, got {type(value).__name__}: {value!r}"
299
+ )
300
+ result = value
301
+ reason = parsed.get("reason", "")
302
+
303
+ logger.info(
304
+ "LLM clarification classification: needs_clarification=%s, reason=%r, input=%r",
305
+ result, reason, user_input,
306
+ )
307
+ return result
308
+
309
+ except (json.JSONDecodeError, KeyError, TypeError) as exc:
310
+ logger.error("Failed to parse LLM classification response: %s (raw=%r)", exc, raw)
311
+ except Exception as exc:
312
+ logger.error("LLM classification call failed: %s", exc)
313
+
314
+ # TODO: Evaluate if this fallback is still needed and remove if not.
315
+ logger.warning("Falling back to rule-based clarification check")
316
+ return self.needs_clarification(session, user_input)
317
+
318
  async def generate_contextual_clarification(self, user_input: str) -> Dict[str, Any]:
319
  """
320
  Use the LLM to produce a clarification question and clickable
multi_llm_chatbot_backend/app/tests/unit/test_clarification.py ADDED
@@ -0,0 +1,336 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import unittest
4
+ from unittest.mock import AsyncMock, MagicMock, patch
5
+
6
+ from app.core.improved_orchestrator import ImprovedChatOrchestrator
7
+
8
+
9
+ def _make_session(user_message_count=1):
10
+ """Build a mock ConversationContext with N user messages."""
11
+ session = MagicMock()
12
+ session.messages = [
13
+ {"role": "user", "content": f"message {i}"}
14
+ for i in range(user_message_count)
15
+ ]
16
+ return session
17
+
18
+
19
+ def _make_mock_settings():
20
+ """Build a mock settings object with the fields needs_clarification_improved reads."""
21
+ settings = MagicMock()
22
+ settings.app.title = "Test Advisory Panel"
23
+ settings.app.subtitle = "AI-Powered Test Guidance"
24
+ settings.orchestrator.specific_keywords = ["methodology", "theory", "research"]
25
+ settings.orchestrator.min_words_without_keywords = 6
26
+ settings.orchestrator.clarification_questions = ["Could you provide more details?"]
27
+ settings.orchestrator.clarification_suggestions = ["Ask about methodology."]
28
+ return settings
29
+
30
+
31
+ def _make_orchestrator(persona_llm=None):
32
+ """Build an orchestrator with mocked dependencies, bypassing __init__."""
33
+ orch = ImprovedChatOrchestrator.__new__(ImprovedChatOrchestrator)
34
+ orch.llm_client = None
35
+ orch.session_manager = MagicMock()
36
+ orch.context_manager = MagicMock()
37
+
38
+ if persona_llm is not None:
39
+ persona = MagicMock()
40
+ persona.name = "Dr. Test"
41
+ persona.id = "tester"
42
+ persona.llm = persona_llm
43
+ orch.personas = {"tester": persona}
44
+ else:
45
+ orch.personas = {}
46
+
47
+ return orch
48
+
49
+
50
+ @patch("app.core.improved_orchestrator.get_settings")
51
+ class TestNeedsClarificationImproved(unittest.TestCase):
52
+
53
+ def _run(self, coro):
54
+ return asyncio.run(coro)
55
+
56
+ # ------------------------------------------------------------------
57
+ # First-message gate
58
+ # ------------------------------------------------------------------
59
+
60
+ def test_skips_when_session_has_multiple_user_messages(self, mock_settings):
61
+ mock_settings.return_value = _make_mock_settings()
62
+ llm = MagicMock()
63
+ llm.generate = AsyncMock()
64
+ orch = _make_orchestrator(persona_llm=llm)
65
+ session = _make_session(user_message_count=3)
66
+
67
+ result = self._run(
68
+ orch.needs_clarification_improved(session, "help")
69
+ )
70
+
71
+ self.assertFalse(result)
72
+ llm.generate.assert_not_called()
73
+
74
+ def test_proceeds_when_session_has_one_user_message(self, mock_settings):
75
+ mock_settings.return_value = _make_mock_settings()
76
+ llm = MagicMock()
77
+ llm.generate = AsyncMock(return_value=json.dumps({
78
+ "needs_clarification": False,
79
+ "reason": "Clear.",
80
+ }))
81
+ orch = _make_orchestrator(persona_llm=llm)
82
+ session = _make_session(user_message_count=1)
83
+
84
+ self._run(orch.needs_clarification_improved(session, "explain transformers"))
85
+
86
+ llm.generate.assert_called_once()
87
+
88
+ # ------------------------------------------------------------------
89
+ # LLM happy path — clear input
90
+ # ------------------------------------------------------------------
91
+
92
+ def test_returns_false_when_llm_says_clear(self, mock_settings):
93
+ mock_settings.return_value = _make_mock_settings()
94
+ llm = MagicMock()
95
+ llm.generate = AsyncMock(return_value=json.dumps({
96
+ "needs_clarification": False,
97
+ "reason": "The user asked about a specific topic.",
98
+ }))
99
+ orch = _make_orchestrator(persona_llm=llm)
100
+ session = _make_session(user_message_count=1)
101
+
102
+ result = self._run(
103
+ orch.needs_clarification_improved(session, "explain transformers")
104
+ )
105
+
106
+ self.assertFalse(result)
107
+
108
+ # ------------------------------------------------------------------
109
+ # LLM happy path — vague input
110
+ # ------------------------------------------------------------------
111
+
112
+ def test_returns_true_when_llm_says_vague(self, mock_settings):
113
+ mock_settings.return_value = _make_mock_settings()
114
+ llm = MagicMock()
115
+ llm.generate = AsyncMock(return_value=json.dumps({
116
+ "needs_clarification": True,
117
+ "reason": "Single generic word with no topic.",
118
+ }))
119
+ orch = _make_orchestrator(persona_llm=llm)
120
+ session = _make_session(user_message_count=1)
121
+
122
+ result = self._run(
123
+ orch.needs_clarification_improved(session, "help")
124
+ )
125
+
126
+ self.assertTrue(result)
127
+
128
+ # ------------------------------------------------------------------
129
+ # Strict boolean parsing
130
+ # ------------------------------------------------------------------
131
+
132
+ def test_rejects_string_false_and_falls_back(self, mock_settings):
133
+ """bool("false") is True in Python; ensure string values are rejected."""
134
+ mock_settings.return_value = _make_mock_settings()
135
+ llm = MagicMock()
136
+ llm.generate = AsyncMock(return_value=json.dumps({
137
+ "needs_clarification": "false",
138
+ "reason": "Should have been a boolean.",
139
+ }))
140
+ orch = _make_orchestrator(persona_llm=llm)
141
+ orch.needs_clarification = MagicMock(return_value=False)
142
+ session = _make_session(user_message_count=1)
143
+
144
+ result = self._run(
145
+ orch.needs_clarification_improved(session, "methodology")
146
+ )
147
+
148
+ self.assertFalse(result)
149
+ orch.needs_clarification.assert_called_once()
150
+
151
+ def test_rejects_string_true_and_falls_back(self, mock_settings):
152
+ mock_settings.return_value = _make_mock_settings()
153
+ llm = MagicMock()
154
+ llm.generate = AsyncMock(return_value=json.dumps({
155
+ "needs_clarification": "true",
156
+ "reason": "Should have been a boolean.",
157
+ }))
158
+ orch = _make_orchestrator(persona_llm=llm)
159
+ orch.needs_clarification = MagicMock(return_value=True)
160
+ session = _make_session(user_message_count=1)
161
+
162
+ result = self._run(
163
+ orch.needs_clarification_improved(session, "help")
164
+ )
165
+
166
+ self.assertTrue(result)
167
+ orch.needs_clarification.assert_called_once()
168
+
169
+ def test_rejects_missing_key_and_falls_back(self, mock_settings):
170
+ """If the LLM omits the key entirely, fall back."""
171
+ mock_settings.return_value = _make_mock_settings()
172
+ llm = MagicMock()
173
+ llm.generate = AsyncMock(return_value=json.dumps({
174
+ "reason": "Forgot the main field.",
175
+ }))
176
+ orch = _make_orchestrator(persona_llm=llm)
177
+ orch.needs_clarification = MagicMock(return_value=True)
178
+ session = _make_session(user_message_count=1)
179
+
180
+ result = self._run(
181
+ orch.needs_clarification_improved(session, "help")
182
+ )
183
+
184
+ self.assertTrue(result)
185
+ orch.needs_clarification.assert_called_once()
186
+
187
+ # ------------------------------------------------------------------
188
+ # Malformed JSON → fallback
189
+ # ------------------------------------------------------------------
190
+
191
+ def test_falls_back_on_malformed_json(self, mock_settings):
192
+ mock_settings.return_value = _make_mock_settings()
193
+ llm = MagicMock()
194
+ llm.generate = AsyncMock(return_value="this is not json at all")
195
+ orch = _make_orchestrator(persona_llm=llm)
196
+ orch.needs_clarification = MagicMock(return_value=False)
197
+ session = _make_session(user_message_count=1)
198
+
199
+ result = self._run(
200
+ orch.needs_clarification_improved(session, "something")
201
+ )
202
+
203
+ self.assertFalse(result)
204
+ orch.needs_clarification.assert_called_once()
205
+
206
+ # ------------------------------------------------------------------
207
+ # LLM exception → fallback
208
+ # ------------------------------------------------------------------
209
+
210
+ def test_falls_back_on_llm_exception(self, mock_settings):
211
+ mock_settings.return_value = _make_mock_settings()
212
+ llm = MagicMock()
213
+ llm.generate = AsyncMock(side_effect=RuntimeError("connection refused"))
214
+ orch = _make_orchestrator(persona_llm=llm)
215
+ orch.needs_clarification = MagicMock(return_value=True)
216
+ session = _make_session(user_message_count=1)
217
+
218
+ result = self._run(
219
+ orch.needs_clarification_improved(session, "help")
220
+ )
221
+
222
+ self.assertTrue(result)
223
+ orch.needs_clarification.assert_called_once()
224
+
225
+ # ------------------------------------------------------------------
226
+ # No personas registered → fallback
227
+ # ------------------------------------------------------------------
228
+
229
+ def test_falls_back_when_no_personas_registered(self, mock_settings):
230
+ mock_settings.return_value = _make_mock_settings()
231
+ orch = _make_orchestrator(persona_llm=None)
232
+ orch.needs_clarification = MagicMock(return_value=True)
233
+ session = _make_session(user_message_count=1)
234
+
235
+ result = self._run(
236
+ orch.needs_clarification_improved(session, "help")
237
+ )
238
+
239
+ self.assertTrue(result)
240
+ orch.needs_clarification.assert_called_once()
241
+
242
+ # ------------------------------------------------------------------
243
+ # LLM call parameters
244
+ # ------------------------------------------------------------------
245
+
246
+ def test_llm_called_with_json_mode_and_zero_temp(self, mock_settings):
247
+ mock_settings.return_value = _make_mock_settings()
248
+ llm = MagicMock()
249
+ llm.generate = AsyncMock(return_value=json.dumps({
250
+ "needs_clarification": False,
251
+ "reason": "Clear.",
252
+ }))
253
+ orch = _make_orchestrator(persona_llm=llm)
254
+ session = _make_session(user_message_count=1)
255
+
256
+ self._run(
257
+ orch.needs_clarification_improved(session, "explain transformers")
258
+ )
259
+
260
+ call_kwargs = llm.generate.call_args.kwargs
261
+ self.assertEqual(call_kwargs["temperature"], 0.0)
262
+ self.assertEqual(call_kwargs["max_tokens"], 128)
263
+ self.assertEqual(call_kwargs["response_mime_type"], "application/json")
264
+
265
+ def test_system_prompt_includes_app_context(self, mock_settings):
266
+ mock_settings.return_value = _make_mock_settings()
267
+ llm = MagicMock()
268
+ llm.generate = AsyncMock(return_value=json.dumps({
269
+ "needs_clarification": False,
270
+ "reason": "Clear.",
271
+ }))
272
+ orch = _make_orchestrator(persona_llm=llm)
273
+ session = _make_session(user_message_count=1)
274
+
275
+ self._run(
276
+ orch.needs_clarification_improved(session, "explain transformers")
277
+ )
278
+
279
+ system_prompt = llm.generate.call_args.kwargs["system_prompt"]
280
+ self.assertIn("Test Advisory Panel", system_prompt)
281
+ self.assertIn("AI-Powered Test Guidance", system_prompt)
282
+
283
+ def test_system_prompt_includes_domain_keywords(self, mock_settings):
284
+ mock_settings.return_value = _make_mock_settings()
285
+ llm = MagicMock()
286
+ llm.generate = AsyncMock(return_value=json.dumps({
287
+ "needs_clarification": False,
288
+ "reason": "Clear.",
289
+ }))
290
+ orch = _make_orchestrator(persona_llm=llm)
291
+ session = _make_session(user_message_count=1)
292
+
293
+ self._run(
294
+ orch.needs_clarification_improved(session, "explain transformers")
295
+ )
296
+
297
+ system_prompt = llm.generate.call_args.kwargs["system_prompt"]
298
+ self.assertIn("methodology", system_prompt)
299
+ self.assertIn("theory", system_prompt)
300
+ self.assertIn("research", system_prompt)
301
+
302
+ def test_system_prompt_includes_advisor_names(self, mock_settings):
303
+ mock_settings.return_value = _make_mock_settings()
304
+ llm = MagicMock()
305
+ llm.generate = AsyncMock(return_value=json.dumps({
306
+ "needs_clarification": False,
307
+ "reason": "Clear.",
308
+ }))
309
+ orch = _make_orchestrator(persona_llm=llm)
310
+ session = _make_session(user_message_count=1)
311
+
312
+ self._run(
313
+ orch.needs_clarification_improved(session, "explain transformers")
314
+ )
315
+
316
+ system_prompt = llm.generate.call_args.kwargs["system_prompt"]
317
+ self.assertIn("Dr. Test (tester)", system_prompt)
318
+
319
+ def test_user_input_passed_in_user_prompt(self, mock_settings):
320
+ mock_settings.return_value = _make_mock_settings()
321
+ llm = MagicMock()
322
+ llm.generate = AsyncMock(return_value=json.dumps({
323
+ "needs_clarification": False,
324
+ "reason": "Clear.",
325
+ }))
326
+ orch = _make_orchestrator(persona_llm=llm)
327
+ session = _make_session(user_message_count=1)
328
+
329
+ self._run(
330
+ orch.needs_clarification_improved(session, "How do I structure my lit review?")
331
+ )
332
+
333
+ context = llm.generate.call_args.kwargs["context"]
334
+ self.assertEqual(len(context), 1)
335
+ self.assertEqual(context[0]["role"], "user")
336
+ self.assertIn("How do I structure my lit review?", context[0]["content"])