更新公文校对相关逻辑

This commit is contained in:
zhangkunxiang 2026-07-22 09:29:07 +08:00
parent d1ba8905cc
commit 429b5d91d2

498
app.py
View File

@ -6,68 +6,16 @@ from typing import Optional, List, Set,Iterable
if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"): if sys.platform.startswith("win") and hasattr(asyncio, "WindowsSelectorEventLoopPolicy"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
try: import ahocorasick
import ahocorasick
except ImportError:
ahocorasick = None
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body, BackgroundTasks from fastapi import FastAPI, File, Path, UploadFile, HTTPException, Form, Request, Header, Body, BackgroundTasks
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
try: try:
from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate
except ImportError: except ImportError:
try: from langchain.prompts import FewShotPromptTemplate, PromptTemplate
from langchain.prompts import FewShotPromptTemplate, PromptTemplate
except ImportError:
class PromptTemplate:
def __init__(self, input_variables=None, template: str = ""):
self.input_variables = input_variables or []
self.template = template
def format(self, **kwargs):
return self.template.format(**kwargs)
class FewShotPromptTemplate:
def __init__(
self,
examples=None,
example_prompt=None,
prefix: str = "",
suffix: str = "",
input_variables=None,
example_separator: str = "\n\n",
):
self.examples = examples or []
self.example_prompt = example_prompt
self.prefix = prefix
self.suffix = suffix
self.input_variables = input_variables or []
self.example_separator = example_separator
def format(self, **kwargs):
rendered_examples = []
for example in self.examples:
rendered_examples.append(self.example_prompt.format(**example))
parts = [self.prefix]
if rendered_examples:
parts.append(self.example_separator.join(rendered_examples))
parts.append(self.suffix.format(**kwargs))
return self.example_separator.join(part for part in parts if part)
from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUserMessageParam, ChatCompletionMessageParam
from chunk_text import ( from chunk_text import get_chunk_bbox, split_text_preserve_sentences, data_replace, chunk_check,merge_short_slices,find_and_read_content_list,process_pdf_file,process_other_file
get_chunk_bbox,
split_text_preserve_sentences,
data_replace,
chunk_check,
merge_short_slices,
find_and_read_content_list,
process_pdf_file,
process_other_file,
safe_original_filename,
image_base64_to_data_url,
extract_image_text,
prepare_split_image,
)
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
import requests import requests
from pathlib import PurePath, Path as PathLib from pathlib import PurePath, Path as PathLib
@ -120,47 +68,14 @@ from checkpointer_config import (
CheckpointerManager, CheckpointerManager,
checkpointer_manager checkpointer_manager
) )
from gw_write import cotprompt,build_review_prompt,length_convert,length_display,build_length_instruction,dedupe_and_filter,filter_positive_word_false_positives,append_dictionary_results,find_keywords,normalize_words,process_rag_prompt
app = FastAPI(max_request_size=1024 * 1024 * 10) app = FastAPI(max_request_size=1024 * 1024 * 10)
_doc2pdf_converter: Optional[Doc2PDF] = None converter = Doc2PDF()
DATA_DIR = "/app/files" DATA_DIR = "/app/files"
load_dotenv() load_dotenv()
VLM_SEMAPHORE = asyncio.Semaphore(1)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
REWRITE_TYPE_MAP = {
"quotePolish": "金句润色",
"formalStyle": "书面化",
"speechStyle": "讲稿化",
"formatting": "规整",
"expand": "扩写",
"continueWriting": "续写",
"report": "汇报",
"quote": "金句",
"summarize": "精简",
"condense": "总结",
}
# /Review 流式并发配置:块大小 5000最多 5 块并行
REVIEW_CHUNK_SIZE = 5000
REVIEW_MAX_CONCURRENCY = 5
REVIEW_SYSTEM_PROMPT = (
"你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错"
"请严格按照用户要求的格式输出。"
"不要输出推理过程、解释性文字、前缀或总结。"
)
def get_doc2pdf_converter() -> Doc2PDF:
global _doc2pdf_converter
if _doc2pdf_converter is None:
try:
_doc2pdf_converter = Doc2PDF()
except RuntimeError as exc:
logger.error(f"LibreOffice 初始化失败: {exc}")
raise HTTPException(status_code=500, detail=str(exc)) from exc
return _doc2pdf_converter
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=["*"],
@ -210,6 +125,18 @@ class RewriteRequest(BaseModel):
sentence: Optional[str] = None sentence: Optional[str] = None
REWRITE_TYPE_MAP = {
"quotePolish": "金句润色",
"formalStyle": "书面化",
"speechStyle": "讲稿化",
"formatting": "规整",
"expand": "扩写",
"continueWriting": "续写",
"report": "汇报",
"quote": "金句",
"summarize": "精简",
"condense": "总结",
}
def build_rewrite_prompt(request: RewriteRequest) -> str: def build_rewrite_prompt(request: RewriteRequest) -> str:
@ -268,6 +195,65 @@ class OutlineRequest(BaseModel):
prompt: Optional[str] = None prompt: Optional[str] = None
# aidoc-web 的公文编辑器会把 Markdown 中的 LaTeX 公式交给 KaTeX 渲染。
WRITE_FORMULA_INSTRUCTION = r"""
公式输出规范必须遵守
1. 仅在正文确实需要数学统计或技术公式时使用 LaTeX普通数字百分比日期编号和金额均使用普通文本不要为了排版而生成公式
2. 行内公式必须且只能写成 `$公式内容$`例如增长率为 $r=\frac{x_2-x_1}{x_1}\times 100\%$开始和结束的 `$` 之间不得换行
3. 独占一行的公式必须且只能写成以下形式起止符各自完整且公式内容位于中间
$$
公式内容
$$
4. 只输出 KaTeX 支持的标准 LaTeX禁止使用 `\(...\)``\[...\]`LaTeX 文档环境代码块或 ``` 包裹公式禁止将公式写成图片HTMLMathMLJSON也不要转义公式两侧的 `$`
5. 每个公式的定界符必须成对闭合公式中的说明文字使用 `\text{...}`百分号写作 `\%`乘号优先写作 `\times`
6. 人民币预算等金额直接写成10020万元等普通文本不得使用 `$` 作为货币符号以免被前端误识别为公式
7. 若正文不需要公式不要输出任何 `$` `$$`
"""
WRITE_SYSTEM_PROMPT = (
"你是专业公文写作助手,请直接输出正文内容。"
"输出使用可由前端 Markdown 和 KaTeX 直接解析的内容;如需公式,必须严格遵守用户提示中的公式输出规范。"
)
def length_convert(length: Optional[str]):
"""将篇幅解析为 {target, max}target 为建议目标字数max 为硬上限。"""
if length == "":
return {"target": 700, "max": 1000}
if length == "":
return {"target": 2500, "max": 3000}
if length == "":
return {"target": 4500, "max": 5000}
# 兜底:从自定义篇幅字符串里解析出最大字数
if length:
nums = re.findall(r"\d+", length)
if nums:
max_chars = int(nums[-1])
return {"target": int(max_chars * 0.8), "max": max_chars}
return None
def length_display(length: Optional[str]) -> str:
"""模板 {length} 占位符用的字符串。"""
info = length_convert(length)
if info:
return f"{info['target']}字左右(不超过{info['max']}字)"
return length or ""
def build_length_instruction(length: Optional[str]) -> str:
"""把 target/max 写进提示词:建议目标 + 硬上限 + 优先级。"""
info = length_convert(length)
if not info:
return ""
return (
f"全文建议写到{info['target']}字左右,绝对不得超过{info['max']}字;"
"字数为硬性要求,不得注水或重复凑字数。"
)
def build_write_prompt(request: WriteRequest) -> str: def build_write_prompt(request: WriteRequest) -> str:
prompt = request.prompt or "" prompt = request.prompt or ""
template = request.template or "" template = request.template or ""
@ -281,7 +267,7 @@ def build_write_prompt(request: WriteRequest) -> str:
if not title.strip(): if not title.strip():
raise HTTPException(status_code=400, detail="title不能为空") raise HTTPException(status_code=400, detail="title不能为空")
return content+prompt_template.format( document_prompt = content + prompt_template.format(
role=request.role or "", role=request.role or "",
title=title, title=title,
length=length_display(request.length), length=length_display(request.length),
@ -289,13 +275,14 @@ def build_write_prompt(request: WriteRequest) -> str:
references=request.references or "", references=request.references or "",
outline=request.outline or "", outline=request.outline or "",
) )
return document_prompt + WRITE_FORMULA_INSTRUCTION
async def stream_write_content(prompt: str): async def stream_write_content(prompt: str):
async for chunk in model_api.OpenaiAPI.open_api_chat_stream( async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
query=prompt, query=prompt,
model=None, model=None,
system_prompt="你是专业公文写作助手,请直接输出正文内容。", system_prompt=WRITE_SYSTEM_PROMPT,
messages=[], messages=[],
): ):
if chunk: if chunk:
@ -379,6 +366,203 @@ class ReviewResponse(BaseModel):
data: List[ReviewItem] data: List[ReviewItem]
def normalize_words(words: List[str]) -> Set[str]:
return {word.strip() for word in words if word and word.strip()}
def find_keywords(text: str, keyword_set: Set[str]) -> List[str]:
if not text or not keyword_set:
return []
automaton = ahocorasick.Automaton()
for idx, keyword in enumerate(keyword_set):
automaton.add_word(keyword, (idx, keyword))
automaton.make_automaton()
found = set()
for _, (_, keyword) in automaton.iter(text):
found.add(keyword)
return list(found)
def append_dictionary_results(
items: List[dict],
content: str,
sensitive_words: Set[str],
negative_words: Set[str],
) -> List[dict]:
for word in find_keywords(content, sensitive_words):
items.append({
"original": word,
"error": "敏感词汇",
"suggestion": "",
})
for word in find_keywords(content, negative_words):
items.append({
"original": word,
"error": "错误词汇",
"suggestion": "",
})
return items
def filter_positive_word_false_positives(
items: List[dict],
positive_words: Set[str],
) -> List[dict]:
if not positive_words:
return items
result = []
for item in items:
original = str(item.get("original", ""))
error = str(item.get("error", ""))
has_positive_word = any(word in original for word in positive_words)
is_typo_error = (
"错别字" in error
or "错别词" in error
or "错别词语" in error
)
if has_positive_word and is_typo_error:
continue
result.append(item)
return result
def dedupe_and_filter(items: List[dict], source_text: str) -> List[dict]:
seen = set()
result = []
for item in items:
original = str(item.get("original", "")).strip()
error = str(item.get("error", "")).strip()
suggestion = str(item.get("suggestion", "")).strip()
if not original:
continue
if original == suggestion:
continue
if original not in source_text:
continue
key = (original, error, suggestion)
if key in seen:
continue
seen.add(key)
result.append({
"original": original,
"error": error,
"suggestion": suggestion,
})
return result
def cotprompt(query):
# 精简 Few-Shot只示范「输入文本 -> 结构化修改结果」,不再包含推理过程/思考链,
# 从源头杜绝模型输出解释、分析、括号备注等多余内容,同时大幅缩短每次请求的前缀长度。
examples = [
{
# 综合示例:覆盖错别字/错别词语/标点不规范/重复内容,
# 重点示范:多余标点(。。。)、全半角括号 -> 标点不规范(不是逻辑不通)
"question": "现,代教育中,智能教学系统【】让每个雪生受益,自动文达系统很方便。现就安全生产大检查工作提出如下意见。。。(本报记者 小陈)",
"answer": (
"###\n"
"错误:标点不规范\n原文:现,代教育\n建议:现代教育\n\n"
"错误:标点不规范\n原文:智能教学系统【】\n建议:智能教学系统\n\n"
"错误:错别字\n原文:每个雪生\n建议:每个学生\n\n"
"错误:错别词语\n原文:自动文达系统\n建议:自动问答系统\n\n"
"错误:标点不规范\n原文:如下意见。。。\n建议:如下意见。\n\n"
"错误:标点不规范\n原文:(本报记者 小陈)\n建议:(本报记者 小陈)\n"
"###"
),
},
{
# 删除类示例:整段重复/冗余内容应删除时,建议字段留空(表示删除),
# 严禁写“(删除该句……)”之类说明文字,否则前端替换会把说明插入原文。
"question": "综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。",
"answer": (
"###\n"
"错误:重复内容\n原文:综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。\n建议:\n"
"###"
),
},
{
# 无明显错误示例:只返回空标记,禁止输出任何说明、分析或括号备注
"question": "全年审核采购合同、对账单据数百份,均按规定完成登记归档。",
"answer": "###\n###",
},
]
# 单个示例模板:只保留 问题/答案,删除“推理过程”
example_prompt = PromptTemplate(
input_variables=["question", "answer"],
template="问题:{question}\n答案:{answer}",
)
few_shot_prompt = FewShotPromptTemplate(
examples=examples,
example_prompt=example_prompt,
prefix=(
"你是专业文本校对专家,只检查错别字、错误标点、重复内容、逻辑不通与合规问题。\n"
"输出规则(务必严格遵守):\n"
"1. 每个错误必须连续占三行:第一行“错误:<错误类型>”,第二行“原文:<有错的原文片段>”,第三行“建议:<修改后的正确文本>”。这三行之间严禁插入空行;只允许在一条完整错误的“建议”行之后、下一条错误的“错误”行之前空一行。整体用 ### 包裹。\n"
"2. 错误类型只能从以下选择:错别字、错别词语、标点不规范、重复内容、逻辑不通、合规问题。\n"
"3. 凡是标点问题(多余或重复标点如“。。。”、全角/半角标点混用如英文括号()应为中文()、标点缺失或误用),一律归为“标点不规范”,禁止归为“逻辑不通”;“逻辑不通”只用于前后文语义矛盾、指代不清等真正的逻辑问题。\n"
"4. 建议字段只写修改后的正确内容,禁止输出任何解释、分析、推理、评论或括号备注(例如禁止出现“(删除该句……)”“(此处无明显错误……)”“(通常……可接受)”这类内容)。\n"
"5. 删除类错误(重复插入、冗余整句等需要删掉的内容):建议字段一律留空,即“建议:”后不写任何字符,用留空表示该原文片段应被删除;绝不能写“(删除)”或删除原因。\n"
"6. 若整段文本没有任何错误,只返回:###\n###,不要输出其它任何字符。\n"
"请参照以下示例完成校对:"
),
suffix="问题:{question}\n答案:",
input_variables=["question"],
example_separator="\n\n",
)
return few_shot_prompt.format(question=query)
def build_review_prompt(types: List[str], content: str, require: Optional[str]) -> Iterable[ChatCompletionMessageParam]:
if require is None:
require = ""
require = require.strip()
review_t = "现在需要你帮我完成以下文本校对任务:"
for i , t in enumerate(types):
if t =="逻辑校对":
review_t += f"{i} 对这段文本进行逻辑校对\n"
if t =="基础校对":
review_t += f"{i}.1 进行错别字校对,错别字错误主要以文本用字不当为主,例如:星光店电,正确的应该为:星光点点。\n"
review_t += f"{i}.2 进行标点校对,主要以标点符号使用不当为主,例如:星光点点;万里无云,正确的应该为:星光点点,万里无云\n"
review_t += f"{i}.3 进行格式规范校对\n"
review_t += f"{i}.4 进行重复内容校对,主要以原文中出现重复词语、句子或文本为主,例如:“今天今天心情相当不错,我很开心。我很开心。“,正确的内容应该为:今天心情相当不错,我很开心。\n"
if t=="合规性检查":
review_t += f"{i} 对这段文本进行合规性检查\n"
require_text = f"\n额外要求:{require}\n" if require else ""
query = f"""
我会给你对应的文本进行相应的检查{review_t}
{require_text}
注意只输出结构化校对结果错误/原文/建议每条结果的错误原文建议必须连续三行输出三行之间不得有空行建议只写修改后的正确文本不要输出任何解释分析推理或括号备注没有错误时只返回 ###\n###。
其中标点问题多余/重复标点全半角括号混用等一律归为标点不规范不要归为逻辑不通需要删除的重复或冗余内容建议字段留空表示删除禁止写删除之类文字
"""
query1 = cotprompt(content)
return [
ChatCompletionUserMessageParam(role="user", content="请不要进行思考,直接输出内容"),
ChatCompletionUserMessageParam(role="user", content=query),
ChatCompletionUserMessageParam(role="user", content=query1),
]
@ -389,6 +573,7 @@ _REVIEW_BLOCK_RE = re.compile(
re.S, re.S,
) )
def parse_review_output(text: str) -> List[dict]: def parse_review_output(text: str) -> List[dict]:
text = text.strip() text = text.strip()
@ -410,6 +595,12 @@ def parse_review_output(text: str) -> List[dict]:
return result return result
REVIEW_SYSTEM_PROMPT = (
"你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错"
"请严格按照用户要求的格式输出。"
"不要输出推理过程、解释性文字、前缀或总结。"
)
async def stream_review_model(prompt: Iterable[ChatCompletionMessageParam]): async def stream_review_model(prompt: Iterable[ChatCompletionMessageParam]):
async for chunk in model_api.OpenaiAPI.open_api_chat_stream( async for chunk in model_api.OpenaiAPI.open_api_chat_stream(
@ -462,6 +653,10 @@ def build_review_stream_require(
return "\n".join(requirements) return "\n".join(requirements)
# /Review 流式并发配置:块大小 5000最多 5 块并行
REVIEW_CHUNK_SIZE = 5000
REVIEW_MAX_CONCURRENCY = 5
async def stream_review_content( async def stream_review_content(
content: str, content: str,
@ -626,7 +821,39 @@ class FeedbackClassifyRequest(BaseModel):
# =============== 辅助函数 ===============
def process_rag_prompt(rag_prompt: Any) -> str:
"""
处理 rag_prompt 参数将各种类型转换为字符串
Args:
rag_prompt: 可以是字符串列表字典等任意类型
Returns:
处理后的字符串
"""
if rag_prompt is None:
return ""
# 如果是字符串,直接返回
if isinstance(rag_prompt, str):
return rag_prompt.strip()
# 如果是列表,转换为多行字符串
if isinstance(rag_prompt, list):
# 将列表中的每个元素转换为字符串,并用换行符连接
return "\n".join(str(item) for item in rag_prompt if item)
# 如果是字典,转换为 JSON 字符串
if isinstance(rag_prompt, dict):
try:
return json.dumps(rag_prompt, ensure_ascii=False, indent=2)
except Exception as e:
logger.warning(f"rag_prompt 字典转换失败: {e}")
return str(rag_prompt)
# 其他类型,直接转换为字符串
return str(rag_prompt).strip()
def format_stream_event(event_type: str, def format_stream_event(event_type: str,
@ -1141,6 +1368,14 @@ async def download_file(file_name: str):
) )
@app.get("/api/health")
async def health_check():
"""健康检查接口"""
return {
"status": "healthy",
"message": "服务正常运行每次请求时创建新的Agent实例"
}
@app.post("/api/v1/feedback/classify") @app.post("/api/v1/feedback/classify")
@ -1197,78 +1432,6 @@ class RequestWrapper(BaseModel):
pdf_contents: List[PdfContentItem] pdf_contents: List[PdfContentItem]
class Base64ImageRequest(BaseModel):
content: Optional[str] = Field(
None,
description="Optional text around the image, for example: 图3.1 船舶主发动机组成图images/test.jpg",
)
filename: str = Field(..., description="Original image filename, for example test.png")
image_base64: str = Field(..., description="Base64 image content, with or without data:image/... prefix")
async def analyze_image_with_vlm(image_bytes: bytes, suffix: str) -> str:
image_url = image_base64_to_data_url(image_bytes, suffix)
async with VLM_SEMAPHORE:
return await model_api.OpenaiAPI.open_api_vl_without_thinking(image_url)
@app.post("/split_image")
async def split_image(request_data: Base64ImageRequest):
filename = safe_original_filename(request_data.filename)
suffix = PathLib(filename).suffix.lower()
if suffix not in {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".gif", ".tif", ".tiff"}:
raise HTTPException(status_code=400, detail="Unsupported image type")
image_bytes, processed_suffix = prepare_split_image(request_data.image_base64, suffix)
if not image_bytes:
raise HTTPException(status_code=400, detail="decoded image is empty")
temp_path = None
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=processed_suffix) as tmp:
tmp.write(image_bytes)
temp_path = PathLib(tmp.name)
ocr_data = await extract_image_text(temp_path)
try:
temp_path.unlink()
except Exception as e:
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
finally:
temp_path = None
content = (request_data.content or "").strip()
ocr_full_text = (ocr_data.get("full_text") or "").strip()
if not content and not ocr_full_text:
vlm_text = await analyze_image_with_vlm(image_bytes, processed_suffix)
image_content = "图片文本描述为:" + (vlm_text or "").strip()
elif content and ocr_full_text:
image_content = f"图片上下文内容为:{content}, {ocr_full_text}"
elif content:
image_content = "图片上下文内容为:" + content
else:
image_content = ocr_full_text
image_content = "图片文件名为:" + filename + ",图片相关内容为:" + image_content
return JSONResponse(
{
"code": 200,
"message": "ok",
"data": {
"image_content": image_content,
"filename": filename,
},
}
)
finally:
if temp_path and temp_path.exists():
try:
temp_path.unlink()
except Exception as e:
logger.warning(f"Failed to delete temp image {temp_path}: {e}")
@app.post("/split_content_list") @app.post("/split_content_list")
@ -1350,7 +1513,7 @@ async def split_result(
logger.info(f"转换 DOCX -> PDF: {filename}") logger.info(f"转换 DOCX -> PDF: {filename}")
# 执行转换 # 执行转换
get_doc2pdf_converter().convert(str(temp_input_path), output_dir=str(DATA_DIR)) converter.convert(str(temp_input_path), output_dir=str(DATA_DIR))
# ? 使用临时文件的基础名查找 PDF # ? 使用临时文件的基础名查找 PDF
temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn temp_base_name = temp_input_path.stem # 如 tmp2bb0l4cn
@ -1448,13 +1611,6 @@ async def split_result(
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete temp file {path}: {e}") logger.warning(f"Failed to delete temp file {path}: {e}")
@app.get("/api/health")
async def health_check():
"""健康检查接口"""
return {
"status": "healthy",
"message": "服务正常运行每次请求时创建新的Agent实例"
}
@app.get("/") @app.get("/")
async def root(): async def root():