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": , "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",
+ }