intake-ai-cli 0.1.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.
- intake/__init__.py +6 -0
- intake/__main__.py +7 -0
- intake/analyze/__init__.py +8 -0
- intake/analyze/analyzer.py +224 -0
- intake/analyze/conflicts.py +63 -0
- intake/analyze/dedup.py +115 -0
- intake/analyze/design.py +136 -0
- intake/analyze/extraction.py +113 -0
- intake/analyze/models.py +152 -0
- intake/analyze/prompts.py +208 -0
- intake/analyze/questions.py +59 -0
- intake/analyze/risks.py +70 -0
- intake/cli.py +670 -0
- intake/config/__init__.py +8 -0
- intake/config/defaults.py +21 -0
- intake/config/loader.py +143 -0
- intake/config/presets.py +99 -0
- intake/config/schema.py +84 -0
- intake/diff/__init__.py +12 -0
- intake/diff/differ.py +314 -0
- intake/doctor/__init__.py +7 -0
- intake/doctor/checks.py +355 -0
- intake/export/__init__.py +17 -0
- intake/export/architect.py +212 -0
- intake/export/base.py +37 -0
- intake/export/generic.py +183 -0
- intake/export/registry.py +70 -0
- intake/generate/__init__.py +8 -0
- intake/generate/lock.py +154 -0
- intake/generate/spec_builder.py +193 -0
- intake/ingest/__init__.py +24 -0
- intake/ingest/base.py +233 -0
- intake/ingest/confluence.py +216 -0
- intake/ingest/docx.py +192 -0
- intake/ingest/image.py +125 -0
- intake/ingest/jira.py +231 -0
- intake/ingest/markdown.py +114 -0
- intake/ingest/pdf.py +165 -0
- intake/ingest/plaintext.py +95 -0
- intake/ingest/registry.py +207 -0
- intake/ingest/yaml_input.py +142 -0
- intake/llm/__init__.py +7 -0
- intake/llm/adapter.py +250 -0
- intake/templates/acceptance.yaml.j2 +35 -0
- intake/templates/context.md.j2 +43 -0
- intake/templates/design.md.j2 +51 -0
- intake/templates/requirements.md.j2 +68 -0
- intake/templates/sources.md.j2 +29 -0
- intake/templates/tasks.md.j2 +37 -0
- intake/utils/__init__.py +3 -0
- intake/utils/cost.py +109 -0
- intake/utils/file_detect.py +56 -0
- intake/utils/logging.py +34 -0
- intake/utils/project_detect.py +104 -0
- intake/verify/__init__.py +24 -0
- intake/verify/engine.py +363 -0
- intake/verify/reporter.py +206 -0
- intake_ai_cli-0.1.0.dist-info/METADATA +365 -0
- intake_ai_cli-0.1.0.dist-info/RECORD +61 -0
- intake_ai_cli-0.1.0.dist-info/WHEEL +4 -0
- intake_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
intake/__init__.py
ADDED
intake/__main__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Phase 2 — LLM-powered analysis of parsed content."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from intake.analyze.analyzer import AnalyzeError, Analyzer
|
|
6
|
+
from intake.analyze.models import AnalysisResult, DesignResult
|
|
7
|
+
|
|
8
|
+
__all__ = ["AnalysisResult", "AnalyzeError", "Analyzer", "DesignResult"]
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Analyzer orchestrator — coordinates the LLM analysis pipeline.
|
|
2
|
+
|
|
3
|
+
Flow:
|
|
4
|
+
1. Combine text from all parsed sources
|
|
5
|
+
2. Phase A — Extraction: LLM extracts structured requirements
|
|
6
|
+
3. Deduplication: detect and remove repeated requirements across sources
|
|
7
|
+
4. Validation: filter incomplete conflicts and questions
|
|
8
|
+
5. Risk assessment: evaluate risk per requirement (optional)
|
|
9
|
+
6. Design: architecture, tasks, and acceptance checks
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
import structlog
|
|
18
|
+
|
|
19
|
+
from intake.analyze.conflicts import validate_conflicts
|
|
20
|
+
from intake.analyze.dedup import deduplicate
|
|
21
|
+
from intake.analyze.design import parse_design
|
|
22
|
+
from intake.analyze.extraction import parse_extraction
|
|
23
|
+
from intake.analyze.models import DesignResult
|
|
24
|
+
from intake.analyze.prompts import DESIGN_PROMPT, EXTRACTION_PROMPT, RISK_ASSESSMENT_PROMPT
|
|
25
|
+
from intake.analyze.questions import validate_questions
|
|
26
|
+
from intake.analyze.risks import parse_risks
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from intake.analyze.models import AnalysisResult, RiskItem
|
|
30
|
+
from intake.config.schema import IntakeConfig
|
|
31
|
+
from intake.ingest.base import ParsedContent
|
|
32
|
+
from intake.llm import LLMAdapter
|
|
33
|
+
|
|
34
|
+
logger = structlog.get_logger()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AnalyzeError(Exception):
|
|
38
|
+
"""Base exception for analysis errors."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, reason: str, suggestion: str = "") -> None:
|
|
41
|
+
self.reason = reason
|
|
42
|
+
self.suggestion = suggestion
|
|
43
|
+
msg = f"Analysis failed: {reason}"
|
|
44
|
+
if suggestion:
|
|
45
|
+
msg += f"\n Hint: {suggestion}"
|
|
46
|
+
super().__init__(msg)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Analyzer:
|
|
50
|
+
"""Orchestrates content analysis with LLM.
|
|
51
|
+
|
|
52
|
+
Coordinates extraction, deduplication, conflict validation,
|
|
53
|
+
risk assessment, and design phases into a single AnalysisResult.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
config: Full intake configuration.
|
|
57
|
+
llm: LLM adapter for making completion calls.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, config: IntakeConfig, llm: LLMAdapter) -> None:
|
|
61
|
+
self.config = config
|
|
62
|
+
self.llm = llm
|
|
63
|
+
|
|
64
|
+
async def analyze(self, sources: list[ParsedContent]) -> AnalysisResult:
|
|
65
|
+
"""Analyze multiple parsed sources and produce a structured result.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
sources: List of parsed content from the ingest phase.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
Complete AnalysisResult with requirements, design, and metadata.
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
AnalyzeError: If analysis fails.
|
|
75
|
+
"""
|
|
76
|
+
if not sources:
|
|
77
|
+
raise AnalyzeError(
|
|
78
|
+
reason="No sources provided for analysis.",
|
|
79
|
+
suggestion="Pass at least one source file with -s.",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
combined_text = self._combine_sources(sources)
|
|
83
|
+
total_words = sum(s.word_count for s in sources)
|
|
84
|
+
|
|
85
|
+
logger.info(
|
|
86
|
+
"analysis_starting",
|
|
87
|
+
sources=len(sources),
|
|
88
|
+
total_words=total_words,
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
# Phase A: Requirement extraction
|
|
92
|
+
extraction_raw = await self.llm.completion(
|
|
93
|
+
system_prompt=EXTRACTION_PROMPT.format(
|
|
94
|
+
n_sources=len(sources),
|
|
95
|
+
language=self.config.project.language,
|
|
96
|
+
requirements_format=self.config.spec.requirements_format,
|
|
97
|
+
),
|
|
98
|
+
user_prompt=combined_text,
|
|
99
|
+
response_format="json",
|
|
100
|
+
phase="extraction",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if not isinstance(extraction_raw, dict):
|
|
104
|
+
msg = "LLM extraction did not return JSON"
|
|
105
|
+
raise AnalyzeError(msg, suggestion="Check your LLM model configuration.")
|
|
106
|
+
result = parse_extraction(extraction_raw)
|
|
107
|
+
|
|
108
|
+
# Deduplication
|
|
109
|
+
result.duplicates_removed = deduplicate(result)
|
|
110
|
+
|
|
111
|
+
# Validate conflicts and questions
|
|
112
|
+
result.conflicts = validate_conflicts(result.conflicts)
|
|
113
|
+
result.open_questions = validate_questions(result.open_questions)
|
|
114
|
+
|
|
115
|
+
# Risk assessment (conditional)
|
|
116
|
+
if self.config.spec.risk_assessment:
|
|
117
|
+
result.risks = await self._assess_risks(result)
|
|
118
|
+
|
|
119
|
+
# Design phase
|
|
120
|
+
result.design = await self._design(result)
|
|
121
|
+
|
|
122
|
+
# Metadata
|
|
123
|
+
result.total_cost = self.llm.total_cost
|
|
124
|
+
result.model_used = self.config.llm.model
|
|
125
|
+
|
|
126
|
+
logger.info(
|
|
127
|
+
"analysis_complete",
|
|
128
|
+
functional=len(result.functional_requirements),
|
|
129
|
+
non_functional=len(result.non_functional_requirements),
|
|
130
|
+
conflicts=len(result.conflicts),
|
|
131
|
+
questions=len(result.open_questions),
|
|
132
|
+
risks=len(result.risks),
|
|
133
|
+
tasks=len(result.design.tasks),
|
|
134
|
+
duplicates_removed=result.duplicates_removed,
|
|
135
|
+
cost=f"${result.total_cost:.4f}",
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
|
|
140
|
+
def _combine_sources(self, sources: list[ParsedContent]) -> str:
|
|
141
|
+
"""Combine multiple sources into a single text for the LLM.
|
|
142
|
+
|
|
143
|
+
Each source is prefixed with a header identifying its index,
|
|
144
|
+
file path, and format.
|
|
145
|
+
"""
|
|
146
|
+
parts: list[str] = []
|
|
147
|
+
for i, source in enumerate(sources, 1):
|
|
148
|
+
header = (
|
|
149
|
+
f"=== SOURCE {i}: {source.source} "
|
|
150
|
+
f"(format: {source.format}) ==="
|
|
151
|
+
)
|
|
152
|
+
parts.append(f"{header}\n\n{source.text}")
|
|
153
|
+
return "\n\n---\n\n".join(parts)
|
|
154
|
+
|
|
155
|
+
async def _assess_risks(self, result: AnalysisResult) -> list[RiskItem]:
|
|
156
|
+
"""Ask LLM to assess risks based on extracted requirements."""
|
|
157
|
+
requirements_data = [
|
|
158
|
+
{
|
|
159
|
+
"id": r.id,
|
|
160
|
+
"title": r.title,
|
|
161
|
+
"type": r.type,
|
|
162
|
+
"priority": r.priority,
|
|
163
|
+
}
|
|
164
|
+
for r in result.all_requirements
|
|
165
|
+
]
|
|
166
|
+
|
|
167
|
+
conflicts_data = [
|
|
168
|
+
{
|
|
169
|
+
"id": c.id,
|
|
170
|
+
"description": c.description,
|
|
171
|
+
"severity": c.severity,
|
|
172
|
+
}
|
|
173
|
+
for c in result.conflicts
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
risk_raw = await self.llm.completion(
|
|
177
|
+
system_prompt=RISK_ASSESSMENT_PROMPT.format(
|
|
178
|
+
language=self.config.project.language,
|
|
179
|
+
requirements_json=json.dumps(requirements_data, indent=2),
|
|
180
|
+
conflicts_json=json.dumps(conflicts_data, indent=2),
|
|
181
|
+
),
|
|
182
|
+
user_prompt="Assess risks for the requirements above.",
|
|
183
|
+
response_format="json",
|
|
184
|
+
phase="risk_assessment",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not isinstance(risk_raw, dict):
|
|
188
|
+
return []
|
|
189
|
+
return parse_risks(risk_raw)
|
|
190
|
+
|
|
191
|
+
async def _design(self, result: AnalysisResult) -> DesignResult:
|
|
192
|
+
"""Ask LLM to produce technical design, tasks, and checks."""
|
|
193
|
+
requirements_data = [
|
|
194
|
+
{
|
|
195
|
+
"id": r.id,
|
|
196
|
+
"title": r.title,
|
|
197
|
+
"description": r.description,
|
|
198
|
+
"type": r.type,
|
|
199
|
+
"priority": r.priority,
|
|
200
|
+
"acceptance_criteria": r.acceptance_criteria,
|
|
201
|
+
}
|
|
202
|
+
for r in result.all_requirements
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
project_stack = self.config.project.stack
|
|
206
|
+
stack = ", ".join(project_stack) if project_stack else "not specified"
|
|
207
|
+
|
|
208
|
+
design_raw = await self.llm.completion(
|
|
209
|
+
system_prompt=DESIGN_PROMPT.format(
|
|
210
|
+
language=self.config.project.language,
|
|
211
|
+
design_depth=self.config.spec.design_depth,
|
|
212
|
+
task_granularity=self.config.spec.task_granularity,
|
|
213
|
+
stack=stack,
|
|
214
|
+
file_tree="(not available)",
|
|
215
|
+
requirements_json=json.dumps(requirements_data, indent=2),
|
|
216
|
+
),
|
|
217
|
+
user_prompt="Produce the technical design for these requirements.",
|
|
218
|
+
response_format="json",
|
|
219
|
+
phase="design",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
if not isinstance(design_raw, dict):
|
|
223
|
+
return DesignResult()
|
|
224
|
+
return parse_design(design_raw)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Conflict validation and enrichment.
|
|
2
|
+
|
|
3
|
+
Validates conflicts extracted by the LLM and filters out
|
|
4
|
+
low-quality or incomplete entries.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from intake.analyze.models import Conflict
|
|
15
|
+
|
|
16
|
+
logger = structlog.get_logger()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_conflicts(conflicts: list[Conflict]) -> list[Conflict]:
|
|
20
|
+
"""Validate and filter extracted conflicts.
|
|
21
|
+
|
|
22
|
+
Removes conflicts that lack essential fields (description,
|
|
23
|
+
source references, or recommendation).
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
conflicts: Raw conflicts from the extraction phase.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
Filtered list of valid conflicts.
|
|
30
|
+
"""
|
|
31
|
+
valid: list[Conflict] = []
|
|
32
|
+
|
|
33
|
+
for conflict in conflicts:
|
|
34
|
+
if _is_valid(conflict):
|
|
35
|
+
valid.append(conflict)
|
|
36
|
+
else:
|
|
37
|
+
logger.debug(
|
|
38
|
+
"conflict_filtered",
|
|
39
|
+
id=conflict.id,
|
|
40
|
+
reason="missing required fields",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
filtered = len(conflicts) - len(valid)
|
|
44
|
+
if filtered > 0:
|
|
45
|
+
logger.info(
|
|
46
|
+
"conflicts_validated",
|
|
47
|
+
total=len(conflicts),
|
|
48
|
+
valid=len(valid),
|
|
49
|
+
filtered=filtered,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
return valid
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _is_valid(conflict: Conflict) -> bool:
|
|
56
|
+
"""Check that a conflict has all required fields populated."""
|
|
57
|
+
if not conflict.description.strip():
|
|
58
|
+
return False
|
|
59
|
+
if not conflict.source_a:
|
|
60
|
+
return False
|
|
61
|
+
if not conflict.source_b:
|
|
62
|
+
return False
|
|
63
|
+
return bool(conflict.recommendation.strip())
|
intake/analyze/dedup.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Deduplication of requirements across multiple sources.
|
|
2
|
+
|
|
3
|
+
Uses title similarity (normalized lowercase comparison) to detect
|
|
4
|
+
duplicates within the same type (functional or non-functional).
|
|
5
|
+
Keeps the first occurrence and removes subsequent duplicates.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import structlog
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from intake.analyze.models import AnalysisResult, Requirement
|
|
17
|
+
|
|
18
|
+
logger = structlog.get_logger()
|
|
19
|
+
|
|
20
|
+
# Minimum similarity ratio (0.0-1.0) to consider two requirements as duplicates.
|
|
21
|
+
# Set at 0.75 for word-level Jaccard similarity (where small differences in
|
|
22
|
+
# token count have a large impact on the ratio).
|
|
23
|
+
SIMILARITY_THRESHOLD = 0.75
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def deduplicate(result: AnalysisResult) -> int:
|
|
27
|
+
"""Remove duplicate requirements from the analysis result.
|
|
28
|
+
|
|
29
|
+
Modifies the result in place by removing duplicates from both
|
|
30
|
+
functional and non-functional requirement lists.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
result: The analysis result to deduplicate.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Number of duplicates removed.
|
|
37
|
+
"""
|
|
38
|
+
fr_before = len(result.functional_requirements)
|
|
39
|
+
nfr_before = len(result.non_functional_requirements)
|
|
40
|
+
|
|
41
|
+
result.functional_requirements = _deduplicate_list(
|
|
42
|
+
result.functional_requirements
|
|
43
|
+
)
|
|
44
|
+
result.non_functional_requirements = _deduplicate_list(
|
|
45
|
+
result.non_functional_requirements
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
removed = (fr_before - len(result.functional_requirements)) + (
|
|
49
|
+
nfr_before - len(result.non_functional_requirements)
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
if removed > 0:
|
|
53
|
+
logger.info("dedup_complete", duplicates_removed=removed)
|
|
54
|
+
|
|
55
|
+
return removed
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _deduplicate_list(requirements: list[Requirement]) -> list[Requirement]:
|
|
59
|
+
"""Remove duplicates from a list of requirements.
|
|
60
|
+
|
|
61
|
+
Keeps the first occurrence of each unique requirement.
|
|
62
|
+
Two requirements are considered duplicates if their normalized
|
|
63
|
+
titles are similar above the threshold.
|
|
64
|
+
"""
|
|
65
|
+
if not requirements:
|
|
66
|
+
return requirements
|
|
67
|
+
|
|
68
|
+
unique: list[Requirement] = []
|
|
69
|
+
seen_titles: list[str] = []
|
|
70
|
+
|
|
71
|
+
for req in requirements:
|
|
72
|
+
normalized = _normalize(req.title)
|
|
73
|
+
if not _is_duplicate(normalized, seen_titles):
|
|
74
|
+
unique.append(req)
|
|
75
|
+
seen_titles.append(normalized)
|
|
76
|
+
|
|
77
|
+
return unique
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _is_duplicate(title: str, seen: list[str]) -> bool:
|
|
81
|
+
"""Check if a normalized title is similar to any previously seen title."""
|
|
82
|
+
return any(_similarity(title, seen_title) >= SIMILARITY_THRESHOLD for seen_title in seen)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _normalize(text: str) -> str:
|
|
86
|
+
"""Normalize text for comparison: lowercase, strip, collapse whitespace."""
|
|
87
|
+
text = text.lower().strip()
|
|
88
|
+
text = re.sub(r"\s+", " ", text)
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _similarity(a: str, b: str) -> float:
|
|
93
|
+
"""Compute similarity ratio between two strings.
|
|
94
|
+
|
|
95
|
+
Uses token overlap (Jaccard similarity on words) for a lightweight,
|
|
96
|
+
dependency-free comparison.
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
Float between 0.0 and 1.0 where 1.0 is identical.
|
|
100
|
+
"""
|
|
101
|
+
if a == b:
|
|
102
|
+
return 1.0
|
|
103
|
+
|
|
104
|
+
tokens_a = set(a.split())
|
|
105
|
+
tokens_b = set(b.split())
|
|
106
|
+
|
|
107
|
+
if not tokens_a and not tokens_b:
|
|
108
|
+
return 1.0
|
|
109
|
+
if not tokens_a or not tokens_b:
|
|
110
|
+
return 0.0
|
|
111
|
+
|
|
112
|
+
intersection = tokens_a & tokens_b
|
|
113
|
+
union = tokens_a | tokens_b
|
|
114
|
+
|
|
115
|
+
return len(intersection) / len(union)
|
intake/analyze/design.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Design phase — parses LLM design output into typed models.
|
|
2
|
+
|
|
3
|
+
Converts the DESIGN_PROMPT JSON response into DesignResult with
|
|
4
|
+
components, file actions, tech decisions, tasks, and acceptance checks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import structlog
|
|
10
|
+
|
|
11
|
+
from intake.analyze.models import (
|
|
12
|
+
AcceptanceCheck,
|
|
13
|
+
DesignResult,
|
|
14
|
+
FileAction,
|
|
15
|
+
TaskItem,
|
|
16
|
+
TechDecision,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
logger = structlog.get_logger()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_design(raw: dict[str, object]) -> DesignResult:
|
|
23
|
+
"""Parse the LLM design response into a typed DesignResult.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
raw: Parsed JSON dict from the LLM design call.
|
|
27
|
+
|
|
28
|
+
Returns:
|
|
29
|
+
DesignResult populated with components, files, tasks, and checks.
|
|
30
|
+
"""
|
|
31
|
+
result = DesignResult()
|
|
32
|
+
|
|
33
|
+
# Components
|
|
34
|
+
components = raw.get("components", [])
|
|
35
|
+
if isinstance(components, list):
|
|
36
|
+
result.components = [str(c) for c in components]
|
|
37
|
+
|
|
38
|
+
# Files to create
|
|
39
|
+
for item in _get_list(raw, "files_to_create"):
|
|
40
|
+
result.files_to_create.append(
|
|
41
|
+
FileAction(
|
|
42
|
+
path=str(item.get("path", "")),
|
|
43
|
+
description=str(item.get("description", "")),
|
|
44
|
+
action="create",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Files to modify
|
|
49
|
+
for item in _get_list(raw, "files_to_modify"):
|
|
50
|
+
result.files_to_modify.append(
|
|
51
|
+
FileAction(
|
|
52
|
+
path=str(item.get("path", "")),
|
|
53
|
+
description=str(item.get("description", item.get("changes", ""))),
|
|
54
|
+
action="modify",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Tech decisions
|
|
59
|
+
for item in _get_list(raw, "tech_decisions"):
|
|
60
|
+
result.tech_decisions.append(
|
|
61
|
+
TechDecision(
|
|
62
|
+
decision=str(item.get("decision", "")),
|
|
63
|
+
justification=str(item.get("justification", "")),
|
|
64
|
+
requirement=str(item.get("requirement", "")),
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# Tasks
|
|
69
|
+
for item in _get_list(raw, "tasks"):
|
|
70
|
+
result.tasks.append(_parse_task(item))
|
|
71
|
+
|
|
72
|
+
# Acceptance checks
|
|
73
|
+
for item in _get_list(raw, "acceptance_checks"):
|
|
74
|
+
result.acceptance_checks.append(_parse_check(item))
|
|
75
|
+
|
|
76
|
+
# Dependencies
|
|
77
|
+
deps = raw.get("dependencies", [])
|
|
78
|
+
if isinstance(deps, list):
|
|
79
|
+
result.dependencies = [str(d) for d in deps]
|
|
80
|
+
|
|
81
|
+
logger.info(
|
|
82
|
+
"design_parsed",
|
|
83
|
+
components=len(result.components),
|
|
84
|
+
files_create=len(result.files_to_create),
|
|
85
|
+
files_modify=len(result.files_to_modify),
|
|
86
|
+
tasks=len(result.tasks),
|
|
87
|
+
checks=len(result.acceptance_checks),
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return result
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_task(item: dict[str, object]) -> TaskItem:
|
|
94
|
+
"""Parse a single task from the LLM output."""
|
|
95
|
+
files = item.get("files", [])
|
|
96
|
+
deps = item.get("dependencies", [])
|
|
97
|
+
checks = item.get("checks", [])
|
|
98
|
+
|
|
99
|
+
raw_id = item.get("id", 0)
|
|
100
|
+
raw_minutes = item.get("estimated_minutes", 15)
|
|
101
|
+
return TaskItem(
|
|
102
|
+
id=int(raw_id) if isinstance(raw_id, (int, float, str)) else 0,
|
|
103
|
+
title=str(item.get("title", "")),
|
|
104
|
+
description=str(item.get("description", "")),
|
|
105
|
+
files=[str(f) for f in files] if isinstance(files, list) else [],
|
|
106
|
+
dependencies=[int(d) for d in deps] if isinstance(deps, list) else [],
|
|
107
|
+
checks=[str(c) for c in checks] if isinstance(checks, list) else [],
|
|
108
|
+
estimated_minutes=int(raw_minutes) if isinstance(raw_minutes, (int, float, str)) else 15,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_check(item: dict[str, object]) -> AcceptanceCheck:
|
|
113
|
+
"""Parse a single acceptance check from the LLM output."""
|
|
114
|
+
tags = item.get("tags", [])
|
|
115
|
+
paths = item.get("paths", [])
|
|
116
|
+
patterns = item.get("patterns", [])
|
|
117
|
+
|
|
118
|
+
return AcceptanceCheck(
|
|
119
|
+
id=str(item.get("id", "")),
|
|
120
|
+
name=str(item.get("name", "")),
|
|
121
|
+
type=str(item.get("type", "command")),
|
|
122
|
+
required=bool(item.get("required", True)),
|
|
123
|
+
tags=[str(t) for t in tags] if isinstance(tags, list) else [],
|
|
124
|
+
command=str(item.get("command", "")),
|
|
125
|
+
paths=[str(p) for p in paths] if isinstance(paths, list) else [],
|
|
126
|
+
glob=str(item.get("glob", "")),
|
|
127
|
+
patterns=[str(p) for p in patterns] if isinstance(patterns, list) else [],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _get_list(data: dict[str, object], key: str) -> list[dict[str, object]]:
|
|
132
|
+
"""Safely extract a list from a dict, returning empty list on failure."""
|
|
133
|
+
value = data.get(key, [])
|
|
134
|
+
if not isinstance(value, list):
|
|
135
|
+
return []
|
|
136
|
+
return [item for item in value if isinstance(item, dict)]
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Phase A — Extract structured requirements from raw LLM output.
|
|
2
|
+
|
|
3
|
+
Parses the JSON response from EXTRACTION_PROMPT into typed dataclasses.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import structlog
|
|
9
|
+
|
|
10
|
+
from intake.analyze.models import (
|
|
11
|
+
AnalysisResult,
|
|
12
|
+
Conflict,
|
|
13
|
+
OpenQuestion,
|
|
14
|
+
Requirement,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
logger = structlog.get_logger()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_extraction(raw: dict[str, object]) -> AnalysisResult:
|
|
21
|
+
"""Parse the LLM JSON response into a typed AnalysisResult.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
raw: Parsed JSON dict from the LLM extraction call.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
AnalysisResult populated with requirements, conflicts, and questions.
|
|
28
|
+
"""
|
|
29
|
+
result = AnalysisResult()
|
|
30
|
+
|
|
31
|
+
for item in _get_list(raw, "functional_requirements"):
|
|
32
|
+
result.functional_requirements.append(_parse_requirement(item, "functional"))
|
|
33
|
+
|
|
34
|
+
for item in _get_list(raw, "non_functional_requirements"):
|
|
35
|
+
result.non_functional_requirements.append(
|
|
36
|
+
_parse_requirement(item, "non_functional")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
for item in _get_list(raw, "conflicts"):
|
|
40
|
+
result.conflicts.append(_parse_conflict(item))
|
|
41
|
+
|
|
42
|
+
for item in _get_list(raw, "open_questions"):
|
|
43
|
+
result.open_questions.append(_parse_open_question(item))
|
|
44
|
+
|
|
45
|
+
logger.info(
|
|
46
|
+
"extraction_parsed",
|
|
47
|
+
functional=len(result.functional_requirements),
|
|
48
|
+
non_functional=len(result.non_functional_requirements),
|
|
49
|
+
conflicts=len(result.conflicts),
|
|
50
|
+
questions=len(result.open_questions),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _parse_requirement(item: dict[str, object], req_type: str) -> Requirement:
|
|
57
|
+
"""Parse a single requirement from the LLM output."""
|
|
58
|
+
return Requirement(
|
|
59
|
+
id=str(item.get("id", "")),
|
|
60
|
+
type=req_type,
|
|
61
|
+
title=str(item.get("title", "")),
|
|
62
|
+
description=str(item.get("description", "")),
|
|
63
|
+
acceptance_criteria=_get_str_list(item, "acceptance_criteria"),
|
|
64
|
+
source=str(item.get("source", "")),
|
|
65
|
+
priority=str(item.get("priority", "medium")),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_conflict(item: dict[str, object]) -> Conflict:
|
|
70
|
+
"""Parse a single conflict from the LLM output."""
|
|
71
|
+
source_a = item.get("source_a", {})
|
|
72
|
+
source_b = item.get("source_b", {})
|
|
73
|
+
|
|
74
|
+
return Conflict(
|
|
75
|
+
id=str(item.get("id", "")),
|
|
76
|
+
description=str(item.get("description", "")),
|
|
77
|
+
source_a=_to_str_dict(source_a) if isinstance(source_a, dict) else {},
|
|
78
|
+
source_b=_to_str_dict(source_b) if isinstance(source_b, dict) else {},
|
|
79
|
+
recommendation=str(item.get("recommendation", "")),
|
|
80
|
+
severity=str(item.get("severity", "medium")),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_open_question(item: dict[str, object]) -> OpenQuestion:
|
|
85
|
+
"""Parse a single open question from the LLM output."""
|
|
86
|
+
return OpenQuestion(
|
|
87
|
+
id=str(item.get("id", "")),
|
|
88
|
+
question=str(item.get("question", "")),
|
|
89
|
+
context=str(item.get("context", "")),
|
|
90
|
+
source=str(item.get("source", "")),
|
|
91
|
+
recommendation=str(item.get("recommendation", "")),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _get_list(data: dict[str, object], key: str) -> list[dict[str, object]]:
|
|
96
|
+
"""Safely extract a list of dicts from a dict, returning empty list on failure."""
|
|
97
|
+
value = data.get(key, [])
|
|
98
|
+
if not isinstance(value, list):
|
|
99
|
+
return []
|
|
100
|
+
return [item for item in value if isinstance(item, dict)]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_str_list(data: dict[str, object], key: str) -> list[str]:
|
|
104
|
+
"""Safely extract a list of strings from a dict."""
|
|
105
|
+
value = data.get(key, [])
|
|
106
|
+
if not isinstance(value, list):
|
|
107
|
+
return []
|
|
108
|
+
return [str(item) for item in value]
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _to_str_dict(data: dict[str, object]) -> dict[str, str]:
|
|
112
|
+
"""Convert a dict with mixed values to a dict with string values."""
|
|
113
|
+
return {str(k): str(v) for k, v in data.items()}
|