Spaces:
Paused
Paused
| """ | |
| project_manifest.py — ProjectManifest: multi-file coherence (S364) | |
| Traccia esportazioni/importazioni per file VFS per dare all'agente | |
| consapevolezza di quali file sono impattati quando uno cambia. | |
| Design: | |
| - Update fire-and-forget (mai blocca SSE) | |
| - Silent failures always | |
| - Regex-based, in-memory cache TTL 5min | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| import re, time, logging | |
| from typing import Any | |
| _manifest_logger = logging.getLogger("agente_ai") | |
| _MANIFEST_CACHE: dict[str, tuple[float, dict]] = {} | |
| _CACHE_TTL = 300 # 5 min | |
| _EXPORT_RE = re.compile( | |
| r'export\s+(?:default\s+)?(?:function|class|const|let|var|type|interface|enum)\s+(\w+)', | |
| re.MULTILINE) | |
| _IMPORT_RE = re.compile( | |
| r"""import\s+.*?from\s+['"](.*?)['"]""", | |
| re.MULTILINE) | |
| _PY_EXPORT_RE = re.compile(r'^(?:class|def)\s+([A-Za-z_]\w*)', re.MULTILINE) | |
| _PY_IMPORT_RE = re.compile(r'^(?:from\s+(\S+)\s+import|import\s+(\S+))', re.MULTILINE) | |
| def _extract_exports(content: str, language: str) -> list[str]: | |
| try: | |
| lang = (language or '').lower() | |
| if lang in ('typescript', 'ts', 'tsx', 'javascript', 'js', 'jsx'): | |
| return _EXPORT_RE.findall(content)[:20] | |
| elif lang in ('python', 'py'): | |
| return _PY_EXPORT_RE.findall(content)[:20] | |
| except Exception as _e: | |
| _manifest_logger.debug("project_manifest extract: %s", _e) | |
| return [] | |
| def _extract_imports(content: str, language: str) -> list[str]: | |
| try: | |
| lang = (language or '').lower() | |
| if lang in ('typescript', 'ts', 'tsx', 'javascript', 'js', 'jsx'): | |
| return _IMPORT_RE.findall(content)[:20] | |
| elif lang in ('python', 'py'): | |
| matches = _PY_IMPORT_RE.findall(content) | |
| return [m[0] or m[1] for m in matches if m[0] or m[1]][:20] | |
| except Exception as _e: | |
| _manifest_logger.debug("project_manifest extract: %s", _e) | |
| return [] | |
| def _get_cached(conversation_id: str) -> dict | None: | |
| entry = _MANIFEST_CACHE.get(conversation_id) | |
| if entry and (time.time() - entry[0]) < _CACHE_TTL: | |
| return entry[1] | |
| return None | |
| def _set_cached(conversation_id: str, manifest: dict) -> None: | |
| _MANIFEST_CACHE[conversation_id] = (time.time(), manifest) | |
| if len(_MANIFEST_CACHE) > 50: | |
| oldest = min(_MANIFEST_CACHE.keys(), key=lambda k: _MANIFEST_CACHE[k][0]) | |
| del _MANIFEST_CACHE[oldest] | |
| async def update_manifest(conversation_id: str, file_path: str, | |
| content: str, language: str) -> None: | |
| """Aggiorna il manifest per un singolo file. Fire-and-forget safe.""" | |
| if not conversation_id or not file_path: | |
| return | |
| try: | |
| manifest = _get_cached(conversation_id) or {} | |
| manifest[file_path] = { | |
| 'exports': _extract_exports(content, language), | |
| 'imports': _extract_imports(content, language), | |
| 'lines': content.count('\n') + 1, | |
| 'updated_at': int(time.time()), | |
| } | |
| _set_cached(conversation_id, manifest) | |
| except Exception: | |
| pass # S364: silent failure always | |
| async def get_impacted_files(conversation_id: str, changed_path: str) -> list[str]: | |
| """ | |
| Ritorna file path che importano da changed_path. | |
| Usato per avvertire l'agente dei file che potrebbero richiedere aggiornamenti. | |
| """ | |
| try: | |
| manifest = _get_cached(conversation_id) | |
| if not manifest: | |
| return [] | |
| base = changed_path.lstrip('./').rsplit('.', 1)[0] | |
| impacted = [] | |
| for path, info in manifest.items(): | |
| if path == changed_path: | |
| continue | |
| for imp in info.get('imports', []): | |
| imp_norm = imp.lstrip('./').rsplit('.', 1)[0] | |
| if imp_norm == base or imp.endswith('/' + base.split('/')[-1]): | |
| impacted.append(path) | |
| break | |
| return impacted[:10] | |
| except Exception: | |
| return [] | |
| async def get_skeleton(conversation_id: str) -> str: | |
| """ | |
| Ritorna lo skeleton compatto del progetto: path + exports. | |
| Fornisce all'agente consapevolezza del progetto senza contenuto completo. | |
| """ | |
| try: | |
| manifest = _get_cached(conversation_id) | |
| if not manifest: | |
| return '' | |
| lines = ['=== PROJECT SKELETON ==='] | |
| for path, info in sorted(manifest.items()): | |
| exports = info.get('exports', []) | |
| n_lines = info.get('lines', 0) | |
| exp_str = ', '.join(exports[:5]) if exports else '(no exports)' | |
| lines.append(f'\u2022 {path} ({n_lines}L) \u2192 {exp_str}') | |
| lines.append('=== END SKELETON ===') | |
| return '\n'.join(lines) | |
| except Exception: | |
| return '' | |
| async def build_manifest_from_vfs(conversation_id: str) -> None: | |
| """ | |
| Ricostruisce il manifest da tutti i file VFS. Chiamato lazy. | |
| Fire-and-forget. | |
| """ | |
| try: | |
| from .state import sb | |
| data = sb().table('vfs_files').select( | |
| 'path, content, language' | |
| ).eq('conversation_id', conversation_id).execute() | |
| files = data.data or [] | |
| manifest = {} | |
| for f in files: | |
| path = f.get('path', '') | |
| content = f.get('content', '') or '' | |
| language = f.get('language', '') or '' | |
| if path and content: | |
| manifest[path] = { | |
| 'exports': _extract_exports(content, language), | |
| 'imports': _extract_imports(content, language), | |
| 'lines': content.count('\n') + 1, | |
| 'updated_at': int(time.time()), | |
| } | |
| if manifest: | |
| _set_cached(conversation_id, manifest) | |
| except Exception: | |
| pass # S364: silent failure always | |