Vibe coding :P

This commit is contained in:
2025-08-20 21:59:11 +02:00
parent 7ef1502249
commit 28685780f3
2 changed files with 344 additions and 183 deletions
+196 -58
View File
@@ -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
View File
@@ -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