25d6d4a3c0
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>
769 lines
31 KiB
Python
769 lines
31 KiB
Python
import asyncio
|
||
import json
|
||
import logging
|
||
import os
|
||
import random
|
||
import tempfile
|
||
|
||
import openai
|
||
import tiktoken
|
||
import time
|
||
from other_functions import discord_friendly_send
|
||
import requests
|
||
|
||
from constants import (
|
||
AI_CONFIGS,
|
||
AI_TIMEOUT_SECONDS,
|
||
ASSISTANT_MEMORY_FILE,
|
||
ASSISTANT_MEMORY_TURNS,
|
||
CLAUDECLIENT,
|
||
CYCLIC_WORDS,
|
||
DEFAULT_AI_CONFIG,
|
||
ENCODING,
|
||
GPT_SETTINGS,
|
||
MEMORY_FIVE_MUZYKA,
|
||
MEMORY_FIVE_SIARA,
|
||
MESSAGE_TABLE,
|
||
MESSAGE_TABLE_MUZYKA,
|
||
OLLAMACLIENT,
|
||
OLLAMA_KEEP_ALIVE,
|
||
OLLAMA_PRELOAD_TIMEOUT,
|
||
OLLAMA_URL,
|
||
OPENAICLIENT,
|
||
SYSTEM_GPT_SETTINGS,
|
||
WORD_REACTIONS,
|
||
CHEAP_MODEL,
|
||
LATEST_MODEL
|
||
)
|
||
|
||
try:
|
||
import anthropic
|
||
except ImportError: # pragma: no cover - optional at runtime
|
||
anthropic = None
|
||
|
||
|
||
|
||
# *=========================================== 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, 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.
|
||
|
||
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
|
||
# 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:
|
||
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()
|
||
|
||
|
||
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):
|
||
"""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 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
|
||
|
||
|
||
# ----------------------------------------------------------------- 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")
|
||
try:
|
||
with open(ASSISTANT_MEMORY_FILE, "r", encoding=ENCODING) as handle:
|
||
data = json.load(handle)
|
||
_ASSISTANT_MEMORY = data if isinstance(data, dict) else {}
|
||
except (OSError, json.JSONDecodeError) as exc:
|
||
logger.info("Brak/uszkodzona pamięć asystentów (%s) - zaczynam pustą", exc)
|
||
_ASSISTANT_MEMORY = {}
|
||
return _ASSISTANT_MEMORY
|
||
|
||
|
||
def _save_assistant_memory() -> None:
|
||
"""Atomic write: a torn file would lose someone's whole conversation."""
|
||
logger = logging.getLogger("discord")
|
||
memory = _load_assistant_memory()
|
||
directory = os.path.dirname(ASSISTANT_MEMORY_FILE) or "."
|
||
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}]
|
||
)
|
||
|
||
|
||
async def chat_with_personal_assistant(message, owner, special_instructions):
|
||
"""Answer a DM as this user's personal assistant, on the active backend.
|
||
|
||
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,
|
||
)
|
||
remember_assistant_turn(user_id, prompt, result)
|
||
logger.info("Asystent odpowiedział %s (%d znaków)", owner, len(result or ""))
|
||
await discord_friendly_send(message.channel, result)
|
||
return result
|
||
|
||
|
||
async def echo(message):
|
||
await discord_friendly_send(message.channel, f"Echo: {message.content}")
|