重构回忆录为 story-first / markdown-first 架构并整合图片意图与前端 UI 修复
本次 squash merge 将 codex-story-first-image-intent 的整体改动合入 development,核心内容包括: 1. 后端数据与迁移:新增 stories、story_versions、story_image_intents、chapter_cover_intents、assets 等模型与 Alembic 迁移,建立 story-first、markdown-first、asset-first 的主数据链路。 2. 生成与任务链:引入 StoryBuilderOrchestrator、ChapterComposerOrchestrator、story_image_tasks、chapter_cover_tasks,图片生成从正文占位符改为结构化 intent -> asset -> markdown 回填。 3. 并发与一致性:为 story/chapter intent 增加 claim_token、claimed_at、attempt_count,采用数据库原子 claim 为主、Redis 锁为辅,避免重复生成、锁误删和 processing 卡死。 4. Memoir 读写路径:章节 canonical_markdown 成为正文真源,列表/详情接口补齐 markdown、cover_asset、word_count 等字段,PDF 与 asset 解析链路同步升级。 5. Memory / Retrieval:扩展 transcript ingest、chunking、evidence 检索与 story 聚合基础设施,为后续 story-first RAG 与多 agent 编排提供底座。 6. App 端体验:章节页继续走 MarkdownRenderer 阅读链,同时吸收 fix3-19 的跨平台 UI glitch 修复;更新对话页、首页、文案资源与章节列表映射逻辑。 7. 测试与文档:补充 asset resolver、story image task、章节封面派发、markdown 映射等回归测试,并加入图片占位符退役设计文档。
This commit is contained in:
152
api/tests/test_story_image_tasks.py
Normal file
152
api/tests/test_story_image_tasks.py
Normal file
@@ -0,0 +1,152 @@
|
||||
import unittest
|
||||
from contextlib import contextmanager
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from app.ports.image_gen import ImageResult, TaskStatus
|
||||
from app.tasks.story_image_tasks import generate_story_image
|
||||
|
||||
|
||||
def _mock_db_cm(db):
|
||||
@contextmanager
|
||||
def _cm():
|
||||
yield db
|
||||
|
||||
return _cm()
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = BytesIO()
|
||||
Image.new("RGB", (1, 1), color="white").save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
class _FakeUUID:
|
||||
def __init__(self, value: str):
|
||||
self.hex = value
|
||||
self._value = value
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self._value
|
||||
|
||||
|
||||
class GenerateStoryImageTaskTest(unittest.TestCase):
|
||||
@patch("app.tasks.story_image_tasks.release_redis_lock")
|
||||
@patch(
|
||||
"app.tasks.story_image_tasks.acquire_redis_lock",
|
||||
return_value=SimpleNamespace(key="lock:story-image:story-1"),
|
||||
)
|
||||
@patch("app.tasks.story_image_tasks._claim_story_image_intent_sync")
|
||||
@patch("app.tasks.story_image_tasks.get_sync_db")
|
||||
@patch("app.tasks.story_image_tasks.TencentCosStorageService")
|
||||
@patch("app.tasks.story_image_tasks.get_image_generator")
|
||||
@patch("app.features.memoir.memoir_images.settings.MemoirImageSettings.from_env")
|
||||
@patch("app.tasks.story_image_tasks.uuid.uuid4")
|
||||
def test_generate_story_image_resumes_processing_intent_and_backfills_markdown(
|
||||
self,
|
||||
uuid4_mock,
|
||||
settings_from_env,
|
||||
get_image_generator_mock,
|
||||
storage_cls,
|
||||
get_sync_db_mock,
|
||||
claim_intent_mock,
|
||||
acquire_lock_mock,
|
||||
release_lock_mock,
|
||||
):
|
||||
uuid4_mock.side_effect = [
|
||||
_FakeUUID("claim-token"),
|
||||
_FakeUUID("asset-uuid"),
|
||||
_FakeUUID("version-uuid"),
|
||||
]
|
||||
settings_from_env.return_value = SimpleNamespace(
|
||||
provider="liblib",
|
||||
default_style="watercolor",
|
||||
default_size="1024x1024",
|
||||
)
|
||||
|
||||
intent = SimpleNamespace(
|
||||
id="intent-1",
|
||||
prompt_brief="院子里的藤椅",
|
||||
style_profile="watercolor",
|
||||
story_version_id="ver-1",
|
||||
caption="主插图",
|
||||
source_span={"paragraph_index": 0},
|
||||
status="processing",
|
||||
)
|
||||
story = SimpleNamespace(
|
||||
id="story-1",
|
||||
user_id="user-1",
|
||||
title="童年的院子",
|
||||
stage="childhood",
|
||||
)
|
||||
db_claim = Mock()
|
||||
claim_intent_mock.return_value = (intent, story)
|
||||
|
||||
intent_db = SimpleNamespace(
|
||||
id="intent-1",
|
||||
story_version_id="ver-1",
|
||||
caption="主插图",
|
||||
source_span={"paragraph_index": 0},
|
||||
status="processing",
|
||||
style_profile="watercolor",
|
||||
claim_token="claim-token",
|
||||
asset_id=None,
|
||||
error=None,
|
||||
updated_at=None,
|
||||
)
|
||||
story_db = SimpleNamespace(
|
||||
id="story-1",
|
||||
current_version_id="ver-1",
|
||||
canonical_markdown="第一段\n\n第二段",
|
||||
)
|
||||
version_db = SimpleNamespace(id="ver-1", markdown_snapshot="第一段\n\n第二段")
|
||||
version_max_result = Mock()
|
||||
version_max_result.scalar.return_value = 1
|
||||
db_persist = Mock()
|
||||
db_persist.get.side_effect = [intent_db, story_db, version_db]
|
||||
db_persist.execute.return_value = version_max_result
|
||||
|
||||
get_sync_db_mock.side_effect = [_mock_db_cm(db_claim), _mock_db_cm(db_persist)]
|
||||
|
||||
generator = get_image_generator_mock.return_value
|
||||
generator.generate.return_value = ImageResult(
|
||||
status=TaskStatus.COMPLETED,
|
||||
task_id="task-1",
|
||||
image_url="https://provider.example.com/story.png",
|
||||
)
|
||||
generator.download_image.return_value = _png_bytes()
|
||||
storage_cls.from_env.return_value.upload_bytes.return_value = (
|
||||
"https://cos.example.com/stories/u1/s1.png"
|
||||
)
|
||||
|
||||
result = generate_story_image.run("story-1")
|
||||
|
||||
self.assertEqual(result["status"], "success")
|
||||
self.assertEqual(intent_db.status, "completed")
|
||||
self.assertIsNotNone(intent_db.asset_id)
|
||||
self.assertNotEqual(story_db.current_version_id, "ver-1")
|
||||
self.assertIn("asset://", story_db.canonical_markdown)
|
||||
generator.generate.assert_called_once()
|
||||
storage_cls.from_env.return_value.upload_bytes.assert_called_once()
|
||||
claim_intent_mock.assert_called_once()
|
||||
acquire_lock_mock.assert_called_once()
|
||||
release_lock_mock.assert_called_once()
|
||||
|
||||
@patch("app.tasks.story_image_tasks.acquire_redis_lock", return_value=None)
|
||||
@patch("app.tasks.story_image_tasks.get_sync_db")
|
||||
@patch("app.tasks.story_image_tasks.get_image_generator")
|
||||
def test_generate_story_image_skips_when_lock_is_already_held(
|
||||
self,
|
||||
get_image_generator_mock,
|
||||
get_sync_db_mock,
|
||||
acquire_lock_mock,
|
||||
):
|
||||
result = generate_story_image.run("story-1")
|
||||
|
||||
self.assertEqual(result, {"status": "locked"})
|
||||
get_sync_db_mock.assert_not_called()
|
||||
get_image_generator_mock.assert_not_called()
|
||||
acquire_lock_mock.assert_called_once()
|
||||
Reference in New Issue
Block a user