Skip to content

Commit 83c7500

Browse files
committed
complete API semantic searcher with ambiguous result handling and tool classifier routing
1 parent d159731 commit 83c7500

20 files changed

Lines changed: 2804 additions & 48 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
declaration:
2+
call: declare
3+
version: 0.1
4+
description: "Search API tool endpoints using semantic (hybrid) search against api_tool_collection(test endpoint)"
5+
method: post
6+
accepts: json
7+
returns: json
8+
namespace: rag-search
9+
allowlist:
10+
body:
11+
- field: query
12+
type: string
13+
description: "Natural-language user query to search API endpoints"
14+
- field: top_k
15+
type: integer
16+
description: "Max number of results to return (default: 5)"
17+
- field: environment
18+
type: string
19+
description: "Embedding environment (default: production)"
20+
21+
extract_request_data:
22+
assign:
23+
query: ${incoming.body.query}
24+
top_k: ${incoming.body.top_k || 5}
25+
environment: ${incoming.body.environment || 'production'}
26+
next: validate_query
27+
28+
validate_query:
29+
switch:
30+
- condition: "${!query || query.trim() === ''}"
31+
next: return_missing_query
32+
next: execute_search
33+
34+
return_missing_query:
35+
assign:
36+
error_data:
37+
success: false
38+
error: "MISSING_QUERY"
39+
message: "'query' field is required and must be a non-empty string"
40+
next: return_bad_request
41+
42+
execute_search:
43+
call: http.post
44+
args:
45+
url: "[#RAG_SEARCH_LLM_SERVICE]/api-tools/search"
46+
body:
47+
query: ${query}
48+
top_k: ${top_k}
49+
environment: ${environment}
50+
result: search_result
51+
on_error: handle_search_error
52+
next: check_search_status
53+
54+
check_search_status:
55+
switch:
56+
- condition: ${200 <= search_result.response.statusCodeValue && search_result.response.statusCodeValue < 300}
57+
next: return_ok
58+
next: handle_search_error
59+
60+
handle_search_error:
61+
assign:
62+
error_data:
63+
success: false
64+
error: "SEARCH_FAILED"
65+
message: "Semantic search failed. LLM service may be unavailable."
66+
next: return_server_error
67+
68+
return_ok:
69+
status: 200
70+
return: ${search_result.response.body}
71+
next: end
72+
73+
return_bad_request:
74+
status: 400
75+
return: ${error_data}
76+
next: end
77+
78+
return_server_error:
79+
status: 500
80+
return: ${error_data}
81+
next: end

constants.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ DOMAIN=localhost
1212
DB_PASSWORD=dbadmin
1313
RAG_SEARCH_RUUTER_PUBLIC_INTERNAL_SERVICE=http://ruuter:8086/services
1414
SERVICE_DMAPPER_HBS=http://data-mapper:3000/hbs/rag-search
15-
SERVICE_PROJECT_LAYER=services
15+
SERVICE_PROJECT_LAYER=services
16+
RAG_SEARCH_LLM_SERVICE=http://llm-orchestration-service:8100

docs/API_TOOL_CALLING.md

Lines changed: 198 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,12 @@ API Tool Calling enables the LLM module to discover and invoke external API endp
88
in response to user queries. endpoints are
99
registered, semantically indexed in Qdrant, and retrieved at query time using hybrid search.
1010

11-
The feature has two halves:
1211

13-
| Half | What it does | Status |
12+
| component | What it does | Status |
1413
|---|---|---|
15-
| **Indexing pipeline** | Takes an endpoint definition → enriches it with LLM context → stores hybrid vectors in Qdrant | Complete |
16-
| **Tool classifier** | At query time, routes to the best matching endpoint via hybrid search | In progress |
14+
| **Indexing pipeline** | Takes an endpoint definition → enriches it with LLM context → stores hybrid vectors in Qdrant | ✅ Complete |
15+
| **Tool classifier** | At query time, routes to the best matching endpoint via hybrid search + LLM disambiguation | ✅ Complete |
16+
| **Workflow executor** | Surfaces the matched endpoint; full agentic loop (param collection → API call) planned | 🔧 Partial (Task 10) |
1717

1818
---
1919

@@ -30,7 +30,11 @@ main_indexer.py (indexing pipeline)
3030
↓ upsert
3131
api_tool_collection (Qdrant)
3232
↑ query at runtime
33-
ToolClassifier (src/tool_classifier/)
33+
APISemanticSearcher (src/tool_classifier/api_semantic_searcher.py)
34+
↑ called by
35+
ToolClassifier._try_api_tool_classification()
36+
↓ ClassificationResult(workflow=API_TOOL_CALLING)
37+
APIToolWorkflowExecutor (src/tool_classifier/workflows/api_tool_workflow.py)
3438
```
3539

3640
---
@@ -97,7 +101,7 @@ Defined in [DSL/CronManager/script/api_tool_indexer.sh](../DSL/CronManager/scrip
97101

98102
**What it does (in order):**
99103

100-
1. Validates required env vars (`endpoint_id`, `name`, `description`)
104+
1. Validates required env vars (`endpoint_id`, `name`, `description`, `url`)
101105
2. Activates the pre-built Python venv at `/app/python_virtual_env`
102106
3. Installs required packages via `uv pip install` (`httpx`, `pydantic`, `qdrant-client`, `loguru`)
103107
4. Sets `PYTHONPATH` to include `/app/src`
@@ -235,4 +239,192 @@ loop can execute the API call without an additional database round-trip.
235239
| `required` | bool | Whether the caller must supply this param |
236240
| `description` | str | Human-readable description |
237241

242+
---
243+
244+
245+
## Part 2 — Tool Classifier (Query-Time)
246+
247+
### Overview
248+
249+
At query time, `ToolClassifier` in [src/tool_classifier/classifier.py](../src/tool_classifier/classifier.py) the layer by layer execution happens
250+
251+
252+
1. **Service search**`intent_collections` (Qdrant) — existing Bürokratt services
253+
2. **API Tool search**`api_tool_collection` (Qdrant) — registered API tool endpoints
254+
255+
API tool search (`_try_api_tool_classification`) is triggered when:
256+
- `SERVICE_WORKFLOW_ENABLED=false` (service workflow disabled globally)
257+
- Dense service search returns no results
258+
- Service cosine score falls below `DENSE_MIN_THRESHOLD`
259+
260+
It is **always** tried before falling back to Context/RAG.
261+
262+
---
263+
264+
### Component: `APISemanticSearcher`
265+
266+
Defined in [src/tool_classifier/api_semantic_searcher.py](../src/tool_classifier/api_semantic_searcher.py).
267+
268+
Instantiated once in `ToolClassifier.__init__()` and reuses the shared Qdrant `httpx.AsyncClient`.
269+
270+
**Constructor:**
271+
272+
```python
273+
APISemanticSearcher(
274+
embedding_service=orchestration_service, # generates dense embeddings
275+
qdrant_client=self._qdrant_client, # shared connection pool
276+
disambiguator=None, # optional: inject for testing
277+
)
278+
```
279+
280+
**Key constants** (from `constants.py`):
281+
282+
| Constant | Value | Purpose |
283+
|---|---|---|
284+
| `API_TOOL_COLLECTION` | `api_tool_collection` | Qdrant collection name |
285+
| `API_TOOL_SEARCH_TOP_K` | `5` | Max hybrid results |
286+
| `API_TOOL_MIN_THRESHOLD` | cosine threshold | Below this → no match |
287+
| `API_TOOL_HIGH_CONFIDENCE_THRESHOLD` | cosine threshold | Above this → high confidence |
288+
| `API_TOOL_SCORE_GAP_THRESHOLD` | gap threshold | Minimum lead over runner-up |
289+
290+
---
291+
292+
### Search Flow: `APISemanticSearcher.search()`
293+
294+
```
295+
User query
296+
297+
├─ precomputed_embedding provided? → reuse it (no extra API call)
298+
└─ otherwise → generate dense embedding via embedding_service
299+
300+
301+
Step 1: Dense search (api_tool_collection)
302+
→ Real cosine similarity scores per endpoint
303+
304+
├─ No results → return []
305+
├─ top_cosine < API_TOOL_MIN_THRESHOLD → return []
306+
└─ continue
307+
308+
309+
Step 2: Hybrid search (dense + sparse/BM25 + RRF)
310+
→ Best-ranked results by RRF fusion score
311+
│ Falls back to dense results if hybrid returns nothing
312+
313+
314+
Step 3: Annotate confidence for each hybrid result
315+
316+
│ cosine lookup: dense_cosine_map[endpoint_id]
317+
│ └─ fallback: point["cosine_score"] (sparse-driven result)
318+
│ └─ skip if neither available
319+
320+
│ effective_gap = this_cosine − best_other_cosine_in_dense
321+
322+
├─ i==0 AND cosine ≥ HIGH_THRESHOLD AND effective_gap ≥ GAP_THRESHOLD → "high"
323+
├─ cosine ≥ MIN_THRESHOLD → "medium"
324+
└─ else → skip
325+
326+
327+
Step 4: Resolve to exactly one result
328+
├─ high-confidence result exists → return immediately
329+
├─ single medium + large gap → return directly
330+
└─ multiple medium OR small gap → LLM disambiguation
331+
332+
└─ EndpointDisambiguatorModule (DSPy + asyncio.to_thread)
333+
→ picks winner or returns None
334+
→ None means no match → return []
335+
```
336+
337+
---
338+
339+
### Embedding Reuse
340+
341+
When `ToolClassifier.classify()` already generated a dense embedding for the service
342+
search, it passes it as `precomputed_embedding` to `_try_api_tool_classification`:
343+
344+
```python
345+
api_tool_result = await self._try_api_tool_classification(
346+
query, request, precomputed_embedding=query_embedding
347+
)
348+
```
349+
350+
`APISemanticSearcher.search()` skips the embedding step entirely when this is provided,
351+
saving one embedding API call per request.
352+
353+
---
354+
355+
### LLM Disambiguation: `EndpointDisambiguatorModule`
356+
357+
Used when multiple medium-confidence endpoints score similarly and no clear winner
358+
can be determined from cosine scores alone.
359+
360+
- DSPy `Predict` module with `EndpointDisambiguationSignature`
361+
- Inputs: `user_query` + `candidates` (JSON list of `{endpoint_id, name, description, cosine_score}`)
362+
- Output: `best_endpoint_id` — the winning `endpoint_id`, or `"none"` if no match
363+
- Run via `asyncio.to_thread()` to avoid blocking the async event loop
364+
- Understands Estonian, Russian, and English queries
365+
366+
---
367+
368+
### Feature Flag
369+
370+
API tool calling is gated by `FeatureFlags.API_TOOL_CALLING_WORKFLOW_ENABLED`.
371+
When `false`, `_try_api_tool_classification` returns `None` immediately without
372+
touching Qdrant.
373+
374+
---
375+
376+
### Component: `APIToolWorkflowExecutor`
377+
378+
Defined in [src/tool_classifier/workflows/api_tool_workflow.py](../src/tool_classifier/workflows/api_tool_workflow.py).
379+
380+
Handles `WorkflowType.API_TOOL_CALLING` after `ToolClassifier.classify()` has set
381+
`matched_endpoint` in the context dict.
382+
383+
**Current behaviour (Task 4.1):**
384+
385+
Reads `context["matched_endpoint"]` and returns a simple confirmation response:
386+
387+
```
388+
**{name}**: {description}
389+
390+
URL: {url}
391+
```
392+
393+
**Planned (Task 10):** Full agentic loop —
394+
session management → parameter collection dialog → external API call → response formatting.
395+
396+
---
397+
398+
### End-to-End Flow (Query Time)
399+
400+
```
401+
User: "What are the public holidays in Estonia?"
402+
403+
404+
ToolClassifier.classify()
405+
406+
├─ Dense search (intent_collections) → low cosine → below threshold
407+
408+
└─ _try_api_tool_classification()
409+
410+
└─ APISemanticSearcher.search()
411+
412+
├─ Dense: get_national_holidays cosine=0.87
413+
├─ Hybrid: get_national_holidays ranked #1 (RRF)
414+
├─ effective_gap large → confidence="high"
415+
└─ return [APIToolSearchResult(name="get_national_holidays", ...)]
416+
417+
└─ ClassificationResult(
418+
workflow=API_TOOL_CALLING,
419+
metadata={"matched_endpoint": {...}}
420+
)
421+
422+
423+
ToolClassifier._execute_with_fallback_async()
424+
425+
└─ APIToolWorkflowExecutor.execute_async(context={"matched_endpoint": {...}})
426+
427+
└─ OrchestrationResponse(content="**get_national_holidays**: ...")
428+
```
429+
238430
---

src/api_tool_indexer/constants.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,9 @@ class ApiToolIndexerConstants:
3131
REQUEST_TIMEOUT = 60 # seconds
3232

3333
# Context Enrichment Template
34-
# Used to generate a rich semantic context for each endpoint before embedding
34+
# Mirrors the service workflow (intent_data_enrichment/constants.py).
35+
# Full template goes in chunk_prompt; document_prompt is left empty.
36+
# The LLM summarises the chunk content into a rich semantic context.
3537
CONTEXT_TEMPLATE = """<document>
3638
{full_endpoint_info}
3739
</document>
@@ -44,12 +46,24 @@ class ApiToolIndexerConstants:
4446
</endpoint>
4547
4648
Please generate a rich, detailed context that describes this API endpoint comprehensively for semantic search.
47-
Include information about:
49+
Keep the prose context general and country-agnostic. Include information about:
4850
- What the user wants to accomplish by calling this endpoint
4951
- Key terms and synonyms for this action
5052
- Related concepts and use cases
5153
- Common ways users might ask for this functionality in natural language
5254
53-
IMPORTANT: Generate the context in the SAME LANGUAGE as the endpoint description above. If the description is in Estonian, respond in Estonian. If in English, respond in English. If in Russian, respond in Russian.
55+
Then, on a new line, add a section exactly as shown below with 6 to 8 realistic and diverse example questions a real user might ask when they need this endpoint. Cover different phrasings, synonyms, and indirect ways of asking — do not just repeat the description verbatim.
5456
55-
Answer only with the enriched context and nothing else."""
57+
IMPORTANT for example queries: This is a system built for Estonian government digital services (Bürokratt). Ground the examples in an Estonian context — use Estonian cities (Tallinn, Tartu, Pärnu, Narva), Estonian institutions, and Estonia-relevant scenarios. Only use non-Estonian locations if the endpoint is explicitly about comparing or fetching data for multiple countries.
58+
59+
Example queries:
60+
- <example question 1>
61+
- <example question 2>
62+
- <example question 3>
63+
- <example question 4>
64+
- <example question 5>
65+
- <example question 6>
66+
67+
IMPORTANT: Generate everything in the SAME LANGUAGE as the endpoint description above. If the description is in Estonian, respond in Estonian. If in English, respond in English. If in Russian, respond in Russian.
68+
69+
Answer only with the enriched context and example queries — nothing else."""

0 commit comments

Comments
 (0)