修复环境变量,UI问题

This commit is contained in:
Kevin
2026-03-23 13:54:41 +08:00
parent b9ecfd02a4
commit f58adb9670
13 changed files with 382 additions and 85 deletions

View File

@@ -135,6 +135,11 @@ MEMOIR_IMAGE_MAX_ATTEMPTS=20
MEMOIR_IMAGE_PROVIDER=liblib
MEMOIR_IMAGE_STYLE_DEFAULT=watercolor
MEMOIR_IMAGE_SIZE_DEFAULT=1280x720
# Story 正文至少多少字才生成主图 intent / 调图0=不限制)
STORY_IMAGE_MIN_BODY_CHARS=800
# 叙事模型输出相对口述过短则回退为口述原文
MEMOIR_NARRATIVE_FALLBACK_BODY_RATIO=0.5
MEMOIR_NARRATIVE_FALLBACK_MIN_CHARS=20
# 可选Liblib 返回图片域名不在默认白名单时(逗号分隔)
# MEMOIR_IMAGE_DOWNLOAD_HOSTS=liblib.cloud,liblibai.cloud

View File

@@ -1,10 +1,14 @@
"""
ClassificationAgent将内容分类到 8 个章节类别,或判定无价值返回 None。
对应现有逻辑_classify_chapter_category
返回 None 表示本段不进入回忆录 Story/章节流水线;与 StoryRoute 中「可独立讲述的一段人生经历」
(见 prompts.get_story_route_prompt在标准上对齐零散档案点不进 Story记忆与 slot 抽取仍由上游完成。
"""
from __future__ import annotations
import re
from typing import Any, Optional
from app.agents.memoir.prompts import (
@@ -15,9 +19,24 @@ from app.core.logging import get_logger
logger = get_logger(__name__)
# 5-stage 关键词(用于 LLM 失败时的兜底)
# 与「仅档案句式」组合使用;过短但明显为叙事句的仍交 LLM 判断
_FRAGMENT_SHORT_MAX_LEN = 48
# 整段仅为出生年份/年份声明(零散档案,不成故事)
_BIRTH_YEAR_LINE = re.compile(
r"^[\s\u200b]*(?:我)?\d{4}\s*年\s*(出生|生的|生)?\s*[。.!]?[\s\u200b]*$",
re.UNICODE,
)
# 极短且为「我是某地人」式籍贯标签,无过程描写
_SHORT_HUKOU_STYLE = re.compile(
r"^[\s\u200b]*(?:我)?是[\u4e00-\u9fff]{1,10}(人|籍)\s*[。.!]?[\s\u200b]*$",
re.UNICODE,
)
# 5-stage 关键词(用于 LLM 失败时的兜底);注意勿含易与「仅年份句」共现的泛词,以免误推类别
STAGE_KEYWORDS = {
"childhood": ["童年", "小时候", "出生", "家乡", "小镇"],
"childhood": ["童年", "小时候", "家乡", "小镇"],
"education": ["上学", "学校", "老师", "同学", "教育", "大学"],
"career": ["工作", "职业", "事业", "公司", "同事", "创业"],
"family": ["伴侣", "孩子", "家庭", "家人", "结婚", "父母"],
@@ -43,6 +62,33 @@ def _detect_stage(text: str, fallback_stage: str) -> str:
return fallback_stage
def _looks_like_fragment_only(text: str) -> bool:
"""
保守启发式:明显为档案点/标签句,不足以作为 Story 叙事单元。
与 get_chapter_classification_prompt 中「应返回 none」的情形一致误判风险通过窄正则控制。
"""
s = (text or "").strip()
if not s:
return True
if _BIRTH_YEAR_LINE.match(s):
return True
if len(s) <= _FRAGMENT_SHORT_MAX_LEN and _SHORT_HUKOU_STYLE.match(s):
return True
return False
def _normalize_llm_category(raw: str) -> str:
"""去掉模型偶发的引号、反引号包裹。"""
s = (raw or "").strip().lower()
if s.startswith("`"):
s = s.strip("`").strip()
if (s.startswith('"') and s.endswith('"')) or (
s.startswith("'") and s.endswith("'")
):
s = s[1:-1].strip()
return s
class ClassificationAgent:
"""将内容分类到 8 个章节类别之一,或判定无价值返回 None"""
@@ -54,17 +100,25 @@ class ClassificationAgent:
) -> Optional[str]:
"""
分类到 8 个章节类别之一。
若 LLM 判定内容无实质回忆录价值,返回 None。
若 LLM 判定内容不足以独立成篇none或启发式判定为零散档案点,返回 None。
llm 需支持 .invoke(prompt) 同步调用。
"""
if _looks_like_fragment_only(text):
logger.debug(
"零散档案/极短标签句,跳过回忆录 Story: text_len=%s text=%s",
len(text or ""),
text or "",
)
return None
if llm:
try:
prompt = get_chapter_classification_prompt(text)
response = llm.invoke(prompt)
category = (response.content or "").strip().lower()
category = _normalize_llm_category(response.content or "")
if category == "none":
logger.debug(
"LLM 判定内容无回忆录价值,跳过: text_len=%s text=%s",
"LLM 判定内容不足以成篇,跳过: text_len=%s text=%s",
len(text or ""),
text or "",
)

View File

@@ -130,11 +130,41 @@ def get_system_prompt() -> str:
"""
def get_memoir_fidelity_system_prompt() -> str:
"""叙事/标题生成专用:准确性优先,禁止编造事实(与 get_system_prompt 分离)。"""
return """你是回忆录编辑助手,任务是把用户口述整理为第一人称书面叙述。
## 事实边界(必须遵守,优先于文采)
1. **正文只能展开「本段用户口述」区块中的内容**。若输入中有「相关记忆摘录」等参考区,其中信息**不得**写成本人本轮亲口经历的细节;最多用一两句作主题衔接,且不得引入摘录里才有的具体人名、地点、时间、对话、数字。
2. **禁止编造**:不得新增用户未提及的具体人物姓名、对话原文、地点、时间、事件经过、因果、数字;不得推断性心理描写或「典型年代场景」填充。
3. **禁止为凑字数扩写**:材料短则输出短;段落数量与长度随材料而定。
4. 允许:去除口语赘词与寒暄、调整语序、合并重复指代、把口语改为书面语;**不得**用虚构细节「让文章更好看」。
## 用户档案与阶段信息
- 「用户基本信息」「时间参考」仅可使用其中**已写明**的条目;不得把档案中的出生地等写进正文,除非用户在本段口述里已提及或明确关联。"""
def get_narrative_editor_system_prompt() -> str:
"""叙事改写:准确性系统提示 + 可执行文体约束(不用 get_system_prompt 中的「过渡句/生动细节」泛化指令)。"""
return f"""{get_memoir_fidelity_system_prompt()}
## 文体(在严守事实的前提下)
- 使用第一人称、书面语;不要直接引用对话原话。
- 不使用 Markdown 标题(#、##)、不使用表格。
- 如有「衔接上下文」,仅保持语气与时间线连贯,不重复已有段落全文。"""
def get_chapter_classification_prompt(segments_text: str) -> str:
"""获取章节分类的提示词"""
"""获取章节分类的提示词
返回 none 的语义与 Story 路由get_story_route_prompt / get_story_batch_plan_prompt
「可独立讲述的一段人生经历」对齐none 表示本段不足以单独成篇进入回忆录 Story 流水线。
"""
return f"""{get_system_prompt()}
请分析以下对话内容,**忽略其中的语气词、寒暄和无关对话**只关注涉及人生经历的实质内容,判断应归类到哪个章节类别
请分析以下对话内容,**忽略其中的语气词、寒暄和无关对话**,判断应归类到哪个章节类别,或是否不足以写入回忆录正文。
## 章节类别
- childhood: 童年与成长背景
- education: 教育经历与青年时期
- career_early: 崭露头角(早期事业)
@@ -144,11 +174,22 @@ def get_chapter_classification_prompt(segments_text: str) -> str:
- beliefs: 信念与价值观
- summary: 人生总结
## 何时必须返回 none与「零散档案点」区分
若去掉寒暄后,内容仅为**档案式点状信息****没有可讲述的叙事骨架**(无事件、场景、过程、互动或情绪展开),则必须返回 **none**,例如:
- 仅出生年份、籍贯一笔、职业名词、姓名等单句事实;
- 仅罗列事实、无画面与过程的短答。
以下情况**不是** none篇幅短但已构成**微型故事**(有画面、动作、对话、转折、感受),应归入最贴合的章节类别。
## 示例(仅作判断参考)
- 应返回 none「我1999年出生的。」「籍贯上海。」「工程师。」
- 应返回 childhood或其它合适类别「小学时有次下大雨爷爷背我过河鞋全湿了他一直笑。」
对话内容:
{segments_text}
请只返回章节类别childhood不要返回其他内容
如果对话内容中没有任何与人生经历相关的实质内容,返回 none。"""
请只返回章节类别英文 keychildhood不要返回其它说明
若内容不足以独立成篇、仅为零散信息,返回 none。"""
def get_text_rewrite_prompt(
@@ -257,31 +298,25 @@ def get_creative_title_prompt(
user_profile: str = "",
birth_year: Optional[int] = None,
) -> str:
"""生成有创意的章节标题,包含年龄/时间信息"""
"""生成故事标题:概括口述事实或主题,禁止纯意象编造。"""
age_hint = _build_age_hint(stage, birth_year)
profile_section = f"\n用户基本信息:\n{user_profile}" if user_profile else ""
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
return f"""{get_system_prompt()}
return f"""{get_memoir_fidelity_system_prompt()}
请根据下面「阶段、情绪、可用信息」生成 **1 个**回忆录故事标题。
请根据阶段和情绪生成 1 个有创意的章节标题。
阶段:{stage}
情绪:{emotion}
可用信息:{slots}{profile_section}{time_section}
可用信息(含口述 slots 与档案){slots}{profile_section}{time_section}
要求:
1. 标题格式:「时间标注 · 标题正文」
- 时间标注用年龄或年代表示,如"6-12岁""1980年代""二十出头"
- 标题正文 12-18 字以内
2. 情绪 + 人生阶段 + 意象
3. 示例风格:
- 《6-12岁 · 那条巷子尽头的蝉鸣》
- 《18岁 · 第一次离开家的夏天》
- 《25-35岁 · 在陌生城市站稳脚跟》
- 《四十不惑 · 慢下来,人生开始发声》
- 《1990年代 · 不是所有选择都被理解》
1. 格式:「时间标注 · 标题正文」(时间标注可用年龄、年代或阶段,须与上列信息一致;勿编造未出现的年份)。
2. 标题正文 **1218 字**,必须概括 **用户口述或 slots 中已出现的主题/事实****禁止**使用用户未提及的纯文学意象(如未提巷子/蝉鸣则不得写)。
3. 可略带文采,但不得引入口述中不存在的人、事、地、物。
只输出标题文字,不要加引号或书名号。
只输出标题这一行文字,不要加引号或书名号。
"""
@@ -315,36 +350,26 @@ def get_narrative_prompt(
age_hint = _build_age_hint(stage, birth_year)
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
return f"""{get_system_prompt()}
return f"""{get_narrative_editor_system_prompt()}
请将以下新的对话内容改写为第一人称文学叙述。
阶段:{stage}
可用信息:{slots}{profile_section}{time_section}
可用信息slots仅可复述其中已出现事实{slots}{profile_section}{time_section}
新的对话内容
输入材料(请严格区分「本段口述」与参考区,规则见系统说明)
{new_content}
{context_section}
{archived_section}
## 第一步:提炼核心内容
在改写之前,请先从对话内容中提炼出与人生经历相关的核心信息:
- 提取具体的事件、人物、地点、时间、感受
- 丢弃语气词嗯、啊、那个、就是说、寒暄你好、谢谢、与AI的交互你帮我整理一下、对对对你说得对、无意义的重复
- 如果对话内容中几乎没有与人生经历相关的实质内容,请输出空字符串
## 步骤
1. 从「本段用户口述」提炼可写事实;丢弃语气词、寒暄、与 AI 的交互。
2. 改写为第一人称书面叙述:可调整语序与用词,**不得**新增事实。
3. 若材料中无值得记录的人生经历内容,输出空字符串。
## 第二步:改写为叙述
基于提炼后的核心内容进行文学改写:
1. 使用第一人称叙述
2. **不要直接引用对话原话**,将所有内容改写为流畅的书面叙述
3. **只输出新内容的改写结果**,不要重复已有内容
4. 如果有衔接上下文,确保新内容与之自然衔接(语气、时间线连贯)
5. 语气自然,有情绪
6. 如果有用户的基本信息(出生地、成长地等),在叙述中自然融入地域文化和时代背景
8. **不要将对话中的交互性语言(如"我跟你说""你知道吗")写入叙述**
9. **不要在正文中插入章节标题或分类标签**(如"章节:信念与价值观""## 童年与成长背景"等),章节标题由系统单独管理
10. **不要使用 Markdown 表格**(不要用 `|` 管道表格);故事标题由系统单独管理,**不要用 `#`、`##` 在正文里写故事标题**
## 格式
- 不要插入章节标题或 `#`、`##`;不要用 Markdown 表格。
- 不要写入与「本段用户口述」无关的交互套话。
只输出新对话内容的改写结果。如果对话中没有值得记录的人生经历内容输出空字符串。
只输出改写后的正文。无内容输出空字符串。
"""
@@ -371,26 +396,22 @@ def get_narrative_json_prompt(
age_hint = _build_age_hint(stage, birth_year)
time_section = f"\n时间参考:{age_hint}" if age_hint else ""
return f"""{get_system_prompt()}
return f"""{get_narrative_editor_system_prompt()}
请将以下新的对话内容改写为第一人称文学叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块。
请将「本段用户口述」改写为第一人称书面叙述,并输出 **纯 JSON**,不要包含任何其他文字或 markdown 代码块。
阶段:{stage}
可用信息:{slots}{profile_section}{time_section}
可用信息slots{slots}{profile_section}{time_section}
新的对话内容
输入材料
{new_content}
{context_section}
## 要求
1. 从对话中提炼与人生经历相关的核心内容过滤语气词、寒暄、与AI的交互
2. 使用第一人称,改写为流畅的书面叙述,不要直接引用对话原话
3. 只输出新内容的改写,不要重复已有内容
4. **本批输入对应一个独立叙事单元**:只围绕同一主题/事件链展开,不要写入与上述对话无关的其他话题或回忆
5. 每 200-300 字左右一个段落
6. 如有衔接上下文,确保新内容与之自然衔接
7. **不要使用 Markdown 表格**(不要用 `|` 管道表格)
8. **不要用 `#`、`##` 写故事或章节标题**;标题由系统管理
1. **只展开「本段用户口述」**;若有参考摘录区,不得把摘录中的具体事实写成本轮亲历经历(见系统说明)。
2. 过滤语气词、寒暄、与 AI 的交互;不重复已有故事全文;本批只写同一主题/事件链。
3. 段落数量与每段长度**随材料而定**,禁止为凑字数编造。
4. 使用第一人称;不要直接引用原话;不要用 `#`、`##`、表格。
## 输出格式(严格 JSON
{{
@@ -400,9 +421,9 @@ def get_narrative_json_prompt(
]
}}
- content: 本段纯正文
- content:仅含正文
如果对话中没有值得记录的人生经历内容,输出{{"paragraphs": []}}
若无值得记录的内容{{"paragraphs": []}}
"""
@@ -413,13 +434,19 @@ def get_story_route_prompt(
batch_transcript: str,
candidate_stories_json: str,
) -> str:
"""Celery 批次:判断写入新 story 还是追加已有 story。输出严格 JSON。"""
"""Celery 批次:判断写入新 story 还是追加已有 story。输出严格 JSON。
「故事」= 可独立讲述的一段人生经历;进入本步的批次已满足 get_chapter_classification_prompt
中章节级分类(非 none二者语义一致。
"""
return f"""你是回忆录编辑助手。根据本批用户口述与候选故事列表,决定:
- append_story内容明显延续、补充某一已有故事的主题与时间线且能对应到具体 candidate id
- new_story新话题、新人生阶段片段或与所有候选故事都不够贴合
「故事」在此指:**可独立讲述的一段人生经历**——单一主题或同一事件链;不要假设本批里包含多个互不相关的故事(多段由系统其它步骤处理)。
**new_story_title 与 reason 只能依据口述中已有信息概括,不得编造口述未出现的人、事、地、物。**
当前章节(写作容器):
- category: {chapter_category}
- title: {chapter_title}
@@ -457,6 +484,8 @@ def get_story_batch_plan_prompt(
## 「故事」定义(必须遵守)
一段「故事」= **可独立讲述的一段人生经历**:单一主题或同一事件链,能单独成篇。若话题切换、时间线跳到另一件事、人物/主线明显变化,应作为**新的故事**new_story而不是塞进同一段 append。
**new_story_title 与 reason 只能依据各 segment 文本中已有信息,不得编造口述未出现的事实。**
## 任务
将本批 segment **划分为连续若干块**(每块包含至少一个 segment顺序不能打乱每个 segment 必须恰好属于一块)。对每一块决定:
- **append_story**:内容明显延续、补充**某一已有候选故事**的主题与时间线,且能对应到具体 candidate id
@@ -492,6 +521,23 @@ def get_story_batch_plan_prompt(
"""
def format_narrative_user_content(oral_text: str, evidence_text: str = "") -> str:
"""
将口述与检索摘录分区,供叙事模型区分「亲历」与参考材料。
evidence 为空时仅输出口述块。
"""
oral = (oral_text or "").strip()
ev = (evidence_text or "").strip()
if not ev:
return f"【本段用户口述】\n{oral}"
return (
"【本段用户口述】\n"
f"{oral}\n\n"
"【仅供参考的相关记忆摘录(非本段口述;不得把其中具体事实写成本轮亲历经历,仅可作主题衔接)】\n"
f"{ev}"
)
def format_evidence_chunks_for_prompt(evidence: dict) -> str:
"""将 retrieve_evidence 结果格式化为简短文本,供叙事 prompt 使用。"""
chunks = evidence.get("relevant_chunks") or []

View File

@@ -2,7 +2,7 @@
统一配置:所有环境变量通过此模块的 Settings 单点读取。
业务代码只允许 import settings禁止散落 os.getenv() / load_dotenv()。
本地开发时由 api/development.sh 在启动前将 .env.development 复制为 .env若尚无 .env)。
本地开发时由 api/development.sh 在启动前将 .env.development 同步为 .env每次启动覆盖)。
Docker / 服务端由镜像与 compose 注入进程环境;此处仅固定读取工作目录下的 .env 作为默认值来源。
进程环境变量(容器 environment、export覆盖 .env 同名项。
"""
@@ -106,6 +106,11 @@ class Settings(BaseSettings):
memoir_image_style_default: str = "watercolor"
memoir_image_size_default: str = "1280x720"
memoir_image_download_hosts: str = ""
# Story 正文至少多少字才创建主图 intent / 调图0 表示不限制)
story_image_min_body_chars: int = 800
# 叙事输出相对口述过短则回退为口述原文(比例与下限)
memoir_narrative_fallback_body_ratio: float = 0.5
memoir_narrative_fallback_min_chars: int = 20
# ── Liblib ───────────────────────────────────────────────
liblib_access_key: str = ""

View File

@@ -21,6 +21,7 @@ class MemoirImageSettings:
poll_interval_seconds: int = DEFAULT_POLL_INTERVAL_SECONDS
max_attempts: int = DEFAULT_MAX_ATTEMPTS
liblib_template_uuid: str = DEFAULT_LIBLIB_TEMPLATE_UUID
story_image_min_body_chars: int = 800
@classmethod
def from_settings(cls, settings: "Settings") -> "MemoirImageSettings":
@@ -33,6 +34,9 @@ class MemoirImageSettings:
poll_interval_seconds=s.memoir_image_poll_interval,
max_attempts=s.memoir_image_max_attempts,
liblib_template_uuid=s.liblib_template_uuid or DEFAULT_LIBLIB_TEMPLATE_UUID,
story_image_min_body_chars=int(
getattr(s, "story_image_min_body_chars", 800) or 0
),
)
@classmethod

View File

@@ -11,7 +11,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, joinedload
from app.agents.memoir.narrative_agent import NarrativeAgent
from app.agents.memoir.prompts import STAGE_TO_ORDER, format_evidence_chunks_for_prompt
from app.agents.memoir.prompts import (
STAGE_TO_ORDER,
format_evidence_chunks_for_prompt,
format_narrative_user_content,
)
from app.core.config import settings
from app.agents.memoir.story_route_agent import (
PLAN_BATCH_MAX_SEGMENTS,
StoryBatchPlan,
@@ -37,6 +42,22 @@ from app.features.story.sync_write import (
logger = get_logger(__name__)
def _should_fallback_to_transcript(md: str, oral: str) -> bool:
"""模型输出相对口述明显过短时回退为口述原文防「1999」类压缩"""
o = (oral or "").strip()
if not o:
return False
m = (md or "").strip()
if not m:
return True
if len(o) < 12:
return len(m) < len(o)
ratio = float(settings.memoir_narrative_fallback_body_ratio)
min_abs = int(settings.memoir_narrative_fallback_min_chars)
threshold = max(min_abs, int(len(o) * ratio))
return len(m) < threshold
def _is_json_narrative(text: str) -> bool:
if not text or not text.strip():
return False
@@ -78,6 +99,18 @@ def _apply_narrative_fallbacks(
chapter_category,
)
return f"{existing_chapter_md}\n\n{combined_unit_text}"
md_check = narrative_to_markdown(narrative_raw).strip()
oral = (combined_unit_text or "").strip()
if oral and _should_fallback_to_transcript(md_check, oral):
logger.warning(
"叙事相对口述过短,回退为口述原文 category=%s oral_len=%s md_len=%s",
chapter_category,
len(oral),
len(md_check),
)
return oral
return narrative_raw
@@ -144,11 +177,7 @@ def _run_batch_plan_writes(
dispatch_ids: set[str] = set()
for unit in plan.units:
unit_text = _ordered_text_for_segment_ids(category_segments, unit.segment_ids)
new_content_input = (
f"{unit_text}\n\n【相关记忆摘录】\n{evidence_text}"
if evidence_text.strip()
else unit_text
)
new_content_input = format_narrative_user_content(unit_text, evidence_text)
target_story_id: str | None = None
existing_for_narrative = ""
@@ -175,8 +204,10 @@ def _run_batch_plan_writes(
chapter_category=chapter_category,
)
md = narrative_to_markdown(narrative_raw)
if not md.strip():
md = narrative_to_markdown(narrative_raw).strip()
if not md:
md = unit_text.strip()
elif _should_fallback_to_transcript(md, unit_text.strip()):
md = unit_text.strip()
if target_story_id:
@@ -245,11 +276,7 @@ def run_story_pipeline_for_category_batch(
}
evidence_text = format_evidence_chunks_for_prompt(evidence)
new_content_input = (
f"{combined_text}\n\n【相关记忆摘录】\n{evidence_text}"
if evidence_text.strip()
else combined_text
)
new_content_input = format_narrative_user_content(combined_text, evidence_text)
stmt_chapter = (
select(Chapter)
@@ -376,8 +403,10 @@ def run_story_pipeline_for_category_batch(
chapter_category=chapter_category,
)
md = narrative_to_markdown(narrative_raw)
if not md.strip():
md = narrative_to_markdown(narrative_raw).strip()
if not md:
md = combined_text.strip()
elif _should_fallback_to_transcript(md, combined_text.strip()):
md = combined_text.strip()
do_append = target_story_id is not None

View File

@@ -13,6 +13,7 @@ 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.memoir.memoir_images.settings import MemoirImageSettings
from app.features.story.image_intent_extractor import extract_primary_image_intent
from app.features.story.repo import (
count_story_versions,
@@ -40,6 +41,21 @@ async def _extract_and_store_image_intent(
从 markdown 提取 primary intent。
仅移除 pending/failed避免删掉正在 processing 的旧任务行;同版本则原地更新行以幂等。
"""
img_settings = MemoirImageSettings.from_env()
plain = strip_asset_image_refs_from_markdown(markdown or "").strip()
min_chars = img_settings.story_image_min_body_chars
if min_chars > 0 and len(plain) < min_chars:
await delete_story_image_intents_by_story(
db, story.id, statuses=["pending", "failed"]
)
logger.debug(
"story image intent skipped: body below min chars story=%s len=%s min=%s",
story.id,
len(plain),
min_chars,
)
return
await delete_story_image_intents_by_story(
db, story.id, statuses=["pending", "failed"]
)

View File

@@ -19,6 +19,7 @@ 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.settings import MemoirImageSettings
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
@@ -178,11 +179,34 @@ def generate_story_image(self, story_id: str):
intent, story = row
img_cfg = MemoirImageSettings.from_env()
min_body = img_cfg.story_image_min_body_chars
if min_body > 0:
plain = strip_asset_image_refs_from_markdown(
story.canonical_markdown or ""
).strip()
if len(plain) < min_body:
with get_sync_db() as db:
intent_db = db.get(StoryImageIntent, intent.id)
if intent_db and (intent_db.status or "").strip() == "processing":
intent_db.status = "skipped"
intent_db.error = f"body_below_min_chars:{len(plain)}"
intent_db.claim_token = None
intent_db.claimed_at = None
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",
story_id,
len(plain),
min_body,
)
return {"status": "skipped_body_too_short"}
image_generator = get_image_generator()
storage = TencentCosStorageService.from_env()
from app.features.memoir.memoir_images.settings import MemoirImageSettings
settings = MemoirImageSettings.from_env()
settings = img_cfg
prompt_final = _build_story_image_prompt(
intent.prompt_brief or "",
story_title=story.title or "",

View File

@@ -180,17 +180,13 @@ ensure_venv() {
fi
}
# 本地约定:仓库维护 .env.development;一键启动时复制为 .env供 pydantic Settings(env_file=".env") 读取。
# 若已存在 .env 则不覆盖(便于你本地覆盖);需要与模板同步时可删除 .env 后重新运行本脚本
# 本地约定: .env.development 为真源;每次一键启动都从 .env.development 覆盖 .env供 pydantic Settings(env_file=".env") 读取。
# 请勿仅在 .env 里改密钥而不同步回 .env.development否则下次启动会被覆盖
ensure_dotenv_from_development() {
print_header "准备本地 .env"
if [[ -f "${ROOT_DIR}/.env" ]]; then
print_ok "已存在 .env未覆盖"
return 0
fi
if [[ -f "${ROOT_DIR}/.env.development" ]]; then
cp "${ROOT_DIR}/.env.development" "${ROOT_DIR}/.env"
print_ok "已从 .env.development 复制为 .env"
print_ok "已从 .env.development 同步为 .env"
return 0
fi
print_warn "未找到 .env.development无法自动生成 .env"

View File

@@ -63,6 +63,7 @@ line-ending = "auto"
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["."]
python_files = ["test_*.py"]
markers = [
"integration: marks tests that require external services or real infrastructure",

View File

@@ -0,0 +1,42 @@
"""ClassificationAgent零散档案启发式与分类 none 语义(纯函数/无 LLM"""
import pytest
from app.agents.memoir.classification_agent import (
ClassificationAgent,
_looks_like_fragment_only,
)
@pytest.mark.parametrize(
"text,expected_fragment",
[
("", True),
(" ", True),
("我1999年出生", True),
("1999年出生。", True),
("1999年出生", True),
("我是云南人", True),
("我是北京籍。", True),
("小学二年级那次下雨爷爷背我过河,鞋全湿了。", False),
("我出生在农村,家里养过一头黄牛。", False),
("我是北京人,后来去上海读了大学。", False),
],
)
def test_looks_like_fragment_only(text: str, expected_fragment: bool) -> None:
assert _looks_like_fragment_only(text) is expected_fragment
def test_classify_skips_story_for_birth_year_without_llm() -> None:
agent = ClassificationAgent()
assert agent.classify("1999年出生", fallback_stage="childhood", llm=None) is None
def test_classify_fallback_when_no_llm_and_narrative_snippet() -> None:
agent = ClassificationAgent()
out = agent.classify(
"小学二年级的时候我在操场上摔了一跤,膝盖流了很多血,是老师背我去医务室的。",
fallback_stage="childhood",
llm=None,
)
assert out == "education"

View File

@@ -0,0 +1,52 @@
"""叙事分区、口述过短回退、配图字数门闸(纯函数/无 DB"""
import pytest
from app.agents.memoir.prompts import format_narrative_user_content
from app.features.memoir import story_pipeline_sync as sps
def test_format_narrative_user_content_oral_only() -> None:
assert format_narrative_user_content("hello", "") == "【本段用户口述】\nhello"
def test_format_narrative_user_content_with_evidence() -> None:
out = format_narrative_user_content("口述A", "摘录B")
assert "【本段用户口述】" in out
assert "口述A" in out
assert "摘录B" in out
assert "非本段口述" in out
def test_should_fallback_to_transcript_short_md(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(sps.settings, "memoir_narrative_fallback_body_ratio", 0.5)
monkeypatch.setattr(sps.settings, "memoir_narrative_fallback_min_chars", 20)
oral = "我一九九九年出生在上海,后来全家搬到苏州生活了好几年。"
assert sps._should_fallback_to_transcript("1999", oral) is True
assert sps._should_fallback_to_transcript(oral, oral) is False
def test_apply_narrative_fallbacks_json_too_short_returns_oral(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(sps.settings, "memoir_narrative_fallback_body_ratio", 0.5)
monkeypatch.setattr(sps.settings, "memoir_narrative_fallback_min_chars", 20)
oral = "我1999年出生在上海小学时爷爷常带我去河边散步。"
raw = '{"paragraphs": [{"content": "1999"}]}'
out = sps._apply_narrative_fallbacks(
raw,
oral,
existing_for_narrative="",
existing_chapter_md="",
chapter_category="childhood",
)
assert out.strip() == oral
def test_memoir_image_settings_min_body_field() -> None:
from app.features.memoir.memoir_images.settings import MemoirImageSettings
cfg = MemoirImageSettings(story_image_min_body_chars=799)
assert cfg.story_image_min_body_chars == 799

View File

@@ -3,6 +3,7 @@ import { useLocalSearchParams } from 'expo-router';
import { Mic, Pause, Play, PlusCircle, Type, X } from 'lucide-react-native';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type {
LayoutChangeEvent,
NativeSyntheticEvent,
TextInputContentSizeChangeEventData,
} from 'react-native';
@@ -669,6 +670,8 @@ export default function ConversationScreen() {
const [isKeyboardVisible, setIsKeyboardVisible] = useState(false);
const [keyboardHeight, setKeyboardHeight] = useState(0);
const listRef = useRef<FlatList>(null);
/** 底部输入区(含连接提示 + 输入条)高度,用于多行输入增高时把列表滚到底,避免挡住最新消息 */
const composerBlockHeightRef = useRef<number | null>(null);
useEffect(() => {
const onShow = (e: { endCoordinates: { height: number } }) => {
@@ -707,6 +710,25 @@ export default function ConversationScreen() {
}
}, [startRecording, t]);
const scrollListToEndAfterComposerLayout = useCallback(() => {
InteractionManager.runAfterInteractions(() => {
requestAnimationFrame(() => {
listRef.current?.scrollToEnd({ animated: true });
});
});
}, []);
const onComposerBlockLayout = useCallback(
(e: LayoutChangeEvent) => {
const h = e.nativeEvent.layout.height;
const prev = composerBlockHeightRef.current;
if (prev !== null && Math.abs(h - prev) < 1) return;
composerBlockHeightRef.current = h;
scrollListToEndAfterComposerLayout();
},
[scrollListToEndAfterComposerLayout],
);
const handleSend = () => {
const text = input.trim();
if (!text) return;
@@ -822,6 +844,7 @@ export default function ConversationScreen() {
paddingBottom: composerZeroBottomInset ? 0 : insets.bottom,
},
]}
onLayout={onComposerBlockLayout}
>
{showConnectionNotice ? (
<View style={styles.connectionNotice}>