fix: 桌面模式服务器不再拼接指令语气的 user 消息外壳,三端 AI 校对统一中性标签
This commit is contained in:
1 parent
8aa0751e2a
commit
68dff21dc6
8 files changed
+123
-8
No files matched your search
@@ -9,6 +9,13 @@
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] - 未发布
|
||||
|
||||
### 新增
|
||||
- 快捷键支持鼠标侧键:「按住说话」与「免提模式」均可绑定鼠标侧键(后退键 / 前进键)。在设置里点录制、按一下侧键即可绑定。
|
||||
|
||||
---
|
||||
|
||||
## [0.0.9] - 2026-07-16
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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}]
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user