zcbot/core/task_title.py

156 lines
5.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""快速新对话的首条消息自动命名。
这是平台 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. 618 个汉字为宜,必要的英文缩写、材料牌号和数字可以保留。
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(result.rowcount)
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