Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 13: Discovery of Requirements

13.3 Building a Requirement Discovery Assistant

"I built an assistant that reads transcripts and writes user stories. Now the product manager asks me what I do all day, and I am not sure how to answer."

A Requirements Engineer Facing an Existential Discovery

Prerequisites

This section synthesizes all the components from the chapter. You should have worked through Section 13.1 (data structures: user stories, use cases, acceptance criteria, traceability matrix) and Section 13.2 (extraction pipeline: LLM extraction, clustering, conflict detection). We also draw on the Model Context Protocol (MCP) server patterns from Chapter 12 to expose the assistant as a tool that other agents can invoke.

The Big Picture

The previous two sections built the individual components: structured requirement models, large language model (LLM) based extraction, embedding clustering, and graph-based conflict detection. This section assembles them into a Requirement Discovery Assistant, a complete tool that a requirements engineer operates in practice. The assistant accepts a folder of transcripts (or a stream of tickets from an issue tracker), runs the full extraction pipeline, produces a visual conflict map, generates a coverage dashboard, and exports the results in formats consumable by downstream tools. It becomes the first domain-specific component of the Discovery Workbench built from software engineering primitives, joining the MCP servers from Chapter 12 and feeding into the architecture discovery pipeline of Chapter 14. Figure 13.3.1 illustrates the six-phase requirement discovery pipeline architecture.

Six-phase requirement discovery pipeline architecture
Figure 13.3.1: The six-phase pipeline of the Requirement Discovery Assistant, from raw source ingestion through extraction, validation, clustering, conflict detection, and traceability linking, with checkpoint saves after each phase for recovery.

1. Architecture of the Assistant

When requirement conflicts go undetected, they surface months later as contradictory features that force costly rework or, worse, ship to users who encounter mutually exclusive behaviors. The faster a team can surface and reconcile those conflicts, the cheaper they are to resolve.

What happens when you hand 42,000 words of stakeholder transcripts, 200 issue-tracker tickets, and a regulatory guidance document to a single tool and press "run"? The answer is typically a validated, deduplicated requirement set with conflicts flagged and coverage gaps highlighted, produced in under ten minutes on a corpus of this size. The assistant that delivers this result follows the layered architecture pattern from Chapter 6, separating concerns into three layers:

A Requirement Discovery Assistant is a software pipeline that reads unstructured stakeholder inputs: meeting transcripts, issue tracker tickets, and design documents. It applies LLM-based extraction and semantic analysis to identify, validate, cluster, and cross-check requirements. It then outputs structured artifacts such as user stories, conflict reports, and traceability matrices. Manual requirement analysis at scale is slow and error-prone. A single analyst reviewing 40,000 words of transcripts can miss implicit requirements, overlook contradictions between stakeholder groups, and produce inconsistent formatting. The assistant chains a sequence of phases: ingestion, extraction, validation, clustering, conflict detection, and traceability linking. Each phase transforms the data into a progressively more structured form and saves checkpoints for recovery. Use this pipeline approach when you have more than a handful of sources or when multiple stakeholder roles contribute conflicting requirements. For a single, short requirements document with one stakeholder, a manual review with an LLM chat session suffices. In short: the assistant is an assembly line for requirements: ingest, extract, validate, cluster, reconcile, trace.

Figure 13.3 shows how these three layers connect. Sources flow into the ingestion layer, which normalizes them into plain-text chunks. The analysis layer runs five sequential phases (extract, validate, cluster, detect conflicts, trace), saving a checkpoint after each phase so a failed run can resume. The presentation layer consumes the structured output and generates the four export formats.

Transcripts Tickets Documents Ingestion Layer Parse, normalize, tag metadata Analysis Layer Extract Validate Cluster Detect Conflicts Trace Presentation Layer Reports, conflict map, coverage dashboard, JSON / Gherkin / CSV exports Checkpoint saved after each analysis phase for recovery
Figure 13.3: Three-layer architecture of the Requirement Discovery Assistant. Sources enter the ingestion layer, pass through five sequential analysis phases (each saving a checkpoint), and exit through the presentation layer as structured exports.
from pathlib import Path
from dataclasses import dataclass, field
from datetime import datetime
import json


@dataclass
class IngestionSource:
    """Metadata for a single ingested document."""
    source_id: str
    source_type: str  # "transcript", "ticket", "document", "slack"
    original_path: str
    ingested_at: datetime
    word_count: int
    speaker_count: int = 0  # For transcripts


@dataclass
class DiscoverySession:
    """A complete requirement discovery session with all artifacts."""
    session_id: str
    created_at: datetime
    sources: list[IngestionSource] = field(default_factory=list)
    stories: list[ExtractedStory] = field(default_factory=list)
    conflicts: list[Conflict] = field(default_factory=list)
    coverage: dict[str, dict[str, int]] = field(default_factory=dict)
    validation_reports: list[ValidationReport] = field(default_factory=list)

    @property
    def summary(self) -> dict:
        """Compute session summary statistics."""
        return {
            "sources": len(self.sources),
            "total_words": sum(s.word_count for s in self.sources),
            "stories_extracted": len(self.stories),
            "conflicts_detected": len(self.conflicts),
            "high_severity_conflicts": sum(
                1 for c in self.conflicts if c.severity == "high"
            ),
            "validation_pass_rate": (
                sum(1 for r in self.validation_reports if r.is_clean)
                / max(len(self.validation_reports), 1)
            ),
        }
Session management for the Requirement Discovery Assistant. A DiscoverySession bundles all artifacts from one run: sources, extracted stories, conflicts, coverage metrics, and validation reports.

2. The Ingestion Layer

Real-world requirement sources come in many formats. Meeting transcripts arrive as plain text (from Otter.ai, Whisper, or manual transcription). Issue tracker tickets come as structured JSON from Jira or Linear APIs. Documentation lives in Confluence pages, Google Docs, or Markdown files. The ingestion layer normalizes all of these into a common format: plain text with source metadata.

import re


class TranscriptParser:
    """Parse meeting transcripts into speaker-attributed segments."""

    # Common transcript formats:
    # "Speaker Name: text..."
    # "[00:01:23] Speaker Name: text..."
    # "Speaker Name (00:01:23): text..."
    SPEAKER_PATTERN = re.compile(
        r'(?:\[[\d:]+\]\s*)?'      # Optional timestamp in brackets
        r'([A-Z][a-zA-Z\s]+?)'     # Speaker name (capitalized)
        r'(?:\s*\([\d:]+\))?\s*'   # Optional timestamp in parens
        r':\s*'                     # Colon separator
        r'(.+)'                     # Spoken text
    )

    def parse(self, text: str) -> list[dict[str, str]]:
        """Parse a transcript into speaker-attributed segments.

        Returns list of {"speaker": name, "text": content} dicts.
        """
        segments = []
        current_speaker = "Unknown"
        current_text = []

        for line in text.split("\n"):
            line = line.strip()
            if not line:
                continue

            match = self.SPEAKER_PATTERN.match(line)
            if match:
                # Save previous segment
                if current_text:
                    segments.append({
                        "speaker": current_speaker,
                        "text": " ".join(current_text),
                    })
                current_speaker = match.group(1).strip()
                current_text = [match.group(2).strip()]
            else:
                # Continuation of current speaker
                current_text.append(line)

        # Save last segment
        if current_text:
            segments.append({
                "speaker": current_speaker,
                "text": " ".join(current_text),
            })

        return segments

    def extract_speakers(self, segments: list[dict]) -> list[str]:
        """Get unique speaker names from parsed segments."""
        return sorted(set(s["speaker"] for s in segments))

    def to_plain_text(self, segments: list[dict]) -> str:
        """Convert back to plain text, preserving speaker attribution."""
        return "\n\n".join(
            f"{s['speaker']}: {s['text']}" for s in segments
        )


class TicketParser:
    """Parse issue tracker tickets (Jira-style JSON) into requirement text."""

    def parse(self, ticket_json: dict) -> str:
        """Extract requirement-relevant text from a ticket.

        Combines title, description, and acceptance criteria fields.
        """
        parts = []

        title = ticket_json.get("summary", "")
        if title:
            parts.append(f"Title: {title}")

        description = ticket_json.get("description", "")
        if description:
            parts.append(f"Description: {description}")

        # Jira custom fields for acceptance criteria
        ac = ticket_json.get("customfield_acceptance_criteria", "")
        if ac:
            parts.append(f"Acceptance Criteria: {ac}")

        # Comments often contain clarifications
        comments = ticket_json.get("comments", [])
        for comment in comments[:5]:  # Limit to first 5 comments
            author = comment.get("author", "Unknown")
            body = comment.get("body", "")
            if body:
                parts.append(f"Comment by {author}: {body}")

        reporter = ticket_json.get("reporter", "Unknown")
        priority = ticket_json.get("priority", "Medium")

        header = f"[Ticket by {reporter}, Priority: {priority}]"
        return f"{header}\n" + "\n\n".join(parts)


def ingest_directory(
    source_dir: Path,
) -> tuple[list[IngestionSource], dict[str, str]]:
    """Ingest all supported files from a directory.

    Supports: .txt (transcripts), .json (tickets), .md (documents).

    Returns:
        Tuple of (source_metadata_list, {source_id: plain_text}).
    """
    sources = []
    texts = {}
    parser = TranscriptParser()

    for path in sorted(source_dir.iterdir()):
        if path.suffix == ".txt":
            text = path.read_text(encoding="utf-8")
            segments = parser.parse(text)
            sources.append(IngestionSource(
                source_id=path.stem,
                source_type="transcript",
                original_path=str(path),
                ingested_at=datetime.now(),
                word_count=len(text.split()),
                speaker_count=len(parser.extract_speakers(segments)),
            ))
            texts[path.stem] = text

        elif path.suffix == ".json":
            raw = json.loads(path.read_text(encoding="utf-8"))
            ticket_parser = TicketParser()
            # Handle single ticket or array of tickets
            tickets = raw if isinstance(raw, list) else [raw]
            for i, ticket in enumerate(tickets):
                source_id = f"{path.stem}-{i}"
                text = ticket_parser.parse(ticket)
                sources.append(IngestionSource(
                    source_id=source_id,
                    source_type="ticket",
                    original_path=str(path),
                    ingested_at=datetime.now(),
                    word_count=len(text.split()),
                ))
                texts[source_id] = text

        elif path.suffix == ".md":
            text = path.read_text(encoding="utf-8")
            sources.append(IngestionSource(
                source_id=path.stem,
                source_type="document",
                original_path=str(path),
                ingested_at=datetime.now(),
                word_count=len(text.split()),
            ))
            texts[path.stem] = text

    print(f"Ingested {len(sources)} sources "
          f"({sum(s.word_count for s in sources)} words)")
    return sources, texts
The ingestion layer with parsers for transcripts (with speaker attribution), Jira-style tickets, and Markdown documents. The TranscriptParser handles common transcript formats with optional timestamps and speaker labels.

With all sources normalized into plain text and tagged with metadata, the next question is how to coordinate the extraction, validation, and conflict-detection steps into a reliable, recoverable pipeline.

3. The Analysis Orchestrator

The analysis orchestrator coordinates the pipeline from Section 13.2, adding session management, progress tracking, and error recovery. The clustering phase uses DBSCAN (Density-Based Spatial Clustering of Applications with Noise), a clustering algorithm that groups nearby points by density without requiring a predefined number of clusters, controlled by an epsilon parameter that sets the maximum neighbor distance. A smaller epsilon groups only very similar requirements together (risking under-merging), while a larger epsilon merges broader clusters (risking over-merging of distinct requirements). The conflict detection phase then uses cosine similarity (a measure of the angle between two embedding vectors, where 1.0 means identical direction and 0.0 means unrelated) to identify requirement pairs that are topically close yet potentially contradictory. In production, each step can fail independently (an LLM call might time out, an embedding batch might exceed rate limits), so the orchestrator saves intermediate results after each phase.

Common Misconception

A frequent misconception is that the Requirement Discovery Assistant replaces human judgment in requirements engineering, producing a final, authoritative requirement set that can be handed directly to developers. In reality, the assistant is a triage and drafting tool: it surfaces candidate requirements, flags likely conflicts, and highlights coverage gaps, but every output still requires human review, prioritization, and stakeholder sign-off before it becomes an accepted requirement.

from enum import Enum


class PipelinePhase(str, Enum):
    """Phases of the requirement discovery pipeline."""
    INGESTION = "ingestion"
    EXTRACTION = "extraction"
    VALIDATION = "validation"
    CLUSTERING = "clustering"
    CONFLICT_DETECTION = "conflict_detection"
    TRACEABILITY = "traceability"
    COMPLETE = "complete"


class RequirementDiscoveryAssistant:
    """The complete Requirement Discovery Assistant.

    Orchestrates the full pipeline from raw sources to
    validated, conflict-checked requirements with traceability.
    """

    def __init__(
        self,
        client: Anthropic,
        model: str = "claude-sonnet-4-20250514",
        embedding_model: str = "voyage-3",
        output_dir: Path = Path("./discovery_output"),
    ):
        self.client = client
        self.model = model
        self.embedding_model = embedding_model
        self.output_dir = output_dir
        self.output_dir.mkdir(parents=True, exist_ok=True)
        self.session: DiscoverySession | None = None

    def run(
        self,
        source_dir: Path,
        confidence_threshold: float = 0.5,
        cluster_eps: float = 0.15,
        conflict_similarity: float = 0.5,
    ) -> DiscoverySession:
        """Execute the full requirement discovery pipeline.

        Args:
            source_dir: Directory containing source files.
            confidence_threshold: Minimum extraction confidence.
            cluster_eps: DBSCAN epsilon for deduplication.
            conflict_similarity: Cosine threshold for conflict candidates.

        Returns:
            A DiscoverySession with all artifacts.
        """
        session_id = datetime.now().strftime("%Y%m%d-%H%M%S")
        self.session = DiscoverySession(
            session_id=session_id,
            created_at=datetime.now(),
        )

        # Phase 1: Ingestion
        print(f"\n{'='*60}")
        print(f"Phase 1: INGESTION")
        print(f"{'='*60}")
        sources, texts = ingest_directory(source_dir)
        self.session.sources = sources
        self._save_checkpoint(PipelinePhase.INGESTION)

        # Phase 2: Extraction
        print(f"\n{'='*60}")
        print(f"Phase 2: EXTRACTION")
        print(f"{'='*60}")
        all_results = []
        for source_id, text in texts.items():
            print(f"  Extracting from {source_id}...")
            results = extract_requirements(text, self.client, self.model)
            all_results.extend(results)

        self._save_checkpoint(PipelinePhase.EXTRACTION)

        # Phase 3: Validation
        print(f"\n{'='*60}")
        print(f"Phase 3: VALIDATION")
        print(f"{'='*60}")
        clean_stories, reports = filter_and_report(
            all_results, min_confidence=confidence_threshold
        )
        self.session.validation_reports = reports
        self._save_checkpoint(PipelinePhase.VALIDATION)

        # Phase 4: Clustering and deduplication
        print(f"\n{'='*60}")
        print(f"Phase 4: CLUSTERING")
        print(f"{'='*60}")
        if len(clean_stories) >= 2:
            embeddings = embed_stories(clean_stories, self.client)
            clusters = cluster_requirements(
                clean_stories, embeddings, eps=cluster_eps
            )
            final_stories = []
            for label, indices in clusters.items():
                if label == -1:
                    final_stories.extend(
                        clean_stories[i] for i in indices
                    )
                else:
                    final_stories.append(
                        deduplicate_cluster(clean_stories, indices)
                    )
        else:
            final_stories = clean_stories

        self.session.stories = final_stories
        self._save_checkpoint(PipelinePhase.CLUSTERING)

        # Phase 5: Conflict detection
        print(f"\n{'='*60}")
        print(f"Phase 5: CONFLICT DETECTION")
        print(f"{'='*60}")
        conflict_graph = ConflictGraph()
        for i, story in enumerate(final_stories):
            conflict_graph.add_requirement(
                f"US-{i+1:03d}",
                role=story.role,
                capability=story.capability,
            )

        if len(final_stories) >= 2:
            final_embeddings = embed_stories(final_stories, self.client)
            candidates = find_candidate_conflicts(
                final_stories, final_embeddings,
                similarity_threshold=conflict_similarity,
            )
            for idx_a, idx_b, sim in candidates:
                id_a, id_b = f"US-{idx_a+1:03d}", f"US-{idx_b+1:03d}"
                assessment = assess_conflict(
                    final_stories[idx_a], final_stories[idx_b],
                    id_a, id_b, self.client, self.model,
                )
                if assessment.conflicts:
                    conflict_graph.add_conflict(
                        id_a, id_b,
                        conflict_type=assessment.conflict_type,
                        severity=assessment.severity,
                        explanation=assessment.explanation,
                        resolution_hint=assessment.resolution_hint,
                    )

        self.session.conflicts = conflict_graph.find_direct_conflicts()
        self._save_checkpoint(PipelinePhase.CONFLICT_DETECTION)

        # Phase 6: Traceability
        print(f"\n{'='*60}")
        print(f"Phase 6: TRACEABILITY")
        print(f"{'='*60}")
        matrix = TraceabilityMatrix()
        for source in sources:
            matrix.add_source(source.source_id, source.source_type)

        for i, story in enumerate(final_stories):
            req_id = f"US-{i+1:03d}"
            full_story = UserStory(
                id=req_id,
                role=StakeholderRole.RESEARCHER,
                capability=story.capability,
                benefit=story.benefit,
                priority=Priority(story.priority_hint),
                source=story.source_quote,
            )
            matrix.add_story(full_story)

        self.session.coverage = matrix.coverage_report()
        self._save_checkpoint(PipelinePhase.COMPLETE)

        # Generate outputs
        self._export_stories(final_stories)
        self._export_gherkin(final_stories)
        self._export_conflict_report(conflict_graph)
        self._export_session_summary()

        print(f"\n{'='*60}")
        print(f"COMPLETE: {len(final_stories)} requirements, "
              f"{len(self.session.conflicts)} conflicts")
        print(f"Output: {self.output_dir / session_id}")
        print(f"{'='*60}")

        return self.session

    def _save_checkpoint(self, phase: PipelinePhase) -> None:
        """Save intermediate state for recovery."""
        checkpoint_dir = self.output_dir / self.session.session_id
        checkpoint_dir.mkdir(parents=True, exist_ok=True)
        checkpoint = {
            "phase": phase.value,
            "timestamp": datetime.now().isoformat(),
            "summary": self.session.summary,
        }
        path = checkpoint_dir / f"checkpoint_{phase.value}.json"
        path.write_text(json.dumps(checkpoint, indent=2))

    def _export_stories(self, stories: list[ExtractedStory]) -> None:
        """Export stories as JSON for downstream tools."""
        out_dir = self.output_dir / self.session.session_id
        data = [s.model_dump() for s in stories]
        path = out_dir / "requirements.json"
        path.write_text(json.dumps(data, indent=2, default=str))
        print(f"  Exported {len(stories)} stories to {path}")

    def _export_gherkin(self, stories: list[ExtractedStory]) -> None:
        """Export as Gherkin feature files for behavior-driven development (BDD) testing."""
        out_dir = self.output_dir / self.session.session_id
        features_dir = out_dir / "features"
        features_dir.mkdir(exist_ok=True)

        feature_content = "Feature: Discovered Requirements\n\n"
        for i, story in enumerate(stories):
            feature_content += (
                f"  # US-{i+1:03d} (confidence: {story.confidence:.2f})\n"
                f"  Scenario: {story.capability[:80]}\n"
                f"    Given the system is in its default state\n"
                f"    When a {story.role} attempts to {story.capability}\n"
                f"    Then the system enables {story.benefit}\n\n"
            )

        path = features_dir / "discovered_requirements.feature"
        path.write_text(feature_content)
        print(f"  Exported Gherkin features to {path}")

    def _export_conflict_report(self, cg: ConflictGraph) -> None:
        """Export conflict analysis as a structured report."""
        out_dir = self.output_dir / self.session.session_id
        report = {
            "summary": cg.summary(),
            "direct_conflicts": [
                {
                    "req_a": c.req_a,
                    "req_b": c.req_b,
                    "type": c.conflict_type,
                    "severity": c.severity,
                    "explanation": c.explanation,
                    "resolution_hint": c.resolution_hint,
                }
                for c in cg.find_direct_conflicts()
            ],
            "dependency_cycles": cg.find_dependency_cycles(),
            "conflict_clusters": [
                list(cluster)
                for cluster in cg.find_conflict_clusters()
            ],
        }
        path = out_dir / "conflict_report.json"
        path.write_text(json.dumps(report, indent=2))
        print(f"  Exported conflict report to {path}")

    def _export_session_summary(self) -> None:
        """Export a human-readable session summary."""
        out_dir = self.output_dir / self.session.session_id
        summary = self.session.summary
        lines = [
            f"Requirement Discovery Session: {self.session.session_id}",
            f"Created: {self.session.created_at.isoformat()}",
            f"",
            f"Sources: {summary['sources']} "
            f"({summary['total_words']} words)",
            f"Stories extracted: {summary['stories_extracted']}",
            f"Conflicts detected: {summary['conflicts_detected']} "
            f"({summary['high_severity_conflicts']} high severity)",
            f"Validation pass rate: "
            f"{summary['validation_pass_rate']:.0%}",
        ]
        path = out_dir / "session_summary.txt"
        path.write_text("\n".join(lines))
        print(f"  Exported session summary to {path}")
The complete RequirementDiscoveryAssistant class with six pipeline phases, checkpoint recovery, and four export formats (JSON requirements, Gherkin features, conflict report, session summary). Each phase saves intermediate state so a failed run can resume from the last checkpoint.

The checkpoint files enable resumption, but the run method shown above always starts from phase 1. To resume from a failed run, load the most recent checkpoint file from the session directory, deserialize the saved DiscoverySession state, and skip forward to the phase after the last completed checkpoint. In practice, you would add a resume(session_id: str) method that scans the checkpoint directory, identifies the latest completed phase, restores the session object, and re-enters the pipeline at the next phase. This pattern mirrors the write-ahead log strategy used in database recovery: each checkpoint is a consistent snapshot, and replay begins from the last known good state.

Practical Example: Running the Assistant

A biotech startup preparing for a Series B needed to document requirements for their cell-therapy manufacturing platform. They had 15 stakeholder interview transcripts (totaling 42,000 words), 200 Jira tickets from two years of development, and a regulatory guidance document. The Requirement Discovery Assistant processed all sources in under 8 minutes, extracting 187 raw stories, deduplicating to 94 unique requirements, and flagging 11 conflicts. The most critical conflict was between the manufacturing team's requirement for real-time process adjustments and the quality team's requirement for locked, pre-approved protocols. The resolution hint ("distinguish advisory suggestions from protocol modifications; allow real-time advisory while requiring formal change control for protocol changes") was adopted directly into the system design. Without the assistant, the team estimated this analysis would have taken two analysts three weeks.

4. Visualizing the Requirement Landscape

Numbers and JSON exports serve downstream processing, but human reviewers need visual representations to grasp the requirement landscape at a glance. Two key visualizations address this need: a conflict map showing requirement relationships and tensions, and a coverage heatmap showing which stakeholder roles and system areas are well-covered versus underspecified.

Mental Model

Think of the coverage matrix like a doctor's intake checklist for a new patient. The rows are body systems (cardiovascular, respiratory, neurological) and the columns are question categories (current symptoms, family history, medications). A blank cell does not mean the patient is healthy in that area; it means nobody asked the question yet. Just as a thorough physician scans the checklist for empty cells and follows up before making a diagnosis, the requirements engineer scans the coverage matrix for zero-count cells and schedules targeted elicitation sessions before declaring the requirement set complete. The mechanism is the same: absence of information is treated as an action item, not as evidence of no need.

The conflict map uses Graphviz DOT, a plain-text graph description language in which nodes and edges are declared as short statements and then rendered automatically into a positioned layout by the Graphviz engine. The function below produces a DOT string that any Graphviz-compatible renderer (the dot command-line tool, or a web viewer such as Viz.js) can turn into a visual graph.

import networkx as nx


def generate_conflict_map_dot(
    conflict_graph: ConflictGraph,
    stories: list[ExtractedStory],
) -> str:
    """Generate a Graphviz DOT representation of the conflict graph.

    Nodes are colored by stakeholder role.
    Conflict edges are red (high severity) or orange (medium/low).
    Dependency edges are blue dashed arrows.
    """
    role_colors = {
        "researcher": "#4CAF50",
        "lab_technician": "#2196F3",
        "principal_investigator": "#9C27B0",
        "data_engineer": "#FF9800",
        "compliance_officer": "#F44336",
        "system_administrator": "#607D8B",
    }

    lines = [
        "digraph RequirementConflicts {",
        '  rankdir=LR;',
        '  node [shape=box, style="rounded,filled", fontsize=10];',
        '  edge [fontsize=8];',
        "",
    ]

    # Add requirement nodes
    for i, story in enumerate(stories):
        req_id = f"US-{i+1:03d}"
        color = role_colors.get(story.role, "#CCCCCC")
        # Truncate capability for display
        label = story.capability[:50].replace('"', '\\"')
        lines.append(
            f'  "{req_id}" [label="{req_id}\\n{label}...", '
            f'fillcolor="{color}", fontcolor="white"];'
        )

    lines.append("")

    # Add conflict edges
    for conflict in conflict_graph.find_direct_conflicts():
        color = "#D32F2F" if conflict.severity == "high" else "#FF9800"
        label = conflict.conflict_type[:10]
        lines.append(
            f'  "{conflict.req_a}" -> "{conflict.req_b}" '
            f'[color="{color}", penwidth=2, dir=none, '
            f'label="{label}"];'
        )

    # Add dependency edges
    for u, v in conflict_graph.dep_graph.edges():
        lines.append(
            f'  "{u}" -> "{v}" '
            f'[color="#1976D2", style=dashed, label="depends"];'
        )

    lines.append("}")
    return "\n".join(lines)


def generate_coverage_matrix(
    stories: list[ExtractedStory],
    system_areas: list[str] | None = None,
) -> dict[str, dict[str, int]]:
    """Build a coverage matrix: stakeholder roles x system areas.

    Counts how many requirements each role has in each area.
    Helps identify blind spots in requirement coverage.
    """
    if system_areas is None:
        # Infer system areas from story tags or capability keywords
        system_areas = [
            "data_upload", "analysis", "reporting",
            "authentication", "storage", "integration",
            "monitoring", "export", "compliance",
        ]

    matrix = {}
    for story in stories:
        role = story.role
        if role not in matrix:
            matrix[role] = {area: 0 for area in system_areas}

        # Simple keyword matching for area classification
        cap_lower = story.capability.lower()
        for area in system_areas:
            area_keywords = area.replace("_", " ").split()
            if any(kw in cap_lower for kw in area_keywords):
                matrix[role][area] += 1

    return matrix


def print_coverage_heatmap(coverage: dict[str, dict[str, int]]) -> None:
    """Print a text-based coverage heatmap.

    Cells with 0 requirements are highlighted as gaps.
    """
    if not coverage:
        print("No coverage data available.")
        return

    areas = list(next(iter(coverage.values())).keys())
    roles = list(coverage.keys())

    # Header
    header = f"{'Role':<25}" + "".join(f"{a[:12]:>13}" for a in areas)
    print(header)
    print("-" * len(header))

    for role in roles:
        row = f"{role:<25}"
        for area in areas:
            count = coverage[role][area]
            marker = f"{count:>13}" if count > 0 else "        [GAP]"
            row += marker
        print(row)

    # Summary: total gaps
    total_gaps = sum(
        1 for role in roles
        for area in areas
        if coverage[role][area] == 0
    )
    total_cells = len(roles) * len(areas)
    print(f"\nCoverage: {total_cells - total_gaps}/{total_cells} cells "
          f"({100 * (total_cells - total_gaps) / total_cells:.0f}%)")
    print(f"Gaps: {total_gaps} (consider targeted elicitation)")
Visualization generators for the requirement landscape. The Graphviz DOT output produces a color-coded conflict map; the coverage matrix identifies blind spots where specific stakeholder roles have no requirements for specific system areas.
Key Insight: Coverage Gaps Are Interview Questions

Each zero cell in the coverage matrix is not just a gap; it is a question for the next elicitation round. If the compliance officer has zero requirements related to data export, either the compliance team does not care about exports (unlikely) or nobody asked them about it. The coverage matrix transforms passive gap detection into active elicitation planning, connecting to the active requirement elicitation research discussed in Section 13.2. This same pattern of using gaps to drive the next round of inquiry appears in the Bayesian experiment design framework of Chapter 46.

Real-World Application: IBM DOORS and Automated Traceability
Real-World Application: IBM DOORS and Automated Traceability

Visualizations give human reviewers the situational awareness they need, but the assistant becomes far more valuable when other software agents can also consume its outputs programmatically.

5. Exposing the Assistant as an MCP Tool

The Requirement Discovery Assistant becomes far more powerful when it can be invoked by other agents. Following the MCP server patterns from Chapter 12, we expose the assistant's capabilities as MCP tools that the multi-agent teams of Chapter 17 can call. A project-planning agent might invoke the requirement extractor as part of a larger workflow that chains requirements into architecture into implementation.

from mcp.server import Server
from mcp.types import Tool, TextContent
import mcp.server.stdio


server = Server("requirement-discovery")


@server.tool()
async def extract_requirements_from_text(
    text: str,
    source_id: str = "inline",
    confidence_threshold: float = 0.5,
) -> str:
    """Extract structured user stories from unstructured text.

    Accepts a transcript, ticket description, or document and
    returns a JSON array of extracted user stories with confidence
    scores and source quotes.

    Args:
        text: The unstructured text to analyze.
        source_id: Identifier for traceability.
        confidence_threshold: Minimum confidence to include (0.0-1.0).
    """
    client = Anthropic()
    results = extract_requirements(text, client)
    clean, reports = filter_and_report(
        results, min_confidence=confidence_threshold
    )

    output = {
        "source_id": source_id,
        "stories": [s.model_dump() for s in clean],
        "ambiguities": [
            amb for r in results for amb in r.ambiguities
        ],
        "stats": {
            "total_extracted": sum(len(r.stories) for r in results),
            "passed_validation": len(clean),
        },
    }
    return json.dumps(output, indent=2, default=str)


@server.tool()
async def check_requirement_conflicts(
    requirements_json: str,
) -> str:
    """Check a set of requirements for conflicts.

    Accepts a JSON array of requirement objects (each with
    'role', 'capability', 'benefit' fields) and returns
    detected conflicts with resolution hints.

    Args:
        requirements_json: JSON array of requirement objects.
    """
    requirements = json.loads(requirements_json)
    stories = [ExtractedStory(**r) for r in requirements]

    client = Anthropic()
    embeddings = embed_stories(stories, client)
    candidates = find_candidate_conflicts(stories, embeddings)

    conflict_graph = ConflictGraph()
    for i, story in enumerate(stories):
        conflict_graph.add_requirement(
            f"US-{i+1:03d}", role=story.role,
            capability=story.capability,
        )

    for idx_a, idx_b, sim in candidates:
        id_a, id_b = f"US-{idx_a+1:03d}", f"US-{idx_b+1:03d}"
        assessment = assess_conflict(
            stories[idx_a], stories[idx_b],
            id_a, id_b, client,
        )
        if assessment.conflicts:
            conflict_graph.add_conflict(
                id_a, id_b,
                conflict_type=assessment.conflict_type,
                severity=assessment.severity,
                explanation=assessment.explanation,
                resolution_hint=assessment.resolution_hint,
            )

    return json.dumps({
        "conflicts": [
            {
                "req_a": c.req_a, "req_b": c.req_b,
                "type": c.conflict_type,
                "severity": c.severity,
                "explanation": c.explanation,
                "resolution_hint": c.resolution_hint,
            }
            for c in conflict_graph.find_direct_conflicts()
        ],
        "summary": conflict_graph.summary(),
    }, indent=2)


@server.tool()
async def generate_acceptance_criteria(
    story_json: str,
    num_criteria: int = 3,
) -> str:
    """Generate acceptance criteria for a user story.

    Takes a user story and produces Given/When/Then acceptance
    criteria that can be used directly in BDD test frameworks.

    Args:
        story_json: JSON object with 'role', 'capability', 'benefit'.
        num_criteria: Number of criteria to generate (1-5).
    """
    story = json.loads(story_json)

    prompt = f"""Generate {num_criteria} acceptance criteria in Given/When/Then format
for this user story:

As a {story['role']}, I want {story['capability']}, so that {story['benefit']}.

Each criterion must:
1. Have a specific, testable 'Then' clause (include numbers, time bounds, or exact outcomes)
2. Cover a different aspect (happy path, error case, edge case)
3. Be independently verifiable

Return a JSON array of objects with 'given', 'when', 'then' fields."""

    client = Anthropic()
    response = client.messages.create(
        model="claude-sonnet-4-20250514",
        max_tokens=2048,
        messages=[{"role": "user", "content": prompt}],
    )

    return response.content[0].text


async def main():
    """Run the MCP server over stdio."""
    async with mcp.server.stdio.stdio_server() as (read, write):
        await server.run(read, write, server.create_initialization_options())


if __name__ == "__main__":
    import asyncio
    asyncio.run(main())
The Requirement Discovery Assistant exposed as an MCP server with three tools: requirement extraction, conflict checking, and acceptance criteria generation. Any MCP-compatible agent can invoke these tools, enabling requirement discovery as a composable step in larger workflows.
Library Shortcut: End-to-End with PydanticAI

We assembled the assistant from individual components (extraction, validation, clustering, conflict detection) to show how each piece works. In production, PydanticAI can wrap the entire pipeline into a single agent with structured tool definitions, automatic retry on validation failure, and built-in conversation memory. A PydanticAI agent configured with our Pydantic models as output schemas and the extraction/conflict prompts as system instructions reduces the orchestration code from 200 lines to roughly 40 lines. The trade-off is less visibility into intermediate pipeline stages, which matters during development but is acceptable in production.

6. Integration with the Discovery Workbench

The Requirement Discovery Assistant is the first domain-specific tool in the Discovery Workbench. Its outputs feed directly into two downstream components:

Architecture discovery (Chapter 14): the validated requirement set becomes the input for automated architecture exploration. Each requirement constrains the design space. Non-functional requirements (performance, security, availability) map to architectural quality attributes. Conflicts that could not be resolved at the requirements level become architectural trade-offs that the design must accommodate.

Test generation (Chapter 18): the Gherkin feature files exported by the assistant become the skeleton of the test suite. Each acceptance criterion maps to a test scenario; the traceability matrix ensures that every requirement has corresponding test coverage. When requirements change, the traceability links identify which tests need updating.

Composing Tools into Multi-Agent Workflows

The MCP server interface lets other Discovery Workbench tools compose with the assistant. A project-planning agent might chain four steps: (1) extract requirements from stakeholder transcripts, (2) propose an architecture based on those requirements, (3) build an implementation plan from the architecture, and (4) generate a test plan from the acceptance criteria. Each step is a tool call that produces structured output for the next step. Chapter 17 develops this multi-agent software team pattern in full.

Research Frontier: Continuous Requirement Discovery

Our assistant operates in batch mode: ingest transcripts, extract, analyze, export. Emerging research explores continuous requirement discovery, where the system monitors ongoing conversations (Slack channels, GitHub issues, customer support tickets) and maintains a live, evolving requirement model. Arora et al. (2023), "Advancing Requirements Engineering through Generative AI: Assessing the Role of LLMs" (published at the IEEE International Requirements Engineering Conference), systematically evaluated GPT-4 and similar models on five core requirements engineering (RE) tasks: requirement classification, traceability link recovery, ambiguity detection, requirement generation, and conflict identification. Their benchmark showed that LLMs matched or exceeded supervised baselines on classification and ambiguity detection but still underperformed dedicated graph-based methods on traceability recovery, establishing concrete performance envelopes for each subtask. More recently, the REFSQ 2024 workshop on "AI for Requirements Engineering" introduced challenge benchmarks for incremental requirement extraction from streaming sources, pushing toward the continuous-discovery paradigm where new requirements are extracted as they appear, conflicts are detected in real time, and stakeholders receive immediate notifications when a new requirement conflicts with existing ones. The technical challenge is maintaining consistency in the traceability graph under concurrent updates, a problem isomorphic to eventual consistency in distributed databases.

Once the assistant is integrated into the workbench and composable with other agents, the remaining challenge is knowing whether its outputs are actually correct.

7. Evaluating the Assistant

How good are the assistant's requirements? Evaluating quality requires metrics for both extraction (did the assistant find the right requirements?) and analysis (did it catch the real conflicts?). Standard information retrieval metrics, adapted for the requirements domain, provide the answer.

Extraction precision: of the stories the assistant extracted, what fraction are genuine requirements? Measured by having a human analyst label a random sample as "valid requirement" or "noise." In practice, precision for LLM extraction typically ranges from 0.75 to 0.92, depending on transcript quality and prompt design.

Extraction recall: of the requirements a human analyst would identify, what fraction did the assistant find? This requires a human-annotated gold standard. Reported recall figures typically range from 0.80 to 0.95, though these numbers vary with domain complexity and the quality of the gold standard; the confidence threshold trades recall for precision. (Put differently, manual analysis alone misses 5 to 20 percent of genuine requirements that automated extraction catches, requirements that become silent specification gaps until implementation.)

Conflict detection F1: the harmonic mean of conflict precision (flagged conflicts that are real) and recall (real conflicts that were flagged). Low precision means false alarms that waste review time; low recall means undetected conflicts that cause implementation problems.

Checkpoint

So far: the assistant's output quality is measured by three complementary metrics: extraction precision (are the extracted stories genuine requirements?), extraction recall (did we find all the real requirements?), and conflict detection F1 (the balance between flagging real conflicts and avoiding false alarms).

$$F_1 = 2 \cdot \frac{\text{precision} \cdot \text{recall}}{\text{precision} + \text{recall}}$$
from dataclasses import dataclass


@dataclass
class EvaluationResult:
    """Metrics from evaluating the assistant against a gold standard."""
    extraction_precision: float
    extraction_recall: float
    extraction_f1: float
    conflict_precision: float
    conflict_recall: float
    conflict_f1: float
    deduplication_accuracy: float
    coverage_completeness: float  # Fraction of roles with stories

    def __str__(self) -> str:
        return (
            f"Extraction:    P={self.extraction_precision:.2f}  "
            f"R={self.extraction_recall:.2f}  "
            f"F1={self.extraction_f1:.2f}\n"
            f"Conflicts:     P={self.conflict_precision:.2f}  "
            f"R={self.conflict_recall:.2f}  "
            f"F1={self.conflict_f1:.2f}\n"
            f"Deduplication: Acc={self.deduplication_accuracy:.2f}\n"
            f"Coverage:      {self.coverage_completeness:.0%}"
        )


def evaluate_session(
    session: DiscoverySession,
    gold_stories: list[dict],
    gold_conflicts: list[tuple[str, str]],
    gold_duplicates: list[tuple[str, str]],
) -> EvaluationResult:
    """Evaluate a discovery session against a gold standard.

    Args:
        session: The completed discovery session.
        gold_stories: Human-annotated list of true requirements.
        gold_conflicts: Human-annotated list of true conflict pairs.
        gold_duplicates: Human-annotated list of true duplicate pairs.

    Returns:
        EvaluationResult with all metrics.
    """
    # Extraction metrics (simplified: match on capability similarity)
    extracted_caps = {s.capability.lower() for s in session.stories}
    gold_caps = {g["capability"].lower() for g in gold_stories}

    # Use fuzzy matching for fair comparison
    from difflib import SequenceMatcher
    true_positives = 0
    for gc in gold_caps:
        best_match = max(
            (SequenceMatcher(None, gc, ec).ratio() for ec in extracted_caps),
            default=0.0,
        )
        if best_match > 0.6:
            true_positives += 1

    ext_precision = true_positives / max(len(extracted_caps), 1)
    ext_recall = true_positives / max(len(gold_caps), 1)
    ext_f1 = (
        2 * ext_precision * ext_recall
        / max(ext_precision + ext_recall, 1e-9)
    )

    # Conflict metrics
    detected_pairs = {(c.req_a, c.req_b) for c in session.conflicts}
    gold_set = set(gold_conflicts)
    conflict_tp = len(detected_pairs & gold_set)
    conf_precision = conflict_tp / max(len(detected_pairs), 1)
    conf_recall = conflict_tp / max(len(gold_set), 1)
    conf_f1 = (
        2 * conf_precision * conf_recall
        / max(conf_precision + conf_recall, 1e-9)
    )

    # Coverage
    roles_with_stories = len(set(s.role for s in session.stories))
    total_roles = len(StakeholderRole)
    coverage = roles_with_stories / total_roles

    return EvaluationResult(
        extraction_precision=ext_precision,
        extraction_recall=ext_recall,
        extraction_f1=ext_f1,
        conflict_precision=conf_precision,
        conflict_recall=conf_recall,
        conflict_f1=conf_f1,
        deduplication_accuracy=0.0,  # Requires cluster-level evaluation
        coverage_completeness=coverage,
    )
Evaluation framework for the Requirement Discovery Assistant. Extraction quality is measured by precision/recall against a human-annotated gold standard using fuzzy capability matching. Conflict detection uses exact pair matching.
Fun Note

The evaluation framework reveals a recursive irony: evaluating the requirement discovery assistant itself requires requirements. You need to specify what "correct extraction" means, what "real conflict" means, and what "adequate coverage" means. In other words, the assistant's evaluation criteria are themselves requirements that must be discovered, validated, and agreed upon by stakeholders (in this case, the development team). The requirements for requirement tools go all the way down.

Try It: Build a Mini Requirement Discovery Pipeline

You can build a working (simplified) version of the Requirement Discovery Assistant using only Python standard libraries and an LLM API key. Follow these steps:

1. Create three fake transcript files. Write three plain-text files (150 words each) in "Speaker Name: statement" format. Include two speakers per file. Make one transcript about data upload features, one about reporting, and one about access control. Deliberately plant one contradictory pair: have a speaker in file 1 request "all data should be publicly accessible by default" and a speaker in file 3 request "all data must be restricted to authorized users only."

2. Write the ingestion function. Using only pathlib and re, write a function that reads each file, splits it into speaker-attributed segments using a regex, and returns a list of {"source": filename, "speaker": name, "text": content} dictionaries.

3. Extract requirements with an LLM call. For each segment, send the text to an LLM with the prompt: "Extract any user requirements from this statement as JSON objects with fields: role, capability, benefit, confidence (0.0 to 1.0)." Parse the JSON response and collect all extracted stories into a single list.

4. Detect conflicts with cosine similarity. Using sklearn.feature_extraction.text.TfidfVectorizer and sklearn.metrics.pairwise.cosine_similarity, compute pairwise similarity among all extracted capability strings. Flag any pair with similarity above 0.4 as a conflict candidate, then print the pairs for manual review.

5. Generate a coverage report. Build a dictionary mapping each unique speaker role to the set of system areas mentioned in their requirements (use simple keyword matching: "upload", "report", "access", "export"). Print the matrix and identify which role/area cells are empty. Verify that your planted contradiction from step 1 appears in the conflict candidates from step 4.

Exercise 13.3.1

The TranscriptParser uses a regex that expects speaker names to start with a capital letter. Write a short transcript (five lines) that would cause the parser to merge two different speakers into one segment. Then modify the regex pattern to handle the edge case you found.

Hint

Consider what happens when a speaker's name starts with a lowercase word (such as "de Silva" or "van Houten"), or when a line contains a colon inside quoted speech ("He said: let's do it"). The regex matches the first capitalized-word-then-colon pattern it finds, so embedded colons or unconventional name capitalization will fool it.

Step-Through: Coverage Matrix Construction

Trace through generate_coverage_matrix with three extracted stories and three system areas. Stories: (1) role="researcher", capability="upload CSV datasets to the platform"; (2) role="researcher", capability="generate monthly analysis reports"; (3) role="compliance_officer", capability="export audit logs for compliance review". System areas: ["data_upload", "reporting", "export"]. After processing story 1, the matrix is {"researcher": {"data_upload": 1, "reporting": 0, "export": 0}}. After story 2, "reporting" increments to 1 (the word "report" from "reports" matches the area keyword "report"). After story 3, a new row appears: {"compliance_officer": {"data_upload": 0, "reporting": 0, "export": 1}}. Final gap count: 3 out of 6 cells are zero (researcher/export, compliance_officer/data_upload, compliance_officer/reporting), yielding 50% coverage. Each zero cell becomes a question for the next stakeholder interview.

Real-World Application: IBM DOORS and Automated Traceability

IBM Engineering DOORS Next (formerly Rational DOORS) is one of the most widely deployed requirements management tools in aerospace and defense. Organizations using DOORS have reportedly integrated LLM-based extraction pipelines similar to the one in this section to auto-generate traceability links between natural-language requirements and test cases, with some teams reporting reductions in manual linking effort on the order of 60% on programs with thousands of requirements. The coverage matrix pattern maps directly to DOORS "suspect links" feature, where changes to a requirement automatically flag downstream artifacts for review.

Lab: Conflict Detection Sensitivity Analysis

Goal: Determine how the conflict_similarity threshold affects conflict detection precision and recall on a controlled requirement set.
Tools needed: Python 3.10+, scikit-learn (for term frequency-inverse document frequency (TF-IDF) and cosine similarity), matplotlib.
Setup: Create 20 synthetic user stories as short strings. Designate 4 pairs as genuine conflicts (contradictory capabilities, e.g., "restrict data access to authorized users" vs. "make all datasets publicly browsable") and 6 pairs as topically similar but non-conflicting (e.g., "upload CSV files" vs. "upload Excel files").
What to vary: Sweep the cosine similarity threshold from 0.1 to 0.9 in steps of 0.05. At each threshold, compute precision (fraction of flagged pairs that are true conflicts) and recall (fraction of true conflicts that were flagged).
What to observe: Plot the precision-recall curve. Identify the threshold that maximizes F1. Notice how the curve shifts if you switch from TF-IDF to sentence embeddings (use sentence-transformers with the "all-MiniLM-L6-v2" model; as of 2025, newer models such as "all-mpnet-base-v2" or the Nomic "nomic-embed-text-v1.5" often provide stronger semantic discrimination for short requirement texts). The embedding-based curve should show a sharper elbow, reflecting better semantic discrimination.

Exercises

  1. Conceptual: The assistant processes transcripts in batch after interviews are complete. Propose a design for a real-time version that sits in a video call, extracts requirements from the live conversation, and surfaces potential conflicts to the facilitator as they emerge. What latency constraints does this impose? How would you handle the speaker changing mid-sentence, interruptions, and off-topic tangents?
  2. Coding: Extend the MCP server with a fourth tool, suggest_next_interview_questions, that analyzes the current requirement set and produces a prioritized list of questions aimed at: (a) resolving open conflicts, (b) filling coverage gaps, and (c) quantifying ambiguous terms. The tool should accept the current requirements JSON and return a ranked list of questions with justifications. Test with a requirement set that has 2 conflicts, 3 coverage gaps, and 4 ambiguous terms.
  3. Analysis: The assistant uses a fixed confidence threshold (default 0.5) to filter extracted stories. Too high a threshold causes recall loss (genuine requirements discarded); too low causes precision loss (noise accepted). Design an adaptive thresholding strategy that adjusts the confidence cutoff based on the source type (transcripts tend to be noisier than formal documents) and the extraction pass number (a second extraction pass on the same text should use a higher threshold to avoid duplicates). Implement and test with synthetic data.

What's Next

The assistant now produces validated, conflict-checked requirements with full traceability. Chapter 14: Discovery of Architectures takes this requirement set and explores the design space of components, connectors, deployment topologies, and quality attribute trade-offs. The traceability matrix extends from requirements through architecture to implementation, chaining stakeholder needs to running code. That pipeline follows the same pattern: AI-assisted exploration of a structured space, with human validation at decision points.