AI: personal assistants without the dead API, and keep Ollama warm
CI / compile (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 26s
build / build (push) Successful in 12s
CI / compile (push) Successful in 5s
CI / unit (push) Successful in 22s
CI / integration (push) Successful in 26s

Two things the field report asked for.

1) PERSONAL ASSISTANTS (replacing the sunset OpenAI Assistants API)

The old implementation gave three capabilities. Two are reimplemented here,
the third was confirmed unused and is deliberately not replaced:

 * per-user persona - it already lived in system_gpt_settings.json; it was
   only ever being shipped to OpenAI. It is now the system prompt.
 * per-user conversation thread - OpenAI held this server-side. It now lives
   in assistant_memory.json, keyed by discord user id, trimmed to the most
   recent turns (CONJURER_ASSISTANT_MEMORY_TURNS) and written atomically so a
   torn write cannot lose someone's history. Deliberately 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".
 * file_search - not replaced. Confirmed not in use.

The conversation goes through handle_response with request_type="NONE" and an
explicit message list, which keeps it out of the bar's shared memory. The big
win: create_chat_assistant hardcoded model="gpt-4o", so assistants were locked
to OpenAI. They now run on whatever $gadaj_teraz selects - Claude and Ollama
included.

create_chat_assistant / chat_with_assistant are gone, and with them the last
call to beta.threads in the startup path - so the cog cannot be killed by that
API again. (add_files_to_vector_store / delete_files_from_vector_store still
reference beta.assistants but are dead code - nothing calls them - so they
cannot crash anything; left alone rather than widening this change.)

2) KEEPING A SELF-HOSTED MODEL WARM

Loading is the slow part - the GPU is shared with other users - so we preload
via Ollama's documented mechanism: /api/generate with a model, a keep_alive
and NO prompt. It loads the model and generates nothing.

 * on switching to ollama, $gadaj_teraz fires a preload in the BACKGROUND
   (not awaited: loading can take minutes and the command must answer at
   once), so the wait lands on the operator rather than the first user;
 * a warm loop re-asserts keep_alive every CONJURER_OLLAMA_WARM_MINUTES.

Both are hard-guarded on the ACTIVE provider being ollama. Warming a metered
API would burn tokens and money for nothing, so that guard is pinned by a test
asserting the preload is never called for gpt/claude, and another asserting the
preload body carries no prompt (a prompt would make every warm-up generate).

Tests: 82 unit + 71 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit was merged in pull request #28.
This commit is contained in:
2026-08-27 16:25:01 +02:00
parent 9b6666dc9c
commit c91ec03b83
4 changed files with 356 additions and 112 deletions
+46 -63
View File
@@ -1,4 +1,5 @@
# ai command cogs
import asyncio
import logging
import re
import sys
@@ -17,7 +18,7 @@ from communication_subroutine import AI_QUERY_Q
import ai_functions
from constants import (
ASSISTANTS,
OLLAMA_WARM_MINUTES,
DATA,
GRAPHICS_PATH,
INITIAL_TIME_WAIT,
@@ -101,74 +102,45 @@ class Events(commands.Cog):
text = text[1900:]
async def cog_load(self):
# The AI query worker must run regardless of the OpenAI guard below - it
# answers via handle_response, which works on Claude too. Start it first.
# The AI query worker answers via handle_response, so it works on every
# backend. Start it first.
if not self.ai_query_worker.is_running():
self.ai_query_worker.start()
self.logger.info("Starting personal assistants")
# Personal assistants use the OpenAI Assistants API (threads/runs), which
# has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up
# (e.g. a Claude-only deployment) instead of crashing the cog load.
if OPENAICLIENT is None:
self.logger.warning(
"OPENAICLIENT niedostępny - osobiści asystenci (OpenAI Assistants API) wyłączeni"
)
return
# The bootstrap below must NEVER take the cog down with it. It calls the
# OpenAI Assistants API (beta threads/runs), which is a legacy surface -
# it now answers 404, and that exception propagated out of cog_load,
# failed the whole extension, and took EVERY AI command with it
# ($gadaj_teraz, $modele_ai, the conversation handler). Personal
# assistants are one optional feature; losing them must not disable the
# AI cog, which otherwise works fine on Claude and Ollama.
# Keeps a self-hosted model resident; it no-ops on any other provider.
if not self.ollama_warm_loop.is_running():
self.ollama_warm_loop.start()
# NOTE: there is no OpenAI-Assistants bootstrap any more. It called a
# sunset API (beta threads), 404'd, and failed the WHOLE extension -
# taking every AI command with it. Personal assistants now ride
# handle_response with per-user memory (ai_functions), so they work on
# Claude and Ollama too and nothing has to be created at startup.
self.logger.info("Osobiści asystenci: pamięć per-user, aktywny backend AI")
@tasks.loop(minutes=OLLAMA_WARM_MINUTES)
async def ollama_warm_loop(self):
"""Keep a self-hosted model resident so users don't pay the load wait.
Loading is the slow part on a GPU shared with other users, so we
re-assert Ollama's keep_alive well inside its window. This preloads
WITHOUT generating - no tokens, no cost.
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.
"""
try:
await self._start_personal_assistants()
if ai_functions.active_provider() != "ollama":
return
await ai_functions.warm_active_model()
except Exception as exc: # pylint: disable=broad-exception-caught
self.logger.warning(
"Osobiści asystenci (OpenAI Assistants API) wyłączeni - %s: %s. "
"Reszta AI (rozmowy, $gadaj_teraz, $modele_ai) działa normalnie.",
type(exc).__name__, exc,
)
self.logger.info("Rozgrzewanie Ollamy nieudane (nieszkodliwe): %s", exc)
async def _start_personal_assistants(self):
"""Bootstrap the per-user OpenAI Assistants threads. Optional feature:
callers must treat a failure here as non-fatal (see cog_load)."""
for superfryta_id, superfryta in SPECJALNE_ZIEMNIACZKI.items():
if superfryta[4] != "":
self.logger.info(
"Personal assistant for user: %s, exists id: %s,name: %s, owner: %s, special instructions: %s assistant id: %s ",
superfryta_id,
superfryta[0],
superfryta[1],
superfryta[2],
superfryta[3],
superfryta[4],
)
thread = await OPENAICLIENT.beta.threads.create()
self.logger.info("Thread id: %s", thread.id)
ASSISTANTS[superfryta[1]] = (
superfryta[2],
superfryta[4],
superfryta[0],
thread,
)
else:
self.logger.info(
"Creating personal assistant for user: %s, id: %s,name: %s, owner: %s, special instructions: %s",
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")
@ollama_warm_loop.before_loop
async def before_ollama_warm_loop(self):
await self.bot.wait_until_ready()
async def cog_unload(self):
self.ai_query_worker.cancel()
self.ollama_warm_loop.cancel()
@commands.hybrid_command(
name="switch_dm_mode",
@@ -297,6 +269,15 @@ class Events(commands.Cog):
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:
@@ -430,8 +411,10 @@ class Events(commands.Cog):
if message.author.id == superfryta[0]:
self.logger.info("Specjalny ziemniak")
if self.armia[message.author.id] == Dm_Mode.SPECJALNY_ZIEMNIACZEK:
#await self.bot.process_commands(message)
await ai_functions.chat_with_assistant(message, superfryta[1])
# superfryta = [discord_id, assistant_name, owner, instructions, legacy_assistant_id]
await ai_functions.chat_with_personal_assistant(
message, superfryta[2], superfryta[3]
)
return
elif self.armia[message.author.id] == Dm_Mode.ECHO_ECHO:
await ai_functions.echo(message)