Compare commits

..

8 Commits

9 changed files with 485 additions and 74 deletions

2
.gitignore vendored
View File

@ -1,6 +1,6 @@
# Environment
.env
.env.example
# .env.example is tracked for reference
# Python
__pycache__/

View File

@ -1,66 +0,0 @@
请在 `./scripts/` 文件夹下写一个 `img2typ.py` 脚本,达成下面的要求。
## 流程
遍历当前所在目录下的文件(不深入到子目录)。
找到所有的 `\S\s?[\d\.]+` 的文件名的图片。且最开头的 `\S` 不能是 `答` 或者 `A` 或者 `a`
- 这里只是大概的描述,你可能需要调整正则,或者不使用正则
- 例如,`问 13.png` `Q3.1.jpg` `R1.1.5.PNG` 等等的文件名都是合法的
检测有没有对应的 `.typ` 文件。即,看有没有后缀名改成 `typ` 的文件。
如果没有,则用 `./scripts/img2typ.prompt.txt` 为提示词,调用一个支持图片的 OpenAI 兼容 API。这个提示词文件在未来会改动请在程序执行时动态读取它。API 的 API 端点和 API Key 应该使用一个 `.env` 文件定义,这个文件将会置放在 `./scripts/` 文件夹下。
对输出结果,如果有 Markdown 代码块包裹,则去除(可能需要你写正则或者其他任何机制)
接着,保存为对应的 `.typ` 文件。
最后,整理所有符合条件的 `.typ` 文件(包括生成失败的),整理成一个列表后,在当前根目录写入(可覆盖)`questions.json`,是一个 JSON 列表,列表的每一个项目都是一个 JSON Object。形如
```json
[
{
"question": "Q3.1",
"format": "typst",
"target": "Q3.1.typ"
},
{
"question": "R1.1.5",
"format": "typst",
"target": "R1.1.5.typ"
}
]
```
## 细节
调用 API 时,如果失败,则重试。最多重试 3 次(或者可定义),真的失败了不能 panic只是在 stderr 中汇报。
## 规范
代码应该是人类可维护的。你可以(而且最好)使用 python 的比较常用的现代语法,例如直接的类型注解(使用 `list` 而不是 `typing.List`),以及尽量使用 `pathlib.Path` 而不是字符串。
这个应用是面向过程的。你应该首先对流程做拆解,然后以子函数的形式声明整个函数。每个函数都应该有 docstring。对于 AI 等一些比较重要的东西,你再使用面向对象的方式去应对。
你可以给我要求,让我依赖一些更多的外置库。当前环境有 `requests``dotenv``rich``tqdm` 可用。
程序往 `stderr` 的输出应该是可审计的。应该汇报:
- 将要处理的文件清单。
- 调用了什么 API调用情况如何输入输出多少 tokens。
- 写入了什么文件。
程序往 `stderr` 的输出应该是良好可视化的,就是说,有颜色区分,但是不要加 emoji。或者说你应该使用自带的日志库 + 一定的格式化。
## 额外功能添补
这个脚本应该可以作为 cli 调用,支持以下参数:
- `--file``-f` 后接文件名,可重复这个参数。当存在这个参数,则解析对应的图片,而不是扫描当前目录
- `--dry-run` 不调用 AI也不写入文件
- `--verbose` 在基础上反馈 AI 调用的输出,相当于 log level 是 `DEBUG`
- `--retry` 接数字,重试次数,默认为 3
- `-n` 接数字,并发数量,默认为 3

134
README.md Normal file
View File

@ -0,0 +1,134 @@
# phomework - 作业模板系统
使用 Typst 和 AI 辅助生成作业的自动化工具。
## 目录结构
```
├── data/ # 作业数据(图片、题目、答案)
├── templates/ # Typst 模板文件
├── scripts/ # Python 脚本
│ ├── img2typ.py # 图片转 typst 格式
│ ├── fix_typ.py # 修复 typst 语法
│ ├── solve.py # AI 答题
│ ├── gen_index.py # 生成 index.typ
│ └── common.py # 共用模块
├── index.typ # 生成的作业文件
└── index.pdf # 编译后的 PDF
```
## 工作流程
1. **图片转题目** (`just img2typ`)
- 扫描 `data/` 中的图片
- 使用 AI 将图片内容转换为 typst 格式
- 生成 `questions.json` 题目列表
2. **AI 答题** (`just solve`)
- 读取 `questions.json`
- 调用 AI 生成答案
- 输出 `data/A_*.md` 答案文件
3. **生成作业** (`just generate`)
- 读取题目和答案
- 生成 `index.typ`
- 可编译为 PDF
## 快速开始
### 1. 准备数据
将作业图片放入 `data/` 目录,文件名格式:
- `P15.png` - P15 题目
- `R7.png` - R7 题目
- `P40_img1.png` - P40 的附件
### 2. 配置
API 配置从以下位置按优先级读取:
1. `scripts/.env` (本地)
2. `~/.config/phomework/.env` (全局)
**方式一:全局配置(推荐)**
```bash
mkdir -p ~/.config/phomework
cp scripts/.env.example ~/.config/phomework/.env
# 编辑 ~/.config/phomework/.env
```
**方式二:本地配置**
```bash
cp scripts/.env.example scripts/.env
# 编辑 scripts/.env
```
`.env` 文件内容:
```bash
# API 端点OpenAI 兼容)
IMG2TYP_API_ENDPOINT=https://api.openai.com/v1/chat/completions
# API 密钥
IMG2TYP_API_KEY=your-api-key-here
# 模型名称(默认 qwen-vl-plus
IMG2TYP_MODEL=qwen-vl-plus
```
### 3. 运行
```bash
just img2typ # 图片转题目
just solve # AI 答题
just generate # 生成 index.typ
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
```bash
-f, --file # 指定单个图片文件
--dry-run # 干跑,不调用 AI 或写入文件
--verbose # 调试日志
--retry N # 重试次数(默认 3
-n N # 并发数(默认 3
```
### solve.py
```bash
-q, --question # 指定题目 ID
--dry-run # 干跑
--verbose # 调试日志
--retry N # 重试次数(默认 3
-n N # 并发数(默认 3
```
### gen_index.py
```bash
--dry-run # 预览生成内容
--force # 强制覆盖 index.typ
```
## 开发
```bash
# 安装依赖
pip install requests python-dotenv rich aiohttp
# 代码检查
ruff check scripts/
ruff format scripts/
```

View File

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

8
scripts/.env.example Normal file
View File

@ -0,0 +1,8 @@
# OpenAI-compatible API endpoint
IMG2TYP_API_ENDPOINT=https://api.openai.com/v1/chat/completions
# API Key for authentication
IMG2TYP_API_KEY=your-api-key-here
# Model name (default: qwen-vl-plus for DashScope)
IMG2TYP_MODEL=qwen-vl-plus

View File

@ -15,7 +15,9 @@ console = Console(
SCRIPT_DIR = Path(__file__).parent
DATA_DIR = SCRIPT_DIR.parent / "data"
ENV_FILE = SCRIPT_DIR / ".env"
GLOBAL_ENV_DIR = Path.home() / ".config" / "phomework"
GLOBAL_ENV_FILE = GLOBAL_ENV_DIR / ".env"
LOCAL_ENV_FILE = SCRIPT_DIR / ".env"
def setup_logging(name: str, verbose: bool) -> logging.Logger:
@ -31,11 +33,23 @@ def setup_logging(name: str, verbose: bool) -> logging.Logger:
def load_env() -> dict[str, str]:
"""Load environment variables from .env file."""
if ENV_FILE.exists():
load_dotenv(ENV_FILE)
"""Load environment variables from .env file.
Priority: scripts/.env > ~/.config/phomework/.env
"""
env_loaded = False
if LOCAL_ENV_FILE.exists():
load_dotenv(LOCAL_ENV_FILE)
env_loaded = True
elif GLOBAL_ENV_FILE.exists():
load_dotenv(GLOBAL_ENV_FILE)
env_loaded = True
console.print(f"[dim]Using config from {GLOBAL_ENV_FILE}[/dim]")
else:
console.print(f"[yellow]Warning: .env file not found at {ENV_FILE}[/yellow]")
console.print(
f"[yellow]Warning: .env not found at {LOCAL_ENV_FILE} or {GLOBAL_ENV_FILE}[/yellow]"
)
api_endpoint = os.environ.get("IMG2TYP_API_ENDPOINT", "")
api_key = os.environ.get("IMG2TYP_API_KEY", "")

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()

View File

@ -82,6 +82,18 @@ def build_prompt(question_data: dict, typ_content: str | None) -> str:
return "".join(parts)
def get_image_attachments(question_data: dict) -> list[tuple[str, Path]]:
"""Return list of (attachment_name, path) for image attachments."""
images = []
for att in question_data.get("attachments", []):
att_path = DATA_DIR / att
if att_path.exists() and att.lower().endswith(
(".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp")
):
images.append((att, att_path))
return images
async def call_api_streaming(
session: aiohttp.ClientSession,
question_name: str,
@ -90,13 +102,34 @@ async def call_api_streaming(
api_key: str,
model: str,
logger: logging.Logger,
image_contents: list[tuple[str, Path]] | None = None,
) -> str | None:
"""Call the AI API with streaming to solve the question."""
import base64
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
if image_contents:
for att_name, att_path in image_contents:
with open(att_path, "rb") as f:
image_data = base64.b64encode(f.read()).decode("utf-8")
suffix = att_path.suffix[1:].lower()
if suffix in ("jpg", "jpeg"):
mime_type = "image/jpeg"
else:
mime_type = f"image/{suffix}"
messages[0]["content"].append(
{
"type": "image_url",
"image_url": {"url": f"data:{mime_type};base64,{image_data}"},
}
)
payload = {
"model": model,
"messages": [{"role": "user", "content": [{"type": "text", "text": prompt}]}],
"messages": messages,
"max_tokens": 4096,
"stream": True,
}
@ -123,12 +156,17 @@ async def call_api_streaming(
try:
chunk = json.loads(data)
logger.debug(f"[{question_name}] Chunk: {chunk}")
delta = chunk.get("choices", [{}])[0].get("delta", {})
choices = chunk.get("choices")
if not choices:
continue
delta = choices[0].get("delta", {})
content = delta.get("content")
if content:
full_content.append(content)
except json.JSONDecodeError:
continue
except IndexError:
continue
content = "".join(full_content)
if not content:
@ -182,6 +220,7 @@ async def solve_question(
)
prompt = build_prompt(question_data, typ_content)
image_contents = get_image_attachments(question_data)
content = await call_api_streaming(
session,
@ -191,6 +230,7 @@ async def solve_question(
api_config["key"],
api_config["model"],
logger,
image_contents,
)
if content is None: