Skip to content

Commit f569070

Browse files
authored
Merge pull request #160 from rootcodelabs/llm-403
Get update from llm-403 into llm-408
2 parents f6a4300 + c5582f8 commit f569070

27 files changed

Lines changed: 5206 additions & 153 deletions

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: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,19 @@ class ApiToolIndexerConstants:
3030
RETRY_DELAY_BASE = 2 # Exponential backoff base (2^attempt seconds)
3131
REQUEST_TIMEOUT = 60 # seconds
3232

33+
# Number of example queries generated per endpoint.
34+
# Each example becomes its own Qdrant point so its vector sits in the exact
35+
# language region of the embedding space, enabling short-query matching.
36+
EXAMPLE_QUERY_COUNT = 5
37+
3338
# Context Enrichment Template
34-
# Used to generate a rich semantic context for each endpoint before embedding
39+
# Full template goes in chunk_prompt; document_prompt is left empty.
40+
#
41+
# Multi-point indexing strategy:
42+
# - Each example query line is extracted and stored as its own Qdrant point,
43+
# embedded from that individual sentence alone.
44+
# - The prose + all examples combined become one summary point.
45+
# All in the same language as the endpoint description — no bilingual duplication.
3546
CONTEXT_TEMPLATE = """<document>
3647
{full_endpoint_info}
3748
</document>
@@ -44,12 +55,23 @@ class ApiToolIndexerConstants:
4455
</endpoint>
4556
4657
Please generate a rich, detailed context that describes this API endpoint comprehensively for semantic search.
47-
Include information about:
58+
Keep the prose context general and country-agnostic. Include information about:
4859
- What the user wants to accomplish by calling this endpoint
4960
- Key terms and synonyms for this action
5061
- Related concepts and use cases
5162
- Common ways users might ask for this functionality in natural language
5263
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.
64+
IMPORTANT: Generate the prose context and the example questions in the SAME LANGUAGE as the endpoint description above. However, always use the exact section header "Example queries:" in English regardless of language — this is a required machine-readable marker.
65+
66+
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.
67+
68+
Then add a section with exactly {example_count} 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.
69+
70+
Example queries:
71+
- <example question 1>
72+
- <example question 2>
73+
- <example question 3>
74+
- <example question 4>
75+
- <example question 5>
5476
55-
Answer only with the enriched context and nothing else."""
77+
Answer only with the enriched context and example queries — nothing else."""

0 commit comments

Comments
 (0)