fix(chat): rút gọn tiêu đề hội thoại không cắt vào giữa từ
Thanh tiêu đề màn Cowork hiện "…tóm tắt từng tệ…" cho một câu dài 61 ký tự: mốc cắt cứng ở 60 rơi đúng giữa chữ "tệp" và bỏ mất một chữ cái, đọc ra như lỗi gõ chứ không như một câu bị rút gọn. Dấu ba chấm ở đó còn nói dối — nó báo còn nhiều chữ nữa trong khi chỉ thiếu đúng một ký tự. Thêm core.history.shorten_title: viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba chấm khi thật sự có chữ bị bỏ. Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ trước đó để một token dài không kéo tiêu đề dài ra tuỳ ý. Dùng chung cho cả hai nơi sinh tiêu đề (derive_title và ChatTurnRunnerMixin) để tiêu đề trên thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo hai kiểu khác nhau. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+41
-2
@@ -12,6 +12,7 @@ sort by recency. History can live locally or in a OneDrive folder (resolved by
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
@@ -33,6 +34,45 @@ def new_session_id() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3]
|
||||
|
||||
|
||||
#: Độ dài mong muốn của một tiêu đề hội thoại, tính bằng ký tự.
|
||||
TITLE_MAX_CHARS = 60
|
||||
#: Số ký tự được phép vượt ``TITLE_MAX_CHARS`` để viết nốt từ đang bị cắt dở.
|
||||
#: Cỡ một từ tiếng Việt — đủ để cứu chữ cuối, không đủ để kéo dài tiêu đề.
|
||||
_TITLE_SLACK = 12
|
||||
|
||||
_KHOANG_TRANG = re.compile(r"\s")
|
||||
|
||||
|
||||
def shorten_title(text: str, limit: int = TITLE_MAX_CHARS) -> str:
|
||||
"""Rút gọn tiêu đề mà KHÔNG cắt vào giữa một từ.
|
||||
|
||||
Cắt cứng ở ký tự thứ ``limit`` đọc rất khó chịu khi mốc đó rơi vào giữa từ:
|
||||
"…tóm tắt từng tệp" thành "…tóm tắt từng tệ…" — trông như lỗi gõ chứ không
|
||||
như một câu bị rút gọn. Nên khi mốc cắt rơi vào giữa từ thì viết nốt từ đó.
|
||||
|
||||
Ba lối ra, theo thứ tự ưu tiên:
|
||||
|
||||
* Viết nốt từ đang dở, nếu chỉ phải vượt thêm tối đa ``_TITLE_SLACK`` ký tự.
|
||||
Viết nốt mà vừa hết chuỗi thì **không** thêm dấu ba chấm — không còn chữ
|
||||
nào bị bỏ thì dấu ba chấm là nói dối.
|
||||
* Từ dài bất thường (đường dẫn, URL) thì lùi về ranh giới từ ngay trước mốc,
|
||||
để một token dài không kéo tiêu đề dài ra tuỳ ý.
|
||||
* Cả tiêu đề chỉ là một từ dài thì đành cắt cứng — không còn ranh giới nào.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
if text[limit].isspace(): # mốc cắt vốn đã nằm giữa hai từ
|
||||
return text[:limit].rstrip() + "…"
|
||||
sau = _KHOANG_TRANG.search(text, limit)
|
||||
het_tu = sau.start() if sau is not None else len(text)
|
||||
if het_tu - limit <= _TITLE_SLACK:
|
||||
return text if het_tu == len(text) else text[:het_tu] + "…"
|
||||
truoc = [m.start() for m in _KHOANG_TRANG.finditer(text, 0, limit)]
|
||||
if truoc:
|
||||
return text[:truoc[-1]] + "…"
|
||||
return text[:limit] + "…"
|
||||
|
||||
|
||||
def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""Suy tiêu đề hội thoại từ tin nhắn đầu tiên của người dùng.
|
||||
|
||||
@@ -40,8 +80,7 @@ def derive_title(messages: List[Dict[str, Any]]) -> str:
|
||||
"""
|
||||
for m in messages:
|
||||
if m.get("role") == "user" and m.get("content"):
|
||||
text = " ".join(m["content"].split())
|
||||
return text[:60] + ("…" if len(text) > 60 else "")
|
||||
return shorten_title(" ".join(m["content"].split()))
|
||||
return "(empty)"
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from ...core.history import shorten_title
|
||||
from ...core.worker import AgentWorker
|
||||
from ...i18n import tr
|
||||
from ...state import AppContext
|
||||
@@ -88,7 +89,11 @@ class ChatTurnRunnerMixin:
|
||||
prefix = f"{prefix}\n\n{agent_prefix}" if prefix else agent_prefix
|
||||
if not self.title:
|
||||
base = text or (Path(attachments[0]).name if attachments else "(attachment)")
|
||||
self.title = (base[:60] + "…") if len(base) > 60 else base
|
||||
# Rút gọn mà không cắt vào giữa từ: xem shorten_title trong
|
||||
# core/history.py. Dùng chung với derive_title để tiêu đề trên
|
||||
# thanh tiêu đề và tiêu đề lưu vào lịch sử không rút gọn theo
|
||||
# hai kiểu khác nhau.
|
||||
self.title = shorten_title(base)
|
||||
self._notify_title()
|
||||
|
||||
# Reset the Plan panel so each message starts from a clean checklist (the
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tiêu đề hội thoại không được cắt vào giữa một từ.
|
||||
|
||||
Triệu chứng người dùng báo: thanh tiêu đề màn Cowork hiện
|
||||
|
||||
Đọc các tệp trong thư mục của project này và tóm tắt từng tệ…
|
||||
|
||||
Câu gốc dài 61 ký tự, mốc cắt cứng ở 60 rơi đúng vào giữa chữ "tệp" và bỏ mất
|
||||
đúng một chữ cái. Người đọc thấy "tệ…" chứ không thấy "tệp", nên nó đọc ra như
|
||||
lỗi gõ chứ không như một câu bị rút gọn.
|
||||
|
||||
``shorten_title`` viết nốt từ đang dở thay vì cắt ngang nó, và chỉ thêm dấu ba
|
||||
chấm khi thật sự có chữ bị bỏ đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from cowork_local.core.history import (
|
||||
TITLE_MAX_CHARS, _TITLE_SLACK, derive_title, shorten_title,
|
||||
)
|
||||
|
||||
#: Đúng câu trong ảnh người dùng gửi — 61 ký tự, vượt giới hạn đúng 1.
|
||||
CAU_TRONG_ANH = "Đọc các tệp trong thư mục của project này và tóm tắt từng tệp"
|
||||
|
||||
|
||||
def test_dung_ca_nguoi_dung_bao():
|
||||
"""Bài đỏ trước khi sửa: cắt cứng cho ra "…từng tệ…"."""
|
||||
assert len(CAU_TRONG_ANH) == TITLE_MAX_CHARS + 1
|
||||
|
||||
ket_qua = shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
assert ket_qua.endswith("tệp"), ket_qua
|
||||
assert "tệ…" not in ket_qua
|
||||
# Không chữ nào bị bỏ thì không được thêm dấu ba chấm — dấu đó là nói dối.
|
||||
assert ket_qua == CAU_TRONG_ANH
|
||||
|
||||
|
||||
def test_ngan_hon_gioi_han_thi_giu_nguyen():
|
||||
assert shorten_title("Tiêu đề ngắn") == "Tiêu đề ngắn"
|
||||
|
||||
|
||||
def test_dung_bang_gioi_han_thi_giu_nguyen():
|
||||
text = "x" * TITLE_MAX_CHARS
|
||||
assert shorten_title(text) == text
|
||||
|
||||
|
||||
def test_moc_cat_roi_dung_giua_hai_tu_thi_cat_ngay_do():
|
||||
text = "x" * TITLE_MAX_CHARS + " còn nữa"
|
||||
|
||||
assert shorten_title(text) == "x" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_viet_not_tu_roi_van_con_chu_phia_sau_thi_co_ba_cham():
|
||||
text = "x" * 57 + " abcdefgh ijk"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + " abcdefgh…"
|
||||
|
||||
|
||||
def test_tu_dai_bat_thuong_thi_lui_ve_ranh_gioi_truoc():
|
||||
"""Một đường dẫn hay URL dài không được kéo tiêu đề dài ra tuỳ ý."""
|
||||
text = "x" * 57 + " " + "y" * 40 + " z"
|
||||
|
||||
ket_qua = shorten_title(text)
|
||||
|
||||
assert ket_qua == "x" * 57 + "…"
|
||||
assert len(ket_qua) <= TITLE_MAX_CHARS + 1
|
||||
|
||||
|
||||
def test_ca_tieu_de_chi_la_mot_tu_dai_thi_danh_cat_cung():
|
||||
"""Không còn ranh giới từ nào để bám — cắt cứng là lối ra duy nhất."""
|
||||
text = "y" * 100
|
||||
|
||||
assert shorten_title(text) == "y" * TITLE_MAX_CHARS + "…"
|
||||
|
||||
|
||||
def test_khong_bao_gio_vuot_qua_gioi_han_cong_slack():
|
||||
text = "x" * 55 + " " + "y" * 11 + " phần đuôi còn dài nữa"
|
||||
|
||||
assert len(shorten_title(text)) <= TITLE_MAX_CHARS + _TITLE_SLACK + 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
CAU_TRONG_ANH,
|
||||
"Phân tích bảng tính doanh thu quý bốn và lập báo cáo tổng hợp cho ban giám đốc",
|
||||
"Tóm tắt toàn bộ tài liệu kỹ thuật trong thư mục rồi xuất ra một tệp markdown",
|
||||
"a bb ccc dddd eeeee ffffff ggggggg hhhhhhhh iiiiiiiii jjjjjjjjjj kkkkkkkkkkk",
|
||||
])
|
||||
def test_ket_qua_luon_ket_thuc_o_ranh_gioi_tu(text):
|
||||
"""Bất biến của cả hàm: phần chữ giữ lại phải là một tiền tố kết thúc đúng
|
||||
chỗ một từ kết thúc trong câu gốc — không bao giờ là nửa từ."""
|
||||
ket_qua = shorten_title(text)
|
||||
giu_lai = ket_qua[:-1] if ket_qua.endswith("…") else ket_qua
|
||||
|
||||
assert text.startswith(giu_lai), "kết quả không còn là tiền tố của câu gốc"
|
||||
assert len(giu_lai) == len(text) or text[len(giu_lai)].isspace(), (
|
||||
f"cắt vào giữa từ: ...{giu_lai[-12:]!r} | còn lại {text[len(giu_lai):][:8]!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_derive_title_dung_cung_mot_luat():
|
||||
"""Tiêu đề lưu vào lịch sử và tiêu đề trên thanh tiêu đề phải khớp nhau."""
|
||||
messages = [{"role": "user", "content": CAU_TRONG_ANH}]
|
||||
|
||||
assert derive_title(messages) == shorten_title(CAU_TRONG_ANH)
|
||||
|
||||
|
||||
def test_derive_title_van_gom_khoang_trang_thua():
|
||||
"""Hành vi cũ phải giữ: xuống dòng và khoảng trắng thừa gộp về một dấu cách."""
|
||||
tin_nhan = """ Dòng một
|
||||
|
||||
Dòng hai """
|
||||
messages = [{"role": "user", "content": tin_nhan}]
|
||||
|
||||
assert derive_title(messages) == "Dòng một Dòng hai"
|
||||
Reference in New Issue
Block a user