LIANJie-Jason Claude Opus 4.6 commited on
Commit
ebd834c
·
1 Parent(s): 06249fb

feat: add query understanding layer for better retrieval

Browse files

Pre-retrieval LLM step that reformulates user queries into
search-optimized forms (expanded keywords, resolved pronouns,
domain-aware terms) and optionally asks clarification questions.

- New src/query_engine.py with understand_query() + JSON parsing
- New prompt template in src/prompts.py
- CLI: query understanding + conversation history + clarification flow
- Web UI: query understanding + pending_clarification state
- Config: query_understanding.enabled/max_history/max_clarifications
- 10 new tests (59 total, all passing)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

app_cli.py CHANGED
@@ -9,6 +9,7 @@ from rich.text import Text
9
 
10
  from src.config_loader import load_config, get_api_key
11
  from src.ingest import ingest_documents
 
12
  from src.retriever import retrieve
13
  from src.verifier import verify_and_respond
14
  from src.llm import list_models
@@ -214,6 +215,7 @@ def main() -> None:
214
  state: dict = {
215
  "last_retrieval": None,
216
  "web_search_override": None,
 
217
  }
218
 
219
  while True:
@@ -254,7 +256,6 @@ def main() -> None:
254
  continue
255
 
256
  # ── RAG pipeline ─────────────────────────────────────────────────
257
- query = user_input
258
 
259
  # Apply web search override if set
260
  effective_cfg = cfg
@@ -266,24 +267,83 @@ def main() -> None:
266
  "enabled": state["web_search_override"],
267
  }
268
 
269
- # Retrieve
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  with console.status("[bold blue]Searching knowledge base...[/bold blue]"):
271
  try:
272
- retrieval_result = retrieve(query, effective_cfg)
273
  except Exception as e:
274
  console.print(f"[red]Retrieval error: {e}[/red]")
275
  continue
276
 
277
  state["last_retrieval"] = retrieval_result
278
 
279
- # Generate + verify
280
  with console.status("[bold blue]Generating response...[/bold blue]"):
281
  try:
282
- result = verify_and_respond(query, retrieval_result, effective_cfg)
283
  except Exception as e:
284
  console.print(f"[red]Generation error: {e}[/red]")
285
  continue
286
 
 
 
 
 
 
 
 
287
  # ── Display response ─────────────────────────────────────────────
288
  response_text = result["response"]
289
 
 
9
 
10
  from src.config_loader import load_config, get_api_key
11
  from src.ingest import ingest_documents
12
+ from src.query_engine import understand_query
13
  from src.retriever import retrieve
14
  from src.verifier import verify_and_respond
15
  from src.llm import list_models
 
215
  state: dict = {
216
  "last_retrieval": None,
217
  "web_search_override": None,
218
+ "conversation_history": [],
219
  }
220
 
221
  while True:
 
256
  continue
257
 
258
  # ── RAG pipeline ─────────────────────────────────────────────────
 
259
 
260
  # Apply web search override if set
261
  effective_cfg = cfg
 
267
  "enabled": state["web_search_override"],
268
  }
269
 
270
+ # ── Query understanding ─────────────────────────────────────────
271
+ qu_cfg = effective_cfg.get("query_understanding", {})
272
+ qu_enabled = qu_cfg.get("enabled", True)
273
+ max_clarifications = qu_cfg.get("max_clarifications", 1)
274
+
275
+ original_query = user_input
276
+ search_query = user_input
277
+
278
+ if qu_enabled:
279
+ with console.status("[bold blue]Understanding your question...[/bold blue]"):
280
+ try:
281
+ qu_result = understand_query(
282
+ user_input, effective_cfg,
283
+ state.get("conversation_history", []),
284
+ )
285
+ except Exception as e:
286
+ console.print(f"[yellow]Query understanding failed, using raw query: {e}[/yellow]")
287
+ qu_result = {"action": "search", "search_query": user_input, "original_query": user_input}
288
+
289
+ # Handle clarification
290
+ clarification_rounds = 0
291
+ while qu_result["action"] == "clarify" and clarification_rounds < max_clarifications:
292
+ console.print(f"\n[bold yellow]Clarification needed:[/bold yellow] {qu_result['clarification_question']}")
293
+ try:
294
+ clarification = input("You> ").strip()
295
+ except (EOFError, KeyboardInterrupt):
296
+ clarification = ""
297
+ if not clarification:
298
+ break
299
+ combined = f"{user_input} — {clarification}"
300
+ with console.status("[bold blue]Understanding your question...[/bold blue]"):
301
+ try:
302
+ qu_result = understand_query(
303
+ combined, effective_cfg,
304
+ state.get("conversation_history", []),
305
+ )
306
+ except Exception:
307
+ qu_result = {"action": "search", "search_query": combined, "original_query": user_input}
308
+ clarification_rounds += 1
309
+
310
+ # After max clarification rounds, force search
311
+ if qu_result["action"] == "clarify":
312
+ qu_result["action"] = "search"
313
+ # search_query fallback is already set in _parse_qu_result
314
+
315
+ search_query = qu_result.get("search_query", user_input)
316
+ original_query = qu_result.get("original_query", user_input)
317
+
318
+ # Show reformulated query if different from original
319
+ if search_query != user_input:
320
+ console.print(f"[dim]Searching for: \"{search_query}\"[/dim]")
321
+
322
+ # ── Retrieve ────────────────────────────────────────────────────
323
  with console.status("[bold blue]Searching knowledge base...[/bold blue]"):
324
  try:
325
+ retrieval_result = retrieve(search_query, effective_cfg)
326
  except Exception as e:
327
  console.print(f"[red]Retrieval error: {e}[/red]")
328
  continue
329
 
330
  state["last_retrieval"] = retrieval_result
331
 
332
+ # ── Generate + verify ───────────────────────────────────────────
333
  with console.status("[bold blue]Generating response...[/bold blue]"):
334
  try:
335
+ result = verify_and_respond(original_query, retrieval_result, effective_cfg)
336
  except Exception as e:
337
  console.print(f"[red]Generation error: {e}[/red]")
338
  continue
339
 
340
+ # ── Update conversation history ─────────────────────────────────
341
+ history = state.get("conversation_history", [])
342
+ max_history = qu_cfg.get("max_history", 6)
343
+ history.append({"role": "user", "content": user_input})
344
+ history.append({"role": "assistant", "content": result["response"]})
345
+ state["conversation_history"] = history[-max_history:]
346
+
347
  # ── Display response ─────────────────────────────────────────────
348
  response_text = result["response"]
349
 
app_web.py CHANGED
@@ -4,6 +4,7 @@ import streamlit as st
4
 
5
  from src.config_loader import load_config, get_api_key
6
  from src.ingest import get_chroma_collection, ingest_documents
 
7
  from src.retriever import retrieve
8
  from src.verifier import verify_and_respond
9
  from src.llm import list_models
@@ -19,6 +20,8 @@ def init_session():
19
  st.session_state.cfg = load_config()
20
  if "last_retrieval" not in st.session_state:
21
  st.session_state.last_retrieval = None
 
 
22
 
23
 
24
  # ── Sidebar ──────────────────────────────────────────────────────────────────
@@ -133,11 +136,56 @@ def render_chat():
133
  with st.chat_message("user"):
134
  st.markdown(prompt)
135
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
136
  # Generate assistant response
137
  with st.chat_message("assistant"):
138
  with st.status("Searching knowledge base...", expanded=True) as status:
 
 
 
 
139
  # Retrieval
140
- retrieval_result = retrieve(prompt, cfg)
141
  st.session_state.last_retrieval = retrieval_result
142
 
143
  n_local = len(retrieval_result.get("db_results", []))
@@ -146,8 +194,8 @@ def render_chat():
146
  label=f"Found {n_local} local + {n_web} web sources. Generating response..."
147
  )
148
 
149
- # Verification and response generation
150
- result = verify_and_respond(prompt, retrieval_result, cfg)
151
 
152
  # Update status based on verification outcome
153
  if result.get("refused"):
 
4
 
5
  from src.config_loader import load_config, get_api_key
6
  from src.ingest import get_chroma_collection, ingest_documents
7
+ from src.query_engine import understand_query
8
  from src.retriever import retrieve
9
  from src.verifier import verify_and_respond
10
  from src.llm import list_models
 
20
  st.session_state.cfg = load_config()
21
  if "last_retrieval" not in st.session_state:
22
  st.session_state.last_retrieval = None
23
+ if "pending_clarification" not in st.session_state:
24
+ st.session_state.pending_clarification = None
25
 
26
 
27
  # ── Sidebar ──────────────────────────────────────────────────────────────────
 
136
  with st.chat_message("user"):
137
  st.markdown(prompt)
138
 
139
+ # ── Check if this is a clarification response ───────────────────
140
+ pending = st.session_state.pending_clarification
141
+ if pending is not None:
142
+ # This prompt is the user's clarification answer
143
+ combined = f"{pending} — {prompt}"
144
+ original_query = pending
145
+ st.session_state.pending_clarification = None
146
+ else:
147
+ combined = prompt
148
+ original_query = prompt
149
+
150
+ # ── Query understanding ─────────────────────────────────────────
151
+ qu_cfg = cfg.get("query_understanding", {})
152
+ qu_enabled = qu_cfg.get("enabled", True)
153
+ max_history = qu_cfg.get("max_history", 6)
154
+
155
+ search_query = combined
156
+
157
+ if qu_enabled:
158
+ history = [
159
+ {"role": m["role"], "content": m["content"]}
160
+ for m in st.session_state.messages[-max_history:]
161
+ ]
162
+ try:
163
+ qu_result = understand_query(combined, cfg, history)
164
+ except Exception:
165
+ qu_result = {"action": "search", "search_query": combined, "original_query": original_query}
166
+
167
+ if qu_result["action"] == "clarify" and pending is None:
168
+ # Ask clarification — store original query, show question
169
+ st.session_state.pending_clarification = original_query
170
+ clarification_msg = f"**Before I search, could you clarify?** {qu_result['clarification_question']}"
171
+ st.session_state.messages.append(
172
+ {"role": "assistant", "content": clarification_msg}
173
+ )
174
+ with st.chat_message("assistant"):
175
+ st.markdown(clarification_msg)
176
+ return
177
+
178
+ search_query = qu_result.get("search_query", combined)
179
+
180
  # Generate assistant response
181
  with st.chat_message("assistant"):
182
  with st.status("Searching knowledge base...", expanded=True) as status:
183
+ # Show reformulated query if different
184
+ if search_query != original_query:
185
+ status.update(label=f'Searching for: "{search_query}"...')
186
+
187
  # Retrieval
188
+ retrieval_result = retrieve(search_query, cfg)
189
  st.session_state.last_retrieval = retrieval_result
190
 
191
  n_local = len(retrieval_result.get("db_results", []))
 
194
  label=f"Found {n_local} local + {n_web} web sources. Generating response..."
195
  )
196
 
197
+ # Verification and response generation (uses original query)
198
+ result = verify_and_respond(original_query, retrieval_result, cfg)
199
 
200
  # Update status based on verification outcome
201
  if result.get("refused"):
setup.py CHANGED
@@ -56,6 +56,11 @@ def generate_config(
56
  "backend": "semantic_scholar",
57
  "max_results": 5,
58
  },
 
 
 
 
 
59
  "verification": {
60
  "enabled": True,
61
  "max_iterations": 3,
 
56
  "backend": "semantic_scholar",
57
  "max_results": 5,
58
  },
59
+ "query_understanding": {
60
+ "enabled": True,
61
+ "max_history": 6,
62
+ "max_clarifications": 1,
63
+ },
64
  "verification": {
65
  "enabled": True,
66
  "max_iterations": 3,
src/prompts.py CHANGED
@@ -3,6 +3,7 @@ Prompt builder with anti-hallucination guardrails and verification prompts.
3
 
4
  Provides system prompt templates and builder functions for RAG chatbots
5
  that enforce strict citation rules and zero-tolerance hallucination policies.
 
6
  """
7
 
8
  SYSTEM_PROMPT_TEMPLATE = """\
@@ -120,6 +121,77 @@ If there are no errors, return an empty "errors" list,
120
  """
121
 
122
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  def build_prompt(context: str, bot_name: str, domain: str) -> str:
124
  """Build the system prompt with anti-hallucination guardrails.
125
 
 
3
 
4
  Provides system prompt templates and builder functions for RAG chatbots
5
  that enforce strict citation rules and zero-tolerance hallucination policies.
6
+ Also includes the query understanding prompt for pre-retrieval reformulation.
7
  """
8
 
9
  SYSTEM_PROMPT_TEMPLATE = """\
 
121
  """
122
 
123
 
124
+ QUERY_UNDERSTANDING_PROMPT_TEMPLATE = """\
125
+ You are a query reformulation assistant for a {domain} research chatbot.
126
+
127
+ Your job is to analyze the user's question and decide:
128
+ 1. SEARCH -- if the question is clear enough, rewrite it as an optimized search query
129
+ 2. CLARIFY -- if the question is genuinely ambiguous, ask ONE clarification question
130
+
131
+ RULES for SEARCH:
132
+ - Expand abbreviations and acronyms (e.g., "HR" -> "human rights")
133
+ - Resolve pronouns using conversation history (e.g., "they" -> the entity from prior turn)
134
+ - Add relevant domain-specific keywords that would match document content
135
+ - Keep the reformulated query concise (15-40 words)
136
+ - Preserve the user's original intent -- do NOT change what they're asking about
137
+ - If the query is already clear and specific, return it with minimal changes
138
+
139
+ RULES for CLARIFY:
140
+ - ONLY ask for clarification when the question is genuinely ambiguous
141
+ (could mean 2+ clearly different things)
142
+ - Ask ONE specific, short question
143
+ - Provide 2-3 options when possible
144
+ - Do NOT ask for clarification on clear questions -- even if they are broad
145
+ - A broad question is NOT the same as an ambiguous question
146
+
147
+ CONVERSATION HISTORY:
148
+ {history}
149
+
150
+ USER'S QUESTION: {query}
151
+
152
+ Respond in JSON:
153
+ {{
154
+ "action": "search" or "clarify",
155
+ "search_query": "reformulated search query (only if action is search)",
156
+ "clarification_question": "question to ask (only if action is clarify)",
157
+ "reasoning": "one sentence explaining your choice"
158
+ }}
159
+ """
160
+
161
+
162
+ def build_query_understanding_prompt(
163
+ query: str, domain: str, history: list[dict],
164
+ ) -> str:
165
+ """Build the query understanding prompt for pre-retrieval reformulation.
166
+
167
+ Args:
168
+ query: The user's raw question.
169
+ domain: Knowledge domain from config.
170
+ history: Recent conversation messages for context resolution.
171
+
172
+ Returns:
173
+ Formatted query understanding prompt string.
174
+ """
175
+ if history:
176
+ history_lines = []
177
+ for msg in history:
178
+ role = msg.get("role", "user").capitalize()
179
+ content = msg.get("content", "")
180
+ # Truncate long assistant responses to save tokens
181
+ if role == "Assistant" and len(content) > 200:
182
+ content = content[:200] + "..."
183
+ history_lines.append(f"{role}: {content}")
184
+ history_str = "\n".join(history_lines)
185
+ else:
186
+ history_str = "(No prior conversation)"
187
+
188
+ return QUERY_UNDERSTANDING_PROMPT_TEMPLATE.format(
189
+ domain=domain,
190
+ history=history_str,
191
+ query=query,
192
+ )
193
+
194
+
195
  def build_prompt(context: str, bot_name: str, domain: str) -> str:
196
  """Build the system prompt with anti-hallucination guardrails.
197
 
src/query_engine.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Query understanding layer: reformulates user queries for better retrieval."""
2
+
3
+ import json
4
+ import re
5
+
6
+ from src.llm import generate
7
+ from src.prompts import build_query_understanding_prompt
8
+
9
+
10
+ def understand_query(
11
+ user_query: str,
12
+ cfg: dict,
13
+ conversation_history: list[dict] | None = None,
14
+ ) -> dict:
15
+ """Analyze and reformulate a user query for better retrieval.
16
+
17
+ Uses the LLM to either:
18
+ - SEARCH: Rewrite the query into a search-optimized form
19
+ - CLARIFY: Ask the user a clarification question
20
+
21
+ Args:
22
+ user_query: Raw user input.
23
+ cfg: App config (provides domain, LLM settings).
24
+ conversation_history: Recent messages for pronoun/reference resolution.
25
+
26
+ Returns:
27
+ Dict with keys:
28
+ - ``action``: ``"search"`` or ``"clarify"``
29
+ - ``search_query``: Reformulated query (always present)
30
+ - ``original_query``: The raw user input
31
+ - ``clarification_question``: Question to ask (only if action is "clarify")
32
+ """
33
+ qu_cfg = cfg.get("query_understanding", {})
34
+
35
+ # Disabled: pass through raw query
36
+ if not qu_cfg.get("enabled", True):
37
+ return {
38
+ "action": "search",
39
+ "search_query": user_query,
40
+ "original_query": user_query,
41
+ }
42
+
43
+ domain = cfg.get("chatbot", {}).get("domain", "research")
44
+ max_history = qu_cfg.get("max_history", 6)
45
+
46
+ # Trim conversation history
47
+ history = conversation_history or []
48
+ if len(history) > max_history:
49
+ history = history[-max_history:]
50
+
51
+ prompt = build_query_understanding_prompt(user_query, domain, history)
52
+
53
+ try:
54
+ raw = generate(
55
+ "You are a query reformulation assistant. Return only JSON.",
56
+ prompt,
57
+ cfg,
58
+ max_tokens=256,
59
+ )
60
+ result = _parse_qu_result(raw, user_query)
61
+ except Exception:
62
+ # LLM failure: fall back to raw query
63
+ result = {
64
+ "action": "search",
65
+ "search_query": user_query,
66
+ "original_query": user_query,
67
+ }
68
+
69
+ return result
70
+
71
+
72
+ def _parse_qu_result(raw: str, original_query: str) -> dict:
73
+ """Parse the LLM's JSON response into a structured result.
74
+
75
+ Falls back to raw query passthrough if parsing fails.
76
+ """
77
+ parsed = None
78
+
79
+ # Try direct parse
80
+ try:
81
+ parsed = json.loads(raw)
82
+ except (json.JSONDecodeError, TypeError):
83
+ pass
84
+
85
+ # Regex fallback: find first { ... } block
86
+ if parsed is None:
87
+ match = re.search(r"\{[\s\S]*\}", raw)
88
+ if match:
89
+ try:
90
+ parsed = json.loads(match.group())
91
+ except (json.JSONDecodeError, TypeError):
92
+ pass
93
+
94
+ # Unparseable: fall back to raw query
95
+ if not isinstance(parsed, dict) or "action" not in parsed:
96
+ return {
97
+ "action": "search",
98
+ "search_query": original_query,
99
+ "original_query": original_query,
100
+ }
101
+
102
+ action = parsed.get("action", "search")
103
+
104
+ if action == "clarify":
105
+ return {
106
+ "action": "clarify",
107
+ "search_query": original_query, # fallback if clarification is skipped
108
+ "original_query": original_query,
109
+ "clarification_question": parsed.get(
110
+ "clarification_question", "Could you be more specific?"
111
+ ),
112
+ }
113
+
114
+ # action == "search"
115
+ return {
116
+ "action": "search",
117
+ "search_query": parsed.get("search_query", original_query),
118
+ "original_query": original_query,
119
+ }
tests/test_prompts.py CHANGED
@@ -36,3 +36,24 @@ def test_build_verification_prompt():
36
  assert "citation" in prompt.lower()
37
  assert "JSON" in prompt or "json" in prompt
38
  assert "error_count" in prompt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
  assert "citation" in prompt.lower()
37
  assert "JSON" in prompt or "json" in prompt
38
  assert "error_count" in prompt
39
+
40
+
41
+ def test_build_query_understanding_prompt():
42
+ from src.prompts import build_query_understanding_prompt
43
+ prompt = build_query_understanding_prompt(
44
+ "What happened in China?",
45
+ "human rights",
46
+ [{"role": "user", "content": "prior question"}],
47
+ )
48
+ assert "human rights" in prompt
49
+ assert "What happened in China?" in prompt
50
+ assert "prior question" in prompt
51
+ assert "SEARCH" in prompt or "search" in prompt
52
+ assert "CLARIFY" in prompt or "clarify" in prompt
53
+
54
+
55
+ def test_build_query_understanding_prompt_no_history():
56
+ from src.prompts import build_query_understanding_prompt
57
+ prompt = build_query_understanding_prompt("test query", "research", [])
58
+ assert "No prior conversation" in prompt
59
+ assert "test query" in prompt
tests/test_query_engine.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import pytest
3
+ from unittest.mock import patch
4
+
5
+
6
+ def _make_cfg(enabled=True, max_history=6, max_clarifications=1):
7
+ """Build a minimal config dict for query engine tests."""
8
+ return {
9
+ "chatbot": {"name": "TestBot", "domain": "human rights research"},
10
+ "llm": {"provider": "openai", "model": "gpt-4o", "temperature": 0.0, "max_tokens": 2048},
11
+ "api_keys": {"openai": "fake-key"},
12
+ "query_understanding": {
13
+ "enabled": enabled,
14
+ "max_history": max_history,
15
+ "max_clarifications": max_clarifications,
16
+ },
17
+ }
18
+
19
+
20
+ def _mock_generate_search(system_prompt, user_message, cfg, **kwargs):
21
+ """Mock LLM that always returns a search reformulation."""
22
+ return json.dumps({
23
+ "action": "search",
24
+ "search_query": "reformulated test query about human rights",
25
+ "reasoning": "expanded for better retrieval",
26
+ })
27
+
28
+
29
+ def _mock_generate_clarify(system_prompt, user_message, cfg, **kwargs):
30
+ """Mock LLM that always returns a clarification request."""
31
+ return json.dumps({
32
+ "action": "clarify",
33
+ "clarification_question": "Are you asking about X or Y?",
34
+ "reasoning": "ambiguous query",
35
+ })
36
+
37
+
38
+ def _mock_generate_error(system_prompt, user_message, cfg, **kwargs):
39
+ """Mock LLM that raises an exception."""
40
+ raise RuntimeError("LLM unavailable")
41
+
42
+
43
+ def test_understand_query_reformulates():
44
+ from src.query_engine import understand_query
45
+ with patch("src.query_engine.generate", _mock_generate_search):
46
+ result = understand_query("test query", _make_cfg())
47
+ assert result["action"] == "search"
48
+ assert result["search_query"] == "reformulated test query about human rights"
49
+ assert result["original_query"] == "test query"
50
+
51
+
52
+ def test_understand_query_clarifies():
53
+ from src.query_engine import understand_query
54
+ with patch("src.query_engine.generate", _mock_generate_clarify):
55
+ result = understand_query("AI", _make_cfg())
56
+ assert result["action"] == "clarify"
57
+ assert "clarification_question" in result
58
+ assert result["original_query"] == "AI"
59
+ # search_query should still be present as fallback
60
+ assert "search_query" in result
61
+
62
+
63
+ def test_understand_query_preserves_original():
64
+ from src.query_engine import understand_query
65
+ original = "What happened in China?"
66
+ with patch("src.query_engine.generate", _mock_generate_search):
67
+ result = understand_query(original, _make_cfg())
68
+ assert result["original_query"] == original
69
+
70
+
71
+ def test_understand_query_with_history():
72
+ from src.query_engine import understand_query
73
+ history = [
74
+ {"role": "user", "content": "Tell me about China"},
75
+ {"role": "assistant", "content": "China has various human rights issues..."},
76
+ ]
77
+ with patch("src.query_engine.generate", _mock_generate_search):
78
+ result = understand_query("what about 2023?", _make_cfg(), history)
79
+ assert result["action"] == "search"
80
+
81
+
82
+ def test_understand_query_disabled():
83
+ from src.query_engine import understand_query
84
+ result = understand_query("test query", _make_cfg(enabled=False))
85
+ assert result["action"] == "search"
86
+ assert result["search_query"] == "test query"
87
+ assert result["original_query"] == "test query"
88
+
89
+
90
+ def test_understand_query_fallback_on_error():
91
+ from src.query_engine import understand_query
92
+ with patch("src.query_engine.generate", _mock_generate_error):
93
+ result = understand_query("test query", _make_cfg())
94
+ assert result["action"] == "search"
95
+ assert result["search_query"] == "test query"
96
+
97
+
98
+ def test_parse_qu_result_malformed_json():
99
+ from src.query_engine import _parse_qu_result
100
+ result = _parse_qu_result("not json at all", "original query")
101
+ assert result["action"] == "search"
102
+ assert result["search_query"] == "original query"
103
+
104
+
105
+ def test_parse_qu_result_embedded_json():
106
+ from src.query_engine import _parse_qu_result
107
+ raw = 'Here is the result: {"action": "search", "search_query": "better query", "reasoning": "test"} end'
108
+ result = _parse_qu_result(raw, "original")
109
+ assert result["action"] == "search"
110
+ assert result["search_query"] == "better query"
tests/test_setup.py CHANGED
@@ -22,6 +22,10 @@ def test_generate_config_yaml():
22
  assert cfg["retrieval"]["chunk_size"] == 1000
23
  assert cfg["retrieval"]["chunk_overlap"] == 100
24
  assert cfg["embeddings"]["provider"] == "local"
 
 
 
 
25
 
26
  def test_generate_env_file():
27
  from setup import generate_env
 
22
  assert cfg["retrieval"]["chunk_size"] == 1000
23
  assert cfg["retrieval"]["chunk_overlap"] == 100
24
  assert cfg["embeddings"]["provider"] == "local"
25
+ # Query understanding config
26
+ assert cfg["query_understanding"]["enabled"] is True
27
+ assert cfg["query_understanding"]["max_history"] == 6
28
+ assert cfg["query_understanding"]["max_clarifications"] == 1
29
 
30
  def test_generate_env_file():
31
  from setup import generate_env