Compare commits

...
3 changed files with 92 additions and 12 deletions
+22 -2
View File
@@ -478,6 +478,24 @@ def _qa_requires_language_adaptation(question: str, answer: str) -> bool:
)
def _qa_requires_per_turn_rendering(
question: str,
answer: str,
history: list[Any],
) -> bool:
"""Keep the direct QA fast path only when no conversation can bias language."""
return bool(history) or _qa_requires_language_adaptation(question, answer)
def _per_turn_language_instruction() -> str:
return (
"本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。"
"即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;"
"不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的"
"自然语言。不要说明你检测、切换或翻译了语言。"
)
def _canonicalize_question(value: str) -> str:
value = _normalize_question(value)
replacements = (
@@ -709,6 +727,8 @@ def _build_prompt(
messages = [{"role": "system", "content": system}]
for item in history[-MAX_HISTORY_MESSAGES:]:
messages.append({"role": item.role, "content": item.content} if hasattr(item, "role") else item)
# Keep the language instruction adjacent to the current turn so long histories cannot override it.
messages.append({"role": "system", "content": _per_turn_language_instruction()})
messages.append({"role": "user", "content": question.strip()})
return messages
@@ -845,7 +865,7 @@ def _resolve_reply(
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
matched and _qa_requires_per_turn_rendering(question, matched.answer, history)
)
if matched and not adapt_qa_language and not image_contexts:
return {"answer": matched.answer, "source": "qa", "references": []}
@@ -940,7 +960,7 @@ def _stream_reply(
qa_pairs = db.query(QAPair).filter(QAPair.avatar_id == avatar.id).all()
matched = _match_standard_qa(question, qa_pairs)
adapt_qa_language = bool(
matched and _qa_requires_language_adaptation(question, matched.answer)
matched and _qa_requires_per_turn_rendering(question, matched.answer, history)
)
messages, reservation = [], None
if matched and not adapt_qa_language and not image_contexts:
@@ -11,6 +11,7 @@ from routers.chat import (
_match_standard_qa,
_public_avatar_payload,
_qa_requires_language_adaptation,
_qa_requires_per_turn_rendering,
_require_owned_avatar,
_resolve_reply,
)
@@ -86,6 +87,41 @@ class ChatOrchestrationTests(unittest.TestCase):
self.assertTrue(_qa_requires_language_adaptation("안녕하세요", "你好"))
self.assertFalse(_qa_requires_language_adaptation("你好", "您好"))
def test_conversation_qa_is_rendered_for_the_current_turn_language(self):
history = [SimpleNamespace(role="user", content="Please answer in English.")]
self.assertTrue(_qa_requires_per_turn_rendering("Quelle est votre adresse ?", "Our address is Test Road 1.", history))
fake_model = Mock(return_value="Notre adresse est Test Road 1.")
result = _resolve_reply(
None,
self.avatar,
"Quelle est votre adresse ?",
history,
qa_pairs=[SimpleNamespace(question="Quelle est votre adresse ?", answer="Our address is Test Road 1.", enabled=True)],
search_fn=Mock(),
model_client=fake_model,
)
self.assertEqual(result["source"], "qa")
self.assertEqual(result["answer"], "Notre adresse est Test Road 1.")
messages = fake_model.call_args.kwargs["messages"]
self.assertEqual(messages[-1], {"role": "user", "content": "Quelle est votre adresse ?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("本轮语言覆盖指令", messages[-2]["content"])
self.assertIn("不要沿用上一轮语言", messages[-2]["content"])
def test_latest_user_message_has_an_adjacent_language_override(self):
history = [
SimpleNamespace(role="user", content="请用中文回答"),
SimpleNamespace(role="assistant", content="好的,请问有什么可以帮你?"),
]
messages = _build_prompt(self.avatar, history, "What can you help me with?", [])
self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"})
self.assertEqual(messages[-2]["role"], "system")
self.assertIn("最新用户消息", messages[-2]["content"])
self.assertIn("立即切换到相同语言", messages[-2]["content"])
def test_conversational_paraphrase_matches_standard_qa(self):
for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"):
with self.subTest(question=question):
@@ -32,7 +32,7 @@
</div>
<div v-if="displayDocs.length" class="mobile-card-list">
<article v-for="doc in displayDocs" :key="doc.id" class="knowledge-card">
<article v-for="doc in displayDocs" :key="doc.id" class="knowledge-card document-card">
<div class="card-icon">{{ fileEmoji(doc.fileType) }}</div>
<div class="card-content">
<div class="card-title-row">
@@ -46,9 +46,14 @@
</div>
</div>
<div class="card-actions">
<button v-if="documentState(doc).tone === 'failed'" class="card-retry" @click="retryDoc(doc.id)">重新索引</button>
<button v-if="!doc.localUploading" class="card-delete" @click="removeDoc(doc.id)">{{ doc.localOnly ? '移除' : '删除' }}</button>
</div>
<div v-if="canRetryDoc(doc)" class="card-retry-area">
<span v-if="retryErrors[doc.id]" class="card-retry-error">{{ retryErrors[doc.id] }}</span>
<button class="card-retry" :disabled="retryingDocs[doc.id]" @click="retryDoc(doc)">
{{ retryingDocs[doc.id] ? '重新索引中…' : '重新索引' }}
</button>
</div>
</article>
</div>
<div v-else class="card-empty">📂 暂无文档,先上传一个知识文件</div>
@@ -115,6 +120,8 @@ const uploading = computed(() => pendingUploads.value.some((doc) => doc.localUpl
const uploadError = ref('')
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const retryingDocs = ref<Record<string, boolean>>({})
const retryErrors = ref<Record<string, string>>({})
let documentPollingTimer: ReturnType<typeof setInterval> | undefined
const query = ref('')
@@ -250,14 +257,24 @@ const uploadOne = async (file: File, ext: string) => {
}
}
const retryDoc = async (id: string) => {
if (!avatarId.value) return
uploadError.value = ''
const canRetryDoc = (doc: any) =>
!doc.localOnly && doc.filePresent !== false && documentState(doc).tone === 'failed'
const retryDoc = async (doc: any) => {
if (!avatarId.value || !canRetryDoc(doc) || retryingDocs.value[doc.id]) return
retryingDocs.value = { ...retryingDocs.value, [doc.id]: true }
retryErrors.value = { ...retryErrors.value, [doc.id]: '' }
try {
await retryKnowledgeDoc(avatarId.value, id)
await loadDocs()
const updated: any = await retryKnowledgeDoc(avatarId.value, doc.id)
Object.assign(doc, updated)
startDocumentPolling()
} catch (e: any) {
uploadError.value = e?.message || '重新索引失败'
retryErrors.value = {
...retryErrors.value,
[doc.id]: e?.response?.data?.message || e?.response?.data?.detail || e?.message || '重新索引失败'
}
} finally {
retryingDocs.value = { ...retryingDocs.value, [doc.id]: false }
}
}
@@ -378,6 +395,7 @@ onUnmounted(stopDocumentPolling)
.panel-heading p { margin: -5px 0 0; color: #9398AE; font-size: 12px; }
.mobile-card-list { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; min-width: 0; gap: 10px; }
.knowledge-card { display: flex; align-items: center; width: 100%; min-width: 0; box-sizing: border-box; gap: 11px; padding: 14px; background: #fff; border: 1px solid #F1E1D3; border-radius: 16px; box-shadow: 0 5px 16px rgba(112, 62, 22, .04); }
.document-card { display: grid; grid-template-columns: 42px minmax(0, 1fr) auto; align-items: center; }
.card-icon { flex: 0 0 auto; width: 42px; height: 42px; display: grid; place-items: center; border-radius: 13px; background: #FFF3E6; font-size: 22px; }
.card-content { min-width: 0; flex: 1; overflow: hidden; }
.card-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; }
@@ -388,10 +406,13 @@ onUnmounted(stopDocumentPolling)
.card-meta, .card-detail { margin: 5px 0 0; color: #9398AE; font-size: 11px; line-height: 1.4; }.card-detail { color: #8B6B58; }
.progress-track { width: 100%; height: 4px; margin-top: 8px; overflow: hidden; border-radius: 999px; background: #FDE7D1; }
.progress-fill { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #FB923C, #F97316); transition: width .25s ease; }
.card-actions { flex: 0 0 auto; display: flex; flex-direction: column; align-items: stretch; gap: 6px; }
.card-actions { flex: 0 0 auto; display: flex; align-items: center; }
.card-delete, .card-retry { align-self: center; border: 0; border-radius: 8px; padding: 7px 9px; font-size: 12px; cursor: pointer; white-space: nowrap; }
.card-delete { color: #EF4444; background: #FEF2F2; }
.card-retry { color: #C15F18; background: #FFF3E6; }
.card-retry:disabled { cursor: wait; opacity: .65; }
.card-retry-area { grid-column: 1 / -1; display: flex; align-items: center; justify-content: flex-end; gap: 10px; min-width: 0; }
.card-retry-error { min-width: 0; overflow: hidden; color: #DC2626; font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.card-empty { padding: 42px 16px; border: 1px dashed #F1D9C3; border-radius: 16px; color: #9398AE; background: #fff; font-size: 14px; text-align: center; }
.qa-card { align-items: stretch; text-align: left; }.qa-card.qa-disabled { opacity: .58; }
.qa-card .card-content,
@@ -617,8 +638,11 @@ onUnmounted(stopDocumentPolling)
@media (max-width: 520px) {
.knowledge-panel { padding: 0 12px; }
.knowledge-card { display: grid; grid-template-columns: 42px minmax(0, 1fr); align-items: start; gap: 10px; padding: 13px; }
.document-card { grid-template-columns: 42px minmax(0, 1fr) auto; }
.card-content { grid-column: 2; }
.card-delete { grid-column: 2; justify-self: end; margin-top: -2px; }
.card-actions { grid-column: 3; grid-row: 1; }
.card-delete { justify-self: end; margin-top: -2px; }
.card-retry-area { grid-column: 1 / -1; }
.qa-card { display: block; }
.qa-card .card-content { width: 100%; grid-column: 1; }
.card-title-row { align-items: flex-start; flex-wrap: wrap; gap: 5px 7px; }