bot: queued AI query interface + librarian AI review of results
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 was merged in pull request #2.
This commit is contained in:
+65
-1
@@ -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",
|
||||
|
||||
@@ -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():
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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",
|
||||
}
|
||||
Reference in New Issue
Block a user