init the project
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# IR 版本差异对比服务
|
||||
|
||||
import logging
|
||||
|
||||
import yaml
|
||||
|
||||
from server.core.utils.diff import compute_diff
|
||||
from server.services.ir_engine.generator import ir_generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IRDiffService:
|
||||
"""Compare two IR versions and return a structured diff."""
|
||||
|
||||
async def diff(self, ir_id_a: str, ir_id_b: str) -> dict:
|
||||
"""Compute diff between two IR versions."""
|
||||
ir_a = await ir_generator.get_ir(ir_id_a)
|
||||
ir_b = await ir_generator.get_ir(ir_id_b)
|
||||
|
||||
if not ir_a or not ir_b:
|
||||
return {"error": "One or both IR versions not found", "diff": {}}
|
||||
|
||||
yaml_a = ir_a.get("yaml_content", "")
|
||||
yaml_b = ir_b.get("yaml_content", "")
|
||||
|
||||
try:
|
||||
data_a = yaml.safe_load(yaml_a)
|
||||
data_b = yaml.safe_load(yaml_b)
|
||||
except yaml.YAMLError as e:
|
||||
return {"error": f"YAML parsing error: {e}", "diff": {}}
|
||||
|
||||
diff_result = compute_diff(data_a or {}, data_b or {})
|
||||
logger.info("IR diff: %d added, %d removed, %d modified",
|
||||
len(diff_result.get("added", [])),
|
||||
len(diff_result.get("removed", [])),
|
||||
len(diff_result.get("modified", [])))
|
||||
|
||||
return {
|
||||
"ir_id_a": ir_id_a,
|
||||
"ir_id_b": ir_id_b,
|
||||
"diff": diff_result,
|
||||
}
|
||||
|
||||
|
||||
ir_diff = IRDiffService()
|
||||
@@ -0,0 +1,76 @@
|
||||
# IR Generator — delegates to the 3-stage pipeline
|
||||
|
||||
import logging
|
||||
|
||||
from server.services.ir_engine.pipeline import ir_pipeline
|
||||
from server.services.prd_manager.service import prd_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IRGenerator:
|
||||
"""IR generator that runs the full 3-stage pipeline on parsed PRD documents."""
|
||||
|
||||
async def generate(self, prd_id: str, prd_text: str = "", skill_name: str = "default") -> dict:
|
||||
"""Run the pipeline on a parsed PRD document."""
|
||||
# Get the parsed document from the PRD service
|
||||
prd = await prd_service.get_prd(prd_id)
|
||||
if not prd:
|
||||
return {"error": f"PRD not found: {prd_id}", "ir_id": ""}
|
||||
|
||||
# Fetch full parsed data from disk if available
|
||||
parsed_doc = self._load_parsed(prd)
|
||||
if not parsed_doc:
|
||||
# Fallback: build minimal doc from PRD text
|
||||
parsed_doc = {
|
||||
"source": "",
|
||||
"sections": [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd.get("full_text", "")}], "images": []}],
|
||||
"image_sources": {},
|
||||
"image_analysis": [],
|
||||
"resolved_conflicts": [],
|
||||
}
|
||||
|
||||
result = await ir_pipeline.run(prd_id, parsed_doc)
|
||||
return {
|
||||
"ir_id": result["ir_id"],
|
||||
"prd_id": result["prd_id"],
|
||||
"yaml_content": result["yaml_content"],
|
||||
"ir_json": result["ir_json"],
|
||||
"audit": result["audit"],
|
||||
"audit_report": result["audit_report"],
|
||||
"skill_used": skill_name,
|
||||
"created_at": result["created_at"],
|
||||
"pipeline_stats": result["pipeline_stats"],
|
||||
}
|
||||
|
||||
async def get_ir(self, ir_id: str) -> dict | None:
|
||||
return await ir_pipeline.get_ir(ir_id)
|
||||
|
||||
def _load_parsed(self, prd: dict) -> dict | None:
|
||||
"""Load the full parsed JSON from disk if available."""
|
||||
import json
|
||||
parsed_path = prd.get("parsed_path", "")
|
||||
if parsed_path:
|
||||
try:
|
||||
with open(parsed_path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Build from in-memory data
|
||||
if prd.get("sections") or prd.get("full_text"):
|
||||
sections = prd.get("sections", [])
|
||||
if not sections and prd.get("full_text"):
|
||||
sections = [{"source": "正文", "blocks": [{"type": "para", "index": 1, "text": prd["full_text"]}], "images": []}]
|
||||
return {
|
||||
"source": "",
|
||||
"sections": sections,
|
||||
"image_sources": {},
|
||||
"image_analysis": prd.get("images", []),
|
||||
"resolved_conflicts": [],
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
|
||||
ir_generator = IRGenerator()
|
||||
@@ -0,0 +1,753 @@
|
||||
# 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()
|
||||
@@ -0,0 +1,100 @@
|
||||
# IR 验证器:Schema 校验 + Principles 规则检查
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
import jsonschema
|
||||
import yaml
|
||||
|
||||
from server.config import settings
|
||||
from server.services.ir_engine.generator import ir_generator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class IRValidator:
|
||||
"""Validate IR against skill schema and soul principles."""
|
||||
|
||||
async def validate(self, ir_yaml: str, skill_name: str = "default") -> dict:
|
||||
"""Validate IR YAML content and return {valid, issues}."""
|
||||
issues = []
|
||||
|
||||
# 1. Parse YAML
|
||||
try:
|
||||
ir_data = yaml.safe_load(ir_yaml)
|
||||
except yaml.YAMLError as e:
|
||||
return {"valid": False, "issues": [{
|
||||
"severity": "error",
|
||||
"message": f"YAML 解析失败: {e}",
|
||||
}]}
|
||||
|
||||
if not isinstance(ir_data, dict):
|
||||
return {"valid": False, "issues": [{
|
||||
"severity": "error",
|
||||
"message": "IR 内容必须是 YAML 字典/对象",
|
||||
}]}
|
||||
|
||||
# 2. Schema validation
|
||||
skill = ir_generator.load_skill(skill_name)
|
||||
schema = skill.get("schema", {})
|
||||
if schema:
|
||||
try:
|
||||
jsonschema.validate(instance=ir_data, schema=schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
issues.append({
|
||||
"severity": "error",
|
||||
"message": f"Schema 校验失败: {e.message}",
|
||||
"location": ".".join(str(p) for p in e.absolute_path),
|
||||
})
|
||||
|
||||
# 3. Principles validation
|
||||
principles = self._load_principles()
|
||||
for principle in principles:
|
||||
rule = principle.get("rule", "")
|
||||
pid = principle.get("id", "")
|
||||
|
||||
# Check completeness: each feature must have both positive and negative paths
|
||||
if pid == "completeness":
|
||||
features = ir_data.get("features", [])
|
||||
if not features:
|
||||
issues.append({
|
||||
"severity": "warning",
|
||||
"message": f"原则 '{pid}': 未检测到功能点,无法验证完整性",
|
||||
})
|
||||
|
||||
# Check traceability: features should have descriptions
|
||||
if pid == "traceability":
|
||||
features = ir_data.get("features", [])
|
||||
for i, feat in enumerate(features):
|
||||
if not feat.get("description"):
|
||||
issues.append({
|
||||
"severity": "warning",
|
||||
"message": f"原则 '{pid}': 功能点 {feat.get('feature_name', f'#{i}')} 缺少描述",
|
||||
})
|
||||
|
||||
# Check no-hallucination: warn about potentially vague features
|
||||
if pid == "no-hallucination":
|
||||
features = ir_data.get("features", [])
|
||||
for feat in features:
|
||||
desc = feat.get("description", "")
|
||||
if "应该" in desc or "可能" in desc or "猜测" in desc:
|
||||
issues.append({
|
||||
"severity": "warning",
|
||||
"message": f"原则 '{pid}': 功能点 '{feat.get('feature_name', '')}' 描述包含不确定措辞",
|
||||
})
|
||||
|
||||
valid = len([i for i in issues if i["severity"] == "error"]) == 0
|
||||
logger.info("IR validation: valid=%s, issues=%d", valid, len(issues))
|
||||
return {"valid": valid, "issues": issues}
|
||||
|
||||
def _load_principles(self) -> list[dict]:
|
||||
"""Load principles from .zeekerwatchmen/soul/principles.yaml."""
|
||||
principles_path = settings.SOUL_DIR / "principles.yaml"
|
||||
if not principles_path.exists():
|
||||
return []
|
||||
with open(principles_path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f)
|
||||
return data.get("principles", []) if data else []
|
||||
|
||||
|
||||
ir_validator = IRValidator()
|
||||
Reference in New Issue
Block a user