feat(i18n): persist language preference and thread through chat, memoir, TTS

- Add users.language_preference (Alembic 0018, default zh); capture at signup/SMS
  only; expose on auth and profile APIs
- Lite English prompts for chat and memoir; localized stage labels and agent
  names (Life Echo / 岁月知己)
- Tencent TTS: language-aware synthesis, ModelType=1 for 501004, English chunking
- WebSocket pipeline: emit all AGENT_RESPONSE segments when TTS cancels; INFO logs
  for tts_this_turn and TTS decisions; on-demand TTS logging
- Expo: device language on auth, i18n tiers/agent name, [SPLIT] streaming UX fixes
- Tests for migration, prompts, pipeline, router tts_this_turn, reply segments

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-11 16:16:49 +08:00
parent 5ce29aad64
commit ccdc4e4277
64 changed files with 3233 additions and 208 deletions

View File

@@ -254,7 +254,11 @@ def _slot_snippets_for_narrative(
return slot_snippets
def _placeholder_title(chapter_category: str) -> str:
def _placeholder_title(chapter_category: str, language: str = "zh") -> str:
if language == "en":
from app.agents.stage_constants import chapter_category_display
return chapter_category_display(chapter_category, language="en")
return CHAPTER_CATEGORIES.get(chapter_category, chapter_category)
@@ -301,16 +305,19 @@ def _strip_ungrounded_title_segments(
hay: str,
*,
chapter_category: str,
language: str = "zh",
) -> str:
"""
按 · / • 分节丢弃含未落地履历短语的小节;全部丢弃则占位。
"""
if not settings.memoir_title_hay_grounding_strict_phrases_enabled:
return (title or "").strip() or _placeholder_title(chapter_category)
return (title or "").strip() or _placeholder_title(
chapter_category, language=language
)
t = (title or "").strip()
h = (hay or "").strip()
if not t:
return _placeholder_title(chapter_category)
return _placeholder_title(chapter_category, language=language)
segments = [s.strip() for s in re.split(r"\s*[·•]\s*", t) if s.strip()]
if not segments:
segments = [t]
@@ -329,7 +336,7 @@ def _strip_ungrounded_title_segments(
continue
kept.append(seg)
if not kept:
return _placeholder_title(chapter_category)
return _placeholder_title(chapter_category, language=language)
if len(kept) == 1:
return kept[0]
return " · ".join(kept)
@@ -346,11 +353,12 @@ def _maybe_generate_title(
llm: Any,
oral_scope: str = "",
narrow_profile_for_title: bool = True,
language: str = "zh",
) -> str:
"""Generate a title only when body is long enough; otherwise return placeholder."""
body_len = len((md or "").strip())
if body_len < settings.story_title_min_body_chars:
return _placeholder_title(chapter_category)
return _placeholder_title(chapter_category, language=language)
content_excerpt = (md or "").strip()[:300]
merged_slots = _title_slots_filtered_for_generation(
slot_snippets, md=md, oral_scope=oral_scope
@@ -366,10 +374,14 @@ def _maybe_generate_title(
user_profile=profile_for_title,
birth_year=user_birth_year,
llm=llm,
language=language,
)
hay = _title_hay_for_grounding(merged_slots, md, oral_scope)
return _strip_ungrounded_title_segments(
raw_title, hay, chapter_category=chapter_category
raw_title,
hay,
chapter_category=chapter_category,
language=language,
)
@@ -733,6 +745,7 @@ def _execute_narrative_unit(
occupation: str = "",
memoir_correlation_id: str | None = None,
fidelity_llm: Any | None = None,
language: str = "zh",
) -> tuple[str | None, bool]:
"""
Unified narrative unit executor: generate narrative, apply fidelity/safety,
@@ -740,7 +753,9 @@ def _execute_narrative_unit(
"""
t0 = time.perf_counter()
oral_norm = (oral_text or "").strip()
new_content_input = format_narrative_user_content(oral_text, evidence_text)
new_content_input = format_narrative_user_content(
oral_text, evidence_text, language=language
)
raw_gen = narrative_agent.generate_narrative(
stage=chapter_category,
@@ -753,6 +768,7 @@ def _execute_narrative_unit(
background_voice=background_voice,
occupation=occupation,
fallback_plain_oral=oral_norm,
language=language,
)
json_invalid = False
s0 = (raw_gen or "").strip()
@@ -816,7 +832,7 @@ def _execute_narrative_unit(
sid_log = target_story_id
is_append = True
else:
story_title = _placeholder_title(chapter_category)
story_title = _placeholder_title(chapter_category, language=language)
st = create_story_with_version_sync(
session,
user_id=user_id,
@@ -905,6 +921,7 @@ def _run_batch_plan_writes(
occupation: str = "",
memoir_correlation_id: str | None = None,
fidelity_llm: Any | None = None,
language: str = "zh",
) -> set[str]:
dispatch_ids: set[str] = set()
for unit in plan.units:
@@ -951,6 +968,7 @@ def _run_batch_plan_writes(
occupation=occupation,
memoir_correlation_id=memoir_correlation_id,
fidelity_llm=fidelity_llm,
language=language,
)
if sid:
dispatch_ids.add(sid)
@@ -972,6 +990,7 @@ def run_story_pipeline_for_category_batch(
memoir_correlation_id: str | None = None,
llm_fast: Any | None = None,
memory_evidence: dict | None = None,
language: str = "zh",
) -> StoryPipelineResult:
"""运行某 chapter_category 的 Phase2 写入管线。
@@ -1064,7 +1083,9 @@ def run_story_pipeline_for_category_batch(
user_id=user_id,
)
title = chapter.title if chapter else _placeholder_title(chapter_category)
title = chapter.title if chapter else _placeholder_title(
chapter_category, language=language
)
# 仅同 chapter_categorystory.stage的 Story 可作为 append 候选,避免跨章节链接导致多章内容相同
all_stories = list_active_stories_for_user_sync(session, user_id)
@@ -1167,6 +1188,7 @@ def run_story_pipeline_for_category_batch(
occupation=occupation,
memoir_correlation_id=memoir_correlation_id,
fidelity_llm=llm_fidelity,
language=language,
)
else:
route = single_route
@@ -1215,6 +1237,7 @@ def run_story_pipeline_for_category_batch(
occupation=occupation,
memoir_correlation_id=memoir_correlation_id,
fidelity_llm=llm_fidelity,
language=language,
)
if sid:
dispatch_ids.add(sid)