mirror of
https://github.com/migatu/conjurer.git
synced 2026-07-14 21:38:38 +00:00
Vibe coding :P
This commit is contained in:
+196
-58
@@ -1,22 +1,47 @@
|
|||||||
# latex_commands.py
|
# latex_commands.py
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import io
|
import io
|
||||||
from typing import Optional, List
|
|
||||||
import logging
|
import logging
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord import app_commands
|
from discord import app_commands
|
||||||
from discord.ext import commands
|
from discord.ext import commands
|
||||||
|
|
||||||
from constants import (
|
from constants import (
|
||||||
LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, LATEX_MAX_ATTACH_MB, LATEX_MAX_ZIP_MB,
|
ALLOWED_ROLES,
|
||||||
OPENAI_MODEL, ALLOWED_ROLES, GUILD_ID
|
GUILD_ID,
|
||||||
|
LATEX_MAX_ATTACH_MB,
|
||||||
|
LATEX_MAX_COMPILE_SECONDS,
|
||||||
|
LATEX_MAX_ZIP_MB,
|
||||||
|
LATEX_TEX_ENGINE,
|
||||||
|
OPENAI_MODEL,
|
||||||
)
|
)
|
||||||
from latex_functions import (
|
from latex_functions import (
|
||||||
is_tex_attachment, compile_single_tex_bytes, compile_zip_to_zip, ask_openai_diagnosis
|
compile_single_tex_bytes,
|
||||||
|
compile_zip_to_zip,
|
||||||
|
is_safe_asset_name,
|
||||||
|
is_safe_tex_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_attachments_bytes(attachments, max_mb: int):
|
||||||
|
"""
|
||||||
|
Zwraca słownik {filename: bytes} dla wszystkich załączników, do limitu MB.
|
||||||
|
Nie podbija wyjątków z .read() — zostawiamy to wyżej; tu zwracamy, co się udało.
|
||||||
|
"""
|
||||||
|
out = {}
|
||||||
|
for a in attachments or []:
|
||||||
|
# limit rozmiaru
|
||||||
|
if (getattr(a, "size", 0) or 0) > max_mb * 1024 * 1024:
|
||||||
|
continue
|
||||||
|
data = await a.read()
|
||||||
|
out[a.filename] = data
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
class LatexModule(commands.Cog):
|
class LatexModule(commands.Cog):
|
||||||
def __init__(self, bot: commands.Bot, logger_name: str):
|
def __init__(self, bot: commands.Bot, logger_name: str):
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
@@ -31,47 +56,94 @@ class LatexModule(commands.Cog):
|
|||||||
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
|
guild=None if GUILD_ID is None else discord.Object(id=GUILD_ID),
|
||||||
)
|
)
|
||||||
@commands.has_any_role(*ALLOWED_ROLES)
|
@commands.has_any_role(*ALLOWED_ROLES)
|
||||||
async def tex(self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True):
|
async def tex(
|
||||||
|
self, ctx: commands.Context, include_source_excerpt: Optional[bool] = True
|
||||||
|
):
|
||||||
self.logger.info("LaTeX command invoked by %s", ctx.author)
|
self.logger.info("LaTeX command invoked by %s", ctx.author)
|
||||||
ch = ctx.message.channel
|
ch = ctx.message.channel
|
||||||
|
# ZBIERZ WSZYSTKIE ZAŁĄCZNIKI
|
||||||
async with ch.typing():
|
async with ch.typing():
|
||||||
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
|
all_bytes = await _collect_attachments_bytes(
|
||||||
if not atts:
|
ctx.message.attachments, LATEX_MAX_ATTACH_MB
|
||||||
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
|
)
|
||||||
self.logger.debug("LaTeX attachments: %s", ", ".join(a.filename for a in atts))
|
if not all_bytes:
|
||||||
files: List[discord.File] = []
|
return await ctx.reply(
|
||||||
parts: List[str] = []
|
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
|
||||||
for att in atts:
|
)
|
||||||
|
|
||||||
|
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
|
||||||
|
tex_names = [
|
||||||
|
n
|
||||||
|
for n in all_bytes.keys()
|
||||||
|
if n.lower().endswith(".tex") and is_safe_tex_name(n)
|
||||||
|
]
|
||||||
|
if not tex_names:
|
||||||
|
return await ctx.reply(
|
||||||
|
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
|
||||||
|
)
|
||||||
|
|
||||||
|
support_names = [
|
||||||
|
n
|
||||||
|
for n in all_bytes.keys()
|
||||||
|
if n not in tex_names and is_safe_asset_name(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
files_to_send = []
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for tex_name in tex_names:
|
||||||
try:
|
try:
|
||||||
data = await att.read()
|
main_bytes = all_bytes[tex_name]
|
||||||
self.logger.info("LaTeX read %s bytes from %s", len(data), att.filename)
|
support = [
|
||||||
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
|
(n, all_bytes[n])
|
||||||
self.logger.info("LaTeX result for %s: %s", att.filename, res)
|
for n in support_names
|
||||||
self.logger.info("LaTeX log (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
|
if n != tex_name and is_safe_asset_name(n)
|
||||||
|
]
|
||||||
|
# (ważne) do supportów dopuszczamy również **inne .tex** – dla \input/\include
|
||||||
|
other_tex = [
|
||||||
|
(n, all_bytes[n])
|
||||||
|
for n in tex_names
|
||||||
|
if n != tex_name and is_safe_tex_name(n)
|
||||||
|
]
|
||||||
|
support.extend(other_tex)
|
||||||
|
|
||||||
|
res = await compile_single_tex_bytes(
|
||||||
|
main_bytes,
|
||||||
|
tex_name,
|
||||||
|
LATEX_TEX_ENGINE,
|
||||||
|
LATEX_MAX_COMPILE_SECONDS,
|
||||||
|
logger="discord",
|
||||||
|
support_files=support,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Twoja dotychczasowa obsługa success/fail – przykładowo:
|
||||||
if res["ok"] and res["pdf_bytes"]:
|
if res["ok"] and res["pdf_bytes"]:
|
||||||
b = io.BytesIO(res["pdf_bytes"])
|
b = io.BytesIO(res["pdf_bytes"])
|
||||||
b.seek(0)
|
b.seek(0)
|
||||||
b.name = res["pdf_name"]
|
b.name = res["pdf_name"]
|
||||||
files.append(discord.File(b, filename=res["pdf_name"]))
|
files_to_send.append(discord.File(b, filename=res["pdf_name"]))
|
||||||
parts.append(f"✅ `{att.filename}` → `{res['pdf_name']}`")
|
parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
|
||||||
else:
|
else:
|
||||||
self.logger.info("LaTeX compile failed for %s", att.filename)
|
parts.append(
|
||||||
excerpt = ""
|
f"❌ `{tex_name}` — błąd kompilacji. (log: { (res['log_text'] or '')[-400] })"
|
||||||
if include_source_excerpt:
|
)
|
||||||
try:
|
# Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
|
||||||
excerpt = data.decode("utf-8", errors="ignore")[:4000]
|
except Exception:
|
||||||
except Exception:
|
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
|
||||||
excerpt = ""
|
logging.getLogger("discord").exception(
|
||||||
advice = await ask_openai_diagnosis(res["log_text"] or "", excerpt, OPENAI_MODEL, logger=self.logger_name)
|
"Unhandled exception while compiling %s", tex_name
|
||||||
parts.append(f"❌ `{att.filename}` — błąd kompilacji.\nDiagnoza:\n{advice}")
|
)
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.info("Exception %s: %s", att.filename, e)
|
|
||||||
parts.append(f"⚠️ `{att.filename}` — wyjątek (szczegóły w logu).")
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
content = f"**LaTeX** — {len(atts)} plik(ów). Model: `{OPENAI_MODEL}`\n\n" + "\n\n".join(parts)
|
content = (
|
||||||
await ctx.reply(content if len(content)<1900 else content[:1900]+"…", files=files, mention_author=False)
|
f"**LaTeX** — {len(tex_names)} plik(ów). Model: `{OPENAI_MODEL}`\n\n"
|
||||||
|
+ "\n\n".join(parts)
|
||||||
|
)
|
||||||
|
await ctx.reply(
|
||||||
|
content if len(content) < 1900 else content[:1900] + "…",
|
||||||
|
files=parts,
|
||||||
|
mention_author=False,
|
||||||
|
)
|
||||||
|
|
||||||
# -------- /latexclean (PDF only; log -> debug logger) --------
|
# -------- /latexclean (PDF only; log -> debug logger) --------
|
||||||
@commands.hybrid_command(
|
@commands.hybrid_command(
|
||||||
@@ -84,57 +156,123 @@ class LatexModule(commands.Cog):
|
|||||||
async def latexclean(self, ctx: commands.Context):
|
async def latexclean(self, ctx: commands.Context):
|
||||||
ch = ctx.message.channel
|
ch = ctx.message.channel
|
||||||
async with ch.typing():
|
async with ch.typing():
|
||||||
atts = [a for a in (ctx.message.attachments or []) if is_tex_attachment(a, LATEX_MAX_ATTACH_MB)]
|
all_bytes = await _collect_attachments_bytes(
|
||||||
if not atts:
|
ctx.message.attachments, LATEX_MAX_ATTACH_MB
|
||||||
return await ctx.reply(f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False)
|
)
|
||||||
|
if not all_bytes:
|
||||||
|
return await ctx.reply(
|
||||||
|
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
|
||||||
|
)
|
||||||
|
|
||||||
files: List[discord.File] = []
|
# PODZIEL NA GŁÓWNE .TEX i WSPIERAJĄCE
|
||||||
parts: List[str] = []
|
tex_names = [
|
||||||
for att in atts:
|
n
|
||||||
|
for n in all_bytes.keys()
|
||||||
|
if n.lower().endswith(".tex") and is_safe_tex_name(n)
|
||||||
|
]
|
||||||
|
if not tex_names:
|
||||||
|
return await ctx.reply(
|
||||||
|
f"Dołącz .tex (≤{LATEX_MAX_ATTACH_MB} MiB).", mention_author=False
|
||||||
|
)
|
||||||
|
|
||||||
|
support_names = [
|
||||||
|
n
|
||||||
|
for n in all_bytes.keys()
|
||||||
|
if n not in tex_names and is_safe_asset_name(n)
|
||||||
|
]
|
||||||
|
|
||||||
|
files_to_send = []
|
||||||
|
parts = []
|
||||||
|
|
||||||
|
for tex_name in tex_names:
|
||||||
try:
|
try:
|
||||||
data = await att.read()
|
main_bytes = all_bytes[tex_name]
|
||||||
res = await compile_single_tex_bytes(data, att.filename, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
|
support = [
|
||||||
self.logger.debug("LaTeX CLEAN (%s)\n%s", att.filename, (res["log_text"] or "")[-2000:])
|
(n, all_bytes[n])
|
||||||
|
for n in support_names
|
||||||
|
if n != tex_name and is_safe_asset_name(n)
|
||||||
|
]
|
||||||
|
# (ważne) do supportów dopuszczamy również **inne .tex** – dla \input/\include
|
||||||
|
other_tex = [
|
||||||
|
(n, all_bytes[n])
|
||||||
|
for n in tex_names
|
||||||
|
if n != tex_name and is_safe_tex_name(n)
|
||||||
|
]
|
||||||
|
support.extend(other_tex)
|
||||||
|
|
||||||
|
res = await compile_single_tex_bytes(
|
||||||
|
main_bytes,
|
||||||
|
tex_name,
|
||||||
|
LATEX_TEX_ENGINE,
|
||||||
|
LATEX_MAX_COMPILE_SECONDS,
|
||||||
|
logger="discord",
|
||||||
|
support_files=support,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Twoja dotychczasowa obsługa success/fail – przykładowo:
|
||||||
if res["ok"] and res["pdf_bytes"]:
|
if res["ok"] and res["pdf_bytes"]:
|
||||||
b = io.BytesIO(res["pdf_bytes"])
|
b = io.BytesIO(res["pdf_bytes"])
|
||||||
b.seek(0)
|
b.seek(0)
|
||||||
b.name = res["pdf_name"]
|
b.name = res["pdf_name"]
|
||||||
files.append(discord.File(b, filename=res["pdf_name"]))
|
files_to_send.append(discord.File(b, filename=res["pdf_name"]))
|
||||||
parts.append(f"✅ `{att.filename}`")
|
parts.append(f"✅ `{tex_name}` → `{res['pdf_name']}`")
|
||||||
else:
|
else:
|
||||||
parts.append(f"❌ `{att.filename}` — błąd (log w debug).")
|
parts.append(
|
||||||
except Exception as e:
|
f"❌ `{tex_name}` — błąd kompilacji. (log: { (res['log_text'] or '')[-400] })"
|
||||||
self.logger.debug("CLEAN exception %s: %s", att.filename, e)
|
)
|
||||||
parts.append(f"⚠️ `{att.filename}` — wyjątek (log w debug).")
|
# Tutaj wywołujesz swoją ścieżkę do OpenAI diagnozy, którą ogarnęliśmy wcześniej.
|
||||||
|
except Exception:
|
||||||
|
# let it crash — pełny stack w logu i re-raise (Discord.py to pokaże sensownie)
|
||||||
|
logging.getLogger("discord").exception(
|
||||||
|
"Unhandled exception while compiling %s", tex_name
|
||||||
|
)
|
||||||
raise
|
raise
|
||||||
|
await ctx.reply(
|
||||||
await ctx.reply("**LaTeX clean** — " + ", ".join(parts), files=files, mention_author=False)
|
"**LaTeX clean** — " + ", ".join(parts),
|
||||||
|
files=tex_names,
|
||||||
|
mention_author=False,
|
||||||
|
)
|
||||||
|
|
||||||
# -------- /texbatch (ZIP -> ZIP) --------
|
# -------- /texbatch (ZIP -> ZIP) --------
|
||||||
@app_commands.command(
|
@app_commands.command(
|
||||||
name="texbatch",
|
name="texbatch",
|
||||||
description="Wyślij ZIP z .tex → odeślę ZIP z PDF-ami (tylko udane)."
|
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.describe(archive=f"Archiwum .zip (max ~{LATEX_MAX_ZIP_MB} MiB)")
|
||||||
@app_commands.checks.has_any_role(*ALLOWED_ROLES)
|
@app_commands.checks.has_any_role(*ALLOWED_ROLES)
|
||||||
async def texbatch(self, interaction: discord.Interaction, archive: discord.Attachment):
|
async def texbatch(
|
||||||
|
self, interaction: discord.Interaction, archive: discord.Attachment
|
||||||
|
):
|
||||||
await interaction.response.defer()
|
await interaction.response.defer()
|
||||||
if not archive or not archive.filename.lower().endswith(".zip"):
|
if not archive or not archive.filename.lower().endswith(".zip"):
|
||||||
return await interaction.followup.send("Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True)
|
return await interaction.followup.send(
|
||||||
|
"Dołącz ZIP (.zip) z plikami .tex.", ephemeral=True
|
||||||
|
)
|
||||||
if archive.size > LATEX_MAX_ZIP_MB * 1024 * 1024:
|
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)
|
return await interaction.followup.send(
|
||||||
|
f"ZIP > {LATEX_MAX_ZIP_MB} MiB — za duży.", ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
data = await archive.read()
|
data = await archive.read()
|
||||||
out_zip_bytes, failed = await compile_zip_to_zip(data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name)
|
out_zip_bytes, failed = await compile_zip_to_zip(
|
||||||
|
data, LATEX_TEX_ENGINE, LATEX_MAX_COMPILE_SECONDS, logger=self.logger_name
|
||||||
|
)
|
||||||
if not out_zip_bytes and failed:
|
if not out_zip_bytes and failed:
|
||||||
return await interaction.followup.send("Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True)
|
return await interaction.followup.send(
|
||||||
|
"Brak PDF-ów. " + "; ".join(failed[:10]), ephemeral=True
|
||||||
|
)
|
||||||
|
|
||||||
bio = io.BytesIO(out_zip_bytes)
|
bio = io.BytesIO(out_zip_bytes)
|
||||||
bio.seek(0)
|
bio.seek(0)
|
||||||
bio.name = "compiled_pdfs.zip"
|
bio.name = "compiled_pdfs.zip"
|
||||||
if failed:
|
if failed:
|
||||||
self.logger.debug("BATCH failed: %s", ", ".join(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)])
|
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):
|
async def setup(bot):
|
||||||
logger = logging.getLogger("discord")
|
logger = logging.getLogger("discord")
|
||||||
|
|||||||
+148
-125
@@ -1,153 +1,176 @@
|
|||||||
# latex_functions.py
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
import zipfile
|
import zipfile
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
||||||
from ai_functions import handle_response
|
|
||||||
import shutil
|
|
||||||
from typing import Iterable, Tuple
|
|
||||||
|
|
||||||
|
# ===== Bezpieczeństwo nazw/rozszerzeń =====
|
||||||
|
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
|
||||||
SAFE_ASSET_NAME = re.compile(
|
SAFE_ASSET_NAME = re.compile(
|
||||||
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
|
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
|
||||||
re.IGNORECASE,
|
re.IGNORECASE,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_safe_tex_name(name: str) -> bool:
|
||||||
|
return bool(SAFE_TEX_NAME.match(name or ""))
|
||||||
|
|
||||||
def is_safe_asset_name(name: str) -> bool:
|
def is_safe_asset_name(name: str) -> bool:
|
||||||
return bool(SAFE_ASSET_NAME.match(name or ""))
|
return bool(SAFE_ASSET_NAME.match(name or ""))
|
||||||
|
|
||||||
|
# ===== Uruchamianie kompilatora =====
|
||||||
|
|
||||||
def is_tex_attachment(att, max_mb: int) -> bool:
|
async def _run(cmd: List[str], cwd: Path, timeout_sec: int, logger_name: Optional[str]) -> Tuple[int, str]:
|
||||||
"""Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna."""
|
"""Uruchamia proces kompilatora w cwd, zwraca (rc, sklejone_stdout_stderr)."""
|
||||||
return bool(
|
logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
|
||||||
getattr(att, "filename", "")
|
logger.debug("RUN %s (cwd=%s, timeout=%s)", " ".join(cmd), cwd, timeout_sec)
|
||||||
and is_safe_tex_name(att.filename)
|
|
||||||
and getattr(att, "size", 0) <= max_mb * 1024 * 1024
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*cmd,
|
||||||
|
stdout=asyncio.subprocess.PIPE,
|
||||||
|
stderr=asyncio.subprocess.STDOUT,
|
||||||
|
cwd=str(cwd),
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
|
out = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
return 124, "TIMEOUT: compiler took too long"
|
||||||
|
rc = proc.returncode
|
||||||
|
log = (out[0] or b"").decode("utf-8", errors="ignore")
|
||||||
|
logger.debug("RC=%s, LOG TAIL:\n%s", rc, log[-1500:])
|
||||||
|
return rc, log
|
||||||
|
|
||||||
|
def _build_cmd(tex_engine: str, main_tex: str) -> List[str]:
|
||||||
def _build_cmd(engine: str, tex_filename: str) -> list[str]:
|
"""Buduje komendę kompilatora; domyślnie tectonic/pdflatex/xelatex."""
|
||||||
base = (engine or "").strip().lower()
|
if tex_engine.lower() == "tectonic":
|
||||||
if base in ("pdflatex", "xelatex", "lualatex"):
|
# Tectonic sam robi wielofazowość, ale i tak uruchamiamy 2x (bezpiecznie dla bib/refs)
|
||||||
return [
|
return ["tectonic", "--keep-intermediates", "--synctex", main_tex]
|
||||||
base,
|
elif tex_engine.lower() in ("pdflatex", "xelatex", "lualatex"):
|
||||||
"-interaction=nonstopmode",
|
return [tex_engine, "-interaction=nonstopmode", "-halt-on-error", main_tex]
|
||||||
"-halt-on-error",
|
else:
|
||||||
"-file-line-error",
|
# fallback – traktujemy jak pdflatex
|
||||||
"-no-shell-escape",
|
return ["pdflatex", "-interaction=nonstopmode", "-halt-on-error", main_tex]
|
||||||
tex_filename,
|
|
||||||
]
|
|
||||||
if base == "latexmk":
|
|
||||||
return [
|
|
||||||
"latexmk",
|
|
||||||
"-pdf",
|
|
||||||
"-interaction=nonstopmode",
|
|
||||||
"-halt-on-error",
|
|
||||||
"-file-line-error",
|
|
||||||
tex_filename,
|
|
||||||
]
|
|
||||||
if base == "tectonic":
|
|
||||||
return [
|
|
||||||
"tectonic",
|
|
||||||
"-X",
|
|
||||||
"compile",
|
|
||||||
"--keep-logs",
|
|
||||||
"--outdir",
|
|
||||||
".",
|
|
||||||
tex_filename,
|
|
||||||
]
|
|
||||||
return [
|
|
||||||
"pdflatex",
|
|
||||||
"-interaction=nonstopmode",
|
|
||||||
"-halt-on-error",
|
|
||||||
"-file-line-error",
|
|
||||||
"-no-shell-escape",
|
|
||||||
tex_filename,
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
async def run_latex_two_passes(
|
async def run_latex_two_passes(
|
||||||
tex_filename: str,
|
tex_filename: str, workdir: Path, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
|
||||||
workdir: Path,
|
) -> Tuple[bool, str, Path]:
|
||||||
|
"""
|
||||||
|
Uruchamia kompilację 2 razy (bezpieczeństwo referencji/bib).
|
||||||
|
Zwraca: (ok, log_text, pdf_path).
|
||||||
|
"""
|
||||||
|
logger_obj = logging.getLogger(logger) if logger else logging.getLogger()
|
||||||
|
logger_obj.info("Compiling %s with %s engine", tex_filename, tex_engine)
|
||||||
|
logs: List[str] = []
|
||||||
|
pdf_path = workdir / (Path(tex_filename).stem + ".pdf")
|
||||||
|
|
||||||
|
# PASS 1
|
||||||
|
rc1, log1 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
|
||||||
|
logs.append(log1)
|
||||||
|
if rc1 != 0:
|
||||||
|
return False, "".join(logs), pdf_path
|
||||||
|
|
||||||
|
# PASS 2 (często już nic nie robi, ale nie szkodzi)
|
||||||
|
rc2, log2 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
|
||||||
|
logs.append(log2)
|
||||||
|
ok = (rc2 == 0) and pdf_path.exists()
|
||||||
|
return ok, "".join(logs), pdf_path
|
||||||
|
|
||||||
|
# ===== Kompilacje: pojedynczy TEX + ZIP =====
|
||||||
|
|
||||||
|
async def compile_single_tex_bytes(
|
||||||
|
tex_bytes: bytes,
|
||||||
|
filename: str,
|
||||||
tex_engine: str,
|
tex_engine: str,
|
||||||
timeout_sec: int,
|
timeout_sec: int,
|
||||||
logger: str = None,
|
logger: Optional[str] = None,
|
||||||
):
|
support_files: Optional[Iterable[Tuple[str, bytes]]] = None,
|
||||||
logger = logging.getLogger(logger) if logger else None
|
) -> Dict[str, Optional[object]]:
|
||||||
|
"""
|
||||||
|
Kompiluje pojedynczy .tex + dowolne pliki pomocnicze (obrazy, sty/cls/bib, inne .tex).
|
||||||
|
Każdy plik zapisujemy w katalogu roboczym widocznym dla kompilatora.
|
||||||
|
"""
|
||||||
|
log = logging.getLogger(logger) if logger else logging.getLogger()
|
||||||
|
log.info("Compiling %s with %s engine", filename, tex_engine)
|
||||||
|
if not is_safe_tex_name(filename):
|
||||||
|
return {"ok": False, "pdf_bytes": None, "log_text": "Invalid .tex filename", "pdf_name": None}
|
||||||
|
|
||||||
async def run_once(cmd: list[str]) -> tuple[int, str]:
|
with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
|
||||||
proc = await asyncio.create_subprocess_exec(
|
wd = Path(td)
|
||||||
*cmd,
|
(wd / Path(filename).name).write_bytes(tex_bytes)
|
||||||
cwd=str(workdir),
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
# Zapisz wszystkie supporty obok głównego pliku
|
||||||
stderr=asyncio.subprocess.STDOUT,
|
if support_files:
|
||||||
)
|
for fname, data in support_files:
|
||||||
try:
|
if not fname or not is_safe_asset_name(fname):
|
||||||
out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
|
continue
|
||||||
return (proc.returncode or 0), (out_b or b"").decode(
|
# spłaszczamy do nazwy bazowej (bez podkatalogów z zewn. źródeł)
|
||||||
"utf-8", errors="replace"
|
(wd / Path(fname).name).write_bytes(data)
|
||||||
)
|
|
||||||
except asyncio.TimeoutError:
|
ok, log_text, pdf_path = await run_latex_two_passes(Path(filename).name, wd, tex_engine, timeout_sec, 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: Optional[str] = None
|
||||||
|
) -> Tuple[bytes, List[str]]:
|
||||||
|
"""
|
||||||
|
Rozpakowuje ZIP, znajduję wszystkie .tex i dla KAŻDEGO tworzy osobny katalog roboczy
|
||||||
|
ze skopiowanym CAŁYM drzewem, by zachować ścieżki względne obrazków/plików.
|
||||||
|
Zwraca (zip_pdf_bytes, lista_rel_sciezek_które_padły).
|
||||||
|
"""
|
||||||
|
log = logging.getLogger(logger) if logger else logging.getLogger()
|
||||||
|
failed: List[str] = []
|
||||||
|
out_pdf_paths: List[Path] = []
|
||||||
|
|
||||||
|
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
|
||||||
|
td_path = Path(td)
|
||||||
|
src_root = td_path / "src"
|
||||||
|
src_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# 1) Rozpakuj
|
||||||
|
zpath = td_path / "in.zip"
|
||||||
|
zpath.write_bytes(zip_bytes)
|
||||||
|
with zipfile.ZipFile(zpath, "r") as zf:
|
||||||
|
zf.extractall(src_root)
|
||||||
|
|
||||||
|
# 2) Zbierz .tex
|
||||||
|
tex_files = [p for p in src_root.rglob("*.tex") if is_safe_tex_name(p.name)]
|
||||||
|
if not tex_files:
|
||||||
|
return b"", ["No .tex files found in archive."]
|
||||||
|
|
||||||
|
# 3) Kompiluj każdy .tex w izolacji (kopiujemy całe drzewo do osobnego workdiru)
|
||||||
|
for tex_path in tex_files:
|
||||||
|
rel_tex = tex_path.relative_to(src_root).as_posix()
|
||||||
|
work = td_path / f"build_{tex_path.stem}"
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
shutil.copytree(src_root, work / "src")
|
||||||
|
ok, log_text, pdf = await run_latex_two_passes(rel_tex, work / "src", tex_engine, timeout_sec, logger)
|
||||||
|
if ok and pdf.exists():
|
||||||
|
out_pdf_paths.append(pdf)
|
||||||
|
else:
|
||||||
|
failed.append(rel_tex)
|
||||||
|
log.info("LaTeX FAIL %s\n%s", rel_tex, (log_text or "")[-2000:])
|
||||||
except Exception:
|
except Exception:
|
||||||
|
# let it crash – log + re-raise (to zobaczy wyższa warstwa/discord.py)
|
||||||
|
log.exception("Exception while compiling %s", rel_tex)
|
||||||
raise
|
raise
|
||||||
return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n"
|
|
||||||
|
|
||||||
logs = []
|
# 4) Spakuj wyjściowe PDF-y
|
||||||
cmd = _build_cmd(tex_engine, tex_filename)
|
out_zip = td_path / "compiled_pdfs.zip"
|
||||||
if logger:
|
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||||
logger.debug("LaTeX cmd: %s (cwd=%s)", " ".join(cmd), workdir)
|
for pdf in out_pdf_paths:
|
||||||
|
zf.write(pdf, arcname=pdf.name)
|
||||||
|
|
||||||
rc1, out1 = await run_once(cmd)
|
return out_zip.read_bytes(), failed
|
||||||
logs.append(out1)
|
|
||||||
second_pass = not ((tex_engine or "").strip().lower() == "tectonic")
|
|
||||||
|
|
||||||
if rc1 != 0:
|
|
||||||
log_text = "".join(logs)
|
|
||||||
pdf = workdir / (Path(tex_filename).stem + ".pdf")
|
|
||||||
return False, log_text, pdf
|
|
||||||
|
|
||||||
if second_pass:
|
|
||||||
rc2, out2 = await run_once(cmd)
|
|
||||||
logs.append(out2)
|
|
||||||
ok = rc2 == 0
|
|
||||||
else:
|
|
||||||
ok = rc1 == 0
|
|
||||||
|
|
||||||
log_text = "".join(logs)
|
|
||||||
pdf = workdir / (Path(tex_filename).stem + ".pdf")
|
|
||||||
ok = ok and pdf.exists()
|
|
||||||
return ok, log_text, pdf
|
|
||||||
|
|
||||||
async def ask_openai_diagnosis(
|
|
||||||
log_text: str, tex_excerpt: str, model: str, logger: str = None
|
|
||||||
) -> str:
|
|
||||||
logger = logging.getLogger(logger) if logger else None
|
|
||||||
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"
|
|
||||||
)
|
|
||||||
logger.debug("LaTeX DIAG (%s)\n%s", model, log_text[-9000:])
|
|
||||||
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---"
|
|
||||||
prompt = [{"role": sys, "content": "none"}, {"role": "user", "content": usr}]
|
|
||||||
|
|
||||||
response, _ = await handle_response(
|
|
||||||
prompt,
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
prompt,
|
|
||||||
"latex_diagnosis",
|
|
||||||
"NONE",
|
|
||||||
algorithm=model,
|
|
||||||
none_request=prompt,
|
|
||||||
)
|
|
||||||
return response
|
|
||||||
|
|||||||
Reference in New Issue
Block a user