""" 故事提取器 - v4 五步流水线策略 第一步:代码预处理(识别老师→过滤杂音→时间范围截取→过滤短段落/非主讲述人) 第二步:判断故事 + 提取事实(短段落直接提取,长段落先判断再切片提取) 第三步:切片提取事实(按自然段切分,并行提取) 第四步:重组(纯代码,事件/情感/原话/人物去重) 第五步:生成最终故事(基于素材生成5部分结构化故事) """ import json import asyncio import logging from sqlalchemy import select from app.database import async_session from app.models.story import Story from app.models.transcript import Transcript from app.services.docx_parser import parse_transcript from app.services.preprocessor import Preprocessor from app.services.llm_client import llm_client from app.state import TaskState logger = logging.getLogger(__name__) # 并发控制 MAX_CONCURRENT_FILES = 3 MAX_CONCURRENT_SEGMENTS = 5 # 短段落阈值(字数):<=4000 字直接提取事实,>4000 字先判断是否有故事 SHORT_THRESHOLD = 4000 # 切片参数 CHUNK_MAX_CHARS = 3000 CHUNK_OVERLAP_PARAGRAPHS = 5 # ============================================================ # 主入口 # ============================================================ async def run_extraction(state: TaskState): """主提取流程:文件间串行(避免 API 限流),文件内各步骤并行""" state.is_running = True try: async with async_session() as db: result = await db.execute( select(Transcript).where(Transcript.status == "pending") ) transcripts = result.scalars().all() if not transcripts: state.update(status="completed", percent=100) return file_total = len(transcripts) state.update( status="running", percent=0, files_total=file_total, files_completed=0, stories_found=0, current_file="", action=f"共发现 {file_total} 份文字稿,开始处理...", phase="preprocess", phase_percent=0, phase_detail="准备中", ) total_stories = [0] # 文件间串行处理(避免 API 限流) for file_index, transcript in enumerate(transcripts): await _process_file(state, file_index, file_total, transcript, total_stories) state.update( status="completed", percent=100, files_completed=file_total, stories_found=total_stories[0], current_file="", action="全部处理完成!", phase="preprocess", phase_percent=100, phase_detail="全部完成", ) state.is_running = False except asyncio.CancelledError: state.update(status="stopped", action="提取已停止") state.is_running = False except Exception as e: logger.exception("Extraction failed") state.update(status="error", action=str(e)) state.is_running = False # ============================================================ # 单文件处理(五步流水线) # ============================================================ async def _process_file(state: TaskState, file_index: int, file_total: int, transcript: Transcript, total_stories: list): """处理单个文件 - 五步流水线""" try: async with async_session() as db: t = await db.get(Transcript, transcript.id) if not t: _increment_files_completed(state) return t.status = "processing" await db.commit() # 计算该文件在总进度中的权重区间 # 预处理 0-5%, 判断+提取 5-30%, 切片提取 30-55%, 重组 55-60%, 生成 60-95%, 完成 100% # 每个文件占 95/file_total 的权重 file_weight = 95.0 / max(file_total, 1) file_start_percent = file_index * file_weight def _file_percent(phase_start_ratio, phase_end_ratio, inner_ratio=1.0): """计算当前文件在总进度中的百分比""" phase_range = phase_end_ratio - phase_start_ratio return min(99, int(file_start_percent + file_weight * (phase_start_ratio + phase_range * inner_ratio))) state.update( current_file=t.filename, action="正在读取文件...", phase="preprocess", phase_percent=0, phase_detail="读取文件", ) # 解析 DOCX lines = await asyncio.to_thread(parse_transcript, t.file_path) if not lines: t.status = "completed" t.error_message = "文件为空" await db.commit() _increment_files_completed(state) return # ========== 第一步:代码预处理(纯代码,不用 LLM)========== state.update( current_file=t.filename, action="正在分析对话结构...", phase="preprocess", phase_percent=10, phase_detail="统计说话人", ) preprocessor = Preprocessor() filtered_lines = [p for p in lines if not preprocessor.is_management_line(p)] if not filtered_lines: t.status = "completed" t.error_message = "过滤后无有效对话内容" await db.commit() _increment_files_completed(state) return segments, teacher = _preprocess_segments(filtered_lines) logger.info(f"预处理完成: 识别老师={teacher}, 有效段落={len(segments)}") if not segments: t.status = "completed" t.error_message = "未发现学员对话段落" await db.commit() _increment_files_completed(state) return state.update( current_file=t.filename, action=f"发现 {len(segments)} 个学员段落,开始识别故事...", phase="preprocess", phase_percent=100, phase_detail="预处理完成", percent=_file_percent(0, 0.05), ) # ========== 第二步:判断故事 + 提取事实(并行)========== state.update( current_file=t.filename, action=f"正在判断 {len(segments)} 个段落是否包含故事...", phase="identify", phase_percent=0, phase_detail=f"准备处理 {len(segments)} 个段落", ) seg_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS) identify_results = [None] * len(segments) identify_errors = [] async def process_segment_for_identify(seg_idx, speaker, seg): """对单个段落进行判断+提取""" async with seg_semaphore: text = _segment_to_text(seg) text_len = len(text) # 更新进度 inner_pct = int((seg_idx + 1) / len(segments) * 100) state.update( phase="identify", phase_percent=inner_pct, phase_detail=f"正在处理段落 {seg_idx + 1}/{len(segments)}({speaker}, {text_len}字)", percent=_file_percent(0.05, 0.30, (seg_idx + 1) / len(segments)), ) if text_len <= SHORT_THRESHOLD: # 短段落:跳过判断,直接提取事实 logger.info(f" 段落 {seg_idx}: {speaker} ({text_len}字) - 短段落,直接提取事实") facts = await _extract_facts_from_chunk(text, "") if facts: hints = facts.get("story_hints", "") or f"{speaker}的故事" return (speaker, seg, hints, [facts]) else: logger.info(f" 段落 {seg_idx}: 提取失败,跳过") return None else: # 长段落:先判断是否有故事 is_story, hints = await _identify_story(text) logger.info(f" 段落 {seg_idx}: {speaker} ({text_len}字) - is_story={is_story}, hints={hints}") if is_story: return (speaker, seg, hints, None) # None 表示需要第三步切片提取 else: logger.info(f" 段落 {seg_idx}: 无故事,跳过") return None tasks = [ process_segment_for_identify(i, speaker, seg) for i, (speaker, seg) in enumerate(segments) ] results = await asyncio.gather(*tasks, return_exceptions=True) # 收集结果:分为两类 # - 直接提取到事实的(短段落) # - 需要切片提取的(长段落,有故事) all_facts = [] # 最终的 (speaker, seg, hints, facts_list) 列表 needs_chunking = [] # 需要进入第三步的 (speaker, seg, hints) for i, r in enumerate(results): if isinstance(r, Exception): error_str = str(r) logger.warning(f"段落 {i} 处理异常: {error_str}") identify_errors.append(error_str) if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower(): raise r continue if r is None: continue speaker, seg, hints, facts_list = r if facts_list is not None: # 短段落已直接提取到事实 all_facts.append((speaker, seg, hints, facts_list)) else: # 长段落有故事,需要切片提取 needs_chunking.append((speaker, seg, hints)) if not all_facts and not needs_chunking: t.status = "completed" t.error_message = "未发现包含个人故事的段落" await db.commit() _increment_files_completed(state) state.update( percent=_file_percent(0.05, 0.30, 1.0), action="未发现故事", ) return logger.info(f"第二步完成: 直接提取={len(all_facts)}, 需切片={len(needs_chunking)}") # 第二步完成时已知道发现了多少个故事,立即更新 discovered = len(all_facts) + len(needs_chunking) total_stories[0] += discovered state.update(stories_found=total_stories[0]) # ========== 第三步:切片提取事实(并行)========== if needs_chunking: state.update( current_file=t.filename, action=f"正在切片提取 {len(needs_chunking)} 个故事...", phase="chunk", phase_percent=0, phase_detail=f"准备切片 {len(needs_chunking)} 个故事段落", ) chunk_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS) async def process_chunked_story(story_idx, speaker, seg, hints): """对单个长段落进行切片提取""" async with chunk_semaphore: text = _segment_to_text(seg) chunks = _chunk_text_by_paragraphs(text, max_chars=CHUNK_MAX_CHARS, overlap_paragraphs=CHUNK_OVERLAP_PARAGRAPHS) logger.info(f" 故事 {story_idx}: {speaker} - 切成 {len(chunks)} 块") facts_list = [] chunk_facts = await asyncio.gather( *[_extract_facts_from_chunk(chunk, hints) for chunk in chunks], return_exceptions=True, ) for j, facts in enumerate(chunk_facts): inner_pct = int((story_idx * 10 + j + 1) / (len(needs_chunking) * 10) * 100) state.update( phase="chunk", phase_percent=inner_pct, phase_detail=f"正在切片提取 故事{story_idx + 1}/{len(needs_chunking)} 块{j + 1}/{len(chunks)}", percent=_file_percent(0.30, 0.55, (story_idx + j / max(len(chunks), 1)) / len(needs_chunking)), ) if isinstance(facts, Exception): logger.warning(f" 块{j}提取异常: {facts}") continue if facts: facts_list.append(facts) return (speaker, seg, hints, facts_list) chunk_tasks = [ process_chunked_story(i, speaker, seg, hints) for i, (speaker, seg, hints) in enumerate(needs_chunking) ] chunk_results = await asyncio.gather(*chunk_tasks, return_exceptions=True) for r in chunk_results: if isinstance(r, Exception): error_str = str(r) logger.warning(f"切片提取异常: {error_str}") if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower(): raise r continue if r is not None: all_facts.append(r) logger.info(f"第三步完成: 总共 {len(all_facts)} 个故事段落完成事实提取") state.update( current_file=t.filename, action="切片提取完成,正在重组素材...", phase="chunk", phase_percent=100, phase_detail="切片提取完成", percent=_file_percent(0.30, 0.55, 1.0), ) # ========== 第四步:重组(纯代码,无需 LLM)========== state.update( current_file=t.filename, action="正在重组素材...", phase="merge", phase_percent=50, phase_detail="去重合并素材", percent=_file_percent(0.55, 0.60, 0.5), ) merged = _merge_facts(all_facts) logger.info(f"第四步完成: 重组出 {len(merged)} 个故事素材") if not merged: t.status = "completed" t.error_message = "素材重组后为空" await db.commit() _increment_files_completed(state) return state.update( current_file=t.filename, action=f"重组完成,正在生成 {len(merged)} 个故事...", phase="merge", phase_percent=100, phase_detail="重组完成", percent=_file_percent(0.55, 0.60, 1.0), ) # ========== 第五步:生成最终故事(并行)========== state.update( current_file=t.filename, action=f"正在生成 {len(merged)} 个故事...", phase="generate", phase_percent=0, phase_detail=f"准备生成 {len(merged)} 个故事", percent=_file_percent(0.60, 0.95, 0), ) gen_semaphore = asyncio.Semaphore(MAX_CONCURRENT_SEGMENTS) async def generate_one_story(story_idx, speaker, seg, material): """生成单个故事并保存到数据库""" async with gen_semaphore: inner_pct = int((story_idx + 1) / len(merged) * 100) state.update( phase="generate", phase_percent=inner_pct, phase_detail=f"正在生成故事 {story_idx + 1}/{len(merged)}({speaker})", percent=_file_percent(0.60, 0.95, (story_idx + 1) / len(merged)), ) story_data = await _generate_story(material) if not story_data: logger.warning(f" 故事 {story_idx}: 生成失败") return None # 保存到数据库 raw_content = story_data.get("content", {}) if isinstance(raw_content, dict): content_str = _format_story_content(raw_content) else: content_str = str(raw_content) story = Story( title=story_data.get("title", "未命名故事"), summary=story_data.get("summary", ""), tags=json.dumps(story_data.get("tags", []), ensure_ascii=False), content=content_str, raw_material=json.dumps(material, ensure_ascii=False), speaker_nickname=story_data.get("speaker_nickname", ""), source_transcript_id=t.id, source_lines=json.dumps([l.get("index", 0) for l in seg if isinstance(l, dict)]), duration_minutes=_estimate_duration(seg), confidence=story_data.get("confidence", 0.7), confidence_level=_confidence_level(story_data.get("confidence", 0.7)), ) db.add(story) await db.commit() state.update( action=f"生成第 {total_stories[0]} 个故事:{story.title}", ) logger.info(f" 故事 {story_idx}: 保存成功 - {story.title}") return story gen_tasks = [ generate_one_story(i, speaker, seg, material) for i, (speaker, seg, material) in enumerate(merged) ] gen_results = await asyncio.gather(*gen_tasks, return_exceptions=True) for r in gen_results: if isinstance(r, Exception): error_str = str(r) logger.warning(f"故事生成异常: {error_str}") if "529" in error_str or "overloaded" in error_str.lower() or "timed out" in error_str.lower(): raise r t.status = "completed" await db.commit() _increment_files_completed(state) state.update( current_file=t.filename, action=f"文件处理完成,共生成 {total_stories[0]} 个故事", phase="generate", phase_percent=100, phase_detail="文件处理完成", percent=_file_percent(0.60, 0.95, 1.0), ) except Exception as e: error_str = str(e) logger.exception(f"Error processing file {transcript.filename}") try: async with async_session() as db: t = await db.get(Transcript, transcript.id) if t: t.status = "error" t.error_message = error_str await db.commit() except Exception: pass _increment_files_completed(state) if "529" in error_str or "overloaded" in error_str.lower(): state.update(status="error", error="AI 服务暂时过载(529),请稍后再试") elif "timed out" in error_str.lower(): state.update(status="error", error="AI 服务请求超时,请稍后再试") else: state.update(status="error", error=f"处理文件时出错:{error_str[:200]}") # ============================================================ # 第一步:代码预处理 # ============================================================ def _preprocess_segments(lines: list[dict]) -> tuple[list[tuple[str, list[dict]]], str]: """ 代码预处理(v3:时间范围截取,允许重叠)。 识别老师 → 过滤杂音(<20句)→ 按时间范围截取 → 过滤短段落(<15句/<80字)→ 过滤非主讲述人(占比<10%) 返回: (segments, teacher) segments: [(speaker, segment_lines), ...] teacher: 老师名称 """ # 1. 统计每个说话人 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 # 2. 识别老师(发言最多的人) teacher = max(speaker_stats, key=lambda s: speaker_stats[s]["lines"]) logger.info(f"识别老师: {teacher} ({speaker_stats[teacher]['lines']}行, {speaker_stats[teacher]['chars']}字)") # 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: logger.info(f"杂音过滤: {', '.join(noise_speakers)}") logger.info(f"有效学员: {students}") # 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] segments.append((speaker, seg)) # 5. 过滤:短段落 + 当事人占比过低(非主讲述人) final_segments = [] for speaker, seg in segments: student_lines = [l for l in seg if l.get("speaker", "") == speaker] if len(student_lines) < 15: logger.info(f"过滤: {speaker} ({len(student_lines)}句<15)") continue student_chars = sum(len(l.get("content", "")) for l in student_lines) if student_chars < 80: logger.info(f"过滤: {speaker} ({student_chars}字<80)") continue # 当事人说话占比 < 10% → 非主讲述人,丢弃 ratio = len(student_lines) / len(seg) if seg else 0 if ratio < 0.1: logger.info(f"过滤: {speaker} (占比{ratio:.1%}<10%)") continue final_segments.append((speaker, seg)) logger.info(f"最终: {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 logger.info(f" 段落{i}: {speaker}, 截取{len(seg)}行, 学员{len(student_lines)}句/{student_chars}字, 占比{ratio:.1%}") return final_segments, teacher # ============================================================ # 第二步:判断故事 # ============================================================ async def _identify_story(text: str) -> tuple[bool, str]: """判断对话段落是否包含个人故事""" prompt = """判断这段对话是否包含学员的个人故事。直接输出JSON,不要分析。 个人故事特征(满足至少2条):学员讲述具体经历、包含时间地点人物细节、表达深层情感、老师深入引导疗愈、揭示心理或家庭问题。 非故事内容:纯课堂讲解、简单问答寒暄、课堂管理、纯疗愈引导词。 直接输出JSON: {"is_story": true或false, "confidence": 0.0到1.0, "story_hints": "一句话描述故事主题和讲述者"} 对话内容: ``` """ + text[:4000] + """ ```""" try: result = await llm_client.chat_json([{"role": "user", "content": prompt}], temperature=0.2) if result: is_story = result.get("is_story", False) confidence = result.get("confidence", 0) hints = result.get("story_hints", "") logger.info(f"Story identification: is_story={is_story}, confidence={confidence}, hints={hints[:80]}") return bool(is_story) and confidence >= 0.5, hints except Exception as e: logger.warning(f"Story identification error: {e}") if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower(): raise return False, "" # ============================================================ # 第三步:切片提取事实 # ============================================================ def _chunk_text_by_paragraphs(text: str, max_chars: int = 3000, overlap_paragraphs: int = 5) -> list[str]: """按自然段切分,在 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: str, hints: str) -> dict | None: """从对话切片中提取关键信息(事件、情感、原话、人物)""" prompt = f"""从这段对话中提取关键信息。只输出JSON。 故事主题:{hints} 提取以下内容: - events: 发生的事件(尽量详细,保留具体细节) - emotions: 表达的情感 - quotes: 讲述者的原话(保留原话,不要改写,尽量多提取;完全相同的重复语句只保留一条) - key_people: 提到的人物 {{"events":["事件1","事件2"],"emotions":["情感1","情感2"],"quotes":["原话1","原话2","原话3"],"key_people":["人物1"]}} 对话内容: ``` {text} ```""" try: result = await llm_client.chat_json([{"role": "user", "content": prompt}], temperature=0.1) if not result: return None # chat_json 可能返回 list(解析错误),需要转为 dict if isinstance(result, list): for item in result: if isinstance(item, dict) and ("events" in item or "quotes" in item): result = item break else: logger.warning(f"chat_json returned list, cannot convert to dict: {result[:3]}") return None return result except Exception as e: logger.warning(f"Extract facts error: {e}") if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower(): raise return None # ============================================================ # 第四步:重组(纯代码,无需 LLM) # ============================================================ 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: """简单的字符级相似度(Jaccard on character bigrams)""" 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: logger.info(f" 模糊去重: {len(quotes)} -> {len(kept)} (去除 {removed} 条相似重复)") return kept def _merge_facts(all_facts: list) -> list[tuple[str, list, dict]]: """ 重组素材(Reduce)。 对每个故事段落的所有切片事实进行合并去重: - 事件按原文顺序保留 - 情感去重 - 原话去重(不限制数量) - 人物去重 输入: [(speaker, seg, hints, facts_list), ...] 输出: [(speaker, seg, material), ...] material: {"events": [...], "emotions": [...], "quotes": [...], "key_people": [...], "story_hints": "..."} """ merged = [] for i, (speaker, seg, hints, facts_list) in enumerate(all_facts): 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_quotes = set() unique_quotes = [q for q in all_quotes if q not in seen_quotes and not seen_quotes.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, } logger.info(f" 重组故事 {i}: {speaker} - 事件:{len(all_events)}, 情感:{len(all_emotions)}, " f"原话:{len(unique_quotes)}, 人物:{len(all_people)}") merged.append((speaker, seg, material)) return merged # ============================================================ # 第五步:生成最终故事 # ============================================================ async def _generate_story(material: dict) -> dict | None: """基于素材生成第三人称故事(5部分结构化输出)""" 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: result = await llm_client.chat_json( [{"role": "user", "content": prompt}], temperature=0.7, ) if not result: return None # chat_json 可能返回 list(解析错误),需要转为 dict if isinstance(result, list): for item in result: if isinstance(item, dict) and "title" in item: result = item break else: logger.warning(f"chat_json returned list, cannot convert to dict: {result[:3]}") return None # 解析 content 纯文本为结构化字段 raw_content = result.get("content", "") if isinstance(raw_content, str) and "|||" in raw_content: parts = raw_content.split("|||") content_dict = {} keys = ["讲述者", "当下的困境", "过往的故事", "转变", "原文金句"] for idx, key in enumerate(keys): content_dict[key] = parts[idx].strip() if idx < len(parts) else "" result["content"] = content_dict elif isinstance(raw_content, dict): for key in ["讲述者", "当下的困境", "过往的故事", "转变", "原文金句"]: if key not in raw_content: raw_content[key] = "" result["content"] = raw_content else: result["content"] = {"完整故事": str(raw_content)} if not result.get("tags"): result["tags"] = [] return result except Exception as e: logger.warning(f"Generate story error: {e}") if "529" in str(e) or "overloaded" in str(e).lower() or "timed out" in str(e).lower(): raise return None # ============================================================ # 工具函数 # ============================================================ def _segment_to_text(segment_lines: list) -> str: """将段落行列表转为对话文本""" parts = [] for line in segment_lines: speaker = line.get("speaker", "") content = line.get("content", "") if speaker: parts.append(f"{speaker}: {content}") else: parts.append(content) return "\n".join(parts) def _increment_files_completed(state: TaskState): """增加已完成文件计数""" p = state.progress files_completed = p.get("files_completed", 0) + 1 files_total = p.get("files_total", 1) state.update(files_completed=files_completed) def _estimate_duration(segment_lines: list) -> float: """根据时间戳估算故事时长(分钟)""" timestamps = [] for line in segment_lines: if isinstance(line, dict) and line.get("timestamp"): try: parts = line["timestamp"].split(":") if len(parts) >= 2: minutes = int(parts[-2]) + int(parts[-1]) / 60 if len(parts) == 3: minutes += int(parts[0]) * 60 timestamps.append(minutes) except (ValueError, IndexError): continue if len(timestamps) >= 2: return round(max(timestamps) - min(timestamps), 1) return 0 def _confidence_level(confidence: float) -> str: if confidence >= 0.8: return "high" elif confidence >= 0.6: return "medium" return "low" def _format_story_content(content_dict: dict) -> str: """将 LLM 返回的结构化故事内容转为可读的 Markdown 格式""" parts = [] # 按顺序输出各个章节 sections = ["讲述者", "当下的困境", "过往的故事", "转变"] for section_name in sections: if content_dict.get(section_name): parts.append(f"## {section_name}\n\n{content_dict[section_name]}") # 原文金句 quotes = content_dict.get("原文金句", []) if not quotes: quotes = content_dict.get("金句摘录", []) if quotes and isinstance(quotes, list): parts.append("## 原文金句") for q in quotes: parts.append(f"\n> {q}") # 兼容旧格式:故事梗概 if content_dict.get("故事梗概") and "讲述者" not in content_dict: parts.insert(0, f"## 故事梗概\n\n{content_dict['故事梗概']}") # 兼容旧格式:关键要素 elements = content_dict.get("关键要素", []) if elements and isinstance(elements, list) and "讲述者" not in content_dict: parts.append("## 关键要素") for elem in elements: if isinstance(elem, dict): title = elem.get("标题", "") desc = elem.get("描述", "") parts.append(f"\n### {title}\n\n{desc}") # 如果上面都没匹配到,按通用 key-value 格式输出 if not parts: for key, value in content_dict.items(): if isinstance(value, str): parts.append(f"## {key}\n\n{value}") elif isinstance(value, list): parts.append(f"## {key}") for item in value: if isinstance(item, dict): for k, v in item.items(): parts.append(f"\n**{k}**:{v}") else: parts.append(f"\n- {item}") return "\n\n".join(parts) if parts else json.dumps(content_dict, ensure_ascii=False)