algomath-extract 1.0.0

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 (90) hide show
  1. package/README.md +260 -0
  2. package/bin/algo-extract.js +143 -0
  3. package/bin/algo-generate.js +102 -0
  4. package/bin/algo-help.js +136 -0
  5. package/bin/algo-list.js +56 -0
  6. package/bin/algo-run.js +141 -0
  7. package/bin/algo-status.js +88 -0
  8. package/bin/algo-verify.js +189 -0
  9. package/bin/install.js +349 -0
  10. package/package.json +57 -0
  11. package/requirements.txt +20 -0
  12. package/src/__pycache__/intent.cpython-313.pyc +0 -0
  13. package/src/cli/__pycache__/commands.cpython-313.pyc +0 -0
  14. package/src/cli/cli_entry.py +106 -0
  15. package/src/cli/commands.py +339 -0
  16. package/src/execution/__init__.py +74 -0
  17. package/src/execution/__pycache__/__init__.cpython-313.pyc +0 -0
  18. package/src/execution/__pycache__/display.cpython-313.pyc +0 -0
  19. package/src/execution/__pycache__/errors.cpython-313.pyc +0 -0
  20. package/src/execution/__pycache__/executor.cpython-313.pyc +0 -0
  21. package/src/execution/__pycache__/sandbox.cpython-313.pyc +0 -0
  22. package/src/execution/display.py +261 -0
  23. package/src/execution/errors.py +158 -0
  24. package/src/execution/executor.py +253 -0
  25. package/src/execution/sandbox.py +333 -0
  26. package/src/extraction/__init__.py +102 -0
  27. package/src/extraction/__pycache__/__init__.cpython-313.pyc +0 -0
  28. package/src/extraction/__pycache__/boundaries.cpython-313.pyc +0 -0
  29. package/src/extraction/__pycache__/errors.cpython-313.pyc +0 -0
  30. package/src/extraction/__pycache__/llm_extraction.cpython-313.pyc +0 -0
  31. package/src/extraction/__pycache__/notation.cpython-313.pyc +0 -0
  32. package/src/extraction/__pycache__/parser.cpython-313.pyc +0 -0
  33. package/src/extraction/__pycache__/pdf_processor.cpython-313.pyc +0 -0
  34. package/src/extraction/__pycache__/prompts.cpython-313.pyc +0 -0
  35. package/src/extraction/__pycache__/review.cpython-313.pyc +0 -0
  36. package/src/extraction/__pycache__/schema.cpython-313.pyc +0 -0
  37. package/src/extraction/__pycache__/validation.cpython-313.pyc +0 -0
  38. package/src/extraction/boundaries.py +281 -0
  39. package/src/extraction/errors.py +156 -0
  40. package/src/extraction/llm_extraction.py +225 -0
  41. package/src/extraction/notation.py +240 -0
  42. package/src/extraction/parser.py +402 -0
  43. package/src/extraction/pdf_processor.py +281 -0
  44. package/src/extraction/prompts.py +90 -0
  45. package/src/extraction/review.py +298 -0
  46. package/src/extraction/schema.py +173 -0
  47. package/src/extraction/validation.py +202 -0
  48. package/src/generation/__init__.py +79 -0
  49. package/src/generation/__pycache__/__init__.cpython-313.pyc +0 -0
  50. package/src/generation/__pycache__/code_generator.cpython-313.pyc +0 -0
  51. package/src/generation/__pycache__/errors.cpython-313.pyc +0 -0
  52. package/src/generation/__pycache__/hybrid.cpython-313.pyc +0 -0
  53. package/src/generation/__pycache__/llm_generator.cpython-313.pyc +0 -0
  54. package/src/generation/__pycache__/persistence.cpython-313.pyc +0 -0
  55. package/src/generation/__pycache__/prompts.cpython-313.pyc +0 -0
  56. package/src/generation/__pycache__/review.cpython-313.pyc +0 -0
  57. package/src/generation/__pycache__/templates.cpython-313.pyc +0 -0
  58. package/src/generation/__pycache__/types.cpython-313.pyc +0 -0
  59. package/src/generation/__pycache__/validation.cpython-313.pyc +0 -0
  60. package/src/generation/code_generator.py +375 -0
  61. package/src/generation/errors.py +84 -0
  62. package/src/generation/hybrid.py +210 -0
  63. package/src/generation/llm_generator.py +223 -0
  64. package/src/generation/persistence.py +221 -0
  65. package/src/generation/prompts.py +202 -0
  66. package/src/generation/review.py +254 -0
  67. package/src/generation/templates.py +208 -0
  68. package/src/generation/types.py +196 -0
  69. package/src/generation/validation.py +278 -0
  70. package/src/intent.py +323 -0
  71. package/src/verification/__init__.py +63 -0
  72. package/src/verification/__pycache__/__init__.cpython-313.pyc +0 -0
  73. package/src/verification/__pycache__/checker.cpython-313.pyc +0 -0
  74. package/src/verification/__pycache__/comparison.cpython-313.pyc +0 -0
  75. package/src/verification/__pycache__/explainer.cpython-313.pyc +0 -0
  76. package/src/verification/__pycache__/static_analysis.cpython-313.pyc +0 -0
  77. package/src/verification/checker.py +220 -0
  78. package/src/verification/comparison.py +492 -0
  79. package/src/verification/explainer.py +414 -0
  80. package/src/verification/static_analysis.py +540 -0
  81. package/src/workflows/__init__.py +21 -0
  82. package/src/workflows/__pycache__/__init__.cpython-313.pyc +0 -0
  83. package/src/workflows/__pycache__/extract.cpython-313.pyc +0 -0
  84. package/src/workflows/__pycache__/generate.cpython-313.pyc +0 -0
  85. package/src/workflows/__pycache__/run.cpython-313.pyc +0 -0
  86. package/src/workflows/__pycache__/verify.cpython-313.pyc +0 -0
  87. package/src/workflows/extract.py +181 -0
  88. package/src/workflows/generate.py +155 -0
  89. package/src/workflows/run.py +187 -0
  90. package/src/workflows/verify.py +334 -0
@@ -0,0 +1,281 @@
1
+ """PDF and text file processing module.
2
+
3
+ Provides functionality to extract text from PDFs and text files,
4
+ auto-detect file types, and handle encoding issues.
5
+ """
6
+
7
+ import os
8
+ import re
9
+ from pathlib import Path
10
+ from typing import Optional, Tuple
11
+ from dataclasses import dataclass
12
+
13
+
14
+ @dataclass
15
+ class PDFExtractionResult:
16
+ """Result of PDF/text extraction."""
17
+ text: str
18
+ file_type: str # 'pdf', 'text', or 'unknown'
19
+ page_count: int
20
+ encoding: str
21
+ success: bool
22
+ error: Optional[str] = None
23
+
24
+
25
+ class PDFProcessor:
26
+ """Process PDF and text files to extract algorithm text."""
27
+
28
+ def __init__(self):
29
+ self.supported_extensions = {'.pdf', '.txt', '.md', '.markdown'}
30
+ self.text_extensions = {'.txt', '.md', '.markdown'}
31
+
32
+ def extract_text(self, file_path: str) -> PDFExtractionResult:
33
+ """
34
+ Extract text from file (PDF or text).
35
+
36
+ Args:
37
+ file_path: Path to file
38
+
39
+ Returns:
40
+ PDFExtractionResult with extracted text
41
+ """
42
+ path = Path(file_path)
43
+
44
+ # Check if file exists
45
+ if not path.exists():
46
+ return PDFExtractionResult(
47
+ text="",
48
+ file_type="unknown",
49
+ page_count=0,
50
+ encoding="",
51
+ success=False,
52
+ error=f"File not found: {file_path}"
53
+ )
54
+
55
+ # Check extension
56
+ ext = path.suffix.lower()
57
+ if ext not in self.supported_extensions:
58
+ return PDFExtractionResult(
59
+ text="",
60
+ file_type="unknown",
61
+ page_count=0,
62
+ encoding="",
63
+ success=False,
64
+ error=f"Unsupported file type: {ext}. Supported: {self.supported_extensions}"
65
+ )
66
+
67
+ # Route to appropriate handler
68
+ if ext == '.pdf':
69
+ return self._extract_pdf(file_path)
70
+ elif ext in self.text_extensions:
71
+ return self._read_text_file(file_path)
72
+ else:
73
+ return PDFExtractionResult(
74
+ text="",
75
+ file_type="unknown",
76
+ page_count=0,
77
+ encoding="",
78
+ success=False,
79
+ error=f"Unknown file type: {ext}"
80
+ )
81
+
82
+ def _extract_pdf(self, file_path: str) -> PDFExtractionResult:
83
+ """
84
+ Extract text from PDF file.
85
+
86
+ Uses pdfplumber for text-based PDFs.
87
+ Falls back to PyMuPDF for complex layouts.
88
+ """
89
+ try:
90
+ # Try pdfplumber first (best for text-based PDFs)
91
+ import pdfplumber
92
+
93
+ text_parts = []
94
+ page_count = 0
95
+
96
+ with pdfplumber.open(file_path) as pdf:
97
+ page_count = len(pdf.pages)
98
+
99
+ for i, page in enumerate(pdf.pages):
100
+ page_text = page.extract_text()
101
+ if page_text:
102
+ text_parts.append(page_text)
103
+
104
+ full_text = '\n\n'.join(text_parts)
105
+
106
+ # Check if we got any text
107
+ if not full_text.strip():
108
+ # Try PyMuPDF as fallback for image-based or complex PDFs
109
+ return self._extract_pdf_with_pymupdf(file_path)
110
+
111
+ return PDFExtractionResult(
112
+ text=full_text,
113
+ file_type='pdf',
114
+ page_count=page_count,
115
+ encoding='utf-8',
116
+ success=True
117
+ )
118
+
119
+ except ImportError:
120
+ # pdfplumber not installed, try PyMuPDF
121
+ return self._extract_pdf_with_pymupdf(file_path)
122
+ except Exception as e:
123
+ return PDFExtractionResult(
124
+ text="",
125
+ file_type='pdf',
126
+ page_count=0,
127
+ encoding="",
128
+ success=False,
129
+ error=f"PDF extraction failed: {str(e)}"
130
+ )
131
+
132
+ def _extract_pdf_with_pymupdf(self, file_path: str) -> PDFExtractionResult:
133
+ """Fallback PDF extraction using PyMuPDF (fitz)."""
134
+ try:
135
+ import fitz # PyMuPDF
136
+
137
+ text_parts = []
138
+ page_count = 0
139
+
140
+ with fitz.open(file_path) as doc:
141
+ page_count = len(doc)
142
+
143
+ for page in doc:
144
+ text_parts.append(page.get_text())
145
+
146
+ full_text = '\n\n'.join(text_parts)
147
+
148
+ return PDFExtractionResult(
149
+ text=full_text,
150
+ file_type='pdf',
151
+ page_count=page_count,
152
+ encoding='utf-8',
153
+ success=True
154
+ )
155
+
156
+ except ImportError:
157
+ return PDFExtractionResult(
158
+ text="",
159
+ file_type='pdf',
160
+ page_count=0,
161
+ encoding="",
162
+ success=False,
163
+ error="PDF libraries not installed. Run: pip install pdfplumber pymupdf"
164
+ )
165
+ except Exception as e:
166
+ return PDFExtractionResult(
167
+ text="",
168
+ file_type='pdf',
169
+ page_count=0,
170
+ encoding="",
171
+ success=False,
172
+ error=f"PDF extraction failed: {str(e)}"
173
+ )
174
+
175
+ def _read_text_file(self, file_path: str) -> PDFExtractionResult:
176
+ """Read plain text file."""
177
+ try:
178
+ # Try UTF-8 first
179
+ with open(file_path, 'r', encoding='utf-8') as f:
180
+ text = f.read()
181
+
182
+ return PDFExtractionResult(
183
+ text=text,
184
+ file_type='text',
185
+ page_count=1,
186
+ encoding='utf-8',
187
+ success=True
188
+ )
189
+
190
+ except UnicodeDecodeError:
191
+ # Try other encodings
192
+ encodings = ['latin-1', 'cp1252', 'iso-8859-1']
193
+ for encoding in encodings:
194
+ try:
195
+ with open(file_path, 'r', encoding=encoding) as f:
196
+ text = f.read()
197
+
198
+ return PDFExtractionResult(
199
+ text=text,
200
+ file_type='text',
201
+ page_count=1,
202
+ encoding=encoding,
203
+ success=True
204
+ )
205
+ except UnicodeDecodeError:
206
+ continue
207
+
208
+ return PDFExtractionResult(
209
+ text="",
210
+ file_type='text',
211
+ page_count=0,
212
+ encoding="",
213
+ success=False,
214
+ error="Could not decode file. Try specifying encoding."
215
+ )
216
+ except Exception as e:
217
+ return PDFExtractionResult(
218
+ text="",
219
+ file_type='text',
220
+ page_count=0,
221
+ encoding="",
222
+ success=False,
223
+ error=f"Text file read failed: {str(e)}"
224
+ )
225
+
226
+ def is_text_based_pdf(self, file_path: str) -> bool:
227
+ """
228
+ Check if PDF contains extractable text vs just images.
229
+
230
+ Returns True if PDF has text content.
231
+ """
232
+ try:
233
+ import pdfplumber
234
+
235
+ with pdfplumber.open(file_path) as pdf:
236
+ for page in pdf.pages:
237
+ text = page.extract_text()
238
+ if text and text.strip():
239
+ return True
240
+ return False
241
+
242
+ except Exception:
243
+ return False
244
+
245
+ def extract_algorithm_section(self, text: str) -> Tuple[str, int, int]:
246
+ """
247
+ Attempt to find algorithm section within extracted text.
248
+
249
+ Args:
250
+ text: Full extracted text
251
+
252
+ Returns:
253
+ Tuple of (algorithm_text, start_index, end_index)
254
+ """
255
+ # Common patterns that indicate algorithm sections
256
+ patterns = [
257
+ r'(?i)(?:algorithm|procedure|function)\s+\w+.*?\n.*?\n(?:end|return)',
258
+ r'(?i)(?:input|output):.*?\n.*?\n(?:end|return)',
259
+ r'(?i)step\s*\d+[:.]\s*.+?(?:\n\n|\Z)',
260
+ ]
261
+
262
+ for pattern in patterns:
263
+ matches = list(re.finditer(pattern, text, re.DOTALL))
264
+ if matches:
265
+ # Return the longest match (most likely complete algorithm)
266
+ longest = max(matches, key=lambda m: len(m.group()))
267
+ return (
268
+ longest.group(),
269
+ longest.start(),
270
+ longest.end()
271
+ )
272
+
273
+ # If no pattern matched, return first 5000 chars (heuristic)
274
+ return (text[:5000], 0, min(5000, len(text)))
275
+
276
+
277
+ # Convenience function
278
+ def extract_text_from_file(file_path: str) -> PDFExtractionResult:
279
+ """Extract text from PDF or text file."""
280
+ processor = PDFProcessor()
281
+ return processor.extract_text(file_path)
@@ -0,0 +1,90 @@
1
+ """LLM prompts for algorithm extraction.
2
+
3
+ This module provides system and user prompts for LLM-based algorithm extraction,
4
+ designed to guide the LLM to produce structured JSON output.
5
+ """
6
+
7
+ EXTRACTION_SYSTEM_PROMPT = """You are an expert at parsing mathematical algorithms from natural language descriptions.
8
+
9
+ Your task is to analyze algorithm text and extract a structured JSON representation.
10
+
11
+ RULES:
12
+ 1. Identify the algorithm name from headers like "Algorithm X", "Procedure Y"
13
+ 2. Extract inputs and outputs from Input/Output sections
14
+ 3. Parse each step with accurate type classification
15
+ 4. Preserve line references to the original text
16
+ 5. Handle mathematical notation (Σ, Π, subscripts, superscripts)
17
+ 6. Output valid JSON only - no explanatory text
18
+
19
+ STEP TYPES (classify each step):
20
+ - assignment: Variable assignment (x = y, x ← y)
21
+ - loop_for: For loops (for each, for i from, repeat n times)
22
+ - loop_while: While loops (while condition, until condition)
23
+ - conditional: If statements (if, when, in case)
24
+ - return: Return statements (return x, output x, result)
25
+ - call: Function calls (call f(), invoke)
26
+ - comment: Annotations and explanations
27
+
28
+ JSON OUTPUT FORMAT:
29
+ {
30
+ "name": "algorithm name or 'unnamed'",
31
+ "description": "brief description",
32
+ "inputs": [{"name": "var", "type": "inferred", "description": ""}],
33
+ "outputs": [{"name": "var", "type": "inferred", "description": ""}],
34
+ "steps": [
35
+ {
36
+ "id": 1,
37
+ "type": "assignment",
38
+ "description": "human readable description",
39
+ "inputs": ["read vars"],
40
+ "outputs": ["written vars"],
41
+ "line_refs": [line_numbers],
42
+ "condition": null,
43
+ "body": [],
44
+ "else_body": [],
45
+ "iter_var": null,
46
+ "iter_range": null,
47
+ "expression": null,
48
+ "call_target": null,
49
+ "arguments": [],
50
+ "annotation": null
51
+ }
52
+ ],
53
+ "source_text": "original text with line numbers"
54
+ }
55
+
56
+ Infer types: int, float, array, matrix, bool based on context.
57
+ Always include line_refs array showing which original lines this step came from."""
58
+
59
+ EXTRACTION_USER_PROMPT_TEMPLATE = """Extract the algorithm from this mathematical text:
60
+
61
+ ```
62
+ {numbered_text}
63
+ ```
64
+
65
+ Provide the structured JSON representation following the schema provided.
66
+
67
+ Return ONLY the JSON. No explanatory text outside the JSON."""
68
+
69
+
70
+ def format_extraction_prompt(text: str) -> str:
71
+ """
72
+ Format extraction prompt with numbered text.
73
+
74
+ Args:
75
+ text: Raw algorithm text
76
+
77
+ Returns:
78
+ Formatted prompt with line numbers for traceability
79
+
80
+ Per D-08 from 02-CONTEXT.md.
81
+ """
82
+ lines = text.split('\n')
83
+ numbered_lines = []
84
+
85
+ for i, line in enumerate(lines, 1):
86
+ numbered_lines.append(f"{i:3d}: {line}")
87
+
88
+ numbered_text = '\n'.join(numbered_lines)
89
+
90
+ return EXTRACTION_USER_PROMPT_TEMPLATE.format(numbered_text=numbered_text)
@@ -0,0 +1,298 @@
1
+ """User review interface for algorithm extraction."""
2
+
3
+ from typing import List, Dict, Any, Optional, Tuple
4
+ from copy import deepcopy
5
+
6
+ from .schema import Algorithm, Step, StepType
7
+
8
+
9
+ def validate_step_edit(step: Step, edits: Dict[str, Any]) -> Tuple[bool, List[str]]:
10
+ """
11
+ Validate proposed edits to a step.
12
+
13
+ Args:
14
+ step: Original step
15
+ edits: Dictionary of proposed changes
16
+
17
+ Returns:
18
+ Tuple of (is_valid, list_of_errors)
19
+
20
+ Per D-20 from 02-CONTEXT.md.
21
+ """
22
+ errors = []
23
+
24
+ # Validate step type
25
+ if "type" in edits:
26
+ try:
27
+ StepType(edits["type"])
28
+ except ValueError:
29
+ errors.append(f"Invalid step type: {edits['type']}")
30
+
31
+ # Validate id is positive integer
32
+ if "id" in edits:
33
+ if not isinstance(edits["id"], int) or edits["id"] < 1:
34
+ errors.append("Step ID must be a positive integer")
35
+
36
+ # Validate description is non-empty
37
+ if "description" in edits:
38
+ if not edits["description"] or not str(edits["description"]).strip():
39
+ errors.append("Step description cannot be empty")
40
+
41
+ # Validate inputs and outputs are lists of strings
42
+ if "inputs" in edits:
43
+ if not isinstance(edits["inputs"], list):
44
+ errors.append("Inputs must be a list")
45
+ elif not all(isinstance(x, str) for x in edits["inputs"]):
46
+ errors.append("All inputs must be strings")
47
+
48
+ if "outputs" in edits:
49
+ if not isinstance(edits["outputs"], list):
50
+ errors.append("Outputs must be a list")
51
+ elif not all(isinstance(x, str) for x in edits["outputs"]):
52
+ errors.append("All outputs must be strings")
53
+
54
+ return len(errors) == 0, errors
55
+
56
+
57
+ class ReviewInterface:
58
+ """
59
+ Interface for reviewing and editing extracted algorithms.
60
+
61
+ Per D-18, D-19 from 02-CONTEXT.md.
62
+ """
63
+
64
+ def __init__(self, algorithm: Algorithm):
65
+ self.original = algorithm
66
+ self.working = deepcopy(algorithm)
67
+ self.pending_edits: List[Dict] = []
68
+
69
+ def get_side_by_side(self) -> Dict[str, Any]:
70
+ """
71
+ Get side-by-side view data for UI rendering.
72
+
73
+ Returns:
74
+ Dict with original_text and structured_steps for display
75
+
76
+ Per D-18 from 02-CONTEXT.md.
77
+ """
78
+ return {
79
+ "original_text": self.original.source_text,
80
+ "algorithm_name": self.working.name,
81
+ "inputs": self.working.inputs,
82
+ "outputs": self.working.outputs,
83
+ "steps": [
84
+ {
85
+ "id": step.id,
86
+ "type": step.type.value,
87
+ "description": step.description,
88
+ "inputs": step.inputs,
89
+ "outputs": step.outputs,
90
+ "line_refs": step.line_refs
91
+ }
92
+ for step in self.working.steps
93
+ ],
94
+ "step_count": len(self.working.steps)
95
+ }
96
+
97
+ def edit_step(self, step_id: int, edits: Dict[str, Any]) -> Tuple[bool, List[str]]:
98
+ """
99
+ Edit a specific step.
100
+
101
+ Args:
102
+ step_id: ID of step to edit
103
+ edits: Dictionary of changes {field: new_value}
104
+
105
+ Returns:
106
+ Tuple of (success, errors)
107
+
108
+ Per D-19 from 02-CONTEXT.md.
109
+ """
110
+ # Find step
111
+ step = self._find_step(step_id)
112
+ if not step:
113
+ return False, [f"Step {step_id} not found"]
114
+
115
+ # Validate edits
116
+ is_valid, errors = validate_step_edit(step, edits)
117
+ if not is_valid:
118
+ return False, errors
119
+
120
+ # Apply edits
121
+ if "type" in edits:
122
+ step.type = StepType(edits["type"])
123
+ if "description" in edits:
124
+ step.description = edits["description"]
125
+ if "inputs" in edits:
126
+ step.inputs = edits["inputs"]
127
+ if "outputs" in edits:
128
+ step.outputs = edits["outputs"]
129
+ if "expression" in edits:
130
+ step.expression = edits["expression"]
131
+ if "condition" in edits:
132
+ step.condition = edits["condition"]
133
+
134
+ self.pending_edits.append({"action": "edit", "step_id": step_id, "edits": edits})
135
+ return True, []
136
+
137
+ def reorder_step(self, step_id: int, new_position: int) -> Tuple[bool, str]:
138
+ """
139
+ Move a step to a new position.
140
+
141
+ Args:
142
+ step_id: ID of step to move
143
+ new_position: New position index (1-based)
144
+
145
+ Returns:
146
+ Tuple of (success, message)
147
+
148
+ Per D-19 from 02-CONTEXT.md.
149
+ """
150
+ steps = self.working.steps
151
+
152
+ # Find current index
153
+ current_idx = None
154
+ for i, step in enumerate(steps):
155
+ if step.id == step_id:
156
+ current_idx = i
157
+ break
158
+
159
+ if current_idx is None:
160
+ return False, f"Step {step_id} not found"
161
+
162
+ # Clamp position
163
+ new_position = max(1, min(new_position, len(steps)))
164
+ new_idx = new_position - 1
165
+
166
+ if current_idx == new_idx:
167
+ return True, "No change needed"
168
+
169
+ # Move step
170
+ step = steps.pop(current_idx)
171
+ steps.insert(new_idx, step)
172
+
173
+ # Renumber all steps
174
+ self._renumber_steps()
175
+
176
+ self.pending_edits.append({"action": "reorder", "step_id": step_id, "new_position": new_position})
177
+ return True, f"Step moved to position {new_position}"
178
+
179
+ def delete_step(self, step_id: int) -> Tuple[bool, str]:
180
+ """
181
+ Delete a step.
182
+
183
+ Args:
184
+ step_id: ID of step to delete
185
+
186
+ Returns:
187
+ Tuple of (success, message)
188
+
189
+ Per D-19 from 02-CONTEXT.md.
190
+ """
191
+ steps = self.working.steps
192
+
193
+ for i, step in enumerate(steps):
194
+ if step.id == step_id:
195
+ steps.pop(i)
196
+ self._renumber_steps()
197
+ self.pending_edits.append({"action": "delete", "step_id": step_id})
198
+ return True, f"Step {step_id} deleted"
199
+
200
+ return False, f"Step {step_id} not found"
201
+
202
+ def add_step(self, position: int, step_data: Dict[str, Any]) -> Tuple[bool, List[str]]:
203
+ """
204
+ Add a new step at specified position.
205
+
206
+ Args:
207
+ position: Position to insert (1-based, or -1 for end)
208
+ step_data: Step data dictionary
209
+
210
+ Returns:
211
+ Tuple of (success, errors_or_message)
212
+
213
+ Per D-19 from 02-CONTEXT.md.
214
+ """
215
+ # Validate
216
+ temp_step = Step(id=0, type=StepType.COMMENT, description="")
217
+ is_valid, errors = validate_step_edit(temp_step, step_data)
218
+ if not is_valid:
219
+ return False, errors
220
+
221
+ # Create step
222
+ step = Step(
223
+ id=0, # Will be renumbered
224
+ type=StepType(step_data.get("type", "comment")),
225
+ description=step_data.get("description", ""),
226
+ inputs=step_data.get("inputs", []),
227
+ outputs=step_data.get("outputs", []),
228
+ line_refs=[]
229
+ )
230
+
231
+ # Insert
232
+ steps = self.working.steps
233
+ if position == -1 or position > len(steps):
234
+ steps.append(step)
235
+ else:
236
+ steps.insert(position - 1, step)
237
+
238
+ self._renumber_steps()
239
+
240
+ self.pending_edits.append({"action": "add", "position": position, "step_data": step_data})
241
+ return True, [f"Step added at position {position}"]
242
+
243
+ def _find_step(self, step_id: int) -> Optional[Step]:
244
+ """Find step by ID."""
245
+ for step in self.working.steps:
246
+ if step.id == step_id:
247
+ return step
248
+ return None
249
+
250
+ def _renumber_steps(self):
251
+ """Renumber all steps sequentially."""
252
+ for i, step in enumerate(self.working.steps, 1):
253
+ step.id = i
254
+
255
+ def get_pending_changes(self) -> List[Dict]:
256
+ """Get list of pending edits."""
257
+ return self.pending_edits
258
+
259
+ def reset(self):
260
+ """Reset to original algorithm."""
261
+ self.working = deepcopy(self.original)
262
+ self.pending_edits = []
263
+
264
+ def approve(self) -> Algorithm:
265
+ """
266
+ Approve and return final algorithm.
267
+
268
+ Per D-22 from 02-CONTEXT.md.
269
+ """
270
+ return self.working
271
+
272
+
273
+ def apply_edits(algorithm: Algorithm, edits: List[Dict]) -> Algorithm:
274
+ """
275
+ Apply a series of edits to an algorithm.
276
+
277
+ Args:
278
+ algorithm: Original algorithm
279
+ edits: List of edit operations
280
+
281
+ Returns:
282
+ Modified algorithm
283
+ """
284
+ review = ReviewInterface(algorithm)
285
+
286
+ for edit in edits:
287
+ action = edit.get("action")
288
+
289
+ if action == "edit":
290
+ review.edit_step(edit["step_id"], edit["edits"])
291
+ elif action == "reorder":
292
+ review.reorder_step(edit["step_id"], edit["new_position"])
293
+ elif action == "delete":
294
+ review.delete_step(edit["step_id"])
295
+ elif action == "add":
296
+ review.add_step(edit["position"], edit["step_data"])
297
+
298
+ return review.approve()