添加修复 Typst 的选项(好像很垃圾)

This commit is contained in:
2026-04-23 02:11:28 +08:00
parent ab1e0875a2
commit dd3f6b4573
4 changed files with 298 additions and 3 deletions

View File

@ -9,6 +9,7 @@
├── templates/ # Typst 模板文件 ├── templates/ # Typst 模板文件
├── scripts/ # Python 脚本 ├── scripts/ # Python 脚本
│ ├── img2typ.py # 图片转 typst 格式 │ ├── img2typ.py # 图片转 typst 格式
│ ├── fix_typ.py # 修复 typst 语法
│ ├── solve.py # AI 答题 │ ├── solve.py # AI 答题
│ ├── gen_index.py # 生成 index.typ │ ├── gen_index.py # 生成 index.typ
│ └── common.py # 共用模块 │ └── common.py # 共用模块
@ -76,14 +77,27 @@ IMG2TYP_MODEL=qwen-vl-plus
### 3. 运行 ### 3. 运行
```bash ```bash
just img2typ # 图片转题目 just img2typ # 图片转题目
just solve # AI 答题 just solve # AI 答题
just generate # 生成 index.typ just generate # 生成 index.typ
typst compile index.typ index.pdf typst compile index.typ index.pdf
``` ```
如需修复 .typ 文件语法(可选):
```bash
just fix
```
## 命令行参数 ## 命令行参数
### fix_typ.py
```bash
-f, --file # 指定单个 .typ 文件
--dry-run # 干跑,不调用 AI 或写入文件
--verbose # 调试日志
-n N # 并发数(默认 3
```
### img2typ.py ### img2typ.py
```bash ```bash
-f, --file # 指定单个图片文件 -f, --file # 指定单个图片文件

View File

@ -1,3 +1,6 @@
fix:
python ./scripts/fix_typ.py
img2typ: img2typ:
python ./scripts/img2typ.py python ./scripts/img2typ.py

View File

@ -0,0 +1,10 @@
你是一个 Typst 格式专家。请修复提供的 Typst 代码,使其符合 Typst 语法规范。
常见的修复点:
1. **数学表达式语法**Typst 中数学表达式使用 `$...$` 而不是 `$$...$$` 或 LaTeX 风格。行内数学用 `$...$`,显示数学用 `$ ... $`(两边有空格)。
2. **函数调用**Typst 函数调用使用圆括号,参数用逗号分隔,如 `func(arg1, arg2)`。
3. **引号**Typst 中字符串使用双引号 `"..."`,不是单引号。
4. **数组和字典**Typst 中数组用 `(...)`,字典用 `(...: ...)`。
5. **逃逸字符**Typst 中 `\` 用于转义,不是 `\\`。
请直接输出修复后的代码,不需要解释。确保输出只包含代码,用 typst 代码块包裹。

268
scripts/fix_typ.py Normal file
View File

@ -0,0 +1,268 @@
#!/usr/bin/env python3
"""
fix_typ.py - Fix Typst files to conform to proper Typst syntax using AI API.
This script scans the data directory for .typ files, uses AI to fix common
syntax issues (e.g., Markdown math syntax), and overwrites the original files.
"""
import argparse
import asyncio
import logging
import sys
from dataclasses import dataclass
from pathlib import Path
import aiohttp
from common import DATA_DIR, console, load_env, load_prompt, setup_logging
@dataclass
class FixResult:
"""Result of fixing a typst file."""
question: str
target: str
skipped: bool
success: bool
error: str | None = None
def find_typ_files() -> list[Path]:
"""Find all .typ files in data directory."""
typ_files = []
for file_path in DATA_DIR.iterdir():
if file_path.is_file() and file_path.suffix == ".typ":
if not file_path.name.startswith("A_"):
typ_files.append(file_path)
return sorted(typ_files)
async def call_api(
session: aiohttp.ClientSession,
typ_content: str,
question_name: str,
endpoint: str,
api_key: str,
model: str,
logger: logging.Logger,
) -> str | None:
"""Call the AI API to fix typst syntax."""
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
prompt = load_prompt("fix_typ.prompt.txt")
if not prompt:
prompt = "请修复以下 Typst 代码的语法错误,使其符合 Typst 规范。特别注意数学表达式不要使用 Markdown/LaTeX 语法。"
full_prompt = f"""{prompt}
需要修复的文件内容:
```
{typ_content}
```
"""
payload = {
"model": model,
"messages": [
{"role": "user", "content": [{"type": "text", "text": full_prompt}]}
],
"max_tokens": 4096,
}
logger.info(f"[{question_name}] Fixing... (timeout 120s)")
try:
async with session.post(
endpoint,
headers=headers,
json=payload,
timeout=aiohttp.ClientTimeout(total=120),
) as response:
response.raise_for_status()
result = await response.json()
if "choices" not in result or len(result["choices"]) == 0:
logger.error(f"Invalid API response")
return None
content = result["choices"][0]["message"]["content"]
usage = result.get("usage", {})
input_tokens = usage.get("prompt_tokens", 0)
output_tokens = usage.get("completion_tokens", 0)
logger.info(
f"[{question_name}] Done: {input_tokens} in, {output_tokens} out"
)
return content
except asyncio.TimeoutError:
logger.error(f"[{question_name}] Timeout")
return None
except asyncio.CancelledError:
logger.warning(f"[{question_name}] Cancelled")
raise
except Exception as e:
logger.error(f"[{question_name}] Error: {e}")
return None
async def fix_typ_file(
session: aiohttp.ClientSession,
typ_path: Path,
api_config: dict,
logger: logging.Logger,
dry_run: bool,
) -> FixResult:
"""Fix a single .typ file."""
question_name = typ_path.stem
if dry_run:
logger.info(f"[{question_name}] Would fix -> {typ_path.name}")
return FixResult(
question=question_name, target=typ_path.name, skipped=False, success=True
)
try:
typ_content = typ_path.read_text(encoding="utf-8")
except IOError as e:
logger.error(f"[{question_name}] Read failed: {e}")
return FixResult(
question=question_name,
target=typ_path.name,
skipped=False,
success=False,
error=str(e),
)
fixed_content = await call_api(
session,
typ_content,
question_name,
str(api_config["endpoint"]),
api_config["key"],
api_config["model"],
logger,
)
if fixed_content is None:
return FixResult(
question=question_name,
target=typ_path.name,
skipped=False,
success=False,
error="API call failed",
)
import re
block_pattern = re.compile(r"```(?:typst)?\s*\n?(.*?)\n?```", re.DOTALL)
matches = list(block_pattern.finditer(fixed_content))
if matches:
fixed_content = matches[0].group(1).strip()
try:
typ_path.write_text(fixed_content, encoding="utf-8")
logger.info(
f"[{question_name}] Wrote {typ_path.name} ({len(fixed_content)} bytes)"
)
return FixResult(
question=question_name, target=typ_path.name, skipped=False, success=True
)
except IOError as e:
logger.error(f"[{question_name}] Write failed: {e}")
return FixResult(
question=question_name,
target=typ_path.name,
skipped=False,
success=False,
error=str(e),
)
def parse_args() -> argparse.Namespace:
"""Parse command line arguments."""
parser = argparse.ArgumentParser(
description="Fix .typ files to conform to Typst syntax using AI API"
)
parser.add_argument(
"-f",
"--file",
action="append",
dest="files",
help="Specific .typ files to fix",
)
parser.add_argument(
"--dry-run", action="store_true", help="Do not call AI or write files"
)
parser.add_argument("--verbose", action="store_true", help="Enable debug logging")
parser.add_argument("-n", type=int, default=3, help="Concurrent limit (default: 3)")
return parser.parse_args()
async def async_main(args: argparse.Namespace, logger: logging.Logger) -> None:
"""Async main entry point."""
if args.files:
typ_paths = []
for f in args.files:
p = Path(f)
if not p.is_absolute():
p = DATA_DIR / f
typ_paths.append(p)
else:
typ_paths = find_typ_files()
logger.info(f"Found {len(typ_paths)} .typ files to fix")
if not typ_paths:
logger.warning("No .typ files found to fix")
return
api_config = load_env()
semaphore = asyncio.Semaphore(args.n)
async def limited_fix(session: aiohttp.ClientSession, typ_path: Path) -> FixResult:
async with semaphore:
return await fix_typ_file(
session, typ_path, api_config, logger, args.dry_run
)
async with aiohttp.ClientSession() as session:
tasks = [asyncio.create_task(limited_fix(session, typ)) for typ in typ_paths]
results = []
try:
for coro in asyncio.as_completed(tasks):
result = await coro
results.append(result)
except asyncio.CancelledError:
logger.warning("Cancelled! Shutting down...")
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
sys.exit(1)
results.sort(key=lambda r: r.question)
skipped = sum(1 for r in results if r.skipped)
fixed = sum(1 for r in results if r.success and not r.skipped)
logger.info(f"Complete: {fixed}/{len(results)} fixed, {skipped} skipped")
def main() -> None:
"""Main entry point."""
args = parse_args()
logger = setup_logging("fix_typ", args.verbose)
logger.info(f"fix_typ starting (Dry-run: {args.dry_run}, Workers: {args.n})")
logger.info(f"Data directory: {DATA_DIR}")
try:
asyncio.run(async_main(args, logger))
except KeyboardInterrupt:
logger.warning("Interrupted by user")
sys.exit(1)
if __name__ == "__main__":
main()