diff --git a/ai_commands.py b/ai_commands.py index 1fcb22e..00892ac 100644 --- a/ai_commands.py +++ b/ai_commands.py @@ -9,8 +9,10 @@ from pathlib import Path import discord import openai import requests -from discord.ext import commands +from queue import Empty +from discord.ext import commands, tasks from other_functions import discord_friendly_send, discord_friendly_reply +from communication_subroutine import AI_QUERY_Q import ai_functions @@ -43,7 +45,66 @@ class Events(commands.Cog): self.armia[superfryta[0]] = Dm_Mode.SPECJALNY_ZIEMNIACZEK self.logger.info(self.armia) + @tasks.loop(seconds=2) + async def ai_query_worker(self): + """Drain AI_QUERY_Q one prompt at a time and answer with the active backend. + + This is the bot-side half of the AI query interface: prompts arrive over + HTTP (POST /ai_query) or in-process (submit_ai_query), get queued, and are + answered here with handle_response - so they automatically use whichever + provider $gadaj_teraz currently selects. The answer is posted to the + channel the request named. + """ + try: + item = AI_QUERY_Q.get(block=False) + except Empty: + return + prompt = item.get("prompt", "") + request_type = item.get("request_type", "NONE") + channel_id = item.get("channel_id") + username = item.get("username", "conjurer") + self.logger.info( + "AI query from %s (%s) -> channel %s", username, request_type, channel_id + ) + global MESSAGE_TABLE # pylint: disable=global-statement + try: + if request_type == "NONE": + # Clean one-shot: no persona system prompt, no memory write. + result, _ = await ai_functions.handle_response( + "", True, True, [], username, "NONE", none_request=prompt + ) + else: + result, MESSAGE_TABLE = await ai_functions.handle_response( + prompt, True, True, MESSAGE_TABLE, username, request_type + ) + except Exception as exc: # pylint: disable=broad-except + self.logger.exception("AI query failed: %s", exc) + result = "*Kondziu drapie się po głowie* Coś się zjebało przy pytaniu do AI." + if channel_id is None: + self.logger.warning("AI query had no channel_id - answer dropped") + return + channel = self.bot.get_channel(channel_id) + if channel is None: + self.logger.warning("AI query channel %s not found - answer dropped", channel_id) + return + await self._send_chunked(channel, result) + + @ai_query_worker.before_loop + async def _before_ai_query_worker(self): + await self.bot.wait_until_ready() + + async def _send_chunked(self, channel, text): + """Send text in <=1900-char pieces (Discord caps messages at 2000).""" + text = text or "" + while text: + await discord_friendly_send(channel, text[:1900]) + text = text[1900:] + async def cog_load(self): + # The AI query worker must run regardless of the OpenAI guard below - it + # answers via handle_response, which works on Claude too. Start it first. + if not self.ai_query_worker.is_running(): + self.ai_query_worker.start() self.logger.info("Starting personal assistants") # Personal assistants use the OpenAI Assistants API (threads/runs), which # has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up @@ -87,6 +148,9 @@ class Events(commands.Cog): ) self.logger.info("Started personal assistants") + async def cog_unload(self): + self.ai_query_worker.cancel() + @commands.hybrid_command( name="switch_dm_mode", description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj", diff --git a/communication_subroutine.py b/communication_subroutine.py index 480507e..31fd0e9 100644 --- a/communication_subroutine.py +++ b/communication_subroutine.py @@ -17,6 +17,11 @@ ICECAST_ADDRESS = os.getenv("CONJURER_ICECAST", "http://192.168.1.12:8000") API_KEY = os.getenv("CONJURER_API_KEY") OUT_COMM_Q = Queue() IN_COMM_Q = Queue() +# AI request queue: prompts to be answered by the bot's own AI backend (whatever +# $gadaj_teraz currently points at). Drained by the AI cog's worker loop, which +# calls handle_response and delivers the answer to the requested channel. Fed +# either over HTTP (POST /ai_query) or in-process via submit_ai_query(). +AI_QUERY_Q = Queue() SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P[^;]*);").search awaiting_q = [] @@ -47,13 +52,17 @@ class QueryControl: content, logger, context, and replies. """ - def __init__(self, query_author, query_uuid, query_content, ctx) -> None: + def __init__(self, query_author, query_uuid, query_content, ctx, ai_review=False) -> None: self.author = query_author self.uuid = query_uuid self.content = query_content self.logger = logging.getLogger("discord") self.stop = False self.ctx = ctx + # When True, once the librarian returns hits, the DOI list + the search + # phrase are sent to the AI backend for a weighted-relevance re-rank and + # source review (see librarian_commands.check_data_q). + self.ai_review = ai_review self.logger.info( f"Created Query control for {self.author}, {self.uuid}: {self.content}" ) @@ -105,6 +114,53 @@ def answer_external_command(): return jsonify("SUCCESS") +def submit_ai_query(prompt, channel_id=None, request_type="NONE", username="conjurer", query_uuid=None): + """Queue an AI prompt for the bot to answer with its configured backend. + + In-process entry point (used by the librarian result handler). ``channel_id`` + is the Discord channel the answer should be posted to; ``request_type`` is + passed through to handle_response ("NONE" keeps it a clean one-shot that does + not touch conversation memory). + """ + AI_QUERY_Q.put( + { + "uuid": query_uuid, + "prompt": prompt, + "channel_id": channel_id, + "request_type": request_type, + "username": username, + } + ) + + +@app.route("/ai_query", methods=["POST"]) +def ai_query(): + """Inbound AI query: queue a prompt to be answered by the bot's AI backend. + + Payload: {"prompt": str, "channel_id": int, "request_type"?: str, + "username"?: str, "uuid"?: str}. The prompt is queued and answered + asynchronously by the AI cog's worker; the answer is posted to channel_id. + """ + _authorize_request() + logger = logging.getLogger("discord") + record = json.loads(request.data) + prompt = record.get("prompt", "") + if not prompt: + return jsonify(isError=True, message="missing 'prompt'", statusCode=400, data=[]), 400 + submit_ai_query( + prompt=prompt, + channel_id=record.get("channel_id"), + request_type=record.get("request_type", "NONE"), + username=record.get("username", "external"), + query_uuid=record.get("uuid"), + ) + logger.info("Queued AI query (qsize=%s)", AI_QUERY_Q.qsize()) + return ( + jsonify(isError=False, message="Queued", statusCode=200, data={"qsize": AI_QUERY_Q.qsize()}), + 200, + ) + + @app.route("/conjurer", methods=["GET"]) def check_alive(): """ diff --git a/docs/deployment/DOCKER_PROXMOX.md b/docs/deployment/DOCKER_PROXMOX.md index 6d3e038..e5e9bf0 100644 --- a/docs/deployment/DOCKER_PROXMOX.md +++ b/docs/deployment/DOCKER_PROXMOX.md @@ -20,6 +20,7 @@ The three talk to each other over HTTP on the Proxmox LAN. Direction of calls: bot --(/query)--------------------------------> librarian musician --(/prepped_tracks)--------------------> bot librarian --(/conjurer results)-----------------> bot + * --(/ai_query)-----------------------------> bot (see 1c-ter) ``` Everything is configured through `CONJURER_*` environment variables (see the @@ -117,6 +118,23 @@ generation (`imaginuje sobie:`) and personal assistants stay on OpenAI whatever the switch says (Anthropic has no equivalent) and degrade quietly if OpenAI is not configured, so a Claude-only box still boots. +### 1c-ter. AI query interface + librarian AI review + +The bot exposes a queued AI interface on its comm layer: `POST /ai_query` with +`{"prompt": ..., "channel_id": <discord channel id>, "request_type"?: "NONE"}` +(same `X-Conjurer-Api-Key` auth as the other endpoints). The prompt is queued and +answered asynchronously by whatever backend `$gadaj_teraz` currently selects +(GPT or Claude), and the answer is posted to `channel_id`. `request_type: "NONE"` +(the default) keeps it a clean one-shot that doesn't touch the bar's +conversation memory. + +The first consumer of this is the librarian command **`$wyszukaj_z_recenzja`**: +it works like `$wyszukaj_linki_do_dokumentow`, but when the DOI hits come back +the list (already sorted by Crossref relevance) plus the search phrase are handed +to the AI for a weighted-relevance re-rank and a short source review, delivered +to the same channel right after the raw results. No extra config — it uses the +active AI backend. + ### 1d. Configure and launch ```bash diff --git a/librarian_commands.py b/librarian_commands.py index 74d5b1e..4c0a7b3 100644 --- a/librarian_commands.py +++ b/librarian_commands.py @@ -14,7 +14,7 @@ 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 +from communication_subroutine import IN_COMM_Q, OUT_COMM_Q, QueryControl, submit_ai_query from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers SERVICE_HEADERS = service_headers() @@ -97,14 +97,18 @@ class DataModule(commands.Cog): if fresh_data.stop: 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] + title = desc["Title"][0] if desc.get("Title") else "(bez tytułu)" entries.append( f"{l_p}. {title} pod linkiem https://www.sci-hub.se/{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: @@ -131,6 +135,36 @@ class DataModule(commands.Cog): await ctx.send(message) message = "" + # 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: @@ -199,6 +233,64 @@ class DataModule(commands.Cog): + " 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, + } + 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) + 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", diff --git a/tests/integration/test_ai_query_endpoint.py b/tests/integration/test_ai_query_endpoint.py new file mode 100644 index 0000000..05e888a --- /dev/null +++ b/tests/integration/test_ai_query_endpoint.py @@ -0,0 +1,70 @@ +"""Integration: the bot's /ai_query endpoint enforces the shared key, validates +the payload, and queues accepted prompts onto AI_QUERY_Q for the AI worker. +""" +import communication_subroutine as cs + + +def _client(key="test-secret"): + cs.API_KEY = key + return cs.app.test_client() + + +def _drain(): + while not cs.AI_QUERY_Q.empty(): + cs.AI_QUERY_Q.get() + + +def test_ai_query_rejected_without_key(): + _drain() + client = _client() + resp = client.post("/ai_query", json={"prompt": "x", "channel_id": 1}) + assert resp.status_code == 401 + assert cs.AI_QUERY_Q.empty() # nothing queued on a rejected call + + +def test_ai_query_accepted_with_key_and_queued(): + _drain() + client = _client() + resp = client.post( + "/ai_query", + json={"prompt": "posortuj DOI", "channel_id": 42, "username": "siara"}, + headers={"X-Conjurer-Api-Key": "test-secret"}, + ) + assert resp.status_code == 200 + item = cs.AI_QUERY_Q.get() + assert item["prompt"] == "posortuj DOI" + assert item["channel_id"] == 42 + assert item["username"] == "siara" + + +def test_ai_query_missing_prompt_is_rejected(): + _drain() + client = _client() + resp = client.post( + "/ai_query", + json={"channel_id": 1}, + headers={"X-Conjurer-Api-Key": "test-secret"}, + ) + assert resp.status_code == 400 + assert cs.AI_QUERY_Q.empty() + + +def test_ai_query_open_when_key_unset(): + _drain() + client = _client(key=None) + resp = client.post("/ai_query", json={"prompt": "y", "channel_id": 1}) + assert resp.status_code == 200 + + +def test_submit_ai_query_enqueues_expected_shape(): + _drain() + cs.submit_ai_query( + prompt="P", channel_id=7, request_type="NONE", username="u", query_uuid="uid-1" + ) + assert cs.AI_QUERY_Q.get() == { + "uuid": "uid-1", + "prompt": "P", + "channel_id": 7, + "request_type": "NONE", + "username": "u", + }