Files
odysseus/src/skill_index.py
T

117 lines
4.2 KiB
Python

"""Semantic ranking for the native Odysseus skill registry.
This deliberately reuses an already-ready ToolIndex embedding lane. Skill search
must never trigger a cold embedding-model load in the foreground request path;
the existing lexical matcher remains the fallback until startup prewarm finishes.
"""
from __future__ import annotations
import hashlib
import logging
import threading
from collections import OrderedDict
from typing import Any, Dict, List, Sequence
from src.embedding_lanes import LANE_FASTEMBED
logger = logging.getLogger(__name__)
_CACHE_LIMIT = 16
_cache_lock = threading.Lock()
_corpus_cache: "OrderedDict[str, List[List[float]]]" = OrderedDict()
def _semantic_text(skill: Dict[str, Any]) -> str:
parts = [
f"Skill: {skill.get('name', '')}",
str(skill.get("description") or skill.get("title") or ""),
f"Use when: {skill.get('when_to_use') or skill.get('problem') or ''}",
]
tags = skill.get("tags") or []
if tags:
parts.append("Tags: " + ", ".join(str(tag) for tag in tags[:24]))
procedure = skill.get("procedure") or skill.get("steps") or []
if procedure:
parts.append("Procedure: " + " ".join(str(step) for step in procedure[:8]))
# Imported skills can be very large. Ranking needs intent, not full reference
# material or bundled templates.
return "\n".join(parts)[:6000]
def _choose_lane(index: Any) -> Any:
lanes = list(getattr(index, "embedding_lanes", ()) or ())
return next((lane for lane in lanes if getattr(lane, "name", "") == LANE_FASTEMBED), None) or (
lanes[0] if lanes else None
)
def _cache_key(lane: Any, documents: Sequence[str]) -> str:
lane_id = str(getattr(lane, "fingerprint", "") or getattr(lane, "name", "unknown"))
digest = hashlib.sha256()
digest.update(lane_id.encode("utf-8"))
for document in documents:
digest.update(b"\0")
digest.update(document.encode("utf-8", errors="replace"))
return digest.hexdigest()
def _document_vectors(lane: Any, documents: Sequence[str]) -> List[List[float]]:
key = _cache_key(lane, documents)
with _cache_lock:
cached = _corpus_cache.get(key)
if cached is not None:
_corpus_cache.move_to_end(key)
return cached
vectors = [list(vector) for vector in lane.encode(documents)]
with _cache_lock:
_corpus_cache[key] = vectors
_corpus_cache.move_to_end(key)
while len(_corpus_cache) > _CACHE_LIMIT:
_corpus_cache.popitem(last=False)
return vectors
def semantic_skill_scores(query: str, skills: Sequence[Dict[str, Any]]) -> Dict[int, float]:
"""Return cosine-like scores keyed by input position, or ``{}`` on fallback."""
if not query.strip() or not skills:
return {}
try:
# This accessor has no initialization side effect. Startup owns model
# loading; foreground skill matching remains fast and deterministic.
from src.tool_index import get_ready_tool_index
index = get_ready_tool_index()
if index is None:
return {}
lane = _choose_lane(index)
if lane is None:
return {}
documents = [_semantic_text(skill) for skill in skills]
vectors = _document_vectors(lane, documents)
query_vectors = lane.encode([query[:4000]])
if not query_vectors:
return {}
query_vector = list(query_vectors[0])
scores: Dict[int, float] = {}
for index_position, vector in enumerate(vectors):
if len(vector) != len(query_vector) or not vector:
continue
# EmbeddingLane.encode requests normalized vectors, so their dot
# product is cosine similarity without another numerical dependency.
score = sum(float(left) * float(right) for left, right in zip(query_vector, vector))
scores[index_position] = max(-1.0, min(1.0, score))
return scores
except Exception as exc:
logger.debug("Semantic skill ranking unavailable; using lexical fallback: %s", exc)
return {}
def reset_skill_index_cache() -> None:
"""Clear process-local vectors after embedding configuration changes/tests."""
with _cache_lock:
_corpus_cache.clear()