nabla-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.
nabla/prompt_recon.py ADDED
@@ -0,0 +1,542 @@
1
+ """T2 — prompt template reconstruction. See cli-plan.md T2.
2
+
3
+ Owner fills this module. Re-parses source from raw_site.file/span; does NOT
4
+ import scanner internals.
5
+
6
+ Approach: pure static backward dataflow over the AST. Starting from the
7
+ `messages=` argument of the SDK call, each message's `content` expression is
8
+ resolved to a template string by chasing Name references through a small
9
+ stack of "frames" -- one per function body currently being inlined:
10
+
11
+ * module-level constants (simple `NAME = <expr>` assignments) are inlined
12
+ directly;
13
+ * local assignments (`x = <expr>`) are inlined, with `<expr>` evaluated in
14
+ the same frame;
15
+ * calls to other functions defined in the same module are inlined by
16
+ binding the callee's parameters to the caller's argument expressions
17
+ (evaluated in the *caller's* frame) and resolving the callee's `return`
18
+ expression;
19
+ * a function parameter that has no binding (i.e. we've walked all the way
20
+ back to the function that directly contains -- or, for a wrapper site,
21
+ is one hop away from -- the SDK call) becomes a named `{slot}`;
22
+ * anything else (attribute access on an unknown object, calls to
23
+ functions we can't locate, subscripts, comprehensions, ...) becomes an
24
+ opaque `{UNRESOLVED_n}` slot and sets `partial=True`. This function never
25
+ raises; degenerate/unfamiliar code always degrades to a partial result.
26
+
27
+ For `wrapper_indirect` sites, `raw_site` describes the *caller* (the
28
+ reported site) and `raw_site.wrapper` describes where the SDK call actually
29
+ lives. We resolve the wrapper function's messages, but treat its own
30
+ parameters as bound to the caller's argument expressions at the one call
31
+ site (within `raw_site.symbol`) that invokes the wrapper -- i.e. exactly one
32
+ cross-module hop, as required.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import ast
38
+ import re
39
+ from dataclasses import dataclass, field
40
+ from pathlib import Path
41
+
42
+ from .contract import PromptTemplate, RawSite, Slot
43
+
44
+ _FALLBACK_NAME = "UNRESOLVED_1"
45
+
46
+
47
+ def reconstruct(raw_site: RawSite, repo_path: str) -> PromptTemplate:
48
+ """Recover the prompt template with named {slot} markers.
49
+
50
+ Must: resolve messages back through assignments, f-strings, .format(),
51
+ %, concatenation, and function parameters (module + one call-hop);
52
+ split system vs user messages; mark unresolvable fragments as
53
+ {UNRESOLVED_n} slots and set partial=True instead of failing.
54
+ """
55
+ try:
56
+ result = _reconstruct_inner(raw_site, repo_path)
57
+ if result is not None:
58
+ return result
59
+ except Exception:
60
+ pass
61
+ return _fallback_template()
62
+
63
+
64
+ def _fallback_template() -> PromptTemplate:
65
+ return PromptTemplate(
66
+ template="{" + _FALLBACK_NAME + "}",
67
+ slots=[Slot(name=_FALLBACK_NAME, inferred_type="unresolved")],
68
+ system_prompt=None,
69
+ embedded_few_shot=[],
70
+ partial=True,
71
+ )
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Analysis state
76
+ # ---------------------------------------------------------------------------
77
+
78
+
79
+ class _Ctx:
80
+ def __init__(self) -> None:
81
+ self.partial = False
82
+ self.unresolved_count = 0
83
+
84
+
85
+ @dataclass
86
+ class _ModuleInfo:
87
+ path: Path
88
+ tree: ast.Module
89
+ module_consts: dict[str, ast.expr]
90
+ functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef]
91
+
92
+
93
+ @dataclass
94
+ class _Frame:
95
+ module: _ModuleInfo
96
+ func: ast.FunctionDef | ast.AsyncFunctionDef | None
97
+ env: dict[str, tuple[ast.expr, "_Frame"]] = field(default_factory=dict)
98
+ # is_root frames are those whose own parameters have no known caller
99
+ # binding -- their unbound parameters become the actual named slots.
100
+ is_root: bool = False
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Top-level driver
105
+ # ---------------------------------------------------------------------------
106
+
107
+
108
+ def _reconstruct_inner(raw_site: RawSite, repo_path: str) -> PromptTemplate | None:
109
+ cache: dict[str, _ModuleInfo] = {}
110
+ ctx = _Ctx()
111
+ slots: dict[str, Slot] = {}
112
+
113
+ if raw_site.wrapper is not None:
114
+ wrapper_module = _load_module(repo_path, raw_site.wrapper.file, cache)
115
+ wrapper_func = _find_function(wrapper_module.tree, raw_site.wrapper.symbol)
116
+ if wrapper_func is None:
117
+ return None
118
+ target_call = _find_call(wrapper_func, raw_site.wrapper.span, raw_site.kind)
119
+ if target_call is None:
120
+ return None
121
+
122
+ caller_module = _load_module(repo_path, raw_site.file, cache)
123
+ caller_func = _find_function(caller_module.tree, raw_site.symbol)
124
+ if caller_func is None:
125
+ return None
126
+ caller_frame = _make_root_frame(caller_module, caller_func)
127
+
128
+ wrapper_name = _bare_name(raw_site.wrapper.symbol)
129
+ inner_call = _find_named_call(caller_func, wrapper_name)
130
+ if inner_call is None:
131
+ return None
132
+ message_frame = _make_callee_frame(wrapper_module, wrapper_func, inner_call, caller_frame)
133
+ else:
134
+ module = _load_module(repo_path, raw_site.file, cache)
135
+ func = _find_function(module.tree, raw_site.symbol)
136
+ if func is None:
137
+ return None
138
+ target_call = _find_call(func, raw_site.span, raw_site.kind)
139
+ if target_call is None:
140
+ return None
141
+ message_frame = _make_root_frame(module, func)
142
+
143
+ messages_node = _get_kw(target_call, "messages")
144
+ if messages_node is None or not isinstance(messages_node, ast.List):
145
+ return None
146
+
147
+ parsed: list[tuple[str, str]] = [
148
+ _resolve_message(elt, message_frame, slots, ctx) for elt in messages_node.elts
149
+ ]
150
+
151
+ system_prompt: str | None = None
152
+ if parsed and parsed[0][0] == "system":
153
+ system_prompt = parsed[0][1]
154
+ parsed = parsed[1:]
155
+
156
+ embedded_few_shot: list[dict[str, str]] = []
157
+ if parsed:
158
+ *lead, last_msg = parsed
159
+ template = last_msg[1]
160
+ embedded_few_shot = [{"role": role, "content": content} for role, content in lead]
161
+ else:
162
+ template = _new_unresolved(slots, ctx)
163
+
164
+ return PromptTemplate(
165
+ template=template,
166
+ slots=list(slots.values()),
167
+ system_prompt=system_prompt,
168
+ embedded_few_shot=embedded_few_shot,
169
+ partial=ctx.partial,
170
+ )
171
+
172
+
173
+ # ---------------------------------------------------------------------------
174
+ # Module loading / AST lookups
175
+ # ---------------------------------------------------------------------------
176
+
177
+
178
+ def _load_module(repo_path: str, rel_file: str, cache: dict[str, _ModuleInfo]) -> _ModuleInfo:
179
+ if rel_file in cache:
180
+ return cache[rel_file]
181
+ full_path = Path(repo_path) / rel_file
182
+ source = full_path.read_text(encoding="utf-8")
183
+ tree = ast.parse(source, filename=str(full_path))
184
+ module_consts: dict[str, ast.expr] = {}
185
+ functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {}
186
+ for stmt in tree.body:
187
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
188
+ module_consts[stmt.targets[0].id] = stmt.value
189
+ elif isinstance(stmt, (ast.FunctionDef, ast.AsyncFunctionDef)):
190
+ functions[stmt.name] = stmt
191
+ info = _ModuleInfo(path=full_path, tree=tree, module_consts=module_consts, functions=functions)
192
+ cache[rel_file] = info
193
+ return info
194
+
195
+
196
+ def _bare_name(qualname: str) -> str:
197
+ name = qualname.strip()
198
+ if name.endswith("()"):
199
+ name = name[:-2]
200
+ return name.split(".")[-1]
201
+
202
+
203
+ def _find_function(tree: ast.AST, qualname: str) -> ast.FunctionDef | ast.AsyncFunctionDef | None:
204
+ target = _bare_name(qualname)
205
+ for node in ast.walk(tree):
206
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == target:
207
+ return node
208
+ return None
209
+
210
+
211
+ def _find_call(func: ast.AST, span: tuple[int, int], kind: str) -> ast.Call | None:
212
+ candidates = [
213
+ node
214
+ for node in ast.walk(func)
215
+ if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == kind
216
+ ]
217
+ if not candidates:
218
+ return None
219
+ lo, hi = span
220
+ for node in candidates:
221
+ node_lo = node.lineno
222
+ node_hi = getattr(node, "end_lineno", node.lineno)
223
+ if node_lo <= hi and node_hi >= lo:
224
+ return node
225
+ return candidates[0]
226
+
227
+
228
+ def _find_named_call(func: ast.AST, name: str) -> ast.Call | None:
229
+ for node in ast.walk(func):
230
+ if isinstance(node, ast.Call):
231
+ f = node.func
232
+ if isinstance(f, ast.Name) and f.id == name:
233
+ return node
234
+ if isinstance(f, ast.Attribute) and f.attr == name:
235
+ return node
236
+ return None
237
+
238
+
239
+ def _get_kw(call: ast.Call, name: str) -> ast.expr | None:
240
+ for kw in call.keywords:
241
+ if kw.arg == name:
242
+ return kw.value
243
+ return None
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # Scope walking (stays within one function; does not descend into nested
248
+ # defs/classes/lambdas, so their locals don't leak into the outer scope)
249
+ # ---------------------------------------------------------------------------
250
+
251
+
252
+ def _scope_statements(stmts: list[ast.stmt]):
253
+ for stmt in stmts:
254
+ yield stmt
255
+ for _, field_val in ast.iter_fields(stmt):
256
+ if isinstance(field_val, list):
257
+ for item in field_val:
258
+ if isinstance(item, ast.stmt) and not isinstance(
259
+ item, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
260
+ ):
261
+ yield from _scope_statements([item])
262
+ elif isinstance(field_val, ast.stmt) and not isinstance(
263
+ field_val, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
264
+ ):
265
+ yield from _scope_statements([field_val])
266
+
267
+
268
+ def _collect_local_assigns(func: ast.FunctionDef | ast.AsyncFunctionDef) -> dict[str, ast.expr]:
269
+ out: dict[str, ast.expr] = {}
270
+ for stmt in _scope_statements(func.body):
271
+ if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1 and isinstance(stmt.targets[0], ast.Name):
272
+ out[stmt.targets[0].id] = stmt.value
273
+ elif isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name) and stmt.value is not None:
274
+ out[stmt.target.id] = stmt.value
275
+ return out
276
+
277
+
278
+ def _find_return_expr(func: ast.FunctionDef | ast.AsyncFunctionDef) -> ast.expr | None:
279
+ result: ast.expr | None = None
280
+ for stmt in _scope_statements(func.body):
281
+ if isinstance(stmt, ast.Return) and stmt.value is not None:
282
+ result = stmt.value
283
+ return result
284
+
285
+
286
+ def _param_names(func: ast.FunctionDef | ast.AsyncFunctionDef) -> list[str]:
287
+ a = func.args
288
+ return [p.arg for p in a.posonlyargs] + [p.arg for p in a.args] + [p.arg for p in a.kwonlyargs]
289
+
290
+
291
+ # ---------------------------------------------------------------------------
292
+ # Frame construction
293
+ # ---------------------------------------------------------------------------
294
+
295
+
296
+ def _make_root_frame(module: _ModuleInfo, func: ast.FunctionDef | ast.AsyncFunctionDef) -> _Frame:
297
+ frame = _Frame(module=module, func=func, env={}, is_root=True)
298
+ for name, val in _collect_local_assigns(func).items():
299
+ frame.env[name] = (val, frame)
300
+ return frame
301
+
302
+
303
+ def _make_callee_frame(
304
+ module: _ModuleInfo,
305
+ func: ast.FunctionDef | ast.AsyncFunctionDef,
306
+ call_node: ast.Call,
307
+ calling_frame: _Frame,
308
+ ) -> _Frame:
309
+ frame = _Frame(module=module, func=func, env={}, is_root=False)
310
+ a = func.args
311
+ positional_params = [p.arg for p in a.posonlyargs] + [p.arg for p in a.args]
312
+ kwonly_params = [p.arg for p in a.kwonlyargs]
313
+
314
+ for i, arg_node in enumerate(call_node.args):
315
+ if i < len(positional_params):
316
+ frame.env[positional_params[i]] = (arg_node, calling_frame)
317
+ for kw in call_node.keywords:
318
+ if kw.arg and (kw.arg in positional_params or kw.arg in kwonly_params):
319
+ frame.env[kw.arg] = (kw.value, calling_frame)
320
+
321
+ if a.defaults:
322
+ default_params = positional_params[-len(a.defaults):]
323
+ for p, d in zip(default_params, a.defaults):
324
+ frame.env.setdefault(p, (d, frame))
325
+ for p, d in zip(kwonly_params, a.kw_defaults):
326
+ if d is not None:
327
+ frame.env.setdefault(p, (d, frame))
328
+
329
+ for name, val in _collect_local_assigns(func).items():
330
+ frame.env.setdefault(name, (val, frame))
331
+ return frame
332
+
333
+
334
+ # ---------------------------------------------------------------------------
335
+ # Expression resolution
336
+ # ---------------------------------------------------------------------------
337
+
338
+
339
+ def _escape(text: str) -> str:
340
+ return text.replace("{", "{{").replace("}", "}}")
341
+
342
+
343
+ def _new_slot(name: str, slots: dict[str, Slot]) -> str:
344
+ if name not in slots:
345
+ slots[name] = Slot(name=name)
346
+ return "{" + name + "}"
347
+
348
+
349
+ def _new_unresolved(slots: dict[str, Slot], ctx: _Ctx) -> str:
350
+ ctx.partial = True
351
+ ctx.unresolved_count += 1
352
+ name = f"UNRESOLVED_{ctx.unresolved_count}"
353
+ slots[name] = Slot(name=name, inferred_type="unresolved")
354
+ return "{" + name + "}"
355
+
356
+
357
+ def _resolve_message(elt: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx) -> tuple[str, str]:
358
+ if not isinstance(elt, ast.Dict):
359
+ return ("unknown", _new_unresolved(slots, ctx))
360
+ role = "unknown"
361
+ content_node: ast.expr | None = None
362
+ for k, v in zip(elt.keys, elt.values):
363
+ if isinstance(k, ast.Constant) and k.value == "role":
364
+ role = v.value if isinstance(v, ast.Constant) and isinstance(v.value, str) else "unknown"
365
+ elif isinstance(k, ast.Constant) and k.value == "content":
366
+ content_node = v
367
+ if content_node is None:
368
+ return (role, _new_unresolved(slots, ctx))
369
+ return (role, _resolve_expr(content_node, frame, slots, ctx))
370
+
371
+
372
+ def _resolve_expr(node: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int = 0) -> str:
373
+ if depth > 60:
374
+ return _new_unresolved(slots, ctx)
375
+
376
+ if isinstance(node, ast.Constant):
377
+ return _escape(node.value) if isinstance(node.value, str) else _escape(str(node.value))
378
+
379
+ if isinstance(node, ast.JoinedStr):
380
+ parts = []
381
+ for v in node.values:
382
+ if isinstance(v, ast.Constant):
383
+ parts.append(_escape(v.value) if isinstance(v.value, str) else _escape(str(v.value)))
384
+ elif isinstance(v, ast.FormattedValue):
385
+ parts.append(_resolve_expr(v.value, frame, slots, ctx, depth + 1))
386
+ else:
387
+ parts.append(_new_unresolved(slots, ctx))
388
+ return "".join(parts)
389
+
390
+ if isinstance(node, ast.Name):
391
+ return _resolve_name(node.id, frame, slots, ctx, depth)
392
+
393
+ if isinstance(node, ast.BinOp):
394
+ if isinstance(node.op, ast.Add):
395
+ return _resolve_expr(node.left, frame, slots, ctx, depth + 1) + _resolve_expr(
396
+ node.right, frame, slots, ctx, depth + 1
397
+ )
398
+ if isinstance(node.op, ast.Mod):
399
+ raw = _raw_literal(node.left, frame)
400
+ if raw is not None:
401
+ return _apply_percent(raw, node.right, frame, slots, ctx)
402
+ return _new_unresolved(slots, ctx)
403
+
404
+ if isinstance(node, ast.Call):
405
+ return _resolve_call(node, frame, slots, ctx, depth)
406
+
407
+ return _new_unresolved(slots, ctx)
408
+
409
+
410
+ def _resolve_name(name: str, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int) -> str:
411
+ if name in frame.env:
412
+ val_node, val_frame = frame.env[name]
413
+ return _resolve_expr(val_node, val_frame, slots, ctx, depth + 1)
414
+ if name in frame.module.module_consts:
415
+ val_node = frame.module.module_consts[name]
416
+ mod_frame = _Frame(module=frame.module, func=None, env={}, is_root=False)
417
+ return _resolve_expr(val_node, mod_frame, slots, ctx, depth + 1)
418
+ if frame.func is not None and frame.is_root and name in _param_names(frame.func):
419
+ return _new_slot(name, slots)
420
+ return _new_unresolved(slots, ctx)
421
+
422
+
423
+ def _resolve_call(node: ast.Call, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx, depth: int) -> str:
424
+ func = node.func
425
+
426
+ if isinstance(func, ast.Attribute):
427
+ if func.attr in ("strip", "lstrip", "rstrip") and not node.args and not node.keywords:
428
+ inner = _resolve_expr(func.value, frame, slots, ctx, depth + 1)
429
+ if func.attr == "strip":
430
+ return inner.strip()
431
+ if func.attr == "lstrip":
432
+ return inner.lstrip()
433
+ return inner.rstrip()
434
+ if func.attr == "format":
435
+ raw = _raw_literal(func.value, frame)
436
+ if raw is not None:
437
+ return _apply_format(raw, node.args, node.keywords, frame, slots, ctx)
438
+ return _new_unresolved(slots, ctx)
439
+
440
+ if isinstance(func, ast.Name) and func.id in frame.module.functions:
441
+ callee = frame.module.functions[func.id]
442
+ callee_frame = _make_callee_frame(frame.module, callee, node, frame)
443
+ ret = _find_return_expr(callee)
444
+ if ret is None:
445
+ return _new_unresolved(slots, ctx)
446
+ return _resolve_expr(ret, callee_frame, slots, ctx, depth + 1)
447
+
448
+ return _new_unresolved(slots, ctx)
449
+
450
+
451
+ def _raw_literal(node: ast.expr, frame: _Frame, depth: int = 0) -> str | None:
452
+ """Best-effort *unescaped* literal string, for use as a .format()/%
453
+ base template (whose braces/`%s` are meaningful, not literal text).
454
+ Returns None if it doesn't bottom out at a plain string constant."""
455
+ if depth > 20:
456
+ return None
457
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
458
+ return node.value
459
+ if isinstance(node, ast.Name):
460
+ if node.id in frame.env:
461
+ val_node, val_frame = frame.env[node.id]
462
+ return _raw_literal(val_node, val_frame, depth + 1)
463
+ if node.id in frame.module.module_consts:
464
+ val_node = frame.module.module_consts[node.id]
465
+ mod_frame = _Frame(module=frame.module, func=None, env={}, is_root=False)
466
+ return _raw_literal(val_node, mod_frame, depth + 1)
467
+ return None
468
+
469
+
470
+ _FORMAT_FIELD_RE = re.compile(r"\{([^{}]*)\}")
471
+
472
+
473
+ def _apply_format(
474
+ raw: str,
475
+ call_args: list[ast.expr],
476
+ call_keywords: list[ast.keyword],
477
+ frame: _Frame,
478
+ slots: dict[str, Slot],
479
+ ctx: _Ctx,
480
+ ) -> str:
481
+ kwmap = {kw.arg: kw.value for kw in call_keywords if kw.arg}
482
+ pos_args = list(call_args)
483
+ auto_idx = 0
484
+ out: list[str] = []
485
+ last = 0
486
+ for m in _FORMAT_FIELD_RE.finditer(raw):
487
+ out.append(_escape(raw[last:m.start()]))
488
+ last = m.end()
489
+ field = m.group(1).split("!", 1)[0].split(":", 1)[0]
490
+ arg_node: ast.expr | None
491
+ if field == "":
492
+ arg_node = pos_args[auto_idx] if auto_idx < len(pos_args) else None
493
+ auto_idx += 1
494
+ elif field.isdigit():
495
+ idx = int(field)
496
+ arg_node = pos_args[idx] if idx < len(pos_args) else None
497
+ else:
498
+ arg_node = kwmap.get(field)
499
+ if arg_node is None:
500
+ out.append(_new_unresolved(slots, ctx))
501
+ else:
502
+ out.append(_resolve_expr(arg_node, frame, slots, ctx))
503
+ out.append(_escape(raw[last:]))
504
+ return "".join(out)
505
+
506
+
507
+ _PERCENT_RE = re.compile(r"%(\(([^)]+)\))?[-+#0 ]*\d*(\.\d+)?[a-zA-Z%]")
508
+
509
+
510
+ def _apply_percent(raw: str, right: ast.expr, frame: _Frame, slots: dict[str, Slot], ctx: _Ctx) -> str:
511
+ positional: list[ast.expr] | None = None
512
+ dict_map: dict[str, ast.expr] = {}
513
+ if isinstance(right, ast.Tuple):
514
+ positional = list(right.elts)
515
+ elif isinstance(right, ast.Dict):
516
+ for k, v in zip(right.keys, right.values):
517
+ if isinstance(k, ast.Constant):
518
+ dict_map[k.value] = v
519
+ else:
520
+ positional = [right]
521
+
522
+ out: list[str] = []
523
+ last = 0
524
+ idx = 0
525
+ for m in _PERCENT_RE.finditer(raw):
526
+ out.append(_escape(raw[last:m.start()]))
527
+ last = m.end()
528
+ if m.group(0) == "%%":
529
+ out.append("%")
530
+ continue
531
+ name = m.group(2)
532
+ if name is not None:
533
+ arg_node = dict_map.get(name)
534
+ else:
535
+ arg_node = positional[idx] if positional is not None and idx < len(positional) else None
536
+ idx += 1
537
+ if arg_node is None:
538
+ out.append(_new_unresolved(slots, ctx))
539
+ else:
540
+ out.append(_resolve_expr(arg_node, frame, slots, ctx))
541
+ out.append(_escape(raw[last:]))
542
+ return "".join(out)