"""快速新对话的首条消息自动命名。 这是平台 UI 元数据调用,不进入 agent loop、不改 working_dir。调用前后以 tasks.auto_title_pending + auto_title_version 为闸:人工 PATCH name 会清闸,清空 会递增版本,在途标题结果因此既不能覆盖用户命名,也不能跨会话轮次写回。 """ from __future__ import annotations import re from typing import Any, Optional from uuid import UUID from sqlalchemy import select, update from .agent_builder import ROOT, load_config from .capabilities import ModelCapabilities from .llm import LLM from .storage import session_scope from .storage.models import Task from .storage.usage import record_chat_usage _TITLE_PROMPT = """请给下面这条新对话消息生成一个简短、具体的中文对话标题。 规则: 1. 只输出标题本身,不要解释、引号、前缀或 Markdown。 2. 6~18 个汉字为宜,必要的英文缩写、材料牌号和数字可以保留。 3. 概括用户真正要做的事,不要使用“新对话”“咨询问题”“帮我处理”等空泛标题。 4. 不得包含斜杠或反斜杠。 用户消息: {content} """ _PREFIX_RE = re.compile(r"^(?:标题|对话标题)\s*[::]\s*", re.I) _ATTACHMENT_LINE_RE = re.compile(r"^\[用户上传的(?:参考图|文件)\]\s+\S.*$") def is_attachment_only_message(content: str) -> bool: """消息是否只包含前端注入的附件路径标记。""" lines = [line.strip() for line in (content or "").splitlines() if line.strip()] return bool(lines) and all(_ATTACHMENT_LINE_RE.fullmatch(line) for line in lines) def clean_generated_title(raw: str, user_message: str) -> str: """把模型输出收敛为 validate_task_name 可接受的短标题,失败时按用户消息降级。""" first = next((line.strip() for line in (raw or "").splitlines() if line.strip()), "") title = _PREFIX_RE.sub("", first).strip(" \t\r\n\"'“”‘’`#*") title = re.sub(r"[/\\\x00]+", "·", title) title = re.sub(r"\s+", " ", title).strip(" .。,::;;-—_") if not title or title == "新对话": fallback = next( (line.strip() for line in (user_message or "").splitlines() if line.strip()), "新对话", ) title = re.sub(r"[/\\\x00]+", "·", fallback) title = re.sub(r"\s+", " ", title).strip(" .。,::;;-—_") return (title or "新对话")[:24] def generate_task_title( *, task_id: UUID, user_id: UUID, user_message: str, model_profile: str, ) -> Optional[str]: """若 task 仍待自动命名,调用一次模型并原子写标题;任何失败均不影响主 run。""" with session_scope() as s: state = s.execute( select(Task.auto_title_pending, Task.auto_title_version).where( Task.task_id == task_id, Task.user_id == user_id, ) ).first() if state is None or not state.auto_title_pending: return None title_version = state.auto_title_version response: Any = None title: Optional[str] = None caps: Optional[ModelCapabilities] = None try: cfg = load_config() profile = model_profile or cfg["default_model"] caps = ModelCapabilities.load(profile, ROOT / cfg["models_dir"]) response = LLM(caps).chat( messages=[{ "role": "user", "content": _TITLE_PROMPT.format(content=user_message[:3000]), }], tools=None, max_retries=2, ) choices = getattr(response, "choices", None) or [] raw = ((choices[0].message.content if choices else "") or "").strip() title = clean_generated_title(raw, user_message) except Exception as e: print( f"[task_title] generate failed task={task_id}: " f"{type(e).__name__}: {e}", flush=True, ) # 一次性消费 pending。WHERE pending=true 是与人工 PATCH name 的竞态闸: # 用户先改名时 PATCH 已清 false,此处 rowcount=0,不覆盖。 with session_scope() as s: values: dict[str, Any] = { "auto_title_pending": False, "title_source": "auto", } if title: values["name"] = title result = s.execute( update(Task) .where( Task.task_id == task_id, Task.user_id == user_id, Task.auto_title_pending.is_(True), Task.auto_title_version == title_version, ) .values(**values) ) applied = bool(getattr(result, "rowcount", 0)) if response is not None and caps is not None: usage = getattr(response, "usage", None) try: record_chat_usage( task_id=task_id, user_id=user_id, message_id=None, model_profile=f"{caps.family}.{caps.variant}", prompt_tokens=getattr(usage, "prompt_tokens", 0) or 0, completion_tokens=getattr(usage, "completion_tokens", 0) or 0, input_cny_per_mtoken=caps.input_cny_per_mtoken, output_cny_per_mtoken=caps.output_cny_per_mtoken, response=response, kind="task_title", ) except Exception: pass return title if applied else None def generate_task_title_safe(**kwargs: Any) -> Optional[str]: """后台辅助任务入口:DB/配置层异常也必须被吞掉,主对话永不受标题影响。""" try: return generate_task_title(**kwargs) except Exception as e: print( f"[task_title] auxiliary failed task={kwargs.get('task_id')}: " f"{type(e).__name__}: {e}", flush=True, ) return None