Files
Story-extractor/test_pipeline.py
2026-04-24 16:02:16 +08:00

734 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
故事提取流程 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())