feat(api+app): 对话阶段化、回忆录流水线与客户端会话体验

- DB: segments 用户输入文本(Alembic 0002)
- Chat: 阶段检测/阶段提示/回复限制,编排与访谈/画像 prompts 调整
- Memoir: 忠实度检查 agent,叙事与分类等链路更新
- Core: agent 日志、Alembic 启动、LangChain/日志/配置等
- Story: time_hints;Memory 检索与相关测试
- Expo: 助手头像、会话页与消息拆分、实时会话与文案/i18n
- Docs/scripts/tests: 迁移脚本、LLM JSON/记忆检索文档、新增单测
This commit is contained in:
Kevin
2026-03-26 12:13:36 +08:00
parent 49b089354c
commit a3f61fcc0f
94 changed files with 3332 additions and 672 deletions

View File

@@ -31,13 +31,13 @@ def recompose_chapters_for_story(self, story_id: str) -> dict:
memoir_repo.compose_chapter_from_story_links_sync(session, cid)
session.commit()
logger.info(
"recompose_chapters_for_story: story=%s recomposed_chapters=%s",
"recompose_chapters_for_story: story={} recomposed_chapters={}",
story_id,
ids,
)
return {"story_id": story_id, "recomposed_chapter_ids": ids}
except Exception as exc:
logger.warning(
"recompose_chapters_for_story failed story=%s err=%s", story_id, exc
"recompose_chapters_for_story failed story={} err={}", story_id, exc
)
raise self.retry(exc=exc) from exc

View File

@@ -100,14 +100,14 @@ def try_enqueue_generate_chapter_cover(
key, "1", nx=True, ex=CHAPTER_COVER_ENQUEUE_DEDUP_TTL_SECONDS
):
logger.debug(
"chapter_cover enqueue skipped (dedup): chapter=%s source=%s",
"chapter_cover enqueue skipped (dedup): chapter={} source={}",
chapter_id,
source,
)
return False
except Exception as exc:
logger.warning(
"chapter_cover enqueue dedup redis failed, allowing enqueue: chapter=%s error=%s",
"chapter_cover enqueue dedup redis failed, allowing enqueue: chapter={} error={}",
chapter_id,
exc,
)
@@ -118,7 +118,7 @@ def try_enqueue_generate_chapter_cover(
generate_chapter_cover.delay(chapter_id)
except Exception as exc:
logger.warning(
"chapter_cover delay failed: chapter=%s error=%s",
"chapter_cover delay failed: chapter={} error={}",
chapter_id,
exc,
)
@@ -130,7 +130,7 @@ def try_enqueue_generate_chapter_cover(
return False
logger.info(
"chapter_cover enqueued: chapter=%s source=%s",
"chapter_cover enqueued: chapter={} source={}",
chapter_id,
source,
)

View File

@@ -167,7 +167,7 @@ def generate_chapter_cover(self, chapter_id: str):
lock_key, ttl_seconds=CHAPTER_COVER_LOCK_TTL_SECONDS
)
if lock_handle is None:
logger.debug("generate_chapter_cover: chapter=%s, reason=locked", chapter_id)
logger.debug("generate_chapter_cover: chapter={}, reason=locked", chapter_id)
return {"status": "locked"}
claim_token = uuid.uuid4().hex
@@ -184,26 +184,26 @@ def generate_chapter_cover(self, chapter_id: str):
chapter = db.execute(stmt).unique().scalar_one_or_none()
if not chapter:
logger.debug(
"generate_chapter_cover: chapter=%s, reason=not_found", chapter_id
"generate_chapter_cover: chapter={}, reason=not_found", chapter_id
)
return {"status": "no_chapter"}
if not chapter_eligible_for_cover_by_inline_body_image_count(chapter):
logger.debug(
"generate_chapter_cover: chapter=%s, reason=insufficient_inline_body_images",
"generate_chapter_cover: chapter={}, reason=insufficient_inline_body_images",
chapter_id,
)
return {"status": "insufficient_inline_body_images"}
if not chapter_has_story_links(chapter):
logger.debug(
"generate_chapter_cover: chapter=%s, reason=no_story_links",
"generate_chapter_cover: chapter={}, reason=no_story_links",
chapter_id,
)
return {"status": "no_story_links"}
if getattr(chapter, "cover_asset_id", None):
logger.debug(
"generate_chapter_cover: chapter=%s, reason=has_cover_asset",
"generate_chapter_cover: chapter={}, reason=has_cover_asset",
chapter_id,
)
return {"status": "already_has_asset"}
@@ -211,7 +211,7 @@ def generate_chapter_cover(self, chapter_id: str):
intent = _claim_chapter_cover_intent_sync(db, chapter, claim_token)
if not intent:
logger.debug(
"generate_chapter_cover: chapter=%s, reason=no_claimable_intent",
"generate_chapter_cover: chapter={}, reason=no_claimable_intent",
chapter_id,
)
return {"status": "no_intent"}
@@ -247,7 +247,7 @@ def generate_chapter_cover(self, chapter_id: str):
or (intent_db.claim_token or "").strip() != claim_token
):
logger.debug(
"generate_chapter_cover: skip persist intent=%s status=%s claim=%s",
"generate_chapter_cover: skip persist intent={} status={} claim={}",
intent.id,
getattr(intent_db, "status", None),
getattr(intent_db, "claim_token", None),
@@ -280,12 +280,12 @@ def generate_chapter_cover(self, chapter_id: str):
db.commit()
logger.info(
"generate_chapter_cover: chapter=%s, asset=%s",
"generate_chapter_cover: chapter={}, asset={}",
chapter_id,
asset_id,
)
logger.debug(
"generate_chapter_cover: chapter=%s asset=%s url=%s cos_key=%s prompt_final=%s",
"generate_chapter_cover: chapter={} asset={} url={} cos_key={} prompt_final={}",
chapter_id,
asset_id,
url,
@@ -308,7 +308,7 @@ def generate_chapter_cover(self, chapter_id: str):
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
logger.warning(
"generate_chapter_cover failed: chapter=%s, error=%s", chapter_id, exc
"generate_chapter_cover failed: chapter={}, error={}", chapter_id, exc
)
raise self.retry(exc=exc) from exc
finally:

View File

@@ -108,7 +108,7 @@ def _update_task_status_sync(
r.hset(key, task_id, json.dumps(task_info))
r.expire(key, 3600) # 1小时过期
logger.debug("任务状态已更新: task_id=%s status=%s", task_id, status)
logger.debug("任务状态已更新: task_id={} status={}", task_id, status)
except Exception as e:
logger.error(f"更新任务状态失败: {e}")
@@ -248,7 +248,7 @@ def _update_slot_sync(
).model_dump()
slots[stage] = stage_slots
state.slots = slots
state.current_stage = state.current_stage or stage
state.current_stage = stage
db.commit()
db.refresh(state)
return _coerce_state(state)
@@ -283,16 +283,17 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
logger.warning(f"未找到段落: {segment_ids}")
return {"status": "no_segments"}
# Memory ingest: transcript -> memory_sources, chunks, FTS
# Memory ingest 先于回忆录流水线 commit保证后续 retrieve_evidence_sync 可见本批 chunk
# (见 api/docs/memory-retrieval.md
conv_id = getattr(segments[0], "conversation_id", None) or ""
transcript = "\n\n".join(seg.transcript_text or "" for seg in segments)
transcript = "\n\n".join(seg.user_input_text or "" for seg in segments)
if transcript.strip():
try:
from app.features.memory.service import ingest_transcript_sync
ingest_transcript_sync(db, user_id, conv_id, transcript)
except Exception as e:
logger.warning("Memory ingest 跳过: %s", e)
logger.warning("Memory ingest 跳过: {}", e)
llm = _get_llm()
image_settings = MemoirImageSettings.from_env()
@@ -328,7 +329,7 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
) in prepared.category_to_segments.items():
if not _acquire_chapter_lock(user_id, chapter_category):
logger.warning(
"章节锁竞争: category=%s, 延迟重试",
"章节锁竞争: category={}, 延迟重试",
chapter_category,
)
raise self.retry(countdown=10)
@@ -389,11 +390,11 @@ def process_memoir_segments(self, user_id: str, segment_ids: List[str]):
try:
generate_story_image.delay(sid)
except Exception as exc:
logger.warning("generate_story_image delay: %s", exc)
logger.warning("generate_story_image delay: {}", exc)
try:
recompose_chapters_for_story.delay(sid)
except Exception as exc:
logger.warning("recompose_chapters_for_story delay: %s", exc)
logger.warning("recompose_chapters_for_story delay: {}", exc)
from app.tasks.chapter_cover_enqueue import (
try_enqueue_generate_chapter_cover,
@@ -452,7 +453,7 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
class _Seg:
def __init__(self, text: str):
self.id = str(uuid.uuid4())
self.transcript_text = text
self.user_input_text = text
state = _get_or_create_state_sync(user_id, db)
chapter, _, dispatch_ids = run_story_pipeline_for_category_batch(
@@ -475,11 +476,11 @@ def generate_chapter_content(self, user_id: str, stage: str, new_content: str):
try:
generate_story_image.delay(sid)
except Exception as exc:
logger.warning("generate_story_image delay: %s", exc)
logger.warning("generate_story_image delay: {}", exc)
try:
recompose_chapters_for_story.delay(sid)
except Exception as exc:
logger.warning("recompose_chapters_for_story delay: %s", exc)
logger.warning("recompose_chapters_for_story delay: {}", exc)
image_settings = MemoirImageSettings.from_env()
if (

View File

@@ -41,7 +41,7 @@ def _enqueue_chapter_recompose_for_story(story_id: str) -> None:
session.commit()
except Exception as exc:
logger.warning(
"mark_chapters_dirty_for_story_sync failed story=%s: %s", story_id, exc
"mark_chapters_dirty_for_story_sync failed story={}: {}", story_id, exc
)
return
try:
@@ -50,7 +50,7 @@ def _enqueue_chapter_recompose_for_story(story_id: str) -> None:
recompose_chapters_for_story.delay(story_id)
except Exception as exc:
logger.warning(
"recompose_chapters_for_story.delay failed story=%s: %s", story_id, exc
"recompose_chapters_for_story.delay failed story={}: {}", story_id, exc
)
@@ -161,7 +161,7 @@ def generate_story_image(self, story_id: str):
lock_key = f"lock:story-image:{story_id}"
lock_handle = acquire_redis_lock(lock_key, ttl_seconds=STORY_IMAGE_LOCK_TTL_SECONDS)
if lock_handle is None:
logger.debug("generate_story_image: story=%s, reason=locked", story_id)
logger.debug("generate_story_image: story={}, reason=locked", story_id)
return {"status": "locked"}
claim_token = uuid.uuid4().hex
@@ -172,7 +172,7 @@ def generate_story_image(self, story_id: str):
row = _claim_story_image_intent_sync(db, story_id, claim_token)
if not row:
logger.debug(
"generate_story_image: story=%s, reason=no_claimable_intent",
"generate_story_image: story={}, reason=no_claimable_intent",
story_id,
)
return {"status": "no_intent"}
@@ -196,7 +196,7 @@ def generate_story_image(self, story_id: str):
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(
"generate_story_image: skipped body too short story=%s len=%s min=%s",
"generate_story_image: skipped body too short story={} len={} min={}",
story_id,
len(plain),
min_body,
@@ -238,7 +238,7 @@ def generate_story_image(self, story_id: str):
or (intent_db.claim_token or "").strip() != claim_token
):
logger.debug(
"generate_story_image: skip persist intent=%s status=%s claim=%s",
"generate_story_image: skip persist intent={} status={} claim={}",
intent.id,
getattr(intent_db, "status", None),
getattr(intent_db, "claim_token", None),
@@ -274,8 +274,8 @@ def generate_story_image(self, story_id: str):
if not target_vid or target_vid != current_vid:
db.commit()
logger.debug(
"generate_story_image: stale intent skip backfill story=%s "
"intent_ver=%s current=%s url=%s asset=%s",
"generate_story_image: stale intent skip backfill story={} "
"intent_ver={} current={} url={} asset={}",
story_id,
target_vid,
current_vid,
@@ -323,12 +323,12 @@ def generate_story_image(self, story_id: str):
_enqueue_chapter_recompose_for_story(story_id)
logger.info(
"generate_story_image: story=%s, asset=%s",
"generate_story_image: story={}, asset={}",
story_id,
asset_id,
)
logger.debug(
"generate_story_image: story=%s asset=%s url=%s cos_key=%s prompt_final=%s",
"generate_story_image: story={} asset={} url={} cos_key={} prompt_final={}",
story_id,
asset_id,
url,
@@ -351,7 +351,7 @@ def generate_story_image(self, story_id: str):
intent_db.error = str(exc)
intent_db.updated_at = datetime.now(timezone.utc)
db.commit()
logger.warning("generate_story_image failed: story=%s, error=%s", story_id, exc)
logger.warning("generate_story_image failed: story={}, error={}", story_id, exc)
raise self.retry(exc=exc) from exc
finally:
release_redis_lock(lock_handle)