mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
Added the following changes to the codebase:
This commit is contained in:
+7
-8
@@ -30,20 +30,23 @@ async def _openai_call(messages, model, temperature=0.2):
|
|||||||
Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
|
Responses API dla 4o/4.1*, fallback Chat Completions dla gpt-3.5-turbo.
|
||||||
Zwraca czysty string odpowiedzi.
|
Zwraca czysty string odpowiedzi.
|
||||||
"""
|
"""
|
||||||
|
logger = logging.getLogger("discord")
|
||||||
|
|
||||||
if model.startswith("gpt-3.5"):
|
if model.startswith("gpt-3.5"):
|
||||||
|
logger.debug("3.5")
|
||||||
# legacy path – bez zmian w Twoim kodzie wyżej/niżej
|
# legacy path – bez zmian w Twoim kodzie wyżej/niżej
|
||||||
def _sync_call():
|
|
||||||
resp = OPENAICLIENT.chat.completions.create(
|
resp = OPENAICLIENT.chat.completions.create(
|
||||||
model=model,
|
model=model,
|
||||||
messages=messages,
|
messages=messages,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
)
|
)
|
||||||
return resp.choices[0].message.content.strip()
|
result = ""
|
||||||
return await asyncio.to_thread(_sync_call)
|
for choice in resp.choices:
|
||||||
|
result += choice.message.content
|
||||||
|
return result.strip()
|
||||||
|
|
||||||
|
else:
|
||||||
# Responses API (zalecane dla 4o/4.1*)
|
# Responses API (zalecane dla 4o/4.1*)
|
||||||
def _sync_resp():
|
|
||||||
resp = OPENAICLIENT.responses.create(
|
resp = OPENAICLIENT.responses.create(
|
||||||
model=model,
|
model=model,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
@@ -51,7 +54,6 @@ async def _openai_call(messages, model, temperature=0.2):
|
|||||||
)
|
)
|
||||||
# SDK zapewnia output_text dla zwykłych odpowiedzi
|
# SDK zapewnia output_text dla zwykłych odpowiedzi
|
||||||
return (getattr(resp, "output_text", None) or "").strip() or str(resp)
|
return (getattr(resp, "output_text", None) or "").strip() or str(resp)
|
||||||
return await asyncio.to_thread(_sync_resp)
|
|
||||||
|
|
||||||
|
|
||||||
def create_vector_store():
|
def create_vector_store():
|
||||||
@@ -302,9 +304,6 @@ async def handle_response(
|
|||||||
result = ""
|
result = ""
|
||||||
logger.debug("Odpowiedzi")
|
logger.debug("Odpowiedzi")
|
||||||
logger.info(response)
|
logger.info(response)
|
||||||
logger.debug(response.choices)
|
|
||||||
for choice in response.choices:
|
|
||||||
result += choice.message.content
|
|
||||||
logger.info("Sformatowane odpowiedzi")
|
logger.info("Sformatowane odpowiedzi")
|
||||||
logger.info(result)
|
logger.info(result)
|
||||||
temp = {"role": "assistant", "content": result}
|
temp = {"role": "assistant", "content": result}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# latex_commands.py
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
import io
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
import discord
|
||||||
|
from discord import app_commands
|
||||||
|
from discord.ext import commands
|
||||||
|
|
||||||
|
from constants import (
|
||||||
|
LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, LATEX_MAX_ATTACH_MB, LATEX_MAX_ZIP_MB,
|
||||||
|
OPENAI_API_KEY, OPENAI_MODEL, LATEX_ALLOWED_ROLES, LATEX_GUILD_ID
|
||||||
|
)
|
||||||
|
from latex_functions import (
|
||||||
|
is_tex_attachment, compile_single_tex_bytes, compile_zip_to_zip, ask_openai_diagnosis
|
||||||
|
)
|
||||||
|
|
||||||
|
class LatexModule(commands.Cog):
|
||||||
|
def __init__(self, bot: commands.Bot, logger_name: str):
|
||||||
|
self.bot = bot
|
||||||
|
import logging
|
||||||
|
self.logger = logging.getLogger(logger_name)
|
||||||
|
|
||||||
|
# -------- /tex (PDF + diagnoza; log -> debug logger) --------
|
||||||
|
@commands.hybrid_command(
|
||||||
|
nsfw=False,
|
||||||
|
name="tex",
|
||||||
|
description="Kompiluje załączone .tex; PDF-y odsyła. Błędy: diagnoza z OpenAI.",
|
||||||
|
guild=None if LATEX_GUILD_ID is None else discord.Object(id=LATEX_GUILD_ID),
|
||||||
|
)
|
||||||
|
@commands.has_any_role(*LATEX_ALLOWED_ROLES)
|
||||||
|
async def tex(self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True):
|
||||||
|
ch = ctx.message.channel
|
||||||
|
async with ch.typing():
|
||||||
|
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
|
||||||
|
if not atts:
|
||||||
|
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
|
||||||
|
|
||||||
|
files: List[discord.File] = []
|
||||||
|
parts: List[str] = []
|
||||||
|
for att in atts:
|
||||||
|
try:
|
||||||
|
data = await att.read()
|
||||||
|
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger)
|
||||||
|
self.logger.debug("LaTeX log (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
|
||||||
|
if res["ok"] and res["pdf_bytes"]:
|
||||||
|
b = io.BytesIO(res["pdf_bytes"])
|
||||||
|
b.seek(0)
|
||||||
|
b.name = res["pdf_name"]
|
||||||
|
files.append(discord.File(b, filename=res["pdf_name"]))
|
||||||
|
parts.append(f"✅ `{att.filename}` → `{res['pdf_name']}`")
|
||||||
|
else:
|
||||||
|
excerpt = ""
|
||||||
|
if include_source_excerpt:
|
||||||
|
try:
|
||||||
|
excerpt = data.decode("utf-8", errors="ignore")[:4000]
|
||||||
|
except Exception:
|
||||||
|
excerpt = ""
|
||||||
|
advice = await ask_openai_diagnosis(res["log_text"] or "", excerpt, OPENAI_API_KEY, OPENAI_MODEL, logger=self.logger)
|
||||||
|
parts.append(f"❌ `{att.filename}` — błąd kompilacji.\nDiagnoza:\n{advice}")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("Exception %s: %s", att.filename, e)
|
||||||
|
parts.append(f"⚠️ `{att.filename}` — wyjątek (szczegóły w logu).")
|
||||||
|
|
||||||
|
content = f"**LaTeX** — {len(atts)} plik(ów). Model: `{OPENAI_MODEL}`\n\n" + "\n\n".join(parts)
|
||||||
|
await ctx.reply(content if len(content)<1900 else content[:1900]+"…", files=files, mention_author=False)
|
||||||
|
|
||||||
|
# -------- /latexclean (PDF only; log -> debug logger) --------
|
||||||
|
@commands.hybrid_command(
|
||||||
|
nsfw=False,
|
||||||
|
name="latexclean",
|
||||||
|
description="Kompiluje .tex i odsyła TYLKO PDF (log w debug).",
|
||||||
|
guild=None if LATEX_GUILD_ID is None else discord.Object(id=LATEX_GUILD_ID),
|
||||||
|
)
|
||||||
|
@commands.has_any_role(*LATEX_ALLOWED_ROLES)
|
||||||
|
async def latexclean(self, ctx: commands.Context):
|
||||||
|
ch = ctx.message.channel
|
||||||
|
async with ch.typing():
|
||||||
|
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
|
||||||
|
if not atts:
|
||||||
|
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
|
||||||
|
|
||||||
|
files: List[discord.File] = []
|
||||||
|
parts: List[str] = []
|
||||||
|
for att in atts:
|
||||||
|
try:
|
||||||
|
data = await att.read()
|
||||||
|
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger)
|
||||||
|
self.logger.debug("LaTeX CLEAN (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
|
||||||
|
if res["ok"] and res["pdf_bytes"]:
|
||||||
|
b = io.BytesIO(res["pdf_bytes"])
|
||||||
|
b.seek(0)
|
||||||
|
b.name = res["pdf_name"]
|
||||||
|
files.append(discord.File(b, filename=res["pdf_name"]))
|
||||||
|
parts.append(f"✅ `{att.filename}`")
|
||||||
|
else:
|
||||||
|
parts.append(f"❌ `{att.filename}` — błąd (log w debug).")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug("CLEAN exception %s: %s", att.filename, e)
|
||||||
|
parts.append(f"⚠️ `{att.filename}` — wyjątek (log w debug).")
|
||||||
|
|
||||||
|
await ctx.reply("**LaTeX clean** — " + ", ".join(parts), files=files, mention_author=False)
|
||||||
|
|
||||||
|
# -------- /texbatch (ZIP -> ZIP) --------
|
||||||
|
@app_commands.command(
|
||||||
|
name="texbatch",
|
||||||
|
description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane)."
|
||||||
|
)
|
||||||
|
@app_commands.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
|
||||||
|
@app_commands.checks.has_any_role(*LATEX_ALLOWED_ROLES)
|
||||||
|
async def texbatch(self, interaction: discord.Interaction, archive: discord.Attachment):
|
||||||
|
await interaction.response.defer()
|
||||||
|
if not archive or not archive.filename.lower().endswith(".zip"):
|
||||||
|
return await interaction.followup.send("Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True)
|
||||||
|
if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
|
||||||
|
return await interaction.followup.send(f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True)
|
||||||
|
|
||||||
|
data = await archive.read()
|
||||||
|
out_zip_bytes, failed = await compile_zip_to_zip(data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger)
|
||||||
|
if not out_zip_bytes and failed:
|
||||||
|
return await interaction.followup.send("Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True)
|
||||||
|
|
||||||
|
bio = io.BytesIO(out_zip_bytes)
|
||||||
|
bio.seek(0)
|
||||||
|
bio.name = "compiled_pdfs.zip"
|
||||||
|
if failed:
|
||||||
|
self.logger.debug("BATCH failed: %s", ", ".join(failed))
|
||||||
|
await interaction.followup.send("✅ Gotowe (ZIP w załączniku)." + (f" ❌ Błędy: {len(failed)}" if failed else ""), files=[discord.File(bio, filename=bio.name)])
|
||||||
|
|
||||||
|
async def setup(bot: commands.Bot):
|
||||||
|
await bot.add_cog(LatexModule(bot, logger_name="latex"))
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
# latex_functions.py
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import annotations
|
||||||
|
import asyncio, os, re, shlex, tempfile, zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Tuple, List, Dict, Optional
|
||||||
|
|
||||||
|
from openai import OpenAI
|
||||||
|
|
||||||
|
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
|
||||||
|
|
||||||
|
def is_safe_tex_name(name: str) -> bool:
|
||||||
|
return bool(SAFE_TEX_NAME.match(name or "")) and name.lower().endswith(".tex")
|
||||||
|
|
||||||
|
def is_tex_attachment(att, max_mb: int) -> bool:
|
||||||
|
"""Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna."""
|
||||||
|
return bool(
|
||||||
|
getattr(att, "filename", "") and
|
||||||
|
is_safe_tex_name(att.filename) and
|
||||||
|
getattr(att, "size", 0) <= max_mb * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_latex_two_passes(tex_filename: str, workdir: Path, tex_engine: str, timeout_sec: int, logger=None) -> Tuple[bool, str, Path]:
|
||||||
|
cmd = f'{tex_engine} -interaction=nonstopmode -halt-on-error -file-line-error -no-shell-escape {shlex.quote(tex_filename)}'
|
||||||
|
if logger: logger.debug("LaTeX cmd: %s (cwd=%s)", cmd, workdir)
|
||||||
|
|
||||||
|
async def _run_once() -> Tuple[int, str]:
|
||||||
|
proc = await asyncio.create_subprocess_shell(
|
||||||
|
cmd, cwd=str(workdir),
|
||||||
|
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.STDOUT
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
||||||
|
out_t = (out_b or b"").decode("utf-8", errors="replace")
|
||||||
|
return proc.returncode or 0, out_t
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
try: proc.kill()
|
||||||
|
except Exception: pass
|
||||||
|
return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n"
|
||||||
|
|
||||||
|
logs: List[str] = []
|
||||||
|
rc1, out1 = await _run_once(); logs.append(out1)
|
||||||
|
if rc1 != 0:
|
||||||
|
log_text = "".join(logs)
|
||||||
|
if logger: logger.debug("LaTeX pass1 FAIL %s\n%s", tex_filename, log_text[-2000:])
|
||||||
|
return False, log_text, workdir / (Path(tex_filename).stem + ".pdf")
|
||||||
|
|
||||||
|
rc2, out2 = await _run_once(); logs.append(out2)
|
||||||
|
log_text = "".join(logs)
|
||||||
|
pdf = workdir / (Path(tex_filename).stem + ".pdf")
|
||||||
|
ok = (rc2 == 0) and pdf.exists()
|
||||||
|
if logger: logger.debug("LaTeX pass2 %s ok=%s\n%s", tex_filename, ok, log_text[-2000:])
|
||||||
|
return ok, log_text, pdf
|
||||||
|
|
||||||
|
async def compile_single_tex_bytes(tex_bytes: bytes, filename: str, tex_engine: str, timeout_sec: int, logger=None) -> Dict[str, Optional[object]]:
|
||||||
|
if not is_safe_tex_name(filename):
|
||||||
|
return {'ok': False, 'pdf_bytes': None, 'log_text': 'Invalid .tex filename', 'pdf_name': None}
|
||||||
|
with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
|
||||||
|
wd = Path(td)
|
||||||
|
tex_path = wd / filename
|
||||||
|
tex_path.write_bytes(tex_bytes)
|
||||||
|
ok, log_text, pdf_path = await run_latex_two_passes(filename, wd, tex_engine, timeout_sec, logger=logger)
|
||||||
|
if ok and pdf_path.exists():
|
||||||
|
return {'ok': True, 'pdf_bytes': pdf_path.read_bytes(), 'log_text': log_text, 'pdf_name': pdf_path.name}
|
||||||
|
return {'ok': False, 'pdf_bytes': None, 'log_text': log_text, 'pdf_name': pdf_path.name}
|
||||||
|
|
||||||
|
async def compile_zip_to_zip(zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger=None) -> Tuple[bytes, List[str]]:
|
||||||
|
failed: List[str] = []; out_pdf_paths: List[Path] = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
|
||||||
|
td_path = Path(td)
|
||||||
|
in_zip = td_path / "in.zip"; in_zip.write_bytes(zip_bytes)
|
||||||
|
extract_dir = td_path / "in"; extract_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with zipfile.ZipFile(in_zip, 'r') as zf: zf.extractall(extract_dir)
|
||||||
|
|
||||||
|
tex_files = [p for p in extract_dir.rglob("*.tex") if is_safe_tex_name(p.name)]
|
||||||
|
if not tex_files:
|
||||||
|
return b"", ["No .tex files found in archive."]
|
||||||
|
|
||||||
|
for tex in tex_files:
|
||||||
|
try:
|
||||||
|
work = td_path / f"build_{tex.stem}"; work.mkdir(parents=True, exist_ok=True)
|
||||||
|
target_tex = work / tex.name; target_tex.write_bytes(tex.read_bytes())
|
||||||
|
ok, log_text, pdf_path = await run_latex_two_passes(target_tex.name, work, tex_engine, timeout_sec, logger=logger)
|
||||||
|
if ok and pdf_path.exists():
|
||||||
|
out_pdf_paths.append(pdf_path)
|
||||||
|
else:
|
||||||
|
failed.append(tex.name)
|
||||||
|
if logger: logger.debug("BATCH FAIL %s\n%s", tex.name, log_text[-1500:])
|
||||||
|
except Exception as e:
|
||||||
|
failed.append(f"{tex.name} (exception: {e})")
|
||||||
|
if logger: logger.debug("BATCH EXC %s: %s", tex.name, e)
|
||||||
|
|
||||||
|
out_zip = td_path / "compiled_pdfs.zip"
|
||||||
|
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for pdf in out_pdf_paths:
|
||||||
|
zf.write(pdf, arcname=pdf.name)
|
||||||
|
|
||||||
|
return out_zip.read_bytes(), failed
|
||||||
|
|
||||||
|
async def ask_openai_diagnosis(log_text: str, tex_excerpt: str, api_key: str, model: str, logger=None) -> str:
|
||||||
|
if not api_key:
|
||||||
|
return "⚠️ Brak OPENAI_API_KEY — pomijam diagnozę."
|
||||||
|
client = OpenAI(api_key=api_key)
|
||||||
|
sys = ("You are a LaTeX build diagnostician. Analyze pdflatex log and return:\n"
|
||||||
|
"1) Root causes (bullets)\n2) Minimal working fix (code block)\n3) Alternative fix")
|
||||||
|
usr = f"---LOG---\n{log_text[-9000:]}\n---END LOG---"
|
||||||
|
if tex_excerpt:
|
||||||
|
usr += f"\n---TEX EXCERPT---\n{tex_excerpt[:4000]}\n---END EXCERPT---"
|
||||||
|
try:
|
||||||
|
if model.startswith("gpt-3.5"):
|
||||||
|
def _legacy():
|
||||||
|
resp = client.chat.completions.create(model=model, messages=[{"role":"system","content":sys},{"role":"user","content":usr}], temperature=0.1)
|
||||||
|
return resp.choices[0].message.content.strip()
|
||||||
|
return await asyncio.to_thread(_legacy)
|
||||||
|
def _resp():
|
||||||
|
r = client.responses.create(model=model, temperature=0.1, input=[{"role":"system","content":sys},{"role":"user","content":usr}])
|
||||||
|
return (getattr(r,"output_text",None) or "").strip() or str(r)
|
||||||
|
return await asyncio.to_thread(_resp)
|
||||||
|
except Exception as e:
|
||||||
|
if logger: logger.debug("OpenAI error: %s", e)
|
||||||
|
return f"⚠️ OpenAI API error: {e}"
|
||||||
Reference in New Issue
Block a user