|
| 1 | +"""User-facing output normalization for ohmo gateway channels.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +from dataclasses import dataclass, field |
| 6 | +from pathlib import Path |
| 7 | +import re |
| 8 | + |
| 9 | +from ohmo.gateway.runtime import GatewayStreamUpdate |
| 10 | + |
| 11 | +REMOTE_CHANNELS = { |
| 12 | + "discord", |
| 13 | + "dingtalk", |
| 14 | + "email", |
| 15 | + "feishu", |
| 16 | + "matrix", |
| 17 | + "qq", |
| 18 | + "slack", |
| 19 | + "telegram", |
| 20 | + "wechat", |
| 21 | + "whatsapp", |
| 22 | +} |
| 23 | + |
| 24 | +_LOCAL_PATH_RE = re.compile( |
| 25 | + r"(?P<path>(?:(?<![A-Za-z0-9])[A-Za-z]:[\\/]|(?<![:/])/)(?:[^\r\n`\"'<>|?*\x00])+)", |
| 26 | +) |
| 27 | +_PATH_CODE_BLOCK_RE = re.compile( |
| 28 | + r"```(?:text|txt|powershell|shell|bash)?\s*\n(?P<body>.*?)\n```", |
| 29 | + re.IGNORECASE | re.DOTALL, |
| 30 | +) |
| 31 | +_URL_RE = re.compile(r"https?://[^\s`<>\"']+") |
| 32 | +_IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} |
| 33 | + |
| 34 | + |
| 35 | +@dataclass |
| 36 | +class GatewayOutputState: |
| 37 | + """Per-message state for channel-facing output normalization.""" |
| 38 | + |
| 39 | + stage_keys: set[str] = field(default_factory=set) |
| 40 | + media_paths: set[str] = field(default_factory=set) |
| 41 | + has_image_media: bool = False |
| 42 | + saw_media: bool = False |
| 43 | + |
| 44 | + |
| 45 | +def normalize_gateway_update( |
| 46 | + update: GatewayStreamUpdate, |
| 47 | + *, |
| 48 | + channel: str, |
| 49 | + content: str, |
| 50 | + mode: str, |
| 51 | + state: GatewayOutputState, |
| 52 | +) -> list[GatewayStreamUpdate]: |
| 53 | + """Return channel-facing updates for one runtime update.""" |
| 54 | + normalized_mode = _normalize_mode(mode) |
| 55 | + if normalized_mode == "debug" or channel not in REMOTE_CHANNELS: |
| 56 | + _remember_update_media(state, update) |
| 57 | + return [update] |
| 58 | + if update.kind == "media": |
| 59 | + _remember_update_media(state, update) |
| 60 | + return [_strip_media_caption(update)] |
| 61 | + if update.kind == "final": |
| 62 | + _remember_update_media(state, update) |
| 63 | + final_text = normalize_final_reply(update.text, state=state) |
| 64 | + if not final_text: |
| 65 | + return [] |
| 66 | + metadata = dict(update.metadata or {}) |
| 67 | + return [ |
| 68 | + GatewayStreamUpdate( |
| 69 | + kind=update.kind, |
| 70 | + text=final_text, |
| 71 | + metadata=metadata, |
| 72 | + media=list(getattr(update, "media", None) or metadata.get("_media") or []), |
| 73 | + ) |
| 74 | + ] |
| 75 | + if update.kind == "error": |
| 76 | + return [update] |
| 77 | + if normalized_mode == "silent": |
| 78 | + return [] |
| 79 | + if update.kind == "progress": |
| 80 | + return _normalize_progress(update, content=content, state=state) |
| 81 | + if update.kind == "tool_hint": |
| 82 | + return _normalize_tool_hint(update, content=content, state=state) |
| 83 | + return [update] |
| 84 | + |
| 85 | + |
| 86 | +def normalize_final_reply(reply: str, *, state: GatewayOutputState) -> str: |
| 87 | + """Remove local paths already represented as media from a final reply.""" |
| 88 | + text, urls = _protect_urls(reply or "") |
| 89 | + text = _remove_path_code_blocks(text, state) |
| 90 | + text = _LOCAL_PATH_RE.sub(lambda match: _replace_local_path(match.group("path"), state), text) |
| 91 | + text = _restore_urls(text, urls) |
| 92 | + text = _clean_text(text) |
| 93 | + if not text and state.saw_media: |
| 94 | + return "已生成图片。" if state.has_image_media else "已生成文件。" |
| 95 | + if state.has_image_media and _only_acknowledges_generated_image(text): |
| 96 | + return "已生成图片。" |
| 97 | + return text |
| 98 | + |
| 99 | + |
| 100 | +def _normalize_progress( |
| 101 | + update: GatewayStreamUpdate, |
| 102 | + *, |
| 103 | + content: str, |
| 104 | + state: GatewayOutputState, |
| 105 | +) -> list[GatewayStreamUpdate]: |
| 106 | + if "thinking" in state.stage_keys: |
| 107 | + return [] |
| 108 | + state.stage_keys.add("thinking") |
| 109 | + return [_copy_with_text(update, "正在处理..." if _prefers_chinese(content) else "Working on it...")] |
| 110 | + |
| 111 | + |
| 112 | +def _normalize_tool_hint( |
| 113 | + update: GatewayStreamUpdate, |
| 114 | + *, |
| 115 | + content: str, |
| 116 | + state: GatewayOutputState, |
| 117 | +) -> list[GatewayStreamUpdate]: |
| 118 | + metadata = update.metadata or {} |
| 119 | + tool_name = str(metadata.get("_tool_name") or "").strip() |
| 120 | + stage = _stage_for_tool(tool_name) |
| 121 | + if not stage: |
| 122 | + return [] |
| 123 | + if stage in state.stage_keys: |
| 124 | + return [] |
| 125 | + state.stage_keys.add(stage) |
| 126 | + text = _stage_text(stage, content) |
| 127 | + return [_copy_with_text(update, text)] |
| 128 | + |
| 129 | + |
| 130 | +def _stage_for_tool(tool_name: str) -> str | None: |
| 131 | + normalized = tool_name.strip().lower().replace("-", "_") |
| 132 | + if normalized in {"web_search", "web_fetch"}: |
| 133 | + return "searching" |
| 134 | + if normalized == "image_generation": |
| 135 | + return "generating_image" |
| 136 | + if normalized in {"shell", "bash", "python", "run_command"}: |
| 137 | + return "processing_files" |
| 138 | + return None |
| 139 | + |
| 140 | + |
| 141 | +def _stage_text(stage: str, content: str) -> str: |
| 142 | + zh = _prefers_chinese(content) |
| 143 | + if stage == "searching": |
| 144 | + return "正在检索资料..." if zh else "Searching..." |
| 145 | + if stage == "generating_image": |
| 146 | + return "正在生成图片..." if zh else "Generating image..." |
| 147 | + if stage == "processing_files": |
| 148 | + return "正在处理文件..." if zh else "Processing files..." |
| 149 | + return "正在处理..." if zh else "Working on it..." |
| 150 | + |
| 151 | + |
| 152 | +def _copy_with_text(update: GatewayStreamUpdate, text: str) -> GatewayStreamUpdate: |
| 153 | + return GatewayStreamUpdate( |
| 154 | + kind=update.kind, |
| 155 | + text=text, |
| 156 | + metadata=dict(update.metadata or {}), |
| 157 | + media=list(getattr(update, "media", None) or []), |
| 158 | + ) |
| 159 | + |
| 160 | + |
| 161 | +def _strip_media_caption(update: GatewayStreamUpdate) -> GatewayStreamUpdate: |
| 162 | + return GatewayStreamUpdate( |
| 163 | + kind=update.kind, |
| 164 | + text="", |
| 165 | + metadata=dict(update.metadata or {}), |
| 166 | + media=list(getattr(update, "media", None) or []), |
| 167 | + ) |
| 168 | + |
| 169 | + |
| 170 | +def _remember_update_media(state: GatewayOutputState, update: GatewayStreamUpdate) -> None: |
| 171 | + raw_media = getattr(update, "media", None) or (update.metadata or {}).get("_media") or [] |
| 172 | + if isinstance(raw_media, str): |
| 173 | + candidates = [raw_media] |
| 174 | + elif isinstance(raw_media, list): |
| 175 | + candidates = [str(item) for item in raw_media if isinstance(item, str) and item.strip()] |
| 176 | + else: |
| 177 | + candidates = [] |
| 178 | + for raw in candidates: |
| 179 | + state.saw_media = True |
| 180 | + resolved = _normalize_path(raw) |
| 181 | + state.media_paths.add(resolved) |
| 182 | + if Path(raw).suffix.lower() in _IMAGE_SUFFIXES: |
| 183 | + state.has_image_media = True |
| 184 | + |
| 185 | + |
| 186 | +def _remove_path_code_blocks(text: str, state: GatewayOutputState) -> str: |
| 187 | + def replace(match: re.Match[str]) -> str: |
| 188 | + body = match.group("body").strip() |
| 189 | + lines = [line.strip() for line in body.splitlines() if line.strip()] |
| 190 | + if lines and all(_looks_like_local_path(line) for line in lines): |
| 191 | + kept = [_replace_local_path(line, state) for line in lines] |
| 192 | + kept = [item for item in kept if item] |
| 193 | + return "\n".join(kept) |
| 194 | + return match.group(0) |
| 195 | + |
| 196 | + return _PATH_CODE_BLOCK_RE.sub(replace, text) |
| 197 | + |
| 198 | + |
| 199 | +def _replace_local_path(path_text: str, state: GatewayOutputState) -> str: |
| 200 | + cleaned = path_text.strip().rstrip(".,;:,。;:、)]}") |
| 201 | + normalized = _normalize_path(cleaned) |
| 202 | + if normalized in state.media_paths: |
| 203 | + return "" |
| 204 | + path = Path(cleaned) |
| 205 | + return path.name if path.name else "" |
| 206 | + |
| 207 | + |
| 208 | +def _normalize_path(path_text: str) -> str: |
| 209 | + try: |
| 210 | + return str(Path(path_text).expanduser().resolve(strict=False)) |
| 211 | + except Exception: |
| 212 | + return path_text |
| 213 | + |
| 214 | + |
| 215 | +def _looks_like_local_path(text: str) -> bool: |
| 216 | + return bool(_LOCAL_PATH_RE.fullmatch(text.strip())) |
| 217 | + |
| 218 | + |
| 219 | +def _clean_text(text: str) -> str: |
| 220 | + lines = [line.rstrip() for line in text.replace("\r\n", "\n").split("\n")] |
| 221 | + cleaned: list[str] = [] |
| 222 | + blank = False |
| 223 | + for line in lines: |
| 224 | + stripped = line.strip() |
| 225 | + if not stripped: |
| 226 | + if cleaned and not blank: |
| 227 | + cleaned.append("") |
| 228 | + blank = True |
| 229 | + continue |
| 230 | + cleaned.append(stripped) |
| 231 | + blank = False |
| 232 | + return "\n".join(cleaned).strip() |
| 233 | + |
| 234 | + |
| 235 | +def _protect_urls(text: str) -> tuple[str, dict[str, str]]: |
| 236 | + urls: dict[str, str] = {} |
| 237 | + |
| 238 | + def replace(match: re.Match[str]) -> str: |
| 239 | + key = f"__OHMO_URL_{len(urls)}__" |
| 240 | + urls[key] = match.group(0) |
| 241 | + return key |
| 242 | + |
| 243 | + return _URL_RE.sub(replace, text), urls |
| 244 | + |
| 245 | + |
| 246 | +def _restore_urls(text: str, urls: dict[str, str]) -> str: |
| 247 | + for key, value in urls.items(): |
| 248 | + text = text.replace(key, value) |
| 249 | + return text |
| 250 | + |
| 251 | + |
| 252 | +def _only_acknowledges_generated_image(text: str) -> bool: |
| 253 | + normalized = re.sub(r"\s+", "", text or "") |
| 254 | + return normalized in { |
| 255 | + "已生成图片:", |
| 256 | + "已生成图片:", |
| 257 | + "已生成图片。", |
| 258 | + "已生成图片", |
| 259 | + "已生成信息图:", |
| 260 | + "已生成信息图:", |
| 261 | + "已生成信息图。", |
| 262 | + "已生成信息图", |
| 263 | + } |
| 264 | + |
| 265 | + |
| 266 | +def _prefers_chinese(content: str) -> bool: |
| 267 | + return bool(re.search(r"[\u4e00-\u9fff]", content or "")) |
| 268 | + |
| 269 | + |
| 270 | +def _normalize_mode(mode: str) -> str: |
| 271 | + normalized = (mode or "user").strip().lower() |
| 272 | + if normalized in {"debug", "silent", "user"}: |
| 273 | + return normalized |
| 274 | + return "user" |
0 commit comments