diff --git a/app.py b/app.py index aecbe72..27bef19 100644 --- a/app.py +++ b/app.py @@ -88,6 +88,7 @@ FastAPI 主 Agent 接口 - 修复重复执行问题 """ import logging import unicodedata +from difflib import SequenceMatcher from time import sleep import aiofiles @@ -121,7 +122,22 @@ from checkpointer_config import ( CheckpointerManager, 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 +from gw_write import ( + REVIEW_TYPE_ERROR_MAP, + 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, + get_allowed_review_error_types, + normalize_review_types, +) app = FastAPI(max_request_size=1024 * 1024 * 10) _doc2pdf_converter: Optional[Doc2PDF] = None @@ -147,7 +163,8 @@ REWRITE_TYPE_MAP = { REVIEW_CHUNK_SIZE = 1000 REVIEW_MAX_CONCURRENCY = 5 REVIEW_SYSTEM_PROMPT = ( - "你是一个严谨、穷尽式的专业文本校对专家,必须完整检查全文并返回所有符合规则的问题,不能只挑选部分问题。" + "你是一个严谨的专业文本校对专家,必须完整检查用户明确选中的校对范围,并返回该范围内的真实问题。" + "禁止检查或输出用户未选中的校对类别,禁止重复输出相同问题,原文与建议相同时不得输出。" "校对结果会由程序直接替换进正式公文,因此建议字段只能包含最终正文,任何解释、括号备注或操作说明都会污染公文,绝对禁止输出。" "每条原文字段必须是输入正文中真实存在的连续原样子串,不能包含任何提前修改后的文字,否则该结果会被系统丢弃。" "请严格按照用户要求的格式输出。" @@ -311,8 +328,28 @@ WRITE_FORMULA_INSTRUCTION = r""" 7. 若正文不需要公式,不要输出任何 `$` 或 `$$`。 """ +WRITE_HEADING_LAYOUT_INSTRUCTION = """ + +【标题与正文换行规范(必须遵守)】 +1. “一、……”形式的一级标题和“(一)……”形式的二级标题必须分别独占一行,严禁把一级标题、二级标题或正文连续写在同一行。 +2. 每个二级标题后必须换行,正文从下一段开始;正文结束后再另起一行输出下一个二级标题。 +3. 标题与正文之间、相邻层级标题之间使用一个空行分隔,以便前端 Markdown 正确显示。 +4. 必须按以下结构输出,不得写成“(一)标题 正文”: + +一、一级标题 + +(一)二级标题 + +正文内容。 + +(二)二级标题 + +正文内容。 +""" + WRITE_SYSTEM_PROMPT = ( "你是专业公文写作助手,请直接输出正文内容。" + "一级标题与二级标题必须分别独占一行,每个二级标题后的正文必须另起一段。" "输出使用可由前端 Markdown 和 KaTeX 直接解析的内容;如需公式,必须严格遵守用户提示中的公式输出规范。" ) @@ -372,7 +409,12 @@ def build_write_prompt(request: WriteRequest) -> str: first_level_count, ) - return document_prompt + structure_instruction + WRITE_FORMULA_INSTRUCTION + return ( + document_prompt + + structure_instruction + + WRITE_HEADING_LAYOUT_INSTRUCTION + + WRITE_FORMULA_INSTRUCTION + ) async def stream_write_content(prompt: str): @@ -450,6 +492,16 @@ class ReviewRequest(BaseModel): def check_content(self): if not self.content or not self.content.strip(): raise ValueError("content不能为空") + normalized_types = normalize_review_types(self.types) + unknown_types = [ + review_type for review_type in self.types + if review_type not in REVIEW_TYPE_ERROR_MAP + ] + if unknown_types: + raise ValueError(f"不支持的检查类型:{'、'.join(dict.fromkeys(unknown_types))}") + if not normalized_types: + raise ValueError("types不能为空") + self.types = normalized_types return self @@ -472,6 +524,12 @@ _REVIEW_BLOCK_RE = re.compile( r"建议[::]\s*(?P.*?)(?=\n\s*\n|$)", re.S, ) +_COMPLETED_REVIEW_BLOCK_RE = re.compile( + r"错误[::]\s*(?P.*?)\s*[\r\n]+" + r"原文[::]\s*(?P.*?)\s*[\r\n]+" + r"建议[::]\s*(?P.*?)(?=\r?\n\s*\r?\n|\r?\n?###)", + re.S, +) def parse_review_output(text: str) -> List[dict]: text = text.strip() @@ -497,40 +555,302 @@ def parse_review_output(text: str) -> List[dict]: return result +def _has_repeating_review_tail(text: str) -> bool: + """检测模型是否开始连续重复同一条完整记录。""" + matches = list(_COMPLETED_REVIEW_BLOCK_RE.finditer(text)) + if len(matches) < 2: + return False + + def record_key(match) -> tuple: + return ( + match.group("error").strip(), + match.group("original").strip(), + match.group("suggestion").strip(), + ) + + return record_key(matches[-1]) == record_key(matches[-2]) + + def _split_review_error_types(error: str) -> List[str]: """按原顺序拆分并去重校对错误类型。""" + aliases = { + "逻辑错误": "逻辑不通", + "逻辑不顺": "逻辑不通", + "错别字符": "错别字", + } result = [] for error_type in re.split(r"[、,,]", error or ""): - error_type = error_type.strip() + error_type = aliases.get(error_type.strip(), error_type.strip()) if error_type and error_type not in result: result.append(error_type) return result def _is_whitespace_only_change(original: str, suggestion: str) -> bool: - """判断原文与建议是否仅存在空白或末尾标点差异。""" - if not original or not suggestion or original == suggestion: + """判断原文与建议是否相同,或仅存在空白差异。""" + if not original or not suggestion: return False + def normalize(value: str) -> str: - # 统一全角/半角字符,避免“(4)”与“(4)”被误判为内容修改。 value = unicodedata.normalize("NFKC", str(value)) - value = re.sub(r"\s+", "", value) - # 校对模型有时会给原文补充句末冒号、句号等格式标点; - # 若除此之外内容完全一致,则不作为实际错误返回。 - return re.sub(r"[,。;:、,.!?!?:;]+$", "", value) + return re.sub(r"\s+", "", value) return normalize(original) == normalize(suggestion) +_HEADING_PREFIX_RE = re.compile( + r"^\s*(?:" + r"[一二三四五六七八九十百]+、" + r"|[((][一二三四五六七八九十百\d]+[))]" + r"|\d+(?:\.\d+)+(?=\s*[\u4e00-\u9fff])" + r"|\d+(?:\.\d+)*[.、]" + r")" +) +_TRAILING_PUNCTUATION_RE = re.compile(r"[,。;:、,.!?!?:;]+$") +_FIGURE_CAPTION_RE = re.compile( + r"^\s*图\s*(?P\d*)\s*[::]\s*(?P.+?)\s*$" +) + + +def _is_standalone_heading_punctuation_change( + original: str, + suggestion: str, +) -> bool: + """过滤只修改独占一行编号标题末尾标点的过度校对。""" + if "\n" in original or "\n" in suggestion: + return False + if not _HEADING_PREFIX_RE.match(original): + return False + original_body = _TRAILING_PUNCTUATION_RE.sub("", original.strip()) + suggestion_body = _TRAILING_PUNCTUATION_RE.sub("", suggestion.strip()) + return ( + original_body == suggestion_body + and original.strip() != suggestion.strip() + ) + + +def _is_figure_numbering_change( + original: str, + suggestion: str, +) -> bool: + """ + 分块无法获知全文图号。若标题内容未变,只新增、删除或修改图号, + 一律不作为校对结果返回。 + """ + original_match = _FIGURE_CAPTION_RE.match(original or "") + suggestion_match = _FIGURE_CAPTION_RE.match(suggestion or "") + if not original_match or not suggestion_match: + return False + return ( + original_match.group("title").strip() + == suggestion_match.group("title").strip() + and original_match.group("number") + != suggestion_match.group("number") + ) + + +def _review_content_skeleton(value: str) -> str: + """保留字母和数字,用于识别仅标点发生变化的建议。""" + normalized = unicodedata.normalize("NFKC", str(value)) + return "".join( + char for char in normalized + if unicodedata.category(char)[:1] in {"L", "N"} + ) + + +def _is_punctuation_only_change(original: str, suggestion: str) -> bool: + return ( + bool(original) + and bool(suggestion) + and original != suggestion + and _review_content_skeleton(original) + == _review_content_skeleton(suggestion) + ) + + +def _is_heading_number_change(original: str, suggestion: str) -> bool: + """识别标题编号变化,避免模型误标为错别字或逻辑错误。""" + original_match = _HEADING_PREFIX_RE.match(original) + suggestion_match = _HEADING_PREFIX_RE.match(suggestion) + if not original_match or not suggestion_match: + return False + return original_match.group() != suggestion_match.group() + + +def _is_adjacent_repetition_removal( + original: str, + suggestion: str, +) -> bool: + """识别“常见常见 -> 常见”这类明确的相邻重复删除。""" + if not original or len(original) <= len(suggestion): + return False + removed_length = len(original) - len(suggestion) + if removed_length > 50: + return False + + for start in range(0, len(original) - removed_length * 2 + 1): + repeated = original[start:start + removed_length] + if ( + repeated + and original[start + removed_length:start + removed_length * 2] + == repeated + and original[:start] + original[start + removed_length:] + == suggestion + ): + return True + return False + + +_CREDENTIAL_EXPOSURE_RE = re.compile( + r"(?i)(?:" + r"(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://[^:\s/]+:[^@\s]+@" + r"|(?:password|passwd|secret|token|api[_-]?key)\s*[:=]\s*[^\s,,;;]+" + r")" +) +_COMMAND_TEXT_RE = re.compile( + r"(?i)(?:^|\s)(?:--?[a-z][\w-]*|docker(?:\s|$)|dockker(?:\s|$)|doker(?:\s|$))" +) + + +def _contains_credential_exposure(text: str) -> bool: + return bool(_CREDENTIAL_EXPOSURE_RE.search(text or "")) + + +def _is_likely_local_text_correction( + original: str, + suggestion: str, +) -> bool: + """ + 识别错字、漏字、短语润色和命令拼写等局部修改。 + 这类修改不能仅通过改写错误标签混入逻辑或合规单选结果。 + """ + if not original or not suggestion or original == suggestion: + return False + if _COMMAND_TEXT_RE.search(original): + return True + + edits = [ + (tag, original[i1:i2], suggestion[j1:j2]) + for tag, i1, i2, j1, j2 in SequenceMatcher( + None, + original, + suggestion, + autojunk=False, + ).get_opcodes() + if tag != "equal" + ] + if not edits: + return False + + changed_text = "".join( + original_part + suggestion_part + for _, original_part, suggestion_part in edits + ) + if any(char.isdigit() for char in changed_text): + # 年份、日期、数值的前后冲突可能是真正的逻辑问题。 + return False + + total_changed = sum( + len(original_part) + len(suggestion_part) + for _, original_part, suggestion_part in edits + ) + max_span = max( + max(len(original_part), len(suggestion_part)) + for _, original_part, suggestion_part in edits + ) + return total_changed <= 8 and max_span <= 4 + + +def _minimize_multiline_review_item( + item: dict, + source_text: str, +) -> Optional[dict]: + """ + 行式返回协议无法承载多行原文。对于“标签换行值”缺少冒号的情况, + 将“名称\\n主发动机 -> 名称:主发动机”缩小为“名称 -> 名称:”。 + 其他不能安全缩小的多行修改直接丢弃,避免前端只显示第一行后误替换。 + """ + original = str(item.get("original", "")).strip() + suggestion = str(item.get("suggestion", "")).strip() + if "\n" not in original and "\r" not in original: + return dict(item) + + original_lines = [ + line.strip() for line in original.splitlines() + if line.strip() + ] + if len(original_lines) < 2 or "\n" in suggestion or "\r" in suggestion: + return None + + original_head = original_lines[0] + unchanged_tail = "".join(original_lines[1:]) + compact_suggestion = re.sub(r"\s+", "", suggestion) + if not unchanged_tail or not compact_suggestion.endswith(unchanged_tail): + return None + + minimized_suggestion = compact_suggestion[:-len(unchanged_tail)] + if ( + not minimized_suggestion + or minimized_suggestion == original_head + or original_head not in source_text + ): + return None + + minimized = dict(item) + minimized["original"] = original_head + minimized["suggestion"] = minimized_suggestion + return minimized + + +def _normalize_review_item_error(item: dict) -> dict: + """让明显可判定的错误类型与实际修改保持一致。""" + normalized = dict(item) + original = str(normalized.get("original", "")).strip() + suggestion = str(normalized.get("suggestion", "")).strip() + + if _contains_credential_exposure(original): + normalized["error"] = "合规问题" + elif _is_adjacent_repetition_removal(original, suggestion): + normalized["error"] = "重复内容" + elif _is_heading_number_change(original, suggestion): + normalized["error"] = "格式不规范" + elif _is_punctuation_only_change(original, suggestion): + normalized["error"] = "标点不规范" + elif _is_likely_local_text_correction(original, suggestion): + normalized["error"] = "错别词语" + else: + normalized["error"] = "、".join( + _split_review_error_types(normalized.get("error", "")) + ) + return normalized + + def repair_review_output( model_output: str, source_text: str, positive_words: Optional[Set[str]] = None, + sensitive_words: Optional[Set[str]] = None, + types: Optional[List[str]] = None, ) -> str: """修复提前改写原文以及修改范围重叠的校对结果。""" - items = parse_review_output(model_output) + sensitive_word_set = sensitive_words or set() + parsed_items = parse_review_output(model_output) + items = [] + for item in parsed_items: + minimized_item = _minimize_multiline_review_item(item, source_text) + if minimized_item is not None: + normalized_item = _normalize_review_item_error(minimized_item) + if ( + str(minimized_item.get("original", "")).strip() + in sensitive_word_set + and "敏感词汇" in _split_review_error_types( + minimized_item.get("error", "") + ) + ): + normalized_item["error"] = "敏感词汇" + items.append(normalized_item) if not items: - return model_output + return "###\n###" corrections = [ item for item in items @@ -609,15 +929,59 @@ def repair_review_output( 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 - and item.get("original", "").strip() - and item.get("error", "").strip() - and not _is_whitespace_only_change( - item.get("original", ""), item.get("suggestion", "") + allowed_error_types = ( + get_allowed_review_error_types(types) + if types is not None + else None + ) + if allowed_error_types is not None and not sensitive_word_set: + allowed_error_types.discard("敏感词汇") + final_items = [] + seen_records = set() + for index, item in enumerate(repaired_items): + original = item.get("original", "").strip() + suggestion = item.get("suggestion", "").strip() + error_types = _split_review_error_types(item.get("error", "")) + suggestion_is_valid = bool(suggestion) or "重复内容" in error_types + if ( + index in covered_indexes + or not original + or not suggestion_is_valid + or not error_types + or original not in source_text + or ( + bool(suggestion) + and _is_whitespace_only_change(original, suggestion) + ) + or _is_standalone_heading_punctuation_change(original, suggestion) + or _is_figure_numbering_change(original, suggestion) + or _is_heading_number_change(original, suggestion) + ): + continue + if ( + allowed_error_types is not None + and any( + error_type not in allowed_error_types + for error_type in error_types + ) + ): + continue + if ( + "敏感词汇" in error_types + and original not in sensitive_word_set + ): + continue + normalized_item = dict(item) + normalized_item["error"] = "、".join(error_types) + record_key = ( + original, + normalized_item["error"], + suggestion, ) - ] + if record_key in seen_records: + continue + seen_records.add(record_key) + final_items.append(normalized_item) # 正词命中的“错别字”记录不视为错误,例如“维休”被定义为正词时, # 不返回“维休”→“维修”的错别字建议。 @@ -635,6 +999,8 @@ def repair_review_output( for item in final_items if item.get("original", "").strip() ] + if not blocks: + return "###\n###" return "###\n" + "\n\n".join(blocks) + "\n###" @@ -656,12 +1022,16 @@ async def call_review_model(prompt: Iterable[ChatCompletionMessageParam]) -> str async for chunk in stream_review_model(prompt): full_text += chunk + if _has_repeating_review_tail(full_text): + logger.warning("[公文校对] 检测到模型连续重复同一条记录,提前结束本块输出") + break return full_text def build_review_stream_require( require: Optional[str], + types: List[str], sensitive_words: List[str], negative_words: List[str], positive_words: List[str], @@ -671,16 +1041,18 @@ def build_review_stream_require( if require and require.strip(): requirements.append(require.strip()) + selected_types = set(normalize_review_types(types)) + sensitive_text = "、".join(dict.fromkeys(word.strip() for word in sensitive_words if word and word.strip())) - if sensitive_text: + if sensitive_text and "合规性检查" in selected_types: requirements.append(f"请重点检查以下敏感词:{sensitive_text}。如果原文出现这些词,错误类型写“敏感词汇”。") negative_text = "、".join(dict.fromkeys(word.strip() for word in negative_words if word and word.strip())) - if negative_text: + if negative_text and "基础校对" in selected_types: requirements.append(f"请重点检查以下错误词:{negative_text}。如果原文出现这些词,错误类型写“错误词汇”。") positive_text = "、".join(dict.fromkeys(word.strip() for word in positive_words if word and word.strip())) - if positive_text: + if positive_text and "基础校对" in selected_types: requirements.append(f"以下词为正确词,涉及错别字判断时不要误判:{positive_text}。") if not requirements: @@ -729,6 +1101,7 @@ async def stream_review_content( ): review_require = build_review_stream_require( require=require, + types=types, sensitive_words=sensitive_words, negative_words=negative_words, positive_words=positive_words, @@ -754,6 +1127,8 @@ async def stream_review_content( model_output, chunk, positive_words=normalize_words(positive_words), + sensitive_words=normalize_words(sensitive_words), + types=types, ) # 仅打印过滤后的最终保留记录,不打印模型原始输出或中间结果。 if repaired_output.strip() and repaired_output.strip() != "###\n###": @@ -1796,4 +2171,4 @@ async def root(): if __name__ == "__main__": import uvicorn - uvicorn.run(app, host="0.0.0.0", port=9090) + uvicorn.run(app, host="0.0.0.0", port=9095) diff --git a/config.py b/config.py index e8122b9..b9c631a 100644 --- a/config.py +++ b/config.py @@ -13,7 +13,7 @@ load_dotenv() # ==================== 大模型配置 ==================== LLM_CONFIG = { # "model": "Qwen3.5-35B-A3B", - "model": "46-qwen3.5-35B", + "model": "46-qwen3.6-35B", # "model": "Qwen3-14B", "base_url": "http://192.168.0.46:59800/v1", "api_key": "gpustack_dee9ca823290886c_5edfc86aeeeceb1e9ee5162941cb2cb5", diff --git a/gw_write.py b/gw_write.py index 072456e..58b8672 100644 --- a/gw_write.py +++ b/gw_write.py @@ -52,11 +52,41 @@ from openai.types.chat import ChatCompletionSystemMessageParam, ChatCompletionUs logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +REVIEW_TYPE_ERROR_MAP = { + "基础校对": { + "错别字", + "错别词语", + "标点不规范", + "格式不规范", + "重复内容", + "错误词汇", + }, + "逻辑校对": {"逻辑不通"}, + "合规性检查": {"合规问题", "敏感词汇"}, +} +REVIEW_TYPE_ORDER = tuple(REVIEW_TYPE_ERROR_MAP) -def cotprompt(query): + +def normalize_review_types(types: List[str]) -> List[str]: + """按前端传入顺序去重,只保留支持的校对类型。""" + return list(dict.fromkeys( + review_type for review_type in types + if review_type in REVIEW_TYPE_ERROR_MAP + )) + + +def get_allowed_review_error_types(types: List[str]) -> Set[str]: + """返回本次所选校对项允许输出的错误类型。""" + allowed = set() + for review_type in normalize_review_types(types): + allowed.update(REVIEW_TYPE_ERROR_MAP[review_type]) + return allowed + + +def cotprompt(query, types: Optional[List[str]] = None): # 精简 Few-Shot:只示范「输入文本 -> 结构化修改结果」,不再包含推理过程/思考链, # 从源头杜绝模型输出解释、分析、括号备注等多余内容,同时大幅缩短每次请求的前缀长度。 - examples = [ + basic_examples = [ { # 综合示例:覆盖错别字/错别词语/标点不规范/重复内容, # 重点示范:多余标点(。。。)、全半角括号 -> 标点不规范(不是逻辑不通) @@ -74,34 +104,78 @@ def cotprompt(query): }, { # 重复内容的原文必须带唯一上下文,建议返回删除后的完整片段。 - "question": "2026年通知:2025年通知内容从下述角度开战:一、项目推进方面。各部门需加快项目进度,确保按时交付。\n\n二、培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n\n二、纪律要求。近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。\n\n特此通知:", + "question": "2026年通知:2026年通知内容如下:\n\n一、项目推进方面\n各部门需加快项目进度,确保按时交付。\n\n二、培训学习方面\n公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n\n二、纪律要求\n近期发现部分员工上班时间做与工作无关的事情,如炒股、聊天等,严重影响工作效率和公司形象。自即日起,严禁在工作时间从事与工作无关的活动。违反规定者,将按照公司规章制度严肃处理。\n\n特此通知:", "answer": ( "###\n" - "错误:逻辑错误\n" - "原文:2026年通知:2025年通知内容从下述角度开战:\n" - "建议:2026年通知:2026年通知内容从下述角度开展:\n\n" - "错误:标点不规范\n" - "原文:一、项目推进方面。\n" - "建议:一、项目推进方面:\n\n" "错误:错别词语、重复内容\n" - "原文:培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n" - "建议:培训学习方面:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无故缺席。\n\n" - "错误:格式不规范\n" - "原文:二、纪律要求。\n" - "建议:三、纪律要求:\n\n" + "原文:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无辜缺席。自即日起,严禁在工作时间从事与工作无关的活动。\n" + "建议:公司将组织系列培训课程,提升员工专业技能。具体时间、地点另行通知。员工需按时参加,不得无故缺席。\n\n" "错误:标点不规范\n" "原文:特此通知:\n" "建议:特此通知。\n" "###" ), }, + ] + + logic_examples = [ { - # 无明显错误示例:只返回空标记,禁止输出任何说明、分析或括号备注 - "question": "全年审核采购合同、对账单据数百份,均按规定完成登记归档。", + "question": "项目计划于2026年1月启动,并于2025年12月完成验收。", + "answer": ( + "###\n" + "错误:逻辑不通\n" + "原文:项目计划于2026年1月启动,并于2025年12月完成验收。\n" + "建议:项目计划于2026年1月启动,并于2026年12月完成验收。\n" + "###" + ), + }, + ] + logic_scope_examples = [ + { + # 单选逻辑校对时,错别字、格式和措辞润色都必须忽略。 + "question": "实习培训是理论与时间结合的关键环节。3.9保秘性。第二步:再输入密码连接服务器。", "answer": "###\n###", }, ] + compliance_examples = [ + { + "question": "数据库连接地址为postgresql://admin:password123@10.0.0.8:5432/app。", + "answer": ( + "###\n" + "错误:合规问题\n" + "原文:postgresql://admin:password123@10.0.0.8:5432/app\n" + "建议:postgresql://admin:***@10.0.0.8:5432/app\n" + "###" + ), + }, + ] + compliance_scope_examples = [ + { + # 普通保密、军用、核心技术等客观表述不因名称本身构成违规。 + "question": "本章介绍保密资料、军用设备维修手册、未公开方案和核心技术参数。文中另有“维休”“保秘性”等错字。", + "answer": "###\n###", + }, + ] + + selected_types = normalize_review_types(types or list(REVIEW_TYPE_ORDER)) + selected_type_set = set(selected_types) + examples = [] + if "基础校对" in selected_type_set: + examples.extend(basic_examples) + if "逻辑校对" in selected_type_set: + examples.extend(logic_examples) + if "合规性检查" in selected_type_set: + examples.extend(compliance_examples) + if selected_type_set == {"逻辑校对"}: + examples.extend(logic_scope_examples) + elif selected_type_set == {"合规性检查"}: + examples.extend(compliance_scope_examples) + examples.append({ + "question": "全年审核采购合同、对账单据数百份,均按规定完成登记归档。", + "answer": "###\n###", + }) + # 单个示例模板:只保留 问题/答案,删除“推理过程” example_prompt = PromptTemplate( input_variables=["question", "answer"], @@ -112,28 +186,29 @@ def cotprompt(query): examples=examples, example_prompt=example_prompt, prefix=( - "你是专业文本校对专家,负责检查错别字、错别词语、错误标点、标题格式与序号、重复内容、逻辑不通及合规问题。\n" + "你是专业文本校对专家,具备检查错别字、错别词语、错误标点、标题格式、重复内容、逻辑不通及合规问题的能力;本次只执行任务要求中明确选中的校对项。\n" "输出规则(务必严格遵守):\n" "1. 每个错误必须连续占三行:第一行“错误:<错误类型>”,第二行“原文:<有错的原文片段>”,第三行“建议:<修改后的正确文本>”。三行之间不得插入空行;不同错误之间空一行。整体用 ### 包裹。\n" - "2. 错误类型只能从以下选择:错别字、错别词语、标点不规范、格式不规范、重复内容、逻辑不通、合规问题。同一连续原文片段存在修改范围重叠的多类错误时,必须合并为一条,错误类型用中文顿号连接,并在一条建议中一次改正。标题序号重复、跳号、顺序错误或层级不一致归为“格式不规范”;标题末尾的纯标点问题归为“标点不规范”。\n" + "2. 错误类型只能从以下选择:错别字、错别词语、标点不规范、格式不规范、重复内容、错误词汇、逻辑不通、合规问题、敏感词汇。同一连续原文片段存在修改范围重叠的多类错误时,必须合并为一条,错误类型用中文顿号连接,并在一条建议中一次改正。\n" "3. 凡是标点问题(多余或重复标点如“。。。”、全角/半角标点混用如英文括号()应为中文()、标点缺失或误用),一律归为“标点不规范”,禁止归为“逻辑不通”;“逻辑不通”只用于前后文语义矛盾、指代不清等真正的逻辑问题。\n" - "4. 建议字段只写修改后的正确内容,禁止输出任何解释、分析、推理、评论或括号备注(例如禁止出现“(删除该句……)”“(此处无明显错误……)”“(通常……可接受)”这类内容)。\n" + "4. 建议字段只写修改后的正确内容,禁止输出任何解释、分析、推理、评论或括号备注(例如禁止出现“(删除该句……)”“(此处无明显错误……)”“(通常……可接受)”这类内容)。原文和建议必须确有差异;两者相同或仅空白不同不得输出。相同原文不得重复输出。\n" "5. 判定重复内容时,必须结合全文语义判断是否造成冗余,不能仅凭文字相同判错。若两处分别承担必要的说明、强调、引用或衔接作用,则不得输出校对项。\n" "6. 确认属于重复内容后,必须比较每次出现位置与标题主题、前后文和表达作用的匹配程度,保留主题最匹配、逻辑最完整的一处,禁止机械删除第一次或最后一次。\n" "7. 删除局部重复内容时,原文必须包含足以唯一定位的连续上下文;建议必须返回删除重复内容后的同一完整片段,不得留空。只有整个原文片段均为应删除的纯冗余内容时,建议才可留空。\n" - "8. 必须按错别字和错别词语、标点、标题格式与序号、重复内容、逻辑、合规的顺序逐项检查全文。发现某类错误后仍须继续检查其他类别。\n" - "9. 若整段文本没有任何错误,只返回:###\n###,不要输出其它任何字符。\n" + "8. 独占一行的各级标题允许不带句末标点,不得机械添加冒号或句号。只有原文明确采用“标题与正文在同一行”的格式且标点确有错误时,才校对标题末尾标点。\n" + "9. 原文和建议字段都只能占一行、不得包含换行。若版式把“名称”和“主发动机”等标签与值分成多行,而实际仅缺冒号,应输出最小替换片段,例如“原文:名称”“建议:名称:”,不得把下一行值合并进建议。\n" + "10. 输入是从长文中截取的中间分块,无法获知全文标题序号和图号。禁止新增、删除、替换或重排任何标题序号,不得把“(二)”改为“一、”,也不得把“三、”改为“一、”。“图:故障定位”这类图片说明是允许的原文格式,禁止补成“图1:故障定位”,也禁止新增、删除或修改任何图号。标题序号与图片全局编号均不属于本次校对范围。\n" + "11. 必须仅对本次任务选中的类别逐项检查全文。发现某类错误后仍须继续检查其他已选类别,但禁止检查和输出未选类别。\n" + "12. 若整段文本没有任何错误,只返回:###\n###,不要输出其它任何字符。输出完全部真实问题后立即结束,严禁重复已有记录或为了凑数量继续输出。\n" "请参照以下示例完成校对:" ), suffix=( "问题:{question}\n" - "输出答案前必须在内部完成以下扫描,但不要输出扫描过程:\n" - "A. 逐字检查错别字和错别词语;\n" - "B. 逐行检查标题及结尾标点,同级标题格式必须一致;标题后直接接正文时检查是否应使用冒号;\n" - "C. 按正文顺序检查各级标题序号是否重复、跳号、倒序或层级错误;\n" - "D. 全文检索重复句子和连续文本,结合标题主题判断是否冗余;\n" - "E. 继续检查逻辑与合规问题。\n" - "必须完成 A 至 E 后再输出全部问题,不得提前结束或限制问题数量。\n" + "输出答案前只对任务要求中明确选中的类别完成内部扫描,不要输出扫描过程:\n" + "选择基础校对时,检查错别字、错别词语、标点、标题文字格式、重复内容及错误词汇,但不得修改标题序号;\n" + "选择逻辑校对时,检查前后矛盾、时间冲突、因果关系、指代和上下文逻辑;\n" + "选择合规性检查时,检查合规问题及敏感词汇。\n" + "未选中的类别不得检查、修改和输出。输出完真实问题后立即结束,不得重复记录。\n" "每条原文必须从本次输入正文中逐字、连续、原样复制,严禁提前采用修改结果。建议只能写可直接替换的最终正文。\n" "答案:" ), @@ -147,30 +222,55 @@ def build_review_prompt(types: List[str], content: str, require: Optional[str]) if require is None: require = "" require = require.strip() - review_t = "现在需要你帮我完成以下文本校对任务:" - for i , t in enumerate(types): + selected_types = normalize_review_types(types) + excluded_types = [ + review_type for review_type in REVIEW_TYPE_ORDER + if review_type not in selected_types + ] + review_t = "现在需要你帮我完成以下文本校对任务:\n" + for i, t in enumerate(selected_types, start=1): if t =="逻辑校对": - review_t += f"{i} 对这段文本进行逻辑校对,主要是内容中词语或者其他内容本身是个合规词语,但在文本用词不当,不符合当前内容中逻辑,例如:#2026年终总结:2025年对本公司的业务开战总结,正确的内容应该为:#2026年终总结:2026年对本公司的业务开展总结\n" + 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}.3 进行格式规范校对,但不得修改标题序号或图片编号\n" review_t += f"{i}.4 进行重复内容校对,主要以原文中出现重复词语、句子或文本为主,例如:“今天今天心情相当不错,我很开心。我很开心。“,正确的内容应该为:今天心情相当不错,我很开心。\n" if t=="合规性检查": - review_t += f"{i} 对这段文本进行合规性检查\n" + review_t += ( + f"{i}. 合规性检查:只检查明文账号口令、访问密钥、令牌等凭据泄露," + "以及正文中有明确依据的违法违规、隐私泄露或安全合规问题。" + "“保密资料”“军用设备”“未公开方案”“核心技术参数”等客观名称本身不构成错误," + "不得为了降敏而改写事实。错别字、格式、逻辑和措辞问题均禁止输出。" + "只有额外要求明确提供了敏感词表时,才能输出“敏感词汇”," + "且原文必须是词表中实际命中的原词。\n" + ) + scope_text = ( + f"本次选中的校对项:{'、'.join(selected_types)}。\n" + f"本次禁止检查和输出的校对项:{'、'.join(excluded_types) if excluded_types else '无'}。\n" + "只允许输出所选校对项对应的错误类型;未选类别即使发现问题也禁止修改和输出。" + ) require_text = f"\n额外要求:{require}\n" if require else "" query = f""" 任务要求: {review_t} +校对范围: +{scope_text} {require_text} 注意:只输出结构化校对结果(错误/原文/建议),每条结果的错误、原文、建议必须连续三行,三行之间不得有空行;建议只写修改后的正确文本;没有错误时只返回 ###\n###。 -必须逐项检查错别字和错别词语、标点、标题格式与序号、重复内容、逻辑及合规。重复内容必须结合标题和上下文判断;删除局部重复内容时,原文必须带唯一上下文,建议必须返回删除后的完整上下文。同一片段中修改范围重叠的多类错误必须合并为一条。 +必须仅完整检查上方明确选中的校对项,禁止检查和输出未选类别。重复内容必须结合标题和上下文判断;删除局部重复内容时,原文必须带唯一上下文,建议必须返回删除后的完整上下文。同一片段中修改范围重叠的多类错误必须合并为一条。 待校对正文开始: {content} 待校对正文结束。 """.strip() - combined_prompt = cotprompt(query) + combined_prompt = cotprompt(query, selected_types) return [ ChatCompletionUserMessageParam(role="user", content=combined_prompt), ] @@ -274,16 +374,21 @@ def filter_positive_word_false_positives( for item in items: original = str(item.get("original", "")) + suggestion = str(item.get("suggestion", "")) error = str(item.get("error", "")) has_positive_word = any(word in original for word in positive_words) + changes_positive_word = any( + word in original and word not in suggestion + for word in positive_words + ) is_typo_error = ( "错别字" in error or "错别词" in error or "错别词语" in error ) - if has_positive_word and is_typo_error: + if changes_positive_word or (has_positive_word and is_typo_error): continue result.append(item)