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.
Files changed (61) hide show
  1. intake/__init__.py +6 -0
  2. intake/__main__.py +7 -0
  3. intake/analyze/__init__.py +8 -0
  4. intake/analyze/analyzer.py +224 -0
  5. intake/analyze/conflicts.py +63 -0
  6. intake/analyze/dedup.py +115 -0
  7. intake/analyze/design.py +136 -0
  8. intake/analyze/extraction.py +113 -0
  9. intake/analyze/models.py +152 -0
  10. intake/analyze/prompts.py +208 -0
  11. intake/analyze/questions.py +59 -0
  12. intake/analyze/risks.py +70 -0
  13. intake/cli.py +670 -0
  14. intake/config/__init__.py +8 -0
  15. intake/config/defaults.py +21 -0
  16. intake/config/loader.py +143 -0
  17. intake/config/presets.py +99 -0
  18. intake/config/schema.py +84 -0
  19. intake/diff/__init__.py +12 -0
  20. intake/diff/differ.py +314 -0
  21. intake/doctor/__init__.py +7 -0
  22. intake/doctor/checks.py +355 -0
  23. intake/export/__init__.py +17 -0
  24. intake/export/architect.py +212 -0
  25. intake/export/base.py +37 -0
  26. intake/export/generic.py +183 -0
  27. intake/export/registry.py +70 -0
  28. intake/generate/__init__.py +8 -0
  29. intake/generate/lock.py +154 -0
  30. intake/generate/spec_builder.py +193 -0
  31. intake/ingest/__init__.py +24 -0
  32. intake/ingest/base.py +233 -0
  33. intake/ingest/confluence.py +216 -0
  34. intake/ingest/docx.py +192 -0
  35. intake/ingest/image.py +125 -0
  36. intake/ingest/jira.py +231 -0
  37. intake/ingest/markdown.py +114 -0
  38. intake/ingest/pdf.py +165 -0
  39. intake/ingest/plaintext.py +95 -0
  40. intake/ingest/registry.py +207 -0
  41. intake/ingest/yaml_input.py +142 -0
  42. intake/llm/__init__.py +7 -0
  43. intake/llm/adapter.py +250 -0
  44. intake/templates/acceptance.yaml.j2 +35 -0
  45. intake/templates/context.md.j2 +43 -0
  46. intake/templates/design.md.j2 +51 -0
  47. intake/templates/requirements.md.j2 +68 -0
  48. intake/templates/sources.md.j2 +29 -0
  49. intake/templates/tasks.md.j2 +37 -0
  50. intake/utils/__init__.py +3 -0
  51. intake/utils/cost.py +109 -0
  52. intake/utils/file_detect.py +56 -0
  53. intake/utils/logging.py +34 -0
  54. intake/utils/project_detect.py +104 -0
  55. intake/verify/__init__.py +24 -0
  56. intake/verify/engine.py +363 -0
  57. intake/verify/reporter.py +206 -0
  58. intake_ai_cli-0.1.0.dist-info/METADATA +365 -0
  59. intake_ai_cli-0.1.0.dist-info/RECORD +61 -0
  60. intake_ai_cli-0.1.0.dist-info/WHEEL +4 -0
  61. intake_ai_cli-0.1.0.dist-info/entry_points.txt +2 -0
intake/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """intake — From requirements in any format to verified implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __app_name__ = "intake"
intake/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ """Allow running intake as a module: python -m intake."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from intake.cli import main
6
+
7
+ main()
@@ -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())
@@ -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)
@@ -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()}