-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathclarification_agent.py
More file actions
276 lines (243 loc) · 11.5 KB
/
Copy pathclarification_agent.py
File metadata and controls
276 lines (243 loc) · 11.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
from __future__ import annotations
import json
import logging
from typing import Awaitable, Callable, Optional
from backend.agents.base import BaseAgent
from backend.models.enums import AgentPhase
from backend.models.schemas import (
AmbiguityReport,
ClarificationQuestion,
ClarificationRequest,
ClarifiedIntent,
)
from backend.prompts.clarification_prompt import (
AUTO_RESOLVE_PROMPT,
CLARIFICATION_SYSTEM_PROMPT,
CLARIFICATION_WITH_CONTEXT_PROMPT,
)
from backend.services.exa_service import ExaService, SearchResult
from backend.services.llm_service import LLMService
logger = logging.getLogger(__name__)
AUTO_RESOLVE_CONFIDENCE_THRESHOLD = 0.7
# System prompt used to derive Exa search queries from the intent
_QUERY_DERIVATION_PROMPT = """\
You are a search query specialist. Given a user's intent and a list of ambiguity \
dimensions, produce 2-3 precise web search queries that would surface current \
best-practice guidance, real-world examples, or domain standards relevant to the task.
Output ONLY a JSON object: {"queries": ["query1", "query2", "query3"]}
Queries should be specific, domain-aware, and suitable for a neural web search engine.
Do NOT include generic queries.
"""
class ClarificationAgent(BaseAgent):
phase = AgentPhase.CLARIFICATION
name = "Clarification Agent"
def __init__(self, llm_service: LLMService, exa_service: ExaService) -> None:
super().__init__(llm_service)
self.exa = exa_service
def get_system_prompt(self) -> str:
return CLARIFICATION_SYSTEM_PROMPT
# ------------------------------------------------------------------
# Public methods
# ------------------------------------------------------------------
async def generate_questions(
self,
input_data: AmbiguityReport,
on_stream: Optional[Callable[[str], Awaitable[None]]] = None,
) -> tuple[ClarificationRequest | None, list[SearchResult]]:
"""Generate clarification questions enriched with Exa search context.
Returns a (ClarificationRequest | None, search_results) tuple so the
caller can pass the same search_results into auto_resolve_questions()
without performing a second round of searches.
"""
if not input_data.needs_clarification:
return None, []
search_results: list[SearchResult] = []
if self.exa.available:
try:
queries = await self._derive_search_queries(input_data)
if queries:
search_results = await self.exa.search_many(queries)
logger.info(
"Exa returned %d results for queries: %s",
len(search_results),
queries,
)
except Exception:
logger.exception("Exa enrichment failed — falling back to LLM-only")
if search_results:
req = await self._generate_with_context(input_data, search_results)
else:
req = await self._generate_without_context(input_data)
return req, search_results
async def auto_resolve_questions(
self,
request: ClarificationRequest,
search_results: list[SearchResult],
ambiguity_report: AmbiguityReport,
) -> ClarificationRequest:
"""Try to auto-answer each question from search results via LLM reasoning.
Questions that can be confidently answered (confidence >= threshold) are
marked auto_resolved=True with their answer and source stored.
Questions that need personal/org-specific context remain unresolved.
"""
if not search_results:
return request
context_block = self._format_context_block(search_results)
for q in request.questions:
try:
resolved = await self._attempt_resolve(q, context_block, ambiguity_report)
if resolved:
answer, source = resolved
q.auto_resolved = True
q.auto_answer = answer
q.auto_answer_source = source
logger.info(
"Auto-resolved question [%s]: %s => %r",
q.dimension, q.question, answer
)
else:
logger.info(
"Could not auto-resolve question [%s]: %s",
q.dimension, q.question
)
except Exception:
logger.exception("Auto-resolve failed for question: %s", q.question)
request.auto_resolved_count = sum(1 for q in request.questions if q.auto_resolved)
return request
@staticmethod
def get_unresolved(request: ClarificationRequest) -> list[ClarificationQuestion]:
"""Return only the questions that could not be auto-resolved."""
return [q for q in request.questions if not q.auto_resolved]
@staticmethod
def build_auto_answers(request: ClarificationRequest) -> dict[str, str]:
"""Build a dimension→answer dict from all auto-resolved questions."""
return {
q.dimension: q.auto_answer
for q in request.questions
if q.auto_resolved and q.dimension
}
async def process_answers(
self,
ambiguity_report: AmbiguityReport,
answers: dict[str, str],
on_stream: Optional[Callable[[str], Awaitable[None]]] = None,
) -> ClarifiedIntent:
"""Take user answers (possibly merged with auto-answers) and refine intent."""
user_msg = (
f"Original intent:\n{ambiguity_report.intent.model_dump_json(indent=2)}\n\n"
f"Ambiguity flags:\n{ambiguity_report.model_dump_json(indent=2)}\n\n"
f"Clarifications:\n{json.dumps(answers, indent=2)}\n\n"
"Produce a refined, unambiguous intent.\n"
"Respond ONLY with JSON containing: refined_goal, refined_constraints, "
"refined_output_format."
)
result = await self.llm.generate_json(self.get_system_prompt(), user_msg)
result["original_intent"] = ambiguity_report.intent.model_dump()
result["clarifications"] = answers
return ClarifiedIntent(**result)
async def process(self, input_data, on_stream=None):
raise NotImplementedError(
"Use generate_questions() and process_answers() directly"
)
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
async def _attempt_resolve(
self,
question: ClarificationQuestion,
context_block: str,
report: AmbiguityReport,
) -> tuple[str, str] | None:
"""Ask LLM whether this question can be answered from search results.
Returns (answer, source) if resolvable with sufficient confidence,
or None if the question needs the user.
"""
user_msg = (
f"User's goal: {report.intent.goal}\n"
f"User's context: {report.intent.context}\n\n"
f"Clarification question: {question.question}\n"
f"Ambiguity dimension: {question.dimension}\n"
f"Why it matters: {question.why_needed}\n\n"
f"{context_block}\n\n"
"Can this question be confidently answered from the web search results alone?\n"
"Remember: only answer from the search results — do NOT use your own knowledge.\n"
'Respond ONLY with JSON: {"resolvable": bool, "answer": "...", '
'"confidence": 0.0-1.0, "source": "..."}'
)
result = await self.llm.generate_json(AUTO_RESOLVE_PROMPT, user_msg)
if not result.get("resolvable", False):
return None
confidence = float(result.get("confidence", 0.0))
if confidence < AUTO_RESOLVE_CONFIDENCE_THRESHOLD:
return None
answer = str(result.get("answer", "")).strip()
source = str(result.get("source", "web search")).strip()
if not answer:
return None
return answer, source
async def _derive_search_queries(self, report: AmbiguityReport) -> list[str]:
"""Ask the LLM to produce targeted Exa search queries from the intent."""
dimensions = ", ".join(f.dimension for f in report.flags) or "general"
user_msg = (
f"User's goal: {report.intent.goal}\n"
f"Context: {report.intent.context}\n"
f"Ambiguous dimensions: {dimensions}\n\n"
"Produce 2-3 specific web search queries to find current best practices, "
"real-world examples, or domain standards that would help clarify these dimensions.\n\n"
'Respond ONLY with JSON: {"queries": ["...", "...", "..."]}'
)
try:
result = await self.llm.generate_json(_QUERY_DERIVATION_PROMPT, user_msg)
queries = result.get("queries", [])
return [q for q in queries if isinstance(q, str) and q.strip()][:3]
except Exception:
logger.exception("Failed to derive Exa search queries")
return []
async def _generate_with_context(
self,
report: AmbiguityReport,
search_results: list[SearchResult],
) -> ClarificationRequest:
"""Generate questions using Exa search context injected into the prompt."""
context_block = self._format_context_block(search_results)
user_msg = (
"Based on the ambiguity report below AND the real-world web search context "
"provided, generate the minimum set of essential, high-impact clarification "
"questions. Use the search results to ground options in current best practices "
"and real-world examples.\n\n"
f"Ambiguity report:\n{report.model_dump_json(indent=2)}\n\n"
f"{context_block}\n\n"
"Principles: minimal, specific, decision-oriented. Max 3 questions.\n"
"Use actual examples from the search results in the 'options' field where relevant.\n\n"
"Respond ONLY with JSON matching:\n"
f"{ClarificationRequest.model_json_schema()}"
)
result = await self.llm.generate_json(CLARIFICATION_WITH_CONTEXT_PROMPT, user_msg)
req = ClarificationRequest(**result)
req.search_context = [dict(r) for r in search_results]
return req
async def _generate_without_context(
self, report: AmbiguityReport
) -> ClarificationRequest:
"""Fallback: generate questions using LLM only."""
user_msg = (
"Based on the ambiguity report below, generate the minimum set of "
"essential, high-impact clarification questions.\n\n"
f"Ambiguity report:\n{report.model_dump_json(indent=2)}\n\n"
"Principles: minimal, specific, decision-oriented. Max 3 questions.\n\n"
"Respond ONLY with JSON matching:\n"
f"{ClarificationRequest.model_json_schema()}"
)
result = await self.llm.generate_json(self.get_system_prompt(), user_msg)
return ClarificationRequest(**result)
@staticmethod
def _format_context_block(results: list[SearchResult]) -> str:
"""Format search results into a readable context block for the LLM."""
if not results:
return ""
lines = ["## Web Search Context"]
for i, r in enumerate(results, 1):
lines.append(f"{i}. **{r['title']}** ({r['url']})")
if r["snippet"]:
lines.append(f" > {r['snippet']}")
return "\n".join(lines)