bot: queued AI query interface + librarian AI review of results
CI / compile (pull_request) Failing after 50s
CI / unit (pull_request) Successful in 33s
CI / integration (pull_request) Failing after 45s

Two connected features.

1) AI query interface (via the comm layer). communication_subroutine gains an
AI_QUERY_Q, a submit_ai_query() in-process entry point, and an authed
POST /ai_query endpoint ({prompt, channel_id, request_type?, username?}). The
prompt is queued and answered asynchronously by a new tasks.loop worker in the
always-loaded AI cog (Events), which calls handle_response - so it runs on
whichever backend $gadaj_teraz currently selects (GPT or Claude) - and posts the
answer to the requested channel, chunked to Discord's limit. request_type "NONE"
(default) is a clean one-shot: no persona system prompt, no memory write. The
worker starts before the OpenAI guard in cog_load, so it also runs on a
Claude-only box; cog_unload cancels it.

2) Librarian AI review. New command $wyszukaj_z_recenzja mirrors
$wyszukaj_linki_do_dokumentow but sets ai_review=True on the QueryControl, which
rides the round-trip and is matched back by UUID. When the hits return,
check_data_q sends the raw list as before, then - if flagged - hands the same
list (already in Crossref-relevance order) plus the search phrase to the AI
queue for a weighted re-rank and per-source review, delivered to the same
channel. QueryControl gains an ai_review flag (default False, so the orphan path
and all existing callers are unaffected).

Confirmed separately (and noted in the docs): the DOI list the AI receives is
pre-sorted by Crossref relevance - the librarian pipeline only filters (drops
title-less items) and splits (in-db / not-in-db), never re-sorts, and relies on
insertion-ordered dicts (Py 3.7+).

Verified: /ai_query auth (401/200/400/open), submit_ai_query and the queued
dict shape, and the QueryControl flag - via a Flask test client and
tests/integration/test_ai_query_endpoint.py (5 tests, all pass; integration
suite 11 passed, the 3 failures are the pre-existing /clear_pr_pls musician
tests fixed on a separate branch). Full first-party compile clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 00:26:20 +02:00
parent f177dee4e9
commit 175f315958
5 changed files with 304 additions and 4 deletions
+65 -1
View File
@@ -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",
+57 -1
View File
@@ -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<title>[^;]*);").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():
"""
+18
View File
@@ -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
+94 -2
View File
@@ -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",
@@ -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",
}