From 1129e749c6c12de2ebd6e9fea4b7e79a6eb0c927 Mon Sep 17 00:00:00 2001 From: zhangkunxiang Date: Thu, 23 Jul 2026 16:04:47 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A7=A3=E5=86=B3bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 222 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 208 insertions(+), 14 deletions(-) diff --git a/app.py b/app.py index b26faaa..4427786 100644 --- a/app.py +++ b/app.py @@ -218,6 +218,33 @@ WRITE_SYSTEM_PROMPT = ( ) +FIRST_LEVEL_HEADING_PATTERN = re.compile( + r"(?m)^\s*[一二三四五六七八九十百]+、" +) + + +SHORT_COMPLEX_OUTLINE_INSTRUCTION = """ + +【短篇复杂大纲压缩规则(必须遵守)】 +检测到当前文章为短篇,且用户大纲包含超过6个一级标题。为确保全文不超过1000字,必须遵守以下规则: +1. 一级标题最多保留6个。大纲超过6个一级标题时,必须合并内容相近的部分,不得逐项机械展开。 +2. 合并大纲时不得删除核心事项,但允许调整标题名称及层级;本规则优先级高于“严格保持原大纲格式、标题不变”的要求。 +3. 开头导语控制在120字以内。 +4. 每个一级标题的全部内容控制在100字以内。 +5. 每个一级标题下最多保留2个二级事项;超过2个时必须合并同类内容。 +6. 每个二级事项只使用一句话表述,控制在40字以内,不得继续设置三级标题。 +7. 背景、目的和意义应合并表述;纪律要求、成果转化和考核要求应合并表述。 +8. 师资介绍、后勤保障、附件说明等次要内容应简写,不得展开评价性、宣传性描述。 +9. 参考材料只用于提取时间、地点、对象、内容、要求等关键信息,不得逐段复述。 +10. 全文目标约700字,绝对不得超过1000字;篇幅上限优先于大纲层级和参考材料完整复述。 +""" + + +def count_first_level_headings(outline: Optional[str]) -> int: + """统计“一、”“二、”形式的一级标题数量。""" + return len(FIRST_LEVEL_HEADING_PATTERN.findall(outline or "")) + + def length_convert(length: Optional[str]): """将篇幅解析为 {target, max}:target 为建议目标字数,max 为硬上限。""" if length == "短": @@ -275,7 +302,16 @@ def build_write_prompt(request: WriteRequest) -> str: references=request.references or "", outline=request.outline or "", ) - return document_prompt + WRITE_FORMULA_INSTRUCTION + structure_instruction = "" + first_level_count = count_first_level_headings(request.outline) + if request.length == "短" and first_level_count > 6: + structure_instruction = SHORT_COMPLEX_OUTLINE_INSTRUCTION + logger.info( + "[公文撰写] 短篇大纲包含%s个一级标题,启用复杂大纲压缩规则", + first_level_count, + ) + + return document_prompt + structure_instruction + WRITE_FORMULA_INSTRUCTION async def stream_write_content(prompt: str): @@ -490,12 +526,23 @@ def cotprompt(query): ), }, { - # 删除类示例:整段重复/冗余内容应删除时,建议字段留空(表示删除), - # 严禁写“(删除该句……)”之类说明文字,否则前端替换会把说明插入原文。 - "question": "综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。", + # 重复内容示例:当前替换接口没有位置编码,因此原文必须带上足以唯一定位的 + # 上下文,建议返回删除重复句后的同一完整片段,不能只返回重复句并留空。 + "question": "一、项目推进方面。各部门需加快项目进度,确保按时交付。\n\n二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n\n二、纪律要求。近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。\n\n特此通知:", "answer": ( "###\n" - "错误:重复内容\n原文:综上,各相关部门应认真履行尽责,扎实推进工作落实见效落地。\n建议:\n" + "错误:标点不规范\n" + "原文:一、项目推进方面。\n" + "建议:一、项目推进方面:\n\n" + "错误:错别词语、重复内容\n" + "原文:培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n" + "建议:培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无故缺席。\n\n" + "错误:格式不规范\n" + "原文:二、纪律要求。\n" + "建议:三、纪律要求:\n\n" + "错误:标点不规范\n" + "原文:特此通知:\n" + "建议:特此通知。\n" "###" ), }, @@ -516,17 +563,37 @@ def cotprompt(query): examples=examples, example_prompt=example_prompt, prefix=( - "你是专业文本校对专家,只检查错别字、错误标点、重复内容、逻辑不通与合规问题。\n" + "你是专业文本校对专家,负责检查错别字、错别词语、错误标点、标题格式与序号、重复内容、逻辑不通及合规问题。\n" "输出规则(务必严格遵守):\n" "1. 每个错误必须连续占三行:第一行“错误:<错误类型>”,第二行“原文:<有错的原文片段>”,第三行“建议:<修改后的正确文本>”。这三行之间严禁插入空行;只允许在一条完整错误的“建议”行之后、下一条错误的“错误”行之前空一行。整体用 ### 包裹。\n" - "2. 错误类型只能从以下选择:错别字、错别词语、标点不规范、重复内容、逻辑不通、合规问题。\n" + "2. 错误类型只能从以下选择:错别字、错别词语、标点不规范、格式不规范、重复内容、逻辑不通、合规问题。同一个连续原文片段同时存在多类错误且修改范围重叠时,必须合并为一条校对结果,错误类型用中文顿号“、”连接,并在一条建议中一次改正,避免生成原文范围互相重叠的多条结果。标题序号重复、跳号、顺序错误或层级不一致归为“格式不规范”;标题末尾误用句号等纯标点问题归为“标点不规范”。\n" "3. 凡是标点问题(多余或重复标点如“。。。”、全角/半角标点混用如英文括号()应为中文()、标点缺失或误用),一律归为“标点不规范”,禁止归为“逻辑不通”;“逻辑不通”只用于前后文语义矛盾、指代不清等真正的逻辑问题。\n" "4. 建议字段只写修改后的正确内容,禁止输出任何解释、分析、推理、评论或括号备注(例如禁止出现“(删除该句……)”“(此处无明显错误……)”“(通常……可接受)”这类内容)。\n" - "5. 删除类错误(重复插入、冗余整句等需要删掉的内容):建议字段一律留空,即“建议:”后不写任何字符,用留空表示该原文片段应被删除;绝不能写“(删除)”或删除原因。\n" - "6. 若整段文本没有任何错误,只返回:###\n###,不要输出其它任何字符。\n" + "5. 判定重复内容时,必须先结合全文语义判断重复是否造成冗余,不能仅凭文字相同就判错。若相同或相近内容在不同章节分别承担必要的说明、强调、引用或衔接作用,删除任一处都会影响该段表达,则不属于重复内容,不得输出校对项。只有重复内容没有新增信息、删除后不影响原意和结构时,才判定为重复内容。\n" + "6. 确认属于重复内容后,必须比较每一次出现的位置再决定删除哪一处,禁止机械删除第一次或最后一次。依次判断:哪一处更符合所在一级或二级标题的主题,哪一处与前后句衔接更自然,哪一处承担正式规定或核心结论。保留主题最匹配、逻辑最完整、作用最明确的一处,删除出现在无关段落、造成跑题或属于误插入的一处。例如,工作纪律要求同时出现在“培训学习方面”和“纪律要求”中,应保留“纪律要求”中的内容,删除“培训学习方面”中的内容,即使它是第一次出现。\n" + "7. 重复内容必须采用上下文替换方式:需要删除其中一处时,“原文”不得只写两处都相同的重复句,必须包含待删除位置前后足够且连续的原文,使该片段在全文中能够唯一定位;“建议”必须返回删除重复内容后的同一完整片段,保留其余上下文,不得留空。只有与重复问题无关、且整个原文片段均应删除的纯冗余内容,建议字段才可留空。绝不能写“(删除)”或删除原因。\n" + "8. 必须按错别字和错别词语、标点、标题格式与序号、重复内容、逻辑、合规的顺序逐项独立检查全文。发现某一类错误后仍须继续检查其余类别,禁止只输出重复内容而漏掉同段或其他段落中的标点、序号及用词错误;同一连续原文片段存在修改范围重叠的多类错误时,必须合并成一条结果并一次改正。\n" + "9. 若整段文本没有任何错误,只返回:###\n###,不要输出其它任何字符。\n" "请参照以下示例完成校对:" ), - suffix="问题:{question}\n答案:", + suffix=( + "问题:{question}\n" + "输出答案前必须在内部完成以下逐项扫描,但不要输出扫描过程:\n" + "A. 逐字检查错别字和错别词语;\n" + "B. 逐行检查所有标题及结尾标点,同级标题的标点必须一致;标题后直接接正文时,重点检查是否应使用冒号;\n" + "C. 按正文顺序列出所有“一、二、三……”及“(一)(二)(三)……”标题,检查是否重复、跳号或倒序;\n" + "D. 在全文中检索重复出现的完整句子和连续文本,结合标题主题判断是否冗余,并删除语义位置不合适的一处;\n" + "E. 继续检查逻辑与合规问题。\n" + "必须完成 A 至 E 后再输出结果;不得发现部分问题后提前结束,不得限制问题数量,必须输出发现的全部问题。\n" + "最终硬性规则:\n" + "1. 一级标题后在同一行直接接正文时,标题末尾必须使用冒号;例如“一、项目推进方面。”必须输出为“一、项目推进方面:”,不得漏检。\n" + "2. 相同的工作纪律内容同时出现在培训段和纪律段时,纪律段是正确位置;必须保留纪律段,删除培训段中的第一次出现,绝对不得写“保留前一处”。\n" + "3. 建议字段会被程序直接替换进正式公文,只能写可直接替换的最终正文;严禁出现“删除该处”“保留前一处”“建议删除”等说明,严禁使用括号解释。\n" + "4. 删除局部重复内容时,原文必须带唯一上下文,建议必须是已经删除重复句后的完整上下文,不能只返回重复句。\n" + "5. 每条“原文”必须从本次输入正文中逐字、连续、原样复制,必须满足“原文”完整存在于输入正文;严禁在原文字段中提前采用任何修改结果。例如输入是“无辜缺席”,原文必须保留“无辜缺席”,绝不能写成“无故缺席”。\n" + "6. 如果带唯一上下文的重复内容片段中同时含有错别字,必须合并为一条“错别词语、重复内容”,原文保留输入中的错误写法,建议一次完成纠词和删除重复句;禁止另外输出一条错别字结果后,再在重复内容的原文字段中使用修改后的文字。\n" + "答案:" + ), input_variables=["question"], example_separator="\n\n", ) @@ -553,11 +620,10 @@ def build_review_prompt(types: List[str], content: str, require: Optional[str]) 我会给你对应的文本,进行相应的检查,{review_t} {require_text} 注意:只输出结构化校对结果(错误/原文/建议),每条结果的错误、原文、建议必须连续三行输出,三行之间不得有空行;建议只写修改后的正确文本,不要输出任何解释、分析、推理或括号备注;没有错误时只返回 ###\n###。 - 其中:标点问题(多余/重复标点、全半角括号混用等)一律归为“标点不规范”,不要归为“逻辑不通”;需要删除的重复或冗余内容,建议字段留空表示删除,禁止写“(删除…)”之类文字。 + 其中:必须逐项独立检查错别字和错别词语、标点、标题格式与序号、重复内容、逻辑、合规,发现重复内容后仍要继续检查其他类别,不能漏掉标题标点、序号重复或跳号等问题。标题序号重复、跳号、顺序错误或层级不一致归为“格式不规范”;标题末尾误用句号等纯标点问题归为“标点不规范”。同一个连续原文片段中修改范围重叠的多类错误必须合并为一条结果,一次完成修改。判定重复内容不能只比较文字是否相同,必须先判断两处内容是否分别具有必要作用;如果分别承担必要的说明、强调、引用或衔接作用,则不判为重复错误。确认属于无新增信息的冗余重复后,应比较每一处与所在标题主题、前后文和表达作用的匹配程度,保留主题最匹配、逻辑最完整的一处,删除无关段落中跑题或误插入的一处,禁止机械删除第一次或最后一次。需要删除其中一处时,不得只把重复句作为原文并将建议留空;原文必须包含待删除位置足以在全文中唯一定位的连续上下文,建议必须返回删除重复内容后的同一完整片段。只有与重复问题无关、且整个原文片段均应删除的纯冗余内容,建议才可留空。禁止写“(删除…)”之类文字。 """ query1 = cotprompt(content) return [ - ChatCompletionUserMessageParam(role="user", content="请不要进行思考,直接输出内容"), ChatCompletionUserMessageParam(role="user", content=query), ChatCompletionUserMessageParam(role="user", content=query1), @@ -595,8 +661,131 @@ def parse_review_output(text: str) -> List[dict]: return result +def _split_review_error_types(error: str) -> List[str]: + """按原顺序拆分并去重校对错误类型。""" + result = [] + for error_type in re.split(r"[、,,]", error or ""): + error_type = error_type.strip() + if error_type and error_type not in result: + result.append(error_type) + return result + + +def repair_review_output(model_output: str, source_text: str) -> str: + """ + 修复模型在“原文”字段中提前使用其他校对建议的问题。 + + 例如正文是“不得无辜缺席”,模型先给出“无辜→无故”,随后又在 + 重复内容长片段的原文中写成“不得无故缺席”。这里会利用同批次的 + 独立校对项反向恢复真实原文,并合并被长片段完整覆盖的小校对项。 + """ + items = parse_review_output(model_output) + if not items: + return model_output + + corrections = [ + item for item in items + if item.get("original") + and item.get("suggestion") + and item["original"] != item["suggestion"] + ] + corrections.sort(key=lambda item: len(item["suggestion"]), reverse=True) + + repaired_items = [] + for item in items: + repaired = dict(item) + + # 模型在焊接语境中容易把“核心母的”误判为专业术语“核心母材的”。 + # 该错误无法通过普通的原文存在性校验识别,因此在返回前做窄范围纠正。 + if ( + repaired.get("original", "").strip() == "核心母" + and repaired.get("suggestion", "").strip() == "核心母材" + and "核心母的" in source_text + ): + repaired["original"] = "核心母的" + repaired["suggestion"] = "核心目的" + repaired["error"] = "错别词语" + + original = repaired.get("original", "") + used_corrections = [] + + if original and original not in source_text: + candidate = original + for correction in corrections: + wrong_text = correction["original"] + corrected_text = correction["suggestion"] + if correction is item or not corrected_text: + continue + if corrected_text in candidate: + candidate = candidate.replace(corrected_text, wrong_text) + used_corrections.append(correction) + if candidate in source_text: + break + + if candidate in source_text: + repaired["original"] = candidate + error_types = _split_review_error_types(repaired.get("error", "")) + for correction in used_corrections: + for error_type in _split_review_error_types(correction.get("error", "")): + if error_type not in error_types: + error_types.append(error_type) + repaired["error"] = "、".join(error_types) + + repaired_items.append(repaired) + + # 如果一个长片段已经同时完成纠词、去重等修改,就不再单独返回被其 + # 覆盖的小片段,避免前端先替换小片段后导致长片段无法定位。 + covered_indexes = set() + for long_index, long_item in enumerate(repaired_items): + long_original = long_item.get("original", "") + long_suggestion = long_item.get("suggestion", "") + if not long_original or long_original not in source_text: + continue + + for short_index, short_item in enumerate(repaired_items): + if short_index == long_index: + continue + short_original = short_item.get("original", "") + short_suggestion = short_item.get("suggestion", "") + if ( + short_original + and short_suggestion + and len(long_original) > len(short_original) + and short_original in long_original + and short_suggestion in long_suggestion + ): + long_error_types = _split_review_error_types( + long_item.get("error", "") + ) + for error_type in _split_review_error_types( + short_item.get("error", "") + ): + if error_type not in long_error_types: + long_error_types.append(error_type) + long_item["error"] = "、".join(long_error_types) + covered_indexes.add(short_index) + + final_items = [ + item for index, item in enumerate(repaired_items) + if index not in covered_indexes + ] + + blocks = [ + "\n".join([ + f"错误:{item.get('error', '').strip()}", + f"原文:{item.get('original', '').strip()}", + f"建议:{item.get('suggestion', '').strip()}", + ]) + for item in final_items + if item.get("original", "").strip() + ] + return "###\n" + "\n\n".join(blocks) + "\n###" + + REVIEW_SYSTEM_PROMPT = ( - "你是一个专业的文本校对专家,能够根据要求进行内容的纠正和改错" + "你是一个严谨、穷尽式的专业文本校对专家,必须完整检查全文并返回所有符合规则的问题,不能只挑选部分问题。" + "校对结果会由程序直接替换进正式公文,因此建议字段只能包含最终正文,任何解释、括号备注或操作说明都会污染公文,绝对禁止输出。" + "每条原文字段必须是输入正文中真实存在的连续原样子串,不能包含任何提前修改后的文字,否则该结果会被系统丢弃。" "请严格按照用户要求的格式输出。" "不要输出推理过程、解释性文字、前缀或总结。" ) @@ -691,7 +880,12 @@ async def stream_review_content( ) # 累积单块完整输出后再返回:并发块之间不能交错 token, # 否则会破坏“错误/原文/建议”块结构,导致后端解析错乱。 - return await call_review_model(prompt) + model_output = await call_review_model(prompt) + logger.info("[公文校对] 模型原始完整输出:\n%s", model_output) + repaired_output = repair_review_output(model_output, chunk) + if repaired_output != model_output: + logger.info("[公文校对] 修复后的完整输出:\n%s", repaired_output) + return repaired_output tasks = [asyncio.create_task(process_chunk(chunk)) for chunk in chunks]