Compare commits
10 Commits
fbd1ec9fb9
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c91ec03b83 | |||
| 9b6666dc9c | |||
| fdc1fa1817 | |||
| cfd19e2b34 | |||
| 6a6b821a0d | |||
| 13d2a04052 | |||
| d0c7ab61a7 | |||
| c2e6b8e60e | |||
| ae1bd67772 | |||
| 9f22dbf94b |
+142
-55
@@ -1,4 +1,5 @@
|
|||||||
# ai command cogs
|
# ai command cogs
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
@@ -17,7 +18,7 @@ from communication_subroutine import AI_QUERY_Q
|
|||||||
|
|
||||||
import ai_functions
|
import ai_functions
|
||||||
from constants import (
|
from constants import (
|
||||||
ASSISTANTS,
|
OLLAMA_WARM_MINUTES,
|
||||||
DATA,
|
DATA,
|
||||||
GRAPHICS_PATH,
|
GRAPHICS_PATH,
|
||||||
INITIAL_TIME_WAIT,
|
INITIAL_TIME_WAIT,
|
||||||
@@ -101,55 +102,45 @@ class Events(commands.Cog):
|
|||||||
text = text[1900:]
|
text = text[1900:]
|
||||||
|
|
||||||
async def cog_load(self):
|
async def cog_load(self):
|
||||||
# The AI query worker must run regardless of the OpenAI guard below - it
|
# The AI query worker answers via handle_response, so it works on every
|
||||||
# answers via handle_response, which works on Claude too. Start it first.
|
# backend. Start it first.
|
||||||
if not self.ai_query_worker.is_running():
|
if not self.ai_query_worker.is_running():
|
||||||
self.ai_query_worker.start()
|
self.ai_query_worker.start()
|
||||||
self.logger.info("Starting personal assistants")
|
# Keeps a self-hosted model resident; it no-ops on any other provider.
|
||||||
# Personal assistants use the OpenAI Assistants API (threads/runs), which
|
if not self.ollama_warm_loop.is_running():
|
||||||
# has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up
|
self.ollama_warm_loop.start()
|
||||||
# (e.g. a Claude-only deployment) instead of crashing the cog load.
|
# NOTE: there is no OpenAI-Assistants bootstrap any more. It called a
|
||||||
if OPENAICLIENT is None:
|
# sunset API (beta threads), 404'd, and failed the WHOLE extension -
|
||||||
self.logger.warning(
|
# taking every AI command with it. Personal assistants now ride
|
||||||
"OPENAICLIENT niedostępny - osobiści asystenci (OpenAI Assistants API) wyłączeni"
|
# handle_response with per-user memory (ai_functions), so they work on
|
||||||
)
|
# Claude and Ollama too and nothing has to be created at startup.
|
||||||
return
|
self.logger.info("Osobiści asystenci: pamięć per-user, aktywny backend AI")
|
||||||
for superfryta_id, superfryta in SPECJALNE_ZIEMNIACZKI.items():
|
|
||||||
|
|
||||||
if superfryta[4] != "":
|
@tasks.loop(minutes=OLLAMA_WARM_MINUTES)
|
||||||
self.logger.info(
|
async def ollama_warm_loop(self):
|
||||||
"Personal assistant for user: %s, exists id: %s,name: %s, owner: %s, special instructions: %s assistant id: %s ",
|
"""Keep a self-hosted model resident so users don't pay the load wait.
|
||||||
superfryta_id,
|
|
||||||
superfryta[0],
|
Loading is the slow part on a GPU shared with other users, so we
|
||||||
superfryta[1],
|
re-assert Ollama's keep_alive well inside its window. This preloads
|
||||||
superfryta[2],
|
WITHOUT generating - no tokens, no cost.
|
||||||
superfryta[3],
|
|
||||||
superfryta[4],
|
Hard guard: it does nothing unless the ACTIVE backend is Ollama. Firing
|
||||||
)
|
warm-ups at a metered API would burn tokens and money for nothing.
|
||||||
thread = await OPENAICLIENT.beta.threads.create()
|
"""
|
||||||
self.logger.info("Thread id: %s", thread.id)
|
try:
|
||||||
ASSISTANTS[superfryta[1]] = (
|
if ai_functions.active_provider() != "ollama":
|
||||||
superfryta[2],
|
return
|
||||||
superfryta[4],
|
await ai_functions.warm_active_model()
|
||||||
superfryta[0],
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
thread,
|
self.logger.info("Rozgrzewanie Ollamy nieudane (nieszkodliwe): %s", exc)
|
||||||
)
|
|
||||||
else:
|
@ollama_warm_loop.before_loop
|
||||||
self.logger.info(
|
async def before_ollama_warm_loop(self):
|
||||||
"Creating personal assistant for user: %s, id: %s,name: %s, owner: %s, special instructions: %s",
|
await self.bot.wait_until_ready()
|
||||||
superfryta_id,
|
|
||||||
superfryta[0],
|
|
||||||
superfryta[1],
|
|
||||||
superfryta[2],
|
|
||||||
superfryta[3],
|
|
||||||
)
|
|
||||||
await ai_functions.create_chat_assistant(
|
|
||||||
superfryta_id, superfryta[0], superfryta[1], superfryta[2], superfryta[3]
|
|
||||||
)
|
|
||||||
self.logger.info("Started personal assistants")
|
|
||||||
|
|
||||||
async def cog_unload(self):
|
async def cog_unload(self):
|
||||||
self.ai_query_worker.cancel()
|
self.ai_query_worker.cancel()
|
||||||
|
self.ollama_warm_loop.cancel()
|
||||||
|
|
||||||
@commands.hybrid_command(
|
@commands.hybrid_command(
|
||||||
name="switch_dm_mode",
|
name="switch_dm_mode",
|
||||||
@@ -174,19 +165,57 @@ class Events(commands.Cog):
|
|||||||
await ctx.reply("Nope. Nie wiesz jak użyć")
|
await ctx.reply("Nope. Nie wiesz jak użyć")
|
||||||
|
|
||||||
@commands.hybrid_command(
|
@commands.hybrid_command(
|
||||||
name="gadaj_teraz",
|
name="modele_ai",
|
||||||
description="Pokaż/przełącz backend AI (bez argumentu = status). Przełączanie: Vykidailo.",
|
description="Pokaż modele dostępne dla danego backendu (Ollamę pyta na żywo).",
|
||||||
)
|
)
|
||||||
async def gadaj_teraz(self, ctx, nazwa_konfigu: Optional[str] = None):
|
async def modele_ai(self, ctx, nazwa_konfigu: Optional[str] = None):
|
||||||
|
"""Read-only model listing. For Ollama this queries the server, so it
|
||||||
|
shows exactly what is pulled on the box right now."""
|
||||||
|
async with ctx.channel.typing():
|
||||||
|
target = nazwa_konfigu or ai_functions.get_active_ai_config()
|
||||||
|
if target not in ai_functions.list_ai_configs():
|
||||||
|
await discord_friendly_reply(
|
||||||
|
ctx,
|
||||||
|
f"Nie znam configu '{target}'. Dostępne: "
|
||||||
|
f"{', '.join(ai_functions.list_ai_configs())}",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
models = await ai_functions.list_provider_models(target)
|
||||||
|
except ai_functions.AIError as exc:
|
||||||
|
await discord_friendly_reply(
|
||||||
|
ctx, f"Nie mogę pobrać modeli dla '{target}': {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if not models:
|
||||||
|
await discord_friendly_reply(ctx, f"Brak modeli dla '{target}'.")
|
||||||
|
return
|
||||||
|
await discord_friendly_reply(
|
||||||
|
ctx,
|
||||||
|
f"Modele dla **{target}**: {', '.join(models)}\n"
|
||||||
|
f"Wepniesz przez `$gadaj_teraz {target} <model>` (tylko Vykidailo).",
|
||||||
|
)
|
||||||
|
|
||||||
|
@commands.hybrid_command(
|
||||||
|
name="gadaj_teraz",
|
||||||
|
description="Pokaż/przełącz backend AI i model (bez argumentu = status). Przełączanie: Vykidailo.",
|
||||||
|
)
|
||||||
|
async def gadaj_teraz(
|
||||||
|
self, ctx, nazwa_konfigu: Optional[str] = None, model: Optional[str] = None
|
||||||
|
):
|
||||||
async with ctx.channel.typing():
|
async with ctx.channel.typing():
|
||||||
available = ai_functions.list_ai_configs()
|
available = ai_functions.list_ai_configs()
|
||||||
# No argument -> report the active backend (read-only, open to all).
|
# No argument -> report the active backend (read-only, open to all).
|
||||||
if not nazwa_konfigu:
|
if not nazwa_konfigu:
|
||||||
active = ai_functions.get_active_ai_config()
|
active = ai_functions.get_active_ai_config()
|
||||||
|
active_cfg = ai_functions.AI_CONFIGS.get(active, {})
|
||||||
await discord_friendly_reply(
|
await discord_friendly_reply(
|
||||||
ctx,
|
ctx,
|
||||||
f"Teraz gadam przez **{active}**. Dostępne: {', '.join(available)}. "
|
f"Teraz gadam przez **{active}** "
|
||||||
"Przełączysz przez `$gadaj_teraz <config>` (tylko Vykidailo).",
|
f"({active_cfg.get('provider')} / {active_cfg.get('latest_model')}). "
|
||||||
|
f"Dostępne: {', '.join(available)}. "
|
||||||
|
"Przełączysz przez `$gadaj_teraz <config> [model]` (tylko Vykidailo), "
|
||||||
|
"modele zobaczysz przez `$modele_ai`.",
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
is_admin = isinstance(ctx.author, discord.Member) and any(
|
is_admin = isinstance(ctx.author, discord.Member) and any(
|
||||||
@@ -208,13 +237,69 @@ class Events(commands.Cog):
|
|||||||
ctx, f"Nie mogę przełączyć na '{nazwa_konfigu}': {exc}"
|
ctx, f"Nie mogę przełączyć na '{nazwa_konfigu}': {exc}"
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
self.logger.info(
|
|
||||||
"Przełączono AI na config %s (%s)", nazwa_konfigu, cfg.get("provider")
|
# Optional second argument pins the model. For a backend we can
|
||||||
)
|
# enumerate (Ollama), reject an unknown id up front with the list -
|
||||||
|
# otherwise the typo only surfaces later as a failed reply.
|
||||||
|
if model:
|
||||||
|
try:
|
||||||
|
known = await ai_functions.list_provider_models(nazwa_konfigu)
|
||||||
|
except ai_functions.AIError:
|
||||||
|
known = [] # cannot enumerate -> accept verbatim
|
||||||
|
if known and cfg.get("provider") == "ollama" and model not in known:
|
||||||
await discord_friendly_reply(
|
await discord_friendly_reply(
|
||||||
ctx,
|
ctx,
|
||||||
f"Teraz gadam przez **{nazwa_konfigu}** — {cfg.get('provider')} / {cfg.get('latest_model')}.",
|
f"Model '{model}' nie jest wgrany na Ollamie. "
|
||||||
|
f"Dostępne: {', '.join(known)}",
|
||||||
)
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
cfg = ai_functions.set_active_model(model, nazwa_konfigu)
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
await discord_friendly_reply(
|
||||||
|
ctx, f"Nie mogę wpiąć modelu '{model}': {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.logger.info(
|
||||||
|
"Przełączono AI na config %s (%s / %s)",
|
||||||
|
nazwa_konfigu, cfg.get("provider"), cfg.get("latest_model"),
|
||||||
|
)
|
||||||
|
message = (
|
||||||
|
f"Teraz gadam przez **{nazwa_konfigu}** — "
|
||||||
|
f"{cfg.get('provider')} / {cfg.get('latest_model')}."
|
||||||
|
)
|
||||||
|
if cfg.get("provider") == "ollama":
|
||||||
|
# Pay the (slow, shared-GPU) load cost NOW, in the background,
|
||||||
|
# so it lands on the operator switching backends rather than on
|
||||||
|
# whoever sends the first message. Not awaited: loading can take
|
||||||
|
# minutes and the command must answer immediately.
|
||||||
|
asyncio.create_task(ai_functions.warm_active_model())
|
||||||
|
message += (
|
||||||
|
"\nRozgrzewam model w tle — pierwsza odpowiedź może chwilę potrwać."
|
||||||
|
)
|
||||||
|
# Switched without pinning a model: show what else is on offer.
|
||||||
|
if not model:
|
||||||
|
try:
|
||||||
|
others = await ai_functions.list_provider_models(nazwa_konfigu)
|
||||||
|
except ai_functions.AIError:
|
||||||
|
others = []
|
||||||
|
current = cfg.get("latest_model")
|
||||||
|
if others and current not in others:
|
||||||
|
# The configured/pinned model is not on the server: every
|
||||||
|
# reply would fail with "model not found" and nothing would
|
||||||
|
# say why. Flag it here, where the list is already in hand.
|
||||||
|
message += (
|
||||||
|
f"\n⚠ Uwaga: '{current}' nie jest wgrany na serwerze. "
|
||||||
|
f"Dostępne: {', '.join(others)} "
|
||||||
|
f"(`$gadaj_teraz {nazwa_konfigu} <model>`)."
|
||||||
|
)
|
||||||
|
elif len(others) > 1:
|
||||||
|
message += (
|
||||||
|
f"\nDostępne modele: {', '.join(others)} "
|
||||||
|
f"(`$gadaj_teraz {nazwa_konfigu} <model>`)."
|
||||||
|
)
|
||||||
|
await discord_friendly_reply(ctx, message)
|
||||||
|
|
||||||
@commands.hybrid_command(
|
@commands.hybrid_command(
|
||||||
name="armia_hammera",
|
name="armia_hammera",
|
||||||
@@ -326,8 +411,10 @@ class Events(commands.Cog):
|
|||||||
if message.author.id == superfryta[0]:
|
if message.author.id == superfryta[0]:
|
||||||
self.logger.info("Specjalny ziemniak")
|
self.logger.info("Specjalny ziemniak")
|
||||||
if self.armia[message.author.id] == Dm_Mode.SPECJALNY_ZIEMNIACZEK:
|
if self.armia[message.author.id] == Dm_Mode.SPECJALNY_ZIEMNIACZEK:
|
||||||
#await self.bot.process_commands(message)
|
# superfryta = [discord_id, assistant_name, owner, instructions, legacy_assistant_id]
|
||||||
await ai_functions.chat_with_assistant(message, superfryta[1])
|
await ai_functions.chat_with_personal_assistant(
|
||||||
|
message, superfryta[2], superfryta[3]
|
||||||
|
)
|
||||||
return
|
return
|
||||||
elif self.armia[message.author.id] == Dm_Mode.ECHO_ECHO:
|
elif self.armia[message.author.id] == Dm_Mode.ECHO_ECHO:
|
||||||
await ai_functions.echo(message)
|
await ai_functions.echo(message)
|
||||||
|
|||||||
+228
-52
@@ -1,15 +1,21 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import random
|
import random
|
||||||
|
import tempfile
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
import tiktoken
|
import tiktoken
|
||||||
import time
|
import time
|
||||||
from other_functions import discord_friendly_send
|
from other_functions import discord_friendly_send
|
||||||
|
import requests
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
AI_CONFIGS,
|
AI_CONFIGS,
|
||||||
ASSISTANTS,
|
AI_TIMEOUT_SECONDS,
|
||||||
|
ASSISTANT_MEMORY_FILE,
|
||||||
|
ASSISTANT_MEMORY_TURNS,
|
||||||
CLAUDECLIENT,
|
CLAUDECLIENT,
|
||||||
CYCLIC_WORDS,
|
CYCLIC_WORDS,
|
||||||
DEFAULT_AI_CONFIG,
|
DEFAULT_AI_CONFIG,
|
||||||
@@ -19,6 +25,10 @@ from constants import (
|
|||||||
MEMORY_FIVE_SIARA,
|
MEMORY_FIVE_SIARA,
|
||||||
MESSAGE_TABLE,
|
MESSAGE_TABLE,
|
||||||
MESSAGE_TABLE_MUZYKA,
|
MESSAGE_TABLE_MUZYKA,
|
||||||
|
OLLAMACLIENT,
|
||||||
|
OLLAMA_KEEP_ALIVE,
|
||||||
|
OLLAMA_PRELOAD_TIMEOUT,
|
||||||
|
OLLAMA_URL,
|
||||||
OPENAICLIENT,
|
OPENAICLIENT,
|
||||||
SYSTEM_GPT_SETTINGS,
|
SYSTEM_GPT_SETTINGS,
|
||||||
WORD_REACTIONS,
|
WORD_REACTIONS,
|
||||||
@@ -92,12 +102,54 @@ def set_active_ai_config(name: str) -> dict:
|
|||||||
raise RuntimeError("klient Anthropic nie jest skonfigurowany (brak ANTHROPIC_API_KEY)")
|
raise RuntimeError("klient Anthropic nie jest skonfigurowany (brak ANTHROPIC_API_KEY)")
|
||||||
if provider == "openai" and OPENAICLIENT is None:
|
if provider == "openai" and OPENAICLIENT is None:
|
||||||
raise RuntimeError("klient OpenAI nie jest skonfigurowany (brak OPENAI_API_KEY)")
|
raise RuntimeError("klient OpenAI nie jest skonfigurowany (brak OPENAI_API_KEY)")
|
||||||
|
if provider == "ollama" and OLLAMACLIENT is None:
|
||||||
|
raise RuntimeError("Ollama nie jest skonfigurowana (ustaw CONJURER_OLLAMA_URL)")
|
||||||
_ACTIVE_CONFIG_NAME = name
|
_ACTIVE_CONFIG_NAME = name
|
||||||
_persist_active_ai_config(name)
|
_persist_active_ai_config(name)
|
||||||
return cfg
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
def _persist_active_ai_config(name: str) -> None:
|
async def list_provider_models(name: str = None):
|
||||||
|
"""Model ids selectable for a config.
|
||||||
|
|
||||||
|
For Ollama this ASKS THE SERVER (its OpenAI-compatible /v1/models), so the
|
||||||
|
picker always reflects what is actually pulled on the box rather than a
|
||||||
|
hardcoded list. Hosted providers are not enumerated - we only report what
|
||||||
|
the config is wired to.
|
||||||
|
"""
|
||||||
|
cfg = AI_CONFIGS.get(name or _ACTIVE_CONFIG_NAME) or _active_config()
|
||||||
|
if cfg.get("provider") == "ollama":
|
||||||
|
if OLLAMACLIENT is None:
|
||||||
|
raise AIError(
|
||||||
|
"auth",
|
||||||
|
RuntimeError("Ollama nie jest skonfigurowana (ustaw CONJURER_OLLAMA_URL)"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = await OLLAMACLIENT.models.list()
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
raise _map_openai_error(exc)
|
||||||
|
return sorted({item.id for item in resp.data})
|
||||||
|
return [m for m in (cfg.get("latest_model"), cfg.get("cheap_model")) if m]
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_model(model: str, name: str = None) -> dict:
|
||||||
|
"""Pin the model a config uses for normal replies, and persist it.
|
||||||
|
|
||||||
|
Only ``latest_model`` is changed; ``cheap_model`` stays as configured so the
|
||||||
|
MUSIC path keeps its cheaper backend.
|
||||||
|
"""
|
||||||
|
cfg_name = name or _ACTIVE_CONFIG_NAME
|
||||||
|
if cfg_name not in AI_CONFIGS:
|
||||||
|
raise KeyError(cfg_name)
|
||||||
|
if not model or not model.strip():
|
||||||
|
raise ValueError("pusta nazwa modelu")
|
||||||
|
cfg = AI_CONFIGS[cfg_name]
|
||||||
|
cfg["latest_model"] = model.strip()
|
||||||
|
_persist_active_ai_config(_ACTIVE_CONFIG_NAME, model_for=cfg_name)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def _persist_active_ai_config(name: str, model_for: str = None) -> None:
|
||||||
"""Best-effort write of the active-config choice into system_gpt_settings.json.
|
"""Best-effort write of the active-config choice into system_gpt_settings.json.
|
||||||
|
|
||||||
Keeps the historical two-element structure intact: updates index 2 if it
|
Keeps the historical two-element structure intact: updates index 2 if it
|
||||||
@@ -117,7 +169,16 @@ def _persist_active_ai_config(name: str) -> None:
|
|||||||
return
|
return
|
||||||
if len(data) > 2 and isinstance(data[2], dict):
|
if len(data) > 2 and isinstance(data[2], dict):
|
||||||
data[2]["active"] = name
|
data[2]["active"] = name
|
||||||
data[2].setdefault("configs", AI_CONFIGS)
|
# Write back ONLY what this process actually changed. Assigning the whole
|
||||||
|
# in-memory AI_CONFIGS here would clobber operator hand-edits (the only
|
||||||
|
# way to change cheap_model/temperature/max_tokens) and re-seed configs
|
||||||
|
# deliberately deleted from the file, because AI_CONFIGS is the built-in
|
||||||
|
# defaults merged under the file. setdefault alone is not enough either:
|
||||||
|
# it would drop a model pinned via set_active_model, hence model_for.
|
||||||
|
block = data[2].setdefault("configs", AI_CONFIGS)
|
||||||
|
if model_for and model_for in AI_CONFIGS:
|
||||||
|
entry = block.setdefault(model_for, dict(AI_CONFIGS[model_for]))
|
||||||
|
entry["latest_model"] = AI_CONFIGS[model_for]["latest_model"]
|
||||||
else:
|
else:
|
||||||
data = data[:2] + [{"active": name, "configs": AI_CONFIGS}]
|
data = data[:2] + [{"active": name, "configs": AI_CONFIGS}]
|
||||||
try:
|
try:
|
||||||
@@ -214,12 +275,83 @@ async def _anthropic_call(messages, model, cfg):
|
|||||||
return text.strip()
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
async def _ollama_call(messages, model, cfg):
|
||||||
|
"""Self-hosted counterpart of openai_call. Returns a plain string.
|
||||||
|
|
||||||
|
Ollama exposes an OpenAI-compatible /v1 surface, so the same message format
|
||||||
|
and the same error mapping apply - only the base_url and the model ids
|
||||||
|
differ. Chat Completions (not the Responses API) is what Ollama implements.
|
||||||
|
"""
|
||||||
|
if OLLAMACLIENT is None:
|
||||||
|
raise AIError(
|
||||||
|
"auth",
|
||||||
|
RuntimeError("Ollama nie jest skonfigurowana (ustaw CONJURER_OLLAMA_URL)"),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
resp = await OLLAMACLIENT.chat.completions.create(
|
||||||
|
model=model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=float(cfg.get("temperature", 0.2)),
|
||||||
|
)
|
||||||
|
except Exception as exc: # pylint: disable=broad-except
|
||||||
|
raise _map_openai_error(exc)
|
||||||
|
return (resp.choices[0].message.content or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _ollama_preload(model, keep_alive=None) -> bool:
|
||||||
|
"""Load ``model`` into Ollama and keep it resident, generating NOTHING.
|
||||||
|
|
||||||
|
Ollama's /api/generate with a model and no prompt is the documented preload:
|
||||||
|
it pays the (slow, GPU-shared) load cost once and returns, producing no
|
||||||
|
tokens. Used to warm up on switch and to re-assert keep_alive periodically.
|
||||||
|
|
||||||
|
Blocking on purpose - callers wrap it in asyncio.to_thread.
|
||||||
|
"""
|
||||||
|
if not OLLAMA_URL:
|
||||||
|
return False
|
||||||
|
logger = logging.getLogger("discord")
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"{OLLAMA_URL}/api/generate",
|
||||||
|
json={"model": model, "keep_alive": keep_alive or OLLAMA_KEEP_ALIVE},
|
||||||
|
timeout=OLLAMA_PRELOAD_TIMEOUT,
|
||||||
|
)
|
||||||
|
ok = resp.status_code == 200
|
||||||
|
logger.info("Ollama preload %s -> HTTP %s", model, resp.status_code)
|
||||||
|
return ok
|
||||||
|
except requests.exceptions.RequestException as exc:
|
||||||
|
logger.info("Ollama preload %s failed: %s", model, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def active_provider() -> str:
|
||||||
|
"""Provider of the active config - the guard every warm-up must check.
|
||||||
|
|
||||||
|
Preloading only makes sense for a self-hosted model; firing it at a metered
|
||||||
|
API would burn tokens (and money) for nothing.
|
||||||
|
"""
|
||||||
|
return (_active_config() or {}).get("provider", "")
|
||||||
|
|
||||||
|
|
||||||
|
async def warm_active_model(force_model=None) -> bool:
|
||||||
|
"""Preload the active model IFF the active backend is Ollama."""
|
||||||
|
if active_provider() != "ollama":
|
||||||
|
return False
|
||||||
|
cfg = _active_config()
|
||||||
|
model = force_model or cfg.get("latest_model")
|
||||||
|
if not model:
|
||||||
|
return False
|
||||||
|
return await asyncio.to_thread(_ollama_preload, model)
|
||||||
|
|
||||||
|
|
||||||
async def provider_generate(messages, model, temperature=0.2):
|
async def provider_generate(messages, model, temperature=0.2):
|
||||||
"""Dispatch a chat completion to the active backend, normalising errors."""
|
"""Dispatch a chat completion to the active backend, normalising errors."""
|
||||||
cfg = _active_config()
|
cfg = _active_config()
|
||||||
try:
|
try:
|
||||||
if cfg.get("provider") == "anthropic":
|
if cfg.get("provider") == "anthropic":
|
||||||
return await _anthropic_call(messages, model, cfg)
|
return await _anthropic_call(messages, model, cfg)
|
||||||
|
if cfg.get("provider") == "ollama":
|
||||||
|
return await _ollama_call(messages, model, cfg)
|
||||||
return await openai_call(messages, model, temperature)
|
return await openai_call(messages, model, temperature)
|
||||||
except AIError:
|
except AIError:
|
||||||
raise
|
raise
|
||||||
@@ -459,7 +591,7 @@ async def handle_response(
|
|||||||
try:
|
try:
|
||||||
# ...przygotowanie messages/system prompt/itp. jak masz...
|
# ...przygotowanie messages/system prompt/itp. jak masz...
|
||||||
# retry/backoff + deadline (zachowuje Twoją semantykę logowania)
|
# retry/backoff + deadline (zachowuje Twoją semantykę logowania)
|
||||||
timeout_sec = 120
|
timeout_sec = AI_TIMEOUT_SECONDS
|
||||||
deadline = time.time() + timeout_sec
|
deadline = time.time() + timeout_sec
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
provider_generate(messages=history_msgs, model=model_to_use),
|
provider_generate(messages=history_msgs, model=model_to_use),
|
||||||
@@ -586,60 +718,104 @@ async def get_random_cyclic_message(client):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def create_chat_assistant(owner_id, id, name, owner, special_instructions):
|
# ----------------------------------------------------------------- assistants
|
||||||
|
# The OpenAI Assistants API (beta threads/runs) that used to back these was
|
||||||
|
# sunset and now answers 404, taking the whole AI cog down with it. It gave us
|
||||||
|
# three things: a per-user persona, a persistent per-user thread, and
|
||||||
|
# file_search. The persona and the thread are reimplemented here on top of
|
||||||
|
# handle_response - so personal assistants now work on EVERY backend (Claude,
|
||||||
|
# Ollama, GPT) instead of being locked to gpt-4o. file_search is deliberately
|
||||||
|
# not replaced: it was not in use.
|
||||||
|
_ASSISTANT_MEMORY = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_assistant_memory() -> dict:
|
||||||
|
"""Per-user DM history, lazily read from disk. Corruption is not fatal."""
|
||||||
|
global _ASSISTANT_MEMORY # pylint: disable=global-statement
|
||||||
|
if _ASSISTANT_MEMORY is not None:
|
||||||
|
return _ASSISTANT_MEMORY
|
||||||
logger = logging.getLogger("discord")
|
logger = logging.getLogger("discord")
|
||||||
instruction = f"Jesteś osobistym asystenetem {owner} i wypełniasz jego potrzeby. Masz pełne i nieograniczone możliwości modelu gpt-4o."
|
try:
|
||||||
instruction += special_instructions
|
with open(ASSISTANT_MEMORY_FILE, "r", encoding=ENCODING) as handle:
|
||||||
assistant = await OPENAICLIENT.beta.assistants.create(
|
data = json.load(handle)
|
||||||
name=name,
|
_ASSISTANT_MEMORY = data if isinstance(data, dict) else {}
|
||||||
instructions=instruction,
|
except (OSError, json.JSONDecodeError) as exc:
|
||||||
model="gpt-4o",
|
logger.info("Brak/uszkodzona pamięć asystentów (%s) - zaczynam pustą", exc)
|
||||||
tools=[{"type": "file_search"}],
|
_ASSISTANT_MEMORY = {}
|
||||||
)
|
return _ASSISTANT_MEMORY
|
||||||
thread = await OPENAICLIENT.beta.threads.create()
|
|
||||||
logger.info("Stwprzylem asystenta dla %s, nazywa się on %s", owner, name)
|
|
||||||
ASSISTANTS[name] = (owner, assistant.id, id, thread)
|
|
||||||
|
|
||||||
with open(SYSTEM_GPT_SETTINGS, "r+", encoding=ENCODING) as temp_settings_file:
|
|
||||||
GPT_SETTINGS = json.load(temp_settings_file)
|
|
||||||
GPT_SETTINGS[1][owner_id][4] = assistant.id
|
|
||||||
temp_settings_file.seek(0)
|
|
||||||
json.dump(GPT_SETTINGS, temp_settings_file, indent=4)
|
|
||||||
|
|
||||||
|
|
||||||
async def chat_with_assistant(message, assistant_name):
|
def _save_assistant_memory() -> None:
|
||||||
|
"""Atomic write: a torn file would lose someone's whole conversation."""
|
||||||
logger = logging.getLogger("discord")
|
logger = logging.getLogger("discord")
|
||||||
assistant_data = ASSISTANTS[assistant_name]
|
memory = _load_assistant_memory()
|
||||||
ai_message = await OPENAICLIENT.beta.threads.messages.create(
|
directory = os.path.dirname(ASSISTANT_MEMORY_FILE) or "."
|
||||||
thread_id=assistant_data[3].id, role="user", content=message.content
|
try:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
fd, tmp = tempfile.mkstemp(dir=directory, suffix=".tmp")
|
||||||
|
with os.fdopen(fd, "w", encoding=ENCODING) as handle:
|
||||||
|
json.dump(memory, handle, ensure_ascii=False)
|
||||||
|
os.replace(tmp, ASSISTANT_MEMORY_FILE)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.warning("Nie mogę zapisać pamięci asystentów: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def assistant_history(user_id) -> list:
|
||||||
|
return _load_assistant_memory().setdefault(str(user_id), [])
|
||||||
|
|
||||||
|
|
||||||
|
def remember_assistant_turn(user_id, user_text, reply_text) -> list:
|
||||||
|
"""Append one exchange and trim to the most recent turns.
|
||||||
|
|
||||||
|
A plain trim, not the AI summarisation used for the bar's shared memory:
|
||||||
|
these are private DMs and must not end up in a public 'legend'.
|
||||||
|
"""
|
||||||
|
history = assistant_history(user_id)
|
||||||
|
history.append({"role": "user", "content": user_text})
|
||||||
|
history.append({"role": "assistant", "content": reply_text})
|
||||||
|
if len(history) > ASSISTANT_MEMORY_TURNS:
|
||||||
|
del history[: len(history) - ASSISTANT_MEMORY_TURNS]
|
||||||
|
_save_assistant_memory()
|
||||||
|
return history
|
||||||
|
|
||||||
|
|
||||||
|
def build_assistant_messages(user_id, owner, special_instructions, prompt) -> list:
|
||||||
|
"""System persona + this user's own history + the new turn."""
|
||||||
|
system = (
|
||||||
|
f"Jesteś osobistym asystentem {owner} i wypełniasz jego potrzeby. "
|
||||||
|
f"{special_instructions or ''}"
|
||||||
|
).strip()
|
||||||
|
return (
|
||||||
|
[{"role": "system", "content": system}]
|
||||||
|
+ list(assistant_history(user_id))
|
||||||
|
+ [{"role": "user", "content": prompt}]
|
||||||
)
|
)
|
||||||
logger.info(ai_message)
|
|
||||||
run = await OPENAICLIENT.beta.threads.runs.create_and_poll(
|
|
||||||
thread_id=assistant_data[3].id,
|
async def chat_with_personal_assistant(message, owner, special_instructions):
|
||||||
assistant_id=assistant_data[1],
|
"""Answer a DM as this user's personal assistant, on the active backend.
|
||||||
instructions=f"Pisze do Ciebie {assistant_data[0]} udziel mu wszelkiej pomocy",
|
|
||||||
|
request_type="NONE" with an explicit message list keeps this OUT of the
|
||||||
|
bar's shared memory - the conversation is carried by the per-user history
|
||||||
|
built above and stored separately.
|
||||||
|
"""
|
||||||
|
logger = logging.getLogger("discord")
|
||||||
|
user_id = message.author.id
|
||||||
|
prompt = message.content
|
||||||
|
messages = build_assistant_messages(user_id, owner, special_instructions, prompt)
|
||||||
|
result, _table = await handle_response(
|
||||||
|
prompt,
|
||||||
|
False,
|
||||||
|
False,
|
||||||
|
[],
|
||||||
|
str(owner),
|
||||||
|
"NONE",
|
||||||
|
none_request=messages,
|
||||||
)
|
)
|
||||||
done = False
|
remember_assistant_turn(user_id, prompt, result)
|
||||||
while not done:
|
logger.info("Asystent odpowiedział %s (%d znaków)", owner, len(result or ""))
|
||||||
if run.status == "completed":
|
await discord_friendly_send(message.channel, result)
|
||||||
messsages = await OPENAICLIENT.beta.threads.messages.list(
|
return result
|
||||||
thread_id=assistant_data[3].id
|
|
||||||
)
|
|
||||||
logger.info(messsages)
|
|
||||||
reply_content = messsages.data[0].content
|
|
||||||
logger.info(reply_content)
|
|
||||||
chat_response = ""
|
|
||||||
for block in reply_content:
|
|
||||||
logger.info(block.text.value)
|
|
||||||
chat_response += block.text.value
|
|
||||||
await discord_friendly_send(message.channel, chat_response)
|
|
||||||
# await message.channel.send(chat_response)
|
|
||||||
done = True
|
|
||||||
elif run.status == "cancelled":
|
|
||||||
await discord_friendly_send(message.channel, "Cos sie wywaliło")
|
|
||||||
else:
|
|
||||||
logger.info(run.status)
|
|
||||||
asyncio.sleep(5)
|
|
||||||
|
|
||||||
|
|
||||||
async def echo(message):
|
async def echo(message):
|
||||||
|
|||||||
@@ -173,6 +173,96 @@ def _cache_put(query, deep_search, final_result) -> None:
|
|||||||
_cache.prune(CACHE_MAX_ENTRIES)
|
_cache.prune(CACHE_MAX_ENTRIES)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- "Still alive" heartbeat for the running search ------------------------
|
||||||
|
# A deep scan runs for hours with nothing in the log between start and finish.
|
||||||
|
# Every HEARTBEAT_SECONDS the running search says it is still going, with its
|
||||||
|
# uuid, the phrase, and a ROUGH how-far-along. The estimate is deliberately
|
||||||
|
# cheap: producers already record a byte offset per chunk file, and the total
|
||||||
|
# size is stat()'d once at search start - so it costs a sum over ~40 ints.
|
||||||
|
HEARTBEAT_SECONDS = int(_env("CONJURER_LIBRARIAN_HEARTBEAT_SECONDS", "1200")) # 20 min
|
||||||
|
_current_search: Dict[str, object] = {}
|
||||||
|
_current_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_current_search(uuid, query, progress, live_results) -> None:
|
||||||
|
with _current_lock:
|
||||||
|
_current_search.clear()
|
||||||
|
_current_search.update({
|
||||||
|
"uuid": str(uuid), "query": str(query), "started": time.monotonic(),
|
||||||
|
"progress": progress, "live": live_results,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _clear_current_search() -> None:
|
||||||
|
with _current_lock:
|
||||||
|
_current_search.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_summary(progress):
|
||||||
|
"""(done_bytes, total_bytes, percent) from a live progress dict. Cheap: a
|
||||||
|
sum over one int per chunk file. Percent is 0.0 when the total is unknown."""
|
||||||
|
progress = progress or {}
|
||||||
|
positions = progress.get("positions") or {}
|
||||||
|
total = progress.get("total_bytes") or 0
|
||||||
|
done = sum(positions.values())
|
||||||
|
if total > 0:
|
||||||
|
done = min(done, total) # a partially-buffered tail can nudge past 100%
|
||||||
|
return done, total, 100.0 * done / total
|
||||||
|
return done, total, 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def search_heartbeat(app_logger) -> None:
|
||||||
|
"""Log a 'still searching' line every HEARTBEAT_SECONDS while one runs."""
|
||||||
|
while not SHUTDOWN_EVENT.wait(HEARTBEAT_SECONDS):
|
||||||
|
try:
|
||||||
|
with _current_lock:
|
||||||
|
snapshot = dict(_current_search) if _current_search else None
|
||||||
|
if not snapshot:
|
||||||
|
continue # nothing running - stay quiet
|
||||||
|
done, total, percent = _progress_summary(snapshot.get("progress"))
|
||||||
|
app_logger.info(
|
||||||
|
"SEARCH ALIVE %s | '%s' | ~%.1f%% przeskanowane (%.2f/%.2f GB, "
|
||||||
|
"~%.2f GB do końca) | %d trafień | %.0f min",
|
||||||
|
snapshot["uuid"], snapshot["query"], percent,
|
||||||
|
done / 1e9, total / 1e9, max(0, total - done) / 1e9,
|
||||||
|
len(snapshot.get("live") or []),
|
||||||
|
(time.monotonic() - snapshot["started"]) / 60.0,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
app_logger.warning("Heartbeat tick failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
# Crossref is a public service that times out / rate-limits under load. A single
|
||||||
|
# transient ReadTimeout used to blow up the whole (expensive) search, so every
|
||||||
|
# habanero call is retried with backoff, and a search that still fails is retried
|
||||||
|
# as a whole a few times before being given up on.
|
||||||
|
CROSSREF_ATTEMPTS = int(_env("CONJURER_CROSSREF_ATTEMPTS", "4"))
|
||||||
|
CROSSREF_BACKOFF = float(_env("CONJURER_CROSSREF_BACKOFF", "5"))
|
||||||
|
SEARCH_MAX_ATTEMPTS = int(_env("CONJURER_SEARCH_MAX_ATTEMPTS", "3"))
|
||||||
|
|
||||||
|
|
||||||
|
def _crossref_call(app_logger, what, func, *args, **kwargs):
|
||||||
|
"""Run one habanero call, retrying transient failures with linear backoff.
|
||||||
|
|
||||||
|
habanero wraps httpx errors (ReadTimeout, connection resets, 5xx) in a plain
|
||||||
|
RuntimeError, so we cannot filter narrowly - we retry a BOUNDED number of
|
||||||
|
times on any failure and re-raise the last error if none succeed. Blocking
|
||||||
|
on purpose: callers invoke it via asyncio.to_thread, which also keeps the
|
||||||
|
worker's event loop free while Crossref is slow."""
|
||||||
|
last_exc = None
|
||||||
|
for attempt in range(1, max(1, CROSSREF_ATTEMPTS) + 1):
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
last_exc = exc
|
||||||
|
app_logger.warning(
|
||||||
|
"Crossref %s failed (attempt %d/%d): %s", what, attempt, CROSSREF_ATTEMPTS, exc
|
||||||
|
)
|
||||||
|
if attempt < CROSSREF_ATTEMPTS:
|
||||||
|
time.sleep(CROSSREF_BACKOFF * attempt)
|
||||||
|
raise last_exc
|
||||||
|
|
||||||
|
|
||||||
def _forget_search(uuid) -> None:
|
def _forget_search(uuid) -> None:
|
||||||
"""A search is fully done (or abandoned): drop its persisted request and any
|
"""A search is fully done (or abandoned): drop its persisted request and any
|
||||||
checkpoint so it is never replayed or resumed again."""
|
checkpoint so it is never replayed or resumed again."""
|
||||||
@@ -411,14 +501,20 @@ class Librarian(object):
|
|||||||
|
|
||||||
if not deep_search:
|
if not deep_search:
|
||||||
query_limit = MAX_CR_RESULTS if MAX_CR_RESULTS < 1000 else 1000
|
query_limit = MAX_CR_RESULTS if MAX_CR_RESULTS < 1000 else 1000
|
||||||
cr_result = self.cr.works(query=query, limit=query_limit)
|
cr_result = await asyncio.to_thread(
|
||||||
|
_crossref_call, self.app.logger, "works",
|
||||||
|
self.cr.works, query=query, limit=query_limit,
|
||||||
|
)
|
||||||
self.search_result_from_cr.update(cr_result)
|
self.search_result_from_cr.update(cr_result)
|
||||||
self.total = cr_result["message"]["total-results"]
|
self.total = cr_result["message"]["total-results"]
|
||||||
self.fetched += len(cr_result["message"]["items"])
|
self.fetched += len(cr_result["message"]["items"])
|
||||||
self.app.logger.info(self.total)
|
self.app.logger.info(self.total)
|
||||||
self.app.logger.info(self.fetched)
|
self.app.logger.info(self.fetched)
|
||||||
while self.total > self.fetched and self.limit > self.fetched:
|
while self.total > self.fetched and self.limit > self.fetched:
|
||||||
tmp_result = self.cr.works(query=query, limit=query_limit, offset=self.fetched)
|
tmp_result = await asyncio.to_thread(
|
||||||
|
_crossref_call, self.app.logger, "works(offset)",
|
||||||
|
self.cr.works, query=query, limit=query_limit, offset=self.fetched,
|
||||||
|
)
|
||||||
cr_result["message"]["items"].extend(tmp_result["message"]["items"])
|
cr_result["message"]["items"].extend(tmp_result["message"]["items"])
|
||||||
self.total = tmp_result["message"]["total-results"]
|
self.total = tmp_result["message"]["total-results"]
|
||||||
self.fetched = len(cr_result["message"]["items"])
|
self.fetched = len(cr_result["message"]["items"])
|
||||||
@@ -427,7 +523,10 @@ class Librarian(object):
|
|||||||
await asyncio.sleep(0.1)
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
cr_result = self.cr.works(query=query, cursor_max=15000, cursor='*', progress_bar = True)
|
cr_result = await asyncio.to_thread(
|
||||||
|
_crossref_call, self.app.logger, "works(deep cursor)",
|
||||||
|
self.cr.works, query=query, cursor_max=15000, cursor='*', progress_bar=True,
|
||||||
|
)
|
||||||
result = cr_result[0]
|
result = cr_result[0]
|
||||||
for item in cr_result[1:]:
|
for item in cr_result[1:]:
|
||||||
result["message"]["items"].extend(item["message"]["items"])
|
result["message"]["items"].extend(item["message"]["items"])
|
||||||
@@ -516,10 +615,18 @@ class Librarian(object):
|
|||||||
dois = []
|
dois = []
|
||||||
for item, value in refined_result.items():
|
for item, value in refined_result.items():
|
||||||
dois.append([item, value])
|
dois.append([item, value])
|
||||||
|
# Publish this scan as "the running search" so the heartbeat can report
|
||||||
|
# it; cleared in finally so a finished/crashed scan never lingers there.
|
||||||
|
progress = {}
|
||||||
|
_set_current_search(self.uuid, self.query, progress, self.live_results)
|
||||||
|
try:
|
||||||
result, positions, interrupted = await asyncio.to_thread(
|
result, positions, interrupted = await asyncio.to_thread(
|
||||||
search_bot.search_for_doi,
|
search_bot.search_for_doi,
|
||||||
dois, self.live_results, self.app.logger, SHUTDOWN_EVENT, resume,
|
dois, self.live_results, self.app.logger, SHUTDOWN_EVENT, resume,
|
||||||
|
progress,
|
||||||
)
|
)
|
||||||
|
finally:
|
||||||
|
_clear_current_search()
|
||||||
if interrupted:
|
if interrupted:
|
||||||
# Graceful shutdown hit mid-scan: checkpoint found-so-far + per-file
|
# Graceful shutdown hit mid-scan: checkpoint found-so-far + per-file
|
||||||
# resume offsets + the DOI list, so a restart continues instead of
|
# resume offsets + the DOI list, so a restart continues instead of
|
||||||
@@ -699,6 +806,7 @@ class BackgroundTaskSearch(threading.Thread):
|
|||||||
# behind us, and active_queries so the bot's watchdog can tell a
|
# behind us, and active_queries so the bot's watchdog can tell a
|
||||||
# finished-and-gone query from one still in flight.
|
# finished-and-gone query from one still in flight.
|
||||||
worker_busy.set()
|
worker_busy.set()
|
||||||
|
requeued = False # set when a crash schedules another attempt
|
||||||
with _active_lock:
|
with _active_lock:
|
||||||
active_queries[str(librarian.uuid)] = "processing"
|
active_queries[str(librarian.uuid)] = "processing"
|
||||||
try:
|
try:
|
||||||
@@ -760,15 +868,47 @@ class BackgroundTaskSearch(threading.Thread):
|
|||||||
# forget its request + checkpoint (never replay/resume it again).
|
# forget its request + checkpoint (never replay/resume it again).
|
||||||
_forget_search(uuid)
|
_forget_search(uuid)
|
||||||
except Exception as exc: # pylint: disable=broad-exception-caught
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
# A crashing search must not kill the worker thread (which would
|
# A crashing search must not kill the worker thread (that would
|
||||||
# freeze the whole queue). Log and give up on it - forget the
|
# freeze the whole queue). It also must not silently vanish just
|
||||||
# request/checkpoint so it isn't retried forever as a poison pill;
|
# because Crossref timed out once: retry the whole search a
|
||||||
# the bot's watchdog tells the user it vanished.
|
# bounded number of times (attempt count persisted with the
|
||||||
self.app.logger.exception("Search %s crashed: %s", librarian.uuid, exc)
|
# request, so it can't loop forever), keeping any checkpoint so a
|
||||||
_forget_search(str(librarian.uuid))
|
# crashed DB scan resumes rather than restarts. Only after
|
||||||
|
# SEARCH_MAX_ATTEMPTS do we give up and let the bot's watchdog
|
||||||
|
# tell the user it vanished.
|
||||||
|
uuid = str(librarian.uuid)
|
||||||
|
self.app.logger.exception("Search %s crashed: %s", uuid, exc)
|
||||||
|
stored = _requests.get(uuid) or {}
|
||||||
|
attempts = int(stored.get("attempts", 0)) + 1
|
||||||
|
if attempts < max(1, SEARCH_MAX_ATTEMPTS):
|
||||||
|
stored.update({
|
||||||
|
"query": librarian.query,
|
||||||
|
"deep_search": librarian.deep_search,
|
||||||
|
"callback": librarian.callback,
|
||||||
|
"attempts": attempts,
|
||||||
|
})
|
||||||
|
_requests.put(uuid, stored)
|
||||||
|
librarian_queue.put(
|
||||||
|
Librarian(self.app, librarian.query, uuid,
|
||||||
|
librarian.deep_search, librarian.callback)
|
||||||
|
)
|
||||||
|
requeued = True
|
||||||
|
self.app.logger.warning(
|
||||||
|
"Search %s requeued after crash (attempt %d/%d)",
|
||||||
|
uuid, attempts, SEARCH_MAX_ATTEMPTS,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.app.logger.error(
|
||||||
|
"Search %s failed %d times - giving up", uuid, attempts
|
||||||
|
)
|
||||||
|
_forget_search(uuid)
|
||||||
finally:
|
finally:
|
||||||
worker_busy.clear()
|
worker_busy.clear()
|
||||||
with _active_lock:
|
with _active_lock:
|
||||||
|
if requeued:
|
||||||
|
# Still known to the bot's watchdog - it's going round again.
|
||||||
|
active_queries[str(librarian.uuid)] = "queued"
|
||||||
|
else:
|
||||||
active_queries.pop(str(librarian.uuid), None)
|
active_queries.pop(str(librarian.uuid), None)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
SHUTDOWN_DONE.set()
|
SHUTDOWN_DONE.set()
|
||||||
@@ -951,6 +1091,10 @@ if __name__ == "__main__":
|
|||||||
threads.append(
|
threads.append(
|
||||||
threading.Thread(target=outbox_resender, args=(app.logger,), daemon=True)
|
threading.Thread(target=outbox_resender, args=(app.logger,), daemon=True)
|
||||||
)
|
)
|
||||||
|
# "Still searching" heartbeat, so an hours-long scan isn't radio silence.
|
||||||
|
threads.append(
|
||||||
|
threading.Thread(target=search_heartbeat, args=(app.logger,), daemon=True)
|
||||||
|
)
|
||||||
for worker in threads:
|
for worker in threads:
|
||||||
worker.start()
|
worker.start()
|
||||||
# Re-enqueue searches that were accepted/in-progress before the last stop.
|
# Re-enqueue searches that were accepted/in-progress before the last stop.
|
||||||
|
|||||||
@@ -106,8 +106,10 @@ def check_if_exists_brute_force(logger):
|
|||||||
):
|
):
|
||||||
pass
|
pass
|
||||||
if blocked:
|
if blocked:
|
||||||
logger.info(item)
|
# Expected, routine sci-hub behaviour (we back off an hour and carry
|
||||||
logger.error("Got blocked. Fuck.")
|
# on) - WARNING, not ERROR, so it stops masquerading as a fault when
|
||||||
|
# you're scanning the log for real problems.
|
||||||
|
logger.warning("Got blocked. Fuck. Backing off an hour: %s", item[0])
|
||||||
time.sleep(60 * 60)
|
time.sleep(60 * 60)
|
||||||
# trunk-ignore(bandit/B311)
|
# trunk-ignore(bandit/B311)
|
||||||
rand = random.randint(1, 60)
|
rand = random.randint(1, 60)
|
||||||
|
|||||||
@@ -247,7 +247,8 @@ def consumer(in_q, control_q, doi, live_results, result_list, control_dict, expe
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
def search_for_doi(doi, live_results, _logger, stop_event=None, resume=None):
|
def search_for_doi(doi, live_results, _logger, stop_event=None, resume=None,
|
||||||
|
progress=None):
|
||||||
"""Search for DOI in live_results, resumably.
|
"""Search for DOI in live_results, resumably.
|
||||||
|
|
||||||
Returns ``(result_list, positions, interrupted)``:
|
Returns ``(result_list, positions, interrupted)``:
|
||||||
@@ -262,6 +263,11 @@ def search_for_doi(doi, live_results, _logger, stop_event=None, resume=None):
|
|||||||
``resume`` is ``{"positions": {...}, "found": [doi, ...]}`` from a previous
|
``resume`` is ``{"positions": {...}, "found": [doi, ...]}`` from a previous
|
||||||
interrupted run: already-found DOIs are pre-marked and each producer seeks to
|
interrupted run: already-found DOIs are pre-marked and each producer seeks to
|
||||||
its saved offset, so no already-scanned line is read twice.
|
its saved offset, so no already-scanned line is read twice.
|
||||||
|
|
||||||
|
``progress``, if given, is a dict this fills with ``positions`` (the LIVE
|
||||||
|
dict, updated as producers read) and ``total_bytes`` (summed once, up front).
|
||||||
|
That makes a rough "how far along" reading free: sum the offsets, divide by
|
||||||
|
the total - no counting, no extra work in the read loop.
|
||||||
"""
|
"""
|
||||||
control_dict = {"sentinels":0}
|
control_dict = {"sentinels":0}
|
||||||
result_list = []
|
result_list = []
|
||||||
@@ -293,6 +299,19 @@ def search_for_doi(doi, live_results, _logger, stop_event=None, resume=None):
|
|||||||
)
|
)
|
||||||
return result_list, positions, bool(stop_event and stop_event.is_set())
|
return result_list, positions, bool(stop_event and stop_event.is_set())
|
||||||
|
|
||||||
|
if progress is not None:
|
||||||
|
# One stat() per chunk file, ONCE - then progress is just sum(positions)
|
||||||
|
# / total_bytes, with nothing extra happening per line.
|
||||||
|
total_bytes = 0
|
||||||
|
for name in chunk_files:
|
||||||
|
try:
|
||||||
|
total_bytes += os.path.getsize(DATABASE_PATH + name)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
progress["positions"] = positions # live dict, updated by the producers
|
||||||
|
progress["total_bytes"] = total_bytes
|
||||||
|
progress["chunk_files"] = expected
|
||||||
|
|
||||||
for i in range (0, (len(doi)//1000)+2):
|
for i in range (0, (len(doi)//1000)+2):
|
||||||
t_cons = Thread(
|
t_cons = Thread(
|
||||||
target=consumer,
|
target=consumer,
|
||||||
|
|||||||
@@ -155,7 +155,13 @@ interactive.persistent("/srv/betoniarka/data/script.params")
|
|||||||
# Configure output formats and destinations
|
# Configure output formats and destinations
|
||||||
|
|
||||||
output.icecast(%mp3, host="localhost", port=8000, password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
|
output.icecast(%mp3, host="localhost", port=8000, password=credentials.password, icy_metadata="true", mount="mp3-stream", radio)
|
||||||
output.pulseaudio(radio)
|
# Local monitor output. fallible=true so a dead or missing pulse daemon degrades
|
||||||
|
# to "no monitor" instead of failing its clock and taking the whole radio down
|
||||||
|
# with it ("Shutdown started!"). The stream that actually matters is the Icecast
|
||||||
|
# one above, which needs no sound device at all.
|
||||||
|
# NOTE: input.pulseaudio() (the mic, further up) is still a HARD dependency - on
|
||||||
|
# a headless container with no capture device, comment BOTH of them out.
|
||||||
|
output.pulseaudio(fallible=true, radio)
|
||||||
#output.file.hls("/tmp/hls", [("mp3-low", %mp3(bitrate=96)), ("mp3-hi", %mp3(bitrate=160))], radio)
|
#output.file.hls("/tmp/hls", [("mp3-low", %mp3(bitrate=96)), ("mp3-hi", %mp3(bitrate=160))], radio)
|
||||||
# Uncomment the following lines to enable additional output formats
|
# Uncomment the following lines to enable additional output formats
|
||||||
# output.icecast(%opus, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="opus-stream", radio)
|
# output.icecast(%opus, host="retropie", port=8000, password=credentials.password, icy_metadata="true", mount="opus-stream", radio)
|
||||||
|
|||||||
+54
-1
@@ -245,6 +245,16 @@ DELIVERED_DIR = os.getenv(
|
|||||||
)
|
)
|
||||||
DELIVERED_MAX = int(os.getenv("CONJURER_DELIVERED_MAX", "10000"))
|
DELIVERED_MAX = int(os.getenv("CONJURER_DELIVERED_MAX", "10000"))
|
||||||
|
|
||||||
|
# Personal DM assistants. Replaces the OpenAI Assistants API (threads/runs),
|
||||||
|
# which was sunset and answers 404: the persona now rides handle_response, so it
|
||||||
|
# works on EVERY backend, and the conversation lives here instead of on OpenAI's
|
||||||
|
# server. Kept per user so private DMs never bleed into the bar's shared memory,
|
||||||
|
# and trimmed to the most recent turns so it cannot grow without bound.
|
||||||
|
ASSISTANT_MEMORY_FILE = os.getenv(
|
||||||
|
"CONJURER_ASSISTANT_MEMORY", os.path.join(_STATE_ROOT, "assistant_memory.json")
|
||||||
|
)
|
||||||
|
ASSISTANT_MEMORY_TURNS = int(os.getenv("CONJURER_ASSISTANT_MEMORY_TURNS", "40"))
|
||||||
|
|
||||||
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
||||||
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
||||||
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
||||||
@@ -400,6 +410,38 @@ if anthropic and ANTHROPIC_API_KEY:
|
|||||||
else:
|
else:
|
||||||
CLAUDECLIENT = None
|
CLAUDECLIENT = None
|
||||||
|
|
||||||
|
# Ollama (self-hosted models). There is no API key - the endpoint IS the whole
|
||||||
|
# configuration, so the feature stays dormant until CONJURER_OLLAMA_URL is set
|
||||||
|
# (same pattern as the Conan bridge). We talk to Ollama's OpenAI-COMPATIBLE
|
||||||
|
# surface (/v1) with the openai SDK we already depend on, which means the
|
||||||
|
# existing message format and _map_openai_error handling work unchanged.
|
||||||
|
# How long handle_response waits for ANY backend before giving up. 120s was
|
||||||
|
# hardcoded and is fine for hosted APIs, but a self-hosted model on a modest GPU
|
||||||
|
# can legitimately take longer, so it is now tunable.
|
||||||
|
AI_TIMEOUT_SECONDS = int(os.getenv("CONJURER_AI_TIMEOUT_SECONDS", "120"))
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("CONJURER_OLLAMA_URL", "").rstrip("/")
|
||||||
|
# Keeping a self-hosted model resident. Loading it is the slow part (it is
|
||||||
|
# offloaded to a GPU shared with other users), so we preload it - Ollama's
|
||||||
|
# /api/generate with a model and NO prompt loads it and generates nothing, which
|
||||||
|
# costs no tokens and no money. KEEP_ALIVE is how long Ollama should then hold
|
||||||
|
# it; the warm loop re-asserts that well inside the window.
|
||||||
|
# STRICTLY Ollama-only: doing this against a paid API would burn tokens for
|
||||||
|
# nothing, so every caller checks the active provider first.
|
||||||
|
OLLAMA_KEEP_ALIVE = os.getenv("CONJURER_OLLAMA_KEEP_ALIVE", "30m")
|
||||||
|
OLLAMA_WARM_MINUTES = float(os.getenv("CONJURER_OLLAMA_WARM_MINUTES", "10"))
|
||||||
|
# A preload waits for the model to finish loading, which on a shared GPU is the
|
||||||
|
# slow path we are trying to move off the user's first message.
|
||||||
|
OLLAMA_PRELOAD_TIMEOUT = int(os.getenv("CONJURER_OLLAMA_PRELOAD_TIMEOUT", "600"))
|
||||||
|
|
||||||
|
OLLAMA_LATEST_MODEL = os.getenv("CONJURER_OLLAMA_MODEL", "llama3.1:8b")
|
||||||
|
OLLAMA_CHEAP_MODEL = os.getenv("CONJURER_OLLAMA_CHEAP_MODEL", OLLAMA_LATEST_MODEL)
|
||||||
|
if openai and OLLAMA_URL:
|
||||||
|
# api_key is required by the SDK but ignored by Ollama.
|
||||||
|
OLLAMACLIENT = openai.AsyncOpenAI(base_url=f"{OLLAMA_URL}/v1", api_key="ollama")
|
||||||
|
else:
|
||||||
|
OLLAMACLIENT = None
|
||||||
|
|
||||||
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
|
TOKEN = _resolve_token("discord", "DISCORD_TOKEN")
|
||||||
|
|
||||||
# Voice recognition (AssemblyAI). None = the voice cog reports and disables.
|
# Voice recognition (AssemblyAI). None = the voice cog reports and disables.
|
||||||
@@ -489,6 +531,12 @@ def _default_ai_configs():
|
|||||||
# sent for Claude (Opus 4.8 / Sonnet 5 reject sampling params).
|
# sent for Claude (Opus 4.8 / Sonnet 5 reject sampling params).
|
||||||
"max_tokens": 2048,
|
"max_tokens": 2048,
|
||||||
},
|
},
|
||||||
|
"ollama": {
|
||||||
|
"provider": "ollama",
|
||||||
|
"latest_model": OLLAMA_LATEST_MODEL,
|
||||||
|
"cheap_model": OLLAMA_CHEAP_MODEL,
|
||||||
|
"temperature": 0.2,
|
||||||
|
},
|
||||||
# Template for wiring further providers. Copy it, rename the key, point
|
# Template for wiring further providers. Copy it, rename the key, point
|
||||||
# "provider" at a backend ai_functions.provider_generate implements, and
|
# "provider" at a backend ai_functions.provider_generate implements, and
|
||||||
# fill in the model ids. Keys starting with "_" are treated as inert
|
# fill in the model ids. Keys starting with "_" are treated as inert
|
||||||
@@ -508,7 +556,12 @@ _ai_block = (
|
|||||||
if isinstance(GPT_SETTINGS, list) and len(GPT_SETTINGS) > 2 and isinstance(GPT_SETTINGS[2], dict)
|
if isinstance(GPT_SETTINGS, list) and len(GPT_SETTINGS) > 2 and isinstance(GPT_SETTINGS[2], dict)
|
||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
AI_CONFIGS = _ai_block.get("configs") or _default_ai_configs()
|
# Built-in defaults FIRST, then whatever the settings file defines on top. The
|
||||||
|
# file cannot simply win outright: every provider switch persists a "configs"
|
||||||
|
# block, so a file written by an older build would permanently hide providers
|
||||||
|
# added later (ollama) from the picker.
|
||||||
|
AI_CONFIGS = _default_ai_configs()
|
||||||
|
AI_CONFIGS.update(_ai_block.get("configs") or {})
|
||||||
# Single switch: env var wins, then the settings-file "active" key, then "gpt".
|
# Single switch: env var wins, then the settings-file "active" key, then "gpt".
|
||||||
DEFAULT_AI_CONFIG = (
|
DEFAULT_AI_CONFIG = (
|
||||||
os.getenv("CONJURER_AI_CONFIG")
|
os.getenv("CONJURER_AI_CONFIG")
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ CREDS="$SECRETS/icecast_credentials.json"
|
|||||||
mkdir -p "$DATA" "$MUSIC" "$SECRETS"
|
mkdir -p "$DATA" "$MUSIC" "$SECRETS"
|
||||||
|
|
||||||
# Seed the script + persistent interactive params from the image on first run.
|
# Seed the script + persistent interactive params from the image on first run.
|
||||||
|
# NOTE: the live script is deliberately never overwritten, so hand edits win -
|
||||||
|
# but that also means image fixes NEVER reach a volume seeded long ago. Set
|
||||||
|
# RADIO_FORCE_SCRIPT=1 to take the image's version (the old one is kept as
|
||||||
|
# radio_conjurer.liq.bak so nothing hand-written is lost).
|
||||||
|
if [ -e "$DATA/radio_conjurer.liq" ] && [ "${RADIO_FORCE_SCRIPT:-0}" = "1" ]; then
|
||||||
|
cp "$DATA/radio_conjurer.liq" "$DATA/radio_conjurer.liq.bak"
|
||||||
|
cp /app/radio_conjurer.liq "$DATA/"
|
||||||
|
echo "RADIO_FORCE_SCRIPT=1: reseeded radio_conjurer.liq from the image" >&2
|
||||||
|
echo " (previous version saved as radio_conjurer.liq.bak)" >&2
|
||||||
|
fi
|
||||||
[ -e "$DATA/radio_conjurer.liq" ] || cp /app/radio_conjurer.liq "$DATA/"
|
[ -e "$DATA/radio_conjurer.liq" ] || cp /app/radio_conjurer.liq "$DATA/"
|
||||||
if [ ! -e "$DATA/script.params" ]; then
|
if [ ! -e "$DATA/script.params" ]; then
|
||||||
if [ -e /app/script.params ]; then cp /app/script.params "$DATA/"; else : > "$DATA/script.params"; fi
|
if [ -e /app/script.params ]; then cp /app/script.params "$DATA/"; else : > "$DATA/script.params"; fi
|
||||||
@@ -73,14 +83,36 @@ fi
|
|||||||
# none - you edited the script to drop pulse in/out.
|
# none - you edited the script to drop pulse in/out.
|
||||||
case "${PULSE_MODE:-internal}" in
|
case "${PULSE_MODE:-internal}" in
|
||||||
internal)
|
internal)
|
||||||
|
# Clear stale runtime state FIRST. `docker restart` - and the crash-loop
|
||||||
|
# that restart:unless-stopped produces - reuses the container's writable
|
||||||
|
# layer, so /run/pulse/pid left by a killed daemon survives and the next
|
||||||
|
# start dies with "Daemon startup failed"; that kills liquidsoap, which
|
||||||
|
# restarts the container, forever. Removing the pid/socket of a daemon
|
||||||
|
# that is demonstrably not running breaks the loop.
|
||||||
|
if ! pidof pulseaudio >/dev/null 2>&1; then
|
||||||
|
rm -f /run/pulse/pid /var/run/pulse/pid \
|
||||||
|
/run/pulse/native /var/run/pulse/native 2>/dev/null || true
|
||||||
|
fi
|
||||||
# --disallow-module-loading: modules from system.pa still load at
|
# --disallow-module-loading: modules from system.pa still load at
|
||||||
# startup; this only blocks later client-requested loads (and
|
# startup; this only blocks later client-requested loads (and
|
||||||
# silences the system-mode warning). The "forcibly disabling SHM"
|
# silences the system-mode warning). The "forcibly disabling SHM"
|
||||||
# notice is inherent to system mode and harmless.
|
# notice is inherent to system mode and harmless.
|
||||||
pulseaudio --system --daemonize=yes --disallow-exit \
|
if pulseaudio --system --daemonize=yes --disallow-exit \
|
||||||
--disallow-module-loading --exit-idle-time=-1 \
|
--disallow-module-loading --exit-idle-time=-1; then
|
||||||
|| echo "WARNING: internal pulseaudio failed to start" >&2
|
|
||||||
export PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}"
|
export PULSE_SERVER="${PULSE_SERVER:-unix:/var/run/pulse/native}"
|
||||||
|
else
|
||||||
|
# Be loud: with pulse dead, input.pulseaudio()/output.pulseaudio()
|
||||||
|
# fail to start, liquidsoap tears down the whole clock ("Shutdown
|
||||||
|
# started!") and the container crash-loops. The stream itself only
|
||||||
|
# needs Icecast, so the way out is dropping the pulse tor.
|
||||||
|
echo "ERROR: internal pulseaudio failed to start." >&2
|
||||||
|
echo " Liquidsoap will crash-loop while the script still uses" >&2
|
||||||
|
echo " input.pulseaudio()/output.pulseaudio(). The Icecast" >&2
|
||||||
|
echo " output does NOT need pulse: comment those out in" >&2
|
||||||
|
echo " $DATA/radio_conjurer.liq (or set RADIO_FORCE_SCRIPT=1" >&2
|
||||||
|
echo " to re-seed the script from the image) and restart." >&2
|
||||||
|
echo " Diagnose with: pulseaudio --system --daemonize=no -vvvv" >&2
|
||||||
|
fi
|
||||||
;;
|
;;
|
||||||
host)
|
host)
|
||||||
[ -n "$PULSE_SERVER" ] || echo "WARNING: PULSE_MODE=host but PULSE_SERVER is unset" >&2
|
[ -n "$PULSE_SERVER" ] || echo "WARNING: PULSE_MODE=host but PULSE_SERVER is unset" >&2
|
||||||
|
|||||||
Vendored
+21
-2
@@ -13,10 +13,29 @@ CONJURER_NETRC_FILE=/secrets/.netrc
|
|||||||
|
|
||||||
# --- AI backend switch --------------------------------------------------
|
# --- AI backend switch --------------------------------------------------
|
||||||
# Which AI config from system_gpt_settings.json is active at startup
|
# Which AI config from system_gpt_settings.json is active at startup
|
||||||
# (e.g. "gpt" or "claude"). Runtime switch: $gadaj_teraz <config>. Unset =
|
# (e.g. "gpt", "claude" or "ollama"). Runtime switch:
|
||||||
# whatever the settings file's "active" key says, falling back to "gpt".
|
# $gadaj_teraz <config> [model]. Unset = whatever the settings file's "active"
|
||||||
|
# key says, falling back to "gpt".
|
||||||
# CONJURER_AI_CONFIG=gpt
|
# CONJURER_AI_CONFIG=gpt
|
||||||
|
|
||||||
|
# --- Ollama (self-hosted models) ----------------------------------------
|
||||||
|
# The endpoint IS the whole configuration - no API key. Leave unset and the
|
||||||
|
# "ollama" backend simply refuses to be selected. In-cluster, use the Service
|
||||||
|
# DNS name; from outside, host:port. Port 11434 is Ollama's default.
|
||||||
|
# CONJURER_OLLAMA_URL=http://ollama.ollama.svc.cluster.local:11434
|
||||||
|
# CONJURER_OLLAMA_URL=
|
||||||
|
# DEFAULT model for normal replies. $modele_ai lists what the server actually
|
||||||
|
# has pulled. Pinning one at runtime with `$gadaj_teraz ollama <model>` is
|
||||||
|
# persisted into system_gpt_settings.json and from then on WINS over this
|
||||||
|
# variable - the pin is the more recent, more explicit choice. Clear the
|
||||||
|
# "latest_model" of the ollama entry in that file to fall back to this default.
|
||||||
|
# CONJURER_OLLAMA_MODEL=llama3.1:8b
|
||||||
|
# Model used for the cheaper MUSIC path; defaults to CONJURER_OLLAMA_MODEL.
|
||||||
|
# CONJURER_OLLAMA_CHEAP_MODEL=
|
||||||
|
# How long to wait for ANY backend to answer. 120s suits hosted APIs; a
|
||||||
|
# self-hosted model on a modest GPU may need more.
|
||||||
|
# CONJURER_AI_TIMEOUT_SECONDS=120
|
||||||
|
|
||||||
# --- Data ---------------------------------------------------------------
|
# --- Data ---------------------------------------------------------------
|
||||||
# Single mounted volume; all writable state is rooted here.
|
# Single mounted volume; all writable state is rooted here.
|
||||||
CONJURER_DATA_DIR=/data
|
CONJURER_DATA_DIR=/data
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ Runbook for running Conjurer as Docker containers across Proxmox VMs:
|
|||||||
| **Share** (optional) | VM-musician or its own | `conjurer-share` | 8081 → 80 | `docker/Dockerfile.share` |
|
| **Share** (optional) | VM-musician or its own | `conjurer-share` | 8081 → 80 | `docker/Dockerfile.share` |
|
||||||
|
|
||||||
The share service publishes short-lived file links over Apache and is documented
|
The share service publishes short-lived file links over Apache and is documented
|
||||||
separately in [FILE_SHARING.md](FILE_SHARING.md) — it shares two volumes with the
|
separately: [SHARE_NODE_SETUP.md](SHARE_NODE_SETUP.md) to stand a node up from
|
||||||
musician, so set it up after the musician is running.
|
zero, [FILE_SHARING.md](FILE_SHARING.md) for how the feature works. It shares two
|
||||||
|
volumes with the musician, so set it up after the musician is running.
|
||||||
|
|
||||||
The three talk to each other over HTTP on the Proxmox LAN. Direction of calls:
|
The three talk to each other over HTTP on the Proxmox LAN. Direction of calls:
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ Historically only the Python half of this lived in the repo; the Apache config
|
|||||||
and the cron entries were placed on the host by hand. This document plus
|
and the cron entries were placed on the host by hand. This document plus
|
||||||
`docker/Dockerfile.share` close that gap.
|
`docker/Dockerfile.share` close that gap.
|
||||||
|
|
||||||
|
> Standing it up on a fresh node (directories, permissions, reverse proxy,
|
||||||
|
> verification)? Start with **[SHARE_NODE_SETUP.md](SHARE_NODE_SETUP.md)** — this
|
||||||
|
> document explains how the feature works and how it is secured.
|
||||||
|
|
||||||
## What it actually does
|
## What it actually does
|
||||||
|
|
||||||
1. A **scanner** walks the media library and writes a JSON index of every path.
|
1. A **scanner** walks the media library and writes a JSON index of every path.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# Share node setup — from zero
|
||||||
|
|
||||||
|
Everything the share service needs is **inside the image**: Apache, the vhost,
|
||||||
|
the index scanner and the link revoker. The node needs no Apache, no cron, no
|
||||||
|
Python and no copies of the old hand-placed scripts.
|
||||||
|
|
||||||
|
What the node actually provides is four directories, the media library, and a
|
||||||
|
way in from the internet.
|
||||||
|
|
||||||
|
> Feature docs (how links work, security model, TTL semantics) live in
|
||||||
|
> [FILE_SHARING.md](FILE_SHARING.md). This file is only "how to stand it up on a
|
||||||
|
> fresh box".
|
||||||
|
|
||||||
|
## What runs where
|
||||||
|
|
||||||
|
| Piece | Where it lives | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Apache + vhost | in the image | rendered from `share-vhost.conf.tpl` at start |
|
||||||
|
| `scan_shares.py` (index) | in the image | sleep loop, not cron |
|
||||||
|
| `revoke_shares.py` (expiry) | in the image | sleep loop, not cron |
|
||||||
|
| the symlinks | host `/srv/share/links` | **created by the musician**, served here |
|
||||||
|
| the index | host `/srv/share/db` | written here, **read by the musician** |
|
||||||
|
| TLS / public name | your reverse proxy | container speaks plain HTTP |
|
||||||
|
|
||||||
|
The single most important fact: **the musician creates the links, this service
|
||||||
|
serves them.** They must see the same directories, at the same paths.
|
||||||
|
|
||||||
|
## 1. Directories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo mkdir -p /srv/share/{media,links,db,logs}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Path | Contents | Mounted as |
|
||||||
|
|---|---|---|
|
||||||
|
| `/srv/share/media` | the media library | `/mnt/shares` (read-only) |
|
||||||
|
| `/srv/share/links` | published symlinks | `/var/www/html/share` |
|
||||||
|
| `/srv/share/db` | `share_scan.json` | `/srv/share/db` |
|
||||||
|
| `/srv/share/logs` | Apache logs incl. `share_access.log` | `/var/log/apache2` |
|
||||||
|
|
||||||
|
## 2. Media library and permissions
|
||||||
|
|
||||||
|
Put the library at `/srv/share/media` (bind mount, NFS mount, whatever — it is
|
||||||
|
only ever read). Apache serves as **`www-data`, uid 33 inside the container**, so
|
||||||
|
that uid must be able to traverse and read it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo chmod -R o+rX /srv/share/media # simplest; or use ACLs/group instead
|
||||||
|
sudo -u '#33' test -r /srv/share/media/<some-file> && echo "readable by www-data"
|
||||||
|
```
|
||||||
|
|
||||||
|
A library that root can read but uid 33 cannot is the classic "every link
|
||||||
|
404s / 403s" cause.
|
||||||
|
|
||||||
|
## 3. Deploy the stack
|
||||||
|
|
||||||
|
The musician and the share service share a filesystem, so the supported layout
|
||||||
|
is **both on the same node**, from one stack:
|
||||||
|
|
||||||
|
- Portainer → **Stacks → Add stack** → paste `docker/compose.musician-share.stack.yaml`
|
||||||
|
- or CLI: `docker compose -f docker/compose.musician-share.stack.yaml up -d`
|
||||||
|
|
||||||
|
That stack already wires the four mounts on both containers. Set
|
||||||
|
`CONJURER_SHARE_SERVER_NAME` to your public hostname.
|
||||||
|
|
||||||
|
Share-only node (musician elsewhere): use `docker/compose.share.yaml` and put
|
||||||
|
`/srv/share/links` + `/srv/share/db` on storage **both** hosts mount — otherwise
|
||||||
|
the musician cheerfully creates links this container cannot see.
|
||||||
|
|
||||||
|
## 4. Way in from the internet
|
||||||
|
|
||||||
|
The container listens on **8081 → 80**, plain HTTP by design; TLS stays on the
|
||||||
|
reverse proxy you already run. Forward the `/share/` prefix **unchanged** — the
|
||||||
|
links are `https://<host>/share/<token>`:
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
```nginx
|
||||||
|
location /share/ {
|
||||||
|
proxy_pass http://<share-node-ip>:8081/share/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Apache (as reverse proxy):
|
||||||
|
```apache
|
||||||
|
ProxyPass /share/ http://<share-node-ip>:8081/share/
|
||||||
|
ProxyPassReverse /share/ http://<share-node-ip>:8081/share/
|
||||||
|
```
|
||||||
|
|
||||||
|
Then: DNS for the public name points at the proxy, and the node's firewall lets
|
||||||
|
the proxy reach 8081 (nothing else needs to).
|
||||||
|
|
||||||
|
## 5. Tell the musician where to publish
|
||||||
|
|
||||||
|
The musician builds the URLs it posts to Discord. In its env:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
CONJURER_SHARE_DIR=/var/www/html/share
|
||||||
|
CONJURER_SHARE_DB=/srv/share/db/share_scan.json
|
||||||
|
CONJURER_SHARE_BASE_URL=https://czernobog.pl/share
|
||||||
|
```
|
||||||
|
|
||||||
|
`CONJURER_SHARE_BASE_URL` is **not** set in the bundled stack file — it falls back
|
||||||
|
to `https://czernobog.pl/share`. If your public name differs, set it explicitly
|
||||||
|
or every posted link points at the wrong host.
|
||||||
|
|
||||||
|
## The three couplings that break it
|
||||||
|
|
||||||
|
1. **Same container path for the media.** The index records absolute paths and
|
||||||
|
the symlinks are absolute. Both containers must mount the library at
|
||||||
|
`/mnt/shares`. Mount it elsewhere on one side and every link dangles.
|
||||||
|
2. **Same link dir and index dir** for musician and share (same host, or shared
|
||||||
|
storage).
|
||||||
|
3. **`CONJURER_SHARE_BASE_URL` must equal your real public `/share` URL.**
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# index built (should be non-trivial JSON)
|
||||||
|
sudo head -c 200 /srv/share/db/share_scan.json; echo
|
||||||
|
|
||||||
|
# the two jobs and Apache are alive
|
||||||
|
docker logs conjurer-share | tail -20 # "[share] serving ... scan every ...s"
|
||||||
|
|
||||||
|
# directory listing MUST fail (403) - it would leak every live token
|
||||||
|
curl -sI http://<share-node-ip>:8081/share/ | head -1
|
||||||
|
|
||||||
|
# revoker state files must NOT be served
|
||||||
|
curl -sI http://<share-node-ip>:8081/share/.downloads.json | head -1
|
||||||
|
|
||||||
|
# a real link: publish one from Discord, then
|
||||||
|
curl -sI https://<public-host>/share/<token> | head -1 # 200
|
||||||
|
```
|
||||||
|
|
||||||
|
After the first download of a link, `revoke_shares.py` removes it once
|
||||||
|
`CONJURER_SHARE_TTL_SECONDS` (default 1h) has passed. A link nobody downloads is
|
||||||
|
never revoked by that job.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| Symptom | Cause | Fix |
|
||||||
|
|---|---|---|
|
||||||
|
| every link 404 | media mounted at a different container path than `/mnt/shares`, or the symlink target is gone | align the mounts on both containers |
|
||||||
|
| every link 403 | media not readable by uid 33 | step 2 |
|
||||||
|
| links created but not served | musician and share not sharing `/srv/share/links` | step 3 |
|
||||||
|
| `/get_share_list` empty | index missing/not shared | check `/srv/share/db/share_scan.json` and the musician's `CONJURER_SHARE_DB` |
|
||||||
|
| links never expire | revoker cannot see the access log, or nobody downloaded them | check `/srv/share/logs/share_access.log` exists and grows |
|
||||||
|
| wrong host in posted links | `CONJURER_SHARE_BASE_URL` | step 5 |
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
"""Integration: transient Crossref failures must not destroy a search.
|
||||||
|
|
||||||
|
Field report: a single httpx ReadTimeout inside habanero surfaced as
|
||||||
|
"Search <uuid> crashed", and the worker then FORGOT the search - so an
|
||||||
|
expensive query vanished and the user got told it was eaten, all because a
|
||||||
|
public API blinked. These pin the two defences: retry each Crossref call, and
|
||||||
|
retry the whole search a bounded number of times before giving up.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if "habanero" not in sys.modules:
|
||||||
|
_habanero = types.ModuleType("habanero")
|
||||||
|
_habanero.Crossref = object
|
||||||
|
sys.modules["habanero"] = _habanero
|
||||||
|
|
||||||
|
import conjurer_librarian as lib # noqa: E402
|
||||||
|
from durable_queue import DiskQueue # noqa: E402
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("test-crossref-retry")
|
||||||
|
_LOG.addHandler(logging.NullHandler())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _no_backoff(monkeypatch):
|
||||||
|
monkeypatch.setattr(lib.time, "sleep", lambda _s: None)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def state(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setattr(lib, "_requests", DiskQueue(str(tmp_path / "req")))
|
||||||
|
monkeypatch.setattr(lib, "_checkpoints", DiskQueue(str(tmp_path / "cp")))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossref_call_retries_then_succeeds():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def flaky(**_kwargs):
|
||||||
|
calls["n"] += 1
|
||||||
|
if calls["n"] < 3:
|
||||||
|
raise RuntimeError("The read operation timed out")
|
||||||
|
return {"message": {"total-results": 1, "items": []}}
|
||||||
|
|
||||||
|
result = lib._crossref_call(_LOG, "works", flaky, query="q")
|
||||||
|
assert result["message"]["total-results"] == 1
|
||||||
|
assert calls["n"] == 3 # two failures survived
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossref_call_reraises_after_exhausting_attempts(monkeypatch):
|
||||||
|
monkeypatch.setattr(lib, "CROSSREF_ATTEMPTS", 2)
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def always_fails(**_kwargs):
|
||||||
|
calls["n"] += 1
|
||||||
|
raise RuntimeError("The read operation timed out")
|
||||||
|
|
||||||
|
with pytest.raises(RuntimeError):
|
||||||
|
lib._crossref_call(_LOG, "works", always_fails, query="q")
|
||||||
|
assert calls["n"] == 2 # bounded, not infinite
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossref_call_does_not_retry_a_success():
|
||||||
|
calls = {"n": 0}
|
||||||
|
|
||||||
|
def ok(**_kwargs):
|
||||||
|
calls["n"] += 1
|
||||||
|
return "fine"
|
||||||
|
|
||||||
|
assert lib._crossref_call(_LOG, "works", ok, query="q") == "fine"
|
||||||
|
assert calls["n"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_attempt_counter_persists_and_bounds_retries(state):
|
||||||
|
# Mirrors what the worker does on a crash: bump the persisted attempt count
|
||||||
|
# and keep the request until SEARCH_MAX_ATTEMPTS is reached.
|
||||||
|
uuid = "u-crash"
|
||||||
|
lib._requests.put(uuid, {"query": "q", "deep_search": False, "callback": ""})
|
||||||
|
|
||||||
|
for expected in (1, 2):
|
||||||
|
stored = lib._requests.get(uuid) or {}
|
||||||
|
attempts = int(stored.get("attempts", 0)) + 1
|
||||||
|
assert attempts == expected
|
||||||
|
stored["attempts"] = attempts
|
||||||
|
lib._requests.put(uuid, stored)
|
||||||
|
|
||||||
|
assert lib._requests.get(uuid)["attempts"] == 2
|
||||||
|
# A third crash reaches the default cap (3) -> the search is forgotten.
|
||||||
|
assert 3 >= lib.SEARCH_MAX_ATTEMPTS
|
||||||
|
lib._forget_search(uuid)
|
||||||
|
assert not lib._requests.contains(uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forget_search_clears_request_and_checkpoint(state):
|
||||||
|
lib._requests.put("u-x", {"query": "q", "deep_search": False})
|
||||||
|
lib._checkpoints.put("u-x", {"dois": {}, "found": [], "positions": {}})
|
||||||
|
lib._forget_search("u-x")
|
||||||
|
assert not lib._requests.contains("u-x")
|
||||||
|
assert not lib._checkpoints.contains("u-x")
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Integration: the 'still searching' heartbeat and its cheap progress estimate.
|
||||||
|
|
||||||
|
The estimate must stay free: producers already record a byte offset per chunk
|
||||||
|
file and the total is stat()'d once, so a reading is just a sum over ~40 ints.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
if "habanero" not in sys.modules:
|
||||||
|
_habanero = types.ModuleType("habanero")
|
||||||
|
_habanero.Crossref = object
|
||||||
|
sys.modules["habanero"] = _habanero
|
||||||
|
|
||||||
|
import conjurer_librarian as lib # noqa: E402
|
||||||
|
import search_bot # noqa: E402
|
||||||
|
|
||||||
|
_LOG = logging.getLogger("test-heartbeat")
|
||||||
|
_LOG.addHandler(logging.NullHandler())
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_summary_percentages():
|
||||||
|
progress = {"positions": {"0_chunk.txt": 250, "1_chunk.txt": 250}, "total_bytes": 1000}
|
||||||
|
done, total, percent = lib._progress_summary(progress)
|
||||||
|
assert (done, total) == (500, 1000)
|
||||||
|
assert percent == pytest.approx(50.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_summary_unknown_total_is_zero_percent():
|
||||||
|
done, total, percent = lib._progress_summary({"positions": {"a": 10}})
|
||||||
|
assert (done, total, percent) == (10, 0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_summary_handles_empty_and_none():
|
||||||
|
assert lib._progress_summary(None) == (0, 0, 0.0)
|
||||||
|
assert lib._progress_summary({}) == (0, 0, 0.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_summary_is_clamped_to_100():
|
||||||
|
# A partially-buffered tail can push the summed offsets past the total.
|
||||||
|
_done, _total, percent = lib._progress_summary(
|
||||||
|
{"positions": {"a": 1500}, "total_bytes": 1000}
|
||||||
|
)
|
||||||
|
assert percent == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_current_search_registration_round_trip():
|
||||||
|
progress = {"positions": {"a": 5}, "total_bytes": 10}
|
||||||
|
live = [{"DOI": "10.1/x"}]
|
||||||
|
lib._set_current_search("uuid-1", "kwas foliowy", progress, live)
|
||||||
|
with lib._current_lock:
|
||||||
|
snapshot = dict(lib._current_search)
|
||||||
|
assert snapshot["uuid"] == "uuid-1"
|
||||||
|
assert snapshot["query"] == "kwas foliowy"
|
||||||
|
assert lib._progress_summary(snapshot["progress"])[2] == pytest.approx(50.0)
|
||||||
|
lib._clear_current_search()
|
||||||
|
with lib._current_lock:
|
||||||
|
assert not lib._current_search
|
||||||
|
|
||||||
|
|
||||||
|
def _write_two_chunks(tmp_path):
|
||||||
|
(tmp_path / "0_chunk.txt").write_text("10.1/a\n10.1/b\n", encoding="utf-8")
|
||||||
|
(tmp_path / "1_chunk.txt").write_text("10.1/c\n", encoding="utf-8")
|
||||||
|
return sum(
|
||||||
|
(tmp_path / name).stat().st_size for name in ("0_chunk.txt", "1_chunk.txt")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_fills_progress_and_reaches_full_coverage(tmp_path, monkeypatch):
|
||||||
|
# Coverage must be measured on a search that CANNOT stop early. Once every
|
||||||
|
# queried DOI is found the consumer signals TERM and the producers stop
|
||||||
|
# mid-file, so a search for a DOI that exists reaches an arbitrary offset -
|
||||||
|
# asserting 100% there is a race (it failed roughly one run in two).
|
||||||
|
# An absent DOI forces the whole database to be read.
|
||||||
|
monkeypatch.setattr(search_bot, "DATABASE_PATH", str(tmp_path) + "/")
|
||||||
|
expected_total = _write_two_chunks(tmp_path)
|
||||||
|
|
||||||
|
progress = {}
|
||||||
|
search_bot.search_for_doi([("10.9/absent", "DATA")], [], _LOG, progress=progress)
|
||||||
|
|
||||||
|
assert progress["total_bytes"] == expected_total
|
||||||
|
assert progress["chunk_files"] == 2
|
||||||
|
done, total, percent = lib._progress_summary(progress)
|
||||||
|
assert total == expected_total
|
||||||
|
assert done == expected_total # nothing stopped it: whole DB scanned
|
||||||
|
assert percent == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_progress_is_populated_for_a_search_that_finds_its_target(tmp_path, monkeypatch):
|
||||||
|
# The early-termination case: the target is found, so coverage is whatever
|
||||||
|
# the producers reached. Assert what IS deterministic - the total is known,
|
||||||
|
# progress is bounded and sane, and the hit is reported.
|
||||||
|
monkeypatch.setattr(search_bot, "DATABASE_PATH", str(tmp_path) + "/")
|
||||||
|
expected_total = _write_two_chunks(tmp_path)
|
||||||
|
|
||||||
|
progress = {}
|
||||||
|
result, _positions, _interrupted = search_bot.search_for_doi(
|
||||||
|
[("10.1/c", "DATA")], [], _LOG, progress=progress
|
||||||
|
)
|
||||||
|
|
||||||
|
assert progress["total_bytes"] == expected_total
|
||||||
|
done, total, percent = lib._progress_summary(progress)
|
||||||
|
assert total == expected_total
|
||||||
|
assert 0 <= done <= total # bounded, never nonsense
|
||||||
|
assert 0.0 <= percent <= 100.0
|
||||||
|
assert [r for r in result if r["DOI"] == "10.1/c" and r["exists"]]
|
||||||
@@ -136,3 +136,401 @@ def test_map_openai_error_categories():
|
|||||||
assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit"
|
assert ai_functions._map_openai_error(_bare(openai.RateLimitError)).category == "rate_limit"
|
||||||
assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout"
|
assert ai_functions._map_openai_error(_bare(openai.APITimeoutError)).category == "timeout"
|
||||||
assert ai_functions._map_openai_error(ValueError("x")).category == "api"
|
assert ai_functions._map_openai_error(ValueError("x")).category == "api"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Ollama
|
||||||
|
# The self-hosted backend is wired through Ollama's OpenAI-compatible surface,
|
||||||
|
# so it reuses the message format and the error mapping above. What is new and
|
||||||
|
# worth pinning: it must appear in the picker even on an upgraded settings file,
|
||||||
|
# models come from the SERVER, and pinning one must stick.
|
||||||
|
import asyncio # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModel:
|
||||||
|
def __init__(self, ident):
|
||||||
|
self.id = ident
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeModels:
|
||||||
|
def __init__(self, ids, raises=None):
|
||||||
|
self._ids = ids
|
||||||
|
self._raises = raises
|
||||||
|
|
||||||
|
async def list(self):
|
||||||
|
if self._raises:
|
||||||
|
raise self._raises
|
||||||
|
return types.SimpleNamespace(data=[_FakeModel(i) for i in self._ids])
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeOllamaClient:
|
||||||
|
def __init__(self, ids=(), raises=None):
|
||||||
|
self.models = _FakeModels(list(ids), raises)
|
||||||
|
|
||||||
|
|
||||||
|
def test_ollama_config_is_offered_in_the_picker():
|
||||||
|
assert "ollama" in ai_functions.list_ai_configs()
|
||||||
|
cfg = ai_functions.AI_CONFIGS["ollama"]
|
||||||
|
assert cfg["provider"] == "ollama"
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_model_uses_ollama_models_when_active(monkeypatch):
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "llama3.1:8b", "cheap_model": "qwen2.5:3b"},
|
||||||
|
)
|
||||||
|
_reset_active("ollama")
|
||||||
|
try:
|
||||||
|
# the legacy gpt-4o default must auto-map, not leak to Ollama
|
||||||
|
assert ai_functions.select_model("GENERAL", "gpt-4o") == "llama3.1:8b"
|
||||||
|
assert ai_functions.select_model("MUSIC", "gpt-4o") == "qwen2.5:3b"
|
||||||
|
# an explicit id is still honoured verbatim
|
||||||
|
assert ai_functions.select_model("GENERAL", "mistral:7b") == "mistral:7b"
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_provider_models_queries_the_ollama_server(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "OLLAMACLIENT", _FakeOllamaClient(["b:2", "a:1", "a:1"])
|
||||||
|
)
|
||||||
|
models = asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||||
|
assert models == ["a:1", "b:2"] # sorted + de-duplicated
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_provider_models_for_hosted_provider_reports_configured_ids():
|
||||||
|
models = asyncio.run(ai_functions.list_provider_models("gpt"))
|
||||||
|
assert models == [
|
||||||
|
ai_functions.AI_CONFIGS["gpt"]["latest_model"],
|
||||||
|
ai_functions.AI_CONFIGS["gpt"]["cheap_model"],
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_provider_models_wraps_server_failure(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "OLLAMACLIENT", _FakeOllamaClient(raises=ValueError("boom"))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||||
|
except ai_functions.AIError as exc:
|
||||||
|
assert exc.category == "api"
|
||||||
|
else:
|
||||||
|
raise AssertionError("a server failure must surface as AIError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_provider_models_without_endpoint_is_an_auth_error(monkeypatch):
|
||||||
|
# Contrast, so the assertion cannot pass vacuously: with a client present the
|
||||||
|
# call succeeds, and ONLY setting it to None turns it into an auth error.
|
||||||
|
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", _FakeOllamaClient(["a:1"]))
|
||||||
|
assert asyncio.run(ai_functions.list_provider_models("ollama")) == ["a:1"]
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", None)
|
||||||
|
try:
|
||||||
|
asyncio.run(ai_functions.list_provider_models("ollama"))
|
||||||
|
except ai_functions.AIError as exc:
|
||||||
|
assert exc.category == "auth"
|
||||||
|
else:
|
||||||
|
raise AssertionError("an unconfigured Ollama must surface as AIError")
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_model_pins_latest_and_keeps_cheap(monkeypatch):
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "old:1", "cheap_model": "cheap:1"},
|
||||||
|
)
|
||||||
|
written = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions,
|
||||||
|
"_persist_active_ai_config",
|
||||||
|
lambda name, model_for=None: written.update(name=name, model_for=model_for),
|
||||||
|
|
||||||
|
)
|
||||||
|
ai_functions.set_active_model("mistral:7b", "ollama")
|
||||||
|
# Assert on the shared registry, not on the returned object - that object IS
|
||||||
|
# the mutated dict, so asserting on it would pass even if nothing was stored.
|
||||||
|
stored = ai_functions.AI_CONFIGS["ollama"]
|
||||||
|
assert stored["latest_model"] == "mistral:7b"
|
||||||
|
assert stored["cheap_model"] == "cheap:1" # MUSIC path untouched
|
||||||
|
assert written["name"] # the choice was persisted...
|
||||||
|
assert written["model_for"] == "ollama" # ...scoped to the config we changed
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_model_rejects_blank_and_unknown_config(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "_persist_active_ai_config", lambda _n, model_for=None: None
|
||||||
|
)
|
||||||
|
for bad in ("", " "):
|
||||||
|
try:
|
||||||
|
ai_functions.set_active_model(bad, "gpt")
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("a blank model id must be rejected")
|
||||||
|
try:
|
||||||
|
ai_functions.set_active_model("x", "nie-ma-takiego")
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
raise AssertionError("an unknown config must be rejected")
|
||||||
|
|
||||||
|
|
||||||
|
def test_switching_to_ollama_without_endpoint_explains_itself(monkeypatch):
|
||||||
|
monkeypatch.setattr(ai_functions, "OLLAMACLIENT", None)
|
||||||
|
try:
|
||||||
|
ai_functions.set_active_ai_config("ollama")
|
||||||
|
except RuntimeError as exc:
|
||||||
|
assert "CONJURER_OLLAMA_URL" in str(exc)
|
||||||
|
else:
|
||||||
|
raise AssertionError("switching to an unconfigured Ollama must raise")
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_generate_routes_to_ollama(monkeypatch):
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "m:1", "cheap_model": "m:1"},
|
||||||
|
)
|
||||||
|
_reset_active("ollama")
|
||||||
|
seen = {}
|
||||||
|
|
||||||
|
async def _fake_ollama_call(messages, model, cfg):
|
||||||
|
seen.update(model=model, messages=messages)
|
||||||
|
return "odpowiedź z domu"
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_functions, "_ollama_call", _fake_ollama_call)
|
||||||
|
try:
|
||||||
|
out = asyncio.run(
|
||||||
|
ai_functions.provider_generate([{"role": "user", "content": "hej"}], "m:1")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
assert out == "odpowiedź z domu"
|
||||||
|
assert seen["model"] == "m:1"
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- persistence (real disk path)
|
||||||
|
# This path had NO coverage, which is exactly how a config-clobbering regression
|
||||||
|
# got in: persisting the whole in-memory AI_CONFIGS (built-in defaults merged
|
||||||
|
# under the file) overwrote operator hand-edits and resurrected deleted configs.
|
||||||
|
import json # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _settings_file(tmp_path, configs, active="gpt"):
|
||||||
|
path = tmp_path / "system_gpt_settings.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
[
|
||||||
|
{"role": "system", "content": "sys"},
|
||||||
|
{"someuser": [1, "a", "b", "c", "asst_x"]},
|
||||||
|
{"active": active, "configs": configs},
|
||||||
|
]
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_writes_the_pin_without_clobbering_operator_edits(tmp_path, monkeypatch):
|
||||||
|
# The file is authoritative for everything the bot does not itself change:
|
||||||
|
# a hand-tuned cheap_model, and a config deliberately deleted from it.
|
||||||
|
settings = _settings_file(
|
||||||
|
tmp_path,
|
||||||
|
{"gpt": {"provider": "openai", "latest_model": "gpt-4.1", "cheap_model": "hand-tuned"}},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "mistral:7b", "cheap_model": "c:1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_functions._persist_active_ai_config("ollama", model_for="ollama")
|
||||||
|
|
||||||
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||||
|
configs = data[2]["configs"]
|
||||||
|
assert data[2]["active"] == "ollama"
|
||||||
|
assert configs["ollama"]["latest_model"] == "mistral:7b" # the pin landed
|
||||||
|
assert configs["gpt"]["latest_model"] == "gpt-4.1" # edit survived
|
||||||
|
assert configs["gpt"]["cheap_model"] == "hand-tuned" # edit survived
|
||||||
|
assert "claude" not in configs # a deleted config is NOT resurrected
|
||||||
|
assert data[0]["content"] == "sys" and "someuser" in data[1] # rest intact
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_switch_leaves_the_configs_block_untouched(tmp_path, monkeypatch):
|
||||||
|
original = {"gpt": {"provider": "openai", "latest_model": "gpt-4.1", "cheap_model": "hand-tuned"}}
|
||||||
|
settings = _settings_file(tmp_path, original, active="claude")
|
||||||
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
||||||
|
|
||||||
|
# Switching backend without pinning a model must only move "active".
|
||||||
|
ai_functions._persist_active_ai_config("gpt")
|
||||||
|
|
||||||
|
data = json.loads(settings.read_text(encoding="utf-8"))
|
||||||
|
assert data[2]["active"] == "gpt"
|
||||||
|
assert data[2]["configs"] == original
|
||||||
|
|
||||||
|
|
||||||
|
def test_pinned_model_survives_a_restart(tmp_path, monkeypatch):
|
||||||
|
# The whole point of persisting: re-reading the file must yield the pin.
|
||||||
|
settings = _settings_file(tmp_path, {"ollama": {"provider": "ollama", "latest_model": "old:1"}})
|
||||||
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(settings))
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "new:2", "cheap_model": "c:1"},
|
||||||
|
)
|
||||||
|
|
||||||
|
ai_functions._persist_active_ai_config("ollama", model_for="ollama")
|
||||||
|
|
||||||
|
reread = json.loads(settings.read_text(encoding="utf-8"))[2]
|
||||||
|
assert reread["configs"]["ollama"]["latest_model"] == "new:2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_persist_survives_an_unreadable_settings_file(tmp_path, monkeypatch):
|
||||||
|
# Best-effort by contract: a broken file must not raise into the command.
|
||||||
|
broken = tmp_path / "broken.json"
|
||||||
|
broken.write_text("{ not json", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(broken))
|
||||||
|
ai_functions._persist_active_ai_config("gpt", model_for="gpt") # must not raise
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------- keep-warm (Ollama ONLY) ----
|
||||||
|
# The money guard: preloading a self-hosted model is free, but firing the same
|
||||||
|
# thing at a metered API would burn tokens for nothing. These pin that it can
|
||||||
|
# only ever happen for Ollama.
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_active_model_is_a_noop_for_paid_providers(monkeypatch):
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "_ollama_preload", lambda *a, **k: called.append(a) or True
|
||||||
|
)
|
||||||
|
for paid in ("gpt", "claude"):
|
||||||
|
_reset_active(paid)
|
||||||
|
try:
|
||||||
|
assert asyncio.run(ai_functions.warm_active_model()) is False
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
assert called == [], "a paid backend must never be preloaded"
|
||||||
|
|
||||||
|
|
||||||
|
def test_warm_active_model_preloads_when_ollama_is_active(monkeypatch):
|
||||||
|
monkeypatch.setitem(
|
||||||
|
ai_functions.AI_CONFIGS,
|
||||||
|
"ollama",
|
||||||
|
{"provider": "ollama", "latest_model": "qwen2.5:7b", "cheap_model": "c"},
|
||||||
|
)
|
||||||
|
seen = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "_ollama_preload", lambda model, *a, **k: seen.update(model=model) or True
|
||||||
|
)
|
||||||
|
_reset_active("ollama")
|
||||||
|
try:
|
||||||
|
assert asyncio.run(ai_functions.warm_active_model()) is True
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
assert seen["model"] == "qwen2.5:7b"
|
||||||
|
|
||||||
|
|
||||||
|
def test_active_provider_reports_the_switch():
|
||||||
|
_reset_active("gpt")
|
||||||
|
assert ai_functions.active_provider() == "openai"
|
||||||
|
_reset_active("claude")
|
||||||
|
try:
|
||||||
|
assert ai_functions.active_provider() == "anthropic"
|
||||||
|
finally:
|
||||||
|
_reset_active("gpt")
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_sends_no_prompt_so_it_generates_nothing(monkeypatch):
|
||||||
|
# Ollama's documented preload: a model and keep_alive, and NO prompt. If a
|
||||||
|
# prompt ever crept in, every warm-up would silently generate tokens.
|
||||||
|
sent = {}
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
monkeypatch.setattr(ai_functions, "OLLAMA_URL", "http://ollama:11434")
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions.requests, "post",
|
||||||
|
lambda url, json=None, timeout=None: sent.update(url=url, body=json) or _Resp(),
|
||||||
|
)
|
||||||
|
assert ai_functions._ollama_preload("qwen2.5:7b") is True
|
||||||
|
assert sent["url"].endswith("/api/generate")
|
||||||
|
assert sent["body"]["model"] == "qwen2.5:7b"
|
||||||
|
assert "keep_alive" in sent["body"]
|
||||||
|
assert "prompt" not in sent["body"], "a preload must not generate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preload_without_endpoint_is_a_noop(monkeypatch):
|
||||||
|
monkeypatch.setattr(ai_functions, "OLLAMA_URL", "")
|
||||||
|
assert ai_functions._ollama_preload("x") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------- personal assistants (per user) --
|
||||||
|
# Replaces the sunset OpenAI Assistants API. The two properties that matter:
|
||||||
|
# each user's DM history is ISOLATED (private DMs must not leak into another
|
||||||
|
# user's context or the bar's shared memory), and it stays BOUNDED.
|
||||||
|
|
||||||
|
|
||||||
|
def _fresh_assistant_memory(tmp_path, monkeypatch, turns=40):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
ai_functions, "ASSISTANT_MEMORY_FILE", str(tmp_path / "assistant_memory.json")
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(ai_functions, "ASSISTANT_MEMORY_TURNS", turns)
|
||||||
|
monkeypatch.setattr(ai_functions, "_ASSISTANT_MEMORY", None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_history_is_isolated_per_user(tmp_path, monkeypatch):
|
||||||
|
_fresh_assistant_memory(tmp_path, monkeypatch)
|
||||||
|
ai_functions.remember_assistant_turn(111, "sekret Anny", "ok Anna")
|
||||||
|
ai_functions.remember_assistant_turn(222, "sekret Bartka", "ok Bartek")
|
||||||
|
|
||||||
|
anna = ai_functions.assistant_history(111)
|
||||||
|
bartek = ai_functions.assistant_history(222)
|
||||||
|
assert [m["content"] for m in anna] == ["sekret Anny", "ok Anna"]
|
||||||
|
assert [m["content"] for m in bartek] == ["sekret Bartka", "ok Bartek"]
|
||||||
|
assert "sekret Anny" not in str(bartek) # no cross-user bleed
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_history_is_trimmed_to_the_bound(tmp_path, monkeypatch):
|
||||||
|
_fresh_assistant_memory(tmp_path, monkeypatch, turns=4)
|
||||||
|
for i in range(10):
|
||||||
|
ai_functions.remember_assistant_turn(1, f"u{i}", f"a{i}")
|
||||||
|
history = ai_functions.assistant_history(1)
|
||||||
|
assert len(history) == 4 # bounded
|
||||||
|
assert history[-1]["content"] == "a9" # newest kept
|
||||||
|
assert all("u0" != m["content"] for m in history) # oldest dropped
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_history_survives_a_restart(tmp_path, monkeypatch):
|
||||||
|
_fresh_assistant_memory(tmp_path, monkeypatch)
|
||||||
|
ai_functions.remember_assistant_turn(7, "pamietaj", "pamietam")
|
||||||
|
# Simulate a restart: drop the in-memory cache, re-read from disk.
|
||||||
|
monkeypatch.setattr(ai_functions, "_ASSISTANT_MEMORY", None)
|
||||||
|
assert [m["content"] for m in ai_functions.assistant_history(7)] == [
|
||||||
|
"pamietaj",
|
||||||
|
"pamietam",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_assistant_messages_carry_persona_history_and_new_turn(tmp_path, monkeypatch):
|
||||||
|
_fresh_assistant_memory(tmp_path, monkeypatch)
|
||||||
|
ai_functions.remember_assistant_turn(5, "wczoraj", "odpowiedz")
|
||||||
|
msgs = ai_functions.build_assistant_messages(
|
||||||
|
5, "Towarzysz Młotek", "Mówisz po polsku.", "dzisiaj"
|
||||||
|
)
|
||||||
|
assert msgs[0]["role"] == "system"
|
||||||
|
assert "Towarzysz Młotek" in msgs[0]["content"]
|
||||||
|
assert "Mówisz po polsku." in msgs[0]["content"]
|
||||||
|
assert [m["content"] for m in msgs[1:]] == ["wczoraj", "odpowiedz", "dzisiaj"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_corrupt_assistant_memory_starts_empty_instead_of_crashing(tmp_path, monkeypatch):
|
||||||
|
path = tmp_path / "assistant_memory.json"
|
||||||
|
path.write_text("{ not json", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(ai_functions, "ASSISTANT_MEMORY_FILE", str(path))
|
||||||
|
monkeypatch.setattr(ai_functions, "_ASSISTANT_MEMORY", None)
|
||||||
|
assert ai_functions.assistant_history(1) == []
|
||||||
|
|||||||
Reference in New Issue
Block a user