mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-06 06:52:20 +02:00
117 lines
4.2 KiB
Python
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()
|