From c0326288ff84987a44e961d81270209e11be473e Mon Sep 17 00:00:00 2001 From: Michal Tuszowski Date: Sat, 16 Aug 2025 17:26:15 +0200 Subject: [PATCH] Added the following changes to the codebase: --- ai_functions.py | 25 +++++---- latex_commands.py | 132 +++++++++++++++++++++++++++++++++++++++++++++ latex_functions.py | 121 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 265 insertions(+), 13 deletions(-) create mode 100644 latex_functions.py diff --git a/ai_functions.py b/ai_functions.py index 3034dd1..197ec6e 100644 --- a/ai_functions.py +++ b/ai_functions.py @@ -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. Zwraca czysty string odpowiedzi. """ + logger = logging.getLogger("discord") if model.startswith("gpt-3.5"): + logger.debug("3.5") # legacy path – bez zmian w Twoim kodzie wyżej/niżej - def _sync_call(): - resp = OPENAICLIENT.chat.completions.create( - model=model, - messages=messages, - temperature=temperature, - ) - return resp.choices[0].message.content.strip() - return await asyncio.to_thread(_sync_call) + resp = OPENAICLIENT.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + ) + result = "" + for choice in resp.choices: + result += choice.message.content + return result.strip() + else: # Responses API (zalecane dla 4o/4.1*) - def _sync_resp(): resp = OPENAICLIENT.responses.create( model=model, temperature=temperature, @@ -51,7 +54,6 @@ async def _openai_call(messages, model, temperature=0.2): ) # SDK zapewnia output_text dla zwykłych odpowiedzi return (getattr(resp, "output_text", None) or "").strip() or str(resp) - return await asyncio.to_thread(_sync_resp) def create_vector_store(): @@ -302,9 +304,6 @@ async def handle_response( result = "" logger.debug("Odpowiedzi") logger.info(response) - logger.debug(response.choices) - for choice in response.choices: - result += choice.message.content logger.info("Sformatowane odpowiedzi") logger.info(result) temp = {"role": "assistant", "content": result} diff --git a/latex_commands.py b/latex_commands.py index e69de29..c878758 100644 --- a/latex_commands.py +++ b/latex_commands.py @@ -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")) diff --git a/latex_functions.py b/latex_functions.py new file mode 100644 index 0000000..5d00655 --- /dev/null +++ b/latex_functions.py @@ -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}"