修复环境变量,UI问题
This commit is contained in:
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user