codeframe-ai 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1118 @@
|
|
|
1
|
+
"""PRD Discovery Session management for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
This module provides AI-native Socratic discovery for PRD generation.
|
|
4
|
+
The AI drives the entire discovery process - generating context-sensitive
|
|
5
|
+
questions, validating answer adequacy, and determining when sufficient
|
|
6
|
+
information has been gathered.
|
|
7
|
+
|
|
8
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import uuid
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from enum import Enum
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from codeframe.adapters.llm.anthropic import AnthropicProvider
|
|
21
|
+
from codeframe.adapters.llm.base import Purpose
|
|
22
|
+
from codeframe.core import blockers, prd
|
|
23
|
+
from codeframe.core.workspace import Workspace, get_db_connection
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utc_now() -> datetime:
|
|
29
|
+
"""Get current UTC time as timezone-aware datetime."""
|
|
30
|
+
return datetime.now(timezone.utc)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DiscoveryError(Exception):
|
|
34
|
+
"""Base exception for discovery errors."""
|
|
35
|
+
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NoApiKeyError(DiscoveryError):
|
|
40
|
+
"""Raised when no API key is available for AI-driven discovery."""
|
|
41
|
+
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ValidationError(DiscoveryError):
|
|
46
|
+
"""Raised when answer validation fails."""
|
|
47
|
+
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class IncompleteSessionError(DiscoveryError):
|
|
52
|
+
"""Raised when trying to generate PRD from incomplete session."""
|
|
53
|
+
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class SessionState(str, Enum):
|
|
58
|
+
"""State of a discovery session."""
|
|
59
|
+
|
|
60
|
+
IDLE = "idle"
|
|
61
|
+
DISCOVERING = "discovering"
|
|
62
|
+
PAUSED = "paused"
|
|
63
|
+
COMPLETED = "completed"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# Categories that should be covered for a complete PRD
|
|
67
|
+
DISCOVERY_CATEGORIES = [
|
|
68
|
+
"problem", # What problem does this solve?
|
|
69
|
+
"users", # Who are the target users?
|
|
70
|
+
"features", # What are the core capabilities?
|
|
71
|
+
"constraints", # What limitations exist?
|
|
72
|
+
"tech_stack", # What technologies are preferred?
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
# System prompt for the discovery AI
|
|
76
|
+
DISCOVERY_SYSTEM_PROMPT = """You are an expert product manager conducting Socratic discovery to gather requirements for a software project. Your goal is to ask thoughtful, context-sensitive questions that help clarify the project vision.
|
|
77
|
+
|
|
78
|
+
## Your Role
|
|
79
|
+
- Ask ONE clear, specific question at a time
|
|
80
|
+
- Build on previous answers to go deeper
|
|
81
|
+
- Be conversational but professional
|
|
82
|
+
- Focus on understanding the "why" behind requirements
|
|
83
|
+
|
|
84
|
+
## Categories to Cover
|
|
85
|
+
You need to gather enough information in these areas:
|
|
86
|
+
1. **Problem**: What problem does this solve? Why does it matter? Who feels the pain?
|
|
87
|
+
2. **Users**: Who are the target users? What are their roles and needs?
|
|
88
|
+
3. **Features**: What are the core capabilities? What's the MVP vs nice-to-have?
|
|
89
|
+
4. **Constraints**: Technical limitations? Timeline? Budget? Compliance requirements?
|
|
90
|
+
5. **Tech Stack**: Preferred technologies? Existing systems to integrate with?
|
|
91
|
+
|
|
92
|
+
## Scoring Guidelines
|
|
93
|
+
For each category, mentally score completeness from 0-100:
|
|
94
|
+
- 0-30: Not mentioned or too vague
|
|
95
|
+
- 30-60: Basic understanding but missing details
|
|
96
|
+
- 60-80: Good understanding, minor gaps
|
|
97
|
+
- 80-100: Clear, specific, actionable
|
|
98
|
+
|
|
99
|
+
## When to Stop
|
|
100
|
+
Stop asking questions when:
|
|
101
|
+
- All categories score 60+ AND total average is 70+
|
|
102
|
+
- OR user has provided enough for a focused MVP PRD
|
|
103
|
+
- OR diminishing returns (user is repeating themselves)
|
|
104
|
+
|
|
105
|
+
Apply a "question tax" - only ask if the answer will meaningfully improve the PRD.
|
|
106
|
+
Don't ask about edge cases, future phases, or nice-to-haves unless core is unclear."""
|
|
107
|
+
|
|
108
|
+
QUESTION_GENERATION_PROMPT = """Based on the conversation so far, generate the next discovery question.
|
|
109
|
+
|
|
110
|
+
## Previous Q&A
|
|
111
|
+
{qa_history}
|
|
112
|
+
|
|
113
|
+
## Current Coverage Assessment
|
|
114
|
+
{coverage_assessment}
|
|
115
|
+
|
|
116
|
+
## Instructions
|
|
117
|
+
1. Identify the biggest gap in understanding
|
|
118
|
+
2. Generate ONE focused question that addresses that gap
|
|
119
|
+
3. Make it specific to what the user has already shared
|
|
120
|
+
4. Don't repeat questions already asked
|
|
121
|
+
|
|
122
|
+
If you believe we have enough information (all categories adequately covered), respond with:
|
|
123
|
+
DISCOVERY_COMPLETE
|
|
124
|
+
|
|
125
|
+
Otherwise, respond with just the question text, nothing else."""
|
|
126
|
+
|
|
127
|
+
ANSWER_VALIDATION_PROMPT = """Evaluate whether this answer adequately addresses the question.
|
|
128
|
+
|
|
129
|
+
Question: {question}
|
|
130
|
+
Answer: {answer}
|
|
131
|
+
|
|
132
|
+
## Evaluation Criteria
|
|
133
|
+
1. **Relevance**: Does it actually answer what was asked?
|
|
134
|
+
2. **Specificity**: Is it concrete enough to act on?
|
|
135
|
+
3. **Completeness**: Does it fully address the question or leave major gaps?
|
|
136
|
+
|
|
137
|
+
## Response Format
|
|
138
|
+
Respond with a JSON object:
|
|
139
|
+
{{
|
|
140
|
+
"adequate": true/false,
|
|
141
|
+
"reason": "brief explanation",
|
|
142
|
+
"follow_up": "optional follow-up question if answer is inadequate but not rejectable"
|
|
143
|
+
}}
|
|
144
|
+
|
|
145
|
+
Be lenient - accept answers that provide useful information even if not perfect.
|
|
146
|
+
Only mark inadequate if the answer is truly unhelpful (off-topic, too vague to use, or dismissive)."""
|
|
147
|
+
|
|
148
|
+
COVERAGE_ASSESSMENT_PROMPT = """Assess the current coverage of discovery categories based on the conversation.
|
|
149
|
+
|
|
150
|
+
## Conversation History
|
|
151
|
+
{qa_history}
|
|
152
|
+
|
|
153
|
+
## Categories to Assess
|
|
154
|
+
1. problem - Understanding of the problem being solved
|
|
155
|
+
2. users - Knowledge of target users and their needs
|
|
156
|
+
3. features - Clarity on core capabilities needed
|
|
157
|
+
4. constraints - Awareness of limitations and requirements
|
|
158
|
+
5. tech_stack - Understanding of technology preferences
|
|
159
|
+
|
|
160
|
+
## Response Format
|
|
161
|
+
Respond with a JSON object:
|
|
162
|
+
{{
|
|
163
|
+
"scores": {{
|
|
164
|
+
"problem": 0-100,
|
|
165
|
+
"users": 0-100,
|
|
166
|
+
"features": 0-100,
|
|
167
|
+
"constraints": 0-100,
|
|
168
|
+
"tech_stack": 0-100
|
|
169
|
+
}},
|
|
170
|
+
"average": 0-100,
|
|
171
|
+
"weakest_category": "category_name",
|
|
172
|
+
"ready_for_prd": true/false,
|
|
173
|
+
"reasoning": "brief explanation of readiness assessment"
|
|
174
|
+
}}"""
|
|
175
|
+
|
|
176
|
+
PRD_GENERATION_PROMPT = """Generate a Product Requirements Document based on the discovery conversation.
|
|
177
|
+
|
|
178
|
+
## Discovery Conversation
|
|
179
|
+
{qa_history}
|
|
180
|
+
|
|
181
|
+
## PRD Format
|
|
182
|
+
Generate a markdown PRD with these sections:
|
|
183
|
+
|
|
184
|
+
# [Project Title - infer from conversation]
|
|
185
|
+
|
|
186
|
+
## Overview
|
|
187
|
+
A clear problem statement and vision (2-3 sentences).
|
|
188
|
+
|
|
189
|
+
## Target Users
|
|
190
|
+
Who will use this and what are their key needs.
|
|
191
|
+
|
|
192
|
+
## Core Features
|
|
193
|
+
Numbered list of MVP features with brief descriptions.
|
|
194
|
+
|
|
195
|
+
## Technical Requirements
|
|
196
|
+
Technology stack, integrations, and technical constraints.
|
|
197
|
+
|
|
198
|
+
## Constraints & Considerations
|
|
199
|
+
Timeline, budget, compliance, or other limitations.
|
|
200
|
+
|
|
201
|
+
## Success Criteria
|
|
202
|
+
How we'll know if this project succeeds (measurable where possible).
|
|
203
|
+
|
|
204
|
+
## Out of Scope (MVP)
|
|
205
|
+
What we're explicitly NOT building in the first version.
|
|
206
|
+
|
|
207
|
+
---
|
|
208
|
+
|
|
209
|
+
Keep it concise but complete. Focus on actionable requirements.
|
|
210
|
+
This PRD should be sufficient to generate development tasks."""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@dataclass
|
|
214
|
+
class PrdDiscoverySession:
|
|
215
|
+
"""Manages an AI-driven PRD discovery session.
|
|
216
|
+
|
|
217
|
+
The AI drives the entire discovery process:
|
|
218
|
+
- Generates context-sensitive questions based on conversation history
|
|
219
|
+
- Validates answer adequacy (not just length/pattern)
|
|
220
|
+
- Determines when sufficient information has been gathered
|
|
221
|
+
- Generates the final PRD
|
|
222
|
+
|
|
223
|
+
Attributes:
|
|
224
|
+
workspace: The workspace this session belongs to
|
|
225
|
+
api_key: API key for LLM provider (required)
|
|
226
|
+
session_id: Unique session identifier
|
|
227
|
+
state: Current session state
|
|
228
|
+
"""
|
|
229
|
+
|
|
230
|
+
workspace: Workspace
|
|
231
|
+
api_key: Optional[str] = None
|
|
232
|
+
session_id: Optional[str] = field(default=None, init=False)
|
|
233
|
+
state: SessionState = field(default=SessionState.IDLE, init=False)
|
|
234
|
+
_llm_provider: Any = field(default=None, init=False)
|
|
235
|
+
_qa_history: list[dict[str, str]] = field(default_factory=list, init=False)
|
|
236
|
+
_current_question: Optional[str] = field(default=None, init=False)
|
|
237
|
+
_coverage: Optional[dict[str, Any]] = field(default=None, init=False)
|
|
238
|
+
_blocker_id: Optional[str] = field(default=None, init=False)
|
|
239
|
+
_is_complete: bool = field(default=False, init=False)
|
|
240
|
+
|
|
241
|
+
def __post_init__(self):
|
|
242
|
+
"""Initialize the LLM provider."""
|
|
243
|
+
key = self.api_key or os.getenv("ANTHROPIC_API_KEY")
|
|
244
|
+
if not key:
|
|
245
|
+
raise NoApiKeyError(
|
|
246
|
+
"ANTHROPIC_API_KEY is required for AI-driven discovery. "
|
|
247
|
+
"Set the environment variable or pass api_key parameter."
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
self._llm_provider = AnthropicProvider(api_key=key)
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def answered_count(self) -> int:
|
|
254
|
+
"""Number of questions answered."""
|
|
255
|
+
return len(self._qa_history)
|
|
256
|
+
|
|
257
|
+
def start_discovery(self) -> None:
|
|
258
|
+
"""Start a new discovery session.
|
|
259
|
+
|
|
260
|
+
Creates a session record and generates the first question.
|
|
261
|
+
"""
|
|
262
|
+
self.session_id = str(uuid.uuid4())
|
|
263
|
+
self.state = SessionState.DISCOVERING
|
|
264
|
+
self._qa_history = []
|
|
265
|
+
self._is_complete = False
|
|
266
|
+
|
|
267
|
+
# Ensure schema exists and save initial session
|
|
268
|
+
_ensure_discovery_schema(self.workspace)
|
|
269
|
+
self._save_session()
|
|
270
|
+
|
|
271
|
+
# Generate first question and persist it
|
|
272
|
+
self._current_question = self._generate_opening_question()
|
|
273
|
+
self._save_session()
|
|
274
|
+
|
|
275
|
+
logger.info(f"Started discovery session {self.session_id}")
|
|
276
|
+
|
|
277
|
+
def _generate_opening_question(self) -> str:
|
|
278
|
+
"""Generate the opening question for discovery."""
|
|
279
|
+
|
|
280
|
+
prompt = """You're starting a new product discovery session.
|
|
281
|
+
Generate an opening question that invites the user to describe their project idea.
|
|
282
|
+
Be warm and encouraging. Just output the question, nothing else."""
|
|
283
|
+
|
|
284
|
+
response = self._llm_provider.complete(
|
|
285
|
+
messages=[{"role": "user", "content": prompt}],
|
|
286
|
+
purpose=Purpose.GENERATION,
|
|
287
|
+
system=DISCOVERY_SYSTEM_PROMPT,
|
|
288
|
+
max_tokens=200,
|
|
289
|
+
temperature=0.7,
|
|
290
|
+
)
|
|
291
|
+
return response.content.strip()
|
|
292
|
+
|
|
293
|
+
def load_session(self, session_id: str) -> None:
|
|
294
|
+
"""Load an existing session from database.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
session_id: Session ID to load
|
|
298
|
+
|
|
299
|
+
Raises:
|
|
300
|
+
ValueError: If session not found
|
|
301
|
+
"""
|
|
302
|
+
conn = get_db_connection(self.workspace)
|
|
303
|
+
cursor = conn.cursor()
|
|
304
|
+
|
|
305
|
+
cursor.execute(
|
|
306
|
+
"""
|
|
307
|
+
SELECT id, state, qa_history, current_question, coverage, blocker_id, is_complete
|
|
308
|
+
FROM discovery_sessions
|
|
309
|
+
WHERE id = ? AND workspace_id = ?
|
|
310
|
+
""",
|
|
311
|
+
(session_id, self.workspace.id),
|
|
312
|
+
)
|
|
313
|
+
row = cursor.fetchone()
|
|
314
|
+
conn.close()
|
|
315
|
+
|
|
316
|
+
if not row:
|
|
317
|
+
raise ValueError(f"Session not found: {session_id}")
|
|
318
|
+
|
|
319
|
+
self.session_id = row[0]
|
|
320
|
+
self.state = SessionState(row[1])
|
|
321
|
+
self._qa_history = json.loads(row[2]) if row[2] else []
|
|
322
|
+
self._current_question = row[3]
|
|
323
|
+
self._coverage = json.loads(row[4]) if row[4] else None
|
|
324
|
+
self._blocker_id = row[5]
|
|
325
|
+
self._is_complete = bool(row[6]) if row[6] is not None else False
|
|
326
|
+
|
|
327
|
+
logger.info(f"Loaded session {session_id} with {len(self._qa_history)} Q&A pairs")
|
|
328
|
+
|
|
329
|
+
def get_current_question(self) -> Optional[dict[str, Any]]:
|
|
330
|
+
"""Get the current question to display.
|
|
331
|
+
|
|
332
|
+
Returns:
|
|
333
|
+
Question dict with 'text' and 'question_number', or None if complete
|
|
334
|
+
"""
|
|
335
|
+
if self._is_complete:
|
|
336
|
+
return None
|
|
337
|
+
|
|
338
|
+
if self._current_question:
|
|
339
|
+
return {
|
|
340
|
+
"text": self._current_question,
|
|
341
|
+
"question_number": len(self._qa_history) + 1,
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def submit_answer(self, answer_text: str) -> dict[str, Any]:
|
|
347
|
+
"""Submit an answer and get validation result.
|
|
348
|
+
|
|
349
|
+
The AI validates the answer for adequacy. If adequate, generates
|
|
350
|
+
the next question (or marks discovery complete).
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
answer_text: User's answer
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Dict with 'accepted', 'feedback', and optionally 'follow_up'
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
ValidationError: If answer is empty
|
|
360
|
+
"""
|
|
361
|
+
answer_text = answer_text.strip()
|
|
362
|
+
|
|
363
|
+
if not answer_text:
|
|
364
|
+
raise ValidationError("Please provide an answer.")
|
|
365
|
+
|
|
366
|
+
# Validate with AI
|
|
367
|
+
validation = self._validate_answer(self._current_question, answer_text)
|
|
368
|
+
|
|
369
|
+
if not validation["adequate"]:
|
|
370
|
+
# Answer not adequate - provide feedback
|
|
371
|
+
if validation.get("follow_up"):
|
|
372
|
+
# Replace current question with follow-up
|
|
373
|
+
self._current_question = validation["follow_up"]
|
|
374
|
+
self._save_session()
|
|
375
|
+
return {
|
|
376
|
+
"accepted": False,
|
|
377
|
+
"feedback": validation["reason"],
|
|
378
|
+
"follow_up": validation["follow_up"],
|
|
379
|
+
}
|
|
380
|
+
else:
|
|
381
|
+
return {
|
|
382
|
+
"accepted": False,
|
|
383
|
+
"feedback": validation["reason"],
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# Answer accepted - store it
|
|
387
|
+
self._qa_history.append({
|
|
388
|
+
"question": self._current_question,
|
|
389
|
+
"answer": answer_text,
|
|
390
|
+
"timestamp": _utc_now().isoformat(),
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
# Check coverage and generate next question
|
|
394
|
+
self._update_coverage()
|
|
395
|
+
next_question = self._generate_next_question()
|
|
396
|
+
|
|
397
|
+
# Complete when AI signals done OR coverage assessment says ready
|
|
398
|
+
if next_question == "DISCOVERY_COMPLETE" or self._coverage_is_sufficient():
|
|
399
|
+
self._is_complete = True
|
|
400
|
+
self._current_question = None
|
|
401
|
+
else:
|
|
402
|
+
self._current_question = next_question
|
|
403
|
+
|
|
404
|
+
self._save_session()
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
"accepted": True,
|
|
408
|
+
"feedback": "Answer recorded.",
|
|
409
|
+
"coverage": self._coverage,
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
def _validate_answer(self, question: str, answer: str) -> dict[str, Any]:
|
|
413
|
+
"""Use AI to validate if answer adequately addresses the question."""
|
|
414
|
+
|
|
415
|
+
prompt = ANSWER_VALIDATION_PROMPT.format(
|
|
416
|
+
question=question,
|
|
417
|
+
answer=answer,
|
|
418
|
+
)
|
|
419
|
+
|
|
420
|
+
response = self._llm_provider.complete(
|
|
421
|
+
messages=[{"role": "user", "content": prompt}],
|
|
422
|
+
purpose=Purpose.GENERATION,
|
|
423
|
+
max_tokens=300,
|
|
424
|
+
temperature=0.3,
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
try:
|
|
428
|
+
# Parse JSON response
|
|
429
|
+
content = response.content.strip()
|
|
430
|
+
# Handle markdown code blocks
|
|
431
|
+
if content.startswith("```"):
|
|
432
|
+
content = content.split("```")[1]
|
|
433
|
+
if content.startswith("json"):
|
|
434
|
+
content = content[4:]
|
|
435
|
+
return json.loads(content)
|
|
436
|
+
except json.JSONDecodeError:
|
|
437
|
+
# If AI doesn't return valid JSON, be lenient and accept
|
|
438
|
+
logger.warning(f"Could not parse validation response: {response.content}")
|
|
439
|
+
return {"adequate": True, "reason": "Accepted"}
|
|
440
|
+
|
|
441
|
+
def _update_coverage(self) -> None:
|
|
442
|
+
"""Update the coverage assessment based on conversation history."""
|
|
443
|
+
|
|
444
|
+
qa_history = self._format_qa_history()
|
|
445
|
+
|
|
446
|
+
prompt = COVERAGE_ASSESSMENT_PROMPT.format(qa_history=qa_history)
|
|
447
|
+
|
|
448
|
+
response = self._llm_provider.complete(
|
|
449
|
+
messages=[{"role": "user", "content": prompt}],
|
|
450
|
+
purpose=Purpose.GENERATION,
|
|
451
|
+
max_tokens=500,
|
|
452
|
+
temperature=0.3,
|
|
453
|
+
)
|
|
454
|
+
|
|
455
|
+
try:
|
|
456
|
+
content = response.content.strip()
|
|
457
|
+
if content.startswith("```"):
|
|
458
|
+
content = content.split("```")[1]
|
|
459
|
+
if content.startswith("json"):
|
|
460
|
+
content = content[4:]
|
|
461
|
+
self._coverage = json.loads(content)
|
|
462
|
+
except json.JSONDecodeError:
|
|
463
|
+
logger.warning(f"Could not parse coverage response: {response.content}")
|
|
464
|
+
self._coverage = None
|
|
465
|
+
|
|
466
|
+
def _coverage_is_sufficient(self) -> bool:
|
|
467
|
+
"""Check if coverage is sufficient to generate PRD.
|
|
468
|
+
|
|
469
|
+
Relies on AI assessment - if a comprehensive answer covers all
|
|
470
|
+
categories (e.g., pasting a full PRD), discovery can complete quickly.
|
|
471
|
+
"""
|
|
472
|
+
if not self._coverage:
|
|
473
|
+
return False
|
|
474
|
+
return self._coverage.get("ready_for_prd", False)
|
|
475
|
+
|
|
476
|
+
def _generate_next_question(self) -> str:
|
|
477
|
+
"""Generate the next discovery question based on context."""
|
|
478
|
+
|
|
479
|
+
qa_history = self._format_qa_history()
|
|
480
|
+
coverage_str = json.dumps(self._coverage, indent=2) if self._coverage else "Not yet assessed"
|
|
481
|
+
|
|
482
|
+
prompt = QUESTION_GENERATION_PROMPT.format(
|
|
483
|
+
qa_history=qa_history,
|
|
484
|
+
coverage_assessment=coverage_str,
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
response = self._llm_provider.complete(
|
|
488
|
+
messages=[{"role": "user", "content": prompt}],
|
|
489
|
+
purpose=Purpose.GENERATION,
|
|
490
|
+
system=DISCOVERY_SYSTEM_PROMPT,
|
|
491
|
+
max_tokens=300,
|
|
492
|
+
temperature=0.7,
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
return response.content.strip()
|
|
496
|
+
|
|
497
|
+
def _format_qa_history(self) -> str:
|
|
498
|
+
"""Format Q&A history for prompts."""
|
|
499
|
+
if not self._qa_history:
|
|
500
|
+
return "(No questions answered yet)"
|
|
501
|
+
|
|
502
|
+
lines = []
|
|
503
|
+
for i, qa in enumerate(self._qa_history, 1):
|
|
504
|
+
lines.append(f"Q{i}: {qa['question']}")
|
|
505
|
+
lines.append(f"A{i}: {qa['answer']}")
|
|
506
|
+
lines.append("")
|
|
507
|
+
return "\n".join(lines)
|
|
508
|
+
|
|
509
|
+
def is_complete(self) -> bool:
|
|
510
|
+
"""Check if discovery is complete.
|
|
511
|
+
|
|
512
|
+
Returns:
|
|
513
|
+
True if AI has determined sufficient information gathered
|
|
514
|
+
"""
|
|
515
|
+
return self._is_complete
|
|
516
|
+
|
|
517
|
+
def get_progress(self) -> dict[str, Any]:
|
|
518
|
+
"""Get discovery progress statistics.
|
|
519
|
+
|
|
520
|
+
Returns:
|
|
521
|
+
Dict with coverage scores and completion status
|
|
522
|
+
"""
|
|
523
|
+
return {
|
|
524
|
+
"answered": len(self._qa_history),
|
|
525
|
+
"coverage": self._coverage or {},
|
|
526
|
+
"is_complete": self._is_complete,
|
|
527
|
+
"percentage": self._coverage.get("average", 0) if self._coverage else 0,
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
def pause_discovery(self, reason: str) -> str:
|
|
531
|
+
"""Pause the discovery session.
|
|
532
|
+
|
|
533
|
+
Creates a blocker for later resume.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
reason: Reason for pausing
|
|
537
|
+
|
|
538
|
+
Returns:
|
|
539
|
+
Blocker ID for resume
|
|
540
|
+
"""
|
|
541
|
+
self.state = SessionState.PAUSED
|
|
542
|
+
|
|
543
|
+
question = (
|
|
544
|
+
f"Discovery session paused: {reason}\n"
|
|
545
|
+
f"Session ID: {self.session_id}\n"
|
|
546
|
+
f"Progress: {len(self._qa_history)} questions answered"
|
|
547
|
+
)
|
|
548
|
+
|
|
549
|
+
blocker = blockers.create(
|
|
550
|
+
self.workspace, question=question, task_id=None, created_by="system"
|
|
551
|
+
)
|
|
552
|
+
self._blocker_id = blocker.id
|
|
553
|
+
self._save_session()
|
|
554
|
+
|
|
555
|
+
logger.info(f"Session paused with blocker {blocker.id}")
|
|
556
|
+
return blocker.id
|
|
557
|
+
|
|
558
|
+
def resume_discovery(self, blocker_id: str) -> None:
|
|
559
|
+
"""Resume discovery from a blocker.
|
|
560
|
+
|
|
561
|
+
Args:
|
|
562
|
+
blocker_id: Blocker ID to resume from
|
|
563
|
+
|
|
564
|
+
Raises:
|
|
565
|
+
ValueError: If blocker not found or not a discovery blocker
|
|
566
|
+
"""
|
|
567
|
+
blocker = blockers.get(self.workspace, blocker_id)
|
|
568
|
+
if not blocker:
|
|
569
|
+
raise ValueError(f"Blocker not found: {blocker_id}")
|
|
570
|
+
|
|
571
|
+
if "discovery" not in blocker.question.lower():
|
|
572
|
+
raise ValueError("Blocker is not a discovery session blocker")
|
|
573
|
+
|
|
574
|
+
# Extract session ID from blocker question
|
|
575
|
+
lines = blocker.question.split("\n")
|
|
576
|
+
session_id = None
|
|
577
|
+
for line in lines:
|
|
578
|
+
if "Session ID:" in line:
|
|
579
|
+
session_id = line.split("Session ID:")[-1].strip()
|
|
580
|
+
break
|
|
581
|
+
|
|
582
|
+
if not session_id:
|
|
583
|
+
raise ValueError("Could not find session ID in blocker")
|
|
584
|
+
|
|
585
|
+
# Load the session
|
|
586
|
+
self.load_session(session_id)
|
|
587
|
+
self.state = SessionState.DISCOVERING
|
|
588
|
+
self._save_session()
|
|
589
|
+
|
|
590
|
+
logger.info(f"Resumed session {session_id} from blocker {blocker_id}")
|
|
591
|
+
|
|
592
|
+
def generate_prd(self, template_id: Optional[str] = None) -> prd.PrdRecord:
|
|
593
|
+
"""Generate PRD from discovery conversation.
|
|
594
|
+
|
|
595
|
+
Uses AI to synthesize the conversation into a structured PRD.
|
|
596
|
+
|
|
597
|
+
Args:
|
|
598
|
+
template_id: Optional PRD template ID to use for formatting.
|
|
599
|
+
If not provided or not found, uses the default built-in
|
|
600
|
+
prompt format (recorded as "default" in metadata).
|
|
601
|
+
|
|
602
|
+
Returns:
|
|
603
|
+
Created PrdRecord
|
|
604
|
+
|
|
605
|
+
Raises:
|
|
606
|
+
IncompleteSessionError: If discovery not complete
|
|
607
|
+
"""
|
|
608
|
+
if not self._is_complete and not self._coverage_is_sufficient():
|
|
609
|
+
raise IncompleteSessionError(
|
|
610
|
+
"Cannot generate PRD: discovery not complete. "
|
|
611
|
+
f"Current coverage: {self._coverage}"
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
qa_history = self._format_qa_history()
|
|
615
|
+
|
|
616
|
+
# Build prompt based on template and get the resolved template ID
|
|
617
|
+
prompt, resolved_template_id = self._build_prd_prompt(qa_history, template_id)
|
|
618
|
+
|
|
619
|
+
response = self._llm_provider.complete(
|
|
620
|
+
messages=[{"role": "user", "content": prompt}],
|
|
621
|
+
purpose=Purpose.PLANNING, # Use stronger model for PRD generation
|
|
622
|
+
max_tokens=4000,
|
|
623
|
+
temperature=0.5,
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
content = response.content.strip()
|
|
627
|
+
|
|
628
|
+
# Extract title from PRD content
|
|
629
|
+
title = self._extract_title_from_prd(content)
|
|
630
|
+
|
|
631
|
+
# Store PRD with both requested and resolved template IDs in metadata
|
|
632
|
+
metadata: dict[str, Any] = {
|
|
633
|
+
"source": "ai_discovery",
|
|
634
|
+
"session_id": self.session_id,
|
|
635
|
+
"questions_asked": len(self._qa_history),
|
|
636
|
+
"coverage": self._coverage,
|
|
637
|
+
"generated_at": _utc_now().isoformat(),
|
|
638
|
+
"template_id": resolved_template_id,
|
|
639
|
+
}
|
|
640
|
+
# Track if a different template was requested but not found
|
|
641
|
+
if template_id and template_id != resolved_template_id:
|
|
642
|
+
metadata["requested_template_id"] = template_id
|
|
643
|
+
|
|
644
|
+
record = prd.store(
|
|
645
|
+
self.workspace,
|
|
646
|
+
content=content,
|
|
647
|
+
title=title,
|
|
648
|
+
metadata=metadata,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
# Update session state
|
|
652
|
+
self.state = SessionState.COMPLETED
|
|
653
|
+
self._save_session()
|
|
654
|
+
|
|
655
|
+
logger.info(f"Generated PRD {record.id} from session {self.session_id} using template '{resolved_template_id}'")
|
|
656
|
+
return record
|
|
657
|
+
|
|
658
|
+
def _build_prd_prompt(
|
|
659
|
+
self, qa_history: str, template_id: Optional[str] = None
|
|
660
|
+
) -> tuple[str, str]:
|
|
661
|
+
"""Build PRD generation prompt based on template.
|
|
662
|
+
|
|
663
|
+
Args:
|
|
664
|
+
qa_history: Formatted Q&A history string
|
|
665
|
+
template_id: Template ID to use (defaults to None, which uses default prompt)
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
Tuple of (prompt string, resolved template ID)
|
|
669
|
+
The resolved template ID is "default" if no template was used,
|
|
670
|
+
or the actual template ID that was successfully loaded.
|
|
671
|
+
"""
|
|
672
|
+
from codeframe.planning.prd_templates import PrdTemplateManager
|
|
673
|
+
from pathlib import Path
|
|
674
|
+
|
|
675
|
+
# Use default prompt if no template specified
|
|
676
|
+
if not template_id:
|
|
677
|
+
return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
|
|
678
|
+
|
|
679
|
+
# Pass workspace path to include project templates
|
|
680
|
+
workspace_path = Path(self.workspace.repo_path) if self.workspace.repo_path else None
|
|
681
|
+
manager = PrdTemplateManager(workspace_path=workspace_path)
|
|
682
|
+
template = manager.get_template(template_id)
|
|
683
|
+
|
|
684
|
+
if template is None:
|
|
685
|
+
logger.warning(f"Template '{template_id}' not found, falling back to default prompt")
|
|
686
|
+
return (PRD_GENERATION_PROMPT.format(qa_history=qa_history), "default")
|
|
687
|
+
|
|
688
|
+
# Build dynamic prompt from template sections
|
|
689
|
+
sections_spec = []
|
|
690
|
+
for section in template.sections:
|
|
691
|
+
required_note = " (required)" if section.required else " (optional)"
|
|
692
|
+
sections_spec.append(f"## {section.title}{required_note}\n{section.source} - related content")
|
|
693
|
+
|
|
694
|
+
sections_text = "\n\n".join(sections_spec)
|
|
695
|
+
|
|
696
|
+
prompt = f"""Generate a Product Requirements Document based on the discovery conversation.
|
|
697
|
+
|
|
698
|
+
## Discovery Conversation
|
|
699
|
+
{qa_history}
|
|
700
|
+
|
|
701
|
+
## Template: {template.name}
|
|
702
|
+
{template.description}
|
|
703
|
+
|
|
704
|
+
## Sections
|
|
705
|
+
Generate a markdown PRD with these sections in order:
|
|
706
|
+
|
|
707
|
+
# [Project Title - infer from conversation]
|
|
708
|
+
|
|
709
|
+
{sections_text}
|
|
710
|
+
|
|
711
|
+
---
|
|
712
|
+
|
|
713
|
+
Keep it concise but complete. Focus on actionable requirements.
|
|
714
|
+
Follow the template structure exactly. This PRD should be sufficient to generate development tasks."""
|
|
715
|
+
|
|
716
|
+
return (prompt, template_id)
|
|
717
|
+
|
|
718
|
+
def _extract_title_from_prd(self, content: str) -> str:
|
|
719
|
+
"""Extract project title from generated PRD content."""
|
|
720
|
+
import re
|
|
721
|
+
|
|
722
|
+
# Try to find H1 heading
|
|
723
|
+
match = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
|
|
724
|
+
if match:
|
|
725
|
+
title = match.group(1).strip()
|
|
726
|
+
# Remove any trailing punctuation or brackets
|
|
727
|
+
title = re.sub(r"[\[\]()]", "", title).strip()
|
|
728
|
+
return title[:100] # Limit length
|
|
729
|
+
|
|
730
|
+
return "Untitled Project"
|
|
731
|
+
|
|
732
|
+
def _save_session(self) -> None:
|
|
733
|
+
"""Save session state to database."""
|
|
734
|
+
conn = get_db_connection(self.workspace)
|
|
735
|
+
cursor = conn.cursor()
|
|
736
|
+
|
|
737
|
+
now = _utc_now().isoformat()
|
|
738
|
+
qa_history_json = json.dumps(self._qa_history)
|
|
739
|
+
coverage_json = json.dumps(self._coverage) if self._coverage else None
|
|
740
|
+
|
|
741
|
+
cursor.execute(
|
|
742
|
+
"""
|
|
743
|
+
INSERT OR REPLACE INTO discovery_sessions
|
|
744
|
+
(id, workspace_id, state, qa_history, current_question,
|
|
745
|
+
coverage, blocker_id, is_complete, created_at, updated_at)
|
|
746
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?,
|
|
747
|
+
COALESCE((SELECT created_at FROM discovery_sessions WHERE id = ?), ?),
|
|
748
|
+
?)
|
|
749
|
+
""",
|
|
750
|
+
(
|
|
751
|
+
self.session_id,
|
|
752
|
+
self.workspace.id,
|
|
753
|
+
self.state.value,
|
|
754
|
+
qa_history_json,
|
|
755
|
+
self._current_question,
|
|
756
|
+
coverage_json,
|
|
757
|
+
self._blocker_id,
|
|
758
|
+
1 if self._is_complete else 0,
|
|
759
|
+
self.session_id,
|
|
760
|
+
now,
|
|
761
|
+
now,
|
|
762
|
+
),
|
|
763
|
+
)
|
|
764
|
+
conn.commit()
|
|
765
|
+
conn.close()
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def _ensure_discovery_schema(workspace: Workspace) -> None:
|
|
769
|
+
"""Ensure discovery_sessions table exists with updated schema.
|
|
770
|
+
|
|
771
|
+
Args:
|
|
772
|
+
workspace: Workspace to update
|
|
773
|
+
"""
|
|
774
|
+
conn = get_db_connection(workspace)
|
|
775
|
+
cursor = conn.cursor()
|
|
776
|
+
|
|
777
|
+
cursor.execute("""
|
|
778
|
+
CREATE TABLE IF NOT EXISTS discovery_sessions (
|
|
779
|
+
id TEXT PRIMARY KEY,
|
|
780
|
+
workspace_id TEXT NOT NULL,
|
|
781
|
+
state TEXT NOT NULL DEFAULT 'idle',
|
|
782
|
+
qa_history TEXT DEFAULT '[]',
|
|
783
|
+
current_question TEXT,
|
|
784
|
+
coverage TEXT,
|
|
785
|
+
blocker_id TEXT,
|
|
786
|
+
is_complete INTEGER DEFAULT 0,
|
|
787
|
+
created_at TEXT NOT NULL,
|
|
788
|
+
updated_at TEXT NOT NULL,
|
|
789
|
+
FOREIGN KEY (workspace_id) REFERENCES workspace(id),
|
|
790
|
+
FOREIGN KEY (blocker_id) REFERENCES blockers(id),
|
|
791
|
+
CHECK (state IN ('idle', 'discovering', 'paused', 'completed'))
|
|
792
|
+
)
|
|
793
|
+
""")
|
|
794
|
+
|
|
795
|
+
cursor.execute(
|
|
796
|
+
"CREATE INDEX IF NOT EXISTS idx_discovery_sessions_workspace "
|
|
797
|
+
"ON discovery_sessions(workspace_id)"
|
|
798
|
+
)
|
|
799
|
+
cursor.execute(
|
|
800
|
+
"CREATE INDEX IF NOT EXISTS idx_discovery_sessions_state "
|
|
801
|
+
"ON discovery_sessions(state)"
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
conn.commit()
|
|
805
|
+
conn.close()
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def get_active_session(workspace: Workspace) -> Optional[PrdDiscoverySession]:
|
|
809
|
+
"""Get the most recent active (non-completed) discovery session.
|
|
810
|
+
|
|
811
|
+
Args:
|
|
812
|
+
workspace: Workspace to query
|
|
813
|
+
|
|
814
|
+
Returns:
|
|
815
|
+
PrdDiscoverySession if found, None if no active session exists
|
|
816
|
+
|
|
817
|
+
Raises:
|
|
818
|
+
NoApiKeyError: If session exists but ANTHROPIC_API_KEY is not set
|
|
819
|
+
"""
|
|
820
|
+
_ensure_discovery_schema(workspace)
|
|
821
|
+
|
|
822
|
+
conn = get_db_connection(workspace)
|
|
823
|
+
cursor = conn.cursor()
|
|
824
|
+
|
|
825
|
+
cursor.execute(
|
|
826
|
+
"""
|
|
827
|
+
SELECT id FROM discovery_sessions
|
|
828
|
+
WHERE workspace_id = ? AND state != 'completed'
|
|
829
|
+
ORDER BY updated_at DESC
|
|
830
|
+
LIMIT 1
|
|
831
|
+
""",
|
|
832
|
+
(workspace.id,),
|
|
833
|
+
)
|
|
834
|
+
row = cursor.fetchone()
|
|
835
|
+
conn.close()
|
|
836
|
+
|
|
837
|
+
if not row:
|
|
838
|
+
return None
|
|
839
|
+
|
|
840
|
+
# Need API key to load session
|
|
841
|
+
api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
842
|
+
if not api_key:
|
|
843
|
+
logger.warning(
|
|
844
|
+
"Cannot load discovery session %s: ANTHROPIC_API_KEY environment "
|
|
845
|
+
"variable is not set. Set the API key to resume this session.",
|
|
846
|
+
row[0],
|
|
847
|
+
)
|
|
848
|
+
raise NoApiKeyError(
|
|
849
|
+
"ANTHROPIC_API_KEY is required to load discovery session. "
|
|
850
|
+
"Set the environment variable to resume."
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
session = PrdDiscoverySession(workspace, api_key=api_key)
|
|
854
|
+
session.load_session(row[0])
|
|
855
|
+
return session
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
# ============================================================================
|
|
859
|
+
# Module-Level Convenience Functions (for Route Delegation)
|
|
860
|
+
# ============================================================================
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def start_discovery_session(
|
|
864
|
+
workspace: Workspace,
|
|
865
|
+
api_key: Optional[str] = None,
|
|
866
|
+
) -> PrdDiscoverySession:
|
|
867
|
+
"""Start a new PRD discovery session.
|
|
868
|
+
|
|
869
|
+
This is a convenience function for routes to delegate to.
|
|
870
|
+
Creates a new PrdDiscoverySession, starts discovery, and returns it.
|
|
871
|
+
|
|
872
|
+
Args:
|
|
873
|
+
workspace: Target workspace
|
|
874
|
+
api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
|
|
875
|
+
|
|
876
|
+
Returns:
|
|
877
|
+
PrdDiscoverySession with first question ready
|
|
878
|
+
|
|
879
|
+
Raises:
|
|
880
|
+
NoApiKeyError: If no API key available
|
|
881
|
+
|
|
882
|
+
Example:
|
|
883
|
+
session = start_discovery_session(workspace)
|
|
884
|
+
question = session.get_current_question()
|
|
885
|
+
"""
|
|
886
|
+
session = PrdDiscoverySession(workspace, api_key=api_key)
|
|
887
|
+
session.start_discovery()
|
|
888
|
+
return session
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def get_session(
|
|
892
|
+
workspace: Workspace,
|
|
893
|
+
session_id: str,
|
|
894
|
+
api_key: Optional[str] = None,
|
|
895
|
+
) -> PrdDiscoverySession:
|
|
896
|
+
"""Load an existing discovery session by ID.
|
|
897
|
+
|
|
898
|
+
Args:
|
|
899
|
+
workspace: Target workspace
|
|
900
|
+
session_id: Session ID to load
|
|
901
|
+
api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
|
|
902
|
+
|
|
903
|
+
Returns:
|
|
904
|
+
Loaded PrdDiscoverySession
|
|
905
|
+
|
|
906
|
+
Raises:
|
|
907
|
+
ValueError: If session not found
|
|
908
|
+
NoApiKeyError: If no API key available
|
|
909
|
+
"""
|
|
910
|
+
session = PrdDiscoverySession(workspace, api_key=api_key)
|
|
911
|
+
session.load_session(session_id)
|
|
912
|
+
return session
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def process_discovery_answer(
|
|
916
|
+
workspace: Workspace,
|
|
917
|
+
session_id: str,
|
|
918
|
+
answer: str,
|
|
919
|
+
api_key: Optional[str] = None,
|
|
920
|
+
) -> dict[str, Any]:
|
|
921
|
+
"""Process an answer for a discovery session.
|
|
922
|
+
|
|
923
|
+
This is a convenience function for routes to delegate to.
|
|
924
|
+
Loads the session, submits the answer, and returns the result.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
workspace: Target workspace
|
|
928
|
+
session_id: Session ID
|
|
929
|
+
answer: Answer text
|
|
930
|
+
api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
|
|
931
|
+
|
|
932
|
+
Returns:
|
|
933
|
+
Dict with keys:
|
|
934
|
+
- accepted: bool - whether answer was accepted
|
|
935
|
+
- feedback: str - feedback message
|
|
936
|
+
- follow_up: Optional[str] - follow-up question if answer inadequate
|
|
937
|
+
- coverage: Optional[dict] - coverage scores after accepted answer
|
|
938
|
+
- is_complete: bool - whether discovery is now complete
|
|
939
|
+
- next_question: Optional[dict] - next question if not complete
|
|
940
|
+
|
|
941
|
+
Raises:
|
|
942
|
+
ValueError: If session not found
|
|
943
|
+
ValidationError: If answer is empty
|
|
944
|
+
NoApiKeyError: If no API key available
|
|
945
|
+
|
|
946
|
+
Example:
|
|
947
|
+
result = process_discovery_answer(workspace, session_id, "My answer")
|
|
948
|
+
if result["is_complete"]:
|
|
949
|
+
prd = generate_prd_from_discovery(workspace, session_id)
|
|
950
|
+
"""
|
|
951
|
+
session = get_session(workspace, session_id, api_key=api_key)
|
|
952
|
+
result = session.submit_answer(answer)
|
|
953
|
+
|
|
954
|
+
# Add convenience fields
|
|
955
|
+
result["is_complete"] = session.is_complete()
|
|
956
|
+
if not session.is_complete():
|
|
957
|
+
result["next_question"] = session.get_current_question()
|
|
958
|
+
else:
|
|
959
|
+
result["next_question"] = None
|
|
960
|
+
|
|
961
|
+
return result
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def generate_prd_from_discovery(
|
|
965
|
+
workspace: Workspace,
|
|
966
|
+
session_id: str,
|
|
967
|
+
template_id: Optional[str] = None,
|
|
968
|
+
api_key: Optional[str] = None,
|
|
969
|
+
) -> "prd.PrdRecord":
|
|
970
|
+
"""Generate a PRD from a completed discovery session.
|
|
971
|
+
|
|
972
|
+
This is a convenience function for routes to delegate to.
|
|
973
|
+
Loads the session, validates it's complete, and generates the PRD.
|
|
974
|
+
|
|
975
|
+
Args:
|
|
976
|
+
workspace: Target workspace
|
|
977
|
+
session_id: Session ID (must be complete)
|
|
978
|
+
template_id: Optional PRD template to use
|
|
979
|
+
api_key: Optional API key (defaults to ANTHROPIC_API_KEY env var)
|
|
980
|
+
|
|
981
|
+
Returns:
|
|
982
|
+
Created PrdRecord
|
|
983
|
+
|
|
984
|
+
Raises:
|
|
985
|
+
ValueError: If session not found
|
|
986
|
+
IncompleteSessionError: If session not complete
|
|
987
|
+
NoApiKeyError: If no API key available
|
|
988
|
+
|
|
989
|
+
Example:
|
|
990
|
+
prd = generate_prd_from_discovery(workspace, session_id)
|
|
991
|
+
print(f"Created PRD: {prd.title}")
|
|
992
|
+
"""
|
|
993
|
+
session = get_session(workspace, session_id, api_key=api_key)
|
|
994
|
+
return session.generate_prd(template_id=template_id)
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def get_discovery_status(
|
|
998
|
+
workspace: Workspace,
|
|
999
|
+
session_id: Optional[str] = None,
|
|
1000
|
+
api_key: Optional[str] = None,
|
|
1001
|
+
) -> dict[str, Any]:
|
|
1002
|
+
"""Get discovery status for a workspace.
|
|
1003
|
+
|
|
1004
|
+
If session_id is provided, returns status for that specific session.
|
|
1005
|
+
Otherwise, returns status for the most recent active session, or
|
|
1006
|
+
an idle status if no active session exists.
|
|
1007
|
+
|
|
1008
|
+
Args:
|
|
1009
|
+
workspace: Target workspace
|
|
1010
|
+
session_id: Optional specific session ID
|
|
1011
|
+
api_key: Optional API key (only needed if session exists)
|
|
1012
|
+
|
|
1013
|
+
Returns:
|
|
1014
|
+
Dict with keys:
|
|
1015
|
+
- state: str - 'idle', 'discovering', 'paused', or 'completed'
|
|
1016
|
+
- session_id: Optional[str] - session ID if active
|
|
1017
|
+
- progress: dict - progress statistics (if session exists)
|
|
1018
|
+
- current_question: Optional[dict] - current question (if discovering)
|
|
1019
|
+
|
|
1020
|
+
Example:
|
|
1021
|
+
status = get_discovery_status(workspace)
|
|
1022
|
+
if status["state"] == "idle":
|
|
1023
|
+
session = start_discovery_session(workspace)
|
|
1024
|
+
"""
|
|
1025
|
+
if session_id:
|
|
1026
|
+
try:
|
|
1027
|
+
session = get_session(workspace, session_id, api_key=api_key)
|
|
1028
|
+
except (ValueError, NoApiKeyError):
|
|
1029
|
+
return {
|
|
1030
|
+
"state": "idle",
|
|
1031
|
+
"session_id": None,
|
|
1032
|
+
"progress": {},
|
|
1033
|
+
"current_question": None,
|
|
1034
|
+
}
|
|
1035
|
+
else:
|
|
1036
|
+
try:
|
|
1037
|
+
session = get_active_session(workspace)
|
|
1038
|
+
except NoApiKeyError:
|
|
1039
|
+
# Session exists but can't load without API key
|
|
1040
|
+
return {
|
|
1041
|
+
"state": "unknown",
|
|
1042
|
+
"session_id": None,
|
|
1043
|
+
"progress": {},
|
|
1044
|
+
"current_question": None,
|
|
1045
|
+
"error": "ANTHROPIC_API_KEY required to load session",
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
if session is None:
|
|
1049
|
+
return {
|
|
1050
|
+
"state": "idle",
|
|
1051
|
+
"session_id": None,
|
|
1052
|
+
"progress": {},
|
|
1053
|
+
"current_question": None,
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
return {
|
|
1057
|
+
"state": session.state.value,
|
|
1058
|
+
"session_id": session.session_id,
|
|
1059
|
+
"progress": session.get_progress(),
|
|
1060
|
+
"current_question": session.get_current_question(),
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def reset_discovery(
|
|
1065
|
+
workspace: Workspace,
|
|
1066
|
+
session_id: Optional[str] = None,
|
|
1067
|
+
) -> bool:
|
|
1068
|
+
"""Reset discovery for a workspace.
|
|
1069
|
+
|
|
1070
|
+
If session_id is provided, resets that specific session.
|
|
1071
|
+
Otherwise, resets the most recent active session.
|
|
1072
|
+
|
|
1073
|
+
This does NOT delete PRDs or tasks - use separate functions for that.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
workspace: Target workspace
|
|
1077
|
+
session_id: Optional specific session ID to reset
|
|
1078
|
+
|
|
1079
|
+
Returns:
|
|
1080
|
+
True if a session was reset, False if no session found
|
|
1081
|
+
|
|
1082
|
+
Note:
|
|
1083
|
+
This marks the session as completed/abandoned. To start fresh,
|
|
1084
|
+
call start_discovery_session() after reset.
|
|
1085
|
+
"""
|
|
1086
|
+
_ensure_discovery_schema(workspace)
|
|
1087
|
+
|
|
1088
|
+
conn = get_db_connection(workspace)
|
|
1089
|
+
cursor = conn.cursor()
|
|
1090
|
+
|
|
1091
|
+
if session_id:
|
|
1092
|
+
cursor.execute(
|
|
1093
|
+
"""
|
|
1094
|
+
UPDATE discovery_sessions
|
|
1095
|
+
SET state = 'completed', updated_at = ?
|
|
1096
|
+
WHERE id = ? AND workspace_id = ?
|
|
1097
|
+
""",
|
|
1098
|
+
(_utc_now().isoformat(), session_id, workspace.id),
|
|
1099
|
+
)
|
|
1100
|
+
else:
|
|
1101
|
+
# Reset most recent non-completed session
|
|
1102
|
+
cursor.execute(
|
|
1103
|
+
"""
|
|
1104
|
+
UPDATE discovery_sessions
|
|
1105
|
+
SET state = 'completed', updated_at = ?
|
|
1106
|
+
WHERE workspace_id = ? AND state != 'completed'
|
|
1107
|
+
""",
|
|
1108
|
+
(_utc_now().isoformat(), workspace.id),
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
updated = cursor.rowcount > 0
|
|
1112
|
+
conn.commit()
|
|
1113
|
+
conn.close()
|
|
1114
|
+
|
|
1115
|
+
if updated:
|
|
1116
|
+
logger.info(f"Reset discovery session for workspace {workspace.id}")
|
|
1117
|
+
|
|
1118
|
+
return updated
|