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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user