Files
DumiFlex-ComfyUI-Wildcard-P…/wp_api/_ai_embed.py
T
Dumitru Picalau 657dc07831 feat(ai): assistant overhaul: tool registry, server-kept chats, search index, security, page tools, web search, saved chats, images, permissions, derivation NOT (schema 10), OpenAI Responses API (#64)
Phases 2 to 8 of the AI assistant overhaul plus fixes from real-model chats: one tool registry with native tool calls, server-kept chat logs with prompt caching, an FTS5 library index with opt-in meaning search, keys bound to their address and a guard on every AI route, page tools and opt-in web search, saved chats with streaming, Try again, a context meter and compaction, chat images, delete/category/tag permissions with Undo, constraint and test cards, card fixes and previews, questions with answers to click, the derivation NOT toggle (schema v10, community catalog 0021), request fixes for the newest Claude models, and OpenAI's Responses API. Review fixes: Try again clears the old turn's fixes and answers, category Undo removes only what it made, a failed delete rolls back in order, not_matches patterns are checked, busy chats refuse a second request, and chat search ignores stored JSON.
2026-10-14 14:56:12 +02:00

215 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Embeddings for the library index's meaning search (opt-in).
Off unless Settings › AI assistant › Meaning search names a model. Ollama
uses its native `/api/embed` (a batch `input`); everything else
OpenAI-compatible uses `/embeddings` (LM Studio, OpenAI, OpenRouter, Voyage,
a llama.cpp server started with `--embeddings`, which needs its own
address). Claude has no embeddings API, so with Claude the embedding server
is always another address. See `/pr-drafts/ai-embeddings-providers.md`.
Item vectors are filled in the background a batch at a time after the index
syncs, so a chat never waits for a whole library to embed; until they are
all there, search simply finds fewer items by meaning. See
`/pr-drafts/ai-retrieval-index.md`.
"""
from __future__ import annotations
import asyncio
import logging
import urllib.parse
from collections import OrderedDict
from typing import Any
import aiohttp
from engine.ai import index as ai_index
from wp_api import _ai_client as client
log = logging.getLogger(__name__)
BATCH = 32
_TIMEOUT = aiohttp.ClientTimeout(total=120, sock_connect=10)
_SHORT = aiohttp.ClientTimeout(total=5, sock_connect=3)
#: Recent query vectors, so a search repeated in one chat costs nothing.
_QUERY_CACHE: OrderedDict[tuple[str, str], list[float]] = OrderedDict()
_QUERY_CACHE_SIZE = 256
#: Index ids with a fill running, so only one runs per index.
_filling: set[int] = set()
def enabled(cfg: dict[str, Any]) -> bool:
return bool(cfg.get("embed_model")) and (cfg.get("embed_api") or cfg.get("api")) != "anthropic"
def _cfg(cfg: dict[str, Any]) -> dict[str, Any]:
out = dict(cfg)
out["base_url"] = cfg.get("embed_base_url") or cfg["base_url"]
# The key goes only where it was given for (engine/ai/config.py).
out["api_key"] = cfg.get("embed_api_key", "")
out["api"] = cfg.get("embed_api") or cfg["api"]
return out
async def list_models(cfg: dict[str, Any]) -> list[str]:
"""The embedding server's models (the chat server when it has no
address of its own), only its embedding models where the server says
which those are: OpenRouter's embeddings list, LM Studio's model types.
Raises client.AIError."""
ecfg = _cfg(cfg)
if ecfg["api"] == "anthropic":
raise client.AIError("Claude has no embeddings API", status=400)
if ecfg["api"] == "openai":
host = (urllib.parse.urlsplit(ecfg["base_url"]).hostname or "").lower()
if host == "openrouter.ai" or host.endswith(".openrouter.ai"):
return await _ids(ecfg, "/models?output_modalities=embeddings")
typed = await _lmstudio_embedders(ecfg)
if typed is not None:
return typed
return await client.list_models(ecfg)
async def _ids(ecfg: dict[str, Any], path: str) -> list[str]:
data = await client._request(ecfg, "GET", path, timeout=_TIMEOUT)
items = data.get("data") if isinstance(data, dict) else None
return sorted({m["id"] for m in items or [] if isinstance(m, dict)
and isinstance(m.get("id"), str) and m["id"]})
async def _lmstudio_embedders(ecfg: dict[str, Any]) -> list[str] | None:
"""LM Studio's own model list says which models embed (`type`); None
when the server isn't LM Studio."""
parts = urllib.parse.urlsplit(ecfg["base_url"])
native = {**ecfg, "base_url": f"{parts.scheme}://{parts.netloc}"}
try:
data = await client._request(native, "GET", "/api/v0/models", timeout=_SHORT)
except client.AIError:
return None
items = data.get("data") if isinstance(data, dict) else None
if not isinstance(items, list) or not all(isinstance(m, dict) and "type" in m for m in items):
return None
return sorted({m["id"] for m in items if m.get("type") in ("embeddings", "embedding")
and isinstance(m.get("id"), str)})
async def embed(cfg: dict[str, Any], texts: list[str]) -> list[list[float]]:
"""One vector per text, in order. Raises client.AIError."""
if not enabled(cfg):
raise client.AIError("meaning search is off", status=400)
ecfg = _cfg(cfg)
if ecfg["api"] == "ollama":
body: dict[str, Any] = {"model": cfg["embed_model"], "input": texts,
"keep_alive": f"{int(cfg.get('unload_after_s') or 0)}s"}
data = await client._request(ecfg, "POST", "/api/embed", body=body, timeout=_TIMEOUT)
vecs = data.get("embeddings") if isinstance(data, dict) else None
else:
data = await client._request(ecfg, "POST", "/embeddings",
body={"model": cfg["embed_model"], "input": texts},
timeout=_TIMEOUT)
items = data.get("data") if isinstance(data, dict) else None
vecs = None
if isinstance(items, list):
ordered = sorted((x for x in items if isinstance(x, dict)),
key=lambda x: x.get("index", 0))
vecs = [x.get("embedding") for x in ordered]
if (not isinstance(vecs, list) or len(vecs) != len(texts)
or not all(isinstance(v, list) and v and all(isinstance(x, (int, float)) for x in v)
for v in vecs)):
raise client.AIError("the embedding model's answer wasn't a list of vectors. "
"Is it an embedding model?")
return [[float(x) for x in v] for v in vecs]
async def query_vector(cfg: dict[str, Any], text: str) -> list[float] | None:
"""The query's vector, or None when meaning search is off or failed
(search then uses keywords only)."""
if not enabled(cfg) or not text.strip():
return None
key = (cfg["embed_model"], text.strip()[:2000])
if key in _QUERY_CACHE:
_QUERY_CACHE.move_to_end(key)
return _QUERY_CACHE[key]
try:
vec = (await embed(cfg, [key[1]]))[0]
except client.AIError as e:
log.info("wildcard-pipeline: query embedding failed: %s", e.message)
return None
_QUERY_CACHE[key] = vec
while len(_QUERY_CACHE) > _QUERY_CACHE_SIZE:
_QUERY_CACHE.popitem(last=False)
return vec
async def fill(cfg: dict[str, Any], idx: ai_index.Index, *, max_batches: int = 1000) -> int:
"""Embed the items that have no current vector, a batch at a time.
Returns how many were embedded; stops at the first error."""
model = cfg["embed_model"]
done = 0
for _ in range(max_batches):
todo = idx.embed_todo(model, BATCH)
if not todo:
break
try:
vecs = await embed(cfg, [t for _i, _s, t in todo])
except client.AIError as e:
log.info("wildcard-pipeline: embedding the library stopped: %s", e.message)
break
idx.put_vectors(model, [(i, s, v) for (i, s, _t), v in zip(todo, vecs, strict=True)])
done += len(todo)
return done
def fill_in_background(cfg: dict[str, Any], idx: ai_index.Index) -> bool:
"""Start filling vectors unless a fill already runs for this index.
Detached from the request: Stop or a closed tab doesn't cut it short."""
if not enabled(cfg) or id(idx) in _filling or not idx.embed_todo(cfg["embed_model"], 1):
return False
_filling.add(id(idx))
async def run() -> None:
try:
await fill(cfg, idx)
finally:
_filling.discard(id(idx))
asyncio.ensure_future(run())
return True
def filling(idx: ai_index.Index) -> bool:
return id(idx) in _filling
async def _ollama_loaded(cfg: dict[str, Any]) -> list[str] | None:
try:
data = await client._request(cfg, "GET", "/api/ps", timeout=client._LIST_TIMEOUT)
except client.AIError:
return None
models = data.get("models") if isinstance(data, dict) else None
return [str(m.get("name") or m.get("model") or "") for m in models or []
if isinstance(m, dict)]
async def check(cfg: dict[str, Any]) -> dict[str, Any] | None:
"""For Test connection: does the embedding model answer, how long are
its vectors, and (Ollama) did loading it push the chat model out of
memory? That happens when Ollama keeps one model at a time (its default
on Windows with a Radeon GPU), and it would reload the chat model, and
lose what it had read, on every search."""
if not enabled(cfg):
return None
out: dict[str, Any] = {"ok": False, "dims": None, "error": None, "swapped": False}
try:
vec = (await embed(cfg, ["warm colors"]))[0]
except client.AIError as e:
out["error"] = e.message
return out
out["ok"] = True
out["dims"] = len(vec)
if cfg["api"] == "ollama" and _cfg(cfg)["base_url"] == cfg["base_url"]:
loaded = await _ollama_loaded(cfg)
chat = cfg.get("model") or ""
if loaded is not None and chat and not any(
n == chat or n.split(":")[0] == chat.split(":")[0] for n in loaded):
out["swapped"] = True
return out