differix 0.1.0a1__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.
- agents/editor.py +455 -0
- agents/researcher.py +405 -0
- agents/reviewer.py +374 -0
- cli/model_cli.py +430 -0
- config.py +557 -0
- differix-0.1.0a1.dist-info/METADATA +123 -0
- differix-0.1.0a1.dist-info/RECORD +62 -0
- differix-0.1.0a1.dist-info/WHEEL +5 -0
- differix-0.1.0a1.dist-info/entry_points.txt +2 -0
- differix-0.1.0a1.dist-info/top_level.txt +6 -0
- main.py +2796 -0
- memory/__init__.py +59 -0
- memory/embeddings/__init__.py +29 -0
- memory/embeddings/factory.py +178 -0
- memory/embeddings/providers.py +259 -0
- memory/manager.py +186 -0
- memory/migrator.py +253 -0
- memory/ranker.py +203 -0
- memory/schemas.py +127 -0
- memory/storage.py +552 -0
- tools/agents_chat.py +152 -0
- tools/asset_extractor.py +545 -0
- tools/asset_manager.py +115 -0
- tools/atomic_changeset.py +264 -0
- tools/backup_manager.py +122 -0
- tools/changelog_engine.py +261 -0
- tools/coherence_checker.py +771 -0
- tools/compiler.py +531 -0
- tools/component_normalizer.py +130 -0
- tools/context_assembler.py +631 -0
- tools/contract_validator.py +478 -0
- tools/cost_predictor.py +141 -0
- tools/drift_guard.py +196 -0
- tools/file_manager.py +119 -0
- tools/handshake.py +351 -0
- tools/lesson_manager.py +570 -0
- tools/lesson_memory_bridge.py +398 -0
- tools/llm_client.py +103 -0
- tools/llm_selector.py +362 -0
- tools/model_ranker.py +535 -0
- tools/palette_extractor.py +1043 -0
- tools/plugin_manager.py +204 -0
- tools/project_context_gatherer.py +404 -0
- tools/project_scanner.py +41 -0
- tools/project_structure.py +244 -0
- tools/reasoning_logger.py +78 -0
- tools/reflection.py +452 -0
- tools/sandbox_utils.py +56 -0
- tools/security/__init__.py +0 -0
- tools/security/behavior_guard.py +83 -0
- tools/security/verification_firewall.py +113 -0
- tools/security_filter.py +213 -0
- tools/session_adr.py +311 -0
- tools/skill_manager.py +129 -0
- tools/state_manager.py +383 -0
- tools/structure_index.py +570 -0
- tools/test_schema_consistency.py +19 -0
- tools/verification_firewall.py +113 -0
- tools/visual_capture.py +65 -0
- tools/visual_diff.py +220 -0
- tools/visual_reviewer.py +79 -0
- tools/web_search.py +371 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/contract_validator.py
|
|
3
|
+
----------------------------
|
|
4
|
+
Pre-write AST validation against the current structure index.
|
|
5
|
+
|
|
6
|
+
Runs before any edit is written to disk. If a proposed change set contains
|
|
7
|
+
calls to methods that don't exist, or imports that resolve to nothing, it
|
|
8
|
+
returns a structured report. main.py decides whether to block the write.
|
|
9
|
+
|
|
10
|
+
Design principles:
|
|
11
|
+
1. Fail-open on internal error. A bug in the validator must never block
|
|
12
|
+
legitimate work; the sandbox is still the authority.
|
|
13
|
+
2. Confidence-scored checks. Anything below 0.9 is a warning, not an error.
|
|
14
|
+
3. Escape hatch: `# vegex: noqa-contract` on a line disables checks for
|
|
15
|
+
that line. `# vegex: ignore-dynamic` at module top disables the
|
|
16
|
+
dynamic-dispatch heuristics entirely.
|
|
17
|
+
4. Fresh index, per Option-1 override — validator builds its own
|
|
18
|
+
StructureIndex.rebuild() internally so there's no staleness window.
|
|
19
|
+
|
|
20
|
+
Public API:
|
|
21
|
+
- ContractValidator(project_root).validate_change_set(change_set) -> ValidationReport
|
|
22
|
+
- ContractValidationError (raised only by strict mode; default is off)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import ast
|
|
28
|
+
import logging
|
|
29
|
+
import time
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
33
|
+
|
|
34
|
+
from tools.structure_index import StructureIndex, ModuleInfo, _path_to_module
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ContractValidationError(Exception):
|
|
40
|
+
"""Raised by strict mode when the report contains blocking errors."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ValidationIssue:
|
|
45
|
+
path: str
|
|
46
|
+
line: int
|
|
47
|
+
col: int
|
|
48
|
+
code: str
|
|
49
|
+
message: str
|
|
50
|
+
severity: str # "error" | "warning"
|
|
51
|
+
confidence: float
|
|
52
|
+
|
|
53
|
+
def as_dict(self) -> Dict:
|
|
54
|
+
return {
|
|
55
|
+
"path": self.path,
|
|
56
|
+
"line": self.line,
|
|
57
|
+
"col": self.col,
|
|
58
|
+
"code": self.code,
|
|
59
|
+
"message": self.message,
|
|
60
|
+
"severity": self.severity,
|
|
61
|
+
"confidence": round(self.confidence, 3),
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class ValidationReport:
|
|
67
|
+
issues: List[ValidationIssue] = field(default_factory=list)
|
|
68
|
+
files_checked: int = 0
|
|
69
|
+
elapsed_ms: float = 0.0
|
|
70
|
+
internal_error: Optional[str] = None
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def errors(self) -> List[ValidationIssue]:
|
|
74
|
+
return [i for i in self.issues if i.severity == "error"]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def warnings(self) -> List[ValidationIssue]:
|
|
78
|
+
return [i for i in self.issues if i.severity == "warning"]
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def ok(self) -> bool:
|
|
82
|
+
return not self.errors and not self.internal_error
|
|
83
|
+
|
|
84
|
+
def as_dict(self) -> Dict:
|
|
85
|
+
return {
|
|
86
|
+
"ok": self.ok,
|
|
87
|
+
"errors": [i.as_dict() for i in self.errors],
|
|
88
|
+
"warnings": [i.as_dict() for i in self.warnings],
|
|
89
|
+
"files_checked": self.files_checked,
|
|
90
|
+
"elapsed_ms": round(self.elapsed_ms, 2),
|
|
91
|
+
"internal_error": self.internal_error,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# Builtin names we never flag
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
import builtins as _builtins_module
|
|
99
|
+
_BUILTIN_NAMES: Set[str] = set(dir(_builtins_module))
|
|
100
|
+
|
|
101
|
+
_COMMON_NAMES: Set[str] = {
|
|
102
|
+
"self", "cls", "True", "False", "None", "NotImplemented", "Ellipsis",
|
|
103
|
+
"__name__", "__file__", "__doc__", "__package__", "__spec__", "__loader__",
|
|
104
|
+
"__builtins__", "__debug__", "__all__",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class ContractValidator:
|
|
109
|
+
"""
|
|
110
|
+
Validates proposed change sets against a freshly-built structure index.
|
|
111
|
+
|
|
112
|
+
The validator rebuilds the index on every call. Cost: ~20-200ms for a
|
|
113
|
+
typical project. Benefit: no staleness window, no LLM retries triggered
|
|
114
|
+
by a stale contract check.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
project_root: str,
|
|
120
|
+
min_error_confidence: float = 0.9,
|
|
121
|
+
rebuild_index_every_call: bool = True,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.project_root = str(Path(project_root).resolve())
|
|
124
|
+
self.min_error_confidence = min_error_confidence
|
|
125
|
+
self.rebuild_index_every_call = rebuild_index_every_call
|
|
126
|
+
self._cached_index: Optional[StructureIndex] = None
|
|
127
|
+
|
|
128
|
+
# ------------------------------------------------------------------ #
|
|
129
|
+
# Public entry point
|
|
130
|
+
# ------------------------------------------------------------------ #
|
|
131
|
+
def validate_change_set(self, change_set) -> ValidationReport:
|
|
132
|
+
"""
|
|
133
|
+
Validate a ChangeSet. Never raises unless the caller sets strict
|
|
134
|
+
mode (default: fail-open, always returns a report).
|
|
135
|
+
|
|
136
|
+
`change_set` must implement `dry_apply(project_root) -> Dict[path, content]`.
|
|
137
|
+
"""
|
|
138
|
+
t0 = time.perf_counter()
|
|
139
|
+
report = ValidationReport()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
post_apply = change_set.dry_apply(self.project_root)
|
|
143
|
+
except Exception as exc:
|
|
144
|
+
report.internal_error = f"dry_apply failed: {exc}"
|
|
145
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
146
|
+
return report
|
|
147
|
+
|
|
148
|
+
if not post_apply:
|
|
149
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
150
|
+
return report
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
index = self._get_index()
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
report.internal_error = f"index build failed: {exc}"
|
|
156
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
157
|
+
return report
|
|
158
|
+
|
|
159
|
+
report.files_checked = len(post_apply)
|
|
160
|
+
|
|
161
|
+
for rel_path, content in post_apply.items():
|
|
162
|
+
if not rel_path.endswith(".py"):
|
|
163
|
+
continue
|
|
164
|
+
try:
|
|
165
|
+
self._validate_python_file(rel_path, content, index, report)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
logger.warning("Validation error on %s: %s", rel_path, exc)
|
|
168
|
+
|
|
169
|
+
report.elapsed_ms = (time.perf_counter() - t0) * 1000.0
|
|
170
|
+
return report
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ #
|
|
173
|
+
# Index management
|
|
174
|
+
# ------------------------------------------------------------------ #
|
|
175
|
+
def _get_index(self) -> StructureIndex:
|
|
176
|
+
if self.rebuild_index_every_call or self._cached_index is None:
|
|
177
|
+
self._cached_index = StructureIndex.rebuild(self.project_root)
|
|
178
|
+
return self._cached_index
|
|
179
|
+
|
|
180
|
+
# ------------------------------------------------------------------ #
|
|
181
|
+
# Python file validation
|
|
182
|
+
# ------------------------------------------------------------------ #
|
|
183
|
+
def _validate_python_file(
|
|
184
|
+
self,
|
|
185
|
+
rel_path: str,
|
|
186
|
+
content: str,
|
|
187
|
+
index: StructureIndex,
|
|
188
|
+
report: ValidationReport,
|
|
189
|
+
) -> None:
|
|
190
|
+
if _module_has_dynamic_opt_out(content):
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
tree = ast.parse(content, filename=rel_path)
|
|
195
|
+
except SyntaxError as exc:
|
|
196
|
+
report.issues.append(ValidationIssue(
|
|
197
|
+
path=rel_path,
|
|
198
|
+
line=exc.lineno or 1,
|
|
199
|
+
col=exc.offset or 1,
|
|
200
|
+
code="syntax_error",
|
|
201
|
+
message=f"Syntax error: {exc.msg}",
|
|
202
|
+
severity="error",
|
|
203
|
+
confidence=1.0,
|
|
204
|
+
))
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
local_names = _collect_local_names(tree)
|
|
208
|
+
imported_symbols = _collect_imported_symbols(tree)
|
|
209
|
+
import_module_aliases = _collect_import_module_aliases(tree)
|
|
210
|
+
|
|
211
|
+
for node in ast.walk(tree):
|
|
212
|
+
if _has_noqa(node, content):
|
|
213
|
+
continue
|
|
214
|
+
if isinstance(node, ast.Call):
|
|
215
|
+
self._check_call(
|
|
216
|
+
node, rel_path, index,
|
|
217
|
+
local_names, imported_symbols, import_module_aliases,
|
|
218
|
+
report,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# ------------------------------------------------------------------ #
|
|
222
|
+
# Call-site checks
|
|
223
|
+
# ------------------------------------------------------------------ #
|
|
224
|
+
def _check_call(
|
|
225
|
+
self,
|
|
226
|
+
node: ast.Call,
|
|
227
|
+
rel_path: str,
|
|
228
|
+
index: StructureIndex,
|
|
229
|
+
local_names: Set[str],
|
|
230
|
+
imported_symbols: Dict[str, Tuple[str, str]],
|
|
231
|
+
module_aliases: Dict[str, str],
|
|
232
|
+
report: ValidationReport,
|
|
233
|
+
) -> None:
|
|
234
|
+
func = node.func
|
|
235
|
+
|
|
236
|
+
# Case A: bare Name — e.g. `foo(...)`
|
|
237
|
+
if isinstance(func, ast.Name):
|
|
238
|
+
name = func.id
|
|
239
|
+
if name in _BUILTIN_NAMES or name in _COMMON_NAMES:
|
|
240
|
+
return
|
|
241
|
+
if name in local_names:
|
|
242
|
+
return
|
|
243
|
+
if name in imported_symbols:
|
|
244
|
+
src_module, original_name = imported_symbols[name]
|
|
245
|
+
src_mod_info = index.modules.get(src_module)
|
|
246
|
+
if src_mod_info is None or src_mod_info.syntax_error:
|
|
247
|
+
return # can't check → fail-open
|
|
248
|
+
if not _module_exports(src_mod_info, original_name):
|
|
249
|
+
alias_note = (
|
|
250
|
+
f" (imported as '{name}')" if name != original_name else ""
|
|
251
|
+
)
|
|
252
|
+
report.issues.append(ValidationIssue(
|
|
253
|
+
path=rel_path,
|
|
254
|
+
line=node.lineno,
|
|
255
|
+
col=node.col_offset,
|
|
256
|
+
code="imported_name_missing",
|
|
257
|
+
message=(
|
|
258
|
+
f"Name '{original_name}' is imported from "
|
|
259
|
+
f"'{src_module}'{alias_note}, but that module "
|
|
260
|
+
f"does not define or export it."
|
|
261
|
+
),
|
|
262
|
+
severity="error",
|
|
263
|
+
confidence=0.9,
|
|
264
|
+
))
|
|
265
|
+
return
|
|
266
|
+
# Unknown name — could be dynamic; skip.
|
|
267
|
+
|
|
268
|
+
# Case B: Attribute — e.g. `module.func(...)` or `obj.method(...)`
|
|
269
|
+
elif isinstance(func, ast.Attribute):
|
|
270
|
+
chain = _attr_chain(func)
|
|
271
|
+
if chain is None or len(chain) < 2:
|
|
272
|
+
return
|
|
273
|
+
root = chain[0]
|
|
274
|
+
if root in module_aliases:
|
|
275
|
+
module_path = module_aliases[root]
|
|
276
|
+
target_module = index.modules.get(module_path)
|
|
277
|
+
if target_module is None or target_module.syntax_error:
|
|
278
|
+
return
|
|
279
|
+
attr_name = chain[-1]
|
|
280
|
+
if not _module_exports(target_module, attr_name):
|
|
281
|
+
report.issues.append(ValidationIssue(
|
|
282
|
+
path=rel_path,
|
|
283
|
+
line=node.lineno,
|
|
284
|
+
col=node.col_offset,
|
|
285
|
+
code="call_to_missing_name",
|
|
286
|
+
message=(
|
|
287
|
+
f"Call to '{'.'.join(chain)}' but "
|
|
288
|
+
f"'{module_path}' does not define '{attr_name}'."
|
|
289
|
+
),
|
|
290
|
+
severity="error",
|
|
291
|
+
confidence=0.85,
|
|
292
|
+
))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Helpers
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
def _module_has_dynamic_opt_out(content: str) -> bool:
|
|
299
|
+
"""Check first 3 lines for `# vegex: ignore-dynamic`."""
|
|
300
|
+
for i, line in enumerate(content.splitlines()):
|
|
301
|
+
if i > 3:
|
|
302
|
+
break
|
|
303
|
+
if "vegex: ignore-dynamic" in line:
|
|
304
|
+
return True
|
|
305
|
+
return False
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _has_noqa(node: ast.AST, content: str) -> bool:
|
|
309
|
+
"""Check whether the node's source line contains `# vegex: noqa-contract`."""
|
|
310
|
+
lineno = getattr(node, "lineno", None)
|
|
311
|
+
if lineno is None:
|
|
312
|
+
return False
|
|
313
|
+
lines = content.splitlines()
|
|
314
|
+
if 1 <= lineno <= len(lines):
|
|
315
|
+
return "vegex: noqa-contract" in lines[lineno - 1]
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _collect_local_names(tree: ast.Module) -> Set[str]:
|
|
320
|
+
names: Set[str] = set()
|
|
321
|
+
|
|
322
|
+
class _Collector(ast.NodeVisitor):
|
|
323
|
+
def visit_FunctionDef(self, node):
|
|
324
|
+
names.add(node.name)
|
|
325
|
+
for arg in _iter_all_args(node.args):
|
|
326
|
+
names.add(arg.arg)
|
|
327
|
+
self.generic_visit(node)
|
|
328
|
+
|
|
329
|
+
def visit_AsyncFunctionDef(self, node):
|
|
330
|
+
names.add(node.name)
|
|
331
|
+
for arg in _iter_all_args(node.args):
|
|
332
|
+
names.add(arg.arg)
|
|
333
|
+
self.generic_visit(node)
|
|
334
|
+
|
|
335
|
+
def visit_ClassDef(self, node):
|
|
336
|
+
names.add(node.name)
|
|
337
|
+
self.generic_visit(node)
|
|
338
|
+
|
|
339
|
+
def visit_Assign(self, node):
|
|
340
|
+
for t in node.targets:
|
|
341
|
+
for sub in ast.walk(t):
|
|
342
|
+
if isinstance(sub, ast.Name):
|
|
343
|
+
names.add(sub.id)
|
|
344
|
+
self.generic_visit(node)
|
|
345
|
+
|
|
346
|
+
def visit_AnnAssign(self, node):
|
|
347
|
+
if isinstance(node.target, ast.Name):
|
|
348
|
+
names.add(node.target.id)
|
|
349
|
+
self.generic_visit(node)
|
|
350
|
+
|
|
351
|
+
def visit_For(self, node):
|
|
352
|
+
for sub in ast.walk(node.target):
|
|
353
|
+
if isinstance(sub, ast.Name):
|
|
354
|
+
names.add(sub.id)
|
|
355
|
+
self.generic_visit(node)
|
|
356
|
+
|
|
357
|
+
def visit_With(self, node):
|
|
358
|
+
for item in node.items:
|
|
359
|
+
if item.optional_vars is not None:
|
|
360
|
+
for sub in ast.walk(item.optional_vars):
|
|
361
|
+
if isinstance(sub, ast.Name):
|
|
362
|
+
names.add(sub.id)
|
|
363
|
+
self.generic_visit(node)
|
|
364
|
+
|
|
365
|
+
def visit_ExceptHandler(self, node):
|
|
366
|
+
if node.name:
|
|
367
|
+
names.add(node.name)
|
|
368
|
+
self.generic_visit(node)
|
|
369
|
+
|
|
370
|
+
def visit_comprehension(self, node):
|
|
371
|
+
for sub in ast.walk(node.target):
|
|
372
|
+
if isinstance(sub, ast.Name):
|
|
373
|
+
names.add(sub.id)
|
|
374
|
+
self.generic_visit(node)
|
|
375
|
+
|
|
376
|
+
def visit_Lambda(self, node):
|
|
377
|
+
for arg in _iter_all_args(node.args):
|
|
378
|
+
names.add(arg.arg)
|
|
379
|
+
self.generic_visit(node)
|
|
380
|
+
|
|
381
|
+
_Collector().visit(tree)
|
|
382
|
+
return names
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _iter_all_args(args: ast.arguments):
|
|
386
|
+
yield from args.posonlyargs
|
|
387
|
+
yield from args.args
|
|
388
|
+
yield from args.kwonlyargs
|
|
389
|
+
if args.vararg:
|
|
390
|
+
yield args.vararg
|
|
391
|
+
if args.kwarg:
|
|
392
|
+
yield args.kwarg
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _collect_imported_symbols(tree: ast.Module) -> Dict[str, Tuple[str, str]]:
|
|
396
|
+
"""
|
|
397
|
+
Map local imported name -> (source_module, original_symbol_name).
|
|
398
|
+
|
|
399
|
+
Handles both `from X import Y` and `from X import Y as Z`:
|
|
400
|
+
- For `from X import Y`, out["Y"] = ("X", "Y")
|
|
401
|
+
- For `from X import Y as Z`, out["Z"] = ("X", "Y")
|
|
402
|
+
|
|
403
|
+
Only absolute imports are tracked. Relative imports (level > 0) fail open
|
|
404
|
+
because we cannot resolve them without package context.
|
|
405
|
+
"""
|
|
406
|
+
out: Dict[str, Tuple[str, str]] = {}
|
|
407
|
+
|
|
408
|
+
for node in ast.walk(tree):
|
|
409
|
+
if not isinstance(node, ast.ImportFrom):
|
|
410
|
+
continue
|
|
411
|
+
if node.level and not node.module:
|
|
412
|
+
continue
|
|
413
|
+
module = node.module or ""
|
|
414
|
+
if node.level:
|
|
415
|
+
# Relative imports: skip to avoid false positives
|
|
416
|
+
continue
|
|
417
|
+
for alias in node.names:
|
|
418
|
+
local = alias.asname or alias.name
|
|
419
|
+
original = alias.name
|
|
420
|
+
out[local] = (module, original)
|
|
421
|
+
return out
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _collect_import_module_aliases(tree: ast.Module) -> Dict[str, str]:
|
|
425
|
+
"""
|
|
426
|
+
Map local alias -> module path for `import X`, `import X as Y`,
|
|
427
|
+
`import a.b.c as Y`.
|
|
428
|
+
|
|
429
|
+
For `import X.Y` without alias, we register only the root: X -> "X.Y".
|
|
430
|
+
That matches Python's own binding behavior.
|
|
431
|
+
"""
|
|
432
|
+
out: Dict[str, str] = {}
|
|
433
|
+
for node in ast.walk(tree):
|
|
434
|
+
if isinstance(node, ast.Import):
|
|
435
|
+
for alias in node.names:
|
|
436
|
+
local = alias.asname or alias.name.split(".")[0]
|
|
437
|
+
out[local] = alias.name
|
|
438
|
+
return out
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _module_exports(mod: ModuleInfo, name: str) -> bool:
|
|
442
|
+
"""
|
|
443
|
+
Does `mod` define or export `name`?
|
|
444
|
+
|
|
445
|
+
Checks:
|
|
446
|
+
- top-level def/class/assignment
|
|
447
|
+
- explicit names in __all__ (best-effort via top_level_names)
|
|
448
|
+
- names re-imported into the module (re-exports)
|
|
449
|
+
"""
|
|
450
|
+
if name in mod.top_level_names:
|
|
451
|
+
return True
|
|
452
|
+
if name in mod.functions or name in mod.classes:
|
|
453
|
+
return True
|
|
454
|
+
for imp in mod.imports:
|
|
455
|
+
if name in imp.names:
|
|
456
|
+
return True
|
|
457
|
+
if name in imp.alias_of_names:
|
|
458
|
+
return True
|
|
459
|
+
return False
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _attr_chain(node: ast.AST) -> Optional[Tuple[str, ...]]:
|
|
463
|
+
parts: List[str] = []
|
|
464
|
+
while isinstance(node, ast.Attribute):
|
|
465
|
+
parts.append(node.attr)
|
|
466
|
+
node = node.value
|
|
467
|
+
if isinstance(node, ast.Name):
|
|
468
|
+
parts.append(node.id)
|
|
469
|
+
return tuple(reversed(parts))
|
|
470
|
+
return None
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
__all__ = [
|
|
474
|
+
"ContractValidator",
|
|
475
|
+
"ContractValidationError",
|
|
476
|
+
"ValidationReport",
|
|
477
|
+
"ValidationIssue",
|
|
478
|
+
]
|
tools/cost_predictor.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
tools/cost_predictor.py
|
|
3
|
+
-----------------------
|
|
4
|
+
Lightweight pre-flight cost estimator for the VegeX pipeline.
|
|
5
|
+
|
|
6
|
+
Given a researcher plan (a list of step dicts) it routes each step to a
|
|
7
|
+
model via the same complexity assessment used at call time, prices the
|
|
8
|
+
expected token budget with that model's published rates, and returns a
|
|
9
|
+
per-step breakdown plus a padded grand total.
|
|
10
|
+
|
|
11
|
+
Public API:
|
|
12
|
+
predict_cost(plan: list[dict], task_prompt: str = "") -> dict
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any, Dict, List
|
|
18
|
+
|
|
19
|
+
from tools.llm_selector import (
|
|
20
|
+
assess_task_complexity,
|
|
21
|
+
select_model_for_task,
|
|
22
|
+
get_model_details,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
INPUT_TOKENS_FIXED: int = 6000
|
|
27
|
+
DEFAULT_ESTIMATED_LINES: int = 120
|
|
28
|
+
MIN_ESTIMATED_LINES: int = 50
|
|
29
|
+
TOKENS_PER_LINE: int = 20
|
|
30
|
+
OVERHEAD_MULTIPLIER: float = 1.3
|
|
31
|
+
DEFAULT_COMPLEXITY: str = "medium"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
__all__ = ["predict_cost"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _safe_complexity(text: str) -> Any:
|
|
38
|
+
try:
|
|
39
|
+
result = assess_task_complexity(text)
|
|
40
|
+
except Exception:
|
|
41
|
+
return DEFAULT_COMPLEXITY
|
|
42
|
+
if result is None:
|
|
43
|
+
return DEFAULT_COMPLEXITY
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _safe_model(complexity: Any) -> str:
|
|
48
|
+
try:
|
|
49
|
+
model = select_model_for_task(complexity)
|
|
50
|
+
except Exception:
|
|
51
|
+
return ""
|
|
52
|
+
return model or ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _safe_pricing(model: str) -> Dict[str, Any]:
|
|
56
|
+
if not model:
|
|
57
|
+
return {}
|
|
58
|
+
try:
|
|
59
|
+
pricing = get_model_details(model)
|
|
60
|
+
except Exception:
|
|
61
|
+
return {}
|
|
62
|
+
if not isinstance(pricing, dict):
|
|
63
|
+
return {}
|
|
64
|
+
return pricing
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _safe_float(value: Any) -> float:
|
|
68
|
+
try:
|
|
69
|
+
if value is None:
|
|
70
|
+
return 0.0
|
|
71
|
+
return float(value)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return 0.0
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_estimated_lines(raw: Any) -> int:
|
|
77
|
+
try:
|
|
78
|
+
value = int(raw) if raw is not None else None
|
|
79
|
+
except (TypeError, ValueError):
|
|
80
|
+
value = None
|
|
81
|
+
if not value:
|
|
82
|
+
value = DEFAULT_ESTIMATED_LINES
|
|
83
|
+
return max(value, MIN_ESTIMATED_LINES)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _step_fields(step: Any) -> tuple:
|
|
87
|
+
if isinstance(step, dict):
|
|
88
|
+
return step.get("title"), step.get("id"), step.get("estimated_lines")
|
|
89
|
+
return None, None, None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def predict_cost(plan: List[Dict[str, Any]], task_prompt: str = "") -> Dict[str, Any]:
|
|
93
|
+
if not isinstance(plan, (list, tuple)):
|
|
94
|
+
return {"total_usd": 0.0, "by_step": []}
|
|
95
|
+
|
|
96
|
+
by_step: List[Dict[str, Any]] = []
|
|
97
|
+
|
|
98
|
+
for index, step in enumerate(plan):
|
|
99
|
+
title, step_id, raw_lines = _step_fields(step)
|
|
100
|
+
|
|
101
|
+
if title is None or not str(title).strip():
|
|
102
|
+
task_text = task_prompt or ""
|
|
103
|
+
display_title = ""
|
|
104
|
+
else:
|
|
105
|
+
task_text = str(title)
|
|
106
|
+
display_title = str(title)
|
|
107
|
+
|
|
108
|
+
if step_id is None:
|
|
109
|
+
step_id = index + 1
|
|
110
|
+
|
|
111
|
+
complexity = _safe_complexity(task_text)
|
|
112
|
+
model = _safe_model(complexity)
|
|
113
|
+
pricing = _safe_pricing(model)
|
|
114
|
+
|
|
115
|
+
input_tokens = INPUT_TOKENS_FIXED
|
|
116
|
+
estimated_lines = _resolve_estimated_lines(raw_lines)
|
|
117
|
+
output_tokens = estimated_lines * TOKENS_PER_LINE
|
|
118
|
+
|
|
119
|
+
price_input = _safe_float(pricing.get("price_input_1m"))
|
|
120
|
+
price_output = _safe_float(pricing.get("price_output_1m"))
|
|
121
|
+
|
|
122
|
+
cost_usd = (
|
|
123
|
+
(input_tokens * price_input / 1_000_000.0)
|
|
124
|
+
+ (output_tokens * price_output / 1_000_000.0)
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
by_step.append(
|
|
128
|
+
{
|
|
129
|
+
"step_id": step_id,
|
|
130
|
+
"title": display_title,
|
|
131
|
+
"model": model,
|
|
132
|
+
"complexity": complexity,
|
|
133
|
+
"input_tokens": input_tokens,
|
|
134
|
+
"output_tokens": output_tokens,
|
|
135
|
+
"cost_usd": cost_usd,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
total_usd = sum(entry["cost_usd"] for entry in by_step) * OVERHEAD_MULTIPLIER
|
|
140
|
+
|
|
141
|
+
return {"total_usd": total_usd, "by_step": by_step}
|