Files
conjurer/librarian_commands.py
T
gitea d4c6d78c2e
CI / compile (pull_request) Successful in 7s
CI / unit (pull_request) Successful in 26s
CI / integration (pull_request) Successful in 26s
build / build (push) Successful in 41s
CI / compile (push) Successful in 9s
CI / unit (push) Successful in 27s
CI / integration (push) Successful in 49s
FIx print
2026-08-03 18:49:59 +02:00

522 lines
23 KiB
Python

import asyncio
import io
import logging
import os
import random
import time
import uuid
from queue import Empty
import discord
import pdf2image
import fitz
import PyPDF2
import requests
from discord.ext import commands, tasks
from ai_functions import handle_response
from communication_subroutine import (
IN_COMM_Q,
OUT_COMM_Q,
QueryControl,
mark_delivered,
submit_ai_query,
)
from constants import (
DIR_PATH_SADOX,
LIBRARIAN_SERVICE_ADDRESS,
QUERY_STATUS,
SELF_CALLBACK,
SEND_QUERY,
service_headers,
)
from librarian_watchdog import FLAG, pending_verdict
SERVICE_HEADERS = service_headers()
# Per-query watchdog tuning.
PENDING_WATCH_SECONDS = 30 # how often to ask the librarian about a uuid
PENDING_GRACE_SECONDS = 45 # unknown-but-pending must persist this long
PENDING_HARD_TTL = 60 * 60 * 24 * 3 # drop tracking after 3 days no matter what
class DataModule(commands.Cog):
def __init__(self, bot, logger_name):
self.bot = bot
self.logger = logging.getLogger(logger_name)
# uuid -> {"ctx", "query", "created", "unknown_since"} for every search
# dispatched but not yet answered. watch_pending polls the librarian for
# each; check_data_q removes an entry the moment its result is rendered.
self.pending = {}
def _track_pending(self, query_uuid, query, ctx):
"""Start watching a dispatched search so a lost result can be caught."""
self.pending[str(query_uuid)] = {
"ctx": ctx,
"query": query,
"created": time.monotonic(),
"unknown_since": None,
}
@commands.hybrid_command(
nsfw=True,
name="get_image_sadox",
description="Wyświetla losową stronę z losowego komiksu FanSadox. Bardzo NSFW.",
guild=discord.Object(id=664789470779932693),
)
@commands.has_any_role('Nocna Zmiana', 'Jarl', 'Thane' , 'Bartender')
async def get_image_sadox(self, ctx):
"""
Take in a context parameter and retrieve an image related from fansadox collection.
:param ctx: ctx stands for "context" and is a parameter commonly used in Discord.py commands. It
represents the context in which the command was invoked, including information such as the message,
the channel, the server, and the user who invoked the command. In this case, it is likely being used
to determine
"""
self.logger.info("Get sadox")
channel = ctx.message.channel
async with channel.typing():
# select random file
res = []
# Iterate directory
for path in os.listdir(DIR_PATH_SADOX):
# check if current path is a file
if os.path.isfile(os.path.join(DIR_PATH_SADOX, path)):
res.append(path)
if not res:
await ctx.send("*Conjurer grzebie w pustej skrzyni* Nie ma dziś żadnych komiksów.")
return
# randrange(len) is 0..len-1; the old randrange(0, len-1) never picked
# the last file and raised ValueError('empty range') on a single file.
# trunk-ignore(bandit/B311)
filename = res[random.randrange(len(res))]
# select random page
file = open(DIR_PATH_SADOX + filename, "rb")
if True:
doc = fitz.open(DIR_PATH_SADOX + filename)
totalpages = len(doc)
# trunk-ignore(bandit/B311)
page_index = random.randrange(0, totalpages)
page = doc.load_page(page_index)
mat = fitz.Matrix(2.0, 2.0) # powiększenie
pix = page.get_pixmap(matrix=mat, alpha=False)
byte_io_stream = io.BytesIO(pix.tobytes("png"))
byte_io_stream.seek(0)
byte_io_stream.name = "image.png"
await ctx.send(file=discord.File(byte_io_stream))
else: #legacy
readpdf = PyPDF2.PdfReader(file)
totalpages = len(readpdf.pages)
# trunk-ignore(bandit/B311)
page = random.randrange(1, totalpages)
# convert page to image
image = pdf2image.convert_from_path(
DIR_PATH_SADOX + filename, first_page=page, last_page=page
)
byte_io_stream = io.BytesIO()
image[0].save(byte_io_stream, "JPEG")
byte_io_stream.seek(0)
byte_io_stream.name = "image.jpg"
file = discord.File(byte_io_stream)
await ctx.send(file=file)
self.logger.info("Get sadox completed")
@tasks.loop(seconds=3)
async def check_data_q(self):
"""
This function checks the data queue for any new entries and processes them accordingly.
"""
try:
fresh_data = IN_COMM_Q.get(block=False)
entries = []
if fresh_data.stop:
# The result arrived and is about to be rendered - stop the
# watchdog from ever flagging this uuid as lost.
self.pending.pop(str(fresh_data.uuid), None)
searcher = fresh_data.author
query = fresh_data.content
# ai_lines is a clean, plain rendering of the SAME list in the
# SAME (Crossref-relevance) order, for the optional AI review.
ai_lines = []
l_p = 1
for doi in fresh_data.entries:
self.logger.info(doi)
desc = fresh_data.entries[doi]
title = desc["Title"][0] if desc.get("Title") else "(bez tytułu)"
entries.append(
f"{l_p}. {title} pod linkiem https://www.sci-hub.red/{doi} i jest to {desc['type']}\n"
)
ai_lines.append(f"{l_p}. {title} (DOI: {doi}, typ: {desc['type']})")
l_p += 1
message = "*Z podłogi wysuwa się winda na książki*"
if fresh_data.ctx is not None:
ctx = fresh_data.ctx
message += f" Dzielny poszukiwaczu @{searcher} w odpowiedzi na twoje zapytanie {query} nr {fresh_data.uuid}"
else:
ctx = self.bot.get_channel(1062047571557744721)
message += f" Mam wynik dla zapytania {fresh_data.uuid} ale że Hammer coś grzebał nie pamiętam kto to chciał i co chciał"
message += "nasza biblioteka służy Ci odpowiedzią. Przy dźwięku fanfar winda się otwiera a w środku "
if len(entries) < 1:
message += "niestety nie ma nic"
await ctx.send(message)
elif len(entries) >= 1 and len(entries) < 5:
message += " znajduje się coś:\n"
for item in entries:
message += item
await ctx.send(message)
else:
message += " znajduje się cholernie dużo:\n"
for item in entries:
message += item
if len(message) > 1500:
await ctx.send(message)
message = ""
# The result is now on screen: mark it delivered so the
# librarian's resends become no-ops and it is dropped from the
# durable inbox (never replayed again). Done after the core
# render but before the optional AI review, which is a bonus.
mark_delivered(str(fresh_data.uuid))
# Optional AI pass: re-rank the (already Crossref-relevance-
# sorted) DOI list and review the sources. Enqueued to the AI
# worker so it runs on whatever backend $gadaj_teraz selected;
# the answer lands in this same channel.
if getattr(fresh_data, "ai_review", False) and ai_lines:
target = getattr(ctx, "channel", ctx)
review_prompt = (
f'Poniżej lista źródeł naukowych znalezionych dla zapytania: "{query}".\n'
"Lista jest już wstępnie posortowana według trafności wg Crossref "
"(od najtrafniejszej).\n\n"
"Twoje zadania:\n"
"1. Przeważ i uporządkuj listę według RZECZYWISTEJ trafności do zapytania "
"(najtrafniejsze u góry).\n"
"2. Do każdej pozycji dopisz jedno-, dwuzdaniową recenzję: typ i wiarygodność "
"źródła oraz dlaczego (nie) pasuje do zapytania.\n"
"Odpowiedz zwięźle, numerowaną listą, po polsku.\n\n"
"Źródła:\n" + "\n".join(ai_lines)
)
submit_ai_query(
prompt=review_prompt,
channel_id=target.id,
request_type="NONE",
username=searcher,
query_uuid=str(fresh_data.uuid),
)
await ctx.send(
"*Conjurer podaje listę naszemu rezydentowi-mądrali od AI* "
"Za chwilę dorzuci recenzję i swoje przesortowanie wg trafności."
)
# Kept for sentimental reasons
# await ctx.send(f"O. A tak będzie wyglądało coś ciekawego w przyszłości: {data}")
except Empty:
pass
@tasks.loop(seconds=PENDING_WATCH_SECONDS)
async def watch_pending(self):
"""Per-query safety net for lost results (case a).
For each dispatched-but-unanswered search, ask the librarian whether it
still knows the uuid (queued or processing). While it does, the search is
progressing - leave it alone (a busy librarian is fine). The moment a
uuid VANISHES on the librarian while still pending here, its result was
computed but never reached us: after a short grace window (to rule out a
result that is merely in flight) we tell the channel - but ONLY then.
A normally-delivered result is popped from self.pending by check_data_q,
so it never reaches the flag path.
"""
now = time.monotonic()
for query_uuid in list(self.pending.keys()):
info = self.pending.get(query_uuid)
if info is None:
continue
# Hard cap so a permanently-unreachable librarian can't leak entries.
if now - info["created"] > PENDING_HARD_TTL:
self.logger.warning("Dropping stale pending query %s (hard TTL)", query_uuid)
self.pending.pop(query_uuid, None)
continue
try:
response = await asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{QUERY_STATUS}",
json={"UUID": query_uuid},
headers=SERVICE_HEADERS,
timeout=5,
)
known = (
response.status_code == 200
and response.json().get("data", {}).get("known", False)
)
except (
requests.exceptions.RequestException,
ValueError,
AttributeError,
KeyError,
TypeError,
) as exc:
# Librarian unreachable / garbled or unexpected answer: we can't
# judge, so don't cry wolf, and don't let one bad poll kill the
# loop. Reset the clock and try again next tick.
self.logger.info("Pending check for %s inconclusive: %s", query_uuid, exc)
info["unknown_since"] = None
continue
action, info["unknown_since"] = pending_verdict(
known, info["unknown_since"], now, PENDING_GRACE_SECONDS
)
# Re-check membership: the await above yields, so check_data_q may
# have just delivered (and popped) this result.
if action == FLAG and query_uuid in self.pending:
await self._flag_lost(query_uuid, info)
self.pending.pop(query_uuid, None)
async def _flag_lost(self, query_uuid, info):
"""Tell the querent their finished search never made it back."""
message = (
"*Winda na książki z hukiem wraca z podziemi PUSTA. Z głośnika trzeszczy:* "
f"Twoje zapytanie {query_uuid} (\"{info['query']}\") przemieliło się w "
"bibliotece do końca, ale wynik przepadł gdzieś w drodze do baru - nic nie "
"dotarło. Zawołaj szefa albo puść jeszcze raz."
)
try:
await info["ctx"].send(message)
except Exception: # pylint: disable=broad-exception-caught
self.logger.exception("Failed to post lost-result notice for %s", query_uuid)
@commands.hybrid_command(
name="wyszukaj_linki_do_dokumentow",
description="Szuka linkow doi w bazie crossref i podaje linki do scihuba",
guild=discord.Object(id=664789470779932693),
)
async def wyszukaj_linki_do_dokumentow(self, ctx):
"""
The function `wyszukaj_linki_do_dokumentow` searches for links to documents in a crossref database and provides links to scihub.
:param ctx: The `ctx` parameter is typically used in Discord.py, a Python library for creating Discord bots.
It represents the context of the command being executed, including information about the message, the server,
and the user who invoked the command.
"""
query = ctx.message.content
query_uuid = uuid.uuid4()
# TODO: TESTING ONLY!!
# query_uuid = uuid.UUID('{12345678-1234-5678-1234-567812345678}')
ctx.message.content = ctx.message.content.replace(
"$wyszukaj_linki_do_dokumentow", ""
)
json_query = {
"UUID": str(query_uuid),
"query": str(query),
"page": 1,
"deep_search": False,
"callback": SELF_CALLBACK,
}
coroutine = asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query,
headers=SERVICE_HEADERS,
timeout=360,
)
await ctx.send(
"*Conjurer powoli notuje podane przez Ciebie. Podchodzi do sprytnie ukrytej przy barze rury od poczty pneumatycznej i"
+ " wrzuca twój liścik do środka* To teraz trza poczekać kilka godzin. Biblioteka to 3/4 stacji."
+ " A przecież pół stacji to browar, motelik dla zabaw cielesnych i ten Bar. Więc 12/8 stacji teraz będzie ciężko pracować"
)
query_response = await coroutine
if not query_response.status_code == 200:
await ctx.send(
"*Z rury wydobywa się dym. Conjurer łapie pierwszą ciecz pod ręką i pryska na rurę. Następuje drobna eksplozja i wszystko zaczyna się palić."
+ " Conjurer jest skonfundowany..* Wołaj szefa - mam nieodparte wrażenie że się coś wyjebało"
)
return
query, query_uuid, queue_size = (
query_response.json()["data"][0],
query_response.json()["data"][1],
query_response.json()["data"][2],
)
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
query_object = QueryControl(username, query_uuid, query, ctx)
OUT_COMM_Q.put(query_object)
self._track_pending(query_uuid, query, ctx)
await ctx.send(
f"No dobra poszło. Zapamiętaj proszę {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce."
+ " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzi - ale mogą być też dni."
)
@commands.hybrid_command(
name="wyszukaj_z_recenzja",
description="Jak wyszukaj_linki_do_dokumentow, ale wyniki przesortuje trafnością i zrecenzuje AI",
guild=discord.Object(id=664789470779932693),
)
async def wyszukaj_z_recenzja(self, ctx):
"""Same as wyszukaj_linki_do_dokumentow, but flags the search for an AI
review: when the DOI hits come back, the list + the search phrase are sent
to the bot's AI backend for a weighted-relevance re-rank and a source
review, delivered to this channel. The flag rides on the QueryControl so
it survives the round-trip and is matched back to this search by UUID.
"""
query = ctx.message.content
query_uuid = uuid.uuid4()
ctx.message.content = ctx.message.content.replace("$wyszukaj_z_recenzja", "")
json_query = {
"UUID": str(query_uuid),
"query": str(query),
"page": 1,
"deep_search": False,
"callback": SELF_CALLBACK,
}
coroutine = asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query,
headers=SERVICE_HEADERS,
timeout=360,
)
await ctx.send(
"*Conjurer notuje, wrzuca liścik do rury pneumatycznej i mruży oko* Tym razem jak coś"
+ " znajdę, przepuszczę wyniki jeszcze przez naszego rezydenta-mądralę od AI - przeważy"
+ " trafność i zrecenzuje źródła. Poczekaj kilka godzin - biblioteka to 3/4 stacji."
)
query_response = await coroutine
if not query_response.status_code == 200:
await ctx.send(
"*Z rury wydobywa się dym. Conjurer pryska w nią pierwszą cieczą pod ręką i wybucha"
+ " drobny pożar.* Wołaj szefa - mam wrażenie że się coś wyjebało"
)
return
query, query_uuid, queue_size = (
query_response.json()["data"][0],
query_response.json()["data"][1],
query_response.json()["data"][2],
)
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
query_object = QueryControl(username, query_uuid, query, ctx, ai_review=True)
OUT_COMM_Q.put(query_object)
self._track_pending(query_uuid, query, ctx)
await ctx.send(
f"Poszło z recenzją AI. Identyfikator: {query_uuid}. Jesteś {queue_size} w kolejce."
+ " Najpierw dojadą surowe wyniki, a zaraz po nich przesortowanie i recenzja od AI."
)
@commands.hybrid_command(
name="glebokie_gardlo",
description="Przygotowuje drinka o nazwie głębokie gardło",
guild=discord.Object(id=664789470779932693),
)
async def wyszukaj_linki_do_dokumentow_deep(self, ctx):
"""
The function `wyszukaj_linki_do_dokumentow_deep` performs a deep search for links to documents in
a crossref database and provides links to scihub.
:param ctx: The `ctx` parameter is typically used in Discord.py, a Python library for creating Discord bots.
It represents the context of the command being executed, including information about the message, the server,
and the user who invoked the command.
"""
# TODO: Implement deep search logic here
query = ctx.message.content.replace("$glebokie_gardlo", "")
allowed = False
for role in ctx.message.author.roles:
if role.name == "Bartender":
allowed = True
if role.name == "Scribe":
allowed = True
if role.name == "Thane":
allowed = True
if not allowed:
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
vykidailo = False
bartender = False
prompt = "Przygotuj mi drinka o nazwie Głębokie Gardło, inspirowanego tym sławnym filmem oraz skandalem Watergate"
for role in ctx.message.author.roles:
if role.name == "Vykidailo":
vykidailo = True
if role.name == "Bartender":
bartender = True
global MESSAGE_TABLE # pylint: disable=global-statement
result, MESSAGE_TABLE = await handle_response(
prompt, vykidailo, bartender, MESSAGE_TABLE, username, "GENERAL"
)
if len(result) < 1500:
await ctx.send(result)
else:
while len(result) > 1500:
await ctx.send(result[:1500])
result = result[1500:]
return
query_uuid = uuid.uuid4()
# TODO: TESTING ONLY!!
# query_uuid = uuid.UUID('{12345678-1234-5678-1234-567812345678}')
json_query = {
"UUID": str(query_uuid),
"query": str(query),
"page": 1,
"deep_search": True,
"callback": SELF_CALLBACK,
}
coroutine = asyncio.to_thread(
requests.post,
f"{LIBRARIAN_SERVICE_ADDRESS}{SEND_QUERY}",
json=json_query,
headers=SERVICE_HEADERS,
timeout=360,
)
await ctx.send(
"*Conjurer mruga okiem i zamiast drinka wyjmuje dysk usb z terminala. Podchodzi do sprytnie ukrytej przy barze rury od poczty pneumatycznej i"
+ " wrzuca ten dysk do środk* To teraz trza poczekać kilka godzin. TO będzie głębokie wyszukanie"
+ " Rozgląda się dookoła i stawia przed tobą mętnego drinka o zapachu mięty, gwiezdnego pyłu i oleju silnikowego"
)
query_response = await coroutine
if not query_response.status_code == 200:
await ctx.send(
"*Z rury wydobywa się dym. Conjurer łapie pierwszą ciecz pod ręką i pryska na rurę. Następuje drobna eksplozja i wszystko zaczyna się palić."
+ " Conjurer jest skonfundowany..* Wołaj szefa - mam nieodparte wrażenie że się coś wyjebało"
)
return
query, query_uuid, queue_size = (
query_response.json()["data"][0],
query_response.json()["data"][1],
query_response.json()["data"][2],
)
if ctx.message.author.nick:
username = ctx.message.author.nick
else:
username = ctx.message.author.name
query_object = QueryControl(username, query_uuid, query, ctx)
OUT_COMM_Q.put(query_object)
self._track_pending(query_uuid, query, ctx)
await ctx.send(
f"Wypij wypi {query_uuid} - to identyfikator twojego zapytania. Jesteś {queue_size} w kolejce niestety kolejka obowiazuje zawsze."
+ " Zapytania obsługuje algorytm zasilany czterema chomikami zapierdalającymi w kołowrotku - więc wyniki najwcześniej za kilka godzin - ale mogą być też dni. Głębokie zabawy trwają dłużej. *Znowu mruga*"
)
async def setup(bot):
logger = logging.getLogger("discord")
dm = DataModule(bot, "discord")
dm.check_data_q.start()
dm.watch_pending.start()
await bot.add_cog(dm)
logger.info("Loading data sharing commands module done")