Spaces:
Sleeping
Sleeping
File size: 6,897 Bytes
94f31ec | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | """
PRD Parser Utilities.
This module contains parsing and normalization functions for PRD documents,
extracted from prd.py to follow single responsibility principle.
"""
import json
import re
from typing import Any
def extract_json_from_response(content: str) -> dict[str, Any] | None:
"""
Extract JSON from PRD response content.
Tries multiple strategies:
1. Fenced code block: ```json ... ```
2. Raw JSON object: outermost { ... }
Returns parsed dict on success, None if no valid JSON found.
"""
if not content or not content.strip():
return None
# Strategy 1: fenced ```json ... ``` blocks
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
if json_match:
raw = json_match.group(1).strip()
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
# Strategy 2: raw JSON object { ... }
json_match = re.search(r"\{[\s\S]*\}", content)
if json_match:
raw = json_match.group(0).strip()
try:
parsed = json.loads(raw)
if isinstance(parsed, dict):
return parsed
except json.JSONDecodeError:
pass
return None
def normalize_llm_content(content: str | list[str | dict[str, Any]]) -> str:
"""
Normalize LLM content into a string.
Handles various content formats that LLMs may return.
"""
if isinstance(content, str):
return content
parts: list[str] = []
for item in content:
if isinstance(item, str):
parts.append(item)
continue
if isinstance(item, dict):
text = item.get("text")
if isinstance(text, str):
parts.append(text)
continue
try:
parts.append(json.dumps(item))
except TypeError:
parts.append(str(item))
return "\n".join(part for part in parts if part)
def normalize_assumptions(value: str | list[str] | None) -> list[str]:
"""
Normalize assumptions stored as string or list into list[str].
"""
if value is None:
return []
if isinstance(value, list):
return [str(item).strip() for item in value if str(item).strip()]
if isinstance(value, str):
if not value.strip():
return []
try:
parsed = json.loads(value)
if isinstance(parsed, list):
return [str(item).strip() for item in parsed if str(item).strip()]
except json.JSONDecodeError:
pass
return [line.strip() for line in value.splitlines() if line.strip()]
return []
def parse_prd_sections(prd_content: str) -> dict[str, Any]:
"""
Parse PRD content and extract structured sections from markdown or JSON.
Handles both JSON and markdown formats that the LLM may return.
"""
# First try JSON parsing
parsed = extract_json_from_response(prd_content)
if parsed:
return {
"product_vision": parsed.get("product_vision", ""),
"key_features": parsed.get("features", {}),
"user_stories": parsed.get("user_stories", []),
"assumptions": parsed.get("assumptions", []),
}
# Parse markdown format - extract sections by headers
sections = {}
# Extract Product Vision
vision_match = re.search(
r"##?\s*1\.?\s*Product Vision\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
if not vision_match:
vision_match = re.search(
r"Product Vision\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
sections["product_vision"] = vision_match.group(1).strip() if vision_match else ""
# Extract Target Users
users_match = re.search(
r"##?\s*Target Users?\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
sections["target_users"] = users_match.group(1).strip() if users_match else ""
# Extract Key Features
features_match = re.search(
r"##?\s*3\.?\s*Key Features?\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
if not features_match:
features_match = re.search(
r"Key Features?\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
# Parse feature list from markdown
features = {}
if features_match:
feature_text = features_match.group(1)
# Find all feature titles (### F1: or ### Feature Name)
feature_matches = re.findall(r"###\s*(?:F\d+:?\s*)?([^\n]+)", feature_text)
for i, title in enumerate(feature_matches, 1):
# Try to find priority
priority = "should"
priority_match = re.search(
r"priority[:\s]*(\w+)", feature_text, re.IGNORECASE
)
if priority_match:
p = priority_match.group(1).lower()
if "must" in p:
priority = "must"
elif "could" in p:
priority = "could"
features[f"F{i}"] = {"title": title.strip(), "priority": priority}
sections["key_features"] = features
# Extract User Stories - look for table format
stories_match = re.search(
r"##?\s*4\.?\s*User Stories?\s*\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
user_stories = []
if stories_match:
table_text = stories_match.group(1)
# Parse markdown table rows
rows = re.findall(r"\|\s*([^|]+)\s*\|", table_text)
# Skip header row if present, extract story info
for row in rows:
if "---" in row or "ID" in row:
continue
user_stories.append({"content": row.strip()})
sections["user_stories"] = user_stories
# Extract Assumptions
assumptions_match = re.search(
r"##?\s*5\.?\s*Assumptions.*?\n(.*?)(?=\n##|\Z)",
prd_content,
re.DOTALL | re.IGNORECASE,
)
if not assumptions_match:
assumptions_match = re.search(
r"Assumptions.*?\n(.*?)(?=\n##|\Z)", prd_content, re.DOTALL | re.IGNORECASE
)
assumptions = []
if assumptions_match:
# Extract bullet points
items = re.findall(
r"[-*]\s*(.+?)(?=\n[-*]|\n\n|\Z)", assumptions_match.group(1)
)
assumptions = [item.strip() for item in items]
sections["assumptions"] = assumptions
return {
"product_vision": sections.get("product_vision", ""),
"key_features": sections.get("key_features", {}),
"user_stories": sections.get("user_stories", []),
"assumptions": sections.get("assumptions", []),
}
|