import json import re import logging from typing import Optional, AsyncGenerator logger = logging.getLogger('app') SENTENCE_END_RE = re.compile(r'[。!?;\n.!?;]') class StreamTagFilter: """ Filters streaming text based on tag blocks. Only passes through content inside [ANSWER] blocks. If no tags are found at all, passes through everything (fallback). Skips content inside [TOOL_CALL], [TOOL_RESPONSE], [THINK], [SOURCE], etc. """ SKIP_TAGS = {"TOOL_CALL", "TOOL_RESPONSE", "THINK", "SOURCE", "REFERENCE"} KNOWN_TAGS = {"ANSWER", "TOOL_CALL", "TOOL_RESPONSE", "THINK", "SOURCE", "REFERENCE", "PREAMBLE", "SUMMARY"} def __init__(self): self.state = "idle" # idle, answer, skip self.found_any_tag = False self._pending = "" self.answer_ended = False def feed(self, chunk: str) -> str: """Feed a chunk, return text that should be passed to TTS.""" self.answer_ended = False self._pending += chunk output = [] while self._pending: if self.state in ("idle", "answer"): bracket_pos = self._pending.find("[") if bracket_pos == -1: if self.state == "answer" or not self.found_any_tag: output.append(self._pending) self._pending = "" else: before = self._pending[:bracket_pos] if before and (self.state == "answer" or not self.found_any_tag): output.append(before) close_pos = self._pending.find("]", bracket_pos) if close_pos == -1: self._pending = self._pending[bracket_pos:] break tag_name = self._pending[bracket_pos + 1:close_pos] self._pending = self._pending[close_pos + 1:] if tag_name not in self.KNOWN_TAGS: if self.state == "answer" or not self.found_any_tag: output.append(f"[{tag_name}]") continue self.found_any_tag = True if tag_name == "ANSWER": self.state = "answer" else: if self.state == "answer": self.answer_ended = True self.state = "skip" elif self.state == "skip": bracket_pos = self._pending.find("[") if bracket_pos == -1: self._pending = "" else: close_pos = self._pending.find("]", bracket_pos) if close_pos == -1: self._pending = self._pending[bracket_pos:] break tag_name = self._pending[bracket_pos + 1:close_pos] self._pending = self._pending[close_pos + 1:] if tag_name not in self.KNOWN_TAGS: continue if tag_name == "ANSWER": self.state = "answer" else: self.state = "skip" return "".join(output) def clean_markdown(text: str) -> str: """Strip Markdown formatting characters for TTS readability.""" text = re.sub(r'!\[([^\]]*)\]\([^)]*\)', r'\1', text) text = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', text) text = re.sub(r'#{1,6}\s*', '', text) text = re.sub(r'\*{1,3}|_{1,3}|~~|`{1,3}', '', text) text = re.sub(r'^>\s*', '', text, flags=re.MULTILINE) text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE) text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE) text = re.sub(r'^[\s]*[-*_]{3,}[\s]*$', '', text, flags=re.MULTILINE) text = re.sub(r'\n{2,}', '\n', text) return text.strip() async def stream_v3_agent( user_text: str, bot_id: str, bot_config: dict, session_id: str, user_identifier: str, ) -> AsyncGenerator[str, None]: """Call v3 agent API in streaming mode, yield text chunks as they arrive.""" import asyncio try: from utils.api_models import ChatRequestV3, Message from utils.fastapi_utils import ( process_messages, create_project_directory, ) from agent.agent_config import AgentConfig from routes.chat import enhanced_generate_stream_response language = bot_config.get("language", "zh") messages_obj = [Message(role="user", content=user_text)] request = ChatRequestV3( messages=messages_obj, bot_id=bot_id, stream=True, session_id=session_id, user_identifier=user_identifier, ) project_dir = create_project_directory( bot_config.get("dataset_ids", []), bot_id, bot_config.get("skills", []), ) processed_messages = process_messages(messages_obj, language) config = await AgentConfig.from_v3_request( request, bot_config, project_dir, processed_messages, language, ) config.stream = True async for sse_line in enhanced_generate_stream_response(config): if not sse_line or not sse_line.startswith("data: "): continue data_str = sse_line.strip().removeprefix("data: ") if data_str == "[DONE]": break try: data = json.loads(data_str) choices = data.get("choices", []) if choices: delta = choices[0].get("delta", {}) content = delta.get("content", "") if content: yield content except (json.JSONDecodeError, KeyError): continue except asyncio.CancelledError: logger.info(f"[Voice] v3 agent call cancelled") raise except Exception as e: logger.error(f"[Voice] Error calling v3 agent: {e}", exc_info=True)