修复环境变量,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

@@ -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 []