feat(task-020): generate context from discovery answers
This commit is contained in:
@@ -14,6 +14,7 @@ from .tasks import generate_agent_prompt, get_next_task, update_task_status
|
||||
from .templates import CONTEXT_FILES, write_file_if_missing
|
||||
from .guardrails import run_all_guardrails, format_report
|
||||
from .telemetry import record_event
|
||||
from .generate_context import generate_context_files
|
||||
|
||||
app = typer.Typer(help="RDB discovery and delivery workflow CLI.")
|
||||
console = Console()
|
||||
@@ -209,3 +210,24 @@ def guardrails() -> None:
|
||||
results = run_all_guardrails(root)
|
||||
report = format_report(results)
|
||||
console.print(report)
|
||||
|
||||
|
||||
@app.command()
|
||||
def generate(min_confidence: str = typer.Option("Medium", help="Minimum confidence to process (High or Medium).")) -> None:
|
||||
"""Generate context files from discovery answers using the mapping document."""
|
||||
root = root_path()
|
||||
record_event(root, "command", "rdb generate")
|
||||
|
||||
result = generate_context_files(root, min_confidence)
|
||||
|
||||
if result["generated"]:
|
||||
console.print("[bold green]Generated context content:[/bold green]")
|
||||
for f in result["generated"]:
|
||||
console.print(f"- {f}")
|
||||
else:
|
||||
console.print("[yellow]No context files generated.[/yellow]")
|
||||
|
||||
if result["skipped"]:
|
||||
console.print("\n[dim]Skipped:[/dim]")
|
||||
for s in result["skipped"]:
|
||||
console.print(f" - {s}")
|
||||
|
||||
@@ -58,7 +58,10 @@ def read_discovery_answers(root: Path) -> list[dict]:
|
||||
if not line.startswith("| Q-"):
|
||||
continue
|
||||
|
||||
cells = [c.strip() for c in line.split("|")[1:-1]]
|
||||
# Replace escaped pipes with a placeholder before splitting,
|
||||
# so they don't create extra columns. Restore after splitting.
|
||||
safe_line = line.replace("\\|", "\x00PIPE\x00")
|
||||
cells = [c.strip().replace("\x00PIPE\x00", "|") for c in safe_line.split("|")[1:-1]]
|
||||
if len(cells) < 5:
|
||||
continue
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Generate context files from discovery answers.
|
||||
|
||||
Uses the mapping defined in context/discovery-context-mapping.md to transform
|
||||
discovery-log.md entries into the appropriate context files.
|
||||
|
||||
Only processes High/Medium confidence answers. Low-confidence answers are
|
||||
skipped and must be handled by `rdb ask-more` first.
|
||||
|
||||
Preserves existing content in context files - never overwrites.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .discovery import read_discovery_answers
|
||||
|
||||
|
||||
# Mapping from question ID to list of (file_path, section_name, format_type)
|
||||
# format_type: "body" = fill under heading with body text
|
||||
# "table" = append row to table-based section
|
||||
# "append" = append as a new section at end of file
|
||||
CONTEXT_MAP = {
|
||||
"Q-001": [ # What problem are we solving?
|
||||
("context/product-brief.md", "## Problem", "body"),
|
||||
("context/project-brief.md", "## Problem Statement", "body"),
|
||||
],
|
||||
"Q-002": [ # Who is the user?
|
||||
("context/product-brief.md", "## Users", "body"),
|
||||
("context/development-context.md", "## IDEs and Editors", "body"),
|
||||
],
|
||||
"Q-003": [ # What does success look like?
|
||||
("context/product-brief.md", "## Success Criteria", "body"),
|
||||
("context/project-brief.md", "## Success Metrics", "body"),
|
||||
],
|
||||
"Q-004": [ # What is the minimum useful version?
|
||||
("context/product-brief.md", "## Minimum Useful Version", "body"),
|
||||
("context/project-brief.md", "## Key Features (MVP)", "body"),
|
||||
],
|
||||
"Q-005": [ # What data do we need?
|
||||
("context/architecture.md", "## Data Flow", "body"),
|
||||
("context/development-context.md", "## Dependencies", "body"),
|
||||
],
|
||||
"Q-006": [ # What systems must it connect to?
|
||||
("context/architecture.md", "## External Integrations", "body"),
|
||||
("context/infrastructure-context.md", None, "append"),
|
||||
],
|
||||
"Q-007": [ # What are the risks?
|
||||
("context/risks.md", "# Risks", "table"),
|
||||
],
|
||||
"Q-008": [ # What must not happen?
|
||||
("context/assumptions.md", "# Assumptions", "table"),
|
||||
],
|
||||
"Q-009": [ # How will we test it?
|
||||
("context/development-context.md", "## Build & Test", "body"),
|
||||
("context/project-brief.md", "## Timeline & Milestones", "body"),
|
||||
],
|
||||
"Q-010": [ # How will it be deployed?
|
||||
("context/architecture.md", "## Deployment Architecture", "body"),
|
||||
("context/infrastructure-context.md", None, "append"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def generate_context_files(
|
||||
root: Path, min_confidence: str = "Medium"
|
||||
) -> dict[str, list[str]]:
|
||||
"""Generate context files from discovery answers.
|
||||
|
||||
Args:
|
||||
root: Project root path.
|
||||
min_confidence: Minimum confidence to process (High, Medium).
|
||||
|
||||
Returns:
|
||||
Dict with keys: 'generated', 'skipped' each a list of strings.
|
||||
"""
|
||||
valid_confidences = {"High", "Medium"}
|
||||
answers = read_discovery_answers(root)
|
||||
|
||||
if not answers:
|
||||
return {"generated": [], "skipped": ["No discovery answers found in discovery-log.md"]}
|
||||
|
||||
filtered = [a for a in answers if a["confidence"] in valid_confidences]
|
||||
skipped = [a for a in answers if a["confidence"] not in valid_confidences]
|
||||
|
||||
generated_files: set[str] = set()
|
||||
|
||||
for answer in filtered:
|
||||
qid = answer["id"]
|
||||
rules = CONTEXT_MAP.get(qid)
|
||||
if not rules:
|
||||
skipped.append(f"{qid} (no mapping)")
|
||||
continue
|
||||
|
||||
for file_path, section_name, fmt in rules:
|
||||
_write_section(
|
||||
root=root,
|
||||
file_path=file_path,
|
||||
section_name=section_name,
|
||||
format_type=fmt,
|
||||
answer=answer,
|
||||
rules=rules,
|
||||
)
|
||||
generated_files.add(file_path)
|
||||
|
||||
skipped_ids = [a["id"] for a in skipped]
|
||||
if skipped_ids:
|
||||
skipped = ["Low-confidence or flagged answers (skipped): " + ", ".join(skipped_ids)]
|
||||
|
||||
return {"generated": sorted(generated_files), "skipped": skipped}
|
||||
|
||||
|
||||
def _write_section(root, file_path, section_name, format_type, answer, rules):
|
||||
"""Dispatch to the appropriate write strategy."""
|
||||
target = root / file_path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if format_type == "table":
|
||||
_write_table_row(target, section_name, answer)
|
||||
elif format_type == "append":
|
||||
_append_new_section(target, answer, rules)
|
||||
else:
|
||||
# body text type
|
||||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||
lines = existing.splitlines() if existing else []
|
||||
|
||||
section_idx = _find_section(lines, section_name) if section_name else -1
|
||||
|
||||
if section_idx >= 0:
|
||||
_fill_or_append_body(lines, target, section_idx, answer)
|
||||
else:
|
||||
_create_new_section(target, section_name, answer, format_type)
|
||||
|
||||
|
||||
def _find_section(lines, section_header):
|
||||
"""Find the index of a section header line in the markdown."""
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == section_header:
|
||||
return i
|
||||
return -1
|
||||
|
||||
|
||||
def _fill_or_append_body(lines, target, section_idx, answer):
|
||||
"""Fill or append body text under an existing section header.
|
||||
|
||||
Preserves the rest of the document - never drops subsequent sections.
|
||||
"""
|
||||
start = section_idx + 1
|
||||
|
||||
# Find another section header to know the boundary
|
||||
end = len(lines)
|
||||
for i in range(start, len(lines)):
|
||||
if lines[i].startswith("## "):
|
||||
end = i
|
||||
break
|
||||
|
||||
content_lines = [l.strip() for l in lines[start:end] if l.strip()]
|
||||
|
||||
# Check if section is empty or only has TBD placeholder
|
||||
is_tbd = (
|
||||
len(content_lines) == 1 and content_lines[0].lower().startswith("tbd")
|
||||
) or len(content_lines) == 0
|
||||
|
||||
answer_text = _format_answer_text(answer)
|
||||
|
||||
if is_tbd:
|
||||
# Replace TBD with discovery answer, keep rest of document
|
||||
new_lines = (
|
||||
lines[:section_idx + 1]
|
||||
+ [answer_text, ""]
|
||||
+ lines[end:]
|
||||
)
|
||||
else:
|
||||
# Append under existing content, after the last non-empty line in this section
|
||||
insert_at = start
|
||||
for i in range(start, end):
|
||||
if lines[i].strip():
|
||||
insert_at = i + 1
|
||||
new_lines = (
|
||||
lines[:insert_at]
|
||||
+ [answer_text, ""]
|
||||
+ lines[insert_at:]
|
||||
)
|
||||
|
||||
target.write_text("\n".join(new_lines), encoding="utf-8")
|
||||
|
||||
|
||||
def _write_table_row(target, section_header, answer):
|
||||
"""Append a row to a table-based context file."""
|
||||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||
|
||||
q_num = answer["id"].split("-")[1]
|
||||
all_questions = [
|
||||
"What problem are we solving?",
|
||||
"Who is the user?",
|
||||
"What does success look like?",
|
||||
"What is the minimum useful version?",
|
||||
"What data do we need?",
|
||||
"What systems must it connect to?",
|
||||
"What are the risks?",
|
||||
"What must not happen?",
|
||||
"How will we test it?",
|
||||
"How will it be deployed?",
|
||||
]
|
||||
question_text = all_questions[int(q_num) - 1] if int(q_num) <= len(all_questions) else answer["question"]
|
||||
|
||||
fname = target.name
|
||||
if fname == "risks.md":
|
||||
risk_id = f"RISK-{q_num}"
|
||||
clean = answer["answer"].replace("\\|", "|").strip()
|
||||
row = f"| {risk_id} | {question_text}: {clean} | Medium | Monitor and review | Open |\n"
|
||||
elif fname == "assumptions.md":
|
||||
assump_id = f"ASSUMPTION-{q_num}"
|
||||
clean = answer["answer"].replace("\\|", "|").strip()
|
||||
row = f"| {assump_id} | {question_text}: {clean} | Medium | Yes |\n"
|
||||
else:
|
||||
row = f"| {answer['id']} | {question_text} | {answer['answer']} | Open |\n"
|
||||
|
||||
target.write_text(existing + "\n" + row, encoding="utf-8")
|
||||
|
||||
|
||||
def _append_new_section(target, answer, rules):
|
||||
"""Append a new section to the end of an existing file."""
|
||||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||
|
||||
display_name = None
|
||||
for _, sec, _ in rules:
|
||||
if sec and sec.startswith("## "):
|
||||
display_name = sec
|
||||
break
|
||||
if not display_name:
|
||||
q_num = answer["id"].split("-")[1]
|
||||
display_name = f"## Q-{q_num} Discovery Answer"
|
||||
|
||||
section_text = (
|
||||
f"\n{display_name}\n\n"
|
||||
f"**Discovery Question:** {answer['question']}\n\n"
|
||||
f"**Answer:** {answer['answer'].strip()}\n\n"
|
||||
f"**Confidence:** {answer['confidence']}\n"
|
||||
)
|
||||
target.write_text(existing + section_text, encoding="utf-8")
|
||||
|
||||
|
||||
def _create_new_section(target, header, answer, fmt):
|
||||
"""Create a new section in a context file when it doesn't exist yet."""
|
||||
existing = target.read_text(encoding="utf-8") if target.exists() else ""
|
||||
|
||||
if not header:
|
||||
q_num = answer["id"].split("-")[1]
|
||||
header = f"## Q-{q_num} Discovery Answer"
|
||||
|
||||
section_text = (
|
||||
f"\n{header}\n\n"
|
||||
f"**Discovery Question:** {answer['question']}\n\n"
|
||||
f"**Answer:** {answer['answer'].strip()}\n\n"
|
||||
f"**Confidence:** {answer['confidence']}\n"
|
||||
)
|
||||
target.write_text(existing + section_text, encoding="utf-8")
|
||||
|
||||
|
||||
def _format_answer_text(answer):
|
||||
"""Format a discovery answer into readable body text."""
|
||||
return answer["answer"].replace("\\|", "|").strip()
|
||||
Reference in New Issue
Block a user