wordformat 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. wordformat/__init__.py +11 -0
  2. wordformat/agent/__init__.py +4 -0
  3. wordformat/agent/message.py +79 -0
  4. wordformat/agent/onnx_infer.py +261 -0
  5. wordformat/api/__init__.py +269 -0
  6. wordformat/base.py +85 -0
  7. wordformat/cli.py +184 -0
  8. wordformat/config/__init__.py +4 -0
  9. wordformat/config/config.py +112 -0
  10. wordformat/config/datamodel.py +288 -0
  11. wordformat/data/model/id2label.json +17 -0
  12. wordformat/data/model/label2id.json +17 -0
  13. wordformat/data/model/tokenizer.json +21283 -0
  14. wordformat/data/system_prompt.txt +41 -0
  15. wordformat/log_config.py +83 -0
  16. wordformat/rules/__init__.py +42 -0
  17. wordformat/rules/abstract.py +288 -0
  18. wordformat/rules/acknowledgement.py +114 -0
  19. wordformat/rules/body.py +59 -0
  20. wordformat/rules/caption.py +100 -0
  21. wordformat/rules/heading.py +154 -0
  22. wordformat/rules/keywords.py +266 -0
  23. wordformat/rules/node.py +181 -0
  24. wordformat/rules/references.py +100 -0
  25. wordformat/set_style.py +177 -0
  26. wordformat/set_tag.py +22 -0
  27. wordformat/settings.py +33 -0
  28. wordformat/style/__init__.py +4 -0
  29. wordformat/style/check_format.py +530 -0
  30. wordformat/style/get_some.py +443 -0
  31. wordformat/style/set_some.py +527 -0
  32. wordformat/style/style_enum.py +603 -0
  33. wordformat/style/utils.py +140 -0
  34. wordformat/tree.py +178 -0
  35. wordformat/utils.py +128 -0
  36. wordformat/word_structure/__init__.py +4 -0
  37. wordformat/word_structure/document_builder.py +38 -0
  38. wordformat/word_structure/node_factory.py +30 -0
  39. wordformat/word_structure/settings.py +56 -0
  40. wordformat/word_structure/tree_builder.py +77 -0
  41. wordformat/word_structure/utils.py +62 -0
  42. wordformat-1.0.0.dist-info/METADATA +166 -0
  43. wordformat-1.0.0.dist-info/RECORD +47 -0
  44. wordformat-1.0.0.dist-info/WHEEL +5 -0
  45. wordformat-1.0.0.dist-info/entry_points.txt +3 -0
  46. wordformat-1.0.0.dist-info/licenses/LICENSE +201 -0
  47. wordformat-1.0.0.dist-info/top_level.txt +1 -0
wordformat/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ #! /usr/bin/env python
2
+ # @Time : 2025/12/22 21:47
3
+ # @Author : afish
4
+ # @File : __init__.py.py
5
+ from wordformat.set_style import auto_format_thesis_document
6
+ from wordformat.set_tag import set_tag_main
7
+
8
+ __all__ = [
9
+ "auto_format_thesis_document",
10
+ "set_tag_main"
11
+ ]
@@ -0,0 +1,4 @@
1
+ #! /usr/bin/env python
2
+ # @Time : 2026/1/11 17:38
3
+ # @Author : afish
4
+ # @File : __init__.py.py
@@ -0,0 +1,79 @@
1
+ #! /usr/bin/env python
2
+ # @Time : 2026/1/11 17:39
3
+ # @Author : afish
4
+ # @File : message.py
5
+ import threading
6
+
7
+
8
+ class MessageManager:
9
+ def __init__(self):
10
+ self._messages = []
11
+ self._instance_lock = threading.Lock() # 实例级操作锁
12
+
13
+ def add_message(self, role: str, content: str):
14
+ """添加角色信息"""
15
+ with self._instance_lock:
16
+ self._messages.append({"role": role, "content": content})
17
+
18
+ def add_dict_message(self, content):
19
+ """保存对话历史或上下文信息"""
20
+ with self._instance_lock:
21
+ self._messages.append(content)
22
+
23
+ def add_user_message(self, content: str):
24
+ """添加角色信息"""
25
+ self.add_dict_message({"role": "user", "content": content})
26
+
27
+ def add_assistant_message(self, msg: str | dict):
28
+ """安全添加AI消息"""
29
+ if isinstance(msg, dict):
30
+ self.add_dict_message(msg)
31
+ return
32
+ content = msg.content if hasattr(msg, "content") else msg
33
+ tool_calls = getattr(msg, "tool_calls", None)
34
+ message = {"role": "assistant", "content": content}
35
+ if tool_calls:
36
+ message["tool_calls"] = [
37
+ {
38
+ "id": call.id,
39
+ "type": call.type,
40
+ "function": {
41
+ "name": call.function.name,
42
+ "arguments": call.function.arguments,
43
+ },
44
+ }
45
+ for call in tool_calls
46
+ ]
47
+
48
+ self.add_dict_message(message)
49
+
50
+ def add_tool_message(self, content: str, tool_call_id):
51
+ """添加工具信息"""
52
+ self.add_dict_message(
53
+ {"role": "tool", "content": content, "tool_call_id": tool_call_id}
54
+ )
55
+
56
+ def add_system_message(self, content: str):
57
+ """添加系统信息"""
58
+ self.add_dict_message({"role": "system", "content": content})
59
+
60
+ def get_messages(self):
61
+ """获取信息"""
62
+ with self._instance_lock:
63
+ return self._messages.copy()
64
+
65
+ def reset_messages(self):
66
+ """清空信息"""
67
+ with self._instance_lock:
68
+ self._messages.clear()
69
+
70
+ @property
71
+ def messages(self):
72
+ """返回信息列表"""
73
+ with self._instance_lock:
74
+ return self._messages.copy()
75
+
76
+ def clear(self):
77
+ self._messages = list(
78
+ filter(lambda msg: msg.get("role") == "system", self._messages)
79
+ )
@@ -0,0 +1,261 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from typing import Dict, List, Optional
5
+
6
+ import numpy as np
7
+ from loguru import logger
8
+
9
+ # ===== 全局变量(初始为 None)=====
10
+ _tokenizer: Optional["Tokenizer"] = None # noqa F821
11
+ _ort_sess: Optional["ort.InferenceSession"] = None # noqa F821
12
+ _id2label: Optional[Dict[int, str]] = None
13
+ MAX_LENGTH = 128
14
+
15
+
16
+ # ===== 路径配置(仅定义,不加载)=====
17
+ def _get_model_paths():
18
+ from importlib.resources import files
19
+
20
+ return {
21
+ "onnx": str(
22
+ files("wordformat.data.model").joinpath("bert_paragraph_classifier.onnx")
23
+ ),
24
+ "tokenizer": str(files("wordformat.data.model").joinpath("tokenizer.json")),
25
+ "id2label": str(files("wordformat.data.model").joinpath("id2label.json")),
26
+ }
27
+
28
+
29
+ # ===== 硬件自动选择函数 =====
30
+ def _get_best_onnx_providers() -> List[str]:
31
+ """自动选择最优推理硬件:CUDA > DirectML > CPU"""
32
+ try:
33
+ import onnxruntime as ort
34
+
35
+ available = ort.get_available_providers()
36
+ # 优先级1:NVIDIA显卡(CUDA)
37
+ if "CUDAExecutionProvider" in available:
38
+ logger.info("检测到CUDA,优先使用GPU推理")
39
+ return ["CUDAExecutionProvider"]
40
+ # 优先级2:Windows核显(Intel/AMD,DirectML)
41
+ elif "DmlExecutionProvider" in available:
42
+ logger.info("检测到核显,使用DirectML推理")
43
+ return ["DmlExecutionProvider"]
44
+ # 优先级3:纯CPU(兜底)
45
+ else:
46
+ logger.info("仅检测到CPU,使用CPU多核推理")
47
+ return ["CPUExecutionProvider"]
48
+ except Exception as e:
49
+ logger.warning(f"硬件检测失败,降级为CPU:{e}")
50
+ return ["CPUExecutionProvider"]
51
+
52
+
53
+ # ===== 模型加载函数 =====
54
+ def _load_model():
55
+ global _tokenizer, _ort_sess, _id2label
56
+ if _tokenizer is not None:
57
+ return
58
+
59
+ import onnxruntime as ort
60
+ from tokenizers import Tokenizer
61
+
62
+ paths = _get_model_paths()
63
+ logger.info(f"首次调用,正在加载模型:{paths['onnx']}")
64
+
65
+ # 1. 加载Tokenizer(原有逻辑保留)
66
+ _tokenizer = Tokenizer.from_file(paths["tokenizer"])
67
+
68
+ # 2. 优化ONNX推理器配置
69
+ ort_options = ort.SessionOptions()
70
+ ort_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
71
+ cpu_core_num = os.cpu_count() or 4
72
+ ort_options.intra_op_num_threads = cpu_core_num
73
+ ort_options.inter_op_num_threads = cpu_core_num
74
+ ort_options.log_severity_level = 3
75
+ ort_options.enable_cpu_mem_arena = True
76
+ ort_options.enable_mem_pattern = True
77
+ ort_options.enable_mem_reuse = True
78
+ ort_options.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL
79
+
80
+ # 3. 加载ONNX模型(自动适配硬件)
81
+ providers = _get_best_onnx_providers()
82
+
83
+ try:
84
+ _ort_sess = ort.InferenceSession(
85
+ paths["onnx"], sess_options=ort_options, providers=providers
86
+ )
87
+
88
+ except Exception as e:
89
+ logger.warning(f"最优硬件加载失败,降级为CPU:{e}")
90
+ _ort_sess = ort.InferenceSession(
91
+ paths["onnx"], sess_options=ort_options, providers=["CPUExecutionProvider"]
92
+ )
93
+ # 4. 加载id2label(原有逻辑保留)
94
+ with open(paths["id2label"], encoding="utf-8") as f:
95
+ _id2label = {int(k): v for k, v in json.load(f).items()}
96
+
97
+
98
+ def onnx_single_infer(text: str) -> dict:
99
+ if _tokenizer is None:
100
+ _load_model()
101
+
102
+ # 确保类型安全(静态检查友好)
103
+ assert _tokenizer is not None
104
+ assert _ort_sess is not None
105
+ assert _id2label is not None
106
+
107
+ # 使用官方 tokenizer 编码
108
+ encoded = _tokenizer.encode(text, add_special_tokens=True)
109
+ input_ids = encoded.ids
110
+ attention_mask = encoded.attention_mask
111
+ token_type_ids = encoded.type_ids
112
+
113
+ # 截断 & 补全
114
+ if len(input_ids) > MAX_LENGTH:
115
+ input_ids = input_ids[:MAX_LENGTH]
116
+ attention_mask = attention_mask[:MAX_LENGTH]
117
+ token_type_ids = token_type_ids[:MAX_LENGTH]
118
+ else:
119
+ pad_len = MAX_LENGTH - len(input_ids)
120
+ input_ids += [0] * pad_len
121
+ attention_mask += [0] * pad_len
122
+ token_type_ids += [0] * pad_len
123
+
124
+ onnx_input = {
125
+ "input_ids": np.array([input_ids], dtype=np.int64),
126
+ "attention_mask": np.array([attention_mask], dtype=np.int64),
127
+ "token_type_ids": np.array([token_type_ids], dtype=np.int64),
128
+ }
129
+ # 超时控制
130
+ try:
131
+ start = time.time()
132
+ logits = _ort_sess.run(["logits"], onnx_input)[0]
133
+ logger.debug(f"单条推理耗时:{time.time() - start:.4f}s")
134
+ except Exception as e:
135
+ logger.error(f"单条推理失败:{e}")
136
+ return {"label": "", "score": 0.0}
137
+
138
+ logits = logits[0]
139
+ logits -= np.max(logits)
140
+ probs = np.exp(logits) / np.sum(np.exp(logits))
141
+ pred_id = int(np.argmax(probs))
142
+ pred_prob = round(float(probs[pred_id]), 4)
143
+
144
+ return {"label": _id2label[pred_id], "score": pred_prob}
145
+
146
+
147
+ def onnx_batch_infer(texts: list[str]) -> list[dict]:
148
+ """
149
+ ONNX模型批量文本推理
150
+ :param texts: 待分类的论文段落文本列表
151
+ :return: 每条文本的预测结果列表,每个元素为字典(同单条推理格式)
152
+ """
153
+ global _tokenizer, _ort_sess, _id2label
154
+
155
+ if not texts:
156
+ return []
157
+
158
+ if _tokenizer is None:
159
+ _load_model()
160
+
161
+ # 确保类型安全(静态检查友好)
162
+ assert _tokenizer is not None
163
+ assert _ort_sess is not None
164
+ assert _id2label is not None
165
+
166
+ # ===== 批量预处理(向量化替代循环拼接)=====
167
+ batch_size = len(texts)
168
+ # 初始化批量数组(直接预分配内存,避免循环append)
169
+ batch_input_ids = np.zeros((batch_size, MAX_LENGTH), dtype=np.int64)
170
+ batch_attention_mask = np.zeros((batch_size, MAX_LENGTH), dtype=np.int64)
171
+ batch_token_type_ids = np.zeros((batch_size, MAX_LENGTH), dtype=np.int64)
172
+
173
+ # 批量编码(保留逻辑,但优化赋值方式)
174
+ for idx, text in enumerate(texts):
175
+ encoded = _tokenizer.encode(text, add_special_tokens=True)
176
+ input_ids = encoded.ids
177
+ attention_mask = encoded.attention_mask
178
+ token_type_ids = encoded.type_ids
179
+
180
+ # 截断
181
+ seq_len = min(len(input_ids), MAX_LENGTH)
182
+ # 直接赋值(比append+拼接快)
183
+ batch_input_ids[idx, :seq_len] = input_ids[:seq_len]
184
+ batch_attention_mask[idx, :seq_len] = attention_mask[:seq_len]
185
+ batch_token_type_ids[idx, :seq_len] = token_type_ids[:seq_len]
186
+
187
+ # ===== 批量推理(超时控制+性能监控)=====
188
+ onnx_input = {
189
+ "input_ids": batch_input_ids,
190
+ "attention_mask": batch_attention_mask,
191
+ "token_type_ids": batch_token_type_ids,
192
+ }
193
+
194
+ try:
195
+ start = time.time()
196
+ # 批量推理(一次性处理,减少ONNX调用开销)
197
+ logits = _ort_sess.run(["logits"], onnx_input)[0] # shape: [batch, num_classes]
198
+ infer_time = time.time() - start
199
+ logger.info(
200
+ f"批量推理完成 | 批次大小:{batch_size} | 耗时:{infer_time:.4f}s | 单条耗时:{infer_time / batch_size:.4f}s" # noqa E501
201
+ )
202
+ except Exception as e:
203
+ logger.error(f"批量推理失败:{e}")
204
+ # 兜底:返回空结果,避免整体崩溃
205
+ return [
206
+ {"text": text, "label": "", "pred_id": -1, "score": 0.0}
207
+ for text in texts
208
+ ]
209
+
210
+ # ===== 向量化计算概率(替代循环)=====
211
+ # 数值稳定化(避免exp溢出)
212
+ logits_stable = logits - np.max(logits, axis=-1, keepdims=True)
213
+ probs = np.exp(logits_stable) / np.sum(
214
+ np.exp(logits_stable), axis=-1, keepdims=True
215
+ )
216
+ # 批量获取预测ID和概率(向量化操作,比循环快)
217
+ pred_ids = np.argmax(probs, axis=-1).astype(int)
218
+ pred_probs = np.round(np.max(probs, axis=-1).astype(float), 4)
219
+
220
+ # ===== 结果组装 =====
221
+ results = []
222
+ for idx, text in enumerate(texts):
223
+ pred_id = pred_ids[idx]
224
+ results.append(
225
+ {
226
+ "text": text,
227
+ "label": _id2label.get(pred_id, ""),
228
+ "pred_id": pred_id,
229
+ "score": float(pred_probs[idx]),
230
+ }
231
+ )
232
+
233
+ return results
234
+
235
+
236
+ def safe_batch_infer(texts: list[str], max_batch_size: int = 128) -> list[dict]:
237
+ """
238
+ 安全批量推理(自动分片,避免超大批次OOM)
239
+ :param texts: 文本列表
240
+ :param max_batch_size: 单批次最大数量(默认128,可根据硬件调整)
241
+ :return: 完整结果列表
242
+ """
243
+ if not texts:
244
+ return []
245
+
246
+ start = time.time()
247
+ results = []
248
+ total = len(texts)
249
+ # 分片处理
250
+ for i in range(0, total, max_batch_size):
251
+ batch = texts[i : i + max_batch_size]
252
+ batch_results = onnx_batch_infer(batch)
253
+ results.extend(batch_results)
254
+ # 进度日志
255
+ logger.info(f"已处理 {min(i + max_batch_size, total)}/{total} 条文本")
256
+
257
+ total_time = time.time() - start
258
+ logger.info(
259
+ f"安全批量推理完成 | 总条数:{total} | 总耗时:{total_time:.4f}s | 平均单条:{total_time / total:.4f}s" # noqa E501
260
+ )
261
+ return results
@@ -0,0 +1,269 @@
1
+ #! /usr/bin/env python
2
+ # @Time : 2026/2/5 21:41
3
+ # @Author : afish
4
+ # @File : __init__.py
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Optional
8
+ from urllib.parse import quote
9
+
10
+ from fastapi import Body, FastAPI, File, HTTPException, UploadFile
11
+ from fastapi.middleware.cors import CORSMiddleware
12
+ from loguru import logger
13
+ from pydantic import BaseModel
14
+ from starlette.responses import FileResponse
15
+
16
+ # 复用原有项目的核心函数和校验工具
17
+ from wordformat.set_style import auto_format_thesis_document
18
+ from wordformat.set_tag import set_tag_main
19
+ from wordformat.settings import BASE_DIR, SERVER_HOST
20
+
21
+ # ---------------------- 初始化FastAPI应用 ----------------------
22
+ app = FastAPI(
23
+ title="学位论文格式自动校验工具-WebAPI",
24
+ description="基于FastAPI实现,支持generate-json/check-format/apply-format三种模式,兼容原有YAML配置",
25
+ version="1.0.0",
26
+ docs_url="/docs", # Swagger UI接口文档地址(推荐)
27
+ redoc_url="/redoc", # ReDoc接口文档地址(备选)
28
+ )
29
+
30
+
31
+ app.add_middleware(
32
+ CORSMiddleware,
33
+ allow_origins=["*"], # 允许上述域名的跨域请求
34
+ allow_credentials=True, # 允许携带cookie(可选,建议开)
35
+ allow_methods=["*"], # 允许所有请求方法(GET/POST/PUT等)
36
+ allow_headers=["*"], # 允许所有请求头(包括文件上传的头)
37
+ )
38
+
39
+ # ---------------------- 全局配置 ----------------------
40
+ # 临时文件目录(存储上传的docx/配置文件、生成的json),自动创建
41
+ TEMP_DIR = BASE_DIR / "temp"
42
+ TEMP_DIR.mkdir(parents=True, exist_ok=True)
43
+ logger.info(f"临时文件目录:{TEMP_DIR}")
44
+ # 输出文件目录(存储校验/格式化后的docx)
45
+ OUTPUT_DIR = BASE_DIR / "output"
46
+ OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
47
+ logger.info(f"输出文件目录:{OUTPUT_DIR}") # 修复原日志笔误
48
+
49
+
50
+ # ---------------------- 数据模型(接口参数校验) ----------------------
51
+ class OperationResult(BaseModel):
52
+ """统一接口返回格式"""
53
+
54
+ code: int # 200=成功,400=参数错误,500=执行失败
55
+ msg: str # 结果描述
56
+ data: Optional[dict] = None # 附加数据(如文件路径、日志信息)
57
+
58
+
59
+ # ---------------------- 核心工具函数 ----------------------
60
+ def save_upload_file(upload_file: UploadFile, save_dir: Path) -> str:
61
+ """
62
+ 保存上传的文件到指定目录,仅返回【文件绝对路径】(重名自动加_1/_2后缀,避免覆盖)
63
+ :param upload_file: FastAPI上传的文件对象
64
+ :param save_dir: 保存目录
65
+ :return: 文件绝对路径
66
+ """
67
+ try:
68
+ original_filename = upload_file.filename
69
+ file_suffix = os.path.splitext(original_filename)[-1]
70
+ original_name_no_suffix = os.path.splitext(original_filename)[0]
71
+
72
+ # 重名处理:自动追加数字后缀,防止文件覆盖
73
+ save_path = os.path.join(save_dir, original_filename)
74
+ counter = 1
75
+ while os.path.exists(save_path):
76
+ save_path = os.path.join(
77
+ save_dir, f"{original_name_no_suffix}_{counter}{file_suffix}"
78
+ )
79
+ counter += 1
80
+
81
+ # 保存文件流
82
+ with open(save_path, "wb") as f:
83
+ f.write(upload_file.file.read())
84
+ abs_path = os.path.abspath(save_path)
85
+ logger.info(f"上传文件已保存:{abs_path}")
86
+ return abs_path
87
+ except Exception as e:
88
+ raise HTTPException(status_code=500, detail=f"文件保存失败:{str(e)}") from e
89
+
90
+
91
+ # ---------------------- 核心API接口 ----------------------
92
+ @app.post(
93
+ "/generate-json", response_model=OperationResult, summary="生成文档结构JSON文件"
94
+ )
95
+ async def api_generate_json(
96
+ docx_file: UploadFile = File(..., description="待处理的Word文档(.docx格式)"), # noqa B008
97
+ config_file: UploadFile = File(..., description="格式配置YAML文件(必填)"), # noqa B008
98
+ ):
99
+ """
100
+ 对应原命令行generate-json模式:仅生成JSON,不执行校验/格式化
101
+ - 上传docx和yaml配置文件,服务端自动生成JSON并返回数据
102
+ """
103
+
104
+ try:
105
+ filename = docx_file.filename.lower()
106
+ if not filename.endswith(".docx"):
107
+ return OperationResult(
108
+ code=400,
109
+ msg=f"上传失败:仅支持 .docx式(你当前上传的是:{docx_file.filename}),请转换为docx后重试"
110
+ )
111
+
112
+ # 保存上传文件(仅返回路径,无需提取原名称)
113
+ docx_path = save_upload_file(docx_file, TEMP_DIR)
114
+ config_path = save_upload_file(config_file, TEMP_DIR)
115
+
116
+ # 执行核心逻辑生成JSON
117
+ json_data = set_tag_main(
118
+ docx_path=docx_path, configpath=config_path
119
+ )
120
+
121
+ return OperationResult(
122
+ code=200,
123
+ msg="JSON文件生成成功",
124
+ data={
125
+ "json_data": json_data,
126
+ "tips": "可使用该JSON数据调用check-format/apply-format接口",
127
+ },
128
+ )
129
+ except Exception as e:
130
+ logger.error(f"生成JSON失败:{str(e)}")
131
+ raise HTTPException(status_code=500, detail=f"生成JSON失败:{str(e)}") from e
132
+
133
+
134
+ @app.post(
135
+ "/check-format",
136
+ response_model=OperationResult,
137
+ summary="仅执行格式校验(需先生成JSON)",
138
+ )
139
+ async def api_check_format(
140
+ docx_file: UploadFile = File(..., description="待校验的Word文档(.docx格式)"), # noqa B008
141
+ config_file: UploadFile = File(..., description="格式配置YAML文件(必填)"), # noqa B008
142
+ json_data: str = Body(..., description="从/generate-json获取的文档结构JSON数据"),
143
+ ):
144
+ """
145
+ 对应原命令行check-format模式:仅执行格式校验,生成【原文件名+--标注版.docx】
146
+ - 基于函数返回的真实路径拼接下载链接,保证路径100%匹配
147
+ """
148
+ try:
149
+ # 1. 保存上传文件(仅返回实际路径)
150
+ docx_path = save_upload_file(docx_file, TEMP_DIR)
151
+ config_path = save_upload_file(config_file, TEMP_DIR)
152
+
153
+ # 2. 执行校验逻辑,获取【函数返回的实际保存文件路径】(核心!)
154
+ actual_save_path = auto_format_thesis_document(
155
+ jsonpath=json_data,
156
+ docxpath=docx_path,
157
+ configpath=config_path,
158
+ savepath=OUTPUT_DIR,
159
+ check=True, # 仅校验模式
160
+ )
161
+
162
+ # 3. 从真实路径中提取最终文件名(如:1 (1)_2--标注版.docx)
163
+ final_filename = os.path.basename(actual_save_path)
164
+ # 4. 拼接正确的下载链接(仅编码文件名,无多余路径)
165
+ encoded_filename = quote(final_filename)
166
+ download_url = f"{SERVER_HOST}/download/{encoded_filename}"
167
+
168
+ # 5. 记录真实的保存路径日志(与函数内部日志一致)
169
+ logger.info(f"格式校验完成,结果保存:{actual_save_path}")
170
+
171
+ # 6. 返回结果(含真实文件名和下载链接)
172
+ return OperationResult(
173
+ code=200,
174
+ msg="格式校验执行成功",
175
+ data={
176
+ "original_docx": docx_file.filename, # 用户上传的原文件名
177
+ "final_filename": final_filename, # 实际保存的最终文件名
178
+ "download_url": download_url, # 可直接点击的下载链接
179
+ "tips": "文件已保存至服务端output目录,点击链接直接下载",
180
+ },
181
+ )
182
+ except Exception as e:
183
+ logger.error(f"格式校验失败:{str(e)}")
184
+ raise HTTPException(status_code=500, detail=f"格式校验失败:{str(e)}") from e
185
+
186
+
187
+ @app.post(
188
+ "/apply-format", response_model=OperationResult, summary="执行格式应用/自动格式化"
189
+ )
190
+ async def api_apply_format(
191
+ docx_file: UploadFile = File(..., description="待格式化的Word文档(.docx格式)"), # noqa B008
192
+ config_file: UploadFile = File(..., description="格式配置YAML文件(必填)"), # noqa B008
193
+ json_data: str = Body(..., description="从/generate-json获取的文档结构JSON数据"),
194
+ ):
195
+ """
196
+ 对应原命令行apply-format模式:自动应用格式,生成【原文件名+--修改版.docx】
197
+ - 基于函数返回的真实路径拼接下载链接,彻底解决404问题
198
+ """
199
+ try:
200
+ # 1. 保存上传文件(仅返回实际路径)
201
+ docx_path = save_upload_file(docx_file, TEMP_DIR)
202
+ config_path = save_upload_file(config_file, TEMP_DIR)
203
+
204
+ # 2. 执行格式化逻辑,获取【函数返回的实际保存文件路径】(核心!)
205
+ actual_save_path = auto_format_thesis_document(
206
+ jsonpath=json_data,
207
+ docxpath=docx_path,
208
+ configpath=config_path,
209
+ savepath=OUTPUT_DIR,
210
+ check=False, # 格式化模式
211
+ )
212
+
213
+ # 3. 从真实路径提取最终文件名(如:1 (1)_2--修改版.docx)
214
+ final_filename = os.path.basename(actual_save_path)
215
+ # 4. 拼接正确下载链接(仅编码文件名,避免路径错误)
216
+ encoded_filename = quote(final_filename)
217
+ download_url = f"/download/{encoded_filename}"
218
+
219
+ # 5. 记录真实保存路径日志(与函数内部日志完全一致)
220
+ logger.info(f"文档格式化完成,结果保存:{actual_save_path}")
221
+
222
+ # 6. 返回结果(含原文件名、最终文件名、下载链接)
223
+ return OperationResult(
224
+ code=200,
225
+ msg="文档格式化执行成功",
226
+ data={
227
+ "original_docx": docx_file.filename, # 用户上传的原文件名
228
+ "final_filename": final_filename, # 服务端实际保存的文件名
229
+ "download_url": download_url, # 前端可直接使用的下载链接
230
+ "tips": "文件已保存至服务端output目录,点击链接直接下载",
231
+ },
232
+ )
233
+ except Exception as e:
234
+ logger.error(f"文档格式化失败:{str(e)}")
235
+ raise HTTPException(status_code=500, detail=f"文档格式化失败:{str(e)}") from e
236
+
237
+
238
+ @app.get("/download/{filename}", summary="下载格式化/校验后的Word文档")
239
+ def download_file(filename: str):
240
+ """
241
+ 下载接口:基于真实文件名匹配,增加多层校验,保证下载稳定
242
+ """
243
+ try:
244
+ # 拼接服务端实际文件路径
245
+ file_path = os.path.join(OUTPUT_DIR, filename)
246
+ # 校验1:文件是否存在
247
+ if not os.path.exists(file_path):
248
+ raise HTTPException(status_code=404, detail="文件不存在或已被删除")
249
+ # 校验2:是否为有效文件(非文件夹/链接)
250
+ if not os.path.isfile(file_path):
251
+ raise HTTPException(status_code=400, detail="请求路径不是有效文件")
252
+ # 核心修复:增加强制下载的响应头
253
+ headers = {
254
+ "Content-Disposition": f"attachment; filename={quote(filename)}", # 强制下载+编码文件名
255
+ "Cache-Control": "no-cache", # 避免缓存问题
256
+ "Pragma": "no-cache",
257
+ }
258
+ # 以附件形式返回,浏览器自动触发下载,指定docx专属MIME类型
259
+ return FileResponse(
260
+ file_path,
261
+ filename=filename, # 强制指定下载显示的文件名(与实际保存一致)
262
+ media_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document",
263
+ headers=headers,
264
+ )
265
+ except HTTPException as e:
266
+ logger.error(str(e))
267
+ except Exception as e:
268
+ logger.error(f"文件下载失败:{filename} -> {str(e)}")
269
+ raise HTTPException(status_code=500, detail=f"文件下载失败:{str(e)}") from e