Tag: 1.28

Intermediate commits (oldest → newest):
- Partial + reformatting
- upgrade to latest OpenAI API usage and logging improvements
- refix
- Vibe coding :P
- Merge branch 'main' of https://github.com/migatu/conjurer
- fx
- fx
- ffx
- testing
- ttt
- fix
- aaa
- FX
- aaa
- sa
This commit is contained in:
2025-10-30 16:59:23 +01:00
parent a6ed8723a5
commit 1ef75678a1
17 changed files with 943 additions and 3056 deletions
+146 -148
View File
@@ -19,13 +19,26 @@ from constants import (
OPENAICLIENT, OPENAICLIENT,
SYSTEM_GPT_SETTINGS, SYSTEM_GPT_SETTINGS,
WORD_REACTIONS, WORD_REACTIONS,
CHEAP_MODEL,
LATEST_MODEL
) )
#this do per user # this do per user
VECTOR_STORE_ID = -1 VECTOR_STORE_ID = -1
def select_model(req_type: str, algo: str) -> str:
# Jeżeli jawnie podano algorithm (i nie jest 'auto'/''):
if algo and str(algo).strip().lower() not in ("auto",):
# wyjątek: MUZYKA ma zawsze być tania — nadpisujemy TYLKO jeśli przyszedł domyślny 'gpt-4o'
if req_type == "MUSIC" and algo.strip() in (LATEST_MODEL,):
return CHEAP_MODEL
return algo
# Auto-dobór:
if req_type == "MUSIC":
return CHEAP_MODEL
return LATEST_MODEL
async def _openai_call(messages, model, temperature=0.2): 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.
@@ -47,23 +60,26 @@ async def _openai_call(messages, model, temperature=0.2):
else: else:
logger.info("4.0+") logger.info("4.0+")
# Responses API (zalecane dla 4o/4.1*) # Responses API (zalecane dla 4o/4.1*)
resp = await OPENAICLIENT.responses.create( resp = await OPENAICLIENT.responses.create(
model=model, model=model,
temperature=temperature, temperature=temperature,
input=messages, input=messages,
) )
# SDK zapewnia output_text dla zwykłych odpowiedzi # SDK zapewnia output_text dla zwykłych odpowiedzi
return (getattr(resp.output_text, "output_text", None) or "").strip() or str(resp.output_text) return (getattr(resp.output_text, "output_text", None) or "").strip() or str(
resp.output_text
)
def create_vector_store(): def create_vector_store():
# Create a vector store caled "Financial Statements" # Create a vector store caled "Financial Statements"
return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash") return OPENAICLIENT.beta.vector_stores.create_and_poll(name="Hammer Stash")
#expires_after={ # expires_after={
#"anchor": "last_active_at", # "anchor": "last_active_at",
#"days": 7} # "days": 7}
#) # )
def upload_files_to_vector_store(assistant): def upload_files_to_vector_store(assistant):
@@ -71,15 +87,14 @@ def upload_files_to_vector_store(assistant):
file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"] file_paths = ["edgar/goog-10k.pdf", "edgar/brka-10k.txt"]
file_streams = [open(path, "rb") for path in file_paths] file_streams = [open(path, "rb") for path in file_paths]
#file = client.beta.vector_stores.files.create_and_poll( # file = client.beta.vector_stores.files.create_and_poll(
#vector_store_id="vs_abc123", # vector_store_id="vs_abc123",
#file_id="file-abc123" # file_id="file-abc123"
#) # )
#batch = client.beta.vector_stores.file_batches.create_and_poll( # batch = client.beta.vector_stores.file_batches.create_and_poll(
#vector_store_id="vs_abc123", # vector_store_id="vs_abc123",
#file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5'] # file_ids=['file_1', 'file_2', 'file_3', 'file_4', 'file_5']
#) # )
# Use the upload and poll SDK helper to upload the files, add them to the vector store, # Use the upload and poll SDK helper to upload the files, add them to the vector store,
# and poll the status of the file batch for completion. # and poll the status of the file batch for completion.
@@ -91,10 +106,11 @@ def upload_files_to_vector_store(assistant):
print(file_batch.status) print(file_batch.status)
print(file_batch.file_counts) print(file_batch.file_counts)
assistant = OPENAICLIENT.beta.assistants.update( assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id, assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}}, tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
) )
def delete_files_from_vector_store(assistant, file_id): def delete_files_from_vector_store(assistant, file_id):
result = OPENAICLIENT.beta.vector_stores.file_batches.delete( result = OPENAICLIENT.beta.vector_stores.file_batches.delete(
vector_store_id=VECTOR_STORE_ID, files=file_id vector_store_id=VECTOR_STORE_ID, files=file_id
@@ -103,8 +119,8 @@ def delete_files_from_vector_store(assistant, file_id):
# You can print the status and the file counts of the batch to see the result of this operation. # You can print the status and the file counts of the batch to see the result of this operation.
print(result) print(result)
assistant = OPENAICLIENT.beta.assistants.update( assistant = OPENAICLIENT.beta.assistants.update(
assistant_id=assistant.id, assistant_id=assistant.id,
tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}}, tool_resources={"file_search": {"vector_store_ids": [VECTOR_STORE_ID]}},
) )
@@ -133,7 +149,15 @@ def num_tokens_from_string(message, model):
async def handle_response( async def handle_response(
prompt, vykidailo, bartender, history, username, request_type, algorithm="gpt-4o", none_request="" prompt,
vykidailo,
bartender,
history,
username,
request_type,
algorithm="gpt-4o",
none_request="",
internal_retry: bool = False
): ):
""" """
Handle responses by appending them to a history, use OpenAI to Handle responses by appending them to a history, use OpenAI to
@@ -157,104 +181,83 @@ async def handle_response(
""" """
logger = logging.getLogger("discord") logger = logging.getLogger("discord")
logger.info("Wywolanie procedury openai z promptem: %s", prompt) logger.info("Wywolanie procedury openai z promptem: %s", prompt)
temp = {"role": "user", "content": username + ":" + prompt}
if vykidailo or bartender: if vykidailo or bartender:
logger.info("Administrator coś chciał") logger.info("Administrator coś chciał")
history.append(temp) model_to_use = select_model(request_type, algorithm)
if request_type == "MUSIC": logger.info("Wybrany model: %s", model_to_use)
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as file_music_memory: if request_type == "MUSIC" and model_to_use == "gpt-4o-mini":
# First we load existing data into a dict. try:
file_data = json.load(file_music_memory) # nic — normalnie pójdzie Responses API
# Join new_data with file_data inside emp_details pass
file_data.append(temp) except Exception:
file_music_memory.seek(0) model_to_use = "gpt-3.5-turbo"
# convert back to json. # --- 2) Budowa historii (token budget + reguły systemowe) ---
json.dump(file_data, file_music_memory, indent=4) # NOTE: ignorujemy przekazany 'history' jako listę (tak było wcześniej),
elif request_type == "RANDOM": # ale zwracamy aktualną tablicę do nadpisania w miejscach wołania (back-compat).
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: base_system = GPT_SETTINGS[0] # zakładamy {"role":"system","content":...}
# First we load existing data into a dict. history_msgs = []
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory:
# First we load existing data into a dict.
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
history = []
if request_type != "NONE":
history.append(GPT_SETTINGS[0])
chat_gpt_config_request_size = num_tokens_from_string(GPT_SETTINGS[0], "gpt-4")
if request_type != "NONE":
history_msgs.append(base_system)
chat_gpt_config_request_size = num_tokens_from_string(base_system, "gpt-4")
# Dynamiczne mikro-reguły (WORD_REACTIONS), jak w Twoim kodzie
for slowo, reakcja in WORD_REACTIONS.items(): for slowo, reakcja in WORD_REACTIONS.items():
if not reakcja[3]: if not reakcja[3]:
content = ( content = f"Kiedy słyszysz {slowo} to reagujesz lub dzieje się to {reakcja[0]}"
"Kiedy słyszysz " sys_msg = {"role": "system", "content": content}
+ slowo chat_gpt_config_request_size += num_tokens_from_string(sys_msg, "gpt-4")
+ " to reagujesz lub dzieje się to " history_msgs.append(sys_msg)
+ reakcja[0]
)
temp = {"role": "system", "content": content}
chat_gpt_config_request_size += num_tokens_from_string(temp, "gpt-4")
history.append(temp)
final_prompt = username + ":" + prompt # wybór właściwej tablicy pamięci i budżetu
logger.debug(
"Rozmiar zapytania przed dodaniem historii %s", chat_gpt_config_request_size
)
if request_type == "MUSIC": if request_type == "MUSIC":
table = MESSAGE_TABLE_MUZYKA table = MESSAGE_TABLE_MUZYKA
token_amount = 10700 token_amount = 10700
elif request_type == "RANDOM": elif request_type in ("RANDOM", "GENERAL"):
table = MESSAGE_TABLE
token_amount = 10700
elif request_type == "GENERAL":
table = MESSAGE_TABLE table = MESSAGE_TABLE
token_amount = 10700 token_amount = 10700
else: else:
table = [] table = []
token_amount = 10000 token_amount = 10000
# doklejanie historii od końca aż do limitu (zachowana kolejność czasowa)
final_prompt = f"{username}:{prompt}"
prompt_gpt_request_size = num_tokens_from_string({"role": "user", "content": final_prompt}, "gpt-4")
acc = []
for msg in reversed(table):
t = num_tokens_from_string(msg, "gpt-4")
if chat_gpt_config_request_size + prompt_gpt_request_size + t <= token_amount:
acc.append(msg)
chat_gpt_config_request_size += t
else:
break
# przywróć chronologicznie
history_msgs.extend(reversed(acc))
# aktualny prompt
history_msgs.append({"role": "user", "content": final_prompt})
logger.info("Rozmiar zapytania (tok): %s", prompt_gpt_request_size) # tokeny już policzone wyżej
prompt_gpt_request_size = num_tokens_from_string(
{"role": "user", "content": final_prompt}, "gpt-4"
)
temptable = []
for i in reversed(table):
temp_token = num_tokens_from_string(i, "gpt-4")
logger.debug(
"Rozmiar zapytania %s prompt %s temp %s",
chat_gpt_config_request_size,
prompt_gpt_request_size,
temp_token,
)
if (
chat_gpt_config_request_size
< token_amount + prompt_gpt_request_size + temp_token
):
temptable.insert(1, i)
chat_gpt_config_request_size += temp_token
history.extend(temptable)
temp = {"role": "user", "content": final_prompt}
history.append(temp)
else: else:
history = none_request # --- tryb NONE: nie dotykamy pamięci i pozwalamy przekazać własny 'none_request' ---
logger.info("Rozmiar zapytania po wyslaniu %s", chat_gpt_config_request_size) if isinstance(none_request, list):
history_msgs = none_request
elif isinstance(none_request, str) and none_request.strip():
history_msgs = [{"role": "user", "content": none_request}]
else:
history_msgs = [{"role": "user", "content": f"{username}:{prompt}"}]
logger.info("Rozmiar zapytania (tok): %s", "n/a") # tokeny już policzone wyżej
try: try:
# ...przygotowanie messages/system prompt/itp. jak masz... # ...przygotowanie messages/system prompt/itp. jak masz...
# retry/backoff + deadline (zachowuje Twoją semantykę logowania) # retry/backoff + deadline (zachowuje Twoją semantykę logowania)
timeout_sec = 120 timeout_sec = 120
deadline = time.time() + timeout_sec deadline = time.time() + timeout_sec
response = await asyncio.wait_for( response = await asyncio.wait_for(
_openai_call(messages=history, model=algorithm), openai_call(messages=history_msgs, model=model_to_use),
timeout=max(0.1, deadline - time.time()) timeout=max(0.1, deadline - time.time()),
) )
except openai.APITimeoutError as e: except openai.APITimeoutError as e:
@@ -264,26 +267,33 @@ async def handle_response(
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}" response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Nie mogę się połączyć z Openai. *Na ekranie pojawia się*: {e}"
except openai.BadRequestError as e: except openai.BadRequestError as e:
# Handle invalid request error, e.g. validate parameters or log # Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response( if internal_retry:
f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'", resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
True, else:
True, resp, _ = await handle_response(
MESSAGE_TABLE,
username, f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
"RANDOM", True,
) True,
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" MESSAGE_TABLE,
username,
"RANDOM",
)
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
except openai.APIResponseValidationError as e: except openai.APIResponseValidationError as e:
# Handle invalid request error, e.g. validate parameters or log # Handle invalid request error, e.g. validate parameters or log
resp, _ = await handle_response( if internal_retry:
f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'", resp = "Nie umiem tego teraz ładnie wytłumaczyć — OpenAI mnie zastrzeliło."
True, else:
True, resp, _ = await handle_response(
MESSAGE_TABLE, f"Wytlumacz jakie sa zasady dotyczące treści które możesz generować używając Dalle. Wytłumacz błąd {e} prostym językiem. Przeproś za nadmierną cenzurę. Wytłumacz co mogło być nie tak w prompcie 'prompt'",
username, True,
"RANDOM", True,
) MESSAGE_TABLE,
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}" username,
"RANDOM",
)
response = f"Sorki, cenzura: {resp}. Jak chcesz to są kanały na nudle #sexy-foteczky i #kanal-do-fapania *Na ekranie pojawia się: {e}"
except openai.AuthenticationError as e: except openai.AuthenticationError as e:
# Handle authentication error, e.g. check credentials or log # Handle authentication error, e.g. check credentials or log
response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}" response = f"*Kondziu patrzy na terminal, chwile się zastanawia. Przypierdala w niego pięścią....* Wołaj szefa - coś się z hasłem zjebało. *Na terminalu pojawia się:* {e}"
@@ -299,43 +309,31 @@ async def handle_response(
response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}" response = f"*Kondziu nurkuje za bar, terminal wybucha. Przed tobą ląduje pergamin zapisany pięknym gotykiem a na nim*: {e}"
logger.info("Historia wysłana:") logger.info("Historia wysłana:")
logger.info(history) temp_assistant = {"role": "assistant", "content": response}
await asyncio.sleep(15) logger.info(temp_assistant)
temp = {"role": "assistant", "content": response}
history.append(temp)
if request_type == "MUSIC": if request_type == "MUSIC":
with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as file_music_memory: # zapis do pliku MUZYKA
# First we load existing data into a dict. with open(MEMORY_FIVE_MUZYKA, "r+", encoding=ENCODING) as fh:
file_data = json.load(file_music_memory) file_data = json.load(fh)
# Join new_data with file_data inside emp_details file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp) file_data.append(temp_assistant)
file_music_memory.seek(0) fh.seek(0)
# convert back to json. json.dump(file_data, fh, indent=4)
json.dump(file_data, file_music_memory, indent=4)
return response, MESSAGE_TABLE_MUZYKA return response, MESSAGE_TABLE_MUZYKA
elif request_type == "RANDOM":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: elif request_type in ("RANDOM", "GENERAL"):
# First we load existing data into a dict. with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as fh:
file_data = json.load(file_memory) file_data = json.load(fh)
# Join new_data with file_data inside emp_details file_data.append({"role": "user", "content": f"{username}:{prompt}"})
file_data.append(temp) file_data.append(temp_assistant)
file_memory.seek(0) fh.seek(0)
# convert back to json. json.dump(file_data, fh, indent=4)
json.dump(file_data, file_memory, indent=4)
return response, MESSAGE_TABLE return response, MESSAGE_TABLE
elif request_type == "GENERAL":
with open(MEMORY_FIVE_SIARA, "r+", encoding=ENCODING) as file_memory: else: # NONE
# First we load existing data into a dict.
file_data = json.load(file_memory)
# Join new_data with file_data inside emp_details
file_data.append(temp)
file_memory.seek(0)
# convert back to json.
json.dump(file_data, file_memory, indent=4)
return response, MESSAGE_TABLE
else:
return response, [] return response, []
async def get_random_cyclic_message(client): async def get_random_cyclic_message(client):
""" """
The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic The function `get_random_cyclic_message` returns a random cyclic message from a list of cyclic
@@ -438,7 +436,7 @@ async def chat_with_assistant(message, assistant_name):
logger.info(block.text.value) logger.info(block.text.value)
chat_response += block.text.value chat_response += block.text.value
await discord_friendly_send(message.channel, chat_response) await discord_friendly_send(message.channel, chat_response)
#await message.channel.send(chat_response) # await message.channel.send(chat_response)
done = True done = True
elif run.status == "cancelled": elif run.status == "cancelled":
await discord_friendly_send(message.channel, "Cos sie wywaliło") await discord_friendly_send(message.channel, "Cos sie wywaliło")
-2579
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -45,7 +45,7 @@
], ],
[ [
"compress_release5", "compress_release5",
170.0 120.0
], ],
[ [
"compress_attack5", "compress_attack5",
@@ -69,7 +69,7 @@
], ],
[ [
"compress_release4", "compress_release4",
180.0 130.0
], ],
[ [
"compress_attack4", "compress_attack4",
@@ -81,7 +81,7 @@
], ],
[ [
"compress_gain3", "compress_gain3",
8.2 5.5
], ],
[ [
"compress_ratio3", "compress_ratio3",
@@ -93,7 +93,7 @@
], ],
[ [
"compress_release3", "compress_release3",
180.0 140.0
], ],
[ [
"compress_attack3", "compress_attack3",
@@ -105,7 +105,7 @@
], ],
[ [
"compress_gain2", "compress_gain2",
7.4 3.3
], ],
[ [
"compress_ratio2", "compress_ratio2",
@@ -117,7 +117,7 @@
], ],
[ [
"compress_release2", "compress_release2",
190.0 120.0
], ],
[ [
"compress_attack2", "compress_attack2",
@@ -129,7 +129,7 @@
], ],
[ [
"compress_gain1", "compress_gain1",
11.1 5.6
], ],
[ [
"compress_ratio1", "compress_ratio1",
@@ -137,11 +137,11 @@
], ],
[ [
"compress_threshold1", "compress_threshold1",
-12.7 -12.2
], ],
[ [
"compress_release1", "compress_release1",
170.0 120.0
], ],
[ [
"compress_attack1", "compress_attack1",
@@ -153,7 +153,7 @@
], ],
[ [
"compress_gain0", "compress_gain0",
16.0 7.5
], ],
[ [
"compress_ratio0", "compress_ratio0",
@@ -161,15 +161,15 @@
], ],
[ [
"compress_threshold0", "compress_threshold0",
-15.3 -13.1
], ],
[ [
"compress_release0", "compress_release0",
200.0 110.0
], ],
[ [
"compress_attack0", "compress_attack0",
90.0 140.0
], ],
[ [
"compress_frequency0", "compress_frequency0",
@@ -185,11 +185,11 @@
], ],
[ [
"g", "g",
9.5 1.0
], ],
[ [
"f", "f",
64.2 106.4
] ]
] ]
} }
+3 -1
View File
@@ -150,4 +150,6 @@ LATEX_MAX_ZIP_MB = 25
OPENAI_MODEL = "gpt-4o-mini" OPENAI_MODEL = "gpt-4o-mini"
ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"] ALLOWED_ROLES = ["Nocna Zmiana", "Jarl", "Thane", "Bartender"]
GUILD_ID = 664789470779932693 GUILD_ID = 664789470779932693
LATEST_MODEL = "gpt-4o" # najnowszy/do rozmów (możesz zmienić w jednym miejscu)
CHEAP_MODEL = "gpt-4o-mini" # najtańszy (fallback do 3.5 niżej)
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
exists() { command -v "$1" >/dev/null 2>&1; }
echo "[i] Checking environment..."
for c in fc-list pdffonts tectonic; do
if ! exists "$c"; then
echo " - $c: NOT FOUND (optional but recommended: sudo apt install -y fontconfig poppler-utils tectonic)"
else
echo " - $c: OK"
fi
done
echo "[i] Listing local ./fonts content:"
ls -l ./fonts 2>/dev/null || echo " (no ./fonts directory)"
if exists fc-list; then
echo "[i] System fonts (grep Garamond|Cinzel|FELL):"
fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none found in system)"
fi
pdfs=(dj_cheat_sheet_transitions.pdf dj_tracklist.pdf dj_mini_sheet.pdf dj_notes.pdf dj_setup_mapping.pdf dj_one_laptop_fallback.pdf dj_rider.pdf dj_podrecznik.pdf)
if exists pdffonts; then
for p in "${pdfs[@]}"; do
[ -f "$p" ] || continue
echo "[i] Fonts embedded in $p:"
pdffonts "$p" || true
done
else
echo "[!] pdffonts not installed; cannot list embedded fonts."
fi
echo "[i] Grepping last Tectonic log (if any .log files exist)..."
logs=$(ls -1 *.log 2>/dev/null || true)
if [ -n "$logs" ]; then
grep -Ei "fontspec|warning|not found" *.log || echo " (no relevant warnings)"
else
echo " (no .log files)"
fi
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# System-wide install & verify (Debian/RPi):
# - EB Garamond (APT)
# - Cinzel Decorative Black (RAW TTF)
# - IM Fell English SC (gstatic/RAW)
# No TeX build here. Very verbose + clear final report.
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing tool: $1"; }
fetch(){
local url="$1" out="$2"
if command -v wget >/dev/null 2>&1; then
log "wget -> $url"
wget -O "$out" --https-only --no-verbose "$url"
else
log "curl -> $url"
curl -L --fail --show-error --output "$out" "$url"
fi
}
# ---- detection using fc-list families (robust, no PCRE) ----
fc_families_lower() {
fc-list -f '%{family}\n' \
| tr ',' '\n' \
| sed 's/^[[:space:]]*//; s/[[:space:]]*$//' \
| tr '[:upper:]' '[:lower:]' \
| sort -u
}
has_family_re() { # $1 = POSIX ERE anchored-at-start pattern (lowercase)
fc_families_lower | grep -Eiq "$1"
}
# ---- prereqs ----
for t in fc-cache fc-list grep sed awk; do need "$t"; done
command- v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget or curl"
SYSTEM_DIR="/usr/local/share/fonts/dj"
DIR_CINZEL="$SYSTEM_DIR/cinzel-decorative"
DIR_IMFELL="$SYSTEM_DIR/im-fell-english-sc"
$SUDO mkdir -p "$DIR_CINZEL" "$DIR_IMFELL"
echo "=== APT phase (EB Garamond) ==="
if command -v apt-get >/dev/null 2>&1; then
$SUDO apt-get update -y || warn "apt update failed (continuing)"
if $SUDO apt-get install -y --no-install-recommends fonts-ebgaramond; then
log "installed: fonts-ebgaramond"
else
warn "apt install failed: fonts-ebgaramond"
fi
else
warn "apt-get not found — skipping"
fi
echo
log "Families BEFORE web fallback (sample grep):"
fc-list | grep -Ei "Garamond|Cinzel|Fell" || echo " (none)"
echo
echo "=== Web fallback (system-wide to $SYSTEM_DIR) ==="
# --- Cinzel Decorative Black ---
CINZEL_TTF="$DIR_CINZEL/CinzelDecorative-Black.ttf"
if ! has_family_re '^cinzel decorative( |$)'; then
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$CINZEL_TTF" || warn "Cinzel Decorative Black download failed"
[ -s "$CINZEL_TTF" ] && $SUDO chmod 0644 "$CINZEL_TTF"
else
log "Cinzel Decorative already present."
fi
# --- IM Fell English SC ---
IMFELL_TTF="$DIR_IMFELL/IMFeENsc.ttf"
if ! has_family_re '^im fell english sc( |$)'; then
# Try known gstatic TTFs (stable direct links)
for u in \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from gstatic OK"; break; }
fi
done
# Secondary RAW in google/fonts (names changed over time)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" \
"https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf"
do
if fetch "$u" "$IMFELL_TTF"; then
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from google/fonts RAW OK"; break; }
fi
done
fi
# Minimal mirror fallback (only if naprawdę trzeba)
if [ ! -s "$IMFELL_TTF" ]; then
for u in \
"https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" \
"https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf"
do
fetch "$u" "$IMFELL_TTF" || true
[ -s "$IMFELL_TTF" ] && { $SUDO chmod 0644 "$IMFELL_TTF"; log "IM Fell SC from mirror OK"; break; }
done
fi
[ -s "$IMFELL_TTF" ] || warn "IM Fell English SC still missing."
else
log "IM Fell English SC already present."
fi
echo
log "Fix perms & rebuild cache..."
$SUDO find "$SYSTEM_DIR" -type f -size 0 -print -delete || true
$SUDO find "$SYSTEM_DIR" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$SYSTEM_DIR" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || warn "fc-cache returned non-zero"
# ---- FINAL REPORT ----
echo
log "FINAL STATUS (fc-list families & file presence)"
families="$(fc_families_lower)"
report(){
local label="$1" fam_re="$2" file_hint="$3"
local fam_ok file_ok="n/a"
if echo "$families" | grep -Eiq "$fam_re"; then fam_ok="OK"; else fam_ok="MISSING"; fi
if [ -n "$file_hint" ]; then
if [ -s "$file_hint" ]; then file_ok="yes"; else file_ok="no"; fi
fi
printf " %-22s : families=%-8s file=%-3s (%s)\n" "$label" "$fam_ok" "$file_ok" "${file_hint:-no-file-hint}"
}
report "EB Garamond" '^eb garamond( |$)' "" # from APT
report "Cinzel Decorative" '^cinzel decorative( |$)' "$CINZEL_TTF"
report "IM Fell English SC" '^im fell english sc( |$)' "$IMFELL_TTF"
echo
log "Done. (Run your LaTeX pipeline next.)"
-40
View File
@@ -1,40 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing: $1" >&2; exit 1; }; }
echo "[+] Checking tools..."
need wget
need unzip
need tectonic || { echo "Install tectonic first (e.g. sudo apt install tectonic)"; exit 1; }
mkdir -p fonts
echo "[+] Downloading EB Garamond (Initials) to ./fonts ..."
tmpzip="/tmp/ebgaramond.zip"
wget -q -O "$tmpzip" https://github.com/octaviopardo/EBGaramond/releases/download/v0.016/EBGaramond-ttf.zip
unzip -jq "$tmpzip" "*Initials*.ttf" -d fonts
ls -1 fonts | grep -i initials || echo "WARN: EBGaramond Initials not found in zip?"
echo "[+] Downloading Cinzel Decorative (Black) to ./fonts ..."
tmpzip="/tmp/cinzel.zip"
wget -q -O "$tmpzip" "https://fonts.google.com/download?family=Cinzel%20Decorative" || true
unzip -jq "$tmpzip" "*Decorative-Black*.ttf" -d fonts || echo "WARN: Cinzel Decorative Black not found; continuing."
echo "[+] (Optional) Downloading IM FELL English SC to ./fonts ..."
tmpzip="/tmp/imfell.zip"
wget -q -O "$tmpzip" "https://fonts.google.com/download?family=IM%20Fell%20English%20SC" || true
unzip -jq "$tmpzip" "*.ttf" -d fonts || echo "INFO: IM FELL SC optional; skip if not needed."
echo "[+] Building PDFs with Tectonic..."
for f in dj_cheat_sheet_transitions.tex dj_tracklist.tex dj_mini_sheet.tex dj_notes.tex dj_setup_mapping.tex dj_one_laptop_fallback.tex dj_rider.tex dj_podrecznik.tex; do
if [ -f "$f" ]; then
echo " -> $f"
tectonic "$f" >/dev/null
else
echo "SKIP: $f not found"
fi
done
echo "[+] Done. Generated PDFs:"
ls -1 *.pdf 2>/dev/null || true
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# "apt dla fontów": Google Fonts ZIP → GitHub (ofl RAW) → fonts.gstatic.com → mirrory.
# System-wide install do /usr/local/share/fonts/fontget/<rodzina> + fc-cache.
# Usage: sudo ./fontget.sh "IM Fell English SC" "Cinzel Decorative" "EB Garamond"
set -Eeuo pipefail
SUDO=sudo; [ "$(id -u)" -eq 0 ] && SUDO=
log(){ echo -e "[+] $*"; }
warn(){ echo -e "[WARN] $*" >&2; }
die(){ echo -e "[FATAL] $*" >&2; exit 1; }
need(){ command -v "$1" >/dev/null 2>&1 || die "Missing: $1"; }
need fc-cache
command -v wget >/dev/null 2>&1 || command -v curl >/dev/null 2>&1 || die "Need wget/curl"
command -v unzip >/dev/null 2>&1 || warn "unzip missing — ZIP installs limited"
command -v file >/dev/null 2>&1 || warn "file missing — MIME checks limited"
command -v fc-list >/dev/null 2>&1 || die "Missing: fc-list"
fetch(){ local u="$1" o="$2"; if command -v wget >/dev/null 2>&1; then wget -O "$o" --https-only --no-verbose "$u"; else curl -L --fail --show-error --output "$o" "$u"; fi; }
slug(){ echo "$1" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g'; }
present(){ fc-list -f '%{family}\n' | tr ',' '\n' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]' | sort -u | grep -Eiq "$1"; }
install_dir="/usr/local/share/fonts/fontget"; $SUDO mkdir -p "$install_dir"
is_zip(){ command -v file >/dev/null 2>&1 && file "$1" | grep -qi zip; }
gf_zip_try(){
local name="$1" outdir="$2" enc zip
enc="$(python3 - <<PY
import urllib.parse,sys
print(urllib.parse.quote(sys.argv[1]))
PY
"$name")" || enc="$(echo "$name" | sed 's/ /%20/g')"
zip="$(mktemp /tmp/fontget.XXXXXX.zip)"
if fetch "https://fonts.google.com/download?family=${enc}" "$zip"; then
if is_zip "$zip"; then $SUDO mkdir -p "$outdir"; $SUDO unzip -o -q "$zip" -d "$outdir" || true; rm -f "$zip"; return 0; fi
fi
rm -f "$zip" || true; return 1
}
gh_ofl_try(){
local family="$1" outdir="$2" html
html="$(mktemp /tmp/ofl.$(slug "$family").html)"
if fetch "https://github.com/google/fonts/tree/main/ofl/$(slug "$family")" "$html"; then
mapfile -t paths < <(grep -Eo '/google/fonts/blob/main/ofl/'"$(slug "$family")"'/[^"]+\.(ttf|otf)' "$html" | sed 's|/blob/|/raw/|g' | sort -u)
[ "${#paths[@]}" -gt 0 ] || { rm -f "$html"; return 1; }
$SUDO mkdir -p "$outdir"
for p in "${paths[@]}"; do fetch "https://github.com$p" "$outdir/$(basename "$p")" || true; done
rm -f "$html"; return 0
fi
rm -f "$html"; return 1
}
gstatic_try(){ local out="$2"; shift 2; for u in "$@"; do fetch "$u" "$out" && [ -s "$out" ] && return 0; done; return 1; }
report(){ local label="$1" re="$2"; if present "$re"; then printf "RESULT: %-24s : OK\n" "$label"; else printf "RESULT: %-24s : FAIL\n" "$label"; fi }
if [ $# -lt 1 ]; then echo "Usage: $0 <Font Family> [Another ...]"; exit 1; fi
for family in "$@"; do
echo; log "=== $family ==="
fam_slug="$(slug "$family")"
outdir="$install_dir/$fam_slug"
$SUDO mkdir -p "$outdir"
case "$fam_slug" in
cinzel-decorative|cinzel|cinzel-decorative-black)
fetch "https://github.com/google/fonts/raw/main/ofl/cinzeldecorative/CinzelDecorative-Black.ttf" "$outdir/CinzelDecorative-Black.ttf" || warn "Cinzel fetch failed"
;;
im-fell-english-sc)
gstatic_try "$fam_slug" "$outdir/IMFeENsc.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v7/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
"https://fonts.gstatic.com/s/imfellenglishsc/v6/h3Tn6yWfw4b5qaLD1RWvz5ATixNthKRRR1XVH3rJNiw.ttf" \
|| { fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://github.com/google/fonts/raw/main/ofl/imfellenglishsc/IMFellEnglishSC-Regular.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.wfonts.com/download/data/2016/06/14/im-fell-english-sc/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true
[ -s "$outdir/IMFeENsc.ttf" ] || fetch "https://www.1001freefonts.com/d/6800/IMFeENsc28P.ttf" "$outdir/IMFeENsc.ttf" || true; }
;;
eb-garamond|ebgaramond)
# Najpierw apt (jeśli chcesz: sudo apt install fonts-ebgaramond), tu tylko web:
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
*)
gf_zip_try "$family" "$outdir" || gh_ofl_try "$family" "$outdir" || true
;;
esac
$SUDO find "$outdir" -type f -size 0 -print -delete || true
$SUDO find "$outdir" -type f \( -name "*.ttf" -o -name "*.otf" \) -exec chmod 0644 {} \; || true
$SUDO find "$outdir" -type d -exec chmod 0755 {} \; || true
$SUDO fc-cache -f -v >/dev/null || true
case "$fam_slug" in
cinzel* ) report "Cinzel Decorative" '^cinzel decorative( |$)' ;;
im-fell-english-sc ) report "IM Fell English SC" '^im fell english sc( |$)' ;;
eb-garamond* ) report "EB Garamond" '^eb garamond( |$)' ;;
* ) report "$family" "$(echo "$family" | tr '[:upper:]' '[:lower:]' | sed 's/ /.* /g')" ;;
esac
done
echo; log "All done."
Binary file not shown.
View File
View File
+14
View File
@@ -0,0 +1,14 @@
from flask import Flask
from file_serv import bp as uploader_bp
from ingest import bp as ingest_bp
from waitress import serve
app = Flask(__name__)
HOST_ADDRESS = "127.0.0.1"
PORT_ADDRESS = 49151
if __name__ == "__main__":
app.register_blueprint(uploader_bp, url_prefix="/api")
app.register_blueprint(ingest_bp, url_prefix="/api")
serve(app, host=HOST_ADDRESS, port=PORT_ADDRESS)
+155
View File
@@ -0,0 +1,155 @@
# -*- coding: utf-8 -*-
from __future__ import annotations
import os
from pathlib import Path
from typing import List, Dict, Any, Optional
from flask import Blueprint, request, jsonify, send_file, abort, current_app
from werkzeug.utils import secure_filename
# ===== Konfiguracja przez ENV =====
if API_KEY := os.getenv("API_KEY") is None:
with open("/home/pi/gpt_cont_api_key", "r") as f:
API_KEY = f.read().strip()
else:
API_KEY = os.getenv("API_KEY", "") # np. openssl rand -hex 32
UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/home/pi/tmp_git/")) # katalog na dysku
MAX_CONTENT_MB = int(os.getenv("MAX_CONTENT_MB", "200"))
# Google Drive (opcjonalnie)
GDRIVE_ENABLE = os.getenv("GDRIVE_ENABLE", "0") == "1"
GDRIVE_SA_JSON = os.getenv("GDRIVE_SA_JSON", "") # ścieżka do pliku .json konta serwisowego
GDRIVE_FOLDER_ID = os.getenv("GDRIVE_FOLDER_ID", "") # ID folderu na Drive
bp = Blueprint("uploader", __name__)
# Inicjalizacja katalogu
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
def _check_auth() -> bool:
"""Proste Bearer auth. Jeśli API_KEY puste -> bez auth (niezalecane)."""
if not API_KEY:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "):
token = auth[7:].strip()
return token == API_KEY
return False
def _safe_join(base: Path, *parts: str) -> Path:
"""Zapobiega ../ — wymusza pozostanie w katalogu bazowym."""
p = (base.joinpath(*parts)).resolve()
if not str(p).startswith(str(base.resolve())):
abort(400, description="Invalid path")
return p
# ===== Google Drive helper =====
_drive_client_cached = None
def _get_drive():
global _drive_client_cached
if _drive_client_cached is not None:
return _drive_client_cached
if not (GDRIVE_ENABLE and GDRIVE_SA_JSON and os.path.exists(GDRIVE_SA_JSON)):
return None
from google.oauth2 import service_account
from googleapiclient.discovery import build
scopes = ["https://www.googleapis.com/auth/drive.file"]
creds = service_account.Credentials.from_service_account_file(GDRIVE_SA_JSON, scopes=scopes)
_drive_client_cached = build("drive", "v3", credentials=creds, cache_discovery=False)
return _drive_client_cached
def upload_to_drive(local_path: Path, filename: str) -> Optional[Dict[str, Any]]:
drv = _get_drive()
if drv is None:
return None
from googleapiclient.http import MediaFileUpload
file_metadata = {"name": filename}
if GDRIVE_FOLDER_ID:
file_metadata["parents"] = [GDRIVE_FOLDER_ID]
media = MediaFileUpload(str(local_path), resumable=False)
created = drv.files().create(body=file_metadata, media_body=media, fields="id,webViewLink,webContentLink").execute()
file_id = created.get("id")
# Przydatne linki:
return {
"file_id": file_id,
"webViewLink": created.get("webViewLink"),
"webContentLink": created.get("webContentLink"),
"direct_view": f"https://drive.google.com/file/d/{file_id}/view",
"direct_download": f"https://drive.google.com/uc?export=download&id={file_id}",
}
@bp.get("/health")
def health():
return jsonify(ok=True, info="Okidokie")
@bp.post("/upload")
def upload():
"""Przyjmuje:
- multipart/form-data z jednym plikiem ('file') lub wieloma ('files')
- opcjonalnie: form 'subdir' (podkatalog), 'drive' (1/0) żeby wymusić wysyłkę na Drive
"""
if not _check_auth():
return jsonify(error="Unauthorized"), 401
# Limit ciała żądania po stronie Flaska:
request.max_content_length = MAX_CONTENT_MB * 1024 * 1024
files = []
if "file" in request.files:
files = [request.files["file"]]
elif "files" in request.files:
files = request.files.getlist("files")
else:
return jsonify(error="No file provided (use 'file' or 'files')"), 400
subdir = (request.form.get("subdir") or "").strip()
target_dir = _safe_join(UPLOAD_DIR, subdir) if subdir else UPLOAD_DIR
target_dir.mkdir(parents=True, exist_ok=True)
want_drive = (request.form.get("drive") == "1") or (request.args.get("drive") == "1")
saved: List[Dict[str, Any]] = []
for f in files:
if not f or not f.filename:
continue
fname = secure_filename(f.filename)
dest = _safe_join(target_dir, fname)
f.save(dest)
item = {
"filename": fname,
"size": dest.stat().st_size,
"path": str(dest.relative_to(UPLOAD_DIR)),
"url_hint": f"/api/files/{dest.relative_to(UPLOAD_DIR)}",
}
if want_drive and GDRIVE_ENABLE:
try:
gd = upload_to_drive(dest, fname)
if gd:
item["gdrive"] = gd
except Exception as e:
# log i idziemy dalej
current_app.logger.exception("Drive upload failed: %s", e)
item["gdrive_error"] = str(e)
saved.append(item)
if not saved:
return jsonify(error="No valid files"), 400
return jsonify(ok=True, saved=saved)
@bp.get("/files/<path:relpath>")
def get_file(relpath: str):
if not _check_auth():
return jsonify(error="Unauthorized"), 401
target = _safe_join(UPLOAD_DIR, relpath)
if not target.exists() or not target.is_file():
return jsonify(error="Not found"), 404
return send_file(target, as_attachment=False)
+52
View File
@@ -0,0 +1,52 @@
# ingest_text.py
import os
import urllib.parse
from pathlib import Path
from flask import Blueprint, abort, jsonify, request
bp = Blueprint("ingest_text", __name__)
BASE = Path(os.getenv("INGEST_DIR", "/home/pi/tmp_git")).resolve()
BASE.mkdir(parents=True, exist_ok=True)
TOKEN = None
with open("/home/pi/gpt_cont_api_key", "r") as f:
TOKEN = f.read().strip()
# prosty bufor kawałków w RAM (na 1 proces)
chunks = {}
@bp.get("/api/ingest-text")
def ingest_text():
if request.args.get("key") != TOKEN:
return jsonify(error="unauthorized"), 401
name = request.args.get("name", "").strip()
index = int(request.args.get("index", "1"))
total = int(request.args.get("total", "1"))
chunk = request.args.get("chunk", "")
if not name or "/" in name or ".." in name:
return jsonify(error="bad name"), 400
if not (1 <= index <= total <= 9999):
return jsonify(error="bad indexing"), 400
# gromadzimy w pamięci (możesz podmienić na Redis)
key = f"{name}:{total}"
entry = chunks.setdefault(key, {})
entry[index] = urllib.parse.unquote_plus(chunk)
if TOKEN is None:
exit("No API token set!")
if len(entry) == total:
# składamy i zapisujemy
data = "".join(entry[i] for i in range(1, total + 1))
out = (BASE / name).resolve()
if not str(out).startswith(str(BASE)):
return jsonify(error="bad path"), 400
out.write_text(data, encoding="utf-8")
del chunks[key]
return jsonify(ok=True, saved=str(out), bytes=len(data.encode("utf-8")))
else:
return jsonify(pending=True, got=len(entry), total=total)
+2
View File
@@ -0,0 +1,2 @@
#!/bin/bash
cd ../conjurer/ && git pull && cp ./gpt_interface/* ../gpt_interf_serv/ && cd -
+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.)"
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=files_to_send,
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.)"
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=files_to_send,
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")
+121 -175
View File
@@ -1,149 +1,121 @@
# 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
# ===== Bezpieczeństwo nazw/rozszerzeń =====
SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE) SAFE_TEX_NAME = re.compile(r"^[\w\-. ]+\.tex$", re.IGNORECASE)
SAFE_ASSET_NAME = re.compile(
r"^[\w\-. /]+?\.(tex|png|jpg|jpeg|pdf|svg|eps|bmp|gif|sty|cls|bib|bst|bbx|cbx|def|cfg)$",
re.IGNORECASE,
)
def is_safe_tex_name(name: str) -> bool: def is_safe_tex_name(name: str) -> bool:
return bool(SAFE_TEX_NAME.match(name or "")) and name.lower().endswith(".tex") return bool(SAFE_TEX_NAME.match(name or ""))
def is_safe_asset_name(name: str) -> bool:
return bool(SAFE_ASSET_NAME.match(name or ""))
def is_tex_attachment(att, max_mb: int) -> bool: # ===== Uruchamianie kompilatora =====
"""Filtruje discord.Attachment: tylko .tex + limit MB + nazwa bezpieczna."""
return bool( async def _run(cmd: List[str], cwd: Path, timeout_sec: int, logger_name: Optional[str]) -> Tuple[int, str]:
getattr(att, "filename", "") """Uruchamia proces kompilatora w cwd, zwraca (rc, sklejone_stdout_stderr)."""
and is_safe_tex_name(att.filename) logger = logging.getLogger(logger_name) if logger_name else logging.getLogger()
and getattr(att, "size", 0) <= max_mb * 1024 * 1024 logger.debug("RUN %s (cwd=%s, timeout=%s)", " ".join(cmd), cwd, timeout_sec)
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]:
tex_engine: str, """
timeout_sec: int, Uruchamia kompilację 2 razy (bezpieczeństwo referencji/bib).
logger: str = None, Zwraca: (ok, log_text, pdf_path).
): """
logger = logging.getLogger(logger) if logger else None logger_obj = logging.getLogger(logger) if logger else logging.getLogger()
logger_obj.info("Compiling %s with %s engine two passes", tex_filename, tex_engine)
async def run_once(cmd: list[str]) -> tuple[int, str]: logs: List[str] = []
proc = await asyncio.create_subprocess_exec( pdf_path = workdir / (Path(tex_filename).stem + ".pdf")
*cmd,
cwd=str(workdir),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
try:
out_b, _ = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec)
return (proc.returncode or 0), (out_b or b"").decode(
"utf-8", errors="replace"
)
except asyncio.TimeoutError:
try:
proc.kill()
except Exception:
raise
return 124, f"[Timeout] Compilation exceeded {timeout_sec}s.\n"
logs = []
cmd = _build_cmd(tex_engine, tex_filename)
if logger:
logger.debug("LaTeX cmd: %s (cwd=%s)", " ".join(cmd), workdir)
rc1, out1 = await run_once(cmd)
logs.append(out1)
second_pass = not ((tex_engine or "").strip().lower() == "tectonic")
# PASS 1
rc1, log1 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(log1)
if rc1 != 0: if rc1 != 0:
log_text = "".join(logs) return False, "".join(logs), pdf_path
pdf = workdir / (Path(tex_filename).stem + ".pdf")
return False, log_text, pdf
if second_pass: # PASS 2 (często już nic nie robi, ale nie szkodzi)
rc2, out2 = await run_once(cmd) rc2, log2 = await _run(_build_cmd(tex_engine, tex_filename), workdir, timeout_sec, logger)
logs.append(out2) logs.append(log2)
ok = rc2 == 0 ok = (rc2 == 0) and pdf_path.exists()
else: return ok, "".join(logs), pdf_path
ok = rc1 == 0
log_text = "".join(logs)
pdf = workdir / (Path(tex_filename).stem + ".pdf")
ok = ok and pdf.exists()
return ok, log_text, pdf
# ===== Kompilacje: pojedynczy TEX + ZIP =====
async def compile_single_tex_bytes( async def compile_single_tex_bytes(
tex_bytes: bytes, tex_bytes: bytes,
filename: str, 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,
) -> Dict[str, Optional[object]]: ) -> 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 single tex", filename, tex_engine)
if not is_safe_tex_name(filename): if not is_safe_tex_name(filename):
return { return {"ok": False, "pdf_bytes": None, "log_text": "Invalid .tex filename", "pdf_name": None}
"ok": False,
"pdf_bytes": None,
"log_text": "Invalid .tex filename",
"pdf_name": None,
}
with tempfile.TemporaryDirectory(prefix="latex_one_") as td: with tempfile.TemporaryDirectory(prefix="latex_one_") as td:
wd = Path(td) wd = Path(td)
tex_path = wd / filename (wd / Path(filename).name).write_bytes(tex_bytes)
tex_path.write_bytes(tex_bytes)
ok, log_text, pdf_path = await run_latex_two_passes( # Zapisz wszystkie supporty obok głównego pliku
filename, wd, tex_engine, timeout_sec, logger=logger if support_files:
) for fname, data in support_files:
if not fname or not is_safe_asset_name(fname):
continue
# spłaszczamy do nazwy bazowej (bez podkatalogów z zewn. źródeł)
(wd / Path(fname).name).write_bytes(data)
ok, log_text, pdf_path = await run_latex_two_passes(Path(filename).name, wd, tex_engine, timeout_sec, logger)
log.info("LaTeX compile finished: %s", "OK" if ok else "FAIL")
log.info("Log text:\n%s", log_text[-2000:]) # Log last 2000 chars
log.info("PDF path: %s", pdf_path)
if ok and pdf_path.exists(): if ok and pdf_path.exists():
return { return {
"ok": True, "ok": True,
@@ -151,83 +123,57 @@ async def compile_single_tex_bytes(
"log_text": log_text, "log_text": log_text,
"pdf_name": pdf_path.name, "pdf_name": pdf_path.name,
} }
return { return {"ok": False, "pdf_bytes": None, "log_text": log_text, "pdf_name": pdf_path.name}
"ok": False,
"pdf_bytes": None,
"log_text": log_text,
"pdf_name": pdf_path.name,
}
async def compile_zip_to_zip( async def compile_zip_to_zip(
zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: str = None zip_bytes: bytes, tex_engine: str, timeout_sec: int, logger: Optional[str] = None
) -> Tuple[bytes, List[str]]: ) -> 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] = [] failed: List[str] = []
out_pdf_paths: List[Path] = [] out_pdf_paths: List[Path] = []
with tempfile.TemporaryDirectory(prefix="latex_zip_") as td: with tempfile.TemporaryDirectory(prefix="latex_zip_") as td:
td_path = Path(td) td_path = Path(td)
in_zip = td_path / "in.zip" src_root = td_path / "src"
in_zip.write_bytes(zip_bytes) src_root.mkdir(parents=True, exist_ok=True)
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)] # 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: if not tex_files:
return b"", ["No .tex files found in archive."] return b"", ["No .tex files found in archive."]
for tex in tex_files: # 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:
work = td_path / f"build_{tex.stem}" shutil.copytree(src_root, work / "src")
work.mkdir(parents=True, exist_ok=True) ok, log_text, pdf = await run_latex_two_passes(rel_tex, work / "src", tex_engine, timeout_sec, logger)
target_tex = work / tex.name if ok and pdf.exists():
target_tex.write_bytes(tex.read_bytes()) out_pdf_paths.append(pdf)
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: else:
failed.append(tex.name) failed.append(rel_tex)
if logger: log.info("LaTeX FAIL %s\n%s", rel_tex, (log_text or "")[-2000:])
logger.debug("BATCH FAIL %s\n%s", tex.name, log_text[-1500:]) except Exception:
except Exception as e: # let it crash log + re-raise (to zobaczy wyższa warstwa/discord.py)
failed.append(f"{tex.name} (exception: {e})") log.exception("Exception while compiling %s", rel_tex)
if logger:
logger.debug("BATCH EXC %s: %s", tex.name, e)
raise raise
# 4) Spakuj wyjściowe PDF-y
out_zip = td_path / "compiled_pdfs.zip" out_zip = td_path / "compiled_pdfs.zip"
with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf: with zipfile.ZipFile(out_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for pdf in out_pdf_paths: for pdf in out_pdf_paths:
zf.write(pdf, arcname=pdf.name) zf.write(pdf, arcname=pdf.name)
return out_zip.read_bytes(), failed return out_zip.read_bytes(), failed
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