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}")