# 3-Stage IR Generation Pipeline # # Stage 1: Semantic Index — full document → function_units + concepts # Stage 2: Per-Unit IR Extraction — precision context → detailed IR rules # Stage 3: Deterministic Merge & Audit — dedup, assign IDs, coverage report import concurrent.futures import hashlib import json import logging import re import time import uuid from collections import defaultdict from datetime import datetime, timezone from pathlib import Path from server.config import settings from server.core.llm_provider.router import router logger = logging.getLogger(__name__) PROMPTS_DIR = Path(__file__).parent.parent / "prompts" # services/prompts/ MAX_CONCURRENCY = 5 PASS, WARN, FAIL = "[PASS]", "[WARN]", "[FAIL]" # ============================================================================= # Stage 1: Semantic Index Generation # ============================================================================= def format_document_for_prompt(doc: dict) -> str: """Render the full parsed document as a readable string for the LLM prompt.""" lines = ["=== SECTIONS ==="] for i, section in enumerate(doc.get("sections", [])): source = section.get("source", f"(无标题-章节{i})") lines.append(f"\n--- Section: {source} ---") for block in section.get("blocks", []): if block["type"] == "para": lines.append(f"[段落 {block['index']}] {block['text']}") elif block["type"] == "table": lines.append(f"[表格 {block.get('table', '?')}]") headers = block.get("headers", []) lines.append(f" 表头: {' | '.join(headers)}") for row in block.get("rows", []): cols = row.get("columns", []) cell_texts = [] for c in cols: cell_texts.append(f"[行{c.get('row','?')}]{c.get('name','')}: {c.get('text','')}") lines.append(f" {'; '.join(cell_texts)}") images = section.get("images", []) if images: lines.append(f" 图片引用: {', '.join(images)}") # Image Analysis lines.append("\n\n=== IMAGE_ANALYSIS ===") for img in doc.get("image_analysis", []): rid = img.get("rid", "?") img_type = img.get("type", "?") lines.append(f"\n--- Image: {rid} (type={img_type}) ---") lines.append(f" 描述: {img.get('description', '')[:500]}") # Resolved Conflicts conflicts = doc.get("resolved_conflicts", []) if conflicts: lines.append("\n\n=== RESOLVED_CONFLICTS ===") for c in conflicts: lines.append( f" [{c.get('conflict_type','?')}] {c.get('section','?')}: " f"以{c.get('source','?')}为准 — {c.get('correction','')}" ) return "\n".join(lines) def extract_json_from_response(text: str, expect_array: bool = False) -> str: """Robustly extract JSON object or array from LLM response.""" open_char = "[" if expect_array else "{" close_char = "]" if expect_array else "}" # Try code fence first pattern = rf"```(?:json)?\s*(\[{open_char}[\s\S]*?\{close_char}\])\s*```" if expect_array else rf"```(?:json)?\s*(\{{[\s\S]*?\}})\s*```" m = re.search(pattern, text) if m: return m.group(1).strip() # Find outermost bracket start = text.find(open_char) if start == -1: raise ValueError(f"No JSON {'array' if expect_array else 'object'} found in LLM response") depth = 0 for i in range(start, len(text)): if text[i] == open_char: depth += 1 elif text[i] == close_char: depth -= 1 if depth == 0: return text[start: i + 1] raise ValueError(f"Unclosed JSON {'array' if expect_array else 'object'} in LLM response") async def stage1_semantic_index(doc: dict) -> dict: """Stage 1: Generate semantic index from full document.""" logger.info("[PIPELINE] ═══ Stage 1/3: 语义索引 ═══") template_path = PROMPTS_DIR / "ir_step1_semantic_index.txt" if not template_path.exists(): logger.warning("[PIPELINE] Stage 1 prompt template not found, using fallback") return _fallback_semantic_index(doc) template = template_path.read_text(encoding="utf-8") formatted_doc = format_document_for_prompt(doc) prompt = template.replace("{document_json}", formatted_doc) logger.info("[PIPELINE] Prompt: %d chars (~%d tokens)", len(prompt), len(prompt) // 3) client = router.get_text_client() for attempt in range(3): try: raw = client.chat( model=router.text_model, messages=[ {"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON,不输出任何其他文字。"}, {"role": "user", "content": prompt}, ], temperature=0.1, ) json_str = extract_json_from_response(raw) result = json.loads(json_str) # Validate if "function_units" not in result or not result["function_units"]: raise ValueError("Missing function_units") logger.info("[PIPELINE] ✅ Stage 1 完成: %d function_units, %d concepts", len(result["function_units"]), len(result.get("concepts", []))) return result except Exception as e: logger.warning("[PIPELINE] Stage 1 attempt %d failed: %s", attempt + 1, e) if attempt < 2: time.sleep(2) # Fallback logger.warning("[PIPELINE] Stage 1 全部重试失败 exhausted, using fallback") return _fallback_semantic_index(doc) def _fallback_semantic_index(doc: dict) -> dict: """Generate a basic semantic index without LLM (for mock/demo).""" units = [] unit_idx = 1 for section in doc.get("sections", []): source = section.get("source", "") for block in section.get("blocks", []): if block["type"] == "para": # Create a function unit from each substantial paragraph text = block.get("text", "") if len(text) > 30: units.append({ "unit_id": f"FU-{unit_idx:03d}", "name": text[:60], "description": text[:200], "sources": [{"section": source, "type": "para", "text_snippet": text[:200]}], }) unit_idx += 1 elif block["type"] == "table": for row in block.get("rows", []): cols = row.get("columns", []) texts = [f"{c.get('name','')}: {c.get('text','')}" for c in cols if c.get('text')] if texts: combined = "; ".join(texts) units.append({ "unit_id": f"FU-{unit_idx:03d}", "name": combined[:60], "description": combined[:200], "sources": [{"section": source, "type": "table", "row": row.get("columns", [{}])[0].get("row", 1) if cols else 1, "text_snippet": combined[:200]}], }) unit_idx += 1 return { "feature_name": doc.get("sections", [{}])[0].get("source", "未命名功能"), "concepts": [], "function_units": units, } # ============================================================================= # Stage 2: Per-Unit IR Extraction # ============================================================================= def build_context_package(fu: dict, doc: dict, image_sources: dict) -> dict: """Build a precision context package for one function unit.""" texts = [] tables = [] images = [] seen_sections = set() img_by_rid = {img.get("rid", ""): img for img in doc.get("image_analysis", [])} for src in fu.get("sources", []): section_key = src.get("section", "") src_type = src.get("type", "") if src_type in ("para", "table") and section_key: if section_key in seen_sections: continue seen_sections.add(section_key) # Find matching section for section in doc.get("sections", []): if section.get("source", "") == section_key: for block in section.get("blocks", []): if block["type"] == "para": texts.append({ "section": section_key, "text": block["text"], }) elif block["type"] == "table": all_rows = [] for row in block.get("rows", []): for col in row.get("columns", []): all_rows.append({ "row": col.get("row"), "name": col.get("name"), "text": col.get("text"), }) # Trim: max 20 cells per table to reduce prompt size tables.append({ "section": section_key, "headers": block.get("headers", []), "all_rows": all_rows[:20], "_truncated": len(all_rows) > 20, }) break if src_type in ("image", "logic_tree"): img_id = src.get("image_id", "") if img_id and img_id in img_by_rid: img = img_by_rid[img_id] images.append({ "image_id": img_id, "type": img.get("type", "other"), "description": img.get("description", ""), }) return { "unit_id": fu["unit_id"], "unit_name": fu.get("name", ""), "unit_description": fu.get("description", ""), "texts": texts, "tables": tables, "images": images, "resolved_conflicts": doc.get("resolved_conflicts", []), } async def stage2_extract_rules(semantic_index: dict, doc: dict) -> list[dict]: """Stage 2: Extract detailed IR rules for each function unit.""" logger.info("=" * 50) logger.info("[PIPELINE] ═══ Stage 2/3: Per-Unit IR Extraction") template_path = PROMPTS_DIR / "ir_step2_extraction.txt" if not template_path.exists(): return _fallback_rules(semantic_index) template = template_path.read_text(encoding="utf-8") function_units = semantic_index.get("function_units", []) image_sources = doc.get("image_sources", {}) logger.info("Processing %d function units (max concurrency: %d)", len(function_units), MAX_CONCURRENCY) # Build context packages packages = [] for fu in function_units: pkg = build_context_package(fu, doc, image_sources) packages.append(pkg) # Parallel LLM calls fragments = [] client = router.get_text_client() def extract_one(pkg: dict) -> dict: """Extract rules for one function unit.""" prompt = _build_stage2_prompt(template, pkg) unit_id = pkg["unit_id"] for attempt in range(3): try: raw = client.chat( model=router.text_model, messages=[ {"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"}, {"role": "user", "content": prompt}, ], temperature=0.1, ) json_str = extract_json_from_response(raw, expect_array=True) rules = json.loads(json_str) if not isinstance(rules, list): raise ValueError(f"Expected array, got {type(rules).__name__}") return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": rules} except Exception as e: if attempt < 2: time.sleep(2) else: logger.warning("Unit %s failed after 3 attempts: %s", unit_id, e) return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": str(e)} return {"unit_id": unit_id, "unit_name": pkg["unit_name"], "rules": [], "error": "unknown"} with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor: futures = {executor.submit(extract_one, pkg): pkg for pkg in packages} for future in concurrent.futures.as_completed(futures): result = future.result() fragments.append(result) n_rules = len(result.get("rules", [])) logger.info("[PIPELINE] %s: %d rules %s", result["unit_id"], n_rules, "❌" if result.get("error") else "✅") fragments.sort(key=lambda f: f["unit_id"]) total = sum(len(f.get("rules", [])) for f in fragments) logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total) return fragments def _build_stage2_prompt(template: str, pkg: dict) -> str: """Render the stage 2 prompt template with context package data.""" # Simple str.replace (avoids format issues with curly braces in JSON) prompt = template prompt = prompt.replace("{unit_id}", pkg["unit_id"]) prompt = prompt.replace("{unit_name}", pkg["unit_name"]) prompt = prompt.replace("{unit_description}", pkg["unit_description"]) prompt = prompt.replace("{texts}", json.dumps(pkg.get("texts", []), ensure_ascii=False, indent=2)) prompt = prompt.replace("{tables}", json.dumps(pkg.get("tables", []), ensure_ascii=False, indent=2)) prompt = prompt.replace("{images}", json.dumps(pkg.get("images", []), ensure_ascii=False, indent=2)) prompt = prompt.replace("{resolved_conflicts}", json.dumps(pkg.get("resolved_conflicts", []), ensure_ascii=False, indent=2)) return prompt def _fallback_rules(semantic_index: dict) -> list[dict]: """Generate basic rules without LLM.""" fragments = [] for fu in semantic_index.get("function_units", []): fragments.append({ "unit_id": fu["unit_id"], "unit_name": fu.get("name", ""), "rules": [{ "description": fu.get("description", ""), "priority": "P2", "sources": fu.get("sources", []), "precondition": {}, "trigger": {"operator": "AND", "conditions": []}, "actions": [], }], }) return fragments # ============================================================================= # Stage 3: Merge & Audit # ============================================================================= def rule_signature(rule: dict) -> str: """SHA256 signature from trigger + actions for dedup.""" trigger = rule.get("trigger", {}) actions = rule.get("actions", []) conditions = sorted(trigger.get("conditions", []), key=lambda c: json.dumps(c, sort_keys=True)) sorted_actions = sorted(actions, key=lambda a: json.dumps(a, sort_keys=True)) sig = json.dumps({"conditions": conditions, "actions": sorted_actions}, ensure_ascii=False, sort_keys=True) return hashlib.sha256(sig.encode()).hexdigest()[:16] def stage3_merge_and_audit(fragments: list[dict], semantic_index: dict, doc: dict) -> dict: """Stage 3: Merge rules, assign IDs, and generate audit report.""" logger.info("=" * 50) logger.info("[PIPELINE] ═══ Stage 3/3: Merge & Audit") # Merge and dedup sig_map: dict[str, dict] = {} order = [] for f in fragments: for rule in f.get("rules", []): sig = rule_signature(rule) if sig in sig_map: existing = sig_map[sig] existing_sources = existing.setdefault("sources", []) for src in rule.get("sources", []): if src not in existing_sources: existing_sources.append(src) if len(rule.get("description", "")) > len(existing.get("description", "")): existing["description"] = rule["description"] else: sig_map[sig] = dict(rule) order.append(sig) merged = [sig_map[sig] for sig in order] logger.info("[PIPELINE] 合并: %d → %d rules (dedup)", sum(len(f.get("rules", [])) for f in fragments), len(merged)) # Assign rule IDs: IR-{feature_id}-{section}-{seq} section_counters = defaultdict(int) feature_name = semantic_index.get("feature_name", "FEATURE") feature_id = re.sub(r'[^A-Z]', '', feature_name.upper())[:6] or "FEAT" if len(feature_id) < 2: feature_id = (feature_id + "FEATURE")[:4] for rule in merged: # Extract section from first para/table source, fallback to "0" section = "0" for src in rule.get("sources", []): s = src.get("section", "") if s: # Extract first number pattern like "4.2.1" or "3.1" m = re.search(r'[\d]+(?:\.[\d]+)*', s) if m: section = m.group() break section_counters[section] += 1 rule["rule_id"] = f"IR-{feature_id}-{section}-{section_counters[section]:03d}" # Build final IR ir_final = { "feature": feature_name, "feature_id": feature_id, "rules": merged, "rule_count": len(merged), "generated_at": datetime.now(timezone.utc).isoformat(), } # Generate audit report audit_results = _generate_audit(merged, doc) report = _format_audit_report(feature_name, len(merged), audit_results) logger.info("[PIPELINE] ✅ Stage 3 完成: %d rules, %d audit issues", len(merged), audit_results.get("issue_count", 0)) return {"ir": ir_final, "audit": audit_results, "audit_report": report} def _generate_audit(rules: list[dict], doc: dict) -> dict: """Run coverage audits and return structured results.""" results = [] # 1. Source coverage: check that all sections with content are referenced all_sources = set() for rule in rules: for src in rule.get("sources", []): section = src.get("section", "") if section: all_sources.add(section) doc_sections = set() for section in doc.get("sections", []): source = section.get("source", "") if source and section.get("blocks"): doc_sections.add(source) uncovered = doc_sections - all_sources results.append({ "check": "文档章节覆盖", "status": PASS if not uncovered else WARN, "detail": f"已覆盖 {len(all_sources)}/{len(doc_sections)} 章节" + (f"; 未覆盖: {list(uncovered)[:5]}" if uncovered else ""), }) # 2. Image coverage img_count = len(doc.get("image_analysis", [])) img_referenced = set() for rule in rules: for src in rule.get("sources", []): if src.get("type") in ("image", "logic_tree"): img_referenced.add(src.get("image_id", "")) results.append({ "check": "图片覆盖率", "status": PASS if len(img_referenced) >= img_count else (WARN if img_referenced else FAIL), "detail": f"已引用 {len(img_referenced)}/{img_count} 张图片", }) # 3. Priority distribution p0 = sum(1 for r in rules if r.get("priority") == "P0") p1 = sum(1 for r in rules if r.get("priority") == "P1") p2 = sum(1 for r in rules if r.get("priority") == "P2") results.append({ "check": "优先级分布", "status": PASS if p0 > 0 else WARN, "detail": f"P0={p0}, P1={p1}, P2={p2}", }) issue_count = sum(1 for r in results if r["status"] in (WARN, FAIL)) return {"checks": results, "issue_count": issue_count} def _sync_call(async_fn, *args, **kwargs): """Run an async function synchronously using the existing event loop if available.""" import asyncio try: loop = asyncio.get_running_loop() except RuntimeError: # No event loop running — just use asyncio.run return asyncio.run(async_fn(*args, **kwargs)) else: # Event loop is running (e.g., inside FastAPI) — must use run_until_complete # Create a new loop in a thread to avoid conflicts import concurrent.futures with concurrent.futures.ThreadPoolExecutor(max_workers=1) as ex: future = ex.submit(asyncio.run, async_fn(*args, **kwargs)) return future.result() def _format_audit_report(feature_name: str, rule_count: int, audit: dict) -> str: """Generate Markdown audit report.""" lines = [ f"# IR 完整性审计报告", f"", f"**功能**: {feature_name}", f"**规则总数**: {rule_count}", f"**生成时间**: {datetime.now(timezone.utc).isoformat()}", f"", f"> ⚠️ 请人工审查以下 ⚠️ 和 ❌ 项。", f"", f"## 审计结果", f"", f"| 检查项 | 状态 | 详情 |", f"|--------|------|------|", ] for r in audit.get("checks", []): lines.append(f"| {r['check']} | {r['status']} | {r['detail']} |") lines.append("") lines.append(f"**审计问题总计**: {audit.get('issue_count', 0)} 个需要人工审查") return "\n".join(lines) # ============================================================================= # Pipeline Orchestrator # ============================================================================= class IRPipeline: """Orchestrates the 3-stage IR generation pipeline.""" def __init__(self): self._store: dict[str, dict] = {} def run_streaming(self, prd_id: str, parsed_doc: dict): """Run pipeline as a sync generator yielding progress events. This is a plain generator (NOT async) so it never blocks the event loop between yields. The SSE endpoint wraps it in a thread + Queue bridge. """ t_total_start = time.time() n_sections = len(parsed_doc.get("sections", [])) est_units = max(n_sections * 2, 3) est_total = 20 + (est_units * 20 / 3) + 1 # --- Stage 1 --- yield {"stage": 1, "status": "running", "message": "Stage 1: 分析文档结构,识别功能单元...", "elapsed": 0, "stage_total": 3, "detail": f"文档含 {n_sections} 个章节"} t0 = time.time() try: semantic_index = _sync_call(stage1_semantic_index, parsed_doc) except Exception as e: yield {"stage": 1, "status": "error", "message": f"Stage 1 失败: {e}", "stage_total": 3} return t1 = time.time() n_units = len(semantic_index.get("function_units", [])) est_total = 20 + (max(n_units, 1) * 25 / 3) + 1 yield {"stage": 1, "status": "done", "message": f"Stage 1 完成: 识别 {n_units} 个功能单元", "elapsed": round(t1 - t0, 1), "stage_total": 3, "detail": f"识别 {n_units} 个功能单元", "n_units": n_units, "estimated_total": round(est_total)} # --- Stage 2 --- est_stage2 = max(n_units, 1) * 25 / min(MAX_CONCURRENCY, max(n_units, 1)) yield {"stage": 2, "status": "running", "message": f"Stage 2: 逐单元提取 IR 规则 ({n_units} 个单元)...", "elapsed": 0, "stage_total": 3, "detail": f"预计 {est_stage2:.0f}s,{n_units} 个单元 × {min(MAX_CONCURRENCY, n_units)} 并发", "n_units": n_units, "estimated_total": round(est_total)} t1b = time.time() completed = 0 total_rules = 0 fragments = [] function_units = semantic_index.get("function_units", []) # Optimization: filter out trivial units (very short desc, no text sources) filtered_units = [] for fu in function_units: sources = fu.get("sources", []) desc = fu.get("description", "") has_text = any(s.get("type") in ("para", "table") for s in sources) if len(desc) < 15 and not has_text: logger.info("[PIPELINE] 跳过空单元: %s", fu.get("unit_id", "?")) continue filtered_units.append(fu) skipped = len(function_units) - len(filtered_units) if skipped: logger.info("[PIPELINE] Stage 2 过滤掉 %d 个空单元,剩余 %d", skipped, len(filtered_units)) function_units = filtered_units image_sources = parsed_doc.get("image_sources", {}) template_path = PROMPTS_DIR / "ir_step2_extraction.txt" template = template_path.read_text(encoding="utf-8") if template_path.exists() else "" client = router.get_text_client() packages = [build_context_package(fu, parsed_doc, image_sources) for fu in function_units] with concurrent.futures.ThreadPoolExecutor(max_workers=MAX_CONCURRENCY) as executor: futures = {} for i, pkg in enumerate(packages): future = executor.submit(self._extract_one_sync, template, pkg, client) futures[future] = (i, pkg["unit_id"], pkg["unit_name"]) for future in concurrent.futures.as_completed(futures): i, uid, uname = futures[future] try: rules = future.result() fragments.append({"unit_id": uid, "unit_name": uname, "rules": rules}) total_rules += len(rules) except Exception as e: logger.warning("Unit %s failed: %s", uid, e) fragments.append({"unit_id": uid, "unit_name": uname, "rules": [], "error": str(e)}) completed += 1 elapsed = round(time.time() - t1b, 1) yield {"stage": 2, "status": "running", "message": f"Stage 2: {completed}/{n_units} 单元完成 ({total_rules} 条规则)", "elapsed": elapsed, "stage_total": 3, "detail": f"已完成 {completed}/{n_units},累计 {total_rules} 条规则", "completed": completed, "total": n_units, "rules_so_far": total_rules, "estimated_total": round(est_total)} fragments.sort(key=lambda f: f["unit_id"]) t2 = time.time() logger.info("[PIPELINE] ✅ Stage 2 完成: %d fragments, %d total rules", len(fragments), total_rules) yield {"stage": 2, "status": "done", "message": f"Stage 2 完成: {len(fragments)} 个单元 → {total_rules} 条规则", "elapsed": round(t2 - t1b, 1), "stage_total": 3, "detail": f"{len(fragments)} 个单元 → {total_rules} 条规则", "total_rules": total_rules} # --- Stage 3 --- yield {"stage": 3, "status": "running", "message": "Stage 3: 合并去重 + 审计报告...", "elapsed": 0, "stage_total": 3, "detail": "去重 + 分配 ID + 生成审计报告"} result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc) t3 = time.time() n_rules = len(result["ir"]["rules"]) ir_id = str(uuid.uuid4())[:8] output = { "ir_id": ir_id, "prd_id": prd_id, "yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2), "ir_json": result["ir"], "audit": result["audit"], "audit_report": result["audit_report"], "skill_used": "default", "created_at": datetime.now(timezone.utc).isoformat(), "pipeline_stats": { "stage1_seconds": round(t1 - t0, 1), "stage2_seconds": round(t2 - t1b, 1), "stage3_seconds": round(t3 - t2, 1), "total_seconds": round(t3 - t_total_start, 1), }, } self._store[ir_id] = output # Save to disk output_dir = settings.OUTPUT_DIR / prd_id output_dir.mkdir(parents=True, exist_ok=True) with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2, default=str) yield {"stage": 3, "status": "done", "message": f"流水线完成!共 {n_rules} 条规则", "elapsed": round(t3 - t2, 1), "stage_total": 3, "detail": f"合并后 {n_rules} 条规则,审计问题 {result['audit'].get('issue_count', 0)} 个", "done": True, "result": output} def _extract_one_sync(self, template: str, pkg: dict, client) -> list[dict]: """Synchronous rule extraction for one unit (runs in thread pool).""" prompt = _build_stage2_prompt(template, pkg) for attempt in range(3): try: raw = client.chat( model=router.text_model, messages=[ {"role": "system", "content": "你是一个精确的 JSON 输出引擎。只输出合法的 JSON 数组,不输出任何其他文字。"}, {"role": "user", "content": prompt}, ], temperature=0.1, ) json_str = extract_json_from_response(raw, expect_array=True) rules = json.loads(json_str) if isinstance(rules, list): return rules except Exception as e: if attempt == 2: raise time.sleep(2) return [] async def run(self, prd_id: str, parsed_doc: dict) -> dict: """Run the full 3-stage pipeline and return the final IR + audit.""" logger.info("[PIPELINE] ═══ 流水线启动 for PRD %s", prd_id) # Stage 1 t0 = time.time() semantic_index = await stage1_semantic_index(parsed_doc) t1 = time.time() logger.info("[PIPELINE] Stage 1 耗时: %.1fs", t1 - t0) # Stage 2 fragments = await stage2_extract_rules(semantic_index, parsed_doc) t2 = time.time() logger.info("[PIPELINE] Stage 2 耗时 %.1fs", t2 - t1) # Stage 3 result = stage3_merge_and_audit(fragments, semantic_index, parsed_doc) t3 = time.time() logger.info("[PIPELINE] Stage 3 耗时 %.1fs", t3 - t2) ir_id = str(uuid.uuid4())[:8] output = { "ir_id": ir_id, "prd_id": prd_id, "yaml_content": json.dumps(result["ir"], ensure_ascii=False, indent=2), "ir_json": result["ir"], "audit": result["audit"], "audit_report": result["audit_report"], "skill_used": "default", "created_at": datetime.now(timezone.utc).isoformat(), "pipeline_stats": { "stage1_seconds": round(t1 - t0, 1), "stage2_seconds": round(t2 - t1, 1), "stage3_seconds": round(t3 - t2, 1), "total_seconds": round(t3 - t0, 1), }, } self._store[ir_id] = output # Save output output_dir = settings.OUTPUT_DIR / prd_id output_dir.mkdir(parents=True, exist_ok=True) with open(output_dir / f"ir_{ir_id}.json", "w", encoding="utf-8") as f: json.dump(output, f, ensure_ascii=False, indent=2, default=str) logger.info("[PIPELINE] ✅ 流水线完成: ir_id=%s, rules=%d, total=%.1fs", ir_id, len(result["ir"]["rules"]), t3 - t0) return output async def get_ir(self, ir_id: str) -> dict | None: return self._store.get(ir_id) ir_pipeline = IRPipeline()