Skip to content
Merged
1 change: 0 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ python scripts/quality-ratchet.py show
* **Before**: One project per agent (broken architecture)
* **After**: Multiple agents (orchestrator, backend, frontend, test, review) collaborate on same project
* **Tests**: 59/59 passing (100%) - Full multi-agent test coverage
- 2025-11-14: 007-context-management Phase 2-5 complete - Context storage, scoring, and tier assignment ✅
* Phase 2: Foundational layer (Pydantic models, migrations, database methods, TokenCounter)
* Phase 3: Context item storage (save/load/get context with persistence)
* Phase 4: Importance scoring with hybrid exponential decay algorithm (T027-T036)
Expand Down
80 changes: 80 additions & 0 deletions codeframe/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,3 +562,83 @@ def to_blocker_message(self) -> str:
msg += f"\n---\n\n{self.summary}"

return msg


# ============================================================================
# Discovery Answer UI Models (Feature: 012-discovery-answer-ui)
# ============================================================================


class DiscoveryAnswer(BaseModel):
"""Request model for discovery answer submission."""

model_config = ConfigDict(str_strip_whitespace=True)

answer: str = Field(
...,
min_length=1,
max_length=5000,
description="User's answer to the current discovery question"
)

@field_validator('answer')
@classmethod
def validate_answer(cls, v: str) -> str:
"""Ensure answer is not empty after trimming."""
trimmed = v.strip()
if not trimmed:
raise ValueError("Answer cannot be empty or whitespace only")
if len(trimmed) > 5000:
raise ValueError("Answer cannot exceed 5000 characters")
return trimmed


class DiscoveryAnswerResponse(BaseModel):
"""Response model for discovery answer submission."""

success: bool = Field(
...,
description="Whether the answer was successfully processed"
)

next_question: Optional[str] = Field(
None,
description="Next discovery question text (null if discovery complete)"
)

is_complete: bool = Field(
...,
description="Whether the discovery phase is complete"
)

current_index: int = Field(
...,
ge=0,
description="Current question index (0-based)"
)

total_questions: int = Field(
...,
gt=0,
description="Total number of discovery questions"
)

progress_percentage: float = Field(
...,
ge=0.0,
le=100.0,
description="Discovery completion percentage (0.0 - 100.0)"
)

model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"next_question": "What tech stack are you planning to use?",
"is_complete": False,
"current_index": 3,
"total_questions": 20,
"progress_percentage": 15.0
}
}
)
135 changes: 135 additions & 0 deletions codeframe/ui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
BlockerResolve,
ContextItemCreateModel,
ContextItemResponse,
DiscoveryAnswer,
DiscoveryAnswerResponse,
)
from codeframe.persistence.database import Database
from codeframe.ui.models import (
Expand Down Expand Up @@ -583,6 +585,139 @@ async def get_chat_history(project_id: int, limit: int = 100, offset: int = 0):
return {"messages": messages}


@app.post("/api/projects/{project_id}/discovery/answer")
async def submit_discovery_answer(project_id: int, answer_data: DiscoveryAnswer):
"""Submit answer to current discovery question (Feature: 012-discovery-answer-ui, US5).

Implementation following TDD approach (T041-T044):
- Validates project exists and is in discovery phase
- Processes answer through Lead Agent
- Broadcasts WebSocket events for real-time UI updates
- Returns updated discovery status

Args:
project_id: Project ID
answer_data: Answer submission data (Pydantic model with validation)

Returns:
DiscoveryAnswerResponse with next question and progress

Raises:
HTTPException:
- 400: Validation error or wrong phase
- 404: Project not found
- 500: Missing API key or processing error
"""
from codeframe.ui.websocket_broadcasts import (
broadcast_discovery_answer_submitted,
broadcast_discovery_question_presented,
broadcast_discovery_completed,
)

# T041: Validate project exists
project = app.state.db.get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail=f"Project {project_id} not found")

# T041: Validate project is in discovery phase
if project.get("phase") != "discovery":
raise HTTPException(
status_code=400,
detail=f"Project is not in discovery phase. Current phase: {project.get('phase')}"
)

# T042: Validate ANTHROPIC_API_KEY is available
api_key = os.environ.get("ANTHROPIC_API_KEY")
if not api_key:
raise HTTPException(
status_code=500,
detail="ANTHROPIC_API_KEY environment variable is not set. Cannot process discovery answers."
)

# T042: Get Lead Agent and process answer
try:
agent = LeadAgent(
project_id=project_id,
db=app.state.db,
api_key=api_key
)

# CRITICAL: Validate discovery is active before processing answer
status = agent.get_discovery_status()
if status.get('state') != 'discovering':
raise HTTPException(
status_code=400,
detail=f"Discovery is not active. Current state: {status.get('state')}. "
f"Please start discovery first by calling POST /api/projects/{project_id}/discovery/start"
)

# Process the answer (trimmed by Pydantic validator)
agent.process_discovery_answer(answer_data.answer)

# Get updated discovery status after processing
status = agent.get_discovery_status()

except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except Exception as e:
logger.error(f"Failed to process discovery answer for project {project_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to process answer: {str(e)}")

# T043: Compute derived values from status to match LeadAgent.get_discovery_status() format
is_complete = status.get("state") == "completed"
total_questions = status.get("total_required", 0)
answered_count = status.get("answered_count", 0)
current_question_index = answered_count # Index is based on how many answered
current_question_id = status.get("current_question", {}).get("id", "")
current_question_text = status.get("current_question", {}).get("question", "")
progress_percentage = status.get("progress_percentage", 0.0)

# T043: Broadcast WebSocket events
try:
# Broadcast answer submitted event
await broadcast_discovery_answer_submitted(
manager=manager,
project_id=project_id,
question_id=current_question_id,
answer_preview=answer_data.answer[:100], # First 100 chars
current_index=current_question_index,
total_questions=total_questions,
)
Comment on lines +645 to +686
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix critical data integrity bug: capture answered question ID before processing.

The broadcast_discovery_answer_submitted call at line 682 passes the wrong question_id. The current implementation:

  1. Line 646: Gets status to validate discovery state
  2. Line 655: Calls agent.process_discovery_answer() which updates the agent's _current_question_id to the next question
  3. Line 658: Gets updated status (now current_question reflects the next question, not the answered one)
  4. Line 672: Extracts current_question_id from this updated status
  5. Line 682: Broadcasts with this next question's ID

According to broadcast_discovery_answer_submitted documentation, the question_id parameter should be "Unique identifier of the answered question", but we're passing the next question's ID instead. This breaks WebSocket event tracking and could cause UI state inconsistencies.

Apply this diff to capture the answered question ID before processing:

     # CRITICAL: Validate discovery is active before processing answer
     status = agent.get_discovery_status()
     if status.get('state') != 'discovering':
         raise HTTPException(
             status_code=400,
             detail=f"Discovery is not active. Current state: {status.get('state')}. "
                    f"Please start discovery first by calling POST /api/projects/{project_id}/discovery/start"
         )
+    
+    # Capture the question ID being answered (before processing updates it to next question)
+    answered_question_id = status.get("current_question", {}).get("id", "")

     # Process the answer (trimmed by Pydantic validator)
     agent.process_discovery_answer(answer_data.answer)

     # Get updated discovery status after processing
     status = agent.get_discovery_status()
     
     # ... (rest of code)
     
     try:
         # Broadcast answer submitted event
         await broadcast_discovery_answer_submitted(
             manager=manager,
             project_id=project_id,
-            question_id=current_question_id,
+            question_id=answered_question_id,
             answer_preview=answer_data.answer[:100],  # First 100 chars
             current_index=current_question_index,
             total_questions=total_questions,
         )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# CRITICAL: Validate discovery is active before processing answer
status = agent.get_discovery_status()
if status.get('state') != 'discovering':
raise HTTPException(
status_code=400,
detail=f"Discovery is not active. Current state: {status.get('state')}. "
f"Please start discovery first by calling POST /api/projects/{project_id}/discovery/start"
)
# Process the answer (trimmed by Pydantic validator)
agent.process_discovery_answer(answer_data.answer)
# Get updated discovery status after processing
status = agent.get_discovery_status()
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except Exception as e:
logger.error(f"Failed to process discovery answer for project {project_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to process answer: {str(e)}")
# T043: Compute derived values from status to match LeadAgent.get_discovery_status() format
is_complete = status.get("state") == "completed"
total_questions = status.get("total_required", 0)
answered_count = status.get("answered_count", 0)
current_question_index = answered_count # Index is based on how many answered
current_question_id = status.get("current_question", {}).get("id", "")
current_question_text = status.get("current_question", {}).get("question", "")
progress_percentage = status.get("progress_percentage", 0.0)
# T043: Broadcast WebSocket events
try:
# Broadcast answer submitted event
await broadcast_discovery_answer_submitted(
manager=manager,
project_id=project_id,
question_id=current_question_id,
answer_preview=answer_data.answer[:100], # First 100 chars
current_index=current_question_index,
total_questions=total_questions,
)
# CRITICAL: Validate discovery is active before processing answer
status = agent.get_discovery_status()
if status.get('state') != 'discovering':
raise HTTPException(
status_code=400,
detail=f"Discovery is not active. Current state: {status.get('state')}. "
f"Please start discovery first by calling POST /api/projects/{project_id}/discovery/start"
)
# Capture the question ID being answered (before processing updates it to next question)
answered_question_id = status.get("current_question", {}).get("id", "")
# Process the answer (trimmed by Pydantic validator)
agent.process_discovery_answer(answer_data.answer)
# Get updated discovery status after processing
status = agent.get_discovery_status()
except HTTPException:
# Re-raise HTTPExceptions as-is
raise
except Exception as e:
logger.error(f"Failed to process discovery answer for project {project_id}: {e}")
raise HTTPException(status_code=500, detail=f"Failed to process answer: {str(e)}")
# T043: Compute derived values from status to match LeadAgent.get_discovery_status() format
is_complete = status.get("state") == "completed"
total_questions = status.get("total_required", 0)
answered_count = status.get("answered_count", 0)
current_question_index = answered_count # Index is based on how many answered
current_question_id = status.get("current_question", {}).get("id", "")
current_question_text = status.get("current_question", {}).get("question", "")
progress_percentage = status.get("progress_percentage", 0.0)
# T043: Broadcast WebSocket events
try:
# Broadcast answer submitted event
await broadcast_discovery_answer_submitted(
manager=manager,
project_id=project_id,
question_id=answered_question_id,
answer_preview=answer_data.answer[:100], # First 100 chars
current_index=current_question_index,
total_questions=total_questions,
)
🧰 Tools
🪛 Ruff (0.14.5)

648-652: Abstract raise to an inner function

(TRY301)


663-663: Do not catch blind exception: Exception

(BLE001)


664-664: Use logging.exception instead of logging.error

Replace with exception

(TRY400)


665-665: Within an except clause, raise exceptions with raise ... from err or raise ... from None to distinguish them from errors in exception handling

(B904)


665-665: Use explicit conversion flag

Replace with conversion flag

(RUF010)

🤖 Prompt for AI Agents
In codeframe/ui/server.py around lines 645-686, the code reads the discovery
status, then calls agent.process_discovery_answer() which advances the agent to
the next question and only afterwards re-reads status; as a result
current_question_id used in broadcast is the next question's ID rather than the
answered question's ID. Fix it by reading and storing the answered question ID
(and any answered question text if needed) from status before calling
agent.process_discovery_answer(), then call process_discovery_answer(), re-fetch
status for derived values/progress, and pass the previously saved
answered_question_id into broadcast_discovery_answer_submitted so the event
contains the correct answered question identifier.


# Broadcast appropriate follow-up event
if is_complete:
await broadcast_discovery_completed(
manager=manager,
project_id=project_id,
total_answers=answered_count,
next_phase="prd_generation",
)
else:
await broadcast_discovery_question_presented(
manager=manager,
project_id=project_id,
question_id=current_question_id,
question_text=current_question_text,
current_index=current_question_index,
total_questions=total_questions,
)

except Exception as e:
logger.warning(f"Failed to broadcast WebSocket events for project {project_id}: {e}")
# Non-fatal - continue with response

# T044: Generate and return response
return DiscoveryAnswerResponse(
success=True,
next_question=current_question_text if not is_complete else None,
is_complete=is_complete,
current_index=current_question_index,
total_questions=total_questions,
progress_percentage=progress_percentage,
)


@app.get("/api/projects/{project_id}/prd")
async def get_project_prd(project_id: int):
"""Get PRD for a project (cf-26).
Expand Down
Loading