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, ASSISTANTS, CLAUDECLIENT, CYCLIC_WORDS, DEFAULT_AI_CONFIG, ENCODING, GPT_SETTINGS, MEMORY_FIVE_MUZYKA, MEMORY_FIVE_SIARA, MESSAGE_TABLE, MESSAGE_TABLE_MUZYKA, 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)") _ACTIVE_CONFIG_NAME = name _persist_active_ai_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 data[2].setdefault("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 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) 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 = 120 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: logger.info("Predefiniowana") # trunk-ignore(bandit/B311) messnum = random.randint(0, 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}")