修复一些已知问题
This commit is contained in:
2
.github/workflows/app-expo-deploy.yml
vendored
2
.github/workflows/app-expo-deploy.yml
vendored
@@ -44,8 +44,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# GitHub Environments: 在 Repo Settings → Environments 中创建 dev/staging/production,可配置独立 secrets
|
||||
environment: ${{ (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'prod') && 'production' || (github.event_name == 'workflow_dispatch' && github.event.inputs.environment == 'stage') && 'staging' || startsWith(github.ref, 'refs/tags/v') && 'production' || 'dev' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
||||
75
.github/workflows/docker-build-deploy.yml
vendored
75
.github/workflows/docker-build-deploy.yml
vendored
@@ -1,5 +1,11 @@
|
||||
# API Docker:main → Dev(GitHub Environment: dev),Tag v*.*.* → Production(environment: production)
|
||||
# 在 Repo Settings → Environments 中为 dev / production 分别配置 SSH、DEPLOY_PATH、迁移 DB 等 Secrets。
|
||||
# API Docker:main → Dev 机(Repository secrets: DEV_*),Tag v*.*.* → Prod 机(PROD_*)
|
||||
# 在 Repo → Settings → Secrets and variables → Actions 中配置,无需 GitHub Environments。
|
||||
# 命名:DEV_SSH_HOST / DEV_SSH_USER / DEV_SSH_PRIVATE_KEY / DEV_SSH_PORT / DEV_DEPLOY_PATH / DEV_MIGRATION_DB_*
|
||||
# PROD_SSH_HOST / PROD_SSH_USER / PROD_SSH_PRIVATE_KEY / PROD_SSH_PORT / PROD_DEPLOY_PATH / PROD_MIGRATION_DB_*
|
||||
# 阿里云镜像仍为仓库级:ALIYUN_CR_USERNAME / ALIYUN_CR_PASSWORD
|
||||
#
|
||||
# 从旧版迁移:若仓库里仍是 SSH_HOST、SSH_PRIVATE_KEY、DEPLOY_PATH 等无前缀名称,
|
||||
# 请把「原机 / 内部测试」对应值复制为 DEV_*,「新生产机」填 PROD_*,并删除旧的无前缀 Secret。
|
||||
#
|
||||
# 发布策略:
|
||||
# - merge / push 到 main:构建并部署到 Dev / 内部测试
|
||||
@@ -97,9 +103,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-and-push
|
||||
if: github.event_name != 'pull_request'
|
||||
# workflow_dispatch 下若填写了 branch 输入,以输入为准选择 environment(避免仅 UI 选了 tag 但部署 main 时误用 production)
|
||||
environment:
|
||||
name: ${{ ((github.event_name == 'workflow_dispatch' && github.event.inputs.branch != '' && startsWith(github.event.inputs.branch, 'v')) || (github.event_name == 'workflow_dispatch' && github.event.inputs.branch == '' && startsWith(github.ref, 'refs/tags/v')) || (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'))) && 'production' || 'dev' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
@@ -107,15 +110,53 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
- name: Determine deploy target
|
||||
id: deploy_target
|
||||
run: |
|
||||
if [ -n "${{ github.event.inputs.branch }}" ]; then
|
||||
REF_NAME="${{ github.event.inputs.branch }}"
|
||||
else
|
||||
REF_NAME="${{ github.ref_name }}"
|
||||
fi
|
||||
if [[ "$REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "target=prod" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "target=dev" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Set up SSH
|
||||
uses: webfactory/ssh-agent@v0.9.0
|
||||
with:
|
||||
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
ssh-private-key: ${{ steps.deploy_target.outputs.target == 'prod' && secrets.PROD_SSH_PRIVATE_KEY || secrets.DEV_SSH_PRIVATE_KEY }}
|
||||
|
||||
- name: Export deploy connection env
|
||||
run: |
|
||||
if [ "${{ steps.deploy_target.outputs.target }}" = "prod" ]; then
|
||||
{
|
||||
echo "SSH_HOST=${{ secrets.PROD_SSH_HOST }}"
|
||||
echo "SSH_USER=${{ secrets.PROD_SSH_USER }}"
|
||||
echo "SSH_PORT=${{ secrets.PROD_SSH_PORT || '22' }}"
|
||||
echo "COMPOSE_DIR=${{ secrets.PROD_DEPLOY_PATH || '/opt/life-echo' }}"
|
||||
echo "DB_USER=${{ secrets.PROD_MIGRATION_DB_USER || '' }}"
|
||||
echo "DB_PASSWORD=${{ secrets.PROD_MIGRATION_DB_PASSWORD || '' }}"
|
||||
echo "DB_NAME=${{ secrets.PROD_MIGRATION_DB_NAME || '' }}"
|
||||
} >> "$GITHUB_ENV"
|
||||
else
|
||||
{
|
||||
echo "SSH_HOST=${{ secrets.DEV_SSH_HOST }}"
|
||||
echo "SSH_USER=${{ secrets.DEV_SSH_USER }}"
|
||||
echo "SSH_PORT=${{ secrets.DEV_SSH_PORT || '22' }}"
|
||||
echo "COMPOSE_DIR=${{ secrets.DEV_DEPLOY_PATH || '/opt/life-echo' }}"
|
||||
echo "DB_USER=${{ secrets.DEV_MIGRATION_DB_USER || '' }}"
|
||||
echo "DB_PASSWORD=${{ secrets.DEV_MIGRATION_DB_PASSWORD || '' }}"
|
||||
echo "DB_NAME=${{ secrets.DEV_MIGRATION_DB_NAME || '' }}"
|
||||
} >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Add server to known hosts
|
||||
run: |
|
||||
mkdir -p ~/.ssh
|
||||
ssh-keyscan -H -p "${{ secrets.SSH_PORT || 22 }}" "${{ secrets.SSH_HOST }}" >> ~/.ssh/known_hosts
|
||||
ssh-keyscan -H -p "${SSH_PORT:-22}" "${SSH_HOST}" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Determine image tag
|
||||
id: image_tag
|
||||
@@ -138,11 +179,7 @@ jobs:
|
||||
|
||||
- name: Prepare remote candidate release
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
|
||||
IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.tag }}
|
||||
COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }}
|
||||
REGISTRY: ${{ env.REGISTRY }}
|
||||
ALIYUN_CR_USERNAME: ${{ secrets.ALIYUN_CR_USERNAME }}
|
||||
ALIYUN_CR_PASSWORD: ${{ secrets.ALIYUN_CR_PASSWORD }}
|
||||
@@ -184,15 +221,8 @@ jobs:
|
||||
|
||||
- name: Backup and run database migrations safely
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
|
||||
IMAGE_TAG: ${{ env.REGISTRY }}/${{ env.REGISTRY_NAMESPACE }}/${{ env.IMAGE_NAME }}:${{ steps.image_tag.outputs.tag }}
|
||||
COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }}
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
DB_USER: ${{ secrets.MIGRATION_DB_USER || '' }}
|
||||
DB_PASSWORD: ${{ secrets.MIGRATION_DB_PASSWORD || '' }}
|
||||
DB_NAME: ${{ secrets.MIGRATION_DB_NAME || '' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
@@ -377,10 +407,6 @@ jobs:
|
||||
|
||||
- name: Promote candidate release
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
|
||||
COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }}
|
||||
COMPOSE_FILE: docker-compose.yml
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -406,11 +432,6 @@ jobs:
|
||||
"
|
||||
|
||||
- name: Verify deployment
|
||||
env:
|
||||
SSH_USER: ${{ secrets.SSH_USER }}
|
||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
||||
SSH_PORT: ${{ secrets.SSH_PORT || 22 }}
|
||||
COMPOSE_DIR: ${{ secrets.DEPLOY_PATH || '/opt/life-echo' }}
|
||||
run: |
|
||||
echo "验证部署状态..."
|
||||
ssh -p "$SSH_PORT" "$SSH_USER@$SSH_HOST" \
|
||||
|
||||
14
api/app/agents/chat/agent_turn.py
Normal file
14
api/app/agents/chat/agent_turn.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""一轮 AI 对话输出:分段文案 + 是否整轮跳过 TTS(如失败兜底)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentChatTurn:
|
||||
"""与 WebSocket pipeline 对齐:messages 为气泡分段;skip_tts 为 True 时不合成语音。"""
|
||||
|
||||
messages: List[str]
|
||||
skip_tts: bool = False
|
||||
@@ -6,6 +6,7 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.agents.chat.agent_turn import AgentChatTurn
|
||||
from app.agents.chat.orchestrator import ChatOrchestrator
|
||||
from app.agents.chat.prompts_conversation import ConversationStage
|
||||
from app.agents.state_schema import MemoirStateSchema
|
||||
@@ -77,7 +78,7 @@ class ConversationAgent:
|
||||
voice_session_id: str | None = None,
|
||||
user_message_timestamp: datetime | None = None,
|
||||
audio_duration_seconds: int | None = None,
|
||||
) -> List[str]:
|
||||
) -> AgentChatTurn:
|
||||
"""委托 ChatOrchestrator/InterviewAgent 生成访谈回复"""
|
||||
return await self._orchestrator.generate_response_with_state(
|
||||
conversation_id=conversation_id,
|
||||
@@ -116,13 +117,13 @@ class ConversationAgent:
|
||||
state = default_state()
|
||||
state.current_stage = (current_stage or ConversationStage.CHILDHOOD).value
|
||||
state.covered_stages = covered_topics or []
|
||||
responses = await self._orchestrator.generate_response_with_state(
|
||||
turn = await self._orchestrator.generate_response_with_state(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
memoir_state=state,
|
||||
user_profile_context="",
|
||||
)
|
||||
return responses[0] if responses else ""
|
||||
return turn.messages[0] if turn.messages else ""
|
||||
|
||||
def detect_stage(
|
||||
self, conversation_id: str, user_message: str
|
||||
|
||||
@@ -5,6 +5,7 @@ InterviewAgent:正式访谈 Specialist
|
||||
|
||||
from typing import Any, List
|
||||
|
||||
from app.agents.chat.agent_turn import AgentChatTurn
|
||||
from app.core.dependencies import get_llm_provider
|
||||
from app.core.logging import get_logger
|
||||
|
||||
@@ -18,6 +19,9 @@ from app.agents.state_schema import MemoirStateSchema
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# LLM 不可用或调用失败时对用户展示(不暴露异常细节、不触发 TTS)
|
||||
_FALLBACK_REPLY = "刚才网络不太稳,没接上。你可以再说一遍,或稍后再试。"
|
||||
|
||||
|
||||
def _get_langchain_llm():
|
||||
try:
|
||||
@@ -149,12 +153,11 @@ class InterviewAgent:
|
||||
user_message: str,
|
||||
memoir_state: MemoirStateSchema,
|
||||
user_profile_context: str = "",
|
||||
) -> List[str]:
|
||||
) -> AgentChatTurn:
|
||||
"""生成状态感知的访谈回复,不持久化(由 Orchestrator 负责)"""
|
||||
if not self.llm:
|
||||
return [
|
||||
"抱歉,LLM 服务未配置。请设置 DEEPSEEK_API_KEY 或 LLM_API_KEY 环境变量。"
|
||||
]
|
||||
logger.warning("InterviewAgent: LLM 未配置,返回兜底文案")
|
||||
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
|
||||
try:
|
||||
empty_slots = memoir_state.empty_slots_for_current_stage()
|
||||
filled_slots = {
|
||||
@@ -191,10 +194,11 @@ class InterviewAgent:
|
||||
messages = [
|
||||
msg.strip() for msg in response_text.split("[SPLIT]") if msg.strip()
|
||||
]
|
||||
return messages[:3] if messages else [response_text]
|
||||
out = messages[:3] if messages else [response_text]
|
||||
return AgentChatTurn(messages=out, skip_tts=False)
|
||||
except Exception as e:
|
||||
logger.error("生成回应失败: %s", e)
|
||||
return [f"抱歉,生成回应时出现错误: {str(e)}"]
|
||||
logger.error("生成回应失败: %s", e, exc_info=True)
|
||||
return AgentChatTurn(messages=[_FALLBACK_REPLY], skip_tts=True)
|
||||
|
||||
async def generate_opening_message(
|
||||
self,
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.agents.chat.agent_turn import AgentChatTurn
|
||||
from app.agents.chat.helpers import save_message
|
||||
from app.agents.chat.interview_agent import InterviewAgent
|
||||
from app.agents.chat.profile_agent import ProfileAgent
|
||||
@@ -20,6 +21,10 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_UNAUTH_TURN = AgentChatTurn(
|
||||
messages=["暂时没法继续对话,请先登录后再试。"], skip_tts=True
|
||||
)
|
||||
|
||||
|
||||
class ChatOrchestrator:
|
||||
"""
|
||||
@@ -45,9 +50,9 @@ class ChatOrchestrator:
|
||||
get_filled_profile_fields_fn,
|
||||
user_message_timestamp: Optional[datetime] = None,
|
||||
audio_duration_seconds: Optional[int] = None,
|
||||
) -> List[str]:
|
||||
) -> AgentChatTurn:
|
||||
"""
|
||||
处理用户消息,返回 AI 回复列表。
|
||||
处理用户消息,返回 AI 回复(分段 + 是否跳过 TTS)。
|
||||
根据 missing_fields 路由到 ProfileAgent 或 InterviewAgent,
|
||||
统一写入 Redis。
|
||||
"""
|
||||
@@ -81,14 +86,14 @@ class ChatOrchestrator:
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_duration_seconds,
|
||||
)
|
||||
return responses
|
||||
return AgentChatTurn(messages=responses, skip_tts=False)
|
||||
except Exception as e:
|
||||
logger.error(f"资料收集处理失败: {e}", exc_info=True)
|
||||
|
||||
# --- 正式访谈模式 ---
|
||||
user_id = user.id if user else None
|
||||
if not user_id:
|
||||
return ["抱歉,无法识别用户。"]
|
||||
return _UNAUTH_TURN
|
||||
|
||||
state = await get_or_create_state(user_id, db)
|
||||
if conversation and conversation.conversation_stage != state.current_stage:
|
||||
@@ -106,7 +111,7 @@ class ChatOrchestrator:
|
||||
occupation=user.occupation,
|
||||
)
|
||||
|
||||
responses = await self.interview_agent.generate_response_with_state(
|
||||
turn = await self.interview_agent.generate_response_with_state(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
memoir_state=state,
|
||||
@@ -115,13 +120,13 @@ class ChatOrchestrator:
|
||||
await self._save_messages(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
response_text="\n\n".join(responses),
|
||||
response_text="\n\n".join(turn.messages),
|
||||
is_from_voice=is_from_voice,
|
||||
voice_session_id=voice_session_id,
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_duration_seconds,
|
||||
)
|
||||
return responses
|
||||
return turn
|
||||
|
||||
async def _save_messages(
|
||||
self,
|
||||
@@ -222,15 +227,15 @@ class ChatOrchestrator:
|
||||
voice_session_id: str | None = None,
|
||||
user_message_timestamp: datetime | None = None,
|
||||
audio_duration_seconds: int | None = None,
|
||||
) -> List[str]:
|
||||
) -> AgentChatTurn:
|
||||
"""委托 InterviewAgent 生成访谈回复,并写入 Redis"""
|
||||
responses = await self.interview_agent.generate_response_with_state(
|
||||
turn = await self.interview_agent.generate_response_with_state(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
memoir_state=memoir_state,
|
||||
user_profile_context=user_profile_context,
|
||||
)
|
||||
response_text = "\n\n".join(responses)
|
||||
response_text = "\n\n".join(turn.messages)
|
||||
await self._save_messages(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
@@ -240,7 +245,7 @@ class ChatOrchestrator:
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_duration_seconds,
|
||||
)
|
||||
return responses
|
||||
return turn
|
||||
|
||||
def detect_user_stage(self, user_message: str) -> str:
|
||||
"""委托 InterviewAgent 检测用户阶段"""
|
||||
|
||||
@@ -539,7 +539,7 @@ async def process_user_message(
|
||||
is_from_voice = bool(segment.audio_url)
|
||||
voice_session_id = _voice_session_id_from_audio_url(segment.audio_url)
|
||||
audio_dur = getattr(segment, "audio_duration_seconds", None)
|
||||
responses = await chat_orchestrator.process_user_message(
|
||||
turn = await chat_orchestrator.process_user_message(
|
||||
conversation_id=conversation_id,
|
||||
user_message=user_message,
|
||||
user=user,
|
||||
@@ -553,6 +553,8 @@ async def process_user_message(
|
||||
user_message_timestamp=user_message_timestamp,
|
||||
audio_duration_seconds=audio_dur,
|
||||
)
|
||||
responses = turn.messages
|
||||
skip_tts = turn.skip_tts
|
||||
|
||||
segment.agent_response = "\n\n".join(responses)
|
||||
_mark_conversation_active(conversation)
|
||||
@@ -574,12 +576,14 @@ async def process_user_message(
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
url = await _send_tts_audio(
|
||||
conversation_id,
|
||||
response_text,
|
||||
chunk_index=i,
|
||||
chunk_total=n,
|
||||
)
|
||||
url = None
|
||||
if not skip_tts:
|
||||
url = await _send_tts_audio(
|
||||
conversation_id,
|
||||
response_text,
|
||||
chunk_index=i,
|
||||
chunk_total=n,
|
||||
)
|
||||
if url:
|
||||
tts_urls.append(url)
|
||||
if i < n - 1:
|
||||
|
||||
@@ -13,6 +13,7 @@ _PLACEHOLDER_RE = re.compile(
|
||||
)
|
||||
|
||||
_ASSET_REF_RE = re.compile(r"!\[([^\]]*)\]\(asset://([a-zA-Z0-9_-]+)\)")
|
||||
_BLANK_RUN_RE = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def strip_legacy_image_placeholders(text: str | None) -> str:
|
||||
@@ -33,6 +34,19 @@ def collect_asset_ids_from_markdown(markdown: str) -> list[str]:
|
||||
return [m.group(2) for m in _ASSET_REF_RE.finditer(markdown or "") if m.group(2)]
|
||||
|
||||
|
||||
def strip_asset_image_refs_from_markdown(markdown: str | None) -> str:
|
||||
"""Remove all `` references; collapse blank lines.
|
||||
|
||||
Used for story single-primary policy: new versions / backfill must not
|
||||
accumulate multiple inline asset images.
|
||||
"""
|
||||
if not markdown or not str(markdown).strip():
|
||||
return ""
|
||||
text = _ASSET_REF_RE.sub("", markdown or "")
|
||||
text = _BLANK_RUN_RE.sub("\n\n", text)
|
||||
return text.strip()
|
||||
|
||||
|
||||
def collect_asset_ids_for_chapter(chapter) -> set[str]:
|
||||
"""章节正文 canonical、收录的各 story 正文、cover_asset_id 中的 asset id。"""
|
||||
ids: set[str] = set()
|
||||
|
||||
@@ -3,6 +3,9 @@ Story 图片回填 — 将 asset:// 引用追加到 markdown 末尾。
|
||||
|
||||
图片生成成功后,在正文最后插入 。
|
||||
alt 使用原始 prompt 短文(prompt_brief),而非模板拼接后的完整出图 prompt。
|
||||
|
||||
单主图策略:Celery 任务在调用本函数前会先 strip 正文中已有 asset:// 插图,
|
||||
避免与旧版本快照叠加多条引用。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from datetime import datetime, timezone
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
|
||||
from app.features.memoir import repo as memoir_repo
|
||||
from app.features.story.image_intent_extractor import extract_primary_image_intent
|
||||
from app.features.story.repo import (
|
||||
@@ -105,6 +106,7 @@ class StoryService:
|
||||
canonical_markdown: str | None = None,
|
||||
) -> str:
|
||||
"""Create story, commit, return story_id."""
|
||||
md = strip_asset_image_refs_from_markdown(canonical_markdown or "")
|
||||
story = await create_story(
|
||||
self._db,
|
||||
user_id=user_id,
|
||||
@@ -112,15 +114,15 @@ class StoryService:
|
||||
stage=stage,
|
||||
story_type=story_type,
|
||||
summary=summary,
|
||||
canonical_markdown=canonical_markdown or "",
|
||||
canonical_markdown=md,
|
||||
)
|
||||
await self._db.flush()
|
||||
if canonical_markdown:
|
||||
if md.strip():
|
||||
version = await create_story_version(
|
||||
self._db,
|
||||
story_id=story.id,
|
||||
version_no=1,
|
||||
markdown_snapshot=canonical_markdown,
|
||||
markdown_snapshot=md,
|
||||
actor_type="ai",
|
||||
source_type="generate",
|
||||
)
|
||||
@@ -130,12 +132,12 @@ class StoryService:
|
||||
self._db,
|
||||
story=story,
|
||||
version=version,
|
||||
markdown=canonical_markdown,
|
||||
markdown=md,
|
||||
)
|
||||
if canonical_markdown:
|
||||
if md.strip():
|
||||
await memoir_repo.mark_chapters_dirty_for_story(self._db, story.id)
|
||||
await self._db.commit()
|
||||
if canonical_markdown:
|
||||
if md.strip():
|
||||
from app.tasks.chapter_compose_tasks import recompose_chapters_for_story
|
||||
from app.tasks.story_image_tasks import generate_story_image
|
||||
|
||||
@@ -163,13 +165,14 @@ class StoryService:
|
||||
story = await get_story_by_id(self._db, story_id)
|
||||
if not story:
|
||||
raise ValueError(f"Story {story_id} not found")
|
||||
md = strip_asset_image_refs_from_markdown(markdown_snapshot or "")
|
||||
parent_id = story.current_version_id
|
||||
version_no = (await count_story_versions(self._db, story_id)) + 1
|
||||
version = await create_story_version(
|
||||
self._db,
|
||||
story_id=story_id,
|
||||
version_no=version_no,
|
||||
markdown_snapshot=markdown_snapshot,
|
||||
markdown_snapshot=md,
|
||||
actor_type=actor_type,
|
||||
source_type=source_type,
|
||||
parent_version_id=parent_id,
|
||||
@@ -177,12 +180,12 @@ class StoryService:
|
||||
)
|
||||
version.change_summary = change_summary
|
||||
story.current_version_id = version.id
|
||||
story.canonical_markdown = markdown_snapshot
|
||||
story.canonical_markdown = md
|
||||
await _extract_and_store_image_intent(
|
||||
self._db,
|
||||
story=story,
|
||||
version=version,
|
||||
markdown=markdown_snapshot,
|
||||
markdown=md,
|
||||
)
|
||||
await memoir_repo.mark_chapters_dirty_for_story(self._db, story_id)
|
||||
await self._db.commit()
|
||||
|
||||
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, joinedload
|
||||
|
||||
from app.core.db import utc_now
|
||||
from app.core.logging import get_logger
|
||||
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
|
||||
from app.features.memoir.models import ChapterStoryLink
|
||||
from app.features.memoir import repo as memoir_repo
|
||||
from app.features.story.image_intent_extractor import extract_primary_image_intent
|
||||
@@ -114,12 +115,13 @@ def create_story_with_version_sync(
|
||||
canonical_markdown: str,
|
||||
stage: str | None = None,
|
||||
) -> Story:
|
||||
md = strip_asset_image_refs_from_markdown(canonical_markdown or "")
|
||||
story = Story(
|
||||
id=str(uuid.uuid4()),
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
stage=stage,
|
||||
canonical_markdown=canonical_markdown or "",
|
||||
canonical_markdown=md,
|
||||
)
|
||||
session.add(story)
|
||||
session.flush()
|
||||
@@ -128,16 +130,16 @@ def create_story_with_version_sync(
|
||||
id=vid,
|
||||
story_id=story.id,
|
||||
version_no=1,
|
||||
markdown_snapshot=canonical_markdown or "",
|
||||
markdown_snapshot=md,
|
||||
actor_type="ai",
|
||||
source_type="generate",
|
||||
)
|
||||
session.add(version)
|
||||
session.flush()
|
||||
story.current_version_id = vid
|
||||
if (canonical_markdown or "").strip():
|
||||
if md.strip():
|
||||
_extract_and_store_image_intent_sync(
|
||||
session, story=story, version=version, markdown=canonical_markdown
|
||||
session, story=story, version=version, markdown=md
|
||||
)
|
||||
memoir_repo.mark_chapters_dirty_for_story_sync(session, story.id)
|
||||
return story
|
||||
@@ -154,6 +156,7 @@ def append_story_version_sync(
|
||||
story = session.get(Story, story_id)
|
||||
if not story:
|
||||
raise ValueError(f"Story {story_id} not found")
|
||||
md = strip_asset_image_refs_from_markdown(markdown_snapshot or "")
|
||||
parent_id = story.current_version_id
|
||||
version_no = count_story_versions_sync(session, story_id) + 1
|
||||
vid = str(uuid.uuid4())
|
||||
@@ -161,7 +164,7 @@ def append_story_version_sync(
|
||||
id=vid,
|
||||
story_id=story_id,
|
||||
version_no=version_no,
|
||||
markdown_snapshot=markdown_snapshot,
|
||||
markdown_snapshot=md,
|
||||
actor_type=actor_type,
|
||||
source_type=source_type,
|
||||
parent_version_id=parent_id,
|
||||
@@ -169,9 +172,9 @@ def append_story_version_sync(
|
||||
session.add(version)
|
||||
session.flush()
|
||||
story.current_version_id = vid
|
||||
story.canonical_markdown = markdown_snapshot
|
||||
story.canonical_markdown = md
|
||||
_extract_and_store_image_intent_sync(
|
||||
session, story=story, version=version, markdown=markdown_snapshot
|
||||
session, story=story, version=version, markdown=md
|
||||
)
|
||||
memoir_repo.mark_chapters_dirty_for_story_sync(session, story_id)
|
||||
return version
|
||||
|
||||
@@ -18,6 +18,7 @@ from app.core.dependencies import get_image_generator
|
||||
from app.core.logging import get_logger
|
||||
from app.core.redis_lock import acquire_redis_lock, release_redis_lock
|
||||
from app.features.asset.models import Asset
|
||||
from app.features.memoir.asset_resolver import strip_asset_image_refs_from_markdown
|
||||
from app.features.memoir.memoir_images.storage import TencentCosStorageService
|
||||
from app.features.story.backfill import backfill_image_into_markdown
|
||||
from app.features.story.models import Story, StoryImageIntent, StoryVersion
|
||||
@@ -262,7 +263,7 @@ def generate_story_image(self, story_id: str):
|
||||
db.commit()
|
||||
return {"status": "success_no_snapshot", "asset_id": asset_id}
|
||||
|
||||
base_md = ver.markdown_snapshot or ""
|
||||
base_md = strip_asset_image_refs_from_markdown(ver.markdown_snapshot or "")
|
||||
alt_text = (getattr(intent_db, "prompt_brief", None) or "").strip()
|
||||
if not alt_text:
|
||||
alt_text = (getattr(intent_db, "caption", None) or "").strip()
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.features.memoir.asset_resolver import (
|
||||
collect_asset_ids_from_markdown,
|
||||
resolve_asset_refs_in_markdown,
|
||||
split_markdown_by_asset_refs,
|
||||
strip_asset_image_refs_from_markdown,
|
||||
strip_legacy_image_placeholders,
|
||||
)
|
||||
from app.features.memoir.models import Chapter
|
||||
@@ -53,6 +54,22 @@ class AssetResolverTest(unittest.TestCase):
|
||||
ids = collect_asset_ids_for_chapter(ch)
|
||||
self.assertEqual(ids, {"a1", "cov1"})
|
||||
|
||||
def test_strip_asset_image_refs_removes_all_and_collapses_blank_lines(self):
|
||||
md = (
|
||||
"第一段\n\n\n\n第二段\n\n\n"
|
||||
"\n\n第三段"
|
||||
)
|
||||
out = strip_asset_image_refs_from_markdown(md)
|
||||
self.assertNotIn("asset://", out)
|
||||
self.assertIn("第一段", out)
|
||||
self.assertIn("第二段", out)
|
||||
self.assertIn("第三段", out)
|
||||
self.assertNotIn("\n\n\n", out)
|
||||
|
||||
def test_strip_asset_image_refs_empty(self):
|
||||
self.assertEqual(strip_asset_image_refs_from_markdown(""), "")
|
||||
self.assertEqual(strip_asset_image_refs_from_markdown(" "), "")
|
||||
|
||||
def test_collect_asset_ids_includes_linked_story_markdown(self):
|
||||
ch = SimpleNamespace(
|
||||
canonical_markdown="",
|
||||
|
||||
@@ -134,6 +134,103 @@ class GenerateStoryImageTaskTest(unittest.TestCase):
|
||||
acquire_lock_mock.assert_called_once()
|
||||
release_lock_mock.assert_called_once()
|
||||
|
||||
@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_strips_existing_asset_refs_before_backfill(
|
||||
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("new-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="主插图",
|
||||
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="主插图",
|
||||
prompt_brief="院子里的藤椅",
|
||||
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\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(story_db.canonical_markdown.count("asset://"), 1)
|
||||
self.assertIn("asset://new-asset-uuid", story_db.canonical_markdown)
|
||||
self.assertNotIn("old-stale-id", story_db.canonical_markdown)
|
||||
|
||||
@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")
|
||||
|
||||
@@ -152,6 +152,8 @@ export default ({ config }: ConfigContext): ExpoConfig => {
|
||||
favicon: './assets/images/favicon.png',
|
||||
},
|
||||
plugins: [
|
||||
// CI/local release: android/app/keystore.properties + store file → release signing; -PversionName/-PversionCode
|
||||
'./plugins/withAndroidReleaseSigning',
|
||||
// --- Web: Required for expo-sqlite on web ---
|
||||
// COEP/COOP headers enable SharedArrayBuffer in browsers.
|
||||
// Also configure metro.config.js (wasm + dev server headers).
|
||||
|
||||
89
app-expo/plugins/withAndroidReleaseSigning.js
Normal file
89
app-expo/plugins/withAndroidReleaseSigning.js
Normal file
@@ -0,0 +1,89 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* When android/app/keystore.properties + store file exist (CI / local release),
|
||||
* wire signingConfigs.release and use it for assembleRelease.
|
||||
* Pass -PversionName / -PversionCode to override defaultConfig.
|
||||
*/
|
||||
const { withAppBuildGradle } = require('@expo/config-plugins');
|
||||
|
||||
function withAndroidReleaseSigning(config) {
|
||||
return withAppBuildGradle(config, (mod) => {
|
||||
let contents = mod.modResults.contents;
|
||||
|
||||
const inject = `def keystorePropertiesFile = file("keystore.properties")
|
||||
def keystoreProperties = new Properties()
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
if (!contents.includes('keystorePropertiesFile')) {
|
||||
contents = contents.replace(/^android \{/m, `${inject}android {`);
|
||||
}
|
||||
|
||||
const oldSigning = ` signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
}`;
|
||||
|
||||
const newSigning = ` signingConfigs {
|
||||
debug {
|
||||
storeFile file('debug.keystore')
|
||||
storePassword 'android'
|
||||
keyAlias 'androiddebugkey'
|
||||
keyPassword 'android'
|
||||
}
|
||||
release {
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
storeFile file(keystoreProperties['storeFile'])
|
||||
storePassword keystoreProperties['storePassword']
|
||||
keyAlias keystoreProperties['keyAlias']
|
||||
keyPassword keystoreProperties['keyPassword']
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
||||
if (!contents.includes(oldSigning)) {
|
||||
throw new Error(
|
||||
'[withAndroidReleaseSigning] Default signingConfigs block not found; update plugin for current Expo prebuild template.',
|
||||
);
|
||||
}
|
||||
contents = contents.replace(oldSigning, newSigning);
|
||||
|
||||
const oldReleaseSigningLine = ` release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig signingConfigs.debug`;
|
||||
|
||||
const newReleaseSigningLine = ` release {
|
||||
// Caution! In production, you need to generate your own keystore file.
|
||||
// see https://reactnative.dev/docs/signed-apk-android.
|
||||
signingConfig (keystorePropertiesFile.exists() ? signingConfigs.release : signingConfigs.debug)`;
|
||||
|
||||
if (!contents.includes(oldReleaseSigningLine)) {
|
||||
throw new Error(
|
||||
'[withAndroidReleaseSigning] Default release signingConfig line not found; update plugin for current Expo prebuild template.',
|
||||
);
|
||||
}
|
||||
contents = contents.replace(oldReleaseSigningLine, newReleaseSigningLine);
|
||||
|
||||
contents = contents.replace(
|
||||
/versionCode \d+/,
|
||||
'versionCode (project.hasProperty("versionCode") ? project.property("versionCode").toInteger() : 1)',
|
||||
);
|
||||
contents = contents.replace(
|
||||
/versionName "[^"]+"/,
|
||||
'versionName (project.hasProperty("versionName") ? project.property("versionName") : "1.0.0")',
|
||||
);
|
||||
|
||||
mod.modResults.contents = contents;
|
||||
return mod;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidReleaseSigning;
|
||||
Reference in New Issue
Block a user