修复环境变量,UI问题
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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 "",
|
||||
)
|
||||
|
||||
@@ -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。"""
|
||||
请只返回章节类别英文 key(如:childhood),不要返回其它说明。
|
||||
若内容不足以独立成篇、仅为零散信息,返回 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. 标题正文 **12–18 字**,必须概括 **用户口述或 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 []
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
)
|
||||
|
||||
@@ -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 "",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
42
api/tests/test_classification_fragment.py
Normal file
42
api/tests/test_classification_fragment.py
Normal 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"
|
||||
52
api/tests/test_narrative_pipeline.py
Normal file
52
api/tests/test_narrative_pipeline.py
Normal 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
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user