From b3367eedaac51ee6d360888a17d76a904f26f2a8 Mon Sep 17 00:00:00 2001 From: stefanfeng Date: Wed, 16 Sep 2026 14:57:37 +0800 Subject: [PATCH] revert: sync production code to verified test version --- digital-avatar-app/backend/routers/chat.py | 165 +++--------------- .../backend/tests/test_chat_orchestration.py | 39 +---- digital-avatar-app/src/router/index.ts | 7 +- digital-avatar-app/src/utils/uniapp-bridge.ts | 18 +- digital-avatar-app/src/views/AvatarManage.vue | 9 +- uniapp-avatar/src/pages/index/index.vue | 6 - uniapp-avatar/src/utils/bridge.js | 3 - 7 files changed, 34 insertions(+), 213 deletions(-) diff --git a/digital-avatar-app/backend/routers/chat.py b/digital-avatar-app/backend/routers/chat.py index 967d236..fece73e 100644 --- a/digital-avatar-app/backend/routers/chat.py +++ b/digital-avatar-app/backend/routers/chat.py @@ -79,34 +79,6 @@ _WRITING_SYSTEM_PATTERNS = { _JAPANESE_KANA = re.compile(r"[\u3040-\u30ff]") _KOREAN_HANGUL = re.compile(r"[\uac00-\ud7af\u1100-\u11ff]") -_LATIN_LANGUAGE_MARKERS = { - "English": re.compile( - r"\b(?:i|you|we|they|he|she|have|has|had|friend|who|what|where|when|why|how|" - r"symptoms?|disease|please|can|could|would|should|is|are|was|were|the|this|that)\b", - re.IGNORECASE, - ), - "French": re.compile( - r"\b(?:je|tu|vous|nous|ils|elle|une|des|avec|pour|pourquoi|comment|bonjour|est|sont)\b", - re.IGNORECASE, - ), - "Spanish": re.compile( - r"\b(?:yo|tu|usted|nosotros|ellos|ella|una|con|para|por que|como|hola|esta|son)\b", - re.IGNORECASE, - ), - "German": re.compile( - r"\b(?:ich|du|sie|wir|eine|mit|fur|warum|wie|hallo|ist|sind|haben)\b", - re.IGNORECASE, - ), - "Portuguese": re.compile( - r"\b(?:eu|voce|nos|eles|ela|uma|com|para|porque|como|ola|esta|sao|tenho)\b", - re.IGNORECASE, - ), - "Italian": re.compile( - r"\b(?:io|tu|voi|noi|loro|una|con|per|perche|come|ciao|sono|avere)\b", - re.IGNORECASE, - ), -} - class ChatMessage(BaseModel): model_config = ConfigDict(populate_by_name=True) @@ -515,67 +487,15 @@ def _qa_requires_per_turn_rendering( return bool(history) or _qa_requires_language_adaptation(question, answer) -def _latin_language_name(value: str) -> str: - scores = { - language: len(pattern.findall(value or "")) - for language, pattern in _LATIN_LANGUAGE_MARKERS.items() - } - language, score = max(scores.items(), key=lambda item: item[1]) - return language if score else "the same natural language as the latest user message" - - -def _turn_language_name(value: str) -> str: - writing_system = _dominant_writing_system(value) - return { - "han": "Chinese", - "japanese": "Japanese", - "korean": "Korean", - "cyrillic": "the same Cyrillic-script language as the latest user message", - "arabic": "the same Arabic-script language as the latest user message", - "hebrew": "Hebrew", - "devanagari": "the same Devanagari-script language as the latest user message", - "thai": "Thai", - "greek": "Greek", - "latin": _latin_language_name(value), - }.get(writing_system, "the same natural language as the latest user message") - - -def _per_turn_language_instruction(question: str = "") -> str: - language = _turn_language_name(question) +def _per_turn_language_instruction() -> str: return ( - f"MANDATORY OUTPUT LANGUAGE FOR THIS TURN: {language}. " - "Write the entire answer only in that language. This instruction overrides the languages used by " - "conversation history, profile data, standard answers, retrieved documents, and custom prompts. " - "Translate grounded source material faithfully when necessary. Do not mention language detection, " - "translation, or this instruction." + "本轮语言覆盖指令:只根据紧随其后的最新用户消息判断本轮回答语言。" + "即使此前整段对话一直使用另一种语言,只要最新消息切换了语言,本轮就必须立即切换到相同语言;" + "不要沿用上一轮语言。若最新消息明确指定回答语言,以该指定为准;若混用多种语言,使用其中占主导的" + "自然语言。不要说明你检测、切换或翻译了语言。" ) -def _answer_requires_language_repair(question: str, answer: str) -> bool: - question_system = _dominant_writing_system(question) - answer_system = _dominant_writing_system(answer) - return ( - question_system != "unknown" - and answer_system != "unknown" - and question_system != answer_system - ) - - -def _language_repair_messages(question: str, answer: str) -> list[dict]: - return [ - {"role": "system", "content": _per_turn_language_instruction(question)}, - { - "role": "system", - "content": ( - "Rewrite the supplied draft in the mandatory output language. Preserve every grounded fact, " - "number, proper noun, uncertainty, and safety qualification. Add no new information and output " - "only the rewritten answer." - ), - }, - {"role": "user", "content": answer.strip()}, - ] - - def _canonicalize_question(value: str) -> str: value = _normalize_question(value) replacements = ( @@ -808,7 +728,7 @@ def _build_prompt( 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(question)}) + messages.append({"role": "system", "content": _per_turn_language_instruction()}) messages.append({"role": "user", "content": question.strip()}) return messages @@ -871,41 +791,6 @@ def _call_qwen( return {"answer": answer.strip(), "usage": data.get("usage") or {}} -def _call_billed_qwen( - db: Session, - avatar: Avatar, - messages: list[dict], - temperature: float, - usage_source: str, - model_config: ChatModelConfig, -) -> tuple[str, dict]: - reservation = reserve_avatar_tokens( - db, - avatar, - usage_source, - model_config.model, - messages, - model_config.max_tokens, - ) - try: - model_result = _call_qwen( - messages=messages, - temperature=temperature, - model_config=model_config, - ) - answer = model_result["answer"] - token_usage = settle_reservation( - db, - reservation, - model_result.get("usage"), - fallback_total=estimate_fallback_usage(messages, answer), - ) - return answer, token_usage - except Exception as exc: - release_reservation(db, reservation, str(exc)) - raise - - def _iter_qwen_stream( messages: list[dict], temperature: float, model_config: ChatModelConfig | None = None ): @@ -1014,36 +899,32 @@ def _resolve_reply( token_usage = None if model_client is not None: answer = model_client(messages=messages, temperature=temperature) - if _answer_requires_language_repair(question, str(answer or "")): - answer = model_client( - messages=_language_repair_messages(question, str(answer)), - temperature=0.0, - ) else: model_config = get_chat_model_config() - answer, token_usage = _call_billed_qwen( + reservation = reserve_avatar_tokens( db, avatar, - messages, - temperature, usage_source, - model_config, + model_config.model, + messages, + model_config.max_tokens, ) - if _answer_requires_language_repair(question, answer): - logger.warning( - "chat response language mismatch avatar=%s source=%s expected=%s", - avatar.id, - usage_source, - _turn_language_name(question), + try: + model_result = _call_qwen( + messages=messages, + temperature=temperature, + model_config=model_config, ) - answer, token_usage = _call_billed_qwen( + answer = model_result["answer"] + token_usage = settle_reservation( db, - avatar, - _language_repair_messages(question, answer), - 0.0, - f"{usage_source}_language_repair", - model_config, + reservation, + model_result.get("usage"), + fallback_total=estimate_fallback_usage(messages, answer), ) + except Exception as exc: + release_reservation(db, reservation, str(exc)) + raise answer = str(answer or "").strip() if image_contexts and _answer_denies_available_image(answer): logger.warning( diff --git a/digital-avatar-app/backend/tests/test_chat_orchestration.py b/digital-avatar-app/backend/tests/test_chat_orchestration.py index 70f7f78..a4a66d4 100644 --- a/digital-avatar-app/backend/tests/test_chat_orchestration.py +++ b/digital-avatar-app/backend/tests/test_chat_orchestration.py @@ -6,7 +6,6 @@ from fastapi import HTTPException from models import Avatar, User from routers.chat import ( - _answer_requires_language_repair, _build_prompt, _iter_text_chunks, _match_standard_qa, @@ -15,7 +14,6 @@ from routers.chat import ( _qa_requires_per_turn_rendering, _require_owned_avatar, _resolve_reply, - _turn_language_name, ) @@ -109,8 +107,8 @@ class ChatOrchestrationTests(unittest.TestCase): 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("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"]) - self.assertIn("French", messages[-2]["content"]) + self.assertIn("本轮语言覆盖指令", messages[-2]["content"]) + self.assertIn("不要沿用上一轮语言", messages[-2]["content"]) def test_latest_user_message_has_an_adjacent_language_override(self): history = [ @@ -121,37 +119,8 @@ class ChatOrchestrationTests(unittest.TestCase): self.assertEqual(messages[-1], {"role": "user", "content": "What can you help me with?"}) self.assertEqual(messages[-2]["role"], "system") - self.assertIn("MANDATORY OUTPUT LANGUAGE", messages[-2]["content"]) - self.assertIn("English", messages[-2]["content"]) - - def test_reported_alzheimer_question_is_explicitly_english(self): - question = "I have a friend who has symptoms of Alzheimer's disease" - - self.assertEqual(_turn_language_name(question), "English") - messages = _build_prompt(self.avatar, [], question, []) - self.assertIn("MANDATORY OUTPUT LANGUAGE FOR THIS TURN: English", messages[-2]["content"]) - - def test_non_stream_reply_repairs_a_wrong_writing_system_before_sending(self): - question = "I have a friend who has symptoms of Alzheimer's disease" - fake_model = Mock(side_effect=["建议尽快就医评估。", "Please arrange a medical assessment soon."]) - - result = _resolve_reply( - None, - self.avatar, - question, - [], - qa_pairs=[], - search_fn=lambda *_args, **_kwargs: [], - model_client=fake_model, - usage_source="takeover", - ) - - self.assertEqual(result["answer"], "Please arrange a medical assessment soon.") - self.assertEqual(fake_model.call_count, 2) - repair_messages = fake_model.call_args.kwargs["messages"] - self.assertIn("English", repair_messages[0]["content"]) - self.assertIn("建议尽快就医评估", repair_messages[-1]["content"]) - self.assertTrue(_answer_requires_language_repair(question, "建议尽快就医评估。")) + self.assertIn("最新用户消息", messages[-2]["content"]) + self.assertIn("立即切换到相同语言", messages[-2]["content"]) def test_conversational_paraphrase_matches_standard_qa(self): for question in ("请问一下,你们公司在哪里呀?", "请问去你们那边怎么走"): diff --git a/digital-avatar-app/src/router/index.ts b/digital-avatar-app/src/router/index.ts index 660da3a..a2128d9 100644 --- a/digital-avatar-app/src/router/index.ts +++ b/digital-avatar-app/src/router/index.ts @@ -1,7 +1,6 @@ import { createRouter, createWebHashHistory } from 'vue-router' import type { RouteRecordRaw } from 'vue-router' import { getAuthToken } from '@/api' -import { isInUniWebView } from '@/utils/uniapp-bridge' const routes: RouteRecordRaw[] = [ { @@ -56,7 +55,7 @@ const routes: RouteRecordRaw[] = [ path: '/token/charge', name: 'TokenCharge', component: () => import('@/views/TokenCharge.vue'), - meta: { title: '积分充值', requiresAuth: true, requiresUniWebView: true } + meta: { title: '积分充值', requiresAuth: true } }, { path: '/avatar/card', @@ -134,10 +133,6 @@ const router = createRouter({ router.beforeEach((to, from, next) => { document.title = to.meta.title as string || '会会数字分身' - if (to.meta.requiresUniWebView && !isInUniWebView()) { - next({ path: '/avatar/manage' }) - return - } const hasLocalSession = Boolean(localStorage.getItem('hh_app_token')) const hasInjectedSession = Boolean(getAuthToken()) if (to.meta.requiresAuth && !hasLocalSession && !hasInjectedSession) { diff --git a/digital-avatar-app/src/utils/uniapp-bridge.ts b/digital-avatar-app/src/utils/uniapp-bridge.ts index cdf11c3..2c17291 100644 --- a/digital-avatar-app/src/utils/uniapp-bridge.ts +++ b/digital-avatar-app/src/utils/uniapp-bridge.ts @@ -12,10 +12,9 @@ export interface UniLaunchParams { nickname?: string avatar?: string ts?: string - nativeShell?: string } -const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts', 'nativeShell'] +const PARAM_KEYS: (keyof UniLaunchParams)[] = ['token', 'userId', 'nickname', 'avatar', 'ts'] function readParams(search: string, target: UniLaunchParams): void { const sp = new URLSearchParams(search) @@ -25,16 +24,6 @@ function readParams(search: string, target: UniLaunchParams): void { } } -function hasNativeShellMarker(): boolean { - const params = getLaunchParams() - if (params.nativeShell === 'uniapp') return true - - // Compatibility for already-installed shells. They have always appended a - // timestamp together with the native SSO context, even before the explicit - // nativeShell marker existed. - return Boolean(params.ts && (params.token || params.userId)) -} - // 是否运行在 uniapp web-view 环境中 export function isInUniWebView(): boolean { const runtime = window as any @@ -51,10 +40,7 @@ export function isInUniWebView(): boolean { runtime.swan?.webView || runtime.tt?.miniProgram ) - // `plus` can be injected after the H5 entry point runs. The native shell - // therefore adds a URL marker while creating its web-view URL, so the - // payment entry does not disappear during that startup window. - return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram || hasNativeShellMarker())) + return Boolean(runtime.uni?.webView && (isDCloudApp || isMiniProgram)) } // 解析 web-view 加载 URL 时原生注入的参数(token / 会会用户) diff --git a/digital-avatar-app/src/views/AvatarManage.vue b/digital-avatar-app/src/views/AvatarManage.vue index 802a487..2ec8398 100644 --- a/digital-avatar-app/src/views/AvatarManage.vue +++ b/digital-avatar-app/src/views/AvatarManage.vue @@ -20,7 +20,7 @@ - +
@@ -28,7 +28,7 @@ {{ tokenBalance.toLocaleString() }} 累计使用 {{ tokenConsumed.toLocaleString() }}
- +
@@ -89,15 +89,14 @@ import { useAvatarStore } from '@/store/avatar' import { useUserStore } from '@/store/user' import { createAvatarShareLink } from '@/api' import { isHuihuiEmbeddedMode } from '@/utils/embed-mode' -import { isInUniWebView } from '@/utils/uniapp-bridge' const router = useRouter() const avatarStore = useAvatarStore() const userStore = useUserStore() const isEmbedded = isHuihuiEmbeddedMode() -// 充值购买只在 uni-app 原生壳内提供,避免普通 H5 进入支付链路。 -const SHOW_POINTS_BALANCE_CARD = isInUniWebView() +// 临时产品开关:余额卡片代码保留,后续改为 true 即可恢复展示。 +const SHOW_POINTS_BALANCE_CARD = false // 当前登录会会用户的资料(头像 / 昵称) const me = computed(() => userStore.user) diff --git a/uniapp-avatar/src/pages/index/index.vue b/uniapp-avatar/src/pages/index/index.vue index 94c7df0..992f4f1 100644 --- a/uniapp-avatar/src/pages/index/index.vue +++ b/uniapp-avatar/src/pages/index/index.vue @@ -13,12 +13,6 @@ import { requestAppPayment } from '@/utils/payment' const userStore = useUserStore() const webview = ref(null) const H5_BASE_URL = import.meta.env.VITE_AVATAR_H5_URL || 'https://digital.99hui.com/' - -// Restore the native shell session before constructing the H5 URL. Without -// this, the web-view opens as an anonymous H5 page and cannot enter the -// authenticated avatar-management route. -userStore.init() - const h5Url = computed(() => buildH5Url(H5_BASE_URL, userStore.$state)) function runtimeSurface() { diff --git a/uniapp-avatar/src/utils/bridge.js b/uniapp-avatar/src/utils/bridge.js index 88880ec..84599c7 100644 --- a/uniapp-avatar/src/utils/bridge.js +++ b/uniapp-avatar/src/utils/bridge.js @@ -1,8 +1,5 @@ export function buildH5Url(base, session) { const url = new URL(base) - // This is deliberately explicit instead of relying on the timing of the - // H5+ `plus` injection inside the embedded page. - url.searchParams.set('nativeShell', 'uniapp') if (session.token) url.searchParams.set('token', session.token) if (session.userId) url.searchParams.set('userId', session.userId) if (session.nickname) url.searchParams.set('nickname', session.nickname)