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 is contained in:
+65
-1
@@ -9,8 +9,10 @@ from pathlib import Path
|
|||||||
import discord
|
import discord
|
||||||
import openai
|
import openai
|
||||||
import requests
|
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 other_functions import discord_friendly_send, discord_friendly_reply
|
||||||
|
from communication_subroutine import AI_QUERY_Q
|
||||||
|
|
||||||
|
|
||||||
import ai_functions
|
import ai_functions
|
||||||
@@ -43,7 +45,66 @@ class Events(commands.Cog):
|
|||||||
self.armia[superfryta[0]] = Dm_Mode.SPECJALNY_ZIEMNIACZEK
|
self.armia[superfryta[0]] = Dm_Mode.SPECJALNY_ZIEMNIACZEK
|
||||||
self.logger.info(self.armia)
|
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):
|
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")
|
self.logger.info("Starting personal assistants")
|
||||||
# Personal assistants use the OpenAI Assistants API (threads/runs), which
|
# Personal assistants use the OpenAI Assistants API (threads/runs), which
|
||||||
# has no Anthropic equivalent - skip cleanly when OpenAI isn't wired up
|
# 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")
|
self.logger.info("Started personal assistants")
|
||||||
|
|
||||||
|
async def cog_unload(self):
|
||||||
|
self.ai_query_worker.cancel()
|
||||||
|
|
||||||
@commands.hybrid_command(
|
@commands.hybrid_command(
|
||||||
name="switch_dm_mode",
|
name="switch_dm_mode",
|
||||||
description="Jeśli nie wiesz jak użyć tej komendy to nawet nie próbuj",
|
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")
|
API_KEY = os.getenv("CONJURER_API_KEY")
|
||||||
OUT_COMM_Q = Queue()
|
OUT_COMM_Q = Queue()
|
||||||
IN_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
|
SRCHTITLE = re.compile(rb"StreamTitle=\\*(?P<title>[^;]*);").search
|
||||||
|
|
||||||
awaiting_q = []
|
awaiting_q = []
|
||||||
@@ -47,13 +52,17 @@ class QueryControl:
|
|||||||
content, logger, context, and replies.
|
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.author = query_author
|
||||||
self.uuid = query_uuid
|
self.uuid = query_uuid
|
||||||
self.content = query_content
|
self.content = query_content
|
||||||
self.logger = logging.getLogger("discord")
|
self.logger = logging.getLogger("discord")
|
||||||
self.stop = False
|
self.stop = False
|
||||||
self.ctx = ctx
|
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(
|
self.logger.info(
|
||||||
f"Created Query control for {self.author}, {self.uuid}: {self.content}"
|
f"Created Query control for {self.author}, {self.uuid}: {self.content}"
|
||||||
)
|
)
|
||||||
@@ -105,6 +114,53 @@ def answer_external_command():
|
|||||||
return jsonify("SUCCESS")
|
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"])
|
@app.route("/conjurer", methods=["GET"])
|
||||||
def check_alive():
|
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
|
bot --(/query)--------------------------------> librarian
|
||||||
musician --(/prepped_tracks)--------------------> bot
|
musician --(/prepped_tracks)--------------------> bot
|
||||||
librarian --(/conjurer results)-----------------> bot
|
librarian --(/conjurer results)-----------------> bot
|
||||||
|
* --(/ai_query)-----------------------------> bot (see 1c-ter)
|
||||||
```
|
```
|
||||||
|
|
||||||
Everything is configured through `CONJURER_*` environment variables (see the
|
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
|
the switch says (Anthropic has no equivalent) and degrade quietly if OpenAI is
|
||||||
not configured, so a Claude-only box still boots.
|
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
|
### 1d. Configure and launch
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
+94
-2
@@ -14,7 +14,7 @@ import requests
|
|||||||
from discord.ext import commands, tasks
|
from discord.ext import commands, tasks
|
||||||
|
|
||||||
from ai_functions import handle_response
|
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
|
from constants import DIR_PATH_SADOX, LIBRARIAN_SERVICE_ADDRESS, SEND_QUERY, service_headers
|
||||||
|
|
||||||
SERVICE_HEADERS = service_headers()
|
SERVICE_HEADERS = service_headers()
|
||||||
@@ -97,14 +97,18 @@ class DataModule(commands.Cog):
|
|||||||
if fresh_data.stop:
|
if fresh_data.stop:
|
||||||
searcher = fresh_data.author
|
searcher = fresh_data.author
|
||||||
query = fresh_data.content
|
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
|
l_p = 1
|
||||||
for doi in fresh_data.entries:
|
for doi in fresh_data.entries:
|
||||||
self.logger.info(doi)
|
self.logger.info(doi)
|
||||||
desc = fresh_data.entries[doi]
|
desc = fresh_data.entries[doi]
|
||||||
title = desc["Title"][0]
|
title = desc["Title"][0] if desc.get("Title") else "(bez tytułu)"
|
||||||
entries.append(
|
entries.append(
|
||||||
f"{l_p}. {title} pod linkiem https://www.sci-hub.se/{doi} i jest to {desc['type']}\n"
|
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
|
l_p += 1
|
||||||
message = "*Z podłogi wysuwa się winda na książki*"
|
message = "*Z podłogi wysuwa się winda na książki*"
|
||||||
if fresh_data.ctx is not None:
|
if fresh_data.ctx is not None:
|
||||||
@@ -131,6 +135,36 @@ class DataModule(commands.Cog):
|
|||||||
await ctx.send(message)
|
await ctx.send(message)
|
||||||
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
|
# Kept for sentimental reasons
|
||||||
# await ctx.send(f"O. A tak będzie wyglądało coś ciekawego w przyszłości: {data}")
|
# await ctx.send(f"O. A tak będzie wyglądało coś ciekawego w przyszłości: {data}")
|
||||||
except Empty:
|
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."
|
+ " 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(
|
@commands.hybrid_command(
|
||||||
name="glebokie_gardlo",
|
name="glebokie_gardlo",
|
||||||
description="Przygotowuje drinka o nazwie głębokie gardło",
|
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