opencode-arch 1.0.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.
- opencode_arch/__init__.py +3 -0
- opencode_arch/artifacts/__init__.py +48 -0
- opencode_arch/artifacts/context.py +451 -0
- opencode_arch/artifacts/diagrams.py +451 -0
- opencode_arch/artifacts/selector.py +331 -0
- opencode_arch/artifacts/templates.py +444 -0
- opencode_arch/cli/__init__.py +1 -0
- opencode_arch/cli/bench.py +25 -0
- opencode_arch/cli/calibrate.py +208 -0
- opencode_arch/cli/confidence.py +66 -0
- opencode_arch/cli/docs.py +333 -0
- opencode_arch/cli/docs_validator.py +295 -0
- opencode_arch/cli/export_data.py +133 -0
- opencode_arch/cli/extract.py +93 -0
- opencode_arch/cli/gap_analyzer.py +107 -0
- opencode_arch/cli/generate.py +68 -0
- opencode_arch/cli/launch.py +264 -0
- opencode_arch/cli/main.py +360 -0
- opencode_arch/cli/metrics.py +186 -0
- opencode_arch/cli/prompts.py +20 -0
- opencode_arch/cli/regen_loop.py +1028 -0
- opencode_arch/context/__init__.py +29 -0
- opencode_arch/context/formatter.py +492 -0
- opencode_arch/context/pipeline_bridge.py +201 -0
- opencode_arch/extract/__init__.py +8 -0
- opencode_arch/extract/constraint_detector.py +398 -0
- opencode_arch/extract/from_artifacts.py +837 -0
- opencode_arch/extract/from_code.py +646 -0
- opencode_arch/extract/route_detector.py +400 -0
- opencode_arch/extract/table_parser.py +177 -0
- opencode_arch/learning/__init__.py +19 -0
- opencode_arch/learning/adapter.py +157 -0
- opencode_arch/learning/assessor.py +170 -0
- opencode_arch/learning/classifier.py +144 -0
- opencode_arch/learning/lessons.py +139 -0
- opencode_arch/learning/maintainer.py +281 -0
- opencode_arch/learning/patterns.py +51 -0
- opencode_arch/mcp/__init__.py +1 -0
- opencode_arch/mcp/__main__.py +8 -0
- opencode_arch/mcp/server.py +183 -0
- opencode_arch/mcp/tools/__init__.py +1 -0
- opencode_arch/mcp/tools/check.py +159 -0
- opencode_arch/mcp/tools/extract.py +107 -0
- opencode_arch/mcp/tools/feedback.py +65 -0
- opencode_arch/mcp/tools/generate.py +104 -0
- opencode_arch/mcp/tools/group.py +62 -0
- opencode_arch/mcp/tools/ingest.py +101 -0
- opencode_arch/mcp/tools/require.py +77 -0
- opencode_arch/mcp/tools/scan.py +53 -0
- opencode_arch/mcp/tools/slice.py +235 -0
- opencode_arch/mcp/tools/validate.py +59 -0
- opencode_arch/prompts/__init__.py +1 -0
- opencode_arch/prompts/regen.py +36 -0
- opencode_arch/runner/__init__.py +5 -0
- opencode_arch/runner/base.py +21 -0
- opencode_arch/runner/opencode.py +66 -0
- opencode_arch/telemetry/__init__.py +6 -0
- opencode_arch/telemetry/collector.py +40 -0
- opencode_arch/telemetry/recorder.py +12 -0
- opencode_arch/telemetry/store.py +537 -0
- opencode_arch-1.0.0.dist-info/METADATA +247 -0
- opencode_arch-1.0.0.dist-info/RECORD +65 -0
- opencode_arch-1.0.0.dist-info/WHEEL +4 -0
- opencode_arch-1.0.0.dist-info/entry_points.txt +2 -0
- opencode_arch-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AST-based route handler detection for Python web frameworks.
|
|
3
|
+
|
|
4
|
+
Scans Python source files and extracts decorated route handlers for
|
|
5
|
+
FastAPI, Flask, and Django projects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import sys
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class RouteInfo:
|
|
18
|
+
"""Information about a detected route handler."""
|
|
19
|
+
|
|
20
|
+
method: str # GET, POST, PUT, DELETE, PATCH
|
|
21
|
+
path: str # "/articles/{slug}"
|
|
22
|
+
function_name: str # "get_article"
|
|
23
|
+
docstring: str # First line of function docstring
|
|
24
|
+
file: str # Relative path to the file
|
|
25
|
+
is_authenticated: bool # True if has auth dependency/decorator
|
|
26
|
+
framework: str # "fastapi", "flask", "django"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# HTTP methods recognized from decorator attribute names (FastAPI/Flask style).
|
|
30
|
+
_HTTP_METHODS = frozenset({"get", "post", "put", "delete", "patch", "options", "head"})
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def detect_routes(
|
|
34
|
+
project_root: Path, web_layer_dirs: list[str] | None = None
|
|
35
|
+
) -> list[RouteInfo]:
|
|
36
|
+
"""Scan Python files for route handler declarations.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
project_root: Root directory of the project.
|
|
40
|
+
web_layer_dirs: Optional list of directories to restrict scanning
|
|
41
|
+
(e.g., ["app/api"]). If None, scans all .py files.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
List of RouteInfo for each detected route handler.
|
|
45
|
+
"""
|
|
46
|
+
root = Path(project_root)
|
|
47
|
+
py_files = _collect_python_files(root, web_layer_dirs)
|
|
48
|
+
routes: list[RouteInfo] = []
|
|
49
|
+
|
|
50
|
+
for py_file in py_files:
|
|
51
|
+
tree = _parse_file(py_file)
|
|
52
|
+
if tree is None:
|
|
53
|
+
continue
|
|
54
|
+
rel_path = str(py_file.relative_to(root))
|
|
55
|
+
routes.extend(_extract_fastapi_routes(tree, rel_path))
|
|
56
|
+
routes.extend(_extract_flask_routes(tree, rel_path))
|
|
57
|
+
if py_file.name == "urls.py":
|
|
58
|
+
routes.extend(_extract_django_routes(tree, rel_path))
|
|
59
|
+
|
|
60
|
+
return routes
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# File collection
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _collect_python_files(
|
|
69
|
+
root: Path, web_layer_dirs: list[str] | None
|
|
70
|
+
) -> list[Path]:
|
|
71
|
+
"""Collect Python files to scan, optionally restricted to given dirs."""
|
|
72
|
+
if web_layer_dirs:
|
|
73
|
+
files: list[Path] = []
|
|
74
|
+
for dir_name in web_layer_dirs:
|
|
75
|
+
target = root / dir_name
|
|
76
|
+
if target.is_dir():
|
|
77
|
+
files.extend(sorted(target.rglob("*.py")))
|
|
78
|
+
return files
|
|
79
|
+
return sorted(root.rglob("*.py"))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Parsing
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _parse_file(path: Path) -> ast.Module | None:
|
|
88
|
+
"""Parse a Python file, returning None on failure."""
|
|
89
|
+
try:
|
|
90
|
+
source = path.read_text(encoding="utf-8")
|
|
91
|
+
return ast.parse(source, filename=str(path))
|
|
92
|
+
except (SyntaxError, UnicodeDecodeError, OSError) as exc:
|
|
93
|
+
print(f"route_detector: skipping {path} ({exc})", file=sys.stderr)
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# FastAPI extraction
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_fastapi_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
|
|
103
|
+
"""Extract routes from FastAPI-style decorators."""
|
|
104
|
+
routes: list[RouteInfo] = []
|
|
105
|
+
|
|
106
|
+
for node in ast.walk(tree):
|
|
107
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
108
|
+
continue
|
|
109
|
+
for decorator in node.decorator_list:
|
|
110
|
+
info = _parse_fastapi_decorator(decorator)
|
|
111
|
+
if info is None:
|
|
112
|
+
continue
|
|
113
|
+
method, path = info
|
|
114
|
+
routes.append(
|
|
115
|
+
RouteInfo(
|
|
116
|
+
method=method.upper(),
|
|
117
|
+
path=path,
|
|
118
|
+
function_name=node.name,
|
|
119
|
+
docstring=_get_docstring(node),
|
|
120
|
+
file=rel_path,
|
|
121
|
+
is_authenticated=_has_auth_dependency(node),
|
|
122
|
+
framework="fastapi",
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return routes
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_fastapi_decorator(node: ast.expr) -> tuple[str, str] | None:
|
|
129
|
+
"""Return (method, path) if the decorator is a FastAPI route call."""
|
|
130
|
+
if not isinstance(node, ast.Call):
|
|
131
|
+
return None
|
|
132
|
+
func = node.func
|
|
133
|
+
if not isinstance(func, ast.Attribute):
|
|
134
|
+
return None
|
|
135
|
+
if func.attr not in _HTTP_METHODS:
|
|
136
|
+
return None
|
|
137
|
+
# First positional arg is the path
|
|
138
|
+
path = _get_first_string_arg(node)
|
|
139
|
+
if path is None:
|
|
140
|
+
path = ""
|
|
141
|
+
return func.attr, path
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _has_auth_dependency(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
145
|
+
"""Check if a function has auth-related Depends() or Security() parameters."""
|
|
146
|
+
for arg in _all_function_args(node):
|
|
147
|
+
if arg.annotation is None:
|
|
148
|
+
continue
|
|
149
|
+
if _is_auth_annotation(arg.annotation):
|
|
150
|
+
return True
|
|
151
|
+
# Check default values
|
|
152
|
+
# Also check defaults
|
|
153
|
+
defaults = _collect_defaults(node)
|
|
154
|
+
for default in defaults:
|
|
155
|
+
if _is_auth_call(default):
|
|
156
|
+
return True
|
|
157
|
+
# Check route decorator dependencies=[Depends(...)] kwarg
|
|
158
|
+
for decorator in node.decorator_list:
|
|
159
|
+
if isinstance(decorator, ast.Call):
|
|
160
|
+
for kw in decorator.keywords:
|
|
161
|
+
if kw.arg == "dependencies" and isinstance(kw.value, ast.List):
|
|
162
|
+
for elt in kw.value.elts:
|
|
163
|
+
if _is_auth_call(elt):
|
|
164
|
+
return True
|
|
165
|
+
return False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _all_function_args(
|
|
169
|
+
node: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
170
|
+
) -> list[ast.arg]:
|
|
171
|
+
"""Get all arguments from a function definition."""
|
|
172
|
+
args = node.args
|
|
173
|
+
return args.posonlyargs + args.args + args.kwonlyargs
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _collect_defaults(node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.expr]:
|
|
177
|
+
"""Collect all default values from function arguments."""
|
|
178
|
+
args = node.args
|
|
179
|
+
return list(args.defaults) + list(args.kw_defaults)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _is_auth_annotation(ann: ast.expr) -> bool:
|
|
183
|
+
"""Check if an annotation references auth (e.g., Depends(get_current_user))."""
|
|
184
|
+
if isinstance(ann, ast.Call):
|
|
185
|
+
return _is_auth_call(ann)
|
|
186
|
+
return False
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _is_auth_call(node: ast.expr) -> bool:
|
|
190
|
+
"""Check if a Call node is Depends(auth...) or Security(...)."""
|
|
191
|
+
if not isinstance(node, ast.Call):
|
|
192
|
+
return False
|
|
193
|
+
func_name = _get_call_name(node)
|
|
194
|
+
if func_name == "Security":
|
|
195
|
+
return True
|
|
196
|
+
if func_name == "Depends":
|
|
197
|
+
if node.args:
|
|
198
|
+
arg = node.args[0]
|
|
199
|
+
# Direct name: Depends(get_current_user)
|
|
200
|
+
arg_name = _get_node_name(arg)
|
|
201
|
+
if arg_name and _is_auth_name(arg_name):
|
|
202
|
+
return True
|
|
203
|
+
# Factory call: Depends(get_current_user_authorizer())
|
|
204
|
+
if isinstance(arg, ast.Call):
|
|
205
|
+
call_name = _get_call_name(arg)
|
|
206
|
+
if call_name and _is_auth_name(call_name):
|
|
207
|
+
return True
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _is_auth_name(name: str) -> bool:
|
|
212
|
+
"""Check if a name looks auth-related."""
|
|
213
|
+
lower = name.lower()
|
|
214
|
+
return (
|
|
215
|
+
"auth" in lower
|
|
216
|
+
or "current_user" in lower
|
|
217
|
+
or "permission" in lower
|
|
218
|
+
or "login_required" in lower
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _get_call_name(node: ast.Call) -> str:
|
|
223
|
+
"""Get the simple name of a Call's function."""
|
|
224
|
+
if isinstance(node.func, ast.Name):
|
|
225
|
+
return node.func.id
|
|
226
|
+
if isinstance(node.func, ast.Attribute):
|
|
227
|
+
return node.func.attr
|
|
228
|
+
return ""
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _get_node_name(node: ast.expr) -> str:
|
|
232
|
+
"""Get the name string from a Name or Attribute node."""
|
|
233
|
+
if isinstance(node, ast.Name):
|
|
234
|
+
return node.id
|
|
235
|
+
if isinstance(node, ast.Attribute):
|
|
236
|
+
return node.attr
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ---------------------------------------------------------------------------
|
|
241
|
+
# Flask extraction
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _extract_flask_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
|
|
246
|
+
"""Extract routes from Flask-style @app.route() decorators."""
|
|
247
|
+
routes: list[RouteInfo] = []
|
|
248
|
+
|
|
249
|
+
for node in ast.walk(tree):
|
|
250
|
+
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
251
|
+
continue
|
|
252
|
+
for decorator in node.decorator_list:
|
|
253
|
+
info = _parse_flask_decorator(decorator)
|
|
254
|
+
if info is None:
|
|
255
|
+
continue
|
|
256
|
+
methods, path = info
|
|
257
|
+
for method in methods:
|
|
258
|
+
routes.append(
|
|
259
|
+
RouteInfo(
|
|
260
|
+
method=method.upper(),
|
|
261
|
+
path=path,
|
|
262
|
+
function_name=node.name,
|
|
263
|
+
docstring=_get_docstring(node),
|
|
264
|
+
file=rel_path,
|
|
265
|
+
is_authenticated=_has_flask_auth_decorator(node),
|
|
266
|
+
framework="flask",
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
return routes
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _parse_flask_decorator(node: ast.expr) -> tuple[list[str], str] | None:
|
|
273
|
+
"""Return (methods, path) if the decorator is a Flask route call."""
|
|
274
|
+
if not isinstance(node, ast.Call):
|
|
275
|
+
return None
|
|
276
|
+
func = node.func
|
|
277
|
+
if not isinstance(func, ast.Attribute):
|
|
278
|
+
return None
|
|
279
|
+
if func.attr != "route":
|
|
280
|
+
return None
|
|
281
|
+
path = _get_first_string_arg(node)
|
|
282
|
+
if path is None:
|
|
283
|
+
return None
|
|
284
|
+
methods = _get_flask_methods(node)
|
|
285
|
+
return methods, path
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _get_flask_methods(node: ast.Call) -> list[str]:
|
|
289
|
+
"""Extract the methods= keyword from a Flask route decorator."""
|
|
290
|
+
for kw in node.keywords:
|
|
291
|
+
if kw.arg == "methods":
|
|
292
|
+
if isinstance(kw.value, ast.List):
|
|
293
|
+
methods: list[str] = []
|
|
294
|
+
for elt in kw.value.elts:
|
|
295
|
+
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
|
|
296
|
+
methods.append(elt.value)
|
|
297
|
+
if methods:
|
|
298
|
+
return methods
|
|
299
|
+
return ["GET"]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _has_flask_auth_decorator(node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool:
|
|
303
|
+
"""Check if a Flask handler has login_required or similar auth decorator."""
|
|
304
|
+
for decorator in node.decorator_list:
|
|
305
|
+
name = ""
|
|
306
|
+
if isinstance(decorator, ast.Name):
|
|
307
|
+
name = decorator.id
|
|
308
|
+
elif isinstance(decorator, ast.Attribute):
|
|
309
|
+
name = decorator.attr
|
|
310
|
+
elif isinstance(decorator, ast.Call):
|
|
311
|
+
if isinstance(decorator.func, ast.Name):
|
|
312
|
+
name = decorator.func.id
|
|
313
|
+
elif isinstance(decorator.func, ast.Attribute):
|
|
314
|
+
name = decorator.func.attr
|
|
315
|
+
if _is_auth_name(name):
|
|
316
|
+
return True
|
|
317
|
+
return False
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Django extraction
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _extract_django_routes(tree: ast.Module, rel_path: str) -> list[RouteInfo]:
|
|
326
|
+
"""Extract routes from Django urlpatterns assignments."""
|
|
327
|
+
routes: list[RouteInfo] = []
|
|
328
|
+
|
|
329
|
+
for node in ast.walk(tree):
|
|
330
|
+
if not isinstance(node, ast.Assign):
|
|
331
|
+
continue
|
|
332
|
+
# Look for: urlpatterns = [...]
|
|
333
|
+
if not any(
|
|
334
|
+
isinstance(t, ast.Name) and t.id == "urlpatterns" for t in node.targets
|
|
335
|
+
):
|
|
336
|
+
continue
|
|
337
|
+
if not isinstance(node.value, ast.List):
|
|
338
|
+
continue
|
|
339
|
+
for elt in node.value.elts:
|
|
340
|
+
info = _parse_django_path_call(elt)
|
|
341
|
+
if info:
|
|
342
|
+
routes.append(
|
|
343
|
+
RouteInfo(
|
|
344
|
+
method="GET",
|
|
345
|
+
path=info[0],
|
|
346
|
+
function_name=info[1],
|
|
347
|
+
docstring="",
|
|
348
|
+
file=rel_path,
|
|
349
|
+
is_authenticated=False,
|
|
350
|
+
framework="django",
|
|
351
|
+
)
|
|
352
|
+
)
|
|
353
|
+
return routes
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _parse_django_path_call(node: ast.expr) -> tuple[str, str] | None:
|
|
357
|
+
"""Parse a path() or re_path() call in urlpatterns."""
|
|
358
|
+
if not isinstance(node, ast.Call):
|
|
359
|
+
return None
|
|
360
|
+
func_name = _get_call_name(node)
|
|
361
|
+
if func_name not in ("path", "re_path"):
|
|
362
|
+
return None
|
|
363
|
+
if len(node.args) < 2:
|
|
364
|
+
return None
|
|
365
|
+
# First arg is the route string
|
|
366
|
+
route_arg = node.args[0]
|
|
367
|
+
if not isinstance(route_arg, ast.Constant) or not isinstance(
|
|
368
|
+
route_arg.value, str
|
|
369
|
+
):
|
|
370
|
+
return None
|
|
371
|
+
route = route_arg.value
|
|
372
|
+
# Second arg is the view function
|
|
373
|
+
view_name = _get_node_name(node.args[1])
|
|
374
|
+
if not view_name:
|
|
375
|
+
# Try dotted access e.g., views.article_list
|
|
376
|
+
if isinstance(node.args[1], ast.Attribute):
|
|
377
|
+
view_name = node.args[1].attr
|
|
378
|
+
return route, view_name
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Utilities
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _get_first_string_arg(node: ast.Call) -> str | None:
|
|
387
|
+
"""Get the first positional string argument from a Call node."""
|
|
388
|
+
if node.args:
|
|
389
|
+
first = node.args[0]
|
|
390
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
391
|
+
return first.value
|
|
392
|
+
return None
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _get_docstring(node: ast.FunctionDef | ast.AsyncFunctionDef) -> str:
|
|
396
|
+
"""Get the first line of a function's docstring, or empty string."""
|
|
397
|
+
ds = ast.get_docstring(node)
|
|
398
|
+
if ds:
|
|
399
|
+
return ds.split("\n")[0].strip()
|
|
400
|
+
return ""
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Markdown table parser — extracts structured data from pipe-delimited tables.
|
|
3
|
+
|
|
4
|
+
Used by the artifact extractors to pull entity data from Tier 1 markdown docs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def parse_tables(markdown: str) -> list[list[dict[str, str]]]:
|
|
14
|
+
"""
|
|
15
|
+
Parse all markdown tables in the text.
|
|
16
|
+
|
|
17
|
+
Returns a list of tables, where each table is a list of row-dicts
|
|
18
|
+
keyed by normalized header names.
|
|
19
|
+
"""
|
|
20
|
+
tables: list[list[dict[str, str]]] = []
|
|
21
|
+
lines = markdown.split("\n")
|
|
22
|
+
i = 0
|
|
23
|
+
|
|
24
|
+
while i < len(lines):
|
|
25
|
+
# Look for a header row (contains pipes)
|
|
26
|
+
if "|" in lines[i]:
|
|
27
|
+
header_line = lines[i].strip()
|
|
28
|
+
# Check next line is separator (dashes)
|
|
29
|
+
if i + 1 < len(lines) and re.match(r"^\s*\|[\s\-:|]+\|\s*$", lines[i + 1]):
|
|
30
|
+
headers = _parse_row(header_line)
|
|
31
|
+
if headers:
|
|
32
|
+
table_rows: list[dict[str, str]] = []
|
|
33
|
+
i += 2 # skip header + separator
|
|
34
|
+
|
|
35
|
+
while i < len(lines) and "|" in lines[i]:
|
|
36
|
+
row_line = lines[i].strip()
|
|
37
|
+
if re.match(r"^\s*\|[\s\-:|]+\|\s*$", row_line):
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
values = _parse_row(row_line)
|
|
41
|
+
if values:
|
|
42
|
+
row_dict = {}
|
|
43
|
+
for idx, h in enumerate(headers):
|
|
44
|
+
key = _normalize_header(h)
|
|
45
|
+
row_dict[key] = values[idx] if idx < len(values) else ""
|
|
46
|
+
table_rows.append(row_dict)
|
|
47
|
+
i += 1
|
|
48
|
+
|
|
49
|
+
if table_rows:
|
|
50
|
+
tables.append(table_rows)
|
|
51
|
+
continue
|
|
52
|
+
i += 1
|
|
53
|
+
|
|
54
|
+
return tables
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def find_table_after_heading(markdown: str, heading_pattern: str) -> list[dict[str, str]]:
|
|
58
|
+
"""
|
|
59
|
+
Find the first table that appears after a heading matching the pattern.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
markdown: Full markdown text
|
|
63
|
+
heading_pattern: Regex pattern to match against heading text (case-insensitive)
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
List of row dicts, or empty list if no matching table found.
|
|
67
|
+
"""
|
|
68
|
+
lines = markdown.split("\n")
|
|
69
|
+
heading_re = re.compile(heading_pattern, re.IGNORECASE)
|
|
70
|
+
found_heading = False
|
|
71
|
+
|
|
72
|
+
i = 0
|
|
73
|
+
while i < len(lines):
|
|
74
|
+
line = lines[i].strip()
|
|
75
|
+
|
|
76
|
+
# Check for heading (only match ## or deeper, skip # document titles)
|
|
77
|
+
if line.startswith("##"):
|
|
78
|
+
heading_text = re.sub(r"^#+\s*", "", line)
|
|
79
|
+
if heading_re.search(heading_text):
|
|
80
|
+
found_heading = True
|
|
81
|
+
i += 1
|
|
82
|
+
continue
|
|
83
|
+
elif found_heading:
|
|
84
|
+
# Hit next heading without finding a table
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
# If we've found the heading, look for a table
|
|
88
|
+
if found_heading and "|" in line:
|
|
89
|
+
if i + 1 < len(lines) and re.match(r"^\s*\|[\s\-:|]+\|\s*$", lines[i + 1]):
|
|
90
|
+
headers = _parse_row(line)
|
|
91
|
+
if headers:
|
|
92
|
+
table_rows: list[dict[str, str]] = []
|
|
93
|
+
i += 2
|
|
94
|
+
|
|
95
|
+
while i < len(lines) and "|" in lines[i]:
|
|
96
|
+
row_line = lines[i].strip()
|
|
97
|
+
if re.match(r"^\s*\|[\s\-:|]+\|\s*$", row_line):
|
|
98
|
+
i += 1
|
|
99
|
+
continue
|
|
100
|
+
values = _parse_row(row_line)
|
|
101
|
+
if values:
|
|
102
|
+
row_dict = {}
|
|
103
|
+
for idx, h in enumerate(headers):
|
|
104
|
+
key = _normalize_header(h)
|
|
105
|
+
row_dict[key] = values[idx] if idx < len(values) else ""
|
|
106
|
+
table_rows.append(row_dict)
|
|
107
|
+
i += 1
|
|
108
|
+
|
|
109
|
+
return table_rows
|
|
110
|
+
i += 1
|
|
111
|
+
|
|
112
|
+
return []
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def extract_sections(markdown: str, level: int = 2) -> dict[str, str]:
|
|
116
|
+
"""
|
|
117
|
+
Split markdown into sections by heading level.
|
|
118
|
+
|
|
119
|
+
Returns dict mapping heading text -> section content (including sub-headings).
|
|
120
|
+
"""
|
|
121
|
+
sections: dict[str, str] = {}
|
|
122
|
+
prefix = "#" * level
|
|
123
|
+
lines = markdown.split("\n")
|
|
124
|
+
current_heading = ""
|
|
125
|
+
current_lines: list[str] = []
|
|
126
|
+
|
|
127
|
+
for line in lines:
|
|
128
|
+
if line.startswith(prefix + " ") and not line.startswith(prefix + "# "):
|
|
129
|
+
# Save previous section
|
|
130
|
+
if current_heading:
|
|
131
|
+
sections[current_heading] = "\n".join(current_lines).strip()
|
|
132
|
+
current_heading = re.sub(r"^#+\s*", "", line.strip())
|
|
133
|
+
current_lines = []
|
|
134
|
+
else:
|
|
135
|
+
current_lines.append(line)
|
|
136
|
+
|
|
137
|
+
# Save last section
|
|
138
|
+
if current_heading:
|
|
139
|
+
sections[current_heading] = "\n".join(current_lines).strip()
|
|
140
|
+
|
|
141
|
+
return sections
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_list_items(text: str) -> list[str]:
|
|
145
|
+
"""Extract bullet/numbered list items from text."""
|
|
146
|
+
items: list[str] = []
|
|
147
|
+
for line in text.split("\n"):
|
|
148
|
+
line = line.strip()
|
|
149
|
+
# Match: - item, * item, 1. item, 1) item
|
|
150
|
+
m = re.match(r"^(?:[-*]|\d+[.)]) \s*(.*)", line)
|
|
151
|
+
if m:
|
|
152
|
+
items.append(m.group(1).strip())
|
|
153
|
+
return items
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Internal
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _parse_row(line: str) -> list[str]:
|
|
162
|
+
"""Parse a pipe-delimited row into cell values."""
|
|
163
|
+
line = line.strip()
|
|
164
|
+
if line.startswith("|"):
|
|
165
|
+
line = line[1:]
|
|
166
|
+
if line.endswith("|"):
|
|
167
|
+
line = line[:-1]
|
|
168
|
+
cells = [cell.strip() for cell in line.split("|")]
|
|
169
|
+
return cells
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _normalize_header(header: str) -> str:
|
|
173
|
+
"""Normalize header to snake_case key."""
|
|
174
|
+
h = header.strip().lower()
|
|
175
|
+
h = re.sub(r"[^a-z0-9]+", "_", h)
|
|
176
|
+
h = h.strip("_")
|
|
177
|
+
return h
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Learning loop: pattern classification, adaptation, assessment, and maintenance."""
|
|
2
|
+
from opencode_arch.learning.classifier import classify_failures
|
|
3
|
+
from opencode_arch.learning.adapter import get_adaptations, apply_adaptations
|
|
4
|
+
from opencode_arch.learning.assessor import generate_report_card, ReportCard
|
|
5
|
+
from opencode_arch.learning.lessons import extract_lessons, Lesson
|
|
6
|
+
from opencode_arch.learning.maintainer import detect_drift, auto_fix_drift, DriftFlag
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"classify_failures",
|
|
10
|
+
"get_adaptations",
|
|
11
|
+
"apply_adaptations",
|
|
12
|
+
"generate_report_card",
|
|
13
|
+
"ReportCard",
|
|
14
|
+
"extract_lessons",
|
|
15
|
+
"Lesson",
|
|
16
|
+
"detect_drift",
|
|
17
|
+
"auto_fix_drift",
|
|
18
|
+
"DriftFlag",
|
|
19
|
+
]
|