Spaces:
Running
Running
Commit ·
275f972
1
Parent(s): a6a4880
ok
Browse files- app/api/v1/router.py +3 -1
- app/api/v1/semantic_router.py +39 -0
- app/api/v1/token_counter.py +40 -0
- app/models/schemas.py +38 -0
- app/services/semantic_router_service.py +87 -0
app/api/v1/router.py
CHANGED
|
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
|
| 5 |
-
from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, sql_validator, system, web_search
|
| 6 |
from app.api.verify import router as verify_router
|
| 7 |
|
| 8 |
api_v1_router = APIRouter()
|
|
@@ -17,4 +17,6 @@ api_v1_router.include_router(reconcile.router, tags=["Reconcile"])
|
|
| 17 |
api_v1_router.include_router(scraper.router, tags=["Web Scraping"])
|
| 18 |
api_v1_router.include_router(web_search.router, tags=["Web Search"])
|
| 19 |
api_v1_router.include_router(sql_validator.router, tags=["SQL Validator"])
|
|
|
|
|
|
|
| 20 |
api_v1_router.include_router(chat.router, tags=["Chat"])
|
|
|
|
| 2 |
|
| 3 |
from fastapi import APIRouter
|
| 4 |
|
| 5 |
+
from app.api.v1 import batch, chat, code_executor, convert, database, embeddings, reconcile, scraper, semantic_router, sql_validator, system, token_counter, web_search
|
| 6 |
from app.api.verify import router as verify_router
|
| 7 |
|
| 8 |
api_v1_router = APIRouter()
|
|
|
|
| 17 |
api_v1_router.include_router(scraper.router, tags=["Web Scraping"])
|
| 18 |
api_v1_router.include_router(web_search.router, tags=["Web Search"])
|
| 19 |
api_v1_router.include_router(sql_validator.router, tags=["SQL Validator"])
|
| 20 |
+
api_v1_router.include_router(semantic_router.router, tags=["Semantic Router"])
|
| 21 |
+
api_v1_router.include_router(token_counter.router, tags=["Token Counter"])
|
| 22 |
api_v1_router.include_router(chat.router, tags=["Chat"])
|
app/api/v1/semantic_router.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from app.api.deps import get_embeddings_service, require_auth
|
| 8 |
+
from app.models.schemas import SemanticRouterRequest, SemanticRouterResponse
|
| 9 |
+
from app.services.embeddings_service import EmbeddingService
|
| 10 |
+
from app.services.semantic_router_service import SemanticRouterService
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@router.post(
|
| 16 |
+
"/semantic-router/route",
|
| 17 |
+
response_model=SemanticRouterResponse,
|
| 18 |
+
summary="Route a query to the best matching route using semantic similarity",
|
| 19 |
+
)
|
| 20 |
+
async def route_query(
|
| 21 |
+
body: SemanticRouterRequest,
|
| 22 |
+
token: str = Depends(require_auth),
|
| 23 |
+
embedding_service: EmbeddingService = Depends(get_embeddings_service),
|
| 24 |
+
) -> SemanticRouterResponse:
|
| 25 |
+
start = time.perf_counter()
|
| 26 |
+
svc = SemanticRouterService(embedding_service)
|
| 27 |
+
routes_dict = [r.model_dump() for r in body.routes]
|
| 28 |
+
result = svc.route(body.query, routes_dict, body.threshold)
|
| 29 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 30 |
+
return SemanticRouterResponse(
|
| 31 |
+
success=result["success"],
|
| 32 |
+
time_ms=elapsed_ms,
|
| 33 |
+
name=result.get("name"),
|
| 34 |
+
models=result.get("models", []),
|
| 35 |
+
error=result.get("error"),
|
| 36 |
+
confidence=result.get("confidence"),
|
| 37 |
+
threshold=result.get("threshold"),
|
| 38 |
+
matched_utterance=result.get("matched_utterance"),
|
| 39 |
+
)
|
app/api/v1/token_counter.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
|
| 7 |
+
from app.api.deps import require_auth
|
| 8 |
+
from app.models.domain import count_tokens
|
| 9 |
+
from app.models.schemas import TokenCountRequest, TokenCountResponse
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@router.post(
|
| 15 |
+
"/token/count",
|
| 16 |
+
response_model=TokenCountResponse,
|
| 17 |
+
summary="Count tokens in text using tiktoken",
|
| 18 |
+
)
|
| 19 |
+
async def count_text_tokens(
|
| 20 |
+
body: TokenCountRequest,
|
| 21 |
+
token: str = Depends(require_auth),
|
| 22 |
+
) -> TokenCountResponse:
|
| 23 |
+
start = time.perf_counter()
|
| 24 |
+
try:
|
| 25 |
+
token_count = count_tokens(body.text, body.encoding)
|
| 26 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 27 |
+
return TokenCountResponse(
|
| 28 |
+
success=True,
|
| 29 |
+
time_ms=elapsed_ms,
|
| 30 |
+
token_count=token_count,
|
| 31 |
+
char_count=len(body.text),
|
| 32 |
+
encoding=body.encoding,
|
| 33 |
+
)
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
elapsed_ms = round((time.perf_counter() - start) * 1000, 3)
|
| 36 |
+
return TokenCountResponse(
|
| 37 |
+
success=False,
|
| 38 |
+
time_ms=elapsed_ms,
|
| 39 |
+
error=str(exc),
|
| 40 |
+
)
|
app/models/schemas.py
CHANGED
|
@@ -6,6 +6,44 @@ from typing import Any, Dict, List, Literal, Optional
|
|
| 6 |
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 7 |
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
class ConversionMetadata(BaseModel):
|
| 11 |
source: str
|
|
|
|
| 6 |
from pydantic import BaseModel, Field, field_validator, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
+
class RouteConfig(BaseModel):
|
| 10 |
+
name: str = Field(..., min_length=1, description="Route name")
|
| 11 |
+
utterances: List[str] = Field(..., min_length=1, description="Example phrases for this route")
|
| 12 |
+
models: List[str] = Field(default_factory=list, description="Model identifiers assigned to this route")
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SemanticRouterRequest(BaseModel):
|
| 16 |
+
query: str = Field(..., min_length=1, max_length=50000, description="User query to route")
|
| 17 |
+
routes: List[RouteConfig] = Field(..., min_length=1, description="Route definitions")
|
| 18 |
+
threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="Minimum similarity score to match")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class SemanticRouterResponse(BaseModel):
|
| 22 |
+
success: bool
|
| 23 |
+
time_ms: float
|
| 24 |
+
name: Optional[str] = None
|
| 25 |
+
models: List[str] = []
|
| 26 |
+
error: Optional[str] = None
|
| 27 |
+
confidence: Optional[float] = None
|
| 28 |
+
margin: Optional[float] = None
|
| 29 |
+
threshold: Optional[float] = None
|
| 30 |
+
matched_utterance: Optional[str] = None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class TokenCountRequest(BaseModel):
|
| 34 |
+
text: str = Field(..., min_length=1, max_length=1000000, description="Text to count tokens for")
|
| 35 |
+
encoding: str = Field(default="o200k_base", description="TikToken encoding name")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TokenCountResponse(BaseModel):
|
| 39 |
+
success: bool
|
| 40 |
+
time_ms: float
|
| 41 |
+
token_count: int = 0
|
| 42 |
+
char_count: int = 0
|
| 43 |
+
encoding: str = "o200k_base"
|
| 44 |
+
error: Optional[str] = None
|
| 45 |
+
|
| 46 |
+
|
| 47 |
|
| 48 |
class ConversionMetadata(BaseModel):
|
| 49 |
source: str
|
app/services/semantic_router_service.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Any, Optional
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
|
| 8 |
+
from app.services.embeddings_service import EmbeddingService
|
| 9 |
+
|
| 10 |
+
_logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
_DIMENSION = 384
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class SemanticRouterService:
|
| 16 |
+
def __init__(self, embedding_service: EmbeddingService) -> None:
|
| 17 |
+
self._embedding_service = embedding_service
|
| 18 |
+
|
| 19 |
+
def route(
|
| 20 |
+
self,
|
| 21 |
+
query: str,
|
| 22 |
+
routes: list[dict[str, Any]],
|
| 23 |
+
threshold: float = 0.7,
|
| 24 |
+
) -> dict[str, Any]:
|
| 25 |
+
if not query or not query.strip():
|
| 26 |
+
return {"success": False, "name": None, "models": [], "error": "Query is empty."}
|
| 27 |
+
if not routes:
|
| 28 |
+
return {"success": False, "name": None, "models": [], "error": "No routes provided."}
|
| 29 |
+
|
| 30 |
+
if not self._embedding_service.is_loaded(_DIMENSION):
|
| 31 |
+
return {"success": False, "name": None, "models": [], "error": "Embedding model not loaded."}
|
| 32 |
+
|
| 33 |
+
all_utterances: list[str] = []
|
| 34 |
+
utterance_to_route: list[int] = []
|
| 35 |
+
|
| 36 |
+
for idx, route in enumerate(routes):
|
| 37 |
+
utterances = route.get("utterances", [])
|
| 38 |
+
if not utterances:
|
| 39 |
+
continue
|
| 40 |
+
all_utterances.extend(utterances)
|
| 41 |
+
utterance_to_route.extend([idx] * len(utterances))
|
| 42 |
+
|
| 43 |
+
if not all_utterances:
|
| 44 |
+
return {"success": False, "name": None, "models": [], "error": "No utterances found in any route."}
|
| 45 |
+
|
| 46 |
+
try:
|
| 47 |
+
all_texts = all_utterances + [query]
|
| 48 |
+
embeddings = self._embedding_service.generate_embedding(all_texts, _DIMENSION)
|
| 49 |
+
except Exception as exc:
|
| 50 |
+
_logger.error("Embedding failed: %s", exc)
|
| 51 |
+
return {"success": False, "name": None, "models": [], "error": str(exc)}
|
| 52 |
+
|
| 53 |
+
utterance_embs = np.array(embeddings[:-1], dtype=np.float32)
|
| 54 |
+
query_emb = np.array(embeddings[-1], dtype=np.float32).reshape(1, -1)
|
| 55 |
+
|
| 56 |
+
similarities = (utterance_embs @ query_emb.T).flatten()
|
| 57 |
+
best_idx = int(np.argmax(similarities))
|
| 58 |
+
best_score = float(similarities[best_idx])
|
| 59 |
+
|
| 60 |
+
sorted_scores = sorted(similarities, reverse=True)
|
| 61 |
+
margin = (sorted_scores[0] - sorted_scores[1]) if len(sorted_scores) > 1 else 1.0
|
| 62 |
+
|
| 63 |
+
if best_score < threshold or margin < 0.01:
|
| 64 |
+
return {
|
| 65 |
+
"success": True,
|
| 66 |
+
"name": None,
|
| 67 |
+
"models": [],
|
| 68 |
+
"error": None,
|
| 69 |
+
"confidence": best_score,
|
| 70 |
+
"margin": margin,
|
| 71 |
+
"threshold": threshold,
|
| 72 |
+
"matched_utterance": all_utterances[best_idx],
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
matched_route_idx = utterance_to_route[best_idx]
|
| 76 |
+
matched_route = routes[matched_route_idx]
|
| 77 |
+
|
| 78 |
+
return {
|
| 79 |
+
"success": True,
|
| 80 |
+
"name": matched_route.get("name"),
|
| 81 |
+
"models": matched_route.get("models", []),
|
| 82 |
+
"error": None,
|
| 83 |
+
"confidence": best_score,
|
| 84 |
+
"margin": margin,
|
| 85 |
+
"threshold": threshold,
|
| 86 |
+
"matched_utterance": all_utterances[best_idx],
|
| 87 |
+
}
|