init
This commit is contained in:
733
test_pipeline.py
Normal file
733
test_pipeline.py
Normal file
@@ -0,0 +1,733 @@
|
||||
"""
|
||||
故事提取流程 v4 - 逐步测试脚本
|
||||
用法:python test_pipeline.py <docx文件路径> [步骤编号]
|
||||
步骤编号:1=预处理, 2=判断故事, 3=切片提取, 4=重组, 5=生成故事, all=全部执行
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "backend"))
|
||||
|
||||
from app.services.docx_parser import parse_transcript
|
||||
from app.services.llm_client import llm_client, _parse_json
|
||||
|
||||
TEST_DIR = os.path.join(os.path.dirname(__file__), "backend", "uploads", "test")
|
||||
|
||||
|
||||
def _save_json(filepath, data):
|
||||
"""保存 JSON 到文件"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
|
||||
def _save_text(filepath, text):
|
||||
"""保存文本到文件"""
|
||||
os.makedirs(os.path.dirname(filepath), exist_ok=True)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第一步:代码预处理(v3:时间范围截取,允许重叠)
|
||||
# ============================================================
|
||||
def step1_preprocess(lines, output_dir, save=True):
|
||||
"""代码预处理:识别老师 → 过滤杂音 → 按时间范围截取每个学员的全部内容"""
|
||||
print("\n" + "=" * 60)
|
||||
print("第一步:代码预处理(v3:时间范围截取)")
|
||||
print("=" * 60)
|
||||
|
||||
# 1.1 统计每个说话人
|
||||
print(f"总行数: {len(lines)}")
|
||||
speaker_stats = {}
|
||||
for i, line in enumerate(lines):
|
||||
speaker = line.get("speaker", "")
|
||||
if not speaker:
|
||||
continue
|
||||
if speaker not in speaker_stats:
|
||||
speaker_stats[speaker] = {"lines": 0, "chars": 0, "first": i, "last": i}
|
||||
speaker_stats[speaker]["lines"] += 1
|
||||
speaker_stats[speaker]["chars"] += len(line.get("content", ""))
|
||||
speaker_stats[speaker]["last"] = i
|
||||
|
||||
print(f"\n说话人统计:")
|
||||
for s, stats in sorted(speaker_stats.items(), key=lambda x: -x[1]["lines"]):
|
||||
print(f" {s}: {stats['lines']}行, {stats['chars']}字, 行范围[{stats['first']}-{stats['last']}]")
|
||||
|
||||
# 1.2 识别老师(发言最多的人)
|
||||
teacher = max(speaker_stats, key=lambda s: speaker_stats[s]["lines"])
|
||||
print(f"\n识别老师: {teacher} ({speaker_stats[teacher]['lines']}行)")
|
||||
|
||||
# 1.3 过滤杂音(< 20句的说话人)+ 排除老师
|
||||
students = []
|
||||
noise_speakers = []
|
||||
for s, stats in speaker_stats.items():
|
||||
if s == teacher:
|
||||
continue
|
||||
if stats["lines"] < 20:
|
||||
noise_speakers.append(f"{s} ({stats['lines']}句)")
|
||||
continue
|
||||
students.append(s)
|
||||
|
||||
if noise_speakers:
|
||||
print(f"\n杂音过滤: {', '.join(noise_speakers)}")
|
||||
print(f"\n有效学员: {students}")
|
||||
|
||||
# 1.4 按时间范围截取:每个学员从第一句到最后一句,中间所有行全部截取
|
||||
segments = []
|
||||
for speaker in students:
|
||||
stats = speaker_stats[speaker]
|
||||
first_idx = stats["first"]
|
||||
last_idx = stats["last"]
|
||||
seg = lines[first_idx:last_idx + 1]
|
||||
|
||||
# 统计该学员在此范围内的发言
|
||||
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
|
||||
student_chars = sum(len(l.get("content", "")) for l in student_lines)
|
||||
|
||||
segments.append((speaker, seg))
|
||||
print(f" {speaker}: 截取行[{first_idx}-{last_idx}], 共{len(seg)}行, 学员{len(student_lines)}句/{student_chars}字")
|
||||
|
||||
# 1.5 过滤:短段落 + 当事人占比过低(非主讲述人)
|
||||
final_segments = []
|
||||
filtered = []
|
||||
for speaker, seg in segments:
|
||||
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
|
||||
if len(student_lines) < 15:
|
||||
filtered.append(f"{speaker} ({len(student_lines)}句<15)")
|
||||
continue
|
||||
student_chars = sum(len(l.get("content", "")) for l in student_lines)
|
||||
if student_chars < 80:
|
||||
filtered.append(f"{speaker} ({student_chars}字<80)")
|
||||
continue
|
||||
# 当事人说话占比 < 1/10 → 非主讲述人,丢弃
|
||||
ratio = len(student_lines) / len(seg) if seg else 0
|
||||
if ratio < 0.1:
|
||||
filtered.append(f"{speaker} (占比{ratio:.1%}<10%)")
|
||||
continue
|
||||
final_segments.append((speaker, seg))
|
||||
|
||||
if filtered:
|
||||
print(f"\n过滤: {', '.join(filtered)}")
|
||||
|
||||
print(f"\n最终: {len(final_segments)} 个学员段落")
|
||||
for i, (speaker, seg) in enumerate(final_segments):
|
||||
student_lines = [l for l in seg if l.get("speaker", "") == speaker]
|
||||
student_chars = sum(len(l.get("content", "")) for l in student_lines)
|
||||
ratio = len(student_lines) / len(seg) if seg else 0
|
||||
print(f" 段落{i}: {speaker}, 截取{len(seg)}行, 学员{len(student_lines)}句/{student_chars}字, 占比{ratio:.1%}")
|
||||
|
||||
# 保存结果
|
||||
if save:
|
||||
step1_dir = os.path.join(output_dir, "step1_预处理")
|
||||
# 保存每个学员段落
|
||||
for i, (speaker, seg) in enumerate(final_segments):
|
||||
text = _segment_to_text(seg)
|
||||
safe_name = speaker.replace("/", "_")
|
||||
_save_text(os.path.join(step1_dir, f"segment_{i}_{safe_name}.txt"), text)
|
||||
print(f" 保存: segment_{i}_{safe_name}.txt ({len(text)}字)")
|
||||
# 保存统计摘要
|
||||
summary = {
|
||||
"total_lines": len(lines),
|
||||
"teacher": teacher,
|
||||
"noise_filtered": noise_speakers,
|
||||
"final_filtered": filtered,
|
||||
"segments": [
|
||||
{
|
||||
"speaker": s,
|
||||
"total_lines": len(seg),
|
||||
"student_lines": len([l for l in seg if l.get("speaker", "") == s]),
|
||||
"student_chars": sum(len(l.get("content", "")) for l in seg if l.get("speaker", "") == s),
|
||||
"ratio": round(len([l for l in seg if l.get("speaker", "") == s]) / len(seg), 3) if seg else 0,
|
||||
}
|
||||
for s, seg in final_segments
|
||||
]
|
||||
}
|
||||
_save_json(os.path.join(step1_dir, "summary.json"), summary)
|
||||
print(f" 保存: summary.json")
|
||||
|
||||
return final_segments, teacher
|
||||
|
||||
|
||||
def _get_main_speaker(student_lines):
|
||||
if not student_lines:
|
||||
return "unknown"
|
||||
counts = {}
|
||||
for l in student_lines:
|
||||
s = l.get("speaker", "")
|
||||
counts[s] = counts.get(s, 0) + 1
|
||||
return max(counts, key=counts.get)
|
||||
|
||||
|
||||
def _segment_to_text(seg):
|
||||
parts = []
|
||||
for line in seg:
|
||||
speaker = line.get("speaker", "")
|
||||
content = line.get("content", "")
|
||||
if speaker:
|
||||
parts.append(f"{speaker}: {content}")
|
||||
else:
|
||||
parts.append(content)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第二步:判断是否包含故事 + 提取事实(短段落合并处理)
|
||||
# ============================================================
|
||||
async def step2_identify(segments, output_dir, save=True):
|
||||
"""短段落直接提取事实,长段落先判断是否有故事再提取"""
|
||||
print("\n" + "=" * 60)
|
||||
print("第二步:判断故事 + 提取事实")
|
||||
print("=" * 60)
|
||||
|
||||
SHORT_THRESHOLD = 4000 # 短段落阈值(字数)
|
||||
all_facts = []
|
||||
step2_results = [] # 记录每个段落的处理结果
|
||||
step2_t0 = time.time()
|
||||
|
||||
# 构建所有段落的异步任务,并行执行
|
||||
async def process_segment(i, speaker, seg):
|
||||
text = _segment_to_text(seg)
|
||||
safe_name = speaker.replace("/", "_")
|
||||
prefix = f"seg{i}_{safe_name}_"
|
||||
print(f"\n--- 段落 {i}: {speaker} ({len(text)} 字) ---")
|
||||
|
||||
result_record = {
|
||||
"index": i,
|
||||
"speaker": speaker,
|
||||
"text_length": len(text),
|
||||
"is_short": len(text) <= SHORT_THRESHOLD,
|
||||
}
|
||||
|
||||
if len(text) <= SHORT_THRESHOLD:
|
||||
print(f" 短段落,跳过判断,直接提取事实")
|
||||
facts = await _extract_facts_from_chunk(text, "", output_dir, prefix)
|
||||
if facts:
|
||||
hints = facts.get("story_hints", "")
|
||||
if not hints:
|
||||
hints = f"{speaker}的故事"
|
||||
print(f" 提取完成: hints={hints}")
|
||||
result_record["status"] = "extracted"
|
||||
result_record["hints"] = hints
|
||||
return (speaker, seg, hints, [facts]), result_record
|
||||
else:
|
||||
print(f" 提取失败,跳过")
|
||||
result_record["status"] = "extract_failed"
|
||||
return None, result_record
|
||||
else:
|
||||
is_story, hints = await _identify_story(text, output_dir, prefix)
|
||||
print(f" 判断结果: is_story={is_story}, hints={hints}")
|
||||
result_record["is_story"] = is_story
|
||||
result_record["hints"] = hints
|
||||
|
||||
if is_story:
|
||||
story_segments_for_step3 = [(speaker, seg, hints)]
|
||||
step3_result = await step3_extract_facts(story_segments_for_step3, output_dir, save)
|
||||
result_record["status"] = "has_story"
|
||||
return step3_result, result_record
|
||||
else:
|
||||
print(f" 无故事,跳过")
|
||||
result_record["status"] = "no_story"
|
||||
return None, result_record
|
||||
|
||||
tasks = [process_segment(i, speaker, seg) for i, (speaker, seg) in enumerate(segments)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
step2_elapsed = round(time.time() - step2_t0, 2)
|
||||
print(f"\n⏱ 第二步总耗时: {step2_elapsed}s")
|
||||
|
||||
# 收集结果
|
||||
for i, (r, record) in enumerate(results):
|
||||
if r is not None:
|
||||
if isinstance(r, list):
|
||||
all_facts.extend(r)
|
||||
else:
|
||||
all_facts.append(r)
|
||||
step2_results.append(record)
|
||||
|
||||
print(f"\n共 {len(all_facts)} 个故事段落完成事实提取")
|
||||
|
||||
# 保存结果
|
||||
if save:
|
||||
step2_dir = os.path.join(output_dir, "step2_判断提取")
|
||||
_save_json(os.path.join(step2_dir, "results.json"), step2_results)
|
||||
print(f" 保存: results.json")
|
||||
|
||||
return all_facts
|
||||
|
||||
|
||||
async def _identify_story(text, output_dir=None, log_prefix="", do_save=True):
|
||||
prompt = """判断这段对话是否包含学员的个人故事。直接输出JSON,不要分析。
|
||||
|
||||
个人故事特征(满足至少2条):学员讲述具体经历、包含时间地点人物细节、表达深层情感、老师深入引导疗愈、揭示心理或家庭问题。
|
||||
|
||||
非故事内容:纯课堂讲解、简单问答寒暄、课堂管理、纯疗愈引导词。
|
||||
|
||||
直接输出JSON:
|
||||
{"is_story": true或false, "confidence": 0.0到1.0, "story_hints": "一句话描述故事主题和讲述者"}
|
||||
|
||||
对话内容:
|
||||
```
|
||||
""" + text[:4000] + """
|
||||
```"""
|
||||
|
||||
try:
|
||||
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.2)
|
||||
api_log = {
|
||||
"type": "identify_story",
|
||||
"prompt": prompt,
|
||||
"content": detail.get("content", ""),
|
||||
"reasoning_content": detail.get("reasoning_content", ""),
|
||||
"finish_reason": detail.get("finish_reason", ""),
|
||||
"usage": detail.get("usage", {}),
|
||||
"elapsed": detail.get("elapsed", 0),
|
||||
}
|
||||
print(f" {log_prefix}API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
|
||||
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
|
||||
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
|
||||
if output_dir and do_save:
|
||||
_save_json(os.path.join(output_dir, "api_logs", f"{log_prefix}identify.json"), api_log)
|
||||
result = _parse_json(detail["content"])
|
||||
if result:
|
||||
return bool(result.get("is_story", False)) and result.get("confidence", 0) >= 0.5, result.get("story_hints", "")
|
||||
except Exception as e:
|
||||
print(f" {log_prefix}错误: {e}")
|
||||
return False, ""
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第三步:切片提取事实(Map)
|
||||
# ============================================================
|
||||
async def step3_extract_facts(story_segments, output_dir, save=True):
|
||||
print("\n" + "=" * 60)
|
||||
print("第三步:切片提取事实(Map)")
|
||||
print("=" * 60)
|
||||
|
||||
all_facts = []
|
||||
step3_t0 = time.time()
|
||||
for i, (speaker, seg, hints) in enumerate(story_segments):
|
||||
text = _segment_to_text(seg)
|
||||
safe_name = speaker.replace("/", "_")
|
||||
print(f"\n--- 故事 {i}: {speaker} - {hints[:50]}... ({len(text)} 字) ---")
|
||||
|
||||
chunks = _chunk_text_by_paragraphs(text, max_chars=3000, overlap_paragraphs=5)
|
||||
print(f" 切成 {len(chunks)} 块")
|
||||
|
||||
# 保存切片信息
|
||||
chunk_records = []
|
||||
for j, chunk in enumerate(chunks):
|
||||
chunk_records.append({"chunk_index": j, "chars": len(chunk), "preview": chunk[:100] + "..."})
|
||||
|
||||
tasks = [_extract_facts_from_chunk(chunk, hints, output_dir, f"story{i}_{safe_name}_chunk{j}_") for j, chunk in enumerate(chunks)]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
facts = []
|
||||
for j, r in enumerate(results):
|
||||
if isinstance(r, Exception):
|
||||
print(f" 块{j} 错误: {r}")
|
||||
chunk_records[j]["status"] = "error"
|
||||
chunk_records[j]["error"] = str(r)
|
||||
else:
|
||||
print(f" 块{j}: {json.dumps(r, ensure_ascii=False)[:200]}")
|
||||
facts.append(r)
|
||||
chunk_records[j]["status"] = "ok"
|
||||
chunk_records[j]["facts"] = r
|
||||
|
||||
all_facts.append((speaker, seg, hints, facts))
|
||||
|
||||
# 保存切片和提取结果
|
||||
if save:
|
||||
step3_dir = os.path.join(output_dir, "step3_切片提取", f"story_{i}_{safe_name}")
|
||||
for j, chunk in enumerate(chunks):
|
||||
_save_text(os.path.join(step3_dir, f"chunk_{j}.txt"), chunk)
|
||||
_save_json(os.path.join(step3_dir, "chunks_info.json"), chunk_records)
|
||||
print(f" 保存: {len(chunks)} 个切片文件 + chunks_info.json")
|
||||
|
||||
step3_elapsed = round(time.time() - step3_t0, 2)
|
||||
print(f"\n⏱ 第三步总耗时: {step3_elapsed}s")
|
||||
|
||||
return all_facts
|
||||
|
||||
|
||||
def _chunk_text_by_paragraphs(text, max_chars=3000, overlap_paragraphs=5):
|
||||
"""按自然段切分,在 max_chars 附近找段落结尾切,重叠 overlap_paragraphs 个自然段"""
|
||||
paragraphs = [p for p in text.split("\n") if p.strip()]
|
||||
if not paragraphs:
|
||||
return [text]
|
||||
if len(text) <= max_chars:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
i = 0
|
||||
while i < len(paragraphs):
|
||||
# 从当前位置开始,累加段落直到超过 max_chars
|
||||
current = []
|
||||
current_len = 0
|
||||
j = i
|
||||
while j < len(paragraphs):
|
||||
para = paragraphs[j]
|
||||
# 超过 max_chars 且已有内容 → 在这个段落前切断(段落结尾切)
|
||||
if current_len + len(para) > max_chars and current:
|
||||
break
|
||||
current.append(para)
|
||||
current_len += len(para)
|
||||
j += 1
|
||||
|
||||
chunks.append("\n".join(current))
|
||||
|
||||
# 到末尾了就结束
|
||||
if j >= len(paragraphs):
|
||||
break
|
||||
|
||||
# 下一次起点:当前切到的位置 - 重叠段落数
|
||||
next_i = j - overlap_paragraphs
|
||||
# 防止死循环:确保至少前进1个段落
|
||||
if next_i <= i:
|
||||
next_i = i + 1
|
||||
i = next_i
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
async def _extract_facts_from_chunk(text, hints, output_dir=None, log_prefix="", do_save=True):
|
||||
prompt = f"""从这段对话中提取关键信息。只输出JSON。
|
||||
|
||||
故事主题:{hints}
|
||||
|
||||
提取以下内容:
|
||||
- events: 发生的事件(尽量详细,保留具体细节)
|
||||
- emotions: 表达的情感
|
||||
- quotes: 讲述者的原话(保留原话,不要改写,尽量多提取;完全相同的重复语句只保留一条)
|
||||
- key_people: 提到的人物
|
||||
|
||||
{{"events":["事件1","事件2"],"emotions":["情感1","情感2"],"quotes":["原话1","原话2","原话3"],"key_people":["人物1"]}}
|
||||
|
||||
对话内容:
|
||||
```
|
||||
{text}
|
||||
```"""
|
||||
|
||||
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.1)
|
||||
api_log = {
|
||||
"type": "extract_facts",
|
||||
"prompt": prompt,
|
||||
"content": detail.get("content", ""),
|
||||
"reasoning_content": detail.get("reasoning_content", ""),
|
||||
"finish_reason": detail.get("finish_reason", ""),
|
||||
"usage": detail.get("usage", {}),
|
||||
"elapsed": detail.get("elapsed", 0),
|
||||
}
|
||||
print(f" {log_prefix}API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
|
||||
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
|
||||
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
|
||||
if output_dir and do_save:
|
||||
_save_json(os.path.join(output_dir, "api_logs", f"{log_prefix}extract.json"), api_log)
|
||||
result = _parse_json(detail["content"])
|
||||
return result
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 模糊去重工具函数
|
||||
# ============================================================
|
||||
def _fuzzy_dedup_quotes(quotes: list[str]) -> list[str]:
|
||||
"""
|
||||
模糊去重:对高度相似的语句只保留第一条。
|
||||
策略:提取每条语句的核心部分(去掉常见前缀后缀),相似度超过阈值的视为重复。
|
||||
"""
|
||||
import re
|
||||
|
||||
def normalize(s: str) -> str:
|
||||
s = s.strip()
|
||||
if s.startswith("「") and s.endswith("」"):
|
||||
s = s[1:-1]
|
||||
for prefix in ["嗯", "啊", "呃", "就是", "然后", "那个", "所以"]:
|
||||
if s.startswith(prefix):
|
||||
s = s[len(prefix):].strip()
|
||||
for suffix in ["啊", "吧", "呢", "嘛", "哦", "呀"]:
|
||||
if s.endswith(suffix) and len(s) > 4:
|
||||
s = s[:-1]
|
||||
s = re.sub(r'(.)\1{2,}', r'\1', s)
|
||||
return s
|
||||
|
||||
def similarity(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
a_bigrams = set(a[i:i+2] for i in range(len(a)-1))
|
||||
b_bigrams = set(b[i:i+2] for i in range(len(b)-1))
|
||||
if not a_bigrams or not b_bigrams:
|
||||
return 0.0
|
||||
intersection = a_bigrams & b_bigrams
|
||||
union = a_bigrams | b_bigrams
|
||||
return len(intersection) / len(union)
|
||||
|
||||
kept = []
|
||||
kept_normalized = []
|
||||
for q in quotes:
|
||||
nq = normalize(q)
|
||||
is_dup = False
|
||||
for kn in kept_normalized:
|
||||
threshold = 0.8 if len(nq) < 15 else 0.7
|
||||
if similarity(nq, kn) >= threshold:
|
||||
is_dup = True
|
||||
break
|
||||
if not is_dup:
|
||||
kept.append(q)
|
||||
kept_normalized.append(nq)
|
||||
|
||||
removed = len(quotes) - len(kept)
|
||||
if removed > 0:
|
||||
print(f" 模糊去重: {len(quotes)} -> {len(kept)} (去除 {removed} 条相似重复)")
|
||||
|
||||
return kept
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第四步:重组(Reduce)
|
||||
# ============================================================
|
||||
def step4_merge(all_facts, output_dir, save=True):
|
||||
print("\n" + "=" * 60)
|
||||
print("第四步:重组(Reduce)")
|
||||
print("=" * 60)
|
||||
|
||||
merged = []
|
||||
for i, (speaker, seg, hints, facts_list) in enumerate(all_facts):
|
||||
print(f"\n--- 故事 {i}: {speaker} ---")
|
||||
|
||||
all_events, all_emotions, all_quotes, all_people = [], [], [], []
|
||||
for facts in facts_list:
|
||||
if isinstance(facts, dict):
|
||||
all_events.extend(facts.get("events", []))
|
||||
all_emotions.extend(facts.get("emotions", []))
|
||||
all_quotes.extend(facts.get("quotes", []))
|
||||
all_people.extend(facts.get("key_people", []))
|
||||
|
||||
all_emotions = list(dict.fromkeys(all_emotions))
|
||||
all_people = list(dict.fromkeys(all_people))
|
||||
seen = set()
|
||||
unique_quotes = [q for q in all_quotes if q not in seen and not seen.add(q)]
|
||||
# 模糊去重:去除相似重复语句(如多次释放语句只保留一条)
|
||||
unique_quotes = _fuzzy_dedup_quotes(unique_quotes)
|
||||
|
||||
material = {
|
||||
"events": all_events,
|
||||
"emotions": all_emotions,
|
||||
"quotes": unique_quotes,
|
||||
"key_people": all_people,
|
||||
"story_hints": hints,
|
||||
}
|
||||
|
||||
print(f" 事件: {len(all_events)}, 情感: {len(all_emotions)}, 原话: {len(unique_quotes)}, 人物: {len(all_people)}")
|
||||
print(f" 素材: {json.dumps(material, ensure_ascii=False)[:500]}")
|
||||
|
||||
merged.append((speaker, seg, material))
|
||||
|
||||
# 保存结果
|
||||
if save:
|
||||
step4_dir = os.path.join(output_dir, "step4_重组")
|
||||
for i, (speaker, seg, material) in enumerate(merged):
|
||||
safe_name = speaker.replace("/", "_")
|
||||
_save_json(os.path.join(step4_dir, f"material_{i}_{safe_name}.json"), material)
|
||||
print(f" 保存: material_{i}_{safe_name}.json")
|
||||
|
||||
return merged
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 第五步:生成最终故事
|
||||
# ============================================================
|
||||
async def step5_generate(merged, output_dir, save=True):
|
||||
print("\n" + "=" * 60)
|
||||
print("第五步:生成最终故事(并行)")
|
||||
print("=" * 60)
|
||||
|
||||
step5_t0 = time.time()
|
||||
|
||||
async def generate_one(i, speaker, seg, material):
|
||||
print(f"\n--- 生成故事 {i}: {speaker} ---")
|
||||
|
||||
prompt = f"""基于素材生成第三人称故事。只输出JSON,不要其他内容。
|
||||
|
||||
素材:
|
||||
主题:{material['story_hints']}
|
||||
事件:{json.dumps(material['events'], ensure_ascii=False)}
|
||||
情感:{json.dumps(material['emotions'], ensure_ascii=False)}
|
||||
原话:{json.dumps(material['quotes'], ensure_ascii=False)}
|
||||
人物:{json.dumps(material['key_people'], ensure_ascii=False)}
|
||||
|
||||
写作手法(非常重要):
|
||||
|
||||
【双时空结构】
|
||||
故事有两个时空层:
|
||||
- "当下时空":学员在课堂上与卢慧老师的对话——这是故事的框架,用对话体呈现,保留师生互动的完整脉络
|
||||
- "过往时空":学员讲述的过去经历——这是故事的核心,用"场景重建"手法呈现,让读者穿越到那个时空
|
||||
|
||||
【场景重建手法(用于"过往的故事"部分)】
|
||||
当学员讲述过去的经历时,不要写成"她说她小时候…"这种转述体,而是直接把读者拉进那个时空:
|
||||
- 直接切入场景,用过去时态叙述。比如不要写"文晔回忆说她的儿子15岁去了美国",而要写"十五岁那年,文晔的儿子独自登上了飞往美国的航班"
|
||||
- 用五感描写构建画面:她看到了什么、听到了什么、身体感受到了什么(比如"手心出汗"、"脚发凉"这些身体反应要融入场景中)
|
||||
- 用环境细节暗示情绪:不要直接说"她很痛苦",而是通过场景让读者感受到痛苦
|
||||
- 关键情感爆发处用直接引语(「」包裹原话),其他地方用间接引语和叙述自然融合
|
||||
- 场景之间要有过渡,比如"然而,命运的转折发生在前年冬天——"
|
||||
|
||||
【对话体手法(用于"当下的困境"和"转变"部分)】
|
||||
- 保留师生对话的现场感,写出谁说了什么、对方如何回应
|
||||
- 在对话中穿插学员的内心活动、身体反应、情绪变化
|
||||
- 老师的引导语要完整保留,这是疗愈过程的关键
|
||||
|
||||
【通用要求】
|
||||
- 每个事件都要展开写,写出具体过程和细节,不要一句话概括
|
||||
- 事件之间要有过渡和因果逻辑,不能跳跃
|
||||
- 情感变化要写出过程:从什么情感转变到什么情感,中间经历了什么
|
||||
- 不编造素材中没有的内容,但可以根据素材合理补充场景细节(如环境、氛围)
|
||||
- 每个部分充分展开,不要限制长度,越详细越好
|
||||
- 原文金句数组中的每条金句必须用英文双引号包裹,如 ["金句1", "金句2"],不要用「」替代双引号
|
||||
|
||||
禁止事项:
|
||||
- 禁止用一句话概括多个事件(如"她倾诉了挣扎"、"她经历了疗愈过程")
|
||||
- 禁止省略素材中的具体细节
|
||||
- 禁止省略老师的引导和回应
|
||||
- 禁止事件之间跳跃,要保持时间顺序和因果逻辑
|
||||
- 禁止过度总结或抽象化描述
|
||||
- 禁止在"过往的故事"部分全程使用转述体("她说…她回忆…"),必须用场景重建手法让读者身临其境
|
||||
- 禁止为了简洁而牺牲内容的丰富性
|
||||
|
||||
输出格式:{{"title":"标题","summary":"80字摘要","tags":["标签"],"speaker_nickname":"昵称","content":{{"讲述者":"...","当下的困境":"...","过往的故事":"...","转变":"...","原文金句":["原话1","原话2","原话3"]}}}}"""
|
||||
|
||||
try:
|
||||
detail = await llm_client.chat_detail([{"role": "user", "content": prompt}], temperature=0.7, max_tokens=16384)
|
||||
raw = detail["content"]
|
||||
api_log = {
|
||||
"type": "generate_story",
|
||||
"speaker": speaker,
|
||||
"prompt": prompt,
|
||||
"content": raw,
|
||||
"reasoning_content": detail.get("reasoning_content", ""),
|
||||
"finish_reason": detail.get("finish_reason", ""),
|
||||
"usage": detail.get("usage", {}),
|
||||
"elapsed": detail.get("elapsed", 0),
|
||||
}
|
||||
print(f" API耗时: {api_log['elapsed']}s, finish_reason={api_log['finish_reason']}, "
|
||||
f"tokens: {api_log['usage'].get('total_tokens', '?')} "
|
||||
f"(reasoning={api_log['usage'].get('reasoning_tokens', 0)})")
|
||||
print(f" 原始输出长度: {len(raw)} 字")
|
||||
if save:
|
||||
step5_dir = os.path.join(output_dir, "step5_生成故事")
|
||||
os.makedirs(step5_dir, exist_ok=True)
|
||||
_save_text(os.path.join(step5_dir, f"raw_{i}_{speaker}.txt"), raw)
|
||||
_save_json(os.path.join(output_dir, "api_logs", f"story{i}_{speaker}_generate.json"), api_log)
|
||||
result = _parse_json(raw)
|
||||
except Exception as e:
|
||||
print(f" 生成失败: {e}")
|
||||
result = None
|
||||
|
||||
if result:
|
||||
print(f" 标题: {result.get('title', '')}")
|
||||
print(f" 摘要: {result.get('summary', '')[:100]}")
|
||||
content = result.get('content', {})
|
||||
if isinstance(content, dict):
|
||||
for key, val in content.items():
|
||||
print(f" {key}: {str(val)[:80]}...")
|
||||
print(f"\n 完整JSON:")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(" 生成失败!")
|
||||
|
||||
return result
|
||||
|
||||
tasks = [generate_one(i, speaker, seg, material) for i, (speaker, seg, material) in enumerate(merged)]
|
||||
results = await asyncio.gather(*tasks)
|
||||
stories = [r for r in results if r is not None]
|
||||
|
||||
step5_elapsed = round(time.time() - step5_t0, 2)
|
||||
print(f"\n⏱ 第五步总耗时: {step5_elapsed}s")
|
||||
|
||||
# 保存结果
|
||||
if save:
|
||||
step5_dir = os.path.join(output_dir, "step5_生成故事")
|
||||
for i, story in enumerate(stories):
|
||||
safe_name = story.get("speaker_nickname", f"story_{i}").replace("/", "_")
|
||||
_save_json(os.path.join(step5_dir, f"story_{i}_{safe_name}.json"), story)
|
||||
print(f" 保存: story_{i}_{safe_name}.json")
|
||||
|
||||
return stories
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 主函数
|
||||
# ============================================================
|
||||
async def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("用法: python test_pipeline.py <docx文件路径> [步骤编号]")
|
||||
print(" 步骤编号: 1=预处理, 2=判断故事, 3=切片提取, 4=重组, 5=生成故事, all=全部")
|
||||
sys.exit(1)
|
||||
|
||||
file_path = sys.argv[1]
|
||||
step = sys.argv[2] if len(sys.argv) > 2 else "all"
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
print(f"文件不存在: {file_path}")
|
||||
sys.exit(1)
|
||||
|
||||
# 根据文件名创建输出目录
|
||||
basename = os.path.splitext(os.path.basename(file_path))[0]
|
||||
output_dir = os.path.join(TEST_DIR, basename)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
print(f"文件: {file_path}")
|
||||
print(f"输出目录: {output_dir}")
|
||||
|
||||
total_t0 = time.time()
|
||||
|
||||
lines = parse_transcript(file_path)
|
||||
print(f"解析到 {len(lines)} 行")
|
||||
|
||||
# 如果只跑第5步,从 step4 产物恢复
|
||||
if step == "5":
|
||||
step4_dir = os.path.join(output_dir, "step4_重组")
|
||||
if os.path.exists(step4_dir):
|
||||
print("从 step4 产物恢复...")
|
||||
merged = []
|
||||
for f in sorted(os.listdir(step4_dir)):
|
||||
if f.endswith(".json"):
|
||||
with open(os.path.join(step4_dir, f), "r", encoding="utf-8") as fp:
|
||||
material = json.load(fp)
|
||||
parts = f.replace("material_", "").replace(".json", "").split("_", 1)
|
||||
speaker = parts[1] if len(parts) > 1 else f
|
||||
merged.append((speaker, [], material))
|
||||
print(f"恢复到 {len(merged)} 个故事")
|
||||
stories = await step5_generate(merged, output_dir)
|
||||
|
||||
total_elapsed = round(time.time() - total_t0, 2)
|
||||
print(f"\n{'=' * 60}")
|
||||
print(f"✅ 全流程完成!总耗时: {total_elapsed}s")
|
||||
print(f" 生成故事: {len(stories)} 个")
|
||||
print(f"{'=' * 60}")
|
||||
return
|
||||
else:
|
||||
print("step4 产物不存在,请先跑前4步")
|
||||
return
|
||||
|
||||
segments, teacher = step1_preprocess(lines, output_dir)
|
||||
if step == "1":
|
||||
return
|
||||
|
||||
all_facts = await step2_identify(segments, output_dir)
|
||||
if step == "2":
|
||||
return
|
||||
|
||||
if not all_facts:
|
||||
print("没有发现故事段落,结束。")
|
||||
return
|
||||
|
||||
merged = step4_merge(all_facts, output_dir)
|
||||
if step == "4":
|
||||
return
|
||||
|
||||
stories = await step5_generate(merged, output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
Reference in New Issue
Block a user