feat: complete grounded digital avatar chat experience

This commit is contained in:
stefanfeng
2026-08-19 14:21:56 +08:00
parent 07d4a21379
commit 4a2d788e85
16 changed files with 1013 additions and 193 deletions
@@ -5,7 +5,7 @@ from unittest.mock import Mock
from fastapi import HTTPException
from models import Avatar, User
from routers.chat import _build_prompt, _match_standard_qa, _require_owned_avatar, _resolve_reply
from routers.chat import _build_prompt, _iter_text_chunks, _match_standard_qa, _public_avatar_payload, _require_owned_avatar, _resolve_reply
class ChatOrchestrationTests(unittest.TestCase):
@@ -13,6 +13,12 @@ class ChatOrchestrationTests(unittest.TestCase):
self.avatar = SimpleNamespace(
id="avatar-1",
owner_id="huihui-user-1",
name="冯医生",
display_name="冯医生",
description="耳鼻喉科领域专家",
photo_url="https://example.test/avatar.png",
emoji="👨‍⚕️",
status="active",
config={
"replyStyle": "professional",
"creativity": 50,
@@ -20,6 +26,10 @@ class ChatOrchestrationTests(unittest.TestCase):
"humor": 20,
"responseLength": "medium",
"systemPrompt": "不要编造政策。",
"profession": "医生",
"position": "主任医师",
"organization": "测试医院",
"organizationAddress": "测试路1号",
},
)
self.qa = SimpleNamespace(question="公司地址?", answer="标准地址", enabled=True)
@@ -40,6 +50,24 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertEqual(result["answer"], "标准地址")
fake_model.assert_not_called()
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question):
matched = _match_standard_qa(question, [self.disabled_qa, self.qa])
self.assertIs(matched, self.qa)
def test_short_related_question_matches_single_standard_qa(self):
matched = _match_standard_qa("地址", [self.qa])
self.assertIs(matched, self.qa)
def test_ambiguous_short_question_does_not_pick_arbitrarily(self):
hospital = SimpleNamespace(question="医院地址", answer="医院地址答案", enabled=True)
company = SimpleNamespace(question="公司地址", answer="公司地址答案", enabled=True)
self.assertIsNone(_match_standard_qa("地址", [hospital, company]))
def test_unrelated_question_does_not_match_standard_qa(self):
self.assertIsNone(_match_standard_qa("今天天气怎么样", [self.qa]))
def test_knowledge_context_is_sent_to_qwen_after_qa_miss(self):
fake_model = Mock(return_value="根据知识库内容回答")
knowledge_hit = {
@@ -58,11 +86,43 @@ class ChatOrchestrationTests(unittest.TestCase):
)
self.assertEqual(result["source"], "knowledge")
self.assertIn("知识库内容", fake_model.call_args.kwargs["messages"][0]["content"])
self.assertIn("只能依据本人资料", fake_model.call_args.kwargs["messages"][0]["content"])
def test_prompt_contains_personality_configuration(self):
messages = _build_prompt(self.avatar, [], "你好", [])
self.assertIn("严谨度", messages[0]["content"])
self.assertNotIn("冯医生", messages[0]["content"])
self.assertIn("耳鼻喉科领域专家", messages[0]["content"])
self.assertIn("职业:医生", messages[0]["content"])
self.assertIn("职位:主任医师", messages[0]["content"])
self.assertIn("单位:测试医院", messages[0]["content"])
self.assertIn("单位地址:测试路1号", messages[0]["content"])
self.assertIn("不要编造政策", messages[0]["content"])
self.assertIn("模型供应商", messages[0]["content"])
self.assertIn("不要称自己为数字人", messages[0]["content"])
self.assertIn("输出排版规范", messages[0]["content"])
self.assertIn("任何回答都不要说出自己的姓名", messages[0]["content"])
self.assertIn("不要自我介绍", messages[0]["content"])
self.assertIn("像熟人之间微信聊天一样", messages[0]["content"])
self.assertIn("不隶属于任何机构", messages[0]["content"])
self.assertIn("不要连续输出空行", messages[0]["content"])
def test_prompt_blocks_ungrounded_factual_answers(self):
messages = _build_prompt(self.avatar, [], "聊聊国际新闻", [])
system = messages[0]["content"]
self.assertIn("没有检索到可靠资料", system)
self.assertIn("不要凭通用知识", system)
self.assertIn("不要提及知识库", system)
def test_public_avatar_payload_excludes_internal_configuration(self):
payload = _public_avatar_payload(self.avatar)
self.assertEqual(payload["displayName"], "冯医生")
self.assertEqual(payload["photoUrl"], "https://example.test/avatar.png")
self.assertNotIn("config", payload)
self.assertNotIn("ownerId", payload)
def test_standard_answer_can_be_emitted_as_sse_chunks(self):
self.assertEqual(list(_iter_text_chunks("标准答案内容", size=2)), ["标准", "答案", "内容"])
def test_chat_rejects_avatar_owned_by_another_user(self):
class Query: