feat: bump to 0.3.0, single-source version, Voyage retry/backoff
- pyproject.toml -> 0.3.0 (significant changes since 0.2.0: full Phase 1-7 KB stack, classifier+labels, batch-upload UI, pending-review panel, bucket rename to legal-kb) - api/__init__.py reads version from pyproject at import time so /api/health and OpenAPI both reflect reality without a second hardcoded copy - api/services/kb/voyage.py: _post_with_retry wraps embed/rerank POSTs with exponential backoff on 429+5xx (max 5 retries, base 2s, capped 30s, +25% jitter, honors Retry-After header). Dev and prod share a single VOYAGE_API_KEY today, so a simultaneous bulk re-ingest on both used to fail half the calls; now the burst is absorbed in seconds. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,27 @@
|
|||||||
|
"""shira-hermes API package."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _read_version() -> str:
|
||||||
|
"""Source-of-truth version from pyproject.toml.
|
||||||
|
|
||||||
|
pyproject lives at the repo root; we walk up from this file
|
||||||
|
instead of relying on importlib.metadata so the version is
|
||||||
|
correct in editable installs and in the Docker image where the
|
||||||
|
package may not be `pip install`-ed at all.
|
||||||
|
"""
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for parent in [here.parent, *here.parents]:
|
||||||
|
candidate = parent / "pyproject.toml"
|
||||||
|
if candidate.exists():
|
||||||
|
with candidate.open("rb") as f:
|
||||||
|
data = tomllib.load(f)
|
||||||
|
return data.get("project", {}).get("version", "0.0.0")
|
||||||
|
return "0.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
__version__ = _read_version()
|
||||||
|
|||||||
+2
-1
@@ -9,6 +9,7 @@ from pathlib import Path
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from api import __version__
|
||||||
from api.routes.admin_kb import router as admin_kb_router
|
from api.routes.admin_kb import router as admin_kb_router
|
||||||
from api.routes.health import router as health_router
|
from api.routes.health import router as health_router
|
||||||
from api.routes.kb_public import router as kb_public_router
|
from api.routes.kb_public import router as kb_public_router
|
||||||
@@ -24,7 +25,7 @@ logger = logging.getLogger("shira.app")
|
|||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="shira-hermes",
|
title="shira-hermes",
|
||||||
description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant",
|
description="Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant",
|
||||||
version="0.2.0",
|
version=__version__,
|
||||||
)
|
)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ from datetime import datetime, timezone
|
|||||||
|
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
|
|
||||||
router = APIRouter()
|
from api import __version__
|
||||||
|
|
||||||
VERSION = "0.2.0"
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/api/health")
|
@router.get("/api/health")
|
||||||
@@ -16,6 +16,6 @@ async def health():
|
|||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"service": "shira-hermes",
|
"service": "shira-hermes",
|
||||||
"version": VERSION,
|
"version": __version__,
|
||||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -20,11 +22,58 @@ _MAX_BATCH_TOKENS = 90_000
|
|||||||
# Voyage's multilingual tokenizer splits Hebrew more aggressively than ASCII.
|
# Voyage's multilingual tokenizer splits Hebrew more aggressively than ASCII.
|
||||||
_TOKENS_PER_CHAR_EST = 0.55
|
_TOKENS_PER_CHAR_EST = 0.55
|
||||||
|
|
||||||
|
# Retry parameters for transient failures (429 + 5xx). Dev and prod share a
|
||||||
|
# single VOYAGE_API_KEY; without retry, simultaneous bulk re-ingests on both
|
||||||
|
# environments hit the per-key RPM limit and fail one of them. With
|
||||||
|
# exponential backoff (jittered) the burst is absorbed in seconds.
|
||||||
|
_MAX_RETRIES = 5
|
||||||
|
_BASE_BACKOFF_S = 2.0
|
||||||
|
_MAX_BACKOFF_S = 30.0
|
||||||
|
|
||||||
|
|
||||||
class VoyageError(RuntimeError):
|
class VoyageError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_after_seconds(resp: httpx.Response, attempt: int) -> float:
|
||||||
|
"""Honor `Retry-After` header if present, else exponential backoff."""
|
||||||
|
hdr = resp.headers.get("retry-after")
|
||||||
|
if hdr:
|
||||||
|
try:
|
||||||
|
return min(_MAX_BACKOFF_S, max(0.1, float(hdr)))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
backoff = min(_MAX_BACKOFF_S, _BASE_BACKOFF_S * (2 ** attempt))
|
||||||
|
return backoff + random.uniform(0, backoff * 0.25)
|
||||||
|
|
||||||
|
|
||||||
|
async def _post_with_retry(
|
||||||
|
http: httpx.AsyncClient, url: str, *, headers: dict, json: dict, what: str,
|
||||||
|
) -> httpx.Response:
|
||||||
|
"""POST with exponential backoff on 429 and 5xx.
|
||||||
|
|
||||||
|
Returns the final Response (caller still checks status_code for
|
||||||
|
non-retryable errors, e.g. 4xx other than 429).
|
||||||
|
"""
|
||||||
|
last_resp: httpx.Response | None = None
|
||||||
|
for attempt in range(_MAX_RETRIES + 1):
|
||||||
|
resp = await http.post(url, headers=headers, json=json)
|
||||||
|
last_resp = resp
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp
|
||||||
|
if resp.status_code != 429 and resp.status_code < 500:
|
||||||
|
return resp
|
||||||
|
if attempt == _MAX_RETRIES:
|
||||||
|
return resp
|
||||||
|
delay = _retry_after_seconds(resp, attempt)
|
||||||
|
logger.warning(
|
||||||
|
"[kb.voyage] %s got %d, retry %d/%d in %.1fs",
|
||||||
|
what, resp.status_code, attempt + 1, _MAX_RETRIES, delay,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
return last_resp # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
async def embed(
|
async def embed(
|
||||||
texts: list[str],
|
texts: list[str],
|
||||||
input_type: Literal["document", "query"] = "document",
|
input_type: Literal["document", "query"] = "document",
|
||||||
@@ -56,10 +105,12 @@ async def embed(
|
|||||||
out: list[list[float]] = []
|
out: list[list[float]] = []
|
||||||
async with httpx.AsyncClient(timeout=120) as http:
|
async with httpx.AsyncClient(timeout=120) as http:
|
||||||
for bi, batch in enumerate(batches):
|
for bi, batch in enumerate(batches):
|
||||||
resp = await http.post(
|
resp = await _post_with_retry(
|
||||||
|
http,
|
||||||
_VOYAGE_URL,
|
_VOYAGE_URL,
|
||||||
headers={"Authorization": f"Bearer {api_key}"},
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
json={"model": model, "input": batch, "input_type": input_type},
|
json={"model": model, "input": batch, "input_type": input_type},
|
||||||
|
what=f"embed batch {bi+1}/{len(batches)}",
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise VoyageError(f"Voyage API {resp.status_code}: {resp.text[:200]}")
|
raise VoyageError(f"Voyage API {resp.status_code}: {resp.text[:200]}")
|
||||||
@@ -104,10 +155,12 @@ async def rerank(
|
|||||||
payload["top_k"] = int(top_k)
|
payload["top_k"] = int(top_k)
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=60) as http:
|
async with httpx.AsyncClient(timeout=60) as http:
|
||||||
resp = await http.post(
|
resp = await _post_with_retry(
|
||||||
|
http,
|
||||||
_VOYAGE_RERANK_URL,
|
_VOYAGE_RERANK_URL,
|
||||||
headers={"Authorization": f"Bearer {api_key}"},
|
headers={"Authorization": f"Bearer {api_key}"},
|
||||||
json=payload,
|
json=payload,
|
||||||
|
what="rerank",
|
||||||
)
|
)
|
||||||
if resp.status_code != 200:
|
if resp.status_code != 200:
|
||||||
raise VoyageError(f"Voyage rerank {resp.status_code}: {resp.text[:200]}")
|
raise VoyageError(f"Voyage rerank {resp.status_code}: {resp.text[:200]}")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "shira-hermes"
|
name = "shira-hermes"
|
||||||
version = "0.2.0"
|
version = "0.3.0"
|
||||||
description = "Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant"
|
description = "Shira AI Assistant — Hermes Agent backend for EspoCRM SmartAssistant"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
|||||||
Reference in New Issue
Block a user