Có lần một client gửi cho tôi một tin nhắn Slack với nội dung: “Bạn có thể xây cho tôi một nền tảng kết nối freelancer với client không? Giống Upwork, nhưng tốt hơn. Chắc mất vài tuần thôi.”
Tôi đã viết phần mềm được mười hai năm. Tôi biết “giống Upwork nhưng tốt hơn, vài tuần” thực sự có nghĩa là gì. Nó có nghĩa là client đang hình dung sản phẩm cuối cùng trong đầu — phiên bản đã hoàn thiện, đầy đủ tính năng, thành công — và không nhìn thấy được khoảng cách giữa tầm nhìn đó và dòng code đầu tiên. Upwork có khoảng 600 kỹ sư và vốn hóa thị trường 1,5 tỷ đô. “Vài tuần” không đưa bạn đến đó được.
Nhưng vấn đề là thế này: đây không phải lỗi của client. Đây là vấn đề giao tiếp. Client đang làm đúng những gì client nên làm — họ đang mô tả kết quả mong muốn bằng ngôn ngữ của lĩnh vực họ, không phải của bạn. Công việc của một Product Owner giỏi là dịch giấc mơ đó thành thứ mà đội phát triển thực sự có thể xây dựng.
Trong Phần 4, chúng ta đã xây dựng class BaseAgent — nền tảng mà mọi agent trong đội AI phần mềm của chúng ta kế thừa. Chúng ta đã có tích hợp LLM, lịch sử hội thoại, phát domain event, và retry logic — tất cả hoạt động trong một base class sạch sẽ, tái sử dụng được. Giờ chúng ta xây hai agent chuyên biệt đầu tiên: Product Owner (PO) và Business Analyst (BA).
Hai agent này có công việc khó nhất trong hệ thống. Mọi agent đến sau — Tech Architect, Senior Software Engineer, QC Engineer — đều phụ thuộc hoàn toàn vào chất lượng đầu ra của PO và BA. Nếu họ tạo ra output mơ hồ, thiếu rõ ràng, các agent downstream sẽ khuếch đại sự mơ hồ đó thành code lỗi và test thất bại. Nếu họ tạo ra output chính xác, có cấu trúc tốt, mọi thứ còn lại trong pipeline sẽ dễ dàng hơn đáng kể.
Hãy xây dựng chúng cho đúng.
1. Vấn Đề Client Brief
Đây là brief mà tôi nhận được thường xuyên nhất dưới dạng này hay dạng khác:
“Xây cho tôi một app giống Uber nhưng cho chó. Người dùng có thể đặt người dắt chó, theo dõi chó theo thời gian thực, thanh toán qua app, và để lại đánh giá. Tôi cũng muốn một web dashboard cho người dắt chó. Và có lẽ một admin panel nữa. Chắc khá đơn giản thôi.”
Brief này chứa thông tin thật. Có ý định chân thật ở đây. Nhưng nó hoàn toàn vô dụng như một đặc tả cho đội phát triển. Để tôi đếm các vấn đề:
Vấn đề 1: Thiếu định nghĩa scope. “Đặt người dắt chó” nghĩa là gì chính xác? Người dùng duyệt danh sách người dắt có sẵn rồi yêu cầu một người? Hệ thống tự động ghép cặp dựa trên vị trí và lịch trống? Có lịch không? Chính sách hủy? Danh sách chờ?
Vấn đề 2: Giả định công nghệ ẩn trong ngôn ngữ kinh doanh. “Theo dõi thời gian thực” không phải là một tính năng. Nó là một quyết định kiến trúc hệ thống. Theo dõi GPS thời gian thực đòi hỏi một mobile app (không phải web app), backend WebSocket, push notification, và các vấn đề tối ưu pin. “Khá đơn giản” không sống sót được khi gặp “GPS thời gian thực.”
Vấn đề 3: Persona người dùng chưa được xác định. Chủ chó là ai? Một chuyên gia bận rộn đặt lịch mỗi tuần một lần? Một người về hưu muốn ai đó dắt chó mỗi sáng? Mức độ thoải mái với công nghệ của họ? Họ dùng thiết bị gì?
Vấn đề 4: Không có tiêu chí thành công. “Thành công” có nghĩa là gì cho app này? Doanh thu? Số lượng đặt lịch? Tỷ lệ giữ chân người dắt chó? Điểm hài lòng khách hàng? Không có tiêu chí thành công, chúng ta không thể ưu tiên tính năng, và không thể biết khi nào chúng ta hoàn thành.
Vấn đề 5: Scope creep lộ thiên. “Có lẽ một admin panel” đang gánh rất nhiều trong câu đó. Admin panel cho cái gì? Quản lý người dùng? Đối soát tài chính? Phát hiện gian lận? Giải quyết tranh chấp? Mỗi thứ đó là một nhánh tính năng đáng kể.
Người viết brief này không cẩu thả. Họ đang mô tả thứ họ muốn bằng vốn từ vựng họ có. Sự cẩu thả sẽ ở phía chúng ta nếu chúng ta chấp nhận brief này nguyên trạng và bắt đầu xây dựng.
Đây là vấn đề mà PO agent giải quyết.
”Yêu Cầu Tốt” Thực Sự Trông Như Thế Nào
Một tài liệu yêu cầu tốt trả lời năm câu hỏi:
- Ai là người dùng? (Persona — tên, động lực, điểm đau)
- Họ cần làm gì? (Tính năng — cụ thể, có giới hạn, triển khai được)
- Tại sao nó quan trọng? (Chỉ số thành công — đo lường được, có thời hạn)
- Cái gì nằm ngoài scope? (Loại trừ rõ ràng — ngăn scope creep)
- Chúng ta đang giả định điều gì? (Được bộc lộ, không ẩn giấu — ngăn bất ngờ)
Công việc của PO agent là trích xuất các câu trả lời này từ brief thô, đặt câu hỏi có mục tiêu để lấp đầy khoảng trống, và tạo ra một RequirementDoc mà đội phát triển có thể làm việc từ đó.
Công việc của BA agent là lấy RequirementDoc đó và phân tách thành các object UserStory riêng lẻ với acceptance criteria — đơn vị công việc nguyên tử mà kỹ sư có thể triển khai và QC engineer có thể xác nhận.
2. Thiết Kế PO Agent
Trước khi viết một dòng code nào, hãy suy nghĩ chính xác về vai trò của PO agent.
Nhận vào: Một brief thô từ stakeholder. Cũng có thể nhận câu trả lời cho các câu hỏi làm rõ trong lần chạy thứ hai.
Tạo ra: Một RequirementDoc có cấu trúc với persona, tính năng, chỉ số thành công, các mục ngoài scope, giả định, và ràng buộc kỹ thuật.
Tính cách: PO agent có tên là Alex. Alex là senior — kiên nhẫn, hoài nghi một cách chuyên nghiệp, tập trung vào việc tạo giá trị. Alex không hào hứng với tính năng chỉ vì bản thân chúng. Alex đặt những câu hỏi khó chịu: “Điều gì xảy ra nếu chúng ta cắt tính năng đó? Người dùng có vẫn nhận được giá trị không?” Alex là người trong phòng luôn phản bác “sẽ tuyệt vời nếu.”
Thiết kế hai lượt: PO agent chạy hai lần. Lượt 1: nhận brief thô, đặt 5 câu hỏi làm rõ. Lượt 2: nhận câu trả lời cho các câu hỏi đó, tạo ra RequirementDoc có cấu trúc. Thiết kế hai lượt này rất quan trọng. Nó buộc có một checkpoint với con người — stakeholder phải trả lời các câu hỏi trước khi hệ thống tiếp tục. Đây không phải hạn chế; đây là thiết kế có chủ đích. Yêu cầu chưa bao giờ bị thách thức không phải là yêu cầu. Chúng là ước muốn.
Công Cụ Của PO Agent
PO agent có quyền truy cập hai công cụ:
search_web(query: str) -> str — Để nghiên cứu thị trường. Nếu brief đề cập đối thủ cạnh tranh (“giống Airbnb cho thuyền”), PO agent nên tìm hiểu đối thủ đó thực sự làm gì trước khi đặt câu hỏi về scope. Điều này ngăn những câu hỏi làm rõ đáng xấu hổ mà stakeholder mong đợi agent đã biết câu trả lời.
create_document(title: str, content: str) -> str — Để ghi RequirementDoc vào bộ lưu trữ bền vững. Đây là cách tài liệu trở nên khả dụng cho các agent downstream.
Ranh Giới Quyền Hạn
PO agent CÓ THỂ:
- Đặt câu hỏi làm rõ về mục tiêu kinh doanh và nhu cầu người dùng
- Nghiên cứu thị trường để hiểu bối cảnh cạnh tranh
- Xác định scope (cái gì trong và ngoài scope)
- Phê duyệt user story do BA agent tạo ra
- Yêu cầu chỉnh sửa user story nếu chúng không khớp với
RequirementDoc
PO agent KHÔNG THỂ:
- Viết code hoặc cung cấp chi tiết triển khai kỹ thuật
- Đưa ra quyết định deployment
- Phê duyệt các lựa chọn kiến trúc kỹ thuật (đó là lĩnh vực của Tech Architect)
- Cam kết timeline giao hàng (đó là lĩnh vực của PM)
Những ranh giới quyền hạn này không chỉ là tài liệu. Chúng được thực thi bởi system prompt và bộ công cụ chúng ta cung cấp cho agent. PO agent không có công cụ thực thi code, không có công cụ GitHub, không có công cụ deployment. Những gì không thể làm thì không thể xảy ra do vô ý.
3. Triển Khai Đầy Đủ POAgent
Để tôi trình bày triển khai đầy đủ. Phần này xây dựng trực tiếp trên BaseAgent từ Phần 4.
# agents/po_agent.py
import json
import re
from typing import Optional
from agents.base import BaseAgent
from domain.models import RequirementDoc, UserPersona, Feature
from domain.state import TeamState
from domain.events import RequirementsCleared
class POAgent(BaseAgent):
"""
Product Owner Agent — Alex.
Transforms raw client briefs into structured RequirementDoc objects.
Runs in two passes:
Pass 1: raw_brief is present, clarified_requirements is None
→ ask 5 clarifying questions
Pass 2: both raw_brief and clarification_answers are present
→ produce RequirementDoc
"""
AGENT_ID = "po_agent"
AGENT_NAME = "Alex"
AGENT_ROLE = "Senior Product Owner"
DEFAULT_MODEL = "claude-opus-4-6"
# System prompt stays constant across both passes
SYSTEM_PROMPT = """You are Alex, a Senior Product Owner with 12 years of experience
delivering software products. You have worked at startups and scale-ups. You are
methodical, user-focused, and professionally skeptical of features that don't
directly serve user needs.
Your job is to turn raw client briefs into structured requirement documents that
development teams can work from. You ask the uncomfortable questions early so
that nobody wastes time building the wrong thing.
You always think in terms of:
- User personas: who are the real humans using this?
- Core value: what problem does this solve, specifically?
- MVP scope: what is the minimum set of features that delivers that value?
- Success metrics: how will we know if this worked?
- Out-of-scope items: what are we explicitly NOT building?
You write clearly, concisely, and in plain language. You avoid jargon.
When you produce JSON, it is valid JSON with no extra text before or after.
"""
def _prepare_prompt(self, state: TeamState) -> str:
"""Build the prompt based on current state."""
has_brief = bool(state.get("raw_brief"))
has_answers = bool(state.get("clarification_answers"))
if not has_brief:
raise ValueError("POAgent requires raw_brief in state")
if not has_answers:
# First pass — ask clarifying questions
return self._clarification_prompt(state["raw_brief"])
else:
# Second pass — produce structured RequirementDoc
return self._structuring_prompt(state)
def _clarification_prompt(self, brief: str) -> str:
return f"""A client has submitted the following project brief:
---
{brief}
---
Your task is to ask exactly 5 clarifying questions that will give you what you
need to write a complete requirement document.
Each question should target one of these areas:
1. Core user personas (who are the primary users, and what is their context?)
2. Key features — MVP vs nice-to-have (if you could only ship 3 things, what are they?)
3. Success metrics (how will the client measure whether this project succeeded?)
4. Technical constraints (existing systems, platforms, compliance requirements, etc.)
5. Timeline and budget signals (what is the urgency? is there a hard deadline?)
Before asking your questions, do a quick internal assessment of what the brief
already tells you. Do not ask questions the brief already answers.
Format your response as a numbered list of 5 questions. Be direct. Each question
should be answerable in 1-3 sentences.
"""
def _structuring_prompt(self, state: TeamState) -> str:
brief = state["raw_brief"]
answers = state.get("clarification_answers", "")
search_context = state.get("po_research_notes", "")
research_section = ""
if search_context:
research_section = f"""
Market Research Notes:
{search_context}
"""
return f"""You now have everything you need to produce a structured requirement document.
Original Brief:
---
{brief}
---
Answers to Clarifying Questions:
---
{answers}
---
{research_section}
Produce a RequirementDoc as a JSON object with this exact schema. Return ONLY
the JSON object — no preamble, no explanation, no code fences.
{{
"project_name": "short name for the project",
"elevator_pitch": "one sentence: who it's for, what it does, why it matters",
"personas": [
{{
"name": "persona name (e.g. 'Dog Owner Dana')",
"description": "2-3 sentences describing this person",
"pain_points": ["pain point 1", "pain point 2"],
"goals": ["goal 1", "goal 2"]
}}
],
"features": [
{{
"title": "feature name",
"description": "what this feature does",
"priority": "must-have" | "should-have" | "nice-to-have",
"estimated_complexity": "small" | "medium" | "large"
}}
],
"success_metrics": ["metric 1", "metric 2"],
"out_of_scope": ["item 1", "item 2"],
"assumptions": ["assumption 1", "assumption 2"],
"tech_constraints": ["constraint 1", "constraint 2"]
}}
Rules:
- Minimum 2 personas, maximum 4
- Minimum 5 features, maximum 15
- Features must cover both must-haves and should-haves; at least 3 must be must-have
- Success metrics must be measurable (avoid vague metrics like "user satisfaction")
- Out-of-scope must be explicit — list at least 3 items
- Assumptions must be surfaced — list at least 2
"""
def _parse_output(self, response: str, state: TeamState) -> TeamState:
"""
Parse LLM output into state updates.
Pass 1 (clarification questions): stores questions in state for
human review and response.
Pass 2 (structured doc): parses JSON into RequirementDoc and
emits RequirementsCleared domain event.
"""
has_answers = bool(state.get("clarification_answers"))
if not has_answers:
# Pass 1: response contains clarifying questions
return {
**state,
"po_clarifying_questions": response.strip(),
"requirement_state": "awaiting_clarification",
}
else:
# Pass 2: response should be JSON RequirementDoc
req_doc = self._parse_requirement_doc(response)
event = RequirementsCleared(
source_agent=self.AGENT_ID,
project_name=req_doc.project_name,
feature_count=len(req_doc.features),
persona_count=len(req_doc.personas),
)
return {
**state,
"clarified_requirements": req_doc.model_dump(),
"requirement_state": "clarified",
"event_outbox": self._append_event(state, event),
}
def _parse_requirement_doc(self, response: str) -> RequirementDoc:
"""Extract and validate RequirementDoc from LLM response."""
# Strip any accidental markdown fences
cleaned = re.sub(r"```(?:json)?", "", response).strip()
# Find the JSON object
match = re.search(r"\{.*\}", cleaned, re.DOTALL)
if not match:
raise ValueError(
f"Could not find JSON object in PO response: {response[:200]}"
)
data = json.loads(match.group())
return RequirementDoc.model_validate(data)
async def _run_tools(self, state: TeamState) -> TeamState:
"""
Pre-LLM tool execution: if the brief mentions competitors,
do market research before structuring the document.
Only runs on the second pass.
"""
if not state.get("clarification_answers"):
return state # No research needed on first pass
if state.get("po_research_notes"):
return state # Already researched
brief = state.get("raw_brief", "")
competitor_signals = [
"like ", "similar to", "uber for", "airbnb for", "spotify for"
]
needs_research = any(sig in brief.lower() for sig in competitor_signals)
if needs_research:
# Extract the comparison and search for context
query = f"market analysis {brief[:100]} key features competitors"
research = await self.tools["search_web"](query)
return {**state, "po_research_notes": research}
return state
Một vài điểm đáng lưu ý trong triển khai này.
Thiết kế hai lượt được thực thi bằng kiểm tra state, không phải bằng một node riêng. Cùng một method POAgent.run() xử lý cả hai lượt. Nó kiểm tra state.get("clarification_answers") để xác định prompt nào cần xây dựng. Điều này giữ cho LangGraph graph đơn giản — có một node po_agent, và nó làm đúng việc dựa trên state.
System prompt là hằng số. Tính cách và ràng buộc của Alex không thay đổi giữa các lượt. Chỉ user prompt thay đổi. Điều này giữ cho danh tính của agent ổn định.
Phân tích JSON mang tính phòng thủ. LLM thỉnh thoảng sẽ bọc JSON trong markdown code fence dù đã được bảo không làm vậy. Method _parse_requirement_doc loại bỏ các fence đó trước khi phân tích. Đây không phải workaround; đây là thực tế production.
Thực thi công cụ xảy ra trước lệnh gọi LLM. Method _run_tools làm giàu state với ghi chú nghiên cứu trước khi _prepare_prompt được gọi. Prompt sau đó có thể kết hợp ngữ cảnh nghiên cứu đó. Base class gọi _run_tools trước khi xây dựng prompt.
4. Schema RequirementDoc
Các model Pydantic xác định những gì PO agent tạo ra. Chúng nằm trong domain/models.py cùng với các domain object khác từ Phần 3.
# domain/models.py (PO section)
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator
class UserPersona(BaseModel):
"""
Represents a distinct type of user for the product.
Personas are not demographics — they are behavioral archetypes.
Two people of the same age can be completely different personas
if they use the product for different reasons.
"""
name: str = Field(
description="A memorable name that evokes the persona (e.g. 'Commuter Carlos')"
)
description: str = Field(
description="2-3 sentences describing who this person is and their relationship to the product"
)
pain_points: list[str] = Field(
min_length=1,
description="The problems this persona has that the product addresses"
)
goals: list[str] = Field(
min_length=1,
description="What this persona wants to achieve using the product"
)
@field_validator("name")
@classmethod
def name_not_generic(cls, v: str) -> str:
generic = ["user", "customer", "person", "individual"]
if v.lower().strip() in generic:
raise ValueError(
f"Persona name '{v}' is too generic. Use a descriptive name."
)
return v
class Feature(BaseModel):
"""
A discrete capability the product will (or will not) provide.
Features at this stage are intentionally coarse-grained.
The BA agent will decompose them into user stories.
"""
title: str = Field(
description="Short, action-oriented title (e.g. 'Real-time GPS Tracking')"
)
description: str = Field(
description="What this feature does and why it matters to the user"
)
priority: Literal["must-have", "should-have", "nice-to-have"] = Field(
description=(
"MoSCoW priority: "
"must-have = product fails without it, "
"should-have = significantly reduces value if absent, "
"nice-to-have = delightful but not required for MVP"
)
)
estimated_complexity: Literal["small", "medium", "large"] = Field(
description=(
"Order-of-magnitude complexity estimate: "
"small = hours/1-2 days, "
"medium = days/1 week, "
"large = weeks/multiple sprints"
)
)
@field_validator("title")
@classmethod
def title_is_not_empty(cls, v: str) -> str:
if len(v.strip()) < 3:
raise ValueError("Feature title too short")
return v.strip()
class RequirementDoc(BaseModel):
"""
The structured output of the PO agent.
This is the aggregate root of the Requirement Context.
Everything downstream in the pipeline derives from this document.
Design principle: every field in this model should be actionable.
If a field cannot influence a decision (technical, prioritization,
or scope), it should not be in this model.
"""
project_name: str = Field(
description="A short, memorable name for the project"
)
elevator_pitch: str = Field(
description=(
"One sentence: 'For [persona] who [need], [product] is a [category] "
"that [key benefit]. Unlike [alternative], [differentiator].'"
)
)
personas: list[UserPersona] = Field(
min_length=2,
max_length=4,
description="The 2-4 primary user archetypes"
)
features: list[Feature] = Field(
min_length=5,
max_length=15,
description="The features being considered, with priorities"
)
success_metrics: list[str] = Field(
min_length=2,
description="Measurable indicators of project success"
)
out_of_scope: list[str] = Field(
min_length=3,
description="Explicit list of things we are NOT building"
)
assumptions: list[str] = Field(
min_length=2,
description="Stated assumptions that could invalidate requirements if wrong"
)
tech_constraints: list[str] = Field(
default_factory=list,
description="Known technical constraints (platforms, integrations, compliance)"
)
@property
def must_have_features(self) -> list[Feature]:
return [f for f in self.features if f.priority == "must-have"]
@property
def mvp_complexity_estimate(self) -> str:
"""Rough total complexity of must-have features."""
weights = {"small": 1, "medium": 3, "large": 8}
total = sum(
weights[f.estimated_complexity]
for f in self.must_have_features
)
if total <= 5:
return "small (days to 1-2 weeks)"
elif total <= 15:
return "medium (2-6 weeks)"
else:
return "large (2+ months)"
def summary(self) -> str:
"""Human-readable summary for logging and notifications."""
must_have_count = len(self.must_have_features)
return (
f"RequirementDoc: {self.project_name} | "
f"{len(self.personas)} personas | "
f"{len(self.features)} features ({must_have_count} must-have) | "
f"MVP estimate: {self.mvp_complexity_estimate}"
)
Schema này thực thi một số ràng buộc ngăn chặn các lỗi phổ biến trong tài liệu yêu cầu:
UserPersona.nametừ chối các tên chung chung như “user” hay “customer.” Nếu PO agent tạo ra persona tên “User,” điều đó có nghĩa nó không suy nghĩ theo hướng con người thật.RequirementDoc.out_of_scopeyêu cầu ít nhất 3 mục. Đây là một hàm ép buộc. Tài liệu yêu cầu không có mục ngoài scope rõ ràng sẽ tích lũy scope creep cho đến khi sụp đổ.RequirementDoc.assumptionsyêu cầu ít nhất 2 mục. Giả định ẩn là cách các dự án thất bại sau sáu tháng. Bộc lộ chúng sớm.- Property
mvp_complexity_estimatecho các agent downstream (và PM agent) một cảm nhận sơ bộ về quy mô mà không cần thêm một field riêng mà LLM phải điền.
5. Thiết Kế BA Agent
Với RequirementDoc trong tay, Business Analyst agent tiếp quản. Công việc của BA agent là phân tách: lấy các tính năng cấp cao và biến chúng thành các user story cụ thể, kiểm tra được, triển khai được.
BA agent có tên là Jordan. Jordan chính xác, có phương pháp, và đối kháng theo nghĩa tốt nhất — họ nghĩ về những gì có thể sai, những edge case nào đang ẩn náu, và acceptance criteria cần bao phủ những gì để ngăn tranh luận trong tương lai về việc liệu một tính năng đã “xong” hay chưa.
Nhận vào: Một RequirementDoc từ PO agent.
Tạo ra: Một danh sách các object UserStory, mỗi cái có:
- Nội dung story theo format “As a… I want to… so that…”
- Acceptance criteria theo format Given/When/Then
- Các edge case cần test
- Dependency với các story khác
- Ước tính story point
Insight Chính Của BA: Thách Thức Các Giả Định
Mỗi tính năng trong RequirementDoc mang theo các giả định ẩn. Công việc của BA agent là bộc lộ chúng ở cấp story bằng cách hỏi “điều gì xảy ra khi X thất bại?” một cách có hệ thống.
Xét một tính năng như “Người dùng có thể đặt người dắt chó.” Các giả định ẩn trong câu đó:
- Điều gì nếu không có người dắt chó nào khả dụng trong khu vực của người dùng?
- Điều gì nếu người dùng cố đặt cho thời điểm cách đây chưa đến 2 giờ?
- Điều gì nếu phương thức thanh toán của người dùng bị lỗi trong lúc đặt?
- Điều gì nếu người dắt chó hủy sau khi đơn đặt đã được xác nhận?
- Điều gì nếu người dùng có nhiều chó hơn mức người dắt được đánh giá?
Không có cái nào trong số này là edge case. Chúng là các trường hợp chính xảy ra hàng ngày trên bất kỳ nền tảng marketplace nào. BA agent cần tạo ra user story bao phủ happy path và các chế độ lỗi này. Nếu không, QC agent sẽ tìm thấy chúng sau — sau khi SSE agent đã ship code không xử lý chúng.
Công Cụ Của BA Agent
read_document(doc_id: str) -> str — Để truy xuất RequirementDoc do PO agent viết. Đây là đầu vào chính của BA.
create_document(title: str, content: str) -> str — Để ghi backlog user story như một tài liệu bền vững mà các agent downstream có thể truy cập.
BA agent không có quyền tìm kiếm web. Nó làm việc hoàn toàn từ RequirementDoc. Nếu nó phát hiện lỗ hổng trong yêu cầu — những thứ mơ hồ hoặc thiếu — nó đánh dấu chúng là giả định thay vì tự bịa câu trả lời.
6. Triển Khai Đầy Đủ BAAgent
# agents/ba_agent.py
import json
import re
from typing import Optional
from agents.base import BaseAgent
from domain.models import UserStory, AcceptanceCriteria, StoryStatus
from domain.state import TeamState
from domain.events import UserStoriesCreated
class BAAgent(BaseAgent):
"""
Business Analyst Agent — Jordan.
Decomposes a RequirementDoc into a prioritized backlog of UserStory
objects with acceptance criteria, edge cases, and story point estimates.
"""
AGENT_ID = "ba_agent"
AGENT_NAME = "Jordan"
AGENT_ROLE = "Senior Business Analyst"
DEFAULT_MODEL = "claude-opus-4-6"
SYSTEM_PROMPT = """You are Jordan, a Senior Business Analyst with 10 years of
experience writing user stories for Agile software teams. You have worked on
consumer apps, B2B platforms, and internal tools.
Your output is what developers read when they start writing code. Every ambiguity
you leave in a story is a decision a developer will make on their own — often wrong.
Every edge case you miss is a bug that gets found in QA or, worse, in production.
You write stories in the classic format: "As a [persona], I want to [action] so
that [benefit]." You write acceptance criteria in Given/When/Then format. You
think adversarially about edge cases — not paranoid, but thorough.
Story points follow the Fibonacci scale: 1, 2, 3, 5, 8.
1 = trivial (a label change, a static page)
2 = simple (one happy path, no edge cases)
3 = medium (one happy path, 2-3 edge cases)
5 = complex (multiple paths, system integration)
8 = epic that should be split further
When you produce JSON, it is valid JSON with no extra text before or after.
"""
def _prepare_prompt(self, state: TeamState) -> str:
req_doc = state.get("clarified_requirements")
if not req_doc:
raise ValueError("BAAgent requires clarified_requirements in state")
# Format the requirement doc as readable text for the prompt
req_text = self._format_req_doc(req_doc)
return f"""You have received the following requirement document from the Product Owner:
{req_text}
Your task: produce a complete set of user stories covering all must-have features,
and the most critical should-have features.
Instructions:
1. Write one or more user stories per must-have feature. Large features may need
2-3 stories. Small features may map to one story.
2. For each story, write 2-4 acceptance criteria in Given/When/Then format.
3. For each story, list 2-3 edge cases that must be handled.
4. Identify any dependencies between stories (a story that cannot be implemented
before another story).
5. Estimate story points using Fibonacci scale: 1, 2, 3, 5, 8.
If a story estimates to 8 points, split it into two stories.
Return a JSON array of story objects. Return ONLY the JSON array — no preamble,
no explanation, no code fences.
[
{{
"story_id": "US-001",
"title": "short title",
"story_text": "As a [persona], I want to [action] so that [benefit]",
"persona": "persona name from RequirementDoc",
"feature_ref": "title of the feature this story implements",
"priority": "must-have" | "should-have" | "nice-to-have",
"story_points": 1 | 2 | 3 | 5,
"acceptance_criteria": [
{{
"id": "AC-001-1",
"given": "the precondition",
"when": "the action taken",
"then": "the expected outcome"
}}
],
"edge_cases": ["edge case description 1", "edge case description 2"],
"dependencies": ["US-XXX", "US-YYY"],
"notes": "optional: anything the developer should know that doesn't fit above"
}}
]
Think carefully before writing. A good user story:
- Is testable: the acceptance criteria are binary pass/fail
- Is small: a developer can finish it in 1-3 days
- Is independent: it can be tested in isolation (or dependencies are explicit)
- Has a clear "so that" clause that explains business value, not just functionality
"""
def _format_req_doc(self, req_doc: dict) -> str:
"""Format the RequirementDoc dict as readable text for the prompt."""
lines = [
f"Project: {req_doc['project_name']}",
f"Elevator Pitch: {req_doc['elevator_pitch']}",
"",
"Personas:",
]
for p in req_doc.get("personas", []):
lines.append(f" - {p['name']}: {p['description']}")
for pain in p.get("pain_points", []):
lines.append(f" Pain: {pain}")
lines.append("")
lines.append("Features:")
for f in req_doc.get("features", []):
lines.append(
f" [{f['priority'].upper()}] {f['title']} ({f['estimated_complexity']})"
)
lines.append(f" {f['description']}")
lines.append("")
lines.append("Success Metrics:")
for m in req_doc.get("success_metrics", []):
lines.append(f" - {m}")
lines.append("")
lines.append("Out of Scope:")
for o in req_doc.get("out_of_scope", []):
lines.append(f" - {o}")
lines.append("")
lines.append("Assumptions:")
for a in req_doc.get("assumptions", []):
lines.append(f" - {a}")
if req_doc.get("tech_constraints"):
lines.append("")
lines.append("Technical Constraints:")
for t in req_doc["tech_constraints"]:
lines.append(f" - {t}")
return "\n".join(lines)
def _parse_output(self, response: str, state: TeamState) -> TeamState:
"""Parse the LLM's JSON array of user stories into UserStory objects."""
stories = self._extract_stories(response)
# Validate that we have enough stories to proceed
must_have_stories = [s for s in stories if s.priority == "must-have"]
if len(must_have_stories) == 0:
raise ValueError(
"BA agent produced no must-have stories. "
"Check that the RequirementDoc contains must-have features."
)
event = UserStoriesCreated(
source_agent=self.AGENT_ID,
story_count=len(stories),
must_have_count=len(must_have_stories),
total_story_points=sum(s.story_points for s in stories),
)
return {
**state,
"user_stories": [s.model_dump() for s in stories],
"requirement_state": "user_stories_created",
"event_outbox": self._append_event(state, event),
}
def _extract_stories(self, response: str) -> list[UserStory]:
"""Extract and validate UserStory list from LLM response."""
cleaned = re.sub(r"```(?:json)?", "", response).strip()
# Find the JSON array
match = re.search(r"\[.*\]", cleaned, re.DOTALL)
if not match:
raise ValueError(
f"Could not find JSON array in BA response: {response[:200]}"
)
raw_stories = json.loads(match.group())
stories = []
for raw in raw_stories:
try:
stories.append(UserStory.model_validate(raw))
except Exception as e:
# Log but don't fail on a single malformed story
self.logger.warning(f"Skipping malformed story {raw.get('story_id')}: {e}")
return stories
Method _format_req_doc xứng đáng được lưu ý. Chúng ta có thể truyền RequirementDoc cho BA agent dưới dạng JSON thô trong prompt. Đôi khi điều đó ổn. Nhưng một biểu diễn văn bản được định dạng tốt dễ hơn để LLM suy luận. JSON được tối ưu cho máy. Văn bản có cấu trúc rõ ràng — danh sách thụt đầu dòng, nhãn rõ ràng, mức ưu tiên trong ngoặc vuông — dễ phân tích hơn trong ngữ cảnh của một prompt dài. Trên thực tế, format văn bản tạo ra output BA nhất quán hơn.
7. Schema UserStory
# domain/models.py (BA section)
from __future__ import annotations
from typing import Literal, Optional
from pydantic import BaseModel, Field, field_validator, model_validator
class AcceptanceCriteria(BaseModel):
"""
A single acceptance criterion in Given/When/Then format.
The Given/When/Then structure forces each criterion to be:
- Given: a specific precondition (not "when logged in" — which login state?)
- When: a specific action (not "user submits form" — which form, which data?)
- Then: a specific, observable, binary outcome (not "it works" — what exactly?)
"""
id: str = Field(description="Unique ID within the story, e.g. 'AC-001-1'")
given: str = Field(description="The precondition / system state")
when: str = Field(description="The action taken by actor or system")
then: str = Field(description="The observable, verifiable outcome")
@field_validator("then")
@classmethod
def then_is_specific(cls, v: str) -> str:
vague_endings = ["works", "succeeds", "is done", "is complete"]
if any(v.lower().strip().endswith(ending) for ending in vague_endings):
raise ValueError(
f"Acceptance criterion 'then' clause is too vague: '{v}'. "
"Specify the exact observable outcome."
)
return v
class StoryStatus(str):
BACKLOG = "backlog"
READY = "ready" # all dependencies resolved
IN_PROGRESS = "in_progress"
IN_REVIEW = "in_review"
DONE = "done"
BLOCKED = "blocked"
class UserStory(BaseModel):
"""
A single user story — the atomic unit of work in our system.
This is what the SSE agent reads when writing code.
This is what the QC agent reads when writing tests.
Every ambiguity here becomes a decision made without the team.
Design principle: a UserStory is a conversation starter, not a contract.
The 'notes' field exists for things that don't fit the structured fields —
use it for developer-facing context that would be lost in a ticket title.
"""
story_id: str = Field(description="Unique ID, e.g. 'US-001'")
title: str = Field(description="Short, verb-first title, e.g. 'Book a Dog Walker'")
story_text: str = Field(
description="'As a [persona], I want to [action] so that [benefit]'"
)
persona: str = Field(
description="The persona name from the RequirementDoc"
)
feature_ref: str = Field(
description="The RequirementDoc feature title this story implements"
)
priority: Literal["must-have", "should-have", "nice-to-have"]
story_points: Literal[1, 2, 3, 5] = Field(
description=(
"Fibonacci estimate. Note: 8 is intentionally excluded — "
"stories that large must be split."
)
)
acceptance_criteria: list[AcceptanceCriteria] = Field(
min_length=2,
max_length=6,
description="2-6 Given/When/Then criteria"
)
edge_cases: list[str] = Field(
min_length=1,
description="Edge cases the implementation must handle"
)
dependencies: list[str] = Field(
default_factory=list,
description="story_ids that must be completed before this story"
)
notes: Optional[str] = Field(
default=None,
description="Free-text notes for developers"
)
status: str = Field(
default=StoryStatus.BACKLOG,
description="Current status in the workflow"
)
@field_validator("story_text")
@classmethod
def validate_story_format(cls, v: str) -> str:
v_lower = v.lower()
if "as a " not in v_lower:
raise ValueError("Story text must contain 'As a'")
if "i want to " not in v_lower:
raise ValueError("Story text must contain 'I want to'")
if "so that " not in v_lower:
raise ValueError("Story text must contain 'so that'")
return v
@field_validator("story_id")
@classmethod
def validate_story_id_format(cls, v: str) -> str:
if not re.match(r"^US-\d{3,}$", v):
raise ValueError(
f"story_id must match format US-NNN, got: {v}"
)
return v
@model_validator(mode="after")
def check_acceptance_criteria_ids_match_story(self) -> "UserStory":
"""Validate that AC ids reference this story's story_id."""
prefix = self.story_id.replace("US-", "AC-")
for ac in self.acceptance_criteria:
if not ac.id.startswith(prefix):
raise ValueError(
f"AcceptanceCriteria id {ac.id} does not match "
f"story {self.story_id}. Expected prefix: {prefix}"
)
return self
def to_markdown(self) -> str:
"""Human-readable Markdown for dashboards and notifications."""
lines = [
f"### [{self.story_id}] {self.title}",
f"**Priority:** {self.priority} | **Points:** {self.story_points}",
f"**Persona:** {self.persona}",
"",
f"> {self.story_text}",
"",
"**Acceptance Criteria:**",
]
for ac in self.acceptance_criteria:
lines.extend([
f"- **Given** {ac.given}",
f" **When** {ac.when}",
f" **Then** {ac.then}",
])
if self.edge_cases:
lines.append("")
lines.append("**Edge Cases:**")
for ec in self.edge_cases:
lines.append(f"- {ec}")
if self.dependencies:
lines.append("")
lines.append(f"**Depends on:** {', '.join(self.dependencies)}")
if self.notes:
lines.append("")
lines.append(f"**Notes:** {self.notes}")
return "\n".join(lines)
Field story_points sử dụng Literal[1, 2, 3, 5] — lưu ý rằng 8 bị loại trừ có chủ đích. Bất kỳ story nào được ước tính 8 điểm đều quá lớn và phải được chia nhỏ. Ràng buộc này được nhúng vào schema, không chỉ trong system prompt. BA agent không thể tạo ra story 8 điểm dù LLM có cố gắng.
Validator validate_story_format thực thi format user story cổ điển ở tầng dữ liệu. Nếu LLM tạo ra “User can book a walker” thay vì “As a Dog Owner, I want to book a walker so that I can…”, validation thất bại và cơ chế retry của base agent khởi động.
8. Quá Trình Bàn Giao PO -> BA
Quá trình bàn giao giữa PO agent và BA agent là chuyển đổi state lớn đầu tiên trong pipeline. Để tôi chỉ chính xác state trông như thế nào ở mỗi thời điểm, vì đây là nơi bug thường ẩn náu.
State khi vào PO agent (Lượt 1):
{
"raw_brief": "Build me a task manager...",
"clarified_requirements": None,
"clarification_answers": None,
"po_clarifying_questions": None,
"requirement_state": "received",
}
State sau PO agent Lượt 1:
{
"raw_brief": "Build me a task manager...",
"clarified_requirements": None,
"clarification_answers": None,
"po_clarifying_questions": "1. Who are the primary users...\n2. ...",
"requirement_state": "awaiting_clarification",
# Graph pauses here at the human_clarification_checkpoint node
}
State sau khi con người phản hồi (checkpoint được resume):
{
"raw_brief": "Build me a task manager...",
"clarified_requirements": None,
"clarification_answers": "1. Primary users are software engineers...\n2. ...",
"po_clarifying_questions": "1. Who are the primary users...\n2. ...",
"requirement_state": "awaiting_clarification",
}
State sau PO agent Lượt 2:
{
"raw_brief": "Build me a task manager...",
"clarified_requirements": {
"project_name": "TaskFlow",
"elevator_pitch": "For software engineers who lose track of...",
"personas": [...],
"features": [...],
"success_metrics": [...],
"out_of_scope": [...],
"assumptions": [...],
"tech_constraints": [...]
},
"clarification_answers": "...",
"po_clarifying_questions": "...",
"requirement_state": "clarified",
"event_outbox": {"pending": [{"event": "RequirementsCleared", ...}]}
}
State khi vào BA agent:
BA agent nhìn thấy toàn bộ state phía trên. Field chính nó đọc là clarified_requirements. Nó không đọc lại raw_brief hay clarification_answers — công việc đó đã xong. RequirementDoc là nguồn sự thật của BA agent. Đây là anti-corruption layer đang hoạt động: thế giới của BA bắt đầu từ RequirementDoc, không phải từ brief thô.
State sau BA agent:
{
# All previous fields preserved
"user_stories": [
{
"story_id": "US-001",
"title": "Create a Task",
"story_text": "As a Software Engineer Dana, I want to create...",
"priority": "must-have",
"story_points": 2,
"acceptance_criteria": [
{
"id": "AC-001-1",
"given": "Dana is on the task list screen",
"when": "she taps the 'New Task' button",
"then": "a task creation form appears with Title, Due Date, and Priority fields"
},
# ...more criteria
],
"edge_cases": [
"User tries to save a task with an empty title",
"User enters a due date in the past"
],
"dependencies": [],
"status": "backlog"
},
# ... more stories
],
"requirement_state": "user_stories_created",
}
State giờ đã sẵn sàng cho checkpoint phê duyệt story — cổng kiểm soát con người thứ hai, nơi stakeholder xem xét user story trước khi hệ thống tiến đến kiến trúc kỹ thuật.
Đấu Nối Node Trong LangGraph
Trong graph builder từ Phần 3, các node PO và BA được kết nối như sau:
# graph/builder.py
from langgraph.graph import StateGraph, END
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.types import interrupt
from agents.po_agent import POAgent
from agents.ba_agent import BAAgent
from domain.state import TeamState
po_agent = POAgent()
ba_agent = BAAgent()
def run_po_agent(state: TeamState) -> TeamState:
"""LangGraph node wrapper for the PO agent."""
import asyncio
return asyncio.run(po_agent.run(state))
def run_ba_agent(state: TeamState) -> TeamState:
"""LangGraph node wrapper for the BA agent."""
import asyncio
return asyncio.run(ba_agent.run(state))
def human_clarification_checkpoint(state: TeamState) -> TeamState:
"""
Pause execution after PO asks clarifying questions.
Human must provide answers via the approval gateway.
"""
if state.get("requirement_state") == "awaiting_clarification":
# Interrupt the graph — resume when answers are provided
answers = interrupt({
"type": "clarification_required",
"questions": state["po_clarifying_questions"],
"project": state.get("raw_brief", "")[:100],
})
return {**state, "clarification_answers": answers}
return state # Already has answers, pass through
def route_after_po(state: TeamState) -> str:
"""Determine where to go after the PO agent runs."""
req_state = state.get("requirement_state")
if req_state == "awaiting_clarification":
return "clarification_checkpoint"
elif req_state == "clarified":
return "ba_agent"
else:
# Unexpected state — escalate to PM
return "pm_agent"
builder = StateGraph(TeamState)
builder.add_node("po_agent", run_po_agent)
builder.add_node("clarification_checkpoint", human_clarification_checkpoint)
builder.add_node("ba_agent", run_ba_agent)
builder.set_entry_point("po_agent")
builder.add_conditional_edges(
"po_agent",
route_after_po,
{
"clarification_checkpoint": "clarification_checkpoint",
"ba_agent": "ba_agent",
"pm_agent": "pm_agent",
},
)
# After clarification, always loop back to PO agent for Pass 2
builder.add_edge("clarification_checkpoint", "po_agent")
Hàm route_after_po là quyết định routing then chốt. Nếu PO agent hoàn thành Lượt 1 (state là awaiting_clarification), chuyển đến checkpoint. Nếu hoàn thành Lượt 2 (state là clarified), chuyển đến BA agent. Điều này có nghĩa node po_agent được truy cập hai lần trong một lần chạy điển hình, và hàm routing xử lý cả hai trường hợp một cách gọn gàng.
9. Ví Dụ Thực Tế Chi Tiết
Để tôi đi qua một ví dụ hoàn chỉnh từ brief thô đến user story. Đầu vào được viết ngắn gọn để dễ đọc, nhưng mô hình này áp dụng được cho các brief phức tạp hơn.
Brief đầu vào:
“Build a simple task manager app for our engineering team. We need to track tasks, assign them to people, and know when things are due. We currently use spreadsheets and it’s getting messy.”
PO Agent Lượt 1 — Câu Hỏi Làm Rõ:
1. Who are the primary users of this task manager, and what is their typical
workflow? For example: are they assigning tasks to themselves, to teammates,
or managing work across a whole team?
2. If you could ship only three features in the first version, which three would
make the spreadsheets feel obviously inadequate by comparison?
3. How will you know this project was successful? Are you measuring something
specific — like time spent in status meetings, number of missed deadlines,
or team onboarding time?
4. Are there any technical constraints we should know about — for example,
does this need to integrate with GitHub, Slack, Jira, or another tool your
team already uses?
5. Is there a hard deadline for the first version, and do you have a rough
sense of the available development budget (in time or money)?
Câu trả lời của con người:
1. Primary users are software engineers (4-6 person team). They assign tasks
to themselves and to teammates. A tech lead occasionally needs to see the
whole team's workload.
2. Must-haves: create/assign tasks with due dates, a board view showing
status (Todo/In Progress/Done), and email notifications for due dates.
Everything else is secondary.
3. Success = the team stops using the spreadsheet within 3 months and
nobody misses a due date that was in the system.
4. Needs to work in a browser. No integrations required for v1, but
Slack notifications would be nice someday. Simple email auth is fine —
no SSO needed.
5. We'd like something usable within 6 weeks. Budget is one developer
for that period.
PO Agent Lượt 2 — RequirementDoc:
{
"project_name": "TaskFlow",
"elevator_pitch": "For small engineering teams drowning in spreadsheet chaos, TaskFlow is a task management app that makes assignments, due dates, and status visible to everyone — so nothing falls through the cracks.",
"personas": [
{
"name": "Engineer Sam",
"description": "A software engineer on a 4-6 person team. Works on 3-5 tasks at any given time. Needs to know what to work on next and when things are due.",
"pain_points": [
"Loses track of tasks assigned by teammates in long Slack threads",
"Misses due dates because the spreadsheet is always stale"
],
"goals": [
"Know exactly what I own and when it's due",
"Update task status without a meeting or a message"
]
},
{
"name": "Tech Lead Morgan",
"description": "The team lead who assigns tasks and monitors workload across the whole team. Needs a high-level view without micromanaging.",
"pain_points": [
"No visibility into who is overloaded or blocked",
"Status meetings take 30 minutes to recreate information that should be visible"
],
"goals": [
"See team workload at a glance",
"Identify blockers before they become missed deadlines"
]
},
{
"name": "New Hire Taylor",
"description": "A developer who joined the team 2 weeks ago. Needs to understand what exists, what they own, and how work flows through the team.",
"pain_points": [
"No single place to understand team priorities",
"Afraid of stepping on colleagues' toes by working on the wrong thing"
],
"goals": [
"Onboard without needing a 1-on-1 to understand team priorities",
"Find and claim available tasks independently"
]
}
],
"features": [
{
"title": "Task Creation and Assignment",
"description": "Create tasks with title, description, assignee, due date, and priority. Assignee receives email notification.",
"priority": "must-have",
"estimated_complexity": "medium"
},
{
"title": "Kanban Board View",
"description": "Visual board with Todo, In Progress, and Done columns. Drag-and-drop to change status.",
"priority": "must-have",
"estimated_complexity": "medium"
},
{
"title": "Due Date Email Notifications",
"description": "Email reminder 24 hours before a task is due. Email notification when a task is assigned to you.",
"priority": "must-have",
"estimated_complexity": "small"
},
{
"title": "User Authentication",
"description": "Email and password login. No SSO, no OAuth for v1.",
"priority": "must-have",
"estimated_complexity": "small"
},
{
"title": "Task Detail View",
"description": "Full task details including history of status changes, comments thread, and file attachment.",
"priority": "should-have",
"estimated_complexity": "medium"
},
{
"title": "Team Workload View",
"description": "A view (for tech leads) showing all team members and their open task counts, grouped by due date.",
"priority": "should-have",
"estimated_complexity": "small"
},
{
"title": "Task Filtering and Search",
"description": "Filter tasks by assignee, status, priority, and due date. Full-text search on task title.",
"priority": "should-have",
"estimated_complexity": "small"
},
{
"title": "Slack Notifications",
"description": "Optional Slack webhook integration for task assignment and due date notifications.",
"priority": "nice-to-have",
"estimated_complexity": "small"
}
],
"success_metrics": [
"Spreadsheet abandoned by 100% of team members within 90 days of launch",
"Zero missed deadlines for tasks that were logged in TaskFlow (measured monthly)",
"New team member can find and claim their first task without asking a teammate"
],
"out_of_scope": [
"SSO or OAuth authentication (email/password only for v1)",
"Mobile app (browser only for v1)",
"Time tracking or billing features",
"External integrations except optional Slack webhook",
"Advanced reporting, burndown charts, or velocity metrics"
],
"assumptions": [
"Team size stays at 4-6 people during v1; no multi-team or org-level features needed",
"Email delivery is handled by a transactional email service (SendGrid, Postmark); no self-hosted email server"
],
"tech_constraints": [
"Must be browser-based (no mobile app for v1)",
"One developer, 6-week delivery timeline"
]
}
BA Agent — Đầu Ra User Stories (8 story):
BA agent tạo ra các story sau từ RequirementDoc:
| Story ID | Title | Persona | Points | Priority |
|---|---|---|---|---|
| US-001 | Register and Log In | Engineer Sam | 2 | must-have |
| US-002 | Create a Task and Assign It | Tech Lead Morgan | 3 | must-have |
| US-003 | View the Kanban Board | Engineer Sam | 3 | must-have |
| US-004 | Move a Task Across Status Columns | Engineer Sam | 2 | must-have |
| US-005 | Receive Assignment Email Notification | Engineer Sam | 2 | must-have |
| US-006 | Receive Due Date Reminder | Engineer Sam | 2 | must-have |
| US-007 | View Team Workload | Tech Lead Morgan | 2 | should-have |
| US-008 | Filter Tasks by Assignee and Status | Engineer Sam | 2 | should-have |
Tổng cộng: 18 story point. Với velocity điển hình 8-10 điểm mỗi sprint (2 tuần), đây là khoảng 4 sprint — tầm 8 tuần. Timeline 6 tuần với một developer khá eo hẹp nhưng khả thi nếu các story should-have trượt sang release thứ hai. mvp_complexity_estimate của PO agent đánh giá đây là “medium (2-6 weeks)” chỉ dựa trên must-have — sự căn chỉnh hợp lý.
Để tôi trình bày một story hoàn chỉnh để minh họa độ sâu của đầu ra BA:
US-002 — Create a Task and Assign It:
{
"story_id": "US-002",
"title": "Create a Task and Assign It",
"story_text": "As Tech Lead Morgan, I want to create a task with a title, description, due date, and assignee so that the assigned engineer knows exactly what they need to do and when it needs to be done by.",
"persona": "Tech Lead Morgan",
"feature_ref": "Task Creation and Assignment",
"priority": "must-have",
"story_points": 3,
"acceptance_criteria": [
{
"id": "AC-002-1",
"given": "Morgan is logged in and on the task board",
"when": "she clicks the 'New Task' button",
"then": "a task creation form opens with fields: Title (required), Description (optional), Assignee (dropdown of team members), Due Date (date picker), Priority (Low/Medium/High)"
},
{
"id": "AC-002-2",
"given": "Morgan has filled in all required fields and selected an assignee",
"when": "she clicks 'Save Task'",
"then": "the task appears in the Todo column on the board, and the assigned engineer receives an email notification within 5 minutes"
},
{
"id": "AC-002-3",
"given": "Morgan tries to save a task without a title",
"when": "she clicks 'Save Task'",
"then": "the form shows an inline error on the Title field: 'Task title is required' and the form does not close"
},
{
"id": "AC-002-4",
"given": "Morgan selects a due date that is in the past",
"when": "she clicks 'Save Task'",
"then": "the form shows a warning: 'Due date is in the past. Are you sure?' with Confirm and Cancel options — the task can still be saved if Morgan confirms"
}
],
"edge_cases": [
"Assignee is not a registered user (e.g., Morgan types an email that isn't in the system) — show inline error",
"Email notification delivery fails — task should still be created; log the failed notification for retry"
],
"dependencies": ["US-001"],
"notes": "The email notification for US-002 and the due date reminder in US-006 should share the same email service integration. Implement them as a single EmailNotificationService class to avoid duplicated SendGrid setup code."
}
Hãy chú ý những gì field notes mang theo: một insight triển khai liên story không nằm gọn trong các field có cấu trúc. “Email notification cho US-002 và due date reminder trong US-006 nên dùng chung cùng class EmailNotificationService.” Loại quan sát này — kết nối các điểm giữa hai story — là điều phân biệt đầu ra BA tốt với đầu ra tầm thường. Nó không áp đặt kiến trúc (đó là lĩnh vực của TA agent), nhưng nó đánh dấu một sự ghép nối mà TA agent nên biết.
10. Test PO Và BA
Test cho agent khác với test cho business logic thông thường, vì đầu ra LLM là không xác định. Chúng ta cần test ở hai mức: logic phân tích đầu ra (xác định, unit-test được) và hành vi agent end-to-end (không xác định, integration-test được với mocking).
Unit Test: Schema Validation
Bắt đầu với các data model. Chúng hoàn toàn xác định.
# tests/unit/test_requirement_doc.py
import pytest
from pydantic import ValidationError
from domain.models import RequirementDoc, UserPersona, Feature, UserStory, AcceptanceCriteria
class TestUserPersona:
def test_rejects_generic_name(self):
with pytest.raises(ValidationError, match="too generic"):
UserPersona(
name="user",
description="A person who uses the app",
pain_points=["pain"],
goals=["goal"],
)
def test_accepts_descriptive_name(self):
persona = UserPersona(
name="Engineer Sam",
description="A software engineer on a small team.",
pain_points=["Loses track of tasks"],
goals=["Know what to work on next"],
)
assert persona.name == "Engineer Sam"
class TestRequirementDoc:
def test_rejects_too_few_features(self):
with pytest.raises(ValidationError):
RequirementDoc(
project_name="Test",
elevator_pitch="A test app",
personas=[
UserPersona(
name="Persona One",
description="A person.",
pain_points=["pain"],
goals=["goal"],
),
UserPersona(
name="Persona Two",
description="Another person.",
pain_points=["pain"],
goals=["goal"],
),
],
features=[
Feature(
title="One Feature",
description="Just one",
priority="must-have",
estimated_complexity="small",
)
], # Too few — minimum is 5
success_metrics=["metric one", "metric two"],
out_of_scope=["item one", "item two", "item three"],
assumptions=["assumption one", "assumption two"],
)
def test_mvp_complexity_estimate(self):
"""Medium complexity features should produce a medium estimate."""
features = [
Feature(
title=f"Feature {i}",
description="A feature",
priority="must-have",
estimated_complexity="medium",
)
for i in range(4)
]
# Add padding features to meet minimum count
features += [
Feature(
title="Extra Feature",
description="Extra",
priority="nice-to-have",
estimated_complexity="small",
)
]
doc = RequirementDoc(
project_name="Test",
elevator_pitch="A test app",
personas=[
UserPersona(name="User A", description=".", pain_points=["p"], goals=["g"]),
UserPersona(name="User B", description=".", pain_points=["p"], goals=["g"]),
],
features=features,
success_metrics=["metric 1", "metric 2"],
out_of_scope=["o1", "o2", "o3"],
assumptions=["a1", "a2"],
)
# 4 medium features = 4 * 3 = 12 points → "medium (2-6 weeks)"
assert "medium" in doc.mvp_complexity_estimate
class TestUserStory:
def test_rejects_invalid_story_format(self):
"""Stories that don't follow 'As a / I want / so that' format are rejected."""
with pytest.raises(ValidationError, match="I want to"):
UserStory(
story_id="US-001",
title="Create Task",
story_text="User can create a task", # Missing format
persona="Engineer Sam",
feature_ref="Task Creation",
priority="must-have",
story_points=2,
acceptance_criteria=[
AcceptanceCriteria(
id="AC-001-1",
given="user is on the board",
when="they click New Task",
then="the task creation form appears with Title and Due Date fields",
),
AcceptanceCriteria(
id="AC-001-2",
given="user fills in the title",
when="they click Save",
then="the task appears in the Todo column",
),
],
edge_cases=["Empty title attempt"],
)
def test_rejects_vague_acceptance_criteria(self):
"""Acceptance criteria with vague 'then' clauses are rejected."""
with pytest.raises(ValidationError, match="too vague"):
AcceptanceCriteria(
id="AC-001-1",
given="user is logged in",
when="they submit the form",
then="it works", # Too vague
)
def test_rejects_8_story_points(self):
"""8-point stories must be split — Literal[1,2,3,5] excludes 8."""
with pytest.raises(ValidationError):
UserStory(
story_id="US-001",
title="Giant Story",
story_text="As Engineer Sam, I want to do everything so that I can ship the whole app",
persona="Engineer Sam",
feature_ref="All Features",
priority="must-have",
story_points=8, # Not in Literal[1, 2, 3, 5]
acceptance_criteria=[
AcceptanceCriteria(
id="AC-001-1",
given="context",
when="action",
then="the system displays the confirmation message",
),
AcceptanceCriteria(
id="AC-001-2",
given="context 2",
when="action 2",
then="the system saves the record to the database",
),
],
edge_cases=["Something fails"],
)
Unit Test: Phân Tích Đầu Ra
Test logic phân tích với các chuỗi cố định, không phải lệnh gọi LLM.
# tests/unit/test_po_agent_parsing.py
import json
import pytest
from agents.po_agent import POAgent
from domain.models import RequirementDoc
VALID_REQUIREMENT_DOC_JSON = json.dumps({
"project_name": "TaskFlow",
"elevator_pitch": "For engineering teams who struggle with spreadsheets.",
"personas": [
{
"name": "Engineer Sam",
"description": "A software engineer.",
"pain_points": ["Loses track of tasks"],
"goals": ["Know what to work on"]
},
{
"name": "Tech Lead Morgan",
"description": "The team lead.",
"pain_points": ["No visibility"],
"goals": ["See team workload"]
}
],
"features": [
{"title": "Task Creation", "description": "Create tasks", "priority": "must-have", "estimated_complexity": "medium"},
{"title": "Kanban Board", "description": "Board view", "priority": "must-have", "estimated_complexity": "medium"},
{"title": "Notifications", "description": "Email alerts", "priority": "must-have", "estimated_complexity": "small"},
{"title": "Auth", "description": "Login", "priority": "must-have", "estimated_complexity": "small"},
{"title": "Task Detail", "description": "Full view", "priority": "should-have", "estimated_complexity": "medium"},
],
"success_metrics": [
"Spreadsheet abandoned within 90 days",
"Zero missed deadlines for logged tasks"
],
"out_of_scope": ["SSO", "Mobile app", "Time tracking"],
"assumptions": ["Team stays at 4-6 people", "Transactional email service available"],
"tech_constraints": ["Browser-only"]
})
class TestPOAgentParsing:
def setup_method(self):
self.agent = POAgent()
def test_parses_valid_json(self):
doc = self.agent._parse_requirement_doc(VALID_REQUIREMENT_DOC_JSON)
assert isinstance(doc, RequirementDoc)
assert doc.project_name == "TaskFlow"
assert len(doc.personas) == 2
assert len(doc.features) == 5
def test_strips_markdown_fences(self):
wrapped = f"```json\n{VALID_REQUIREMENT_DOC_JSON}\n```"
doc = self.agent._parse_requirement_doc(wrapped)
assert doc.project_name == "TaskFlow"
def test_raises_on_missing_json(self):
with pytest.raises(ValueError, match="Could not find JSON"):
self.agent._parse_requirement_doc("Here is my analysis of your brief...")
Integration Test: Pipeline PO -> BA
# tests/integration/test_po_ba_pipeline.py
import pytest
from unittest.mock import AsyncMock, patch
from agents.po_agent import POAgent
from agents.ba_agent import BAAgent
from domain.state import TeamState
# A realistic RequirementDoc to inject (bypassing the LLM for the PO pass)
MOCK_REQUIREMENT_DOC = {
"project_name": "TaskFlow",
"elevator_pitch": "For engineering teams who struggle with spreadsheets.",
"personas": [
{
"name": "Engineer Sam",
"description": "A software engineer on a 4-6 person team.",
"pain_points": ["Loses track of tasks"],
"goals": ["Know what to work on"]
},
{
"name": "Tech Lead Morgan",
"description": "The team lead who assigns tasks.",
"pain_points": ["No visibility into workload"],
"goals": ["See team workload at a glance"]
}
],
"features": [
{"title": "Task Creation", "description": "Create and assign tasks", "priority": "must-have", "estimated_complexity": "medium"},
{"title": "Kanban Board", "description": "Visual board view", "priority": "must-have", "estimated_complexity": "medium"},
{"title": "Email Notifications", "description": "Assignment and due date emails", "priority": "must-have", "estimated_complexity": "small"},
{"title": "User Authentication", "description": "Email/password login", "priority": "must-have", "estimated_complexity": "small"},
{"title": "Task Detail View", "description": "Full task details with comments", "priority": "should-have", "estimated_complexity": "medium"},
],
"success_metrics": ["Spreadsheet abandoned in 90 days", "Zero missed deadlines"],
"out_of_scope": ["SSO", "Mobile app", "Time tracking"],
"assumptions": ["Team stays at 4-6 people", "Transactional email available"],
"tech_constraints": ["Browser-only"],
}
@pytest.mark.asyncio
async def test_ba_agent_produces_stories_from_requirement_doc():
"""
Integration test: given a RequirementDoc, the BA agent should produce
at least one user story per must-have feature, and all stories should
pass schema validation.
"""
ba_agent = BAAgent()
initial_state: TeamState = {
"raw_brief": "Build a task manager",
"clarified_requirements": MOCK_REQUIREMENT_DOC,
"clarification_answers": "Answered in previous step",
"user_stories": [],
"requirement_state": "clarified",
"event_outbox": {"pending": [], "delivered": []},
"conversation_id": "test-integration-001",
}
# Mock the LLM to return a realistic JSON response
mock_llm_response = """[
{
"story_id": "US-001",
"title": "Register and Log In",
"story_text": "As Engineer Sam, I want to register with my email and log in so that I can access the team task board.",
"persona": "Engineer Sam",
"feature_ref": "User Authentication",
"priority": "must-have",
"story_points": 2,
"acceptance_criteria": [
{
"id": "AC-001-1",
"given": "Sam is on the registration page",
"when": "she enters a valid email and password and clicks Register",
"then": "her account is created and she is redirected to the task board"
},
{
"id": "AC-001-2",
"given": "Sam tries to register with an email already in the system",
"when": "she clicks Register",
"then": "the form shows: 'This email is already registered. Log in instead?'"
}
],
"edge_cases": ["Weak password attempt", "Invalid email format"],
"dependencies": [],
"notes": null,
"status": "backlog"
},
{
"story_id": "US-002",
"title": "Create and Assign a Task",
"story_text": "As Tech Lead Morgan, I want to create a task with a title, due date, and assignee so that the assigned engineer knows what to work on.",
"persona": "Tech Lead Morgan",
"feature_ref": "Task Creation",
"priority": "must-have",
"story_points": 3,
"acceptance_criteria": [
{
"id": "AC-002-1",
"given": "Morgan is on the task board",
"when": "she clicks New Task",
"then": "a form appears with Title, Description, Assignee, Due Date, and Priority fields"
},
{
"id": "AC-002-2",
"given": "Morgan fills all required fields and clicks Save",
"when": "the form is submitted",
"then": "the task appears in the Todo column and the assignee receives an email notification within 5 minutes"
}
],
"edge_cases": ["Empty title attempt", "Past due date warning"],
"dependencies": ["US-001"],
"notes": null,
"status": "backlog"
}
]"""
with patch.object(ba_agent, "_call_llm", new=AsyncMock(return_value=mock_llm_response)):
result_state = await ba_agent.run(initial_state)
# Assertions
stories = result_state.get("user_stories", [])
assert len(stories) > 0, "BA agent must produce at least one story"
must_have_stories = [s for s in stories if s["priority"] == "must-have"]
assert len(must_have_stories) > 0, "Must have at least one must-have story"
# Every story must have at least 2 acceptance criteria
for story in stories:
assert len(story["acceptance_criteria"]) >= 2, (
f"Story {story['story_id']} has fewer than 2 acceptance criteria"
)
# State should be updated
assert result_state["requirement_state"] == "user_stories_created"
assert len(result_state["event_outbox"]["pending"]) > 0
Chạy chúng với:
# Unit tests (no LLM calls — fast)
pytest tests/unit/ -v
# Integration tests (with mocked LLM — still fast)
pytest tests/integration/test_po_ba_pipeline.py -v
# Full end-to-end (real LLM — slow, costs tokens, run sparingly)
pytest tests/e2e/ -v --run-llm
Unit test có giá trị nhất cho phát triển hàng ngày — chúng chạy trong vài mili giây và bắt các regression schema và parsing ngay lập tức. Integration test bắt các vấn đề đấu nối. End-to-end test được dành cho validation trước khi release.
11. Các Chế Độ Lỗi Phổ Biến (Và Cách Chúng Ta Xử Lý)
Xây dựng PO và BA agent đã bộc lộ một số chế độ lỗi đáng ghi lại, vì bạn cũng sẽ gặp chúng.
PO agent tạo yêu cầu mà không đặt câu hỏi làm rõ. Đôi khi LLM bỏ qua Lượt 1 và nhảy thẳng sang tạo RequirementDoc dù đã được yêu cầu đặt câu hỏi. Cách sửa: trong prompt Lượt 1, nói rõ ràng “Phản hồi của bạn phải là một danh sách đánh số gồm đúng 5 câu hỏi. Không tạo tài liệu yêu cầu ở bước này.” Base class cũng validate phản hồi — nếu nó trông giống JSON, nó được giả định là phản hồi Lượt 2, và agent được gọi lại với prompt Lượt 1 được thực thi rõ ràng.
BA agent tạo story 8 điểm. Điều này xảy ra khi BA agent coi một tính năng như một story đơn lẻ thay vì phân tách nó. Ràng buộc kiểu Literal[1, 2, 3, 5] bắt lỗi này tại thời điểm validation. Cơ chế retry của base agent khởi động và gọi lại LLM với hướng dẫn bổ sung: “Các story sau có ước tính story point không hợp lệ. Chia bất kỳ story nào trên 5 điểm thành hai story nhỏ hơn.”
BA agent tham chiếu persona không có trong RequirementDoc. LLM thỉnh thoảng bịa ra persona mới (một “casual user” không có trong tài liệu). Field UserStory.persona nên được validate đối chiếu với RequirementDoc.personas. Thêm một model validator vào UserStory kiểm tra điều này khi RequirementDoc khả dụng. Trên thực tế, chúng ta truyền tên persona hợp lệ trong prompt như một ràng buộc rõ ràng: “Persona phải là một trong: Engineer Sam, Tech Lead Morgan, New Hire Taylor.”
Acceptance criteria quá mơ hồ. Validator AcceptanceCriteria.then_is_specific bắt các trường hợp tệ nhất (“it works,” “it succeeds”). Cho sự mơ hồ tinh vi hơn — “the user is redirected” mà không chỉ rõ đến đâu — prompt hướng dẫn BA agent rõ ràng: “Mệnh đề ‘then’ phải đặt tên chính xác trang hoặc phần tử UI xuất hiện, thông báo chính xác được hiển thị, hoặc thay đổi dữ liệu chính xác xảy ra.”
Dependency vòng tròn trong user story. BA agent thỉnh thoảng tạo ra vòng dependency (US-003 phụ thuộc US-004, mà lại phụ thuộc US-003). Điều này được phát hiện bởi bộ kiểm tra dependency graph của PM agent, chạy sau BA agent và đánh dấu các vòng để con người giải quyết trước khi story đi vào hàng đợi triển khai.
12. Những Gì Chúng Ta Đã Xây — Và Tiếp Theo Là Gì
Trong phần này, chúng ta đã xây dựng hai agent đầu tiên hoạt động trong đội AI phần mềm.
PO agent (Alex) biến đổi brief mơ hồ của client thành RequirementDoc có cấu trúc thông qua quy trình làm rõ hai lượt. Nó thực hiện nghiên cứu thị trường khi cần, thực thi kỷ luật scope thông qua các field out-of-scope rõ ràng, và bộc lộ giả định trước khi chúng thành bất ngờ. Đầu ra của nó là một model Pydantic đã validate với hơn 40 field vượt qua schema validation nghiêm ngặt.
BA agent (Jordan) lấy RequirementDoc và phân tách thành backlog các object UserStory với acceptance criteria Given/When/Then, edge case, ước tính story point, và ánh xạ dependency. Đầu ra của nó là đơn vị công việc nguyên tử mà mọi agent downstream sẽ đọc.
Cùng nhau, chúng biến “xây cho tôi app giống Uber nhưng cho chó” thành tám user story có cấu trúc tốt với acceptance criteria có thể test — trong vài phút, không phải vài tuần.
Cơ chế bàn giao — kiểm tra state, routing có điều kiện, checkpoint con người — đạt chuẩn production. Schema thực thi chất lượng ở tầng dữ liệu, không chỉ trong hướng dẫn bằng lời. Bộ test bao phủ phân tích, validation, và hành vi agent end-to-end.
Trong Phần 6, chúng ta xây Technical Architect agent. TA agent đọc RequirementDoc và danh sách UserStory, tự nghiên cứu các lựa chọn công nghệ, và tạo ra TechnicalSpec: sơ đồ component, data model, API contract, và ArchitectureDecisionRecord cho mọi quyết định lớn. Chúng ta cũng sẽ xây anti-corruption layer dịch các object UserStory thành các object FunctionalRequirement — bài kiểm tra thực tế đầu tiên cho mô hình bounded context chúng ta đã định nghĩa trong Phần 3.
Thách thức thú vị trong Phần 6: TA agent cần đưa ra quyết định kỹ thuật thực sự. Không phải “tùy trường hợp” — mà là lựa chọn thực tế, với lý do được ghi chép. Chúng ta sẽ thấy cách prompt một agent để nó quyết đoán mà không liều lĩnh.
Toàn bộ source code cho Phần 5 tại github.com/thuanpham582002/ai-software-team trong branch part-5.