""" 工作流公共工具函数 提取自 workflow_fault_diagnosis.py 和 workflow_operate.py 的共享代码 """ from typing import TypedDict, Optional, Dict, Any, List from difflib import SequenceMatcher from modelsAPI.model_api import OpenaiAPI from utils.function_tracker import get_all_callbacks, get_current_stream_handler from utils.image_utils import extract_image_paths, normalize_markdown_images import asyncio import json import re import random import inspect import unicodedata RAG_DEDUP_SIMILARITY_THRESHOLD = 0.86 RAG_DEDUP_CONTAINMENT_THRESHOLD = 0.92 RAG_DEDUP_MIN_TEXT_LENGTH = 20 def safe_json_extract(text: str) -> Optional[Any]: if not text: return None cleaned = text.strip() cleaned = re.sub(r"```json\s*", "", cleaned, flags=re.IGNORECASE) cleaned = re.sub(r"```\s*", "", cleaned) m = re.search(r"\[[\s\S]*\]", cleaned) if m: try: return json.loads(m.group(0)) except Exception: pass m = re.search(r"\{[\s\S]*\}", cleaned) if m: try: return json.loads(m.group(0)) except Exception: pass try: return json.loads(cleaned) except Exception: return None from workflows.history_manager import parse_history, build_history_str, filter_image_urls, init_history # noqa: F401 import re def normalize_latex_spacing(text: str) -> str: """ 规范 Markdown 中的 LaTeX 公式。 规则: 1. 行内公式 $...$ - 清除公式定界符内部首尾空格 - 公式前后只要存在非空白字符,就补一个空格 - ($T$) 会转换为 ( $T$ ) 2. 段落公式 $$...$$ - 多行公式完整保留,不会删除结尾的 $$ - 单行 $$公式$$ 转换为标准多行段落公式 - Markdown 表格中的 $$公式$$ 不拆行 3. 转换: - \\(...\\) 转换为 $...$ - \\[...\\] 转换为 $$...$$ 4. 不处理: - Markdown 围栏代码块 - Markdown 行内代码 - HTML code、pre、script、style - HTML 注释 - Markdown 链接地址 - 转义美元符号 \\$ """ if not isinstance(text, str): raise TypeError( f"text 必须是 str,实际类型为 {type(text).__name__}" ) if not text: return text text = text.replace("\r\n", "\n").replace("\r", "\n") protected_parts: list[str] = [] def protect(content: str) -> str: token = ( f"\uE000LATEX_PROTECTED_" f"{len(protected_parts)}" f"\uE001" ) protected_parts.append(content) return token def restore(content: str) -> str: # 保护内容可能再次包含更早生成的占位符,必须按后进先出恢复。 for index in reversed(range(len(protected_parts))): original = protected_parts[index] token = f"\uE000LATEX_PROTECTED_{index}\uE001" content = content.replace(token, original) return content def is_escaped(content: str, position: int) -> bool: """ 判断 content[position] 是否被奇数个反斜杠转义。 """ slash_count = 0 position -= 1 while position >= 0 and content[position] == "\\": slash_count += 1 position -= 1 return slash_count % 2 == 1 # ================================================================ # 1. 保护 Markdown 围栏代码块 # ================================================================ lines = text.splitlines(keepends=True) protected_lines: list[str] = [] line_index = 0 while line_index < len(lines): line = lines[line_index] opening_match = re.match( r"^[ \t]{0,3}(`{3,}|~{3,})", line, ) if not opening_match: protected_lines.append(line) line_index += 1 continue opening_fence = opening_match.group(1) fence_character = opening_fence[0] fence_length = len(opening_fence) closing_pattern = re.compile( rf"^[ \t]{{0,3}}" rf"{re.escape(fence_character)}" rf"{{{fence_length},}}" rf"[ \t]*(?:\n|$)" ) block_lines = [line] line_index += 1 while line_index < len(lines): current_line = lines[line_index] block_lines.append(current_line) line_index += 1 if closing_pattern.match(current_line): break protected_lines.append( protect("".join(block_lines)) ) text = "".join(protected_lines) # ================================================================ # 2. 保护 HTML 代码区域和注释 # ================================================================ text = re.sub( r"(?is)" r"<(?Ppre|code|script|style)\b[^>]*>" r".*?" r"", lambda match: protect(match.group(0)), text, ) text = re.sub( r"(?s)", lambda match: protect(match.group(0)), text, ) # ================================================================ # 3. 保护 Markdown 链接地址 # ================================================================ text = re.sub( r"(!?\[[^\]\n]*\]\()([^)\n]*)(\))", lambda match: ( match.group(1) + protect(match.group(2)) + match.group(3) ), text, ) text = re.sub( r"(?im)" r"^([ \t]{0,3}\[[^\]\n]+\]:[ \t]*)" r"(\S+(?:[ \t]+.*)?)$", lambda match: ( match.group(1) + protect(match.group(2)) ), text, ) # ================================================================ # 4. 保护 Markdown 行内代码 # ================================================================ inline_code_result: list[str] = [] position = 0 while position < len(text): if text[position] != "`": inline_code_result.append(text[position]) position += 1 continue delimiter_end = position while ( delimiter_end < len(text) and text[delimiter_end] == "`" ): delimiter_end += 1 delimiter = text[position:delimiter_end] search_position = delimiter_end closing_position = -1 while search_position < len(text): candidate = text.find( delimiter, search_position, ) if candidate < 0: break previous_character = ( text[candidate - 1] if candidate > 0 else "" ) candidate_end = candidate + len(delimiter) next_character = ( text[candidate_end] if candidate_end < len(text) else "" ) if ( previous_character != "`" and next_character != "`" ): closing_position = candidate break search_position = candidate + 1 if closing_position < 0: inline_code_result.append(delimiter) position = delimiter_end continue end_position = closing_position + len(delimiter) inline_code_result.append( protect(text[position:end_position]) ) position = end_position text = "".join(inline_code_result) # ================================================================ # 5. 转换其他 LaTeX 公式定界符 # ================================================================ # \( x + y \) -> $x + y$ text = re.sub( r"(? $$x + y$$ text = re.sub( r"(?s)(? bool: compact_body = re.sub(r"\s+", "", formula_body) if len(compact_body) >= 46: return True has_relation = bool( re.search( r"(?]" r"|\\(?:leq?|geq?|approx|sim|equiv|propto|rightarrow|mapsto)\b", formula_body, ) ) has_large_operator = bool( re.search( r"\\(?:sum|int|iint|iiint|prod|lim|begin|cases|matrix)\b", formula_body, ) ) has_structured_expression = bool( re.search( r"\\(?:d?frac|sqrt|left|right|overbrace|underbrace)\b", formula_body, ) ) return ( has_large_operator or ( has_relation and has_structured_expression and len(compact_body) >= 24 ) ) promoted_result: list[str] = [] position = 0 while position < len(text): is_inline_opening = ( text[position] == "$" and not is_escaped(text, position) and not text.startswith("$$", position) and not ( position > 0 and text[position - 1] == "$" ) ) if not is_inline_opening: promoted_result.append(text[position]) position += 1 continue opening_position = position closing_position = -1 search_position = position + 1 while search_position < len(text): candidate = text.find("$", search_position) if candidate < 0: break if "\n" in text[opening_position + 1:candidate]: break if is_escaped(text, candidate): search_position = candidate + 1 continue if text.startswith("$$", candidate): search_position = candidate + 2 continue closing_position = candidate break if closing_position < 0: promoted_result.append("$") position += 1 continue formula_body = text[ opening_position + 1:closing_position ].strip() line_start = text.rfind("\n", 0, opening_position) + 1 line_end = text.find("\n", closing_position + 1) if line_end < 0: line_end = len(text) stripped_line = text[line_start:line_end].strip() is_markdown_table_row = ( stripped_line.startswith("|") and stripped_line.endswith("|") ) if ( not formula_body or is_markdown_table_row or not should_promote_inline_formula(formula_body) ): promoted_result.append( text[opening_position:closing_position + 1] ) position = closing_position + 1 continue before_text = "".join(promoted_result) if before_text and not before_text.endswith("\n\n"): before_text = before_text.rstrip(" \t\n") + "\n\n" promoted_result = [before_text] if before_text else [] promoted_result.append( "$$\n" + formula_body + "\n$$" ) position = closing_position + 1 while position < len(text) and text[position] in " \t": position += 1 # 独立公式后的句号不再单独占一段;逗号和冒号仍保留语义。 if position < len(text) and text[position] in "。.": position += 1 if position < len(text): while position < len(text) and text[position] == "\n": position += 1 promoted_result.append("\n\n") text = "".join(promoted_result) # ================================================================ # 7. 完整提取并保护 $$...$$ 段落公式 # ================================================================ display_result: list[str] = [] position = 0 while position < len(text): if not ( text.startswith("$$", position) and not is_escaped(text, position) ): display_result.append(text[position]) position += 1 continue opening_position = position search_position = position + 2 closing_position = -1 while search_position < len(text): candidate = text.find( "$$", search_position, ) if candidate < 0: break if not is_escaped(text, candidate): closing_position = candidate break search_position = candidate + 2 # 未找到闭合 $$,剩余内容原样保留。 if closing_position < 0: display_result.append( text[opening_position:] ) position = len(text) break complete_formula = text[ opening_position:closing_position + 2 ] formula_body = text[ opening_position + 2:closing_position ] # ------------------------------------------------------------ # 多行段落公式也统一重建,并确保公式块前后各有一个空行。 # 相邻公式若只有一个换行,部分 Markdown/KaTeX 解析器会把它们 # 合并到同一个显示块中,导致两条公式横向排在同一行。 # ------------------------------------------------------------ if "\n" in complete_formula: formula_lines = [ line.strip() for line in formula_body.splitlines() if line.strip() ] contains_multiline_environment = bool( re.search( r"\\(?:begin|end|substack)\b" r"|\\\\", formula_body, ) ) lines_are_independent_equations = ( len(formula_lines) > 1 and not contains_multiline_environment and all( re.search( r"(?]" r"|\\(?:leq?|geq?|approx|sim|equiv|propto)\b", line, ) for line in formula_lines ) ) if lines_are_independent_equations: normalized_formula = "\n\n".join( "$$\n" + line + "\n$$" for line in formula_lines ) else: normalized_formula = ( "$$\n" + formula_body.strip() + "\n$$" ) before_text = "".join(display_result) if before_text and not before_text.endswith("\n\n"): before_text = before_text.rstrip(" \t\n") + "\n\n" display_result = [before_text] if before_text else [] display_result.append( protect(normalized_formula) ) position = closing_position + 2 while ( position < len(text) and text[position] in " \t" ): position += 1 if position < len(text): while ( position < len(text) and text[position] == "\n" ): position += 1 display_result.append("\n\n") continue # ------------------------------------------------------------ # 判断公式是否位于 Markdown 表格内。 # 表格中的公式不能强制拆成多行。 # ------------------------------------------------------------ line_start = text.rfind( "\n", 0, opening_position, ) + 1 line_end = text.find( "\n", closing_position + 2, ) if line_end < 0: line_end = len(text) current_line = text[line_start:line_end] clean_body = formula_body.strip() stripped_line = current_line.strip() is_markdown_table_row = ( stripped_line.startswith("|") and stripped_line.endswith("|") ) if is_markdown_table_row: display_result.append( protect( "$$" + clean_body + "$$" ) ) position = closing_position + 2 continue # ------------------------------------------------------------ # 单行段落公式转换成标准多行格式。 # # $$R = K$$ # # 转换为: # # $$ # R = K # $$ # ------------------------------------------------------------ normalized_formula = ( "$$\n" + clean_body + "\n$$" ) before_text = "".join(display_result) # 公式前存在普通正文时,确保前面有空行。 if before_text and not before_text.endswith("\n\n"): before_text = before_text.rstrip(" \t\n") + "\n\n" display_result = [before_text] if before_text else [] display_result.append( protect(normalized_formula) ) position = closing_position + 2 # 清理公式后已有的普通空格。 while ( position < len(text) and text[position] in " \t" ): position += 1 # 后面还有内容时,确保公式后有空行。 if position < len(text): if text[position] == "\n": while ( position < len(text) and text[position] == "\n" ): position += 1 display_result.append("\n\n") text = "".join(display_result) # ================================================================ # 8. 规范 $...$ 行内公式 # ================================================================ inline_result: list[str] = [] position = 0 while position < len(text): is_inline_opening = ( text[position] == "$" and not is_escaped(text, position) and not text.startswith("$$", position) and not ( position > 0 and text[position - 1] == "$" ) ) if not is_inline_opening: inline_result.append(text[position]) position += 1 continue opening_position = position # 避免把 $100 识别成行内公式开始。 next_opening_character = ( text[position + 1] if position + 1 < len(text) else "" ) if next_opening_character.isdigit(): inline_result.append("$") position += 1 continue search_position = position + 1 closing_position = -1 while search_position < len(text): candidate = text.find( "$", search_position, ) if candidate < 0: break # 行内公式不能跨行。 if "\n" in text[ opening_position + 1:candidate ]: break if is_escaped(text, candidate): search_position = candidate + 1 continue # 跳过属于 $$ 的美元符号。 if text.startswith("$$", candidate): search_position = candidate + 2 continue closing_position = candidate break # 未找到闭合符时原样保留。 if closing_position < 0: inline_result.append("$") position += 1 continue formula_body = text[ opening_position + 1:closing_position ].strip() # 空公式不处理。 if not formula_body: inline_result.append( text[ opening_position:closing_position + 1 ] ) position = closing_position + 1 continue # 折叠公式内部普通空格,不修改 LaTeX 命令。 formula_body = re.sub( r"[ \t]+", " ", formula_body, ) previous_character = "" for part in reversed(inline_result): if part: previous_character = part[-1] break next_character = ( text[closing_position + 1] if closing_position + 1 < len(text) else "" ) # 公式前只要存在非空白字符,就补空格。 # # 参数$T$ -> 参数 $T$ # ($T$) -> ( $T$ # ($T$) -> ( $T$ if ( previous_character and not previous_character.isspace() ): inline_result.append(" ") inline_result.append( "$" + formula_body + "$" ) # 公式后只要存在非空白字符,就补空格。 # # $T$参数 -> $T$ 参数 # ($T$) -> $T$ ) # ($T$) -> $T$ ) if ( next_character and not next_character.isspace() ): inline_result.append(" ") position = closing_position + 1 return restore("".join(inline_result)) def convert_display_math_for_frontend(text: str) -> str: """将段落公式改为 16000 前端能正确识别的块级定界符。""" return re.sub( r"(?ms)^[ \t]*\$\$[ \t]*\n" r"(.*?)" r"\n[ \t]*\$\$[ \t]*$", lambda match: ( "\\[\n" + match.group(1).strip() + "\n\\]" ), text, ) def remove_duplicate_content(text: str) -> str: """ 删除普通 Markdown 正文中的完全重复行。 不对以下内容进行去重: - $$ ... $$ 段落公式 - \\[ ... \\] 段落公式 - equation、align、gather 等 LaTeX 环境 - Markdown 围栏代码块 - Markdown 缩进代码块 - Markdown 标题、列表、引用、表格等结构行 这样不会删除段落公式末尾的第二个 $$。 """ if not text or len(text) < 10: return text if not isinstance(text, str): raise TypeError( f"text 必须是 str,实际类型为 {type(text).__name__}" ) text = text.replace("\r\n", "\n").replace("\r", "\n") lines = text.split("\n") result: list[str] = [] seen_segments: set[str] = set() in_code_block = False code_fence_character = "" code_fence_length = 0 in_dollar_math = False in_bracket_math = False latex_environment_stack: list[str] = [] display_environments = { "equation", "equation*", "align", "align*", "alignat", "alignat*", "gather", "gather*", "multline", "multline*", "flalign", "flalign*", "eqnarray", "eqnarray*", "displaymath", "math", } def is_escaped(value: str, position: int) -> bool: slash_count = 0 position -= 1 while position >= 0 and value[position] == "\\": slash_count += 1 position -= 1 return slash_count % 2 == 1 def count_unescaped_double_dollars(value: str) -> int: """ 统计一行中未转义的 $$ 数量。 """ count = 0 position = 0 while position < len(value) - 1: if ( value[position:position + 2] == "$$" and not is_escaped(value, position) ): count += 1 position += 2 else: position += 1 return count def is_markdown_structure_line(value: str) -> bool: """ Markdown 结构行不参与去重,避免破坏语义。 """ stripped = value.strip() if not stripped: return True patterns = ( # 标题 r"^#{1,6}\s+", # 无序列表 r"^[-+*]\s+", # 有序列表 r"^\d+[.)]\s+", # 引用 r"^>\s*", # 任务列表 r"^[-+*]\s+\[[ xX]\]\s+", # 水平分隔线 r"^(?:-{3,}|\*{3,}|_{3,})$", # 表格分隔行 r"^\|?\s*:?-{3,}:?" r"(?:\s*\|\s*:?-{3,}:?)+\s*\|?$", # 普通表格行 r"^\|.*\|$", # HTML 标签 r"^]*>$", # LaTeX 环境边界 r"^\\(?:begin|end)\{[^}]+\}", # 公式定界符 r"^(?:\$\$|\\\[|\\\])$", # Markdown 链接引用定义 r"^\[[^\]]+\]:\s*\S+", ) return any( re.match(pattern, stripped) for pattern in patterns ) for line in lines: line = line.rstrip() stripped = line.strip() # ============================================================ # 1. Markdown 围栏代码块 # ============================================================ fence_match = re.match( r"^[ \t]{0,3}(`{3,}|~{3,})", line, ) if not in_code_block and fence_match: fence = fence_match.group(1) in_code_block = True code_fence_character = fence[0] code_fence_length = len(fence) result.append(line) continue if in_code_block: result.append(line) closing_pattern = re.compile( rf"^[ \t]{{0,3}}" rf"{re.escape(code_fence_character)}" rf"{{{code_fence_length},}}" rf"[ \t]*$" ) if closing_pattern.match(line): in_code_block = False code_fence_character = "" code_fence_length = 0 continue # Markdown 缩进代码块不参与去重。 if re.match(r"^(?:\t| {4})", line): result.append(line) continue # ============================================================ # 2. \[ ... \] 段落公式 # ============================================================ if in_bracket_math: result.append(line) if re.search(r"(? bracket_close_count: in_bracket_math = True continue # ============================================================ # 3. LaTeX display 环境 # ============================================================ begin_matches = re.findall( r"\\begin\{([^}]+)\}", line, ) end_matches = re.findall( r"\\end\{([^}]+)\}", line, ) relevant_begins = [ environment for environment in begin_matches if environment in display_environments ] relevant_ends = [ environment for environment in end_matches if environment in display_environments ] if latex_environment_stack: result.append(line) for environment in relevant_begins: latex_environment_stack.append(environment) for environment in relevant_ends: if ( latex_environment_stack and latex_environment_stack[-1] == environment ): latex_environment_stack.pop() elif environment in latex_environment_stack: latex_environment_stack.remove(environment) continue if relevant_begins: result.append(line) for environment in relevant_begins: latex_environment_stack.append(environment) for environment in relevant_ends: if ( latex_environment_stack and latex_environment_stack[-1] == environment ): latex_environment_stack.pop() elif environment in latex_environment_stack: latex_environment_stack.remove(environment) continue # ============================================================ # 4. $$ ... $$ 段落公式 # ============================================================ double_dollar_count = ( count_unescaped_double_dollars(line) ) if in_dollar_math: # 公式块中的内容全部保留,包括最后一行的 $$。 result.append(line) if double_dollar_count % 2 == 1: in_dollar_math = False continue if double_dollar_count > 0: # 含有 $$ 的行永远不参与去重。 result.append(line) # 奇数个 $$ 代表开启了跨行段落公式。 if double_dollar_count % 2 == 1: in_dollar_math = True continue # ============================================================ # 5. 空行和 Markdown 结构行 # ============================================================ if not stripped: result.append(line) continue if is_markdown_structure_line(line): result.append(line) continue # ============================================================ # 6. 仅对普通正文行去重 # ============================================================ normalized_line = re.sub( r"\s+", " ", stripped.lower(), ) if normalized_line in seen_segments: continue seen_segments.add(normalized_line) result.append(line) return "\n".join(result) # 推荐调用顺序: # # text = remove_duplicate_content(text) # text = normalize_latex_spacing(text) def _extract_rag_text(item: Any) -> str: if isinstance(item, dict): return str(item.get("text") or item.get("content") or "") if isinstance(item, str): return item return "" def _extract_rag_score(item: Any) -> float: if not isinstance(item, dict): return 0.0 try: return float(item.get("score") or item.get("socre") or 0.0) except (TypeError, ValueError): return 0.0 def _normalize_rag_text_for_dedupe(text: Any) -> str: value = unicodedata.normalize("NFKC", str(text or "")) value = re.sub(r"!\[[^\]]*\]\([^)]+\)", "", value) value = re.sub(r"\[([^\]]*)\]\([^)]+\)", r"\1", value) value = re.sub(r"https?://\S+", "", value, flags=re.IGNORECASE) value = re.sub(r"[/\\]?picture[/\\]\S+", "", value, flags=re.IGNORECASE) value = re.sub(r"\s+", "", value).lower() return "".join(ch for ch in value if ch.isalnum()) def _char_ngrams(text: str, n: int) -> set: if len(text) <= n: return {text} if text else set() return {text[i:i + n] for i in range(len(text) - n + 1)} def _rag_texts_are_similar(text_a: str, text_b: str, threshold: float) -> bool: if not text_a or not text_b: return False if text_a == text_b: return True min_len = min(len(text_a), len(text_b)) max_len = max(len(text_a), len(text_b)) if min_len < 8: return False if min_len >= RAG_DEDUP_MIN_TEXT_LENGTH and (text_a in text_b or text_b in text_a): return (min_len / max_len) >= RAG_DEDUP_CONTAINMENT_THRESHOLD if min_len < RAG_DEDUP_MIN_TEXT_LENGTH: return SequenceMatcher(None, text_a, text_b).ratio() >= 0.95 ngram_size = 3 if min_len >= 30 else 2 grams_a = _char_ngrams(text_a, ngram_size) grams_b = _char_ngrams(text_b, ngram_size) if not grams_a or not grams_b: return False jaccard = len(grams_a & grams_b) / len(grams_a | grams_b) if jaccard >= threshold: return True return SequenceMatcher(None, text_a, text_b).ratio() >= threshold def dedupe_rag_results( items: Any, limit: Optional[int] = None, similarity_threshold: float = RAG_DEDUP_SIMILARITY_THRESHOLD, ) -> Any: """ Deduplicate RAG result chunks while preserving the original item shape. Higher-score items are kept first. Graph results are intentionally not handled here; callers should pass only text RAG chunks. """ if not isinstance(items, list): return items if len(items) < 2: return items[:limit] if limit else items prepared = [] for idx, item in enumerate(items): prepared.append({ "item": item, "index": idx, "score": _extract_rag_score(item), "normalized_text": _normalize_rag_text_for_dedupe(_extract_rag_text(item)), }) prepared.sort(key=lambda entry: (-entry["score"], entry["index"])) kept = [] for entry in prepared: current_text = entry["normalized_text"] is_duplicate = False if current_text: for kept_entry in kept: if _rag_texts_are_similar( current_text, kept_entry["normalized_text"], similarity_threshold, ): is_duplicate = True break if not is_duplicate: kept.append(entry) deduped = [entry["item"] for entry in kept] return deduped[:limit] if limit else deduped def convert_rag_result(rag_result): if not rag_result or not rag_result.get("success", False): return [] source_citation = rag_result.get("sourceCitation", {}) flat_results = [] for resource, items in source_citation.items(): for item in items: flat_results.append(item) return flat_results def calculate_info_completeness(weights: Dict[str, float], values: Dict[str, str]) -> float: score = 0.0 for field, weight in weights.items(): value = values.get(field, "") or "" if value and str(value).strip(): score += weight return round(score, 2) def emit_callback_event(title_options: List[str], details: str): start_event = {"type": "function_execution", "title": random.choice(title_options), "details": details} for callback in get_all_callbacks(): try: callback(start_event) except Exception: pass async def resolve_ship_number_for_workflow( ship_number: str, context: str = "workflow", ) -> tuple[str, Dict[str, Any]]: """ 工作流内舷号/舰名标准化。 能映射到具体舷号时返回映射后的舷号;失败或未匹配时返回原值。 """ original_ship_number = str(ship_number or "").strip() if not original_ship_number: return original_ship_number, {} try: from utils.ship_number_search import smart_ship_number_mapping matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( original_ship_number ) result = { "matched_ship_number": matched_ship_number, "matched_model": matched_model, "all_numbers": all_numbers, "match_type": match_type, } if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: mapped_ship_number = str(matched_ship_number).strip() if mapped_ship_number and mapped_ship_number != original_ship_number: print( f"[{context}] 舷号映射: '{original_ship_number}' -> " f"'{mapped_ship_number}',匹配类型={match_type}" ) return mapped_ship_number or original_ship_number, result print(f"[{context}] 舷号未映射,继续使用原值: {original_ship_number}") return original_ship_number, result except Exception as e: print(f"[{context}] 舷号映射异常,继续使用原值: {original_ship_number}; 错误: {str(e)}") return original_ship_number, {} async def resolve_ship_scope_for_workflow( ship_number: str, context: str = "workflow", ) -> tuple[str, List[str], Dict[str, Any]]: """ Resolve user ship input into a display hull number and a hull-number scope. Hull number and ship-name matches collapse to one hull. Model matches keep the user's original value for display but return every hull number under that model so shared device normalization and system lookup can search the whole scope. """ original_ship_number = str(ship_number or "").strip() if not original_ship_number: return original_ship_number, [], {} try: from utils.ship_number_search import smart_ship_number_mapping matched_ship_number, matched_model, all_numbers, match_type = await smart_ship_number_mapping( original_ship_number ) scope_numbers = [str(num).strip() for num in (all_numbers or []) if str(num or "").strip()] result = { "matched_ship_number": matched_ship_number, "matched_model": matched_model, "all_numbers": scope_numbers, "match_type": match_type, } if match_type in ["exact_number", "exact_name", "embedding"] and matched_ship_number: mapped_ship_number = str(matched_ship_number).strip() return mapped_ship_number or original_ship_number, [mapped_ship_number], result if match_type == "exact_model" and scope_numbers: print( f"[{context}] ship model scope resolved: '{original_ship_number}' -> " f"{matched_model}, hull_numbers={scope_numbers}" ) return original_ship_number, scope_numbers, result print(f"[{context}] ship scope not resolved, continuing with original value: {original_ship_number}") return original_ship_number, [], result except Exception as e: print(f"[{context}] ship scope resolve failed, continuing with original value: {original_ship_number}; error: {str(e)}") return original_ship_number, [], {} async def normalize_device_name_for_workflow( device_name: str, ship_number: str = "", ship_numbers: Optional[List[str]] = None, context: str = "workflow", ) -> tuple[str, Dict[str, Any]]: """ 工作流内设备名称标准化。 标准化失败时返回原设备名,避免检索流程被 Neo4j 或 embedding 服务异常阻断。 """ original_device_name = str(device_name or "").strip() if not original_device_name: return original_device_name, {} try: from tools.graph_tools import normalize_device_name_by_ship_graph print( f"[{context}] 开始设备名称标准化: device='{original_device_name}', " f"ship_number='{ship_number or '未提供'}'" ) call_kwargs = {} if "ship_number" in inspect.signature(normalize_device_name_by_ship_graph).parameters: call_kwargs["ship_number"] = str(ship_number or "").strip() or None if "ship_numbers" in inspect.signature(normalize_device_name_by_ship_graph).parameters: scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] call_kwargs["ship_numbers"] = scoped_numbers or None elif ship_number: print(f"[{context}] 当前进程加载的设备标准化函数不支持 ship_number,请重启服务加载最新 graph_tools.py") result = await normalize_device_name_by_ship_graph(original_device_name, **call_kwargs) if result.get("success", False): normalized_device_name = str(result.get("device_name") or original_device_name).strip() print( f"[{context}] 设备名称标准化完成: '{original_device_name}' -> " f"'{normalized_device_name or original_device_name}',舷号={ship_number or '未提供'}" ) emit_callback_event( ["🔎 设备名称标准化", "🔧 设备匹配"], f"{original_device_name} -> {normalized_device_name or original_device_name}" ) return normalized_device_name or original_device_name, result print( f"[{context}] 设备名称标准化失败,继续使用原设备名: {original_device_name}; " f"原因: {result.get('error', '未知错误')}" ) return original_device_name, result except Exception as e: print(f"[{context}] 设备名称标准化异常,继续使用原设备名: {original_device_name}; 错误: {str(e)}") return original_device_name, {} async def resolve_device_system_for_workflow( device_name: str, ship_number: str = "", ship_numbers: Optional[List[str]] = None, context: str = "workflow", ) -> tuple[str, Dict[str, Any]]: """ 工作流内根据设备名称反推所属系统。 失败时返回空字符串,调用方可继续执行主流程。 """ device_name = str(device_name or "").strip() if not device_name: return "", {} try: print( f"[{context}] 开始设备反推系统: device='{device_name}', " f"ship_number='{ship_number or '未提供'}'" ) from tools.graph_tools import find_system_by_device call_kwargs = {"device_name": device_name} if "ship_number" in inspect.signature(find_system_by_device).parameters: call_kwargs["ship_number"] = str(ship_number or "").strip() or None if "ship_numbers" in inspect.signature(find_system_by_device).parameters: scoped_numbers = [str(num).strip() for num in (ship_numbers or []) if str(num or "").strip()] call_kwargs["ship_numbers"] = scoped_numbers or None elif ship_number: print(f"[{context}] 当前进程加载的设备反推系统函数不支持 ship_number,请重启服务加载最新 graph_tools.py") result = await find_system_by_device(**call_kwargs) if not result.get("success", False): print(f"[{context}] 设备反推系统失败: {result.get('error', '未知错误')}") return "", result systems = result.get("systems") or [] first_system = systems[0] if systems else {} system_name = "" if isinstance(first_system, dict): system_name = str(first_system.get("name") or first_system.get("系统名称") or first_system.get("名称") or "").strip() if system_name: print(f"[{context}] 设备反推系统完成: {device_name} -> {system_name}") emit_callback_event( ["🔎 设备所属系统识别", "🧭 系统反查"], f"{device_name} -> {system_name}" ) else: print(f"[{context}] 设备反推系统无结果: {device_name}") return system_name, result except Exception as e: print(f"[{context}] 设备反推系统异常: {str(e)}") return "", {} def build_detail_info(fields: Dict[str, str]) -> str: detail_info = "" for label, value in fields.items(): if value: detail_info += f"- {label}:{value}\n" return detail_info def append_atlas_section(answer: str, atlas_results: Dict[str, Any]) -> str: if not atlas_results: return answer atlas_section = "\n\n---\n\n**参考图册:**\n" for node_name, node_data in atlas_results.items(): if isinstance(node_data, dict): for title, urls in node_data.items(): if isinstance(urls, list) and urls: for url in urls: atlas_section += f"- {title}: [ `{title}` ]({url})\n" return answer + atlas_section async def stream_generate_and_postprocess( system_prompt: str, messages: List[Dict[str, Any]], original_image_paths: List[str], temperature: float = 0.3, fallback: str = "抱歉,无法生成回答。", ) -> str: """非流式生成完整答案,统一校正后再按安全语义块模拟流式发送。 公式、代码块、表格和图片会作为完整单元发送,避免前端在内容尚未闭合时 错误解析。函数返回值与已经发送的所有片段严格一致。 """ stream_handler = get_current_stream_handler() raw_answer = await OpenaiAPI.open_api_chat_without_thinking( query=None, model=None, system_prompt=system_prompt, messages=messages, enable_thinking=False, temperature=temperature, ) answer = (raw_answer or fallback).strip() answer = re.sub( r"ynchroneg>.*?ost switching>", "", answer, flags=re.DOTALL, ) # 修复模型把下一个无序列表项接在上一句末尾的情况。 answer = re.sub( r"(?<=[。!?;:])\s+\*\s+(?=\S)", "\n* ", answer, ) answer = remove_duplicate_content(answer) answer = normalize_markdown_images( answer, original_paths=original_image_paths, ) answer = normalize_latex_spacing(answer).strip() or fallback # 16000 前端同时注册了两套 KaTeX 插件,其中旧插件会抢先把标准 # $$...$$ 识别为 displayMode=False。改用同为标准 LaTeX 的 \[...\] # 可命中新插件的块级规则,生成真正居中且独占一行的 katex-display。 answer = convert_display_math_for_frontend(answer) def split_prose_line(line: str) -> List[str]: """在闭合的行内公式/代码之外按中文语义停顿切分。""" if len(line) <= 52: return [line] pieces: List[str] = [] start = 0 for index, character in enumerate(line): length = index + 1 - start is_sentence_end = character in "。!?;" is_long_pause = character in ",," and length >= 92 if length < 36 or not (is_sentence_end or is_long_pause): continue candidate = line[start:index + 1] if candidate.count("$") % 2 or candidate.count("`") % 2: continue pieces.append(candidate) start = index + 1 if start < len(line): pieces.append(line[start:]) return pieces or [line] def build_fake_stream_chunks(content: str) -> List[str]: """构造不会截断特殊 Markdown 单元、且可原样拼回正文的片段。""" lines = content.splitlines(keepends=True) chunks: List[str] = [] index = 0 while index < len(lines): line = lines[index] stripped = line.strip() if stripped in ("$$", "\\["): closing_delimiter = ( "$$" if stripped == "$$" else "\\]" ) block = line index += 1 while index < len(lines): block += lines[index] closing = ( lines[index].strip() == closing_delimiter ) index += 1 if closing: break chunks.append(block) continue if stripped.startswith("```"): block = line index += 1 while index < len(lines): block += lines[index] closing = lines[index].strip().startswith("```") index += 1 if closing: break chunks.append(block) continue if stripped.startswith("|"): block = line index += 1 while index < len(lines) and lines[index].strip().startswith("|"): block += lines[index] index += 1 chunks.append(block) continue if "![" in line: chunks.append(line) else: chunks.extend(split_prose_line(line)) index += 1 return [chunk for chunk in chunks if chunk] chunks = build_fake_stream_chunks(answer) for index, chunk in enumerate(chunks): if stream_handler: stream_handler.send_stream_content(chunk) if index < len(chunks) - 1: visible_length = len(re.sub(r"\s+", "", chunk)) # 总体约 120 字/秒,并保留 160~600ms 的自然停顿。 delay = max(0.16, min(0.60, visible_length / 120)) if chunk.lstrip().startswith(("$$", "\\[", "```", "|", "![")): delay = max(delay, 0.22) await asyncio.sleep(delay) return answer