Files
conjurer/ai_functions.py
T
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

719 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import asyncio
import json
import logging
import random
import openai
import tiktoken
import time
from other_functions import discord_friendly_send
from constants import (
AI_CONFIGS,
AI_TIMEOUT_SECONDS,
ASSISTANTS,
CLAUDECLIENT,
CYCLIC_WORDS,
DEFAULT_AI_CONFIG,
ENCODING,
GPT_SETTINGS,
MEMORY_FIVE_MUZYKA,
MEMORY_FIVE_SIARA,
MESSAGE_TABLE,
MESSAGE_TABLE_MUZYKA,
OLLAMACLIENT,
OPENAICLIENT,
SYSTEM_GPT_SETTINGS,
WORD_REACTIONS,
CHEAP_MODEL,
LATEST_MODEL
)
try:
import anthropic
except ImportError: # pragma: no cover - optional at runtime
anthropic = None
# this do per user
VECTOR_STORE_ID = -1
# *=========================================== AI provider abstraction
# The AI cog talks to exactly one backend at a time, chosen by _ACTIVE_CONFIG.
# Legacy defaults ("gpt"/OpenAI) keep the historical behaviour byte-for-byte;
# selecting a "claude" config routes the same handle_response pipeline through
# the Anthropic Messages API instead. Backend-specific exceptions are funnelled
# into a single AIError so handle_response can keep its one set of in-character
# error replies regardless of provider.
_ACTIVE_CONFIG_NAME = DEFAULT_AI_CONFIG
# Legacy default algorithm strings that mean "let the bot pick" rather than
# "force this exact model" - so a caller that still passes the old gpt-4o
# default auto-selects the active provider's model instead of 400-ing on Claude.
_AUTO_ALGOS = {"", "auto", "gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"}
class AIError(Exception):
"""Provider-neutral wrapper so handle_response reacts to one exception type.
``category`` is one of: timeout, connection, bad_request,
response_validation, auth, permission, rate_limit, unprocessable, api.
``original`` is the underlying SDK exception (interpolated into replies).
"""
def __init__(self, category: str, original: Exception):
super().__init__(str(original))
self.category = category
self.original = original
def _active_config() -> dict:
return (
AI_CONFIGS.get(_ACTIVE_CONFIG_NAME)
or AI_CONFIGS.get("gpt")
or next(iter(AI_CONFIGS.values()))
)
def list_ai_configs():
"""Selectable config names (templates prefixed with '_' are hidden)."""
return [name for name in AI_CONFIGS if not name.startswith("_")]
def get_active_ai_config() -> str:
return _ACTIVE_CONFIG_NAME
def set_active_ai_config(name: str) -> dict:
"""Switch the active AI backend and persist the choice. Raises on error."""
global _ACTIVE_CONFIG_NAME
if name not in AI_CONFIGS:
raise KeyError(name)
cfg = AI_CONFIGS[name]
provider = cfg.get("provider")
if provider == "anthropic" and CLAUDECLIENT is None:
raise RuntimeError("klient Anthropic nie jest skonfigurowany (brak ANTHROPIC_API_KEY)")
if provider == "openai" and OPENAICLIENT is None:
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
_persist_active_ai_config(name)
return cfg
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)
return cfg
def _persist_active_ai_config(name: str) -> None:
"""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
already exists, appends it when the file has exactly the original two
elements, and otherwise leaves the file untouched (the in-memory switch
still applies).
"""
logger = logging.getLogger("discord")
try:
with open(SYSTEM_GPT_SETTINGS, "r", encoding=ENCODING) as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError) as exc:
logger.warning("Nie mogę odczytać %s do zapisu configu AI: %s", SYSTEM_GPT_SETTINGS, exc)
return
if not isinstance(data, list) or len(data) < 2:
logger.warning("Nietypowa struktura %s - pomijam zapis configu AI", SYSTEM_GPT_SETTINGS)
return
if len(data) > 2 and isinstance(data[2], dict):
data[2]["active"] = name
# Assign (not setdefault): AI_CONFIGS is the in-memory truth and may
# carry a model pinned via set_active_model, which setdefault would
# silently drop on restart.
data[2]["configs"] = AI_CONFIGS
else:
data = data[:2] + [{"active": name, "configs": AI_CONFIGS}]
try:
with open(SYSTEM_GPT_SETTINGS, "w", encoding=ENCODING) as handle:
json.dump(data, handle, indent=4, ensure_ascii=False)
except OSError as exc:
logger.warning("Nie mogę zapisać configu AI do %s: %s", SYSTEM_GPT_SETTINGS, exc)
def _map_openai_error(exc: Exception) -> AIError:
mapping = [
(openai.APITimeoutError, "timeout"),
(openai.APIConnectionError, "connection"),
(openai.BadRequestError, "bad_request"),
(openai.APIResponseValidationError, "response_validation"),
(openai.AuthenticationError, "auth"),
(openai.PermissionDeniedError, "permission"),
(openai.RateLimitError, "rate_limit"),
(openai.UnprocessableEntityError, "unprocessable"),
(openai.APIError, "api"),
]
for cls, category in mapping:
if isinstance(exc, cls):
return AIError(category, exc)
return AIError("api", exc)
def _map_anthropic_error(exc: Exception) -> AIError:
mapping = [
("APITimeoutError", "timeout"),
("APIConnectionError", "connection"),
("BadRequestError", "bad_request"),
("APIResponseValidationError", "response_validation"),
("AuthenticationError", "auth"),
("PermissionDeniedError", "permission"),
("RateLimitError", "rate_limit"),
("UnprocessableEntityError", "unprocessable"),
("APIError", "api"),
]
for name, category in mapping:
cls = getattr(anthropic, name, None)
if cls and isinstance(exc, cls):
return AIError(category, exc)
return AIError("api", exc)
def _to_anthropic_messages(messages):
"""Split OpenAI-style messages into (system_prompt, alternating convo).
Claude takes the system prompt as a separate parameter (not a role in the
messages list) and requires the conversation to open with a user turn, so
system messages are concatenated out and any leading assistant turns are
dropped.
"""
system_parts = []
convo = []
for msg in messages:
role = msg.get("role")
content = msg.get("content", "")
if role == "system":
system_parts.append(content)
else:
convo.append(
{"role": "assistant" if role == "assistant" else "user", "content": content}
)
while convo and convo[0]["role"] != "user":
convo.pop(0)
if not convo:
convo = [{"role": "user", "content": " "}]
return "\n\n".join(part for part in system_parts if part), convo
async def _anthropic_call(messages, model, cfg):
"""Claude counterpart of openai_call. Returns a plain string."""
if CLAUDECLIENT is None:
raise AIError("auth", RuntimeError("klient Anthropic nie jest skonfigurowany"))
system_prompt, convo = _to_anthropic_messages(messages)
kwargs = {
"model": model,
"max_tokens": int(cfg.get("max_tokens", 2048)),
"messages": convo,
}
if system_prompt:
kwargs["system"] = system_prompt
# NOTE: temperature is deliberately omitted - Opus 4.8 / Sonnet 5 reject
# sampling params with a 400.
try:
resp = await CLAUDECLIENT.messages.create(**kwargs)
except Exception as exc: # pylint: disable=broad-except
raise _map_anthropic_error(exc)
text = "".join(
block.text for block in resp.content if getattr(block, "type", None) == "text"
)
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()
async def provider_generate(messages, model, temperature=0.2):
"""Dispatch a chat completion to the active backend, normalising errors."""
cfg = _active_config()
try:
if cfg.get("provider") == "anthropic":
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)
except AIError:
raise
except Exception as exc: # pylint: disable=broad-except
# Only the OpenAI path reaches here un-normalised (_anthropic_call
# already wraps its own errors).
raise _map_openai_error(exc)
def select_model(req_type: str, algo: str) -> str:
cfg = _active_config()
latest = cfg.get("latest_model", LATEST_MODEL)
cheap = cfg.get("cheap_model", CHEAP_MODEL)
algo_str = (algo or "").strip()
# An explicit, non-legacy model id is honoured verbatim; anything in
# _AUTO_ALGOS (incl. the old gpt-4o default) means "auto pick for the
# active provider", so flipping the switch actually changes the model.
if algo_str and algo_str.lower() not in _AUTO_ALGOS:
return algo_str
if req_type == "MUSIC":
return cheap
return latest
async def openai_call(messages, model, temperature=0.2):
"""
Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
Zwraca czysty string odpowiedzi.
"""
logger = logging.getLogger("discord")
if model.startswith("gpt-3.5"):
logger.info("3.5")
# legacy path bez zmian w Twoim kodzie wyżej/niżej
resp = await OPENAICLIENT.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
)
result = ""
for choice in resp.choices:
result += choice.message.content
return result.strip()
else:
logger.info("4.0+")
# Responses API (zalecane dla 4o/4.1*)
resp = await OPENAICLIENT.responses.create(
model=model,
temperature=temperature,
input=messages,
)
# SDK zapewnia output_text dla zwykłych odpowiedzi
return (getattr(resp.output_text, "output_text", None) or "").strip() or str(
resp.output_text
)
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):
"""
The function takes a string message and a model as input and returns the number of tokens in the
message according to the given model.
:param message: A string containing the message or text from which you want to count the number of
tokens
:param model: The model parameter refers to a language model or tokenizer that can be used to
tokenize the input string. It could be a pre-trained model or a custom tokenizer
"""
tokens_per_message = 3
tokens_per_name = 1
chat_gpt_encoding = tiktoken.encoding_for_model(model)
num_tokens = 0
num_tokens += tokens_per_message
for keys, values in message.items():
num_tokens += len(chat_gpt_encoding.encode(values))
if keys == "role":
num_tokens += tokens_per_name
num_tokens += 3 # every reply is primed with <|start|>assistant<|message|>
return num_tokens
async def handle_response(
prompt,
vykidailo,
bartender,
history,
username,
request_type,
algorithm="gpt-4o",
none_request="",
internal_retry: bool = False
):
"""
Handle responses by appending them to a history, use OpenAI to
generate a response, and then append the generated response to the history.
:param prompt: The prompt for the OpenAI chatbot to generate a response to
:param vykidailo: It is a boolean variable that indicates whether the user invoking the function is
an administrator or not
:param bartender: The bartender parameter is a boolean value indicating whether the user making the
request is a bartender or not
:param history: A list containing the conversation history between the user and the assistant
:param username: The username of the user who initiated the conversation
:param music: The "music" parameter is a boolean value that indicates whether the conversation is
related to music or not. If it is True, the conversation history will be stored in a different file
and the response will be generated using a different model
:param request_type: The type of request being made, which can be "MUSIC", "RANDOM", "NONE" or "GENERAL"
GENERAL is for regular conversations, MUSIC is for music-related requests,
RANDOM is for random requests, and NONE is to not store the request in memory
:param algorithm: The algorithm to be used for generating the response, default is "gpt-4o"
:return: The function `handle_response` returns a tuple containing the `result` and `MESSAGE_TABLE`.
"""
logger = logging.getLogger("discord")
logger.info("Wywolanie procedury openai z promptem: %s", prompt)
if vykidailo or bartender:
logger.info("Administrator coś chciał")
model_to_use = select_model(request_type, algorithm)
logger.info("Wybrany model: %s", model_to_use)
if request_type == "MUSIC" and model_to_use == "gpt-4o-mini":
try:
# nic — normalnie pójdzie Responses API
pass
except Exception:
model_to_use = "gpt-3.5-turbo"
# --- 2) Budowa historii (token budget + reguły systemowe) ---
# NOTE: ignorujemy przekazany 'history' jako listę (tak było wcześniej),
# ale zwracamy aktualną tablicę do nadpisania w miejscach wołania (back-compat).
base_system = GPT_SETTINGS[0] # zakładamy {"role":"system","content":...}
history_msgs = []
if request_type != "NONE":
history_msgs.append(base_system)
chat_gpt_config_request_size = num_tokens_from_string(base_system, "gpt-4")
# Dynamiczne mikro-reguły (WORD_REACTIONS), jak w Twoim kodzie
for slowo, reakcja in WORD_REACTIONS.items():
if not reakcja[3]:
content = f"Kiedy słyszysz {slowo} to reagujesz lub dzieje się to {reakcja[0]}"
sys_msg = {"role": "system", "content": content}
chat_gpt_config_request_size += num_tokens_from_string(sys_msg, "gpt-4")
history_msgs.append(sys_msg)
# wybór właściwej tablicy pamięci i budżetu
if request_type == "MUSIC":
table = MESSAGE_TABLE_MUZYKA
token_amount = 10700
elif request_type in ("RANDOM", "GENERAL"):
table = MESSAGE_TABLE
token_amount = 10700
else:
table = []
token_amount = 10000
# doklejanie historii od końca aż do limitu (zachowana kolejność czasowa)
final_prompt = f"{username}:{prompt}"
prompt_gpt_request_size = num_tokens_from_string({"role": "user", "content": final_prompt}, "gpt-4")
acc = []
for msg in reversed(table):
t = num_tokens_from_string(msg, "gpt-4")
if chat_gpt_config_request_size + prompt_gpt_request_size + t <= token_amount:
acc.append(msg)
chat_gpt_config_request_size += t
else:
break
# przywróć chronologicznie
history_msgs.extend(reversed(acc))
# aktualny prompt
history_msgs.append({"role": "user", "content": final_prompt})
logger.info("Rozmiar zapytania (tok): %s", prompt_gpt_request_size) # tokeny już policzone wyżej
else:
# --- tryb NONE: nie dotykamy pamięci i pozwalamy przekazać własny 'none_request' ---
if isinstance(none_request, list):
history_msgs = none_request
elif isinstance(none_request, str) and none_request.strip():
history_msgs = [{"role": "user", "content": none_request}]
else:
history_msgs = [{"role": "user", "content": f"{username}:{prompt}"}]
logger.info("Rozmiar zapytania (tok): %s", "n/a") # tokeny już policzone wyżej
try:
# ...przygotowanie messages/system prompt/itp. jak masz...
# retry/backoff + deadline (zachowuje Twoją semantykę logowania)
timeout_sec = AI_TIMEOUT_SECONDS
deadline = time.time() + timeout_sec
response = await asyncio.wait_for(
provider_generate(messages=history_msgs, model=model_to_use),
timeout=max(0.1, deadline - time.time()),
)
except AIError as e:
# One handler for both backends; e.category is provider-neutral and
# e.original is the underlying SDK exception (kept for the {..} tails).
err = e.original
if e.category == "timeout":
response = f"*Kondziu patrzy na terminal, czeka, czeka, czeka,.... Jeszcze chwile czeka Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai spróbuj od nowa. *Na ekranie pojawia się*: {err}"
elif e.category == "connection":
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {err}"
elif e.category in ("bad_request", "response_validation"):
# Handle invalid request error, e.g. validate parameters or log
if internal_retry:
resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
else:
resp, _ = await handle_response(
f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {err} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
True,
True,
MESSAGE_TABLE,
username,
"RANDOM",
internal_retry=True,
)
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {err}"
elif e.category == "auth":
# Handle authentication error, e.g. check credentials or log
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {err}"
elif e.category == "permission":
# Handle permission error, e.g. check scope or log
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z uprawnieniami zjebało. *Na terminalu pojawia się:* {err}"
elif e.category == "rate_limit":
response = f"*Kondziu patrzy na terminal* Wołaj szefa. Zapłacić rachunki za AI trzeba. Jak chcesz to się na #zebranie dorzuć. {err}"
elif e.category == "unprocessable":
response = f"*Kondziu patrzy na terminal. Potem na to co każesz mu wysłać....* Ja wiem że jesteśmy w barze BDSM - ale nie da się włożyć TEGO w TO. *Za jego plecami na terminalu pojawia się:* {err}"
else: # "api" and anything unmapped
# Handle API error, e.g. retry or log
response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {err}"
logger.info("Historia wysłana:")
temp_assistant = {"role": "assistant", "content": response}
logger.info(temp_assistant)
if request_type == "MUSIC":
# zapis do pliku MUZYKA
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as fh:
file_data = json.load(fh)
file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp_assistant)
fh.seek(0)
json.dump(file_data, fh, indent=4)
return response, MESSAGE_TABLE_MUZYKA
elif request_type in ("RANDOM", "GENERAL"):
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as fh:
file_data = json.load(fh)
file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp_assistant)
fh.seek(0)
json.dump(file_data, fh, indent=4)
return response, MESSAGE_TABLE
else: # NONE
return response, []
async def get_random_cyclic_message(client):
"""
The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic
words.
:return: a random cyclic message from the list `cyclic_words`.
"""
logger = logging.getLogger("discord")
channel_id = 1062047367337095268
channel = client.get_channel(channel_id)
# trunk-ignore(bandit/B311)
ai_check = random.randint(0, 10)
logger.info("Losowa wypowiedź")
if ai_check < 2 and CYCLIC_WORDS:
logger.info("Predefiniowana")
# randrange(n) is 0..n-1; randint(0, n) was inclusive and could return n
# -> list(...)[n] IndexError. Guarded on empty CYCLIC_WORDS above.
# trunk-ignore(bandit/B311)
messnum = random.randrange(len(CYCLIC_WORDS))
logger.debug(messnum)
logger.debug(len(CYCLIC_WORDS))
mess_key = list(CYCLIC_WORDS.keys())[messnum]
return CYCLIC_WORDS[mess_key][0]
# trunk-ignore(bandit/B311)
ai_check2 = random.randint(0, 10)
global MESSAGE_TABLE
if ai_check2 < 6:
logger.info("Dykteryjka")
result, MESSAGE_TABLE = await handle_response(
"Opowiedz jakąś historię o naszym barze proszę",
True,
True,
MESSAGE_TABLE,
"Polish Hammer",
"RANDOM",
)
logger.info(result)
else:
logger.info("Wtracenie w dyskusje")
messages = [message async for message in channel.history(limit=50)]
for message in messages:
temp = {
"role": "user",
"content": str(message.author) + ":" + str(message.content),
}
MESSAGE_TABLE.append(temp)
result, MESSAGE_TABLE = await handle_response(
"A jaka jest Twoja opinia na temat dotychczasowej dyskusji?",
True,
True,
MESSAGE_TABLE,
"Polish Hammer",
"RANDOM",
)
logger.info(result)
return result
async def create_chat_assistant(owner_id, id, name, owner, special_instructions):
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."
instruction += special_instructions
assistant = await OPENAICLIENT.beta.assistants.create(
name=name,
instructions=instruction,
model="gpt-4o",
tools=[{"type": "file_search"}],
)
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):
logger = logging.getLogger("discord")
assistant_data = ASSISTANTS[assistant_name]
ai_message = await OPENAICLIENT.beta.threads.messages.create(
thread_id=assistant_data[3].id, role="user", content=message.content
)
logger.info(ai_message)
run = await OPENAICLIENT.beta.threads.runs.create_and_poll(
thread_id=assistant_data[3].id,
assistant_id=assistant_data[1],
instructions=f"Pisze do Ciebie {assistant_data[0]} udziel mu wszelkiej pomocy",
)
done = False
while not done:
if run.status == "completed":
messsages = await OPENAICLIENT.beta.threads.messages.list(
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):
await discord_friendly_send(message.channel, f"Echo: {message.content}")