Compare commits

..

7 Commits

Author SHA1 Message Date
gitea 25d6d4a3c0 AI: delete the dead OpenAI vector-store / file_search code
CI / compile (pull_request) Successful in 9s
CI / unit (pull_request) Successful in 24s
CI / integration (pull_request) Successful in 29s
file_search was confirmed unused, and these four functions were its only
implementation: create_vector_store, upload_files_to_vector_store,
add_files_to_vector_store and delete_files_from_vector_store, plus the
VECTOR_STORE_ID constant they were the only readers of.

Nothing called any of them - verified before removing - so they could not
crash anything, but they were the last references to beta.vector_stores and
beta.assistants: a sunset API that already took the whole AI cog down once
when a startup path touched it. Wiring them back up would have hit the same
404. Removing them means no path in this module can reach that API again.

54 lines gone, no behaviour change. Suite: 82 unit + 71 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-28 09:39:56 +02:00
gitea c91ec03b83 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>
2026-08-27 16:25:01 +02:00
gitea 9b6666dc9c AI: a dead Assistants API must not disable the whole AI cog
CI / compile (pull_request) Successful in 8s
CI / unit (pull_request) Successful in 29s
CI / integration (pull_request) Successful in 39s
build / build (push) Successful in 4m29s
CI / compile (push) Successful in 5s
CI / unit (push) Successful in 22s
CI / integration (push) Successful in 27s
Field report from both instances: "Command modele_ai is not found", and on
restart the extension fails outright:

  ai_commands.py:129 in cog_load
    thread = await OPENAICLIENT.beta.threads.create()
  openai.NotFoundError: Error code: 404
  -> ExtensionFailed: Extension 'ai_commands' raised an error

The personal-assistants bootstrap calls the OpenAI Assistants API (beta
threads/runs), a legacy surface that now answers 404. That exception
propagated out of cog_load, so discord.py failed the whole extension - and
with it EVERY AI command: $gadaj_teraz, $modele_ai and the conversation
handler. The bot kept running (bot.py loads each extension defensively), it
simply had no AI at all.

cog_load already had the right instinct - it skips the bootstrap cleanly
when OPENAICLIENT is None, so a Claude-only deployment works - but it
guarded against the client being ABSENT, not against the call FAILING.

Move the bootstrap into _start_personal_assistants() and treat any failure
there as non-fatal: log what was lost and carry on. Personal assistants are
one optional feature; the rest of the cog works fine on Claude and Ollama
and must not go down with them.

Not unit-tested on purpose: exercising cog_load needs stubs for openai,
tiktoken and a tasks.loop complete enough to answer is_running(), at which
point the test exercises the stubs rather than the code. Verified against
the running cluster instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-27 16:12:03 +02:00
gitea fdc1fa1817 AI: warn when the selected Ollama model is not on the server
build / build (push) Successful in 16s
CI / compile (push) Successful in 6s
CI / unit (push) Successful in 22s
CI / integration (push) Successful in 1m38s
Switching to a backend whose configured model the server does not have
succeeded silently, and then every reply failed with "model not found" with
nothing explaining why. The switch already fetches the model list to show
what else is available, so use it: if the config's model is absent, say so
and list what IS there.

Found while probing the real server (192.168.1.72): it has exactly one
model, gemma4:e2b, so the built-in llama3.1:8b default would have hit this
on the first switch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:41:28 +00:00
gitea cfd19e2b34 AI: persist only the pinned field, not the whole config block
Adversarial review of the previous commit found a real regression it
introduced, reproduced against the actual code rather than inferred.

Changing _persist_active_ai_config from setdefault("configs", ...) to a
direct assignment made every backend switch write the whole in-memory
AI_CONFIGS over the settings file. Because AI_CONFIGS is now the built-in
defaults merged UNDER the file, that meant:

* an operator's hand edits were destroyed - and hand editing is the only
  way to change cheap_model / temperature / max_tokens, since
  set_active_model writes latest_model and there is no command for the rest,
* a config deliberately deleted from the file was re-seeded from the
  defaults and written back, permanently,
* pinning a model for one provider silently reverted another provider's
  entry,
* CONJURER_OLLAMA_MODEL stopped having any effect once the env-derived
  block had been persisted once.

The original motivation was still valid (plain setdefault would drop a
pinned model), so the fix is narrower rather than a revert: persist ONLY
the field this process actually changed. _persist_active_ai_config takes
model_for and writes back just that config's latest_model; everything else
in the on-disk block is left exactly as found. The constants.py merge stays
- it is what keeps a newly added provider visible after an upgrade - and is
now in-memory only, so it cannot reach the file.

Tests: the disk-write path had ZERO coverage, which is precisely how this
got in. Added four tests that drive the real _persist_active_ai_config
against a temp settings file: the pin lands while operator edits survive and
a deleted config is not resurrected; a plain switch leaves the configs block
byte-identical; a pin survives a re-read; a corrupt file does not raise.
Verified they have teeth - reintroducing the regression fails two of them.

Also hardened two weak tests the review caught: the pin test asserted on the
object set_active_model returns, which IS the mutated dict (so it passed
regardless), and the unconfigured-endpoint test monkeypatched OLLAMACLIENT
to None when it was already None, passing vacuously.

Suite: 72 unit + 70 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:41:28 +00:00
gitea 6a6b821a0d AI: add a self-hosted Ollama backend, and let the picker choose the model
The bot could talk to OpenAI or Anthropic; this adds Ollama as a third
provider so it can run against models hosted on our own box, and extends
the switch command to pick WHICH model - not just which backend.

Provider: Ollama exposes an OpenAI-compatible /v1 surface, so the client is
just openai.AsyncOpenAI(base_url=OLLAMA_URL + "/v1"). That reuses the
existing message format and the whole _map_openai_error mapping instead of
forking a second error taxonomy. There is no API key - the endpoint IS the
configuration, so the backend stays dormant (and refuses to be selected,
with a message naming the variable) until CONJURER_OLLAMA_URL is set, the
same way the Conan bridge behaves.

Model selection:
* list_provider_models() asks the SERVER for Ollama (/v1/models), so the
  picker shows what is actually pulled on the box rather than a hardcoded
  list. Hosted providers just report what they are wired to.
* set_active_model() pins the config's latest_model and persists it;
  cheap_model is left alone so the MUSIC path keeps its cheaper backend.
* $gadaj_teraz now takes "<config> [model]", and a new read-only $modele_ai
  lists what is available. Pinning an id Ollama does not have is rejected up
  front with the real list - otherwise the typo only surfaces later as a
  failed reply.

Two fixes this exposed:
* AI_CONFIGS now merges built-in defaults with the settings-file block
  instead of letting the file win outright. Every provider switch persists a
  "configs" block, so a file written by an older build would have
  permanently hidden ollama from the picker after an upgrade.
* _persist_active_ai_config assigns "configs" instead of setdefault, so a
  pinned model actually survives a restart.
* the hardcoded 120s response timeout is now CONJURER_AI_TIMEOUT_SECONDS - a
  self-hosted model on a modest GPU can legitimately need longer.

Tests cover: ollama appears in the picker, select_model maps the legacy
gpt-4o default instead of leaking it, model listing (server-queried, sorted,
de-duplicated, failure -> AIError, unconfigured -> auth), pinning (latest
only, blank/unknown rejected), and that provider_generate routes to the new
path. Suite: 68 unit + 70 integration green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 15:41:28 +00:00
gitea 13d2a04052 tests: fix the flaky heartbeat coverage assertion
CI / compile (pull_request) Successful in 5s
CI / unit (pull_request) Successful in 22s
CI / integration (pull_request) Successful in 27s
build / build (push) Successful in 1m5s
CI / compile (push) Successful in 9s
CI / unit (push) Successful in 25s
CI / integration (push) Successful in 30s
test_search_fills_progress_with_live_positions_and_total asserted 100%
coverage after searching for a DOI that EXISTS. Once every queried DOI is
found the consumer signals TERM and the producers stop mid-file, so the
recorded offsets reach an arbitrary point - the assertion was racing the
scan and failed roughly one full-suite run in two.

Split into the two things that are actually deterministic: coverage is now
measured with an ABSENT DOI (nothing can stop the scan early, so 100% is
guaranteed), and the found-target case asserts what holds regardless of
where the producers stopped - the total is known, progress is bounded and
sane, and the hit is reported.

Verified: 5 consecutive runs of the file and 3 consecutive full integration
runs, all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-24 14:10:06 +02:00
5 changed files with 401 additions and 158 deletions
+59 -47
View File
@@ -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",
@@ -278,13 +269,32 @@ class Events(commands.Cog):
f"Teraz gadam przez **{nazwa_konfigu}** — " f"Teraz gadam przez **{nazwa_konfigu}** — "
f"{cfg.get('provider')} / {cfg.get('latest_model')}." 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. # Switched without pinning a model: show what else is on offer.
if not model: if not model:
try: try:
others = await ai_functions.list_provider_models(nazwa_konfigu) others = await ai_functions.list_provider_models(nazwa_konfigu)
except ai_functions.AIError: except ai_functions.AIError:
others = [] others = []
if len(others) > 1: 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 += ( message += (
f"\nDostępne modele: {', '.join(others)} " f"\nDostępne modele: {', '.join(others)} "
f"(`$gadaj_teraz {nazwa_konfigu} <model>`)." f"(`$gadaj_teraz {nazwa_konfigu} <model>`)."
@@ -401,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)
+147 -103
View File
@@ -1,16 +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,
AI_TIMEOUT_SECONDS, AI_TIMEOUT_SECONDS,
ASSISTANTS, ASSISTANT_MEMORY_FILE,
ASSISTANT_MEMORY_TURNS,
CLAUDECLIENT, CLAUDECLIENT,
CYCLIC_WORDS, CYCLIC_WORDS,
DEFAULT_AI_CONFIG, DEFAULT_AI_CONFIG,
@@ -21,6 +26,9 @@ from constants import (
MESSAGE_TABLE, MESSAGE_TABLE,
MESSAGE_TABLE_MUZYKA, MESSAGE_TABLE_MUZYKA,
OLLAMACLIENT, OLLAMACLIENT,
OLLAMA_KEEP_ALIVE,
OLLAMA_PRELOAD_TIMEOUT,
OLLAMA_URL,
OPENAICLIENT, OPENAICLIENT,
SYSTEM_GPT_SETTINGS, SYSTEM_GPT_SETTINGS,
WORD_REACTIONS, WORD_REACTIONS,
@@ -33,8 +41,6 @@ try:
except ImportError: # pragma: no cover - optional at runtime except ImportError: # pragma: no cover - optional at runtime
anthropic = None anthropic = None
# this do per user
VECTOR_STORE_ID = -1
# *=========================================== AI provider abstraction # *=========================================== AI provider abstraction
@@ -290,6 +296,52 @@ async def _ollama_call(messages, model, cfg):
return (resp.choices[0].message.content or "").strip() 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()
@@ -356,58 +408,6 @@ async def openai_call(messages, model, temperature=0.2):
) )
def create_vector_store():
# Create a vector store caled "Financial Statements"
return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash")
# expires_after={
# "anchor": "last_active_at",
# "days": 7}
# )
def upload_files_to_vector_store(assistant):
# Ready the files for upload to OpenAI
file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"]
file_streams = [open(path, "rb") for path in file_paths]
# file = client.beta.vector_stores.files.create_and_poll(
# vector_store_id="vs_abc123",
# file_id="file-abc123"
# )
# batch = client.beta.vector_stores.file_batches.create_and_poll(
# vector_store_id="vs_abc123",
# file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5']
# )
# Use the upload and poll SDK helper to upload the files, add them to the vector store,
# and poll the status of the file batch for completion.
file_batch = OPENAICLIENT.beta.vector_stores.file_batches.upload_and_poll(
vector_store_id=VECTOR_STORE_ID, files=file_streams
)
# You can print the status and the file counts of the batch to see the result of this operation.
print(file_batch.status)
print(file_batch.file_counts)
assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
)
def delete_files_from_vector_store(assistant, file_id):
result = OPENAICLIENT.beta.vector_stores.file_batches.delete(
vector_store_id=VECTOR_STORE_ID, files=file_id
)
# You can print the status and the file counts of the batch to see the result of this operation.
print(result)
assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
)
def num_tokens_from_string(message, model): def num_tokens_from_string(message, model):
""" """
The function takes a string message and a model as input and returns the number of tokens in the The function takes a string message and a model as input and returns the number of tokens in the
@@ -664,60 +664,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):
+23
View File
@@ -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
@@ -411,6 +421,19 @@ else:
AI_TIMEOUT_SECONDS = int(os.getenv("CONJURER_AI_TIMEOUT_SECONDS", "120")) AI_TIMEOUT_SECONDS = int(os.getenv("CONJURER_AI_TIMEOUT_SECONDS", "120"))
OLLAMA_URL = os.getenv("CONJURER_OLLAMA_URL", "").rstrip("/") 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_LATEST_MODEL = os.getenv("CONJURER_OLLAMA_MODEL", "llama3.1:8b")
OLLAMA_CHEAP_MODEL = os.getenv("CONJURER_OLLAMA_CHEAP_MODEL", OLLAMA_LATEST_MODEL) OLLAMA_CHEAP_MODEL = os.getenv("CONJURER_OLLAMA_CHEAP_MODEL", OLLAMA_LATEST_MODEL)
if openai and OLLAMA_URL: if openai and OLLAMA_URL:
+32 -8
View File
@@ -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"]]
+140
View File
@@ -394,3 +394,143 @@ def test_persist_survives_an_unreadable_settings_file(tmp_path, monkeypatch):
broken.write_text("{ not json", encoding="utf-8") broken.write_text("{ not json", encoding="utf-8")
monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(broken)) monkeypatch.setattr(ai_functions, "SYSTEM_GPT_SETTINGS", str(broken))
ai_functions._persist_active_ai_config("gpt", model_for="gpt") # must not raise 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) == []