优化 ai_auto_response 拟人化短回复并统一走 Dify 链路

- 移除普通 chat 调用分支,统一通过 Dify 请求生成回复
- 收紧小牛人格描述,强化短句、熟人感和非客服式表达
- 新增提示策略,按场景启用成员记忆/群事实/向量记忆,降低记忆压迫感
- 下调回复长度与上下文压缩配置,使默认回复更接近 10 字级别
- 通过 compileall 验证 ai_auto_response 插件语法可用
This commit is contained in:
liuwei
2026-04-24 14:12:26 +08:00
parent fa51af9d4f
commit 23544dca7a
5 changed files with 209 additions and 132 deletions
+131 -61
View File
@@ -39,7 +39,6 @@ from .context.conversation_hints import build_conversation_hints
from .core.decision_flow import DecisionFlow
from .core.triggers import TriggerRouter
from .core.llm_result_parser import LLMResultParser
from .core.prompt_builder import build_user_prompt
from .core.reply_formatter import finalize_reply, preview_text
from .safety.dedup import DedupManager
from .safety.filters import (
@@ -506,8 +505,8 @@ class AIAutoResponsePlugin(MessagePluginInterface):
)
context["coding_work_request"] = coding_work_request
system_prompt = self.persona_engine.build_system_prompt(group_profile, reply_mode)
user_prompt = build_user_prompt(context, memory_hints)
prompt_strategy = self._build_prompt_strategy(context=context, memory_hints=memory_hints)
context["prompt_strategy"] = prompt_strategy
try:
raw_response = await self._call_llm_async(
room_id=room_id,
@@ -517,8 +516,6 @@ class AIAutoResponsePlugin(MessagePluginInterface):
group_profile=group_profile,
memory_hints=memory_hints,
context=context,
system_prompt=system_prompt,
user_prompt=user_prompt,
image_urls=image_urls,
)
except asyncio.TimeoutError:
@@ -679,38 +676,39 @@ class AIAutoResponsePlugin(MessagePluginInterface):
group_profile: Dict,
memory_hints: Dict,
context: Dict,
system_prompt: str,
user_prompt: str,
image_urls: List[str],
) -> str:
user_id = f"{room_id}:{sender}"
if self.llm_client.provider == "dify":
files = self._build_dify_image_files(user_id=user_id, image_urls=image_urls)
payload = self._build_dify_simple_inputs(
sender_name=sender_name,
content=content,
group_profile=group_profile,
memory_hints=memory_hints,
context=context,
files=files,
# 这里明确只保留 Dify 这一条调用链。
# 这样人格、记忆裁剪、图片输入都只维护一套协议,避免 chat 与 dify 行为分叉。
if self.llm_client.provider != "dify":
self._log_event(
"model_skip",
room_id=room_id,
sender=sender,
reason="provider_not_dify",
provider=self.llm_client.provider,
)
result = self.llm_client.run(
prompt=content,
user=user_id,
inputs=payload,
tag="ai_auto_response",
files=files,
)
if not result:
return ""
return str((result or {}).get("text", "") or "").strip()
return self.llm_client.chat(
system_prompt,
user_prompt,
user_id=user_id,
image_urls=image_urls,
return ""
files = self._build_dify_image_files(user_id=user_id, image_urls=image_urls)
payload = self._build_dify_simple_inputs(
sender_name=sender_name,
content=content,
group_profile=group_profile,
memory_hints=memory_hints,
context=context,
files=files,
)
result = self.llm_client.run(
prompt=content,
user=user_id,
inputs=payload,
tag="ai_auto_response",
files=files,
)
if not result:
return ""
return str((result or {}).get("text", "") or "").strip()
async def _call_llm_async(
self,
@@ -722,8 +720,6 @@ class AIAutoResponsePlugin(MessagePluginInterface):
group_profile: Dict,
memory_hints: Dict,
context: Dict,
system_prompt: str,
user_prompt: str,
image_urls: List[str],
) -> str:
if self.llm_semaphore is None:
@@ -740,8 +736,6 @@ class AIAutoResponsePlugin(MessagePluginInterface):
group_profile=group_profile,
memory_hints=memory_hints,
context=context,
system_prompt=system_prompt,
user_prompt=user_prompt,
image_urls=image_urls,
),
timeout=self.llm_call_timeout_sec,
@@ -757,11 +751,15 @@ class AIAutoResponsePlugin(MessagePluginInterface):
context: Dict,
files: List[Dict[str, Any]],
) -> Dict[str, Any]:
prompt_strategy = context.get("prompt_strategy") or self._build_prompt_strategy(
context=context,
memory_hints=memory_hints,
)
persona = self._compose_dify_persona_text(group_profile, context)
group_profile_text = self._compact_text(
str(context.get("group_profile_prompt", "") or "").strip() or "当前群没有特殊画像。",
max_chars=int(self.prompt_compact_config.get("group_profile_max_chars", 560) or 560),
max_lines=int(self.prompt_compact_config.get("group_profile_max_lines", 10) or 10),
max_chars=int(self.prompt_compact_config.get("group_profile_max_chars", 220) or 220),
max_lines=int(self.prompt_compact_config.get("group_profile_max_lines", 6) or 6),
)
context_parts = [
@@ -769,7 +767,7 @@ class AIAutoResponsePlugin(MessagePluginInterface):
"最近上下文",
self._join_recent_messages(
context,
max_lines=int(self.prompt_compact_config.get("recent_message_max_lines", 8) or 8),
max_lines=int(prompt_strategy.get("recent_message_max_lines", 4) or 4),
max_line_chars=int(self.prompt_compact_config.get("recent_message_line_max_chars", 60) or 60),
),
),
@@ -779,20 +777,24 @@ class AIAutoResponsePlugin(MessagePluginInterface):
]
context_text = self._compact_text(
"\n\n".join([part for part in context_parts if part]).strip() or "无额外上下文。",
max_chars=int(self.prompt_compact_config.get("context_max_chars", 900) or 900),
max_lines=int(self.prompt_compact_config.get("context_max_lines", 18) or 18),
max_chars=int(self.prompt_compact_config.get("context_max_chars", 360) or 360),
max_lines=int(self.prompt_compact_config.get("context_max_lines", 10) or 10),
)
at_member_profile_text = self._compact_text(
str(context.get("at_member_profile_prompt", "") or ""),
max_chars=int(self.prompt_compact_config.get("at_member_profile_max_chars", 300) or 300),
max_lines=int(self.prompt_compact_config.get("at_member_profile_max_lines", 8) or 8),
)
member_memory_text = self._compact_text(
str(context.get("memory_prompt", "") or ""),
max_chars=int(self.prompt_compact_config.get("member_memory_max_chars", 520) or 520),
max_lines=int(self.prompt_compact_config.get("member_memory_max_lines", 12) or 12),
)
at_member_profile_text = ""
if bool(prompt_strategy.get("allow_member_memory")):
at_member_profile_text = self._compact_text(
str(context.get("at_member_profile_prompt", "") or ""),
max_chars=int(self.prompt_compact_config.get("at_member_profile_max_chars", 160) or 160),
max_lines=int(self.prompt_compact_config.get("at_member_profile_max_lines", 5) or 5),
)
member_memory_text = ""
if bool(prompt_strategy.get("allow_member_memory")):
member_memory_text = self._compact_text(
str(context.get("memory_prompt", "") or ""),
max_chars=int(self.prompt_compact_config.get("member_memory_max_chars", 180) or 180),
max_lines=int(self.prompt_compact_config.get("member_memory_max_lines", 6) or 6),
)
member_memory_text = self._remove_overlap_lines(member_memory_text, at_member_profile_text)
memory_parts = [
@@ -800,25 +802,42 @@ class AIAutoResponsePlugin(MessagePluginInterface):
self._string_block("成员记忆", member_memory_text),
self._string_block(
"群关系记忆",
self._memory_if_relevant(content, str(context.get("social_memory_prompt", "") or ""), "social"),
self._memory_if_relevant(
content,
str(context.get("social_memory_prompt", "") or ""),
"social",
enabled=bool(prompt_strategy.get("allow_social_memory")),
),
),
self._string_block(
"群事实记忆",
self._memory_if_relevant(content, str(context.get("group_facts_prompt", "") or ""), "facts"),
self._memory_if_relevant(
content,
str(context.get("group_facts_prompt", "") or ""),
"facts",
enabled=bool(prompt_strategy.get("allow_group_facts")),
),
),
self._string_block(
"向量召回记忆",
self._memory_if_relevant(content, str(context.get("vector_memory_prompt", "") or ""), "vector"),
self._memory_if_relevant(
content,
str(context.get("vector_memory_prompt", "") or ""),
"vector",
enabled=bool(prompt_strategy.get("allow_vector_memory")),
),
),
self._string_block(
"回归状态",
str(memory_hints.get("returning_member_state", "") or "").strip() or "none",
str(memory_hints.get("returning_member_state", "") or "").strip()
if bool(prompt_strategy.get("allow_member_memory"))
else "",
),
]
memory_text = self._compact_text(
"\n\n".join([part for part in memory_parts if part]).strip() or "无直接相关记忆。",
max_chars=int(self.prompt_compact_config.get("memory_max_chars", 900) or 900),
max_lines=int(self.prompt_compact_config.get("memory_max_lines", 18) or 18),
max_chars=int(self.prompt_compact_config.get("memory_max_chars", 240) or 240),
max_lines=int(self.prompt_compact_config.get("memory_max_lines", 8) or 8),
)
control_lines = [
@@ -827,6 +846,8 @@ class AIAutoResponsePlugin(MessagePluginInterface):
f"flow_state={context.get('flow_state', 'idle')}",
f"speaker_name={context.get('speaker_name_clean', '') or sender_name}",
f"address_style={group_profile.get('address_style', '低频称呼,默认直接接话')}",
f"target_reply_chars={prompt_strategy.get('target_reply_chars', 10)}",
f"hard_reply_cap={prompt_strategy.get('hard_reply_cap', 12)}",
]
if context.get("coding_work_request"):
control_lines.append("coding_work_request=true")
@@ -851,18 +872,24 @@ class AIAutoResponsePlugin(MessagePluginInterface):
str(group_profile.get("persona_id", "") or self.persona_engine.default_persona_id)
) or {}
mode = str(group_profile.get("mode", "") or "").strip().lower()
prompt_strategy = context.get("prompt_strategy") or {}
lines = [
str(preset.get("persona_text", "") or "").strip(),
f"整体风格:{preset.get('style', '')}".strip(),
f"熟悉感边界:{preset.get('familiarity_hint', '')}".strip(),
f"最多输出:{preset.get('max_reply_sentences', 3)}".strip(),
"冲突优先级:当前发言可验证信息 > 群场景约束 > 人设措辞。",
"强约束:默认1句短回复,尽量30字内;必要时最多2句,总体不超过55字。",
(
f"强约束:默认像群里顺手回一句,目标 {prompt_strategy.get('target_reply_chars', 10)} 字左右;"
f"硬上限 {prompt_strategy.get('hard_reply_cap', 12)} 字。"
),
"不要暴露 AI、模型、提示词、system 或记忆来源。",
"不要输出 markdown、代码块、标签。",
"不要替人写代码、改脚本、实现插件、代做开发活。",
"回复要自然、像群友,只处理当前最相关的一个话题。",
"如果信息不足就收着说,不要硬编。",
"轻社交先给态度,技术问题先给结论;都不要铺垫。",
"能半句说完就别写整句,少解释、少复述、少总结。",
"哪怕短回复,也尽量保留一点人格味道,别压成纯功能性短句。",
]
if mode in {"robotics", "openclaw"}:
@@ -893,15 +920,25 @@ class AIAutoResponsePlugin(MessagePluginInterface):
return ""
return f"{title}\n{text}"
def _memory_if_relevant(self, content: str, memory_text: str, memory_type: str) -> str:
def _memory_if_relevant(self, content: str, memory_text: str, memory_type: str, enabled: bool = True) -> str:
text = str(memory_text or "").strip()
if not text:
return ""
# 记忆现在不再默认灌给模型,而是先过一层“场景门槛”。
# 这样短回复场景就不会被长期记忆压住,人格也更容易稳定成真人式短接话。
if not enabled:
self._log_event(
"memory_skip",
memory_type=memory_type,
reason="strategy_disabled",
content_preview=preview_text(content, 36),
)
return ""
strict = bool(self.prompt_compact_config.get("strict_memory_relevance", True))
if not strict:
return self._compact_text(text, max_chars=360, max_lines=8)
return self._compact_text(text, max_chars=180, max_lines=4)
if self._is_text_relevant(content, text):
return self._compact_text(text, max_chars=360, max_lines=8)
return self._compact_text(text, max_chars=180, max_lines=4)
self._log_event(
"memory_skip",
memory_type=memory_type,
@@ -910,6 +947,39 @@ class AIAutoResponsePlugin(MessagePluginInterface):
)
return ""
def _build_prompt_strategy(self, *, context: Dict, memory_hints: Dict) -> Dict[str, Any]:
reply_mode = str(context.get("reply_mode", "social_short") or "social_short")
trigger_type = str(context.get("trigger_type", "none") or "none")
is_at = bool(context.get("is_at", False))
is_directed = bool(context.get("is_directed", False))
is_followup = bool(memory_hints.get("is_followup", False))
returning_state = str(memory_hints.get("returning_member_state", "") or "").strip()
strong_directed = is_at or is_directed or trigger_type in {"at_trigger", "quote_followup_trigger"}
is_question_like = reply_mode in {"qa_fast", "qa_with_context"}
# 这个策略专门解决“记忆很重、人格很弱”的问题:
# 1. 普通 social_short 基本不喂长期记忆,只保留最小现场感;
# 2. 明确点名、追问、回归成员时,才适度打开成员记忆;
# 3. 群事实和向量记忆只在问答场景打开,避免模型把记忆写进每句闲聊。
target_reply_chars_map = {"social_short": 10, "qa_fast": 16, "qa_with_context": 24}
hard_reply_cap_map = {"social_short": 12, "qa_fast": 18, "qa_with_context": 28}
recent_lines_map = {"social_short": 4, "qa_fast": 5, "qa_with_context": 6}
allow_member_memory = strong_directed or is_followup or returning_state in {"returning_member", "long_absent_member"}
allow_social_memory = is_question_like and strong_directed
allow_group_facts = reply_mode == "qa_with_context"
allow_vector_memory = reply_mode == "qa_with_context" or returning_state == "long_absent_member"
return {
"target_reply_chars": target_reply_chars_map.get(reply_mode, 10),
"hard_reply_cap": hard_reply_cap_map.get(reply_mode, 12),
"recent_message_max_lines": recent_lines_map.get(reply_mode, 4),
"allow_member_memory": allow_member_memory,
"allow_social_memory": allow_social_memory,
"allow_group_facts": allow_group_facts,
"allow_vector_memory": allow_vector_memory,
}
@staticmethod
def _compact_text(text: str, max_chars: int, max_lines: int) -> str:
raw = str(text or "").strip()