diff --git a/constants.py b/constants.py
index 9eed08d2..1187cd74 100644
--- a/constants.py
+++ b/constants.py
@@ -17,6 +17,9 @@
# 传统人格更新(其他类型)
UPDATE_TYPE_TRADITIONAL = "traditional"
+# 内部黑话重学标记:保护已人工确认/完成的释义不被自动重学覆盖
+PRESERVE_COMPLETED_JARGON_KEY = "_preserve_completed"
+
# 兼容性:旧的update_type值映射
# 用于数据库中已存在的旧记录
LEGACY_UPDATE_TYPE_MAPPING = {
diff --git a/pages/dashboard/app.js b/pages/dashboard/app.js
index b2a089b9..17f6c589 100644
--- a/pages/dashboard/app.js
+++ b/pages/dashboard/app.js
@@ -408,6 +408,52 @@
else modal.removeAttribute("open");
}
+ function showConfirm(title, message, confirmText = t("actions.confirm", "确定")) {
+ return new Promise((resolve) => {
+ const modal = $("detail-modal");
+ if (!modal) {
+ showToast(message, "error");
+ resolve(false);
+ return;
+ }
+
+ if (modal.open && typeof modal.close === "function") modal.close();
+ const closeButton = $("modal-close");
+ let settled = false;
+ const done = (result) => {
+ if (settled) return;
+ settled = true;
+ modal.removeEventListener("close", onClose);
+ closeButton?.removeEventListener("click", onCloseClick);
+ if (typeof modal.close === "function" && modal.open) modal.close();
+ else modal.removeAttribute("open");
+ resolve(result);
+ };
+ const onClose = () => done(false);
+ const onCloseClick = () => done(false);
+
+ setText("modal-title", title);
+ setHtml("modal-body", `
+
- ${jargonCheckbox(item.id)}
-
- ${escapeHtml(item.term || item.content || `#${item.id}`)}
- ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))}
-
-
${escapeHtml(item.group_id || "global")}
- ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")}
- ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))}
-
- ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)}
+ const html = items.map((item) => {
+ const reviewActions = item.is_confirmed ? "" : `
${button(t("actions.confirm", "确认"), `data-jargon-action="approve" data-id="${escapeAttr(item.id)}"`)}
${button(t("actions.reject", "驳回"), `data-jargon-action="reject" data-id="${escapeAttr(item.id)}"`)}
- ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)}
- ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")}
+ `;
+ return `
+
+ ${jargonCheckbox(item.id)}
+
+ ${escapeHtml(item.term || item.content || `#${item.id}`)}
+ ${escapeHtml(item.meaning || item.definition || t("empty.definition", "暂无释义"))}
+
+
${escapeHtml(item.group_id || "global")}
+ ${pill(item.is_confirmed ? t("jargon.confirmed", "已确认") : t("jargon.pending", "待确认"), item.is_confirmed ? "ok" : "warn")}
+ ${pill(item.is_global ? t("jargon.global", "全局") : t("jargon.local", "本地"))}
+
+ ${button(t("actions.edit", "编辑"), `data-jargon-action="edit" data-id="${escapeAttr(item.id)}"`)}
+ ${reviewActions}
+ ${button(item.is_global ? t("actions.unsetGlobal", "取消全局") : t("actions.setGlobal", "设为全局"), `data-jargon-action="toggle_global" data-id="${escapeAttr(item.id)}"`)}
+ ${button(t("actions.delete", "删除"), `data-jargon-action="delete" data-id="${escapeAttr(item.id)}"`, "danger-button")}
+
-
- `).join("");
+ `;
+ }).join("");
setHtml("jargon-list", html || empty(t("empty.jargon", "暂无黑话数据")));
state.pageData.lastJargonItems = items;
state.pageData.currentJargonData = data;
@@ -1095,11 +1146,11 @@
const typeText = { persona: t("reviews.personaUpdates", "人格更新"), style: t("reviews.expressionReviews", "表达审查"), jargon: t("reviews.jargonCandidates", "黑话候选") }[kind] || t("reviews.items", "审查项");
const actionText = action === "approve" ? t("actions.pass", "通过") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除");
const scopeText = selectedReviewIds(kind).length ? t("selection.selected", "选中") : t("selection.currentPage", "当前页");
- if (!window.confirm(t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?")
+ if (!await showConfirm(t("reviews.batchConfirmTitle", "批量操作确认"), t("reviews.confirmBatch", "确定批量{action}{scope} {count} 条{type}?")
.replace("{action}", actionText)
.replace("{scope}", scopeText)
.replace("{count}", fmt(ids.length, 0))
- .replace("{type}", typeText))) return;
+ .replace("{type}", typeText), actionText)) return;
const payload = {
action: action === "delete"
@@ -1305,7 +1356,7 @@
return;
}
const actionText = action === "approve" ? t("actions.confirm", "确认") : action === "reject" ? t("actions.rejectBack", "驳回") : t("actions.delete", "删除");
- if (!window.confirm(t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return;
+ if (!await showConfirm(t("jargon.batchConfirmTitle", "批量操作确认"), t("jargon.confirmBatch", "确定批量{action}选中的 {count} 条黑话?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return;
const result = await apiPost("jargon/action", {
action: action === "delete" ? "batch_delete" : "batch_review",
@@ -1367,7 +1418,7 @@
return;
}
const actionText = action === "approve" ? t("actions.approve", "批准") : action === "reject" ? t("actions.reject", "拒绝") : t("actions.delete", "删除");
- if (!window.confirm(t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)))) return;
+ if (!await showConfirm(t("style.batchConfirmTitle", "批量操作确认"), t("style.confirmBatch", "确定批量{action}选中的 {count} 条表达审查?").replace("{action}", actionText).replace("{count}", fmt(ids.length, 0)), actionText)) return;
const result = await apiPost("style/action", {
action: action === "delete" ? "batch_delete" : "batch_review",
diff --git a/pages/dashboard/styles.css b/pages/dashboard/styles.css
index 9d5a301e..1559c067 100644
--- a/pages/dashboard/styles.css
+++ b/pages/dashboard/styles.css
@@ -1186,6 +1186,18 @@ textarea {
box-shadow: var(--shadow);
}
+.confirm-message {
+ margin: 0 0 16px;
+ line-height: 1.6;
+}
+
+.confirm-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
@media (max-width: 1120px) {
.app-shell {
grid-template-columns: 1fr;
diff --git a/services/core_learning/v2_learning_integration.py b/services/core_learning/v2_learning_integration.py
index 5f0a1c85..e1363686 100644
--- a/services/core_learning/v2_learning_integration.py
+++ b/services/core_learning/v2_learning_integration.py
@@ -36,6 +36,7 @@
from astrbot.api import logger
+from ...constants import PRESERVE_COMPLETED_JARGON_KEY
from ...config import PluginConfig
from ...core.interfaces import MessageData
from ...utils.cache_manager import get_cache_manager
@@ -733,6 +734,16 @@ async def _jargon_batch(group_id: str) -> None:
return
for candidate in candidates[:10]:
try:
+ existing = None
+ if db and hasattr(db, "get_jargon"):
+ existing = await db.get_jargon(group_id, candidate["term"])
+ if existing and existing.get("is_complete"):
+ logger.debug(
+ f"[V2Integration] Skip completed jargon "
+ f"'{candidate['term']}'"
+ )
+ continue
+
meaning = await llm.generate_response(
f"Explain the slang/jargon term "
f"'{candidate['term']}' in the context of an "
@@ -744,6 +755,11 @@ async def _jargon_batch(group_id: str) -> None:
and db
and hasattr(db, "save_or_update_jargon")
):
+ observed_count = max(
+ int(candidate.get("frequency") or 1),
+ int((existing or {}).get("count") or 0),
+ 1,
+ )
await db.save_or_update_jargon(
group_id,
candidate["term"],
@@ -751,8 +767,9 @@ async def _jargon_batch(group_id: str) -> None:
"meaning": meaning,
"raw_content": "[]",
"is_jargon": True,
- "count": 1,
+ "count": observed_count,
"is_complete": True,
+ PRESERVE_COMPLETED_JARGON_KEY: True,
},
)
except Exception as exc:
diff --git a/services/database/facades/jargon_facade.py b/services/database/facades/jargon_facade.py
index 26e81824..a943ed9a 100644
--- a/services/database/facades/jargon_facade.py
+++ b/services/database/facades/jargon_facade.py
@@ -13,9 +13,11 @@
from ._base import BaseFacade
try:
+ from ....constants import PRESERVE_COMPLETED_JARGON_KEY
from ....models.orm.jargon import Jargon
from ....utils.text_utils import truncate_for_db
except ImportError:
+ from constants import PRESERVE_COMPLETED_JARGON_KEY
from models.orm.jargon import Jargon
from utils.text_utils import truncate_for_db
@@ -728,20 +730,24 @@ async def _save_or_update_jargon_select_then_write(
now_ts = self._coerce_jargon_timestamp()
if record:
+ preserve_completed = (
+ self._preserve_completed_jargon(jargon_data)
+ and bool(record.is_complete)
+ )
# 更新已有记录
- if 'meaning' in jargon_data:
+ if 'meaning' in jargon_data and not preserve_completed:
record.meaning = jargon_data['meaning']
- if 'raw_content' in jargon_data:
+ if 'raw_content' in jargon_data and not preserve_completed:
record.raw_content = truncate_for_db(jargon_data['raw_content'])
- if 'is_jargon' in jargon_data:
+ if 'is_jargon' in jargon_data and not preserve_completed:
record.is_jargon = jargon_data['is_jargon']
- if 'count' in jargon_data:
+ if 'count' in jargon_data and not preserve_completed:
record.count = jargon_data['count']
if 'last_inference_count' in jargon_data:
record.last_inference_count = jargon_data['last_inference_count']
- if 'is_complete' in jargon_data:
+ if 'is_complete' in jargon_data and not preserve_completed:
record.is_complete = jargon_data['is_complete']
- if 'is_global' in jargon_data:
+ if 'is_global' in jargon_data and not preserve_completed:
record.is_global = jargon_data['is_global']
record.updated_at = now_ts
@@ -825,6 +831,27 @@ async def _save_or_update_jargon_sqlite(
def _coerce_jargon_timestamp() -> int:
return int(time.time())
+ @staticmethod
+ def _preserve_completed_jargon(jargon_data: Dict[str, Any]) -> bool:
+ return bool(jargon_data.get(PRESERVE_COMPLETED_JARGON_KEY))
+
+ @staticmethod
+ def _completed_preserving_value(new_value: Any, old_column: Any):
+ return case(
+ (Jargon.is_complete.is_(True), old_column),
+ else_=new_value,
+ )
+
+ @staticmethod
+ def _completed_preserving_count(new_value: Any):
+ return case(
+ (
+ Jargon.is_complete.is_(True),
+ Jargon.count,
+ ),
+ else_=new_value,
+ )
+
@staticmethod
def _jargon_insert_values(
chat_id: str,
@@ -852,20 +879,61 @@ def _jargon_update_values(
now_ts: int,
) -> Dict[str, Any]:
update_values = {"updated_at": now_ts}
+ preserve_completed = JargonFacade._preserve_completed_jargon(jargon_data)
if "meaning" in jargon_data:
- update_values["meaning"] = jargon_data["meaning"]
+ update_values["meaning"] = (
+ JargonFacade._completed_preserving_value(
+ jargon_data["meaning"],
+ Jargon.meaning,
+ )
+ if preserve_completed
+ else jargon_data["meaning"]
+ )
if "raw_content" in jargon_data:
- update_values["raw_content"] = truncate_for_db(jargon_data["raw_content"])
+ raw_content = truncate_for_db(jargon_data["raw_content"])
+ update_values["raw_content"] = (
+ JargonFacade._completed_preserving_value(
+ raw_content,
+ Jargon.raw_content,
+ )
+ if preserve_completed
+ else raw_content
+ )
if "is_jargon" in jargon_data:
- update_values["is_jargon"] = jargon_data["is_jargon"]
+ update_values["is_jargon"] = (
+ JargonFacade._completed_preserving_value(
+ jargon_data["is_jargon"],
+ Jargon.is_jargon,
+ )
+ if preserve_completed
+ else jargon_data["is_jargon"]
+ )
if "count" in jargon_data:
- update_values["count"] = jargon_data["count"]
+ update_values["count"] = (
+ JargonFacade._completed_preserving_count(jargon_data["count"])
+ if preserve_completed
+ else jargon_data["count"]
+ )
if "last_inference_count" in jargon_data:
update_values["last_inference_count"] = jargon_data["last_inference_count"]
if "is_complete" in jargon_data:
- update_values["is_complete"] = jargon_data["is_complete"]
+ update_values["is_complete"] = (
+ JargonFacade._completed_preserving_value(
+ jargon_data["is_complete"],
+ Jargon.is_complete,
+ )
+ if preserve_completed
+ else jargon_data["is_complete"]
+ )
if "is_global" in jargon_data:
- update_values["is_global"] = jargon_data["is_global"]
+ update_values["is_global"] = (
+ JargonFacade._completed_preserving_value(
+ jargon_data["is_global"],
+ Jargon.is_global,
+ )
+ if preserve_completed
+ else jargon_data["is_global"]
+ )
return update_values
@staticmethod
diff --git a/tests/integration/test_webui_static_assets.py b/tests/integration/test_webui_static_assets.py
index 53d010c4..1b5d8c2e 100644
--- a/tests/integration/test_webui_static_assets.py
+++ b/tests/integration/test_webui_static_assets.py
@@ -35,6 +35,37 @@
]
+def _extract_js_function_source(script: str, name: str) -> str:
+ match = re.search(rf"(?:async\s+)?function\s+{re.escape(name)}\s*\([^)]*\)\s*{{", script)
+ assert match, f"Missing JS function: {name}"
+
+ depth = 1
+ quote = None
+ escaped = False
+ index = match.end()
+
+ while index < len(script):
+ char = script[index]
+ if quote:
+ if escaped:
+ escaped = False
+ elif char == "\\":
+ escaped = True
+ elif char == quote:
+ quote = None
+ elif char in {"'", '"', "`"}:
+ quote = char
+ elif char == "{":
+ depth += 1
+ elif char == "}":
+ depth -= 1
+ if depth == 0:
+ return script[match.start(): index + 1]
+ index += 1
+
+ raise AssertionError(f"Unterminated JS function: {name}")
+
+
def test_webui_html_templates_no_external_frontend_cdn_refs():
for path in HTML_FILES:
text = path.read_text(encoding="utf-8")
@@ -102,6 +133,17 @@ def test_embedded_plugin_page_uses_astrbot_bridge_and_module_dashboard():
assert "function handleBatchReviewAction" in script
assert "batch_review_style" in script
assert "batch_review_jargon" in script
+ assert "function showConfirm" in script
+ for function_name in [
+ "handleBatchReviewAction",
+ "handleJargonBatchAction",
+ "handleStyleBatchAction",
+ ]:
+ function_source = _extract_js_function_source(script, function_name)
+ assert "showConfirm(" in function_source
+ assert "window.confirm" not in function_source
+ assert "data-confirm-ok" in script
+ assert "data-confirm-cancel" in script
assert 'review_source !== "style_learning"' in script
assert "分类去向" in script
assert "style_learning_reviews" in script
@@ -123,6 +165,7 @@ def test_embedded_plugin_page_uses_astrbot_bridge_and_module_dashboard():
assert "graphHomePosition" in script
assert "GRAPH_HOME_STRENGTH" in script
assert "graphNodeMargin" in script
+ assert 'const reviewActions = item.is_confirmed ? ""' in script
assert "manual_dependency_source" in script
assert "installButton.disabled = true" in script
assert "正在调用 pip 安装依赖" in script
diff --git a/tests/unit/test_database_engine.py b/tests/unit/test_database_engine.py
index f707128e..c4ca520e 100644
--- a/tests/unit/test_database_engine.py
+++ b/tests/unit/test_database_engine.py
@@ -10,6 +10,7 @@
from sqlalchemy.engine import make_url
from config import PluginConfig
+from constants import PRESERVE_COMPLETED_JARGON_KEY
from core.database.engine import DatabaseEngine
from models.orm import Base
from models.orm.jargon import Jargon
@@ -505,6 +506,57 @@ async def save_term(index: int):
await manager.stop()
+@pytest.mark.asyncio
+async def test_jargon_relearning_upsert_preserves_completed_manual_definition(tmp_path):
+ config = PluginConfig(
+ data_dir=str(tmp_path),
+ enable_web_interface=False,
+ db_type="sqlite",
+ )
+ config.messages_db_path = str(tmp_path / "messages.db")
+ manager = SQLAlchemyDatabaseManager(config)
+
+ try:
+ assert await manager.start() is True
+
+ jargon_id = await manager.save_or_update_jargon(
+ "group-a",
+ "打爆",
+ {
+ "raw_content": "[\"manual\"]",
+ "meaning": "管理员手动释义",
+ "is_jargon": True,
+ "count": 8,
+ "is_complete": True,
+ "is_global": True,
+ },
+ )
+
+ relearned_id = await manager.save_or_update_jargon(
+ "group-a",
+ "打爆",
+ {
+ "raw_content": "[\"auto\"]",
+ "meaning": "LLM 新释义",
+ "is_jargon": True,
+ "count": 1,
+ "is_complete": True,
+ "is_global": False,
+ PRESERVE_COMPLETED_JARGON_KEY: True,
+ },
+ )
+
+ assert relearned_id == jargon_id
+ saved = await manager.get_jargon("group-a", "打爆")
+ assert saved["meaning"] == "管理员手动释义"
+ assert saved["raw_content"] == "[\"manual\"]"
+ assert saved["count"] == 8
+ assert saved["is_complete"] is True
+ assert saved["is_global"] is True
+ finally:
+ await manager.stop()
+
+
def test_database_engine_mysql_uses_aiomysql_without_pool_pre_ping(monkeypatch):
captured = {}
diff --git a/tests/unit/test_provider_registry_rebind.py b/tests/unit/test_provider_registry_rebind.py
index 314d3dbf..17d22006 100644
--- a/tests/unit/test_provider_registry_rebind.py
+++ b/tests/unit/test_provider_registry_rebind.py
@@ -5,7 +5,7 @@
import sys
from pathlib import Path
from types import SimpleNamespace
-from unittest.mock import Mock
+from unittest.mock import AsyncMock, Mock
import pytest
from astrbot.core.provider.provider import (
@@ -13,6 +13,8 @@
RerankProvider as FrameworkRerankProvider,
)
+from constants import PRESERVE_COMPLETED_JARGON_KEY
+
PLUGIN_ROOT = Path(__file__).resolve().parents[2]
@@ -160,3 +162,82 @@ def _create_jargon_filter(self):
assert integration._embedding_provider.provider_id == "embed-a"
assert integration._rerank_provider.provider_id == "rerank-a"
context.get_provider_by_id.assert_not_called()
+
+
+@pytest.mark.asyncio
+async def test_v2_jargon_batch_skips_completed_terms_and_preserves_candidate_count(plugin_modules):
+ class MinimalV2LearningIntegration(plugin_modules.V2LearningIntegration):
+ def _create_embedding_provider(self):
+ return None
+
+ def _create_rerank_provider(self):
+ return None
+
+ def _create_knowledge_manager(self):
+ return None
+
+ def _create_memory_manager(self):
+ return None
+
+ def _create_exemplar_library(self):
+ return None
+
+ def _create_social_analyzer(self):
+ return None
+
+ def _create_jargon_filter(self):
+ return SimpleNamespace(
+ update_from_message=Mock(),
+ get_jargon_candidates=Mock(
+ return_value=[
+ {"term": "打爆", "frequency": 9},
+ {"term": "上桌", "frequency": 6},
+ ],
+ ),
+ )
+
+ db = SimpleNamespace(
+ get_jargon=AsyncMock(
+ side_effect=[
+ {
+ "content": "打爆",
+ "meaning": "管理员手动释义",
+ "count": 9,
+ "is_complete": True,
+ },
+ None,
+ ]
+ ),
+ save_or_update_jargon=AsyncMock(return_value=2),
+ )
+ llm = SimpleNamespace(generate_response=AsyncMock(return_value="新释义"))
+ config = plugin_modules.PluginConfig(
+ embedding_provider_id="",
+ rerank_provider_id="",
+ knowledge_engine="legacy",
+ memory_engine="legacy",
+ )
+
+ integration = MinimalV2LearningIntegration(
+ config=config,
+ llm_adapter=llm,
+ db_manager=db,
+ context=SimpleNamespace(),
+ )
+
+ ok = await integration._trigger.force_tier2("jargon", "group-a")
+
+ assert ok is True
+ assert llm.generate_response.await_count == 1
+ db.save_or_update_jargon.assert_awaited_once_with(
+ "group-a",
+ "上桌",
+ {
+ "meaning": "新释义",
+ "raw_content": "[]",
+ "is_jargon": True,
+ "count": 6,
+ "is_complete": True,
+ PRESERVE_COMPLETED_JARGON_KEY: True,
+ },
+ )