Compare commits
6 Commits
d0c7ab61a7
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c91ec03b83 | |||
| 9b6666dc9c | |||
| fdc1fa1817 | |||
| cfd19e2b34 | |||
| 6a6b821a0d | |||
| 13d2a04052 |
+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):
|
||||||
|
|||||||
+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")
|
||||||
|
|||||||
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
|
||||||
|
|||||||
@@ -60,25 +60,49 @@ def test_current_search_registration_round_trip():
|
|||||||
assert not lib._current_search
|
assert not lib._current_search
|
||||||
|
|
||||||
|
|
||||||
def test_search_fills_progress_with_live_positions_and_total(tmp_path, monkeypatch):
|
def _write_two_chunks(tmp_path):
|
||||||
# End to end against the real scan: total_bytes matches the chunk files on
|
|
||||||
# disk, and once finished the recorded offsets cover them.
|
|
||||||
monkeypatch.setattr(search_bot, "DATABASE_PATH", str(tmp_path) + "/")
|
|
||||||
(tmp_path / "0_chunk.txt").write_text("10.1/a\n10.1/b\n", encoding="utf-8")
|
(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")
|
(tmp_path / "1_chunk.txt").write_text("10.1/c\n", encoding="utf-8")
|
||||||
expected_total = sum(
|
return sum(
|
||||||
(tmp_path / name).stat().st_size for name in ("0_chunk.txt", "1_chunk.txt")
|
(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 = {}
|
progress = {}
|
||||||
result, _positions, _interrupted = search_bot.search_for_doi(
|
result, _positions, _interrupted = search_bot.search_for_doi(
|
||||||
[("10.1/c", "DATA")], [], _LOG, progress=progress
|
[("10.1/c", "DATA")], [], _LOG, progress=progress
|
||||||
)
|
)
|
||||||
|
|
||||||
assert progress["total_bytes"] == expected_total
|
assert progress["total_bytes"] == expected_total
|
||||||
assert progress["chunk_files"] == 2
|
|
||||||
done, total, percent = lib._progress_summary(progress)
|
done, total, percent = lib._progress_summary(progress)
|
||||||
assert total == expected_total
|
assert total == expected_total
|
||||||
assert done == expected_total # whole DB scanned
|
assert 0 <= done <= total # bounded, never nonsense
|
||||||
assert percent == pytest.approx(100.0)
|
assert 0.0 <= percent <= 100.0
|
||||||
assert [r for r in result if r["DOI"] == "10.1/c" and r["exists"]]
|
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