fix: 桌面模式服务器不再拼接指令语气的 user 消息外壳,三端 AI 校对统一中性标签

This commit is contained in:
crosswk committed 2026-07-17 10:11:16 +08:00
1 parent 8aa0751e2a
commit 68dff21dc6
8 files changed
+123 -8

No files matched your search

+7
View File
@@ -9,6 +9,13 @@
---
## [0.1.0] - 未发布
### 新增
- 快捷键支持鼠标侧键:「按住说话」与「免提模式」均可绑定鼠标侧键(后退键 / 前进键)。在设置里点录制、按一下侧键即可绑定。
---
## [0.0.9] - 2026-07-16
### 新增
+4 -1
View File
@@ -1,6 +1,7 @@
// Ollama AI 供应商
// 调用本地 Ollama 的 /api/generate 接口
use super::prompt::wrap_user_text;
use super::types::{AiProviderConfig, AiResult, TestResult};
use std::time::Instant;
@@ -19,7 +20,9 @@ pub async fn polish(
let url = normalize_url(&config.api_url);
let sys_prompt = system_prompt.unwrap_or("你是语音转文本的校对助手。");
let combined = format!("{}\n\n请处理以下语音转写文本:\n\n{}", sys_prompt, text);
// Ollama /api/generate 是单一 prompt 字符串,没有 system/user 角色区分,
// 这里手动拼接:system prompt 在前,user 消息(中性标签包裹)在后。
let combined = format!("{}\n\n{}", sys_prompt, wrap_user_text(text));
let model = if config.model.is_empty() {
"qwen2.5:7b"
@@ -1,6 +1,7 @@
// OpenAI 兼容 AI 供应商
// 覆盖所有支持 /v1/chat/completions 的服务:DeepSeek、通义、豆包(火山方舟)等
use super::prompt::wrap_user_text;
use super::types::{AiProviderConfig, AiResult, TestResult};
use std::time::Instant;
@@ -21,7 +22,7 @@ pub async fn polish(
let url = format!("{}/chat/completions", base_url);
let sys_prompt = system_prompt.unwrap_or("你是语音转文本的校对助手。");
let user_content = format!("请处理以下语音转写文本:\n\n{}", text);
let user_content = wrap_user_text(text);
let mut body = serde_json::json!({
"model": config.model,
+1
View File
@@ -1,6 +1,7 @@
// 转写供应商模块 — ASR 和 AI 的 trait 定义及具体实现
pub mod types;
pub mod prompt;
pub mod ai_openai_compat;
pub mod ai_ollama;
pub mod asr_doubao;
+14
View File
@@ -0,0 +1,14 @@
// AI 校对请求的 user 消息统一构造 —— 云 API / 本地 Ollama 共用。
//
// 背景:早期这里的 user 消息外壳是一句祈使句("请处理以下语音转写文本:"),
// 与服务器模式此前的做法(server/backend/app/llm.py 里 "请校对以下…")是同一类
// 问题:当 ASR 识别出的文本本身也是祈使句(如用户说的是"请执行")时,外壳的
// "请…" 和文本内容的"请…"首尾相连,容易被模型误读成一句连续指令,而不是
// "这是一段待清洗的数据",导致模型反问"请提供文本"而不是正常输出清洗结果。
//
// 现在改为与服务器模式完全一致的中性标签包裹,不含任何动词/祈使句,只标出
// 文本边界,prompt 的全部措辞(风格、约束)仍 100% 由 system_prompt(用户的
// 预设)决定,这里不再"额外说话"。三种模式(服务器 / 云 API / 本地)保持统一。
pub fn wrap_user_text(text: &str) -> String {
format!("<asr_text>\n{}\n</asr_text>", text)
}
+33 -5
View File
@@ -40,6 +40,13 @@ _DEFAULT_SYSTEM_PROMPT = """\
_DEFAULT_USER_PREFIX = "请校对以下 <asr_text> 标签内的语音转写文本:\n\n<asr_text>"
# 桌面客户端模式下用户消息的外壳:不含任何动词/祈使句,只用一对标签给待清洗文本
# 标出边界(模型输出后会被 _strip_thinking 清掉,不会混进结果)。这条壳文字固定、
# 不可配置——桌面模式的全部 prompt 措辞(风格、约束)都由客户端的 system_prompt
# 决定,服务器不再额外"发出"任何指令,避免壳文字与文本内容本身恰好都是祈使句时
# (如 ASR 说的是"请执行")被模型误当成连续指令。
_DESKTOP_USER_WRAPPER = "<asr_text>\n{text}\n</asr_text>"
class LLMEngine:
def __init__(self, profile: LLMProfile) -> None:
@@ -54,12 +61,20 @@ class LLMEngine:
# ── public API ──
async def polish(self, text: str, system_prompt: str | None = None) -> tuple[str, int, dict]:
"""Polish text, return (result, elapsed_ms, debug_info)."""
async def polish(
self, text: str, system_prompt: str | None = None, *, is_web_demo: bool = False,
) -> tuple[str, int, dict]:
"""Polish text, return (result, elapsed_ms, debug_info).
is_web_demo=False(桌面客户端,默认):system prompt 完全由调用方(客户端)
提供,用户消息用固定的中性标签包一下,服务器不额外拼任何指令措辞。
is_web_demo=True:网页体验版没有客户端,永远使用服务器本地 prompts/ 目录下的
system.txt / user.txt(或内置默认值),忽略传入的 system_prompt(即使有)。
"""
if not text or not self._cfg.enabled:
return text, 0, {"skipped": True}
messages = self._build_messages(text, system_prompt)
messages = self._build_messages(text, system_prompt, is_web_demo=is_web_demo)
provider = self._cfg.provider.lower()
try:
@@ -99,9 +114,22 @@ class LLMEngine:
# ── internals ──
def _build_messages(self, text: str, system_prompt: str | None = None) -> list[dict]:
def _build_messages(
self, text: str, system_prompt: str | None = None, *, is_web_demo: bool = False,
) -> list[dict]:
if not is_web_demo:
# 桌面模式:system prompt 全由客户端决定(服务器本地 system.txt 仅在客户端
# 异常未传时兜底);用户消息固定用中性标签包裹,不含任何指令措辞。
sys_p = system_prompt or _read_prompt(
f"{self._cfg.prompt_dir}/system.txt", _DEFAULT_SYSTEM_PROMPT
)
user_content = _DESKTOP_USER_WRAPPER.format(text=text)
return [{"role": "system", "content": sys_p}, {"role": "user", "content": user_content}]
# Web demo:没有客户端,永远用服务器本地 prompts/ 目录(或内置默认值)兜底——
# 即使调用方误传了 system_prompt 也忽略,双重保险防止客户端预设泄露到公开体验页。
d = self._cfg.prompt_dir
sys_p = system_prompt or _read_prompt(f"{d}/system.txt", _DEFAULT_SYSTEM_PROMPT)
sys_p = _read_prompt(f"{d}/system.txt", _DEFAULT_SYSTEM_PROMPT)
user_prefix = _read_prompt(f"{d}/user.txt", _DEFAULT_USER_PREFIX)
user_content = f"{user_prefix}{text}\n</asr_text>" if "<asr_text>" in user_prefix else f"{user_prefix}{text}"
return [{"role": "system", "content": sys_p}, {"role": "user", "content": user_content}]
+3 -1
View File
@@ -442,7 +442,9 @@ async def _run_asr_llm(
llm_text, llm_ms, llm_debug = asr_text, 0, {}
if asr_text and selected_llm and selected_profile.enabled and not disable_ai:
llm_text, llm_ms, llm_debug = await selected_llm.polish(asr_text, system_prompt)
llm_text, llm_ms, llm_debug = await selected_llm.polish(
asr_text, system_prompt, is_web_demo=use_web_demo_profile,
)
if cfg.logging.slow_llm_ms and llm_ms >= cfg.logging.slow_llm_ms:
logger.warning("Slow LLM detected duration_sec=%.2f llm_ms=%d", duration, llm_ms)
+59
View File
@@ -0,0 +1,59 @@
"""Tests for LLMEngine message construction: desktop vs web-demo prompt handling.
桌面模式:服务器不得拼接任何指令措辞,system prompt 完全来自调用方,用户消息只用
中性标签包裹待清洗文本。Web demo:沿用服务器本地 prompts/ 目录兜底(保持原行为)。
"""
from __future__ import annotations
import unittest
from backend.app.config import LLMProfile
from backend.app.llm import LLMEngine, _DEFAULT_SYSTEM_PROMPT
class DesktopModeMessageTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = LLMEngine(LLMProfile(prompt_dir="/nonexistent/prompt/dir"))
def test_uses_client_system_prompt_verbatim(self) -> None:
messages = self.engine._build_messages(
"请执行", system_prompt="自定义客户端预设", is_web_demo=False,
)
self.assertEqual(messages[0]["content"], "自定义客户端预设")
def test_user_message_has_no_instruction_wording(self) -> None:
messages = self.engine._build_messages(
"请执行", system_prompt="自定义客户端预设", is_web_demo=False,
)
user_content = messages[1]["content"]
# 不应包含任何祈使句式的服务器指令措辞
self.assertNotIn("请校对", user_content)
self.assertNotIn("请处理", user_content)
# 待清洗文本仍完整出现在标签内
self.assertIn("<asr_text>", user_content)
self.assertIn("请执行", user_content)
def test_falls_back_to_local_system_prompt_if_none_given(self) -> None:
messages = self.engine._build_messages("你好", system_prompt=None, is_web_demo=False)
# 找不到本地 prompts/system.txt(目录不存在)时应回退到内置默认值
self.assertEqual(messages[0]["content"], _DEFAULT_SYSTEM_PROMPT)
class WebDemoMessageTests(unittest.TestCase):
def setUp(self) -> None:
self.engine = LLMEngine(LLMProfile(prompt_dir="/nonexistent/prompt/dir"))
def test_ignores_client_system_prompt_and_uses_server_default(self) -> None:
messages = self.engine._build_messages(
"你好", system_prompt="客户端预设(web demo 应忽略)", is_web_demo=True,
)
self.assertEqual(messages[0]["content"], _DEFAULT_SYSTEM_PROMPT)
def test_wraps_user_text_with_default_prefix(self) -> None:
messages = self.engine._build_messages("你好", system_prompt=None, is_web_demo=True)
self.assertIn("请校对以下", messages[1]["content"])
self.assertIn("你好", messages[1]["content"])
if __name__ == "__main__":
unittest.main()