Compare commits
2 Commits
868ea96e15
...
3f4a1d5083
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f4a1d5083 | |||
| e9e731e2bd |
+183
@@ -0,0 +1,183 @@
|
||||
"""Bar commands: Conjurer invents cocktails and keeps a growing menu.
|
||||
|
||||
The single most in-character thing the bot does - the persona is literally a
|
||||
200kg bartender who mixes strong drinks with intriguing names. `$nalej` asks the
|
||||
active AI backend (whatever $gadaj_teraz points at) to invent a themed cocktail
|
||||
in Conjurer's voice; every drink is appended to a menu.json that becomes
|
||||
emergent bar lore, browsable with `$menu`.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
from datetime import datetime
|
||||
|
||||
import discord
|
||||
from discord.ext import commands
|
||||
|
||||
from ai_functions import handle_response
|
||||
from communication_subroutine import PREPPED_TRACKS
|
||||
from constants import ENCODING, GPT_SETTINGS, GUILD_ID, MENU_FILE
|
||||
|
||||
# Keywords that mean "theme the drink on whatever is playing on the radio".
|
||||
_RADIO_THEMES = {"pod muzykę", "pod muzyke", "radio", "muzyka", "muzyką"}
|
||||
_MAX_MSG = 1900 # Discord caps messages at 2000 chars.
|
||||
_MENU_LIST_LIMIT = 15 # how many drink names $menu shows at once
|
||||
|
||||
|
||||
def _persona() -> str:
|
||||
"""Conjurer's bartender persona (reused from the AI settings, not duplicated)."""
|
||||
try:
|
||||
if isinstance(GPT_SETTINGS, list) and GPT_SETTINGS and isinstance(GPT_SETTINGS[0], dict):
|
||||
return GPT_SETTINGS[0].get("content", "") or ""
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return (
|
||||
"Jesteś Conjurerem, 200-kilowym barmanem i wykidajłą w klimatycznym barze "
|
||||
"mechawojowników. Mówisz po polsku, jesteś rubaszny, wtrącasz staronorweskie "
|
||||
"i jidysz powiedzonka."
|
||||
)
|
||||
|
||||
|
||||
def _load_menu() -> list:
|
||||
try:
|
||||
with open(MENU_FILE, "r", encoding=ENCODING) as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, list) else []
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
def _append_menu(entry: dict) -> None:
|
||||
menu = _load_menu()
|
||||
menu.append(entry)
|
||||
try:
|
||||
with open(MENU_FILE, "w", encoding=ENCODING) as handle:
|
||||
json.dump(menu, handle, indent=2, ensure_ascii=False)
|
||||
except OSError as exc:
|
||||
logging.getLogger("discord").warning("Nie mogę zapisać menu do %s: %s", MENU_FILE, exc)
|
||||
|
||||
|
||||
def _extract_name(text: str) -> str:
|
||||
"""First non-empty line, stripped of the name marker / markdown, as the drink name."""
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip().lstrip("🍸#*-–—•0123456789. ").strip().strip("*_`")
|
||||
if stripped:
|
||||
return stripped[:120]
|
||||
return "Bezimienny drink"
|
||||
|
||||
|
||||
class BarModule(commands.Cog):
|
||||
"""Drink generation + the bar menu."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.logger = logging.getLogger("discord")
|
||||
|
||||
async def _send_chunked(self, ctx, text: str) -> None:
|
||||
text = text or ""
|
||||
while text:
|
||||
await ctx.send(text[:_MAX_MSG])
|
||||
text = text[_MAX_MSG:]
|
||||
|
||||
@commands.hybrid_command(
|
||||
name="nalej",
|
||||
description="Conjurer wymyśla drinka (opcjonalnie na motyw, albo 'radio' pod obecny kawałek)",
|
||||
guild=discord.Object(id=GUILD_ID),
|
||||
)
|
||||
async def nalej(self, ctx, *, motyw: str = ""):
|
||||
"""Invent a cocktail themed on `motyw`. Empty = a surprise; 'radio' / 'pod
|
||||
muzykę' themes it on whatever is currently playing on the radio."""
|
||||
async with ctx.channel.typing():
|
||||
motyw = (motyw or "").strip()
|
||||
author = ctx.author.nick if getattr(ctx.author, "nick", None) else ctx.author.name
|
||||
|
||||
if motyw.lower() in _RADIO_THEMES:
|
||||
now = (PREPPED_TRACKS.get("now_playing") or "").strip()
|
||||
if now:
|
||||
theme_desc = f"pod obecnie grany na radiu kawałek: „{now}”"
|
||||
stored_theme = f"radio: {now}"
|
||||
else:
|
||||
theme_desc = "zaskakujący, bo na radiu akurat cisza"
|
||||
stored_theme = "radio (cisza)"
|
||||
elif motyw:
|
||||
theme_desc = f"na motyw: „{motyw}”"
|
||||
stored_theme = motyw
|
||||
else:
|
||||
theme_desc = "całkowicie zaskakujący, wymyślony spontanicznie"
|
||||
stored_theme = "(niespodzianka)"
|
||||
|
||||
instructions = (
|
||||
f"Wymyśl JEDEN autorski koktajl {theme_desc}. Ma być mocny i mieć intrygującą, "
|
||||
"klimatyczną nazwę. Format odpowiedzi:\n"
|
||||
"- pierwsza linia: sama nazwa drinka (bez nagłówków, bez 'Nazwa:'),\n"
|
||||
"- potem lista składników,\n"
|
||||
"- potem krótki sposób przygotowania,\n"
|
||||
"- na końcu jedno-, dwuzdaniowy klimatyczny opis w Twoim stylu.\n"
|
||||
"Całość po polsku, w postaci Conjurera. Bądź zwięzły."
|
||||
)
|
||||
# request_type NONE + a proper [system, user] message list: persona as
|
||||
# system (so the drink is in-character), instructions as user, and NO
|
||||
# write to the bar's conversation memory.
|
||||
prompt = [
|
||||
{"role": "system", "content": _persona()},
|
||||
{"role": "user", "content": instructions},
|
||||
]
|
||||
try:
|
||||
result, _ = await handle_response(
|
||||
"", True, True, [], author, "NONE", none_request=prompt
|
||||
)
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("nalej: AI failed: %s", exc)
|
||||
await ctx.send("*Conjurer upuszcza shaker* Coś mi się rozlało - spróbuj jeszcze raz.")
|
||||
return
|
||||
|
||||
name = _extract_name(result)
|
||||
_append_menu(
|
||||
{
|
||||
"nazwa": name,
|
||||
"motyw": stored_theme,
|
||||
"autor": author,
|
||||
"kiedy": datetime.now().isoformat(timespec="seconds"),
|
||||
"tekst": result,
|
||||
}
|
||||
)
|
||||
self.logger.info("Nowy drink w menu: %s (motyw: %s)", name, stored_theme)
|
||||
await self._send_chunked(ctx, result)
|
||||
|
||||
@commands.hybrid_command(
|
||||
name="menu",
|
||||
description="Pokazuje menu wymyślonych do tej pory drinków",
|
||||
guild=discord.Object(id=GUILD_ID),
|
||||
)
|
||||
async def menu(self, ctx):
|
||||
"""List the bar's invented drinks (names), and pour one from the archive."""
|
||||
async with ctx.channel.typing():
|
||||
menu = _load_menu()
|
||||
if not menu:
|
||||
await ctx.send(
|
||||
"*Conjurer stuka w pustą tablicę* Menu jeszcze świeci pustkami. "
|
||||
"Rzuć `$nalej`, a coś wymyślę."
|
||||
)
|
||||
return
|
||||
|
||||
total = len(menu)
|
||||
recent = menu[-_MENU_LIST_LIMIT:]
|
||||
lines = [
|
||||
f"🍸 **Menu Baru** — {total} drink(ów) w archiwum"
|
||||
+ (f" (ostatnie {len(recent)}):" if total > len(recent) else ":")
|
||||
]
|
||||
base = total - len(recent)
|
||||
for i, drink in enumerate(recent, start=1):
|
||||
lines.append(f"{base + i}. {drink.get('nazwa', '(bez nazwy)')} — od {drink.get('autor', '?')}")
|
||||
lines.append("\n*Conjurer poleca dziś z archiwum:*")
|
||||
await self._send_chunked(ctx, "\n".join(lines))
|
||||
|
||||
# ...and pour one at random from the whole archive, in full.
|
||||
pick = random.choice(menu) # nosec B311 - flavour, not security
|
||||
await self._send_chunked(ctx, pick.get("tekst", pick.get("nazwa", "")))
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
logger = logging.getLogger("discord")
|
||||
await bot.add_cog(BarModule(bot))
|
||||
logger.info("Loading bar commands module done")
|
||||
@@ -91,6 +91,8 @@ CORE_EXTENSIONS = [
|
||||
"administration_commands",
|
||||
"ai_commands",
|
||||
"other_commands",
|
||||
"bar_commands",
|
||||
"lore_commands",
|
||||
"latex_commands",
|
||||
"voice_recognition_commands",
|
||||
"conanjurer_commands",
|
||||
|
||||
@@ -203,6 +203,27 @@ TRANSCRIPTS_PATH = os.getenv(
|
||||
os.path.join(os.path.dirname(LOGFILE) or ".", "transcripts") + os.sep,
|
||||
)
|
||||
|
||||
# The bar's growing menu of AI-invented drinks (bar_commands). Lives next to the
|
||||
# conversation memory by default; seeded empty on first run.
|
||||
MENU_FILE = os.getenv(
|
||||
"CONJURER_MENU_FILE",
|
||||
os.path.join(os.path.dirname(MEMORY_FIVE_SIARA) or ".", "menu.json"),
|
||||
)
|
||||
# "Legendy Baru": archive of AI-written summaries of old conversation memory
|
||||
# (lore_commands). Lives next to pamiec.json by default; seeded empty.
|
||||
LEGENDS_FILE = os.getenv(
|
||||
"CONJURER_LEGENDS_FILE",
|
||||
os.path.join(os.path.dirname(MEMORY_FIVE_SIARA) or ".", "legendy.json"),
|
||||
)
|
||||
# Channel the compacted "legends" are announced to (the #legendy channel).
|
||||
LEGENDS_CHANNEL_ID = int(os.getenv("CONJURER_LEGENDS_CHANNEL", "1084448332841230388"))
|
||||
# Memory compaction: when pamiec.json grows past THRESHOLD messages, summarise
|
||||
# everything except the last KEEP_RECENT into one legend. HOURS is how often the
|
||||
# background task checks.
|
||||
MEMORY_COMPACT_THRESHOLD = int(os.getenv("CONJURER_MEMORY_COMPACT_THRESHOLD", "400"))
|
||||
MEMORY_KEEP_RECENT = int(os.getenv("CONJURER_MEMORY_KEEP_RECENT", "200"))
|
||||
MEMORY_COMPACT_HOURS = float(os.getenv("CONJURER_MEMORY_COMPACT_HOURS", "6"))
|
||||
|
||||
FILE_SERVICE_ADDRESS = os.getenv("CONJURER_FILE_SERVICE", "http://192.168.1.15:5000")
|
||||
RADIO_HARBOR_ADDRESS = os.getenv("CONJURER_RADIO_HARBOR", "http://192.168.1.15:54321")
|
||||
# Betoniarka (radio-operator service colocated with Liquidsoap). Falls back to
|
||||
@@ -272,6 +293,8 @@ def _ensure_runtime_layout() -> None:
|
||||
_seed_file(SETTINGS_FILE, "settings.json", "{}")
|
||||
_seed_file(SYSTEM_GPT_SETTINGS, "system_gpt_settings.json", "{}")
|
||||
_seed_file(MEMORY_FIVE_SIARA, "pamiec.json", "[]")
|
||||
_seed_file(MENU_FILE, "menu.json", "[]")
|
||||
_seed_file(LEGENDS_FILE, "legendy.json", "[]")
|
||||
_seed_file(MEMORY_FIVE_MUZYKA, "pamiec_muzyki.json", "[]")
|
||||
_seed_file(ACCIDENT_LOG, "accident_log.json", "[]")
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Legendy Baru: the bar's conversation memory condenses into legends.
|
||||
|
||||
pamiec.json grows forever - every chat appends a user+assistant pair. This cog
|
||||
periodically summarises the oldest slice of that file into a single, in-character
|
||||
"legend" through the active AI backend, replaces those old messages with the
|
||||
summary (bounding the file and keeping continuity for the next startup's
|
||||
context), archives the legend to legendy.json, and announces it on #legendy.
|
||||
|
||||
The compaction transform is kept as pure functions so it can be unit-tested
|
||||
without Discord or the AI; the cog only wires them to the file, the AI and the
|
||||
channel. The write is done as re-read -> atomic rewrite (no await in between) so
|
||||
a concurrent handle_response append can neither be lost nor corrupt the file.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
|
||||
import discord
|
||||
from discord.ext import commands, tasks
|
||||
|
||||
from ai_functions import handle_response
|
||||
from constants import (
|
||||
ENCODING,
|
||||
GPT_SETTINGS,
|
||||
GUILD_ID,
|
||||
LEGENDS_CHANNEL_ID,
|
||||
LEGENDS_FILE,
|
||||
MEMORY_COMPACT_HOURS,
|
||||
MEMORY_COMPACT_THRESHOLD,
|
||||
MEMORY_FIVE_SIARA,
|
||||
MEMORY_KEEP_RECENT,
|
||||
)
|
||||
|
||||
_MAX_MSG = 1900
|
||||
_TRANSCRIPT_MAX = 10000 # chars of old memory fed to the summariser
|
||||
_LEGEND_MARKER = "[LEGENDA BARU] "
|
||||
|
||||
|
||||
# ----------------------------------------------------------- pure transforms
|
||||
def build_transcript(messages, max_chars=_TRANSCRIPT_MAX):
|
||||
"""Render messages as 'role: content' lines, head+tail truncated to fit."""
|
||||
lines = []
|
||||
for msg in messages:
|
||||
content = str(msg.get("content", "")).strip()
|
||||
if content:
|
||||
lines.append(f"{msg.get('role', '?')}: {content}")
|
||||
text = "\n".join(lines)
|
||||
if len(text) <= max_chars:
|
||||
return text
|
||||
half = max_chars // 2
|
||||
return text[:half] + "\n[...urwane...]\n" + text[-half:]
|
||||
|
||||
|
||||
def apply_compaction(messages, old_count, legend_text):
|
||||
"""Replace the first `old_count` messages with one legend system-message.
|
||||
|
||||
Everything from index old_count onward is preserved verbatim, so any append
|
||||
that happened while the summary was being generated (they only ever go to the
|
||||
end) is kept.
|
||||
"""
|
||||
legend_msg = {"role": "system", "content": _LEGEND_MARKER + legend_text}
|
||||
return [legend_msg] + messages[old_count:]
|
||||
|
||||
|
||||
def _persona() -> str:
|
||||
try:
|
||||
if isinstance(GPT_SETTINGS, list) and GPT_SETTINGS and isinstance(GPT_SETTINGS[0], dict):
|
||||
return GPT_SETTINGS[0].get("content", "") or ""
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
return "Jesteś Conjurerem, barmanem i kronikarzem baru mechawojowników. Mówisz po polsku."
|
||||
|
||||
|
||||
def _load_json_list(path) -> list:
|
||||
try:
|
||||
with open(path, "r", encoding=ENCODING) as handle:
|
||||
data = json.load(handle)
|
||||
return data if isinstance(data, list) else []
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
return []
|
||||
|
||||
|
||||
class LoreModule(commands.Cog):
|
||||
"""Memory compaction into legends + legend recall."""
|
||||
|
||||
def __init__(self, bot):
|
||||
self.bot = bot
|
||||
self.logger = logging.getLogger("discord")
|
||||
|
||||
async def cog_load(self):
|
||||
if not self.compaction_loop.is_running():
|
||||
self.compaction_loop.start()
|
||||
|
||||
async def cog_unload(self):
|
||||
self.compaction_loop.cancel()
|
||||
|
||||
@tasks.loop(hours=MEMORY_COMPACT_HOURS)
|
||||
async def compaction_loop(self):
|
||||
try:
|
||||
await self._maybe_compact()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
self.logger.exception("Memory compaction tick failed: %s", exc)
|
||||
|
||||
@compaction_loop.before_loop
|
||||
async def _before_compaction(self):
|
||||
await self.bot.wait_until_ready()
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
async def _summarise(self, transcript: str) -> str:
|
||||
instructions = (
|
||||
"Jesteś kronikarzem naszego baru. Poniżej najstarszy fragment historii rozmów. "
|
||||
"Streść go w JEDNĄ zwartą, klimatyczną legendę w swoim stylu: zachowaj kluczowe "
|
||||
"postacie, wydarzenia i wątki (dla ciągłości pamięci), ale skróć drastycznie. "
|
||||
"Po polsku, bez wstępów typu 'oto streszczenie'.\n\n"
|
||||
f"HISTORIA:\n{transcript}"
|
||||
)
|
||||
prompt = [
|
||||
{"role": "system", "content": _persona()},
|
||||
{"role": "user", "content": instructions},
|
||||
]
|
||||
result, _ = await handle_response("", True, True, [], "kronikarz", "NONE", none_request=prompt)
|
||||
return (result or "").strip()
|
||||
|
||||
def _write_memory_atomic(self, old_count: int, legend: str) -> bool:
|
||||
"""Re-read, back up, then rewrite pamiec.json - all synchronously.
|
||||
|
||||
Returns False (and leaves the file untouched) if the file changed shape
|
||||
in a way that makes compaction unsafe.
|
||||
"""
|
||||
messages = _load_json_list(MEMORY_FIVE_SIARA)
|
||||
if len(messages) < old_count:
|
||||
self.logger.warning(
|
||||
"Memory shrank during compaction (%d < %d) - skipping rewrite",
|
||||
len(messages),
|
||||
old_count,
|
||||
)
|
||||
return False
|
||||
try:
|
||||
if os.path.exists(MEMORY_FIVE_SIARA):
|
||||
shutil.copyfile(MEMORY_FIVE_SIARA, MEMORY_FIVE_SIARA + ".bak")
|
||||
new = apply_compaction(messages, old_count, legend)
|
||||
with open(MEMORY_FIVE_SIARA, "w", encoding=ENCODING) as handle:
|
||||
json.dump(new, handle, indent=4, ensure_ascii=False)
|
||||
except OSError as exc:
|
||||
self.logger.error("Failed to rewrite compacted memory %s: %s", MEMORY_FIVE_SIARA, exc)
|
||||
return False
|
||||
return True
|
||||
|
||||
def _append_legend(self, legend: str) -> None:
|
||||
legends = _load_json_list(LEGENDS_FILE)
|
||||
legends.append({"kiedy": datetime.now().isoformat(timespec="seconds"), "tekst": legend})
|
||||
try:
|
||||
with open(LEGENDS_FILE, "w", encoding=ENCODING) as handle:
|
||||
json.dump(legends, handle, indent=2, ensure_ascii=False)
|
||||
except OSError as exc:
|
||||
self.logger.warning("Nie mogę zapisać legendy do %s: %s", LEGENDS_FILE, exc)
|
||||
|
||||
async def _post(self, channel, text: str) -> None:
|
||||
text = text or ""
|
||||
while text:
|
||||
await channel.send(text[:_MAX_MSG])
|
||||
text = text[_MAX_MSG:]
|
||||
|
||||
async def _post_legend(self, legend: str) -> None:
|
||||
channel = self.bot.get_channel(LEGENDS_CHANNEL_ID)
|
||||
if channel is None:
|
||||
self.logger.warning("Legends channel %s not found - legend not announced", LEGENDS_CHANNEL_ID)
|
||||
return
|
||||
await self._post(channel, "📜 *Z mgieł baru wyłania się nowa legenda...*\n\n" + legend)
|
||||
|
||||
async def _maybe_compact(self, force: bool = False):
|
||||
messages = _load_json_list(MEMORY_FIVE_SIARA)
|
||||
if not force and len(messages) <= MEMORY_COMPACT_THRESHOLD:
|
||||
return None
|
||||
old_count = len(messages) - MEMORY_KEEP_RECENT
|
||||
if old_count <= 1:
|
||||
self.logger.info("Compaction: nothing meaningful to compact (%d msgs)", len(messages))
|
||||
return None
|
||||
|
||||
transcript = build_transcript(messages[:old_count])
|
||||
legend = await self._summarise(transcript)
|
||||
if not legend:
|
||||
self.logger.warning("Compaction: summariser returned nothing - aborting")
|
||||
return None
|
||||
|
||||
if not self._write_memory_atomic(old_count, legend):
|
||||
return None
|
||||
self._append_legend(legend)
|
||||
self.logger.info(
|
||||
"Compacted %d old messages into a legend (kept last %d)", old_count, MEMORY_KEEP_RECENT
|
||||
)
|
||||
await self._post_legend(legend)
|
||||
return legend
|
||||
|
||||
# --------------------------------------------------------------- commands
|
||||
@commands.hybrid_command(
|
||||
name="zapisz_legende",
|
||||
description="Wymuś kompaktowanie pamięci w legendę teraz (tylko Vykidailo)",
|
||||
guild=discord.Object(id=GUILD_ID),
|
||||
)
|
||||
async def zapisz_legende(self, ctx):
|
||||
is_admin = isinstance(ctx.author, discord.Member) and any(
|
||||
role.name == "Vykidailo" for role in ctx.author.roles
|
||||
)
|
||||
if not is_admin:
|
||||
await ctx.send("Tylko Vykidailo spisuje legendy.")
|
||||
return
|
||||
async with ctx.channel.typing():
|
||||
legend = await self._maybe_compact(force=True)
|
||||
if legend is None:
|
||||
await ctx.send("*Conjurer wzrusza ramionami* Za mało jeszcze historii na legendę.")
|
||||
else:
|
||||
await ctx.send("*Conjurer maczkiem spisuje kolejną legendę baru.* Poszła na #legendy.")
|
||||
|
||||
@commands.hybrid_command(
|
||||
name="legendy",
|
||||
description="Przypomina losową legendę baru",
|
||||
guild=discord.Object(id=GUILD_ID),
|
||||
)
|
||||
async def legendy(self, ctx):
|
||||
async with ctx.channel.typing():
|
||||
legends = _load_json_list(LEGENDS_FILE)
|
||||
if not legends:
|
||||
await ctx.send("*Conjurer patrzy w ogień* Legend jeszcze nie spisano. Wszystko przed nami.")
|
||||
return
|
||||
pick = random.choice(legends) # nosec B311 - flavour, not security
|
||||
await self._post(ctx, f"📜 *Conjurer przywołuje legendę ({pick.get('kiedy', '?')}):*\n\n{pick.get('tekst', '')}")
|
||||
|
||||
|
||||
async def setup(bot):
|
||||
logger = logging.getLogger("discord")
|
||||
await bot.add_cog(LoreModule(bot))
|
||||
logger.info("Loading lore commands module done")
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Unit tests for bar_commands: drink-name parsing and menu persistence.
|
||||
|
||||
The unit CI job installs only pytest, so bar_commands' heavy imports (discord,
|
||||
ai_functions -> openai/tiktoken, communication_subroutine -> flask) are stubbed
|
||||
only when genuinely absent - locally the real packages are used.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _stub_if_missing(name, build):
|
||||
if name in sys.modules:
|
||||
return
|
||||
try:
|
||||
__import__(name)
|
||||
except ImportError:
|
||||
sys.modules[name] = build()
|
||||
|
||||
|
||||
def _build_discord():
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002 - mirrors discord.Object
|
||||
self.id = id
|
||||
|
||||
mod.Object = Object
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
def hybrid_command(**_kwargs):
|
||||
return lambda fn: fn
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = hybrid_command
|
||||
ext.commands = commands
|
||||
mod.ext = ext
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
return mod
|
||||
|
||||
|
||||
def _build_ai_functions():
|
||||
mod = types.ModuleType("ai_functions")
|
||||
|
||||
async def handle_response(*_a, **_k):
|
||||
return ("stub", [])
|
||||
|
||||
mod.handle_response = handle_response
|
||||
return mod
|
||||
|
||||
|
||||
def _build_comm():
|
||||
mod = types.ModuleType("communication_subroutine")
|
||||
mod.PREPPED_TRACKS = {"now_playing": ""}
|
||||
return mod
|
||||
|
||||
|
||||
_stub_if_missing("discord", _build_discord)
|
||||
_stub_if_missing("ai_functions", _build_ai_functions)
|
||||
_stub_if_missing("communication_subroutine", _build_comm)
|
||||
|
||||
import bar_commands # noqa: E402 (after stubbing)
|
||||
|
||||
|
||||
def test_extract_name_strips_markers_and_markdown():
|
||||
assert bar_commands._extract_name("🍸 Krew Wilka\nskładniki...") == "Krew Wilka"
|
||||
assert bar_commands._extract_name("**Miód Odyna**\nfoo") == "Miód Odyna"
|
||||
assert bar_commands._extract_name("1. Cichy Grom\nbar") == "Cichy Grom"
|
||||
assert bar_commands._extract_name("\n\n Ostatni Skald \nx") == "Ostatni Skald"
|
||||
|
||||
|
||||
def test_extract_name_fallback_on_empty():
|
||||
assert bar_commands._extract_name("") == "Bezimienny drink"
|
||||
assert bar_commands._extract_name("\n \n") == "Bezimienny drink"
|
||||
|
||||
|
||||
def test_menu_round_trip(tmp_path, monkeypatch):
|
||||
menu_file = tmp_path / "menu.json"
|
||||
monkeypatch.setattr(bar_commands, "MENU_FILE", str(menu_file))
|
||||
|
||||
assert bar_commands._load_menu() == [] # missing file -> empty
|
||||
|
||||
bar_commands._append_menu({"nazwa": "Krew Wilka", "autor": "Hammer"})
|
||||
bar_commands._append_menu({"nazwa": "Miód Odyna", "autor": "Siara"})
|
||||
|
||||
menu = bar_commands._load_menu()
|
||||
assert [d["nazwa"] for d in menu] == ["Krew Wilka", "Miód Odyna"]
|
||||
|
||||
|
||||
def test_load_menu_survives_corrupt_file(tmp_path, monkeypatch):
|
||||
menu_file = tmp_path / "menu.json"
|
||||
menu_file.write_text("{ not json", encoding="utf-8")
|
||||
monkeypatch.setattr(bar_commands, "MENU_FILE", str(menu_file))
|
||||
assert bar_commands._load_menu() == []
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Unit tests for lore_commands' pure compaction transforms.
|
||||
|
||||
Heavy imports (discord, ai_functions, constants side effects) are stubbed only
|
||||
when genuinely absent, matching the pytest-only unit CI job.
|
||||
"""
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _stub_if_missing(name, build):
|
||||
if name in sys.modules:
|
||||
return
|
||||
try:
|
||||
__import__(name)
|
||||
except ImportError:
|
||||
sys.modules[name] = build()
|
||||
|
||||
|
||||
def _build_discord():
|
||||
mod = types.ModuleType("discord")
|
||||
|
||||
class Object:
|
||||
def __init__(self, id=None): # noqa: A002
|
||||
self.id = id
|
||||
|
||||
class Member:
|
||||
pass
|
||||
|
||||
mod.Object = Object
|
||||
mod.Member = Member
|
||||
ext = types.ModuleType("discord.ext")
|
||||
commands = types.ModuleType("discord.ext.commands")
|
||||
|
||||
class Cog:
|
||||
pass
|
||||
|
||||
def _passthrough(*_a, **_k):
|
||||
return lambda fn: fn
|
||||
|
||||
commands.Cog = Cog
|
||||
commands.hybrid_command = _passthrough
|
||||
tasks = types.ModuleType("discord.ext.tasks")
|
||||
commands_tasks = types.ModuleType("_")
|
||||
|
||||
def loop(**_k):
|
||||
def deco(fn):
|
||||
fn.before_loop = lambda f: f
|
||||
return fn
|
||||
return deco
|
||||
|
||||
tasks.loop = loop
|
||||
commands.tasks = tasks
|
||||
ext.commands = commands
|
||||
ext.tasks = tasks
|
||||
mod.ext = ext
|
||||
sys.modules["discord.ext"] = ext
|
||||
sys.modules["discord.ext.commands"] = commands
|
||||
sys.modules["discord.ext.tasks"] = tasks
|
||||
return mod
|
||||
|
||||
|
||||
def _build_ai_functions():
|
||||
mod = types.ModuleType("ai_functions")
|
||||
|
||||
async def handle_response(*_a, **_k):
|
||||
return ("legenda stub", [])
|
||||
|
||||
mod.handle_response = handle_response
|
||||
return mod
|
||||
|
||||
|
||||
_stub_if_missing("discord", _build_discord)
|
||||
_stub_if_missing("ai_functions", _build_ai_functions)
|
||||
|
||||
import lore_commands # noqa: E402 (after stubbing)
|
||||
|
||||
|
||||
def _msgs(n):
|
||||
return [{"role": "user" if i % 2 == 0 else "assistant", "content": f"m{i}"} for i in range(n)]
|
||||
|
||||
|
||||
def test_apply_compaction_replaces_prefix_keeps_tail():
|
||||
messages = _msgs(10)
|
||||
result = lore_commands.apply_compaction(messages, old_count=6, legend_text="LEGENDA")
|
||||
# one legend system-message, then the last 4 verbatim
|
||||
assert result[0]["role"] == "system"
|
||||
assert result[0]["content"].endswith("LEGENDA")
|
||||
assert [m["content"] for m in result[1:]] == ["m6", "m7", "m8", "m9"]
|
||||
assert len(result) == 5
|
||||
|
||||
|
||||
def test_apply_compaction_preserves_appends_made_during_summarize():
|
||||
# Simulate: we decided old_count on a 10-message file, but by write time two
|
||||
# more messages were appended (a chat happened during the AI call). Those
|
||||
# tail appends must survive.
|
||||
grown = _msgs(10) + [{"role": "user", "content": "new1"}, {"role": "assistant", "content": "new2"}]
|
||||
result = lore_commands.apply_compaction(grown, old_count=6, legend_text="L")
|
||||
tail = [m["content"] for m in result[1:]]
|
||||
assert tail == ["m6", "m7", "m8", "m9", "new1", "new2"]
|
||||
|
||||
|
||||
def test_build_transcript_formats_lines():
|
||||
text = lore_commands.build_transcript(
|
||||
[{"role": "user", "content": "cześć"}, {"role": "assistant", "content": "no elo"}]
|
||||
)
|
||||
assert text == "user: cześć\nassistant: no elo"
|
||||
|
||||
|
||||
def test_build_transcript_skips_empty_and_truncates():
|
||||
text = lore_commands.build_transcript(
|
||||
[{"role": "user", "content": "x" * 20000}, {"role": "assistant", "content": ""}],
|
||||
max_chars=1000,
|
||||
)
|
||||
assert "[...urwane...]" in text
|
||||
assert len(text) <= 1000 + len("\n[...urwane...]\n")
|
||||
Reference in New Issue
Block a user