diff --git a/ai_software_factory/agents/database_manager.py b/ai_software_factory/agents/database_manager.py index 88ae18c..db338e7 100644 --- a/ai_software_factory/agents/database_manager.py +++ b/ai_software_factory/agents/database_manager.py @@ -2,10 +2,33 @@ from sqlalchemy.orm import Session from sqlalchemy import text -from database import get_db -from models import ( - ProjectHistory, ProjectLog, UISnapshot, PullRequestData, SystemLog, UserAction, AuditTrail, PullRequest, ProjectStatus -) + +try: + from ..models import ( + AuditTrail, + ProjectHistory, + ProjectLog, + ProjectStatus, + PromptCodeLink, + PullRequest, + PullRequestData, + SystemLog, + UISnapshot, + UserAction, + ) +except ImportError: + from models import ( + AuditTrail, + ProjectHistory, + ProjectLog, + ProjectStatus, + PromptCodeLink, + PullRequest, + PullRequestData, + SystemLog, + UISnapshot, + UserAction, + ) from datetime import datetime import json @@ -61,6 +84,21 @@ class DatabaseManager: self.db = db self.migrations = DatabaseMigrations(self.db) + @staticmethod + def _normalize_metadata(metadata: object) -> dict: + """Normalize JSON-like metadata stored in audit columns.""" + if metadata is None: + return {} + if isinstance(metadata, dict): + return metadata + if isinstance(metadata, str): + try: + parsed = json.loads(metadata) + return parsed if isinstance(parsed, dict) else {"value": parsed} + except json.JSONDecodeError: + return {"value": metadata} + return {"value": metadata} + def log_project_start(self, project_id: str, project_name: str, description: str) -> ProjectHistory: """Log project start.""" history = ProjectHistory( @@ -87,6 +125,63 @@ class DatabaseManager: return history + def log_prompt_submission( + self, + history_id: int, + project_id: str, + prompt_text: str, + features: list[str] | None = None, + tech_stack: list[str] | None = None, + actor_name: str = "api", + actor_type: str = "user", + source: str = "generate-endpoint", + ) -> AuditTrail | None: + """Persist the originating prompt so later code changes can be correlated to it.""" + history = self.db.query(ProjectHistory).filter(ProjectHistory.id == history_id).first() + if not history: + return None + + feature_list = features or [] + tech_list = tech_stack or [] + history.features = json.dumps(feature_list) + history.current_step_description = "Prompt accepted" + history.current_step_details = prompt_text + self.db.commit() + + self.log_user_action( + history_id=history_id, + action_type="PROMPT_SUBMITTED", + actor_type=actor_type, + actor_name=actor_name, + action_description="Submitted software generation request", + action_data={ + "prompt": prompt_text, + "features": feature_list, + "tech_stack": tech_list, + "source": source, + }, + ) + + audit = AuditTrail( + project_id=project_id, + action="PROMPT_RECEIVED", + actor=actor_name, + action_type="PROMPT", + details=prompt_text, + message="Software generation prompt received", + metadata_json={ + "history_id": history_id, + "prompt_text": prompt_text, + "features": feature_list, + "tech_stack": tech_list, + "source": source, + }, + ) + self.db.add(audit) + self.db.commit() + self.db.refresh(audit) + return audit + def log_progress_update(self, history_id: int, progress: int, step: str, message: str) -> None: """Log progress update.""" history = self.db.query(ProjectHistory).filter( @@ -121,6 +216,8 @@ class DatabaseManager: if history: history.status = ProjectStatus.COMPLETED.value + history.progress = 100 + history.current_step = "Completed" history.completed_at = datetime.utcnow() history.message = message self.db.commit() @@ -300,7 +397,7 @@ class DatabaseManager: "actor": audit.actor, "action_type": audit.action_type, "details": audit.details, - "metadata_json": audit.metadata_json, + "metadata_json": self._normalize_metadata(audit.metadata_json), "timestamp": audit.created_at.isoformat() if audit.created_at else None } for audit in audits @@ -317,7 +414,7 @@ class DatabaseManager: "actor": audit.actor, "action_type": audit.action_type, "details": audit.details, - "metadata_json": audit.metadata_json, + "metadata_json": self._normalize_metadata(audit.metadata_json), "timestamp": audit.created_at.isoformat() if audit.created_at else None } for audit in audits @@ -387,7 +484,9 @@ class DatabaseManager: ] def log_code_change(self, project_id: str, change_type: str, file_path: str, - actor: str, actor_type: str, details: str) -> AuditTrail: + actor: str, actor_type: str, details: str, + history_id: int | None = None, prompt_id: int | None = None, + diff_summary: str | None = None) -> AuditTrail: """Log a code change.""" audit = AuditTrail( project_id=project_id, @@ -396,12 +495,168 @@ class DatabaseManager: action_type=change_type, details=f"File {file_path} {change_type}", message=f"Code change: {file_path}", - metadata_json=json.dumps({"file": file_path, "change_type": change_type, "actor": actor}) + metadata_json={ + "file": file_path, + "change_type": change_type, + "actor": actor, + "actor_type": actor_type, + "history_id": history_id, + "prompt_id": prompt_id, + "details": details, + "diff_summary": diff_summary, + } ) self.db.add(audit) self.db.commit() + self.db.refresh(audit) + + if history_id is not None and prompt_id is not None: + link = PromptCodeLink( + history_id=history_id, + project_id=project_id, + prompt_audit_id=prompt_id, + code_change_audit_id=audit.id, + file_path=file_path, + change_type=change_type, + ) + self.db.add(link) + self.db.commit() return audit + def get_prompt_change_links(self, project_id: str | None = None, limit: int = 200) -> list[dict]: + """Return stored prompt/code lineage rows.""" + query = self.db.query(PromptCodeLink) + if project_id: + query = query.filter(PromptCodeLink.project_id == project_id) + links = query.order_by(PromptCodeLink.created_at.desc()).limit(limit).all() + return [ + { + "id": link.id, + "history_id": link.history_id, + "project_id": link.project_id, + "prompt_audit_id": link.prompt_audit_id, + "code_change_audit_id": link.code_change_audit_id, + "file_path": link.file_path, + "change_type": link.change_type, + "created_at": link.created_at.isoformat() if link.created_at else None, + } + for link in links + ] + + def _build_correlations_from_links(self, project_id: str | None = None, limit: int = 100) -> list[dict]: + """Build prompt-change correlations from explicit lineage rows.""" + prompt_events = self.get_prompt_events(project_id=project_id, limit=limit) + if not prompt_events: + return [] + + links = self.get_prompt_change_links(project_id=project_id, limit=limit * 10) + if not links: + return [] + + prompt_map = {prompt["id"]: {**prompt, "changes": []} for prompt in prompt_events} + change_map = {change["id"]: change for change in self.get_code_changes(project_id=project_id, limit=limit * 10)} + + for link in links: + prompt = prompt_map.get(link["prompt_audit_id"]) + change = change_map.get(link["code_change_audit_id"]) + if prompt is None or change is None: + continue + prompt["changes"].append( + { + "id": change["id"], + "file_path": link["file_path"] or change["file_path"], + "change_type": link["change_type"] or change["action_type"], + "details": change["details"], + "diff_summary": change["diff_summary"], + "timestamp": change["timestamp"], + } + ) + + correlations = [ + { + "project_id": prompt["project_id"], + "prompt_id": prompt["id"], + "prompt_text": prompt["prompt_text"], + "features": prompt["features"], + "tech_stack": prompt["tech_stack"], + "timestamp": prompt["timestamp"], + "changes": prompt["changes"], + } + for prompt in prompt_map.values() + ] + correlations.sort(key=lambda item: item["timestamp"] or "", reverse=True) + return correlations[:limit] + + def _build_correlations_from_audit_fallback(self, project_id: str | None = None, limit: int = 100) -> list[dict]: + """Fallback correlation builder for older rows without explicit lineage.""" + query = self.db.query(AuditTrail) + if project_id: + query = query.filter(AuditTrail.project_id == project_id) + events = query.filter( + AuditTrail.action.in_(["PROMPT_RECEIVED", "CODE_CHANGE"]) + ).order_by(AuditTrail.project_id.asc(), AuditTrail.created_at.asc(), AuditTrail.id.asc()).all() + + grouped: dict[str, list[AuditTrail]] = {} + for event in events: + grouped.setdefault(event.project_id or "", []).append(event) + + correlations: list[dict] = [] + for grouped_project_id, project_events in grouped.items(): + current_prompt: AuditTrail | None = None + current_changes: list[AuditTrail] = [] + for event in project_events: + if event.action == "PROMPT_RECEIVED": + if current_prompt is not None: + prompt_metadata = self._normalize_metadata(current_prompt.metadata_json) + correlations.append({ + "project_id": grouped_project_id, + "prompt_id": current_prompt.id, + "prompt_text": prompt_metadata.get("prompt_text", current_prompt.details), + "features": prompt_metadata.get("features", []), + "tech_stack": prompt_metadata.get("tech_stack", []), + "timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None, + "changes": [ + { + "id": change.id, + "file_path": self._normalize_metadata(change.metadata_json).get("file"), + "change_type": change.action_type, + "details": self._normalize_metadata(change.metadata_json).get("details", change.details), + "diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"), + "timestamp": change.created_at.isoformat() if change.created_at else None, + } + for change in current_changes + ], + }) + current_prompt = event + current_changes = [] + elif event.action == "CODE_CHANGE" and current_prompt is not None: + current_changes.append(event) + + if current_prompt is not None: + prompt_metadata = self._normalize_metadata(current_prompt.metadata_json) + correlations.append({ + "project_id": grouped_project_id, + "prompt_id": current_prompt.id, + "prompt_text": prompt_metadata.get("prompt_text", current_prompt.details), + "features": prompt_metadata.get("features", []), + "tech_stack": prompt_metadata.get("tech_stack", []), + "timestamp": current_prompt.created_at.isoformat() if current_prompt.created_at else None, + "changes": [ + { + "id": change.id, + "file_path": self._normalize_metadata(change.metadata_json).get("file"), + "change_type": change.action_type, + "details": self._normalize_metadata(change.metadata_json).get("details", change.details), + "diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"), + "timestamp": change.created_at.isoformat() if change.created_at else None, + } + for change in current_changes + ], + }) + + correlations.sort(key=lambda item: item["timestamp"] or "", reverse=True) + return correlations[:limit] + def log_commit(self, project_id: str, commit_message: str, actor: str, actor_type: str = "agent") -> AuditTrail: """Log a git commit.""" @@ -429,7 +684,10 @@ class DatabaseManager: "project": None, "logs": [], "actions": [], - "audit_trail": [] + "audit_trail": [], + "prompts": [], + "code_changes": [], + "prompt_change_correlations": [], } # Get logs @@ -446,6 +704,10 @@ class DatabaseManager: audit_trails = self.db.query(AuditTrail).filter( AuditTrail.project_id == project_id ).order_by(AuditTrail.created_at.desc()).all() + + prompts = self.get_prompt_events(project_id=project_id) + code_changes = self.get_code_changes(project_id=project_id) + correlations = self.get_prompt_change_correlations(project_id=project_id) return { "project": { @@ -489,10 +751,92 @@ class DatabaseManager: "actor": audit.actor, "action_type": audit.action_type, "details": audit.details, + "metadata": self._normalize_metadata(audit.metadata_json), "timestamp": audit.created_at.isoformat() if audit.created_at else None } for audit in audit_trails - ] + ], + "prompts": prompts, + "code_changes": code_changes, + "prompt_change_correlations": correlations, + } + + def get_prompt_events(self, project_id: str | None = None, limit: int = 100) -> list[dict]: + """Return prompt receipt events from the audit trail.""" + query = self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED") + if project_id: + query = query.filter(AuditTrail.project_id == project_id) + prompts = query.order_by(AuditTrail.created_at.desc()).limit(limit).all() + return [ + { + "id": prompt.id, + "project_id": prompt.project_id, + "actor": prompt.actor, + "message": prompt.message, + "prompt_text": self._normalize_metadata(prompt.metadata_json).get("prompt_text", prompt.details), + "features": self._normalize_metadata(prompt.metadata_json).get("features", []), + "tech_stack": self._normalize_metadata(prompt.metadata_json).get("tech_stack", []), + "history_id": self._normalize_metadata(prompt.metadata_json).get("history_id"), + "timestamp": prompt.created_at.isoformat() if prompt.created_at else None, + } + for prompt in prompts + ] + + def get_code_changes(self, project_id: str | None = None, limit: int = 100) -> list[dict]: + """Return code change events from the audit trail.""" + query = self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE") + if project_id: + query = query.filter(AuditTrail.project_id == project_id) + changes = query.order_by(AuditTrail.created_at.desc()).limit(limit).all() + return [ + { + "id": change.id, + "project_id": change.project_id, + "action_type": change.action_type, + "actor": change.actor, + "details": change.details, + "file_path": self._normalize_metadata(change.metadata_json).get("file"), + "prompt_id": self._normalize_metadata(change.metadata_json).get("prompt_id"), + "history_id": self._normalize_metadata(change.metadata_json).get("history_id"), + "diff_summary": self._normalize_metadata(change.metadata_json).get("diff_summary"), + "timestamp": change.created_at.isoformat() if change.created_at else None, + } + for change in changes + ] + + def get_prompt_change_correlations(self, project_id: str | None = None, limit: int = 100) -> list[dict]: + """Correlate prompts with the concrete code changes that followed them.""" + correlations = self._build_correlations_from_links(project_id=project_id, limit=limit) + if correlations: + return correlations + return self._build_correlations_from_audit_fallback(project_id=project_id, limit=limit) + + def get_dashboard_snapshot(self, limit: int = 8) -> dict: + """Return DB-backed dashboard data for the UI.""" + projects = self.db.query(ProjectHistory).order_by(ProjectHistory.updated_at.desc()).limit(limit).all() + system_logs = self.db.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(limit).all() + return { + "summary": { + "total_projects": self.db.query(ProjectHistory).count(), + "running_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.RUNNING.value).count(), + "completed_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.COMPLETED.value).count(), + "error_projects": self.db.query(ProjectHistory).filter(ProjectHistory.status == ProjectStatus.ERROR.value).count(), + "prompt_events": self.db.query(AuditTrail).filter(AuditTrail.action == "PROMPT_RECEIVED").count(), + "code_changes": self.db.query(AuditTrail).filter(AuditTrail.action == "CODE_CHANGE").count(), + }, + "projects": [self.get_project_audit_data(project.project_id) for project in projects], + "system_logs": [ + { + "id": log.id, + "component": log.component, + "level": log.log_level, + "message": log.log_message, + "timestamp": log.created_at.isoformat() if log.created_at else None, + } + for log in system_logs + ], + "lineage_links": self.get_prompt_change_links(limit=limit * 10), + "correlations": self.get_prompt_change_correlations(limit=limit), } def cleanup_audit_trail(self) -> None: diff --git a/ai_software_factory/agents/git_manager.py b/ai_software_factory/agents/git_manager.py index 83c6fc5..eb1395b 100644 --- a/ai_software_factory/agents/git_manager.py +++ b/ai_software_factory/agents/git_manager.py @@ -2,8 +2,14 @@ import os import subprocess +from pathlib import Path from typing import Optional +try: + from ..config import settings +except ImportError: + from config import settings + class GitManager: """Manages git operations for the project.""" @@ -12,7 +18,15 @@ class GitManager: if not project_id: raise ValueError("project_id cannot be empty or None") self.project_id = project_id - self.project_dir = f"{os.path.dirname(__file__)}/../../test-project/{project_id}" + project_path = Path(project_id) + if project_path.is_absolute() or len(project_path.parts) > 1: + resolved = project_path.expanduser().resolve() + else: + base_root = settings.projects_root + if base_root.name != "test-project": + base_root = base_root / "test-project" + resolved = (base_root / project_id).resolve() + self.project_dir = str(resolved) def init_repo(self): """Initialize git repository.""" diff --git a/ai_software_factory/agents/n8n_setup.py b/ai_software_factory/agents/n8n_setup.py index 3810c3a..0c923d5 100644 --- a/ai_software_factory/agents/n8n_setup.py +++ b/ai_software_factory/agents/n8n_setup.py @@ -2,7 +2,11 @@ import json from typing import Optional -from config import settings + +try: + from ..config import settings +except ImportError: + from config import settings class N8NSetupAgent: @@ -21,95 +25,266 @@ class N8NSetupAgent: self.api_url = api_url.rstrip("/") self.webhook_token = webhook_token self.session = None + + def _api_path(self, path: str) -> str: + """Build a full n8n API URL for a given endpoint path.""" + if path.startswith("http://") or path.startswith("https://"): + return path + trimmed = path.lstrip("/") + if trimmed.startswith("api/"): + return f"{self.api_url}/{trimmed}" + return f"{self.api_url}/api/v1/{trimmed}" def get_auth_headers(self) -> dict: """Get authentication headers for n8n API using webhook token.""" - return { + headers = { "n8n-no-credentials": "true", "Content-Type": "application/json", "User-Agent": "AI-Software-Factory" } + if self.webhook_token: + headers["X-N8N-API-KEY"] = self.webhook_token + return headers + + async def _request(self, method: str, path: str, **kwargs) -> dict: + """Send a request to n8n and normalize the response.""" + import aiohttp + + headers = kwargs.pop("headers", None) or self.get_auth_headers() + url = self._api_path(path) + try: + async with aiohttp.ClientSession() as session: + async with session.request(method, url, headers=headers, **kwargs) as resp: + content_type = resp.headers.get("Content-Type", "") + if "application/json" in content_type: + payload = await resp.json() + else: + payload = {"text": await resp.text()} + + if 200 <= resp.status < 300: + if isinstance(payload, dict): + payload.setdefault("status_code", resp.status) + return payload + return {"data": payload, "status_code": resp.status} + + message = payload.get("message") if isinstance(payload, dict) else str(payload) + return {"error": f"Status {resp.status}: {message}", "status_code": resp.status, "payload": payload} + except Exception as e: + return {"error": str(e)} async def get_workflow(self, workflow_name: str) -> Optional[dict]: """Get a workflow by name.""" - import aiohttp - try: - async with aiohttp.ClientSession() as session: - # Use the webhook URL directly for workflow operations - # n8n supports calling workflows via /webhook/ path with query params - # For API token auth, n8n checks the token against webhook credentials - headers = self.get_auth_headers() - - # Try standard workflow endpoint first (for API token setup) - async with session.get( - f"{self.api_url}/workflow/{workflow_name}.json", - headers=headers - ) as resp: - if resp.status == 200: - return await resp.json() - elif resp.status == 404: - return None - else: - return {"error": f"Status {resp.status}"} - except Exception as e: - return {"error": str(e)} + workflows = await self.list_workflows() + if isinstance(workflows, dict) and workflows.get("error"): + return workflows + for workflow in workflows: + if workflow.get("name") == workflow_name: + return workflow + return None async def create_workflow(self, workflow_json: dict) -> dict: """Create or update a workflow.""" - import aiohttp - try: - async with aiohttp.ClientSession() as session: - # Use POST to create/update workflow - headers = self.get_auth_headers() - - async with session.post( - f"{self.api_url}/workflow", - headers=headers, - json=workflow_json - ) as resp: - if resp.status == 200 or resp.status == 201: - return await resp.json() - else: - return {"error": f"Status {resp.status}: {await resp.text()}"} - except Exception as e: - return {"error": str(e)} + return await self._request("POST", "workflows", json=workflow_json) + + async def update_workflow(self, workflow_id: str, workflow_json: dict) -> dict: + """Update an existing workflow.""" + return await self._request("PATCH", f"workflows/{workflow_id}", json=workflow_json) async def enable_workflow(self, workflow_id: str) -> dict: """Enable a workflow.""" - import aiohttp - try: - async with aiohttp.ClientSession() as session: - headers = self.get_auth_headers() - - async with session.post( - f"{self.api_url}/workflow/{workflow_id}/toggle", - headers=headers, - json={"state": True} - ) as resp: - if resp.status in (200, 201): - return {"success": True, "id": workflow_id} - else: - return {"error": f"Status {resp.status}: {await resp.text()}"} - except Exception as e: - return {"error": str(e)} + result = await self._request("POST", f"workflows/{workflow_id}/activate") + if result.get("error"): + fallback = await self._request("PATCH", f"workflows/{workflow_id}", json={"active": True}) + if fallback.get("error"): + return fallback + return {"success": True, "id": workflow_id, "method": "patch"} + return {"success": True, "id": workflow_id, "method": "activate"} async def list_workflows(self) -> list: """List all workflows.""" - import aiohttp - try: - async with aiohttp.ClientSession() as session: - headers = self.get_auth_headers() - - async with session.get( - f"{self.api_url}/workflow", - headers=headers - ) as resp: - if resp.status == 200: - return await resp.json() - else: - return [] - except Exception as e: + result = await self._request("GET", "workflows") + if result.get("error"): + return result + if isinstance(result, list): + return result + if isinstance(result, dict): + for key in ("data", "workflows"): + value = result.get(key) + if isinstance(value, list): + return value + return [] + + def build_telegram_workflow(self, webhook_path: str, backend_url: str) -> dict: + """Build the Telegram-to-backend workflow definition.""" + normalized_path = webhook_path.strip().strip("/") or "telegram" + return { + "name": "Telegram to AI Software Factory", + "active": False, + "settings": {"executionOrder": "v1"}, + "nodes": [ + { + "id": "webhook-node", + "name": "Telegram Webhook", + "type": "n8n-nodes-base.webhook", + "typeVersion": 2, + "position": [-520, 120], + "parameters": { + "httpMethod": "POST", + "path": normalized_path, + "responseMode": "responseNode", + "options": {}, + }, + }, + { + "id": "parse-node", + "name": "Prepare Software Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-200, 120], + "parameters": { + "language": "javaScript", + "jsCode": "const body = $json.body ?? $json;\nconst message = body.message ?? body;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [] };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: { ...request, _source: { raw_text: text, chat_id: message.chat?.id ?? null } } }];", + }, + }, + { + "id": "api-node", + "name": "AI Software Factory API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [120, 120], + "parameters": { + "method": "POST", + "url": backend_url, + "sendBody": True, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": {"response": {"response": {"fullResponse": False}}}, + }, + }, + { + "id": "response-node", + "name": "Respond to Telegram Webhook", + "type": "n8n-nodes-base.respondToWebhook", + "typeVersion": 1.2, + "position": [420, 120], + "parameters": { + "respondWith": "json", + "responseBody": "={{ $json }}", + }, + }, + ], + "connections": { + "Telegram Webhook": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, + "Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, + "AI Software Factory API": {"main": [[{"node": "Respond to Telegram Webhook", "type": "main", "index": 0}]]}, + }, + } + + def build_telegram_trigger_workflow( + self, + backend_url: str, + credential_name: str, + ) -> dict: + """Build a production Telegram Trigger based workflow.""" + return { + "name": "Telegram to AI Software Factory", + "active": False, + "settings": {"executionOrder": "v1"}, + "nodes": [ + { + "id": "telegram-trigger-node", + "name": "Telegram Trigger", + "type": "n8n-nodes-base.telegramTrigger", + "typeVersion": 1, + "position": [-520, 120], + "parameters": {"updates": ["message"]}, + "credentials": {"telegramApi": {"name": credential_name}}, + }, + { + "id": "parse-node", + "name": "Prepare Software Request", + "type": "n8n-nodes-base.code", + "typeVersion": 2, + "position": [-180, 120], + "parameters": { + "language": "javaScript", + "jsCode": "const message = $json.message ?? $json;\nconst text = String(message.text ?? '').trim();\nconst lines = text.split(/\\r?\\n/);\nconst request = { name: null, description: '', features: [], tech_stack: [], _source: { raw_text: text, chat_id: message.chat?.id ?? null } };\nlet nameIndex = -1;\nlet featuresIndex = -1;\nlet techIndex = -1;\nfor (let i = 0; i < lines.length; i += 1) {\n const line = lines[i].trim();\n if (line.toLowerCase().startsWith('name:')) { request.name = line.split(':', 2)[1]?.trim() || null; nameIndex = i; }\n if (line.toLowerCase().startsWith('features:') && featuresIndex === -1) { featuresIndex = i; }\n if (line.toLowerCase().startsWith('tech stack:') && techIndex === -1) { techIndex = i; }\n}\nif (nameIndex >= 0) {\n const descriptionEnd = featuresIndex >= 0 ? featuresIndex : (techIndex >= 0 ? techIndex : lines.length);\n request.description = lines.slice(nameIndex + 1, descriptionEnd).join('\\n').replace(/^description:\\s*/i, '').trim();\n}\nfunction collectList(startIndex, fieldName) {\n if (startIndex < 0) return;\n const firstLine = lines[startIndex].split(':').slice(1).join(':').trim();\n if (firstLine && !firstLine.startsWith('-') && !firstLine.startsWith('*')) {\n request[fieldName].push(...firstLine.split(',').map(item => item.trim()).filter(Boolean));\n }\n for (const rawLine of lines.slice(startIndex + 1)) {\n const line = rawLine.trim();\n if (!line) continue;\n if (/^[A-Za-z ]+:/.test(line)) break;\n if (line.startsWith('-') || line.startsWith('*')) {\n const value = line.slice(1).trim();\n if (value) request[fieldName].push(value);\n }\n }\n}\ncollectList(featuresIndex, 'features');\ncollectList(techIndex, 'tech_stack');\nif (!request.name || request.features.length === 0) { throw new Error('Could not parse software request from Telegram message'); }\nreturn [{ json: request }];", + }, + }, + { + "id": "api-node", + "name": "AI Software Factory API", + "type": "n8n-nodes-base.httpRequest", + "typeVersion": 4.2, + "position": [120, 120], + "parameters": { + "method": "POST", + "url": backend_url, + "sendBody": True, + "specifyBody": "json", + "jsonBody": "={{ $json }}", + "options": {"response": {"response": {"fullResponse": False}}}, + }, + }, + { + "id": "reply-node", + "name": "Send Telegram Update", + "type": "n8n-nodes-base.telegram", + "typeVersion": 1, + "position": [420, 120], + "parameters": { + "resource": "message", + "operation": "sendMessage", + "chatId": "={{ $('Telegram Trigger').item.json.message.chat.id }}", + "text": "={{ $json.data ? `Generated ${$json.data.name} (${($json.data.changed_files || []).length} files)` : ($json.message || 'Software generation request accepted') }}", + }, + "credentials": {"telegramApi": {"name": credential_name}}, + }, + ], + "connections": { + "Telegram Trigger": {"main": [[{"node": "Prepare Software Request", "type": "main", "index": 0}]]}, + "Prepare Software Request": {"main": [[{"node": "AI Software Factory API", "type": "main", "index": 0}]]}, + "AI Software Factory API": {"main": [[{"node": "Send Telegram Update", "type": "main", "index": 0}]]}, + }, + } + + async def list_credentials(self) -> list: + """List n8n credentials.""" + result = await self._request("GET", "credentials") + if result.get("error"): return [] + if isinstance(result, list): + return result + if isinstance(result, dict): + for key in ("data", "credentials"): + value = result.get(key) + if isinstance(value, list): + return value + return [] + + async def get_credential(self, credential_name: str, credential_type: str = "telegramApi") -> Optional[dict]: + """Get an existing credential by name and type.""" + credentials = await self.list_credentials() + for credential in credentials: + if credential.get("name") == credential_name and credential.get("type") == credential_type: + return credential + return None + + async def create_credential(self, name: str, credential_type: str, data: dict) -> dict: + """Create an n8n credential.""" + payload = {"name": name, "type": credential_type, "data": data} + return await self._request("POST", "credentials", json=payload) + + async def ensure_telegram_credential(self, bot_token: str, credential_name: str) -> dict: + """Ensure a Telegram credential exists for the workflow trigger.""" + existing = await self.get_credential(credential_name) + if existing: + return existing + return await self.create_credential( + name=credential_name, + credential_type="telegramApi", + data={"accessToken": bot_token}, + ) async def setup_telegram_workflow(self, webhook_path: str) -> dict: """Setup the Telegram webhook workflow in n8n. @@ -120,117 +295,85 @@ class N8NSetupAgent: Returns: Result of setup operation """ - import os - webhook_token = os.getenv("TELEGRAM_BOT_TOKEN", "") - - # Define the workflow using n8n's Telegram trigger - workflow = { - "name": "Telegram to AI Software Factory", - "nodes": [ - { - "parameters": { - "httpMethod": "post", - "responseMode": "response", - "path": webhook_path or "telegram", - "httpBody": "={{ json.stringify($json) }}", - "httpAuthType": "headerParam", - "headerParams": { - "x-n8n-internal": "true", - "content-type": "application/json" - } - }, - "id": "webhook-node", - "name": "Telegram Webhook" - }, - { - "parameters": { - "operation": "editFields", - "fields": "json", - "editFieldsValue": "={{ json.parse($json.text) }}", - "options": {} - }, - "id": "parse-node", - "name": "Parse Message" - }, - { - "parameters": { - "url": "http://localhost:8000/generate", - "method": "post", - "sendBody": True, - "responseMode": "onReceived", - "ignoreSSL": True, - "retResponse": True, - "sendQueryParams": False - }, - "id": "api-node", - "name": "AI Software Factory API" - }, - { - "parameters": { - "operation": "editResponse", - "editResponseValue": "={{ $json }}" - }, - "id": "response-node", - "name": "Response Builder" - } - ], - "connections": { - "Telegram Webhook": { - "webhook": ["parse"] - }, - "Parse Message": { - "API Call": ["POST"] - }, - "Response Builder": { - "respondToWebhook": ["response"] - } - }, - "settings": { - "executionOrder": "v1" - } - } - - # Create the workflow - result = await self.create_workflow(workflow) - - if result.get("success") or result.get("id"): - # Try to enable the workflow - enable_result = await self.enable_workflow(result.get("id", "")) - result.update(enable_result) - - return result + return await self.setup( + webhook_path=webhook_path, + backend_url=f"{settings.backend_public_url}/generate", + force_update=False, + ) async def health_check(self) -> dict: """Check n8n API health.""" - import aiohttp - try: - async with aiohttp.ClientSession() as session: - headers = self.get_auth_headers() - - async with session.get( - f"{self.api_url}/api/v1/workflow", - headers=headers - ) as resp: - if resp.status == 200: - return {"status": "ok"} - else: - return {"error": f"Status {resp.status}"} - except Exception as e: - return {"error": str(e)} + result = await self._request("GET", f"{self.api_url}/healthz") + if result.get("error"): + fallback = await self._request("GET", "workflows") + if fallback.get("error"): + return fallback + return {"status": "ok", "checked_via": "workflows"} + return {"status": "ok", "checked_via": "healthz"} - async def setup(self) -> dict: + async def setup( + self, + webhook_path: str = "telegram", + backend_url: str | None = None, + force_update: bool = False, + use_telegram_trigger: bool | None = None, + telegram_bot_token: str | None = None, + telegram_credential_name: str | None = None, + ) -> dict: """Setup n8n webhooks automatically.""" # First, verify n8n is accessible health = await self.health_check() if health.get("error"): return {"status": "error", "message": health.get("error")} - # Try to get existing telegram workflow - existing = await self.get_workflow("Telegram to AI Software Factory") - if existing and not existing.get("error"): - # Enable existing workflow - return await self.enable_workflow(existing.get("id", "")) - - # Create new workflow - result = await self.setup_telegram_workflow("/webhook/telegram") - return result + effective_backend_url = backend_url or f"{settings.backend_public_url}/generate" + effective_bot_token = telegram_bot_token or settings.telegram_bot_token + effective_credential_name = telegram_credential_name or settings.n8n_telegram_credential_name + trigger_mode = use_telegram_trigger if use_telegram_trigger is not None else bool(effective_bot_token) + + if trigger_mode: + credential = await self.ensure_telegram_credential(effective_bot_token, effective_credential_name) + if credential.get("error"): + return {"status": "error", "message": credential["error"]} + workflow = self.build_telegram_trigger_workflow( + backend_url=effective_backend_url, + credential_name=effective_credential_name, + ) + else: + workflow = self.build_telegram_workflow( + webhook_path=webhook_path, + backend_url=effective_backend_url, + ) + + existing = await self.get_workflow(workflow["name"]) + if isinstance(existing, dict) and existing.get("error"): + return {"status": "error", "message": existing["error"]} + + workflow_id = None + if existing and existing.get("id"): + workflow_id = str(existing["id"]) + if force_update: + result = await self.update_workflow(workflow_id, workflow) + else: + result = existing + else: + result = await self.create_workflow(workflow) + workflow_id = str(result.get("id", "")) if isinstance(result, dict) else None + + if isinstance(result, dict) and result.get("error"): + return {"status": "error", "message": result["error"]} + + workflow_id = workflow_id or str(result.get("id", "")) + enable_result = await self.enable_workflow(workflow_id) + if enable_result.get("error"): + return {"status": "error", "message": enable_result["error"], "workflow": result} + + return { + "status": "success", + "message": f'Workflow "{workflow["name"]}" is active', + "workflow_id": workflow_id, + "workflow_name": workflow["name"], + "webhook_path": webhook_path.strip().strip("/") or "telegram", + "backend_url": effective_backend_url, + "trigger_mode": "telegram" if trigger_mode else "webhook", + } diff --git a/ai_software_factory/agents/orchestrator.py b/ai_software_factory/agents/orchestrator.py index 459e227..339c25e 100644 --- a/ai_software_factory/agents/orchestrator.py +++ b/ai_software_factory/agents/orchestrator.py @@ -1,14 +1,24 @@ """Agent orchestrator for software generation.""" -import asyncio +from __future__ import annotations + +import py_compile +from pathlib import Path from typing import Optional -from agents.git_manager import GitManager -from agents.ui_manager import UIManager -from agents.gitea import GiteaAPI -from agents.database_manager import DatabaseManager -from config import settings from datetime import datetime -import os + +try: + from ..config import settings + from .database_manager import DatabaseManager + from .git_manager import GitManager + from .gitea import GiteaAPI + from .ui_manager import UIManager +except ImportError: + from config import settings + from agents.database_manager import DatabaseManager + from agents.git_manager import GitManager + from agents.gitea import GiteaAPI + from agents.ui_manager import UIManager class AgentOrchestrator: @@ -21,7 +31,9 @@ class AgentOrchestrator: description: str, features: list, tech_stack: list, - db = None + db=None, + prompt_text: str | None = None, + prompt_actor: str = "api", ): """Initialize orchestrator.""" self.project_id = project_id @@ -36,6 +48,11 @@ class AgentOrchestrator: self.logs = [] self.ui_data = {} self.db = db + self.prompt_text = prompt_text + self.prompt_actor = prompt_actor + self.changed_files: list[str] = [] + self.project_root = settings.projects_root / project_id + self.prompt_audit = None # Initialize agents self.git_manager = GitManager(project_id) @@ -60,52 +77,115 @@ class AgentOrchestrator: ) # Re-fetch with new history_id self.db_manager = DatabaseManager(db) + if self.prompt_text: + self.prompt_audit = self.db_manager.log_prompt_submission( + history_id=self.history.id, + project_id=project_id, + prompt_text=self.prompt_text, + features=self.features, + tech_stack=self.tech_stack, + actor_name=self.prompt_actor, + ) + + self.ui_manager.ui_data["project_root"] = str(self.project_root) + self.ui_manager.ui_data["features"] = list(self.features) + self.ui_manager.ui_data["tech_stack"] = list(self.tech_stack) + + def _append_log(self, message: str) -> None: + timestamped = f"[{datetime.utcnow().isoformat()}] {message}" + self.logs.append(timestamped) + if self.db_manager and self.history: + self.db_manager._log_action(self.history.id, "INFO", message) + + def _update_progress(self, progress: int, step: str, message: str) -> None: + self.progress = progress + self.current_step = step + self.message = message + self.ui_manager.update_status(self.status, progress, message) + if self.db_manager and self.history: + self.db_manager.log_progress_update( + history_id=self.history.id, + progress=progress, + step=step, + message=message, + ) + + def _write_file(self, relative_path: str, content: str) -> None: + target = self.project_root / relative_path + target.parent.mkdir(parents=True, exist_ok=True) + change_type = "UPDATE" if target.exists() else "CREATE" + target.write_text(content, encoding="utf-8") + self.changed_files.append(relative_path) + if self.db_manager and self.history: + self.db_manager.log_code_change( + project_id=self.project_id, + change_type=change_type, + file_path=relative_path, + actor="orchestrator", + actor_type="agent", + details=f"{change_type.title()}d generated artifact {relative_path}", + history_id=self.history.id, + prompt_id=self.prompt_audit.id if self.prompt_audit else None, + diff_summary=f"Wrote {len(content.splitlines())} lines to {relative_path}", + ) + + def _template_files(self) -> dict[str, str]: + feature_section = "\n".join(f"- {feature}" for feature in self.features) or "- None specified" + tech_section = "\n".join(f"- {tech}" for tech in self.tech_stack) or "- Python" + return { + ".gitignore": "__pycache__/\n*.pyc\n.venv/\n.pytest_cache/\n.mypy_cache/\n", + "README.md": ( + f"# {self.project_name}\n\n" + f"{self.description}\n\n" + "## Features\n" + f"{feature_section}\n\n" + "## Tech Stack\n" + f"{tech_section}\n" + ), + "requirements.txt": "fastapi\nuvicorn\npytest\n", + "main.py": ( + "from fastapi import FastAPI\n\n" + "app = FastAPI(title=\"Generated App\")\n\n" + "@app.get('/')\n" + "def read_root():\n" + f" return {{'name': '{self.project_name}', 'status': 'generated', 'features': {self.features!r}}}\n" + ), + "tests/test_app.py": ( + "from main import read_root\n\n" + "def test_read_root():\n" + f" assert read_root()['name'] == '{self.project_name}'\n" + ), + } async def run(self) -> dict: """Run the software generation process with full audit logging.""" try: # Step 1: Initialize project - self.progress = 5 - self.current_step = "Initializing project" - self.message = "Setting up project structure..." - self.logs.append(f"[{datetime.utcnow().isoformat()}] Initializing project.") + self.status = "running" + self._update_progress(5, "initializing", "Setting up project structure...") + self._append_log("Initializing project.") # Step 2: Create project structure (skip git operations) - self.progress = 15 - self.current_step = "Creating project structure" - self.message = "Creating project files..." + self._update_progress(20, "project-structure", "Creating project files...") await self._create_project_structure() # Step 3: Generate initial code - self.progress = 25 - self.current_step = "Generating initial code" - self.message = "Generating initial code with Ollama..." + self._update_progress(55, "code-generation", "Generating project entrypoint and tests...") await self._generate_code() # Step 4: Test the code - self.progress = 50 - self.current_step = "Testing code" - self.message = "Running tests..." + self._update_progress(80, "validation", "Validating generated code...") await self._run_tests() - # Step 5: Commit to git (skip in test env) - self.progress = 75 - self.current_step = "Committing to git" - self.message = "Skipping git operations in test environment..." - - # Step 6: Create PR (skip in test env) - self.progress = 90 - self.current_step = "Creating PR" - self.message = "Skipping PR creation in test environment..." - # Step 7: Complete - self.progress = 100 - self.current_step = "Completed" - self.message = "Software generation complete!" - self.logs.append(f"[{datetime.utcnow().isoformat()}] Software generation complete!") + self.status = "completed" + self._update_progress(100, "completed", "Software generation complete!") + self._append_log("Software generation complete!") + self.ui_manager.ui_data["changed_files"] = list(dict.fromkeys(self.changed_files)) # Log completion to database if available if self.db_manager and self.history: + self.db_manager.save_ui_snapshot(self.history.id, self.ui_manager.get_ui_data()) self.db_manager.log_project_complete( history_id=self.history.id, message="Software generation complete!" @@ -118,13 +198,15 @@ class AgentOrchestrator: "current_step": self.current_step, "logs": self.logs, "ui_data": self.ui_manager.ui_data, - "history_id": self.history.id if self.history else None + "history_id": self.history.id if self.history else None, + "project_root": str(self.project_root), + "changed_files": list(dict.fromkeys(self.changed_files)), } except Exception as e: self.status = "error" self.message = f"Error: {str(e)}" - self.logs.append(f"[{datetime.utcnow().isoformat()}] Error: {str(e)}") + self._append_log(f"Error: {str(e)}") # Log error to database if available if self.db_manager and self.history: @@ -141,64 +223,32 @@ class AgentOrchestrator: "logs": self.logs, "error": str(e), "ui_data": self.ui_manager.ui_data, - "history_id": self.history.id if self.history else None + "history_id": self.history.id if self.history else None, + "project_root": str(self.project_root), + "changed_files": list(dict.fromkeys(self.changed_files)), } async def _create_project_structure(self) -> None: """Create initial project structure.""" - project_dir = self.project_id - - # Create .gitignore - gitignore_path = f"{project_dir}/.gitignore" - try: - os.makedirs(project_dir, exist_ok=True) - with open(gitignore_path, "w") as f: - f.write("# Python\n__pycache__/\n*.pyc\n*.pyo\n*.pyd\n.Python\n*.env\n.venv/\nnode_modules/\n.env\nbuild/\ndist/\n.pytest_cache/\n.mypy_cache/\n.coverage\nhtmlcov/\n.idea/\n.vscode/\n*.swp\n*.swo\n*~\n.DS_Store\n.git\n") - except Exception as e: - self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create .gitignore: {str(e)}") - - # Create README.md - readme_path = f"{project_dir}/README.md" - try: - with open(readme_path, "w") as f: - f.write(f"# {self.project_name}\n\n{self.description}\n\n## Features\n") - for feature in self.features: - f.write(f"- {feature}\n") - f.write(f"\n## Tech Stack\n") - for tech in self.tech_stack: - f.write(f"- {tech}\n") - except Exception as e: - self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create README.md: {str(e)}") + self.project_root.mkdir(parents=True, exist_ok=True) + for relative_path, content in self._template_files().items(): + if relative_path.startswith("main.py") or relative_path.startswith("tests/"): + continue + self._write_file(relative_path, content) + self._append_log(f"Project structure created under {self.project_root}.") async def _generate_code(self) -> None: """Generate code using Ollama.""" - # This would call Ollama API to generate code - # For now, create a placeholder file - try: - main_py_path = f"{self.project_id}/main.py" - os.makedirs(self.project_id, exist_ok=True) - with open(main_py_path, "w") as f: - f.write("# Generated by AI Software Factory\n") - f.write("print('Hello, World!')\n") - except Exception as e: - self.logs.append(f"[{datetime.utcnow().isoformat()}] Failed to create main.py: {str(e)}") - - # Log code change to audit trail - if self.db_manager and self.history: - self.db_manager.log_code_change( - project_id=self.project_id, - change_type="CREATE", - file_path="main.py", - actor="agent", - actor_type="agent", - details="Generated main.py file" - ) + for relative_path, content in self._template_files().items(): + if relative_path in {"main.py", "tests/test_app.py"}: + self._write_file(relative_path, content) + self._append_log("Application entrypoint and smoke test generated.") async def _run_tests(self) -> None: """Run tests for the generated code.""" - # This would run pytest or other test framework - # For now, simulate test success - pass + py_compile.compile(str(self.project_root / "main.py"), doraise=True) + py_compile.compile(str(self.project_root / "tests/test_app.py"), doraise=True) + self._append_log("Generated Python files compiled successfully.") async def _commit_to_git(self) -> None: """Commit changes to git.""" diff --git a/ai_software_factory/agents/ui_manager.py b/ai_software_factory/agents/ui_manager.py index 052e834..35ad99e 100644 --- a/ai_software_factory/agents/ui_manager.py +++ b/ai_software_factory/agents/ui_manager.py @@ -1,5 +1,6 @@ """UI manager for web dashboard with audit trail display.""" +import html import json from typing import Optional, List @@ -50,14 +51,7 @@ class UIManager: """Escape HTML special characters for safe display.""" if text is None: return "" - safe_chars = { - '&': '&', - '<': '<', - '>': '>', - '"': '"', - "'": ''' - } - return ''.join(safe_chars.get(c, c) for c in str(text)) + return html.escape(str(text), quote=True) def render_dashboard(self, audit_trail: Optional[List[dict]] = None, actions: Optional[List[dict]] = None, diff --git a/ai_software_factory/alembic.ini b/ai_software_factory/alembic.ini new file mode 100644 index 0000000..938ee36 --- /dev/null +++ b/ai_software_factory/alembic.ini @@ -0,0 +1,36 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:////tmp/ai_software_factory_test.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = console +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s \ No newline at end of file diff --git a/ai_software_factory/alembic/env.py b/ai_software_factory/alembic/env.py new file mode 100644 index 0000000..c90051c --- /dev/null +++ b/ai_software_factory/alembic/env.py @@ -0,0 +1,50 @@ +"""Alembic environment for AI Software Factory.""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +try: + from ai_software_factory.models import Base +except ImportError: + from models import Base + +config = context.config + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in offline mode.""" + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, compare_type=True) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations in online mode.""" + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata, compare_type=True) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/ai_software_factory/alembic/script.py.mako b/ai_software_factory/alembic/script.py.mako new file mode 100644 index 0000000..2e911e1 --- /dev/null +++ b/ai_software_factory/alembic/script.py.mako @@ -0,0 +1,17 @@ +"""${message}""" + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + +from alembic import op +import sqlalchemy as sa + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/ai_software_factory/alembic/versions/20260410_01_initial_schema.py b/ai_software_factory/alembic/versions/20260410_01_initial_schema.py new file mode 100644 index 0000000..ec93206 --- /dev/null +++ b/ai_software_factory/alembic/versions/20260410_01_initial_schema.py @@ -0,0 +1,164 @@ +"""initial schema + +Revision ID: 20260410_01 +Revises: +Create Date: 2026-04-10 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "20260410_01" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "agent_actions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("agent_name", sa.String(length=100), nullable=False), + sa.Column("action_type", sa.String(length=100), nullable=False), + sa.Column("success", sa.Boolean(), nullable=True), + sa.Column("message", sa.String(length=500), nullable=True), + sa.Column("timestamp", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "audit_trail", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("component", sa.String(length=50), nullable=True), + sa.Column("log_level", sa.String(length=50), nullable=True), + sa.Column("message", sa.String(length=500), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("project_id", sa.String(length=255), nullable=True), + sa.Column("action", sa.String(length=100), nullable=True), + sa.Column("actor", sa.String(length=100), nullable=True), + sa.Column("action_type", sa.String(length=50), nullable=True), + sa.Column("details", sa.Text(), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "project_history", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("project_id", sa.String(length=255), nullable=False), + sa.Column("project_name", sa.String(length=255), nullable=True), + sa.Column("features", sa.Text(), nullable=True), + sa.Column("description", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=50), nullable=True), + sa.Column("progress", sa.Integer(), nullable=True), + sa.Column("message", sa.String(length=500), nullable=True), + sa.Column("current_step", sa.String(length=255), nullable=True), + sa.Column("total_steps", sa.Integer(), nullable=True), + sa.Column("current_step_description", sa.String(length=1024), nullable=True), + sa.Column("current_step_details", sa.Text(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "system_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("component", sa.String(length=50), nullable=False), + sa.Column("log_level", sa.String(length=50), nullable=True), + sa.Column("log_message", sa.String(length=500), nullable=False), + sa.Column("user_agent", sa.String(length=255), nullable=True), + sa.Column("ip_address", sa.String(length=45), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "project_logs", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=False), + sa.Column("log_level", sa.String(length=50), nullable=True), + sa.Column("log_message", sa.String(length=500), nullable=False), + sa.Column("timestamp", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "prompt_code_links", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=False), + sa.Column("project_id", sa.String(length=255), nullable=False), + sa.Column("prompt_audit_id", sa.Integer(), nullable=False), + sa.Column("code_change_audit_id", sa.Integer(), nullable=False), + sa.Column("file_path", sa.String(length=500), nullable=True), + sa.Column("change_type", sa.String(length=50), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pull_request_data", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=False), + sa.Column("pr_number", sa.Integer(), nullable=False), + sa.Column("pr_title", sa.String(length=500), nullable=False), + sa.Column("pr_body", sa.Text(), nullable=True), + sa.Column("pr_state", sa.String(length=50), nullable=False), + sa.Column("pr_url", sa.String(length=500), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "pull_requests", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=False), + sa.Column("pr_number", sa.Integer(), nullable=False), + sa.Column("pr_title", sa.String(length=500), nullable=False), + sa.Column("pr_body", sa.Text(), nullable=True), + sa.Column("base", sa.String(length=255), nullable=False), + sa.Column("user", sa.String(length=255), nullable=False), + sa.Column("pr_url", sa.String(length=500), nullable=False), + sa.Column("merged", sa.Boolean(), nullable=True), + sa.Column("merged_at", sa.DateTime(), nullable=True), + sa.Column("pr_state", sa.String(length=50), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "ui_snapshots", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=False), + sa.Column("snapshot_data", sa.JSON(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_table( + "user_actions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("history_id", sa.Integer(), nullable=True), + sa.Column("user_id", sa.String(length=100), nullable=True), + sa.Column("action_type", sa.String(length=100), nullable=True), + sa.Column("actor_type", sa.String(length=50), nullable=True), + sa.Column("actor_name", sa.String(length=100), nullable=True), + sa.Column("action_description", sa.String(length=500), nullable=True), + sa.Column("action_data", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["history_id"], ["project_history.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + +def downgrade() -> None: + op.drop_table("user_actions") + op.drop_table("ui_snapshots") + op.drop_table("pull_requests") + op.drop_table("pull_request_data") + op.drop_table("prompt_code_links") + op.drop_table("project_logs") + op.drop_table("system_logs") + op.drop_table("project_history") + op.drop_table("audit_trail") + op.drop_table("agent_actions") \ No newline at end of file diff --git a/ai_software_factory/config.py b/ai_software_factory/config.py index 76726c1..a155380 100644 --- a/ai_software_factory/config.py +++ b/ai_software_factory/config.py @@ -28,9 +28,15 @@ class Settings(BaseSettings): # n8n settings N8N_WEBHOOK_URL: str = "" N8N_API_URL: str = "" + N8N_API_KEY: str = "" + N8N_TELEGRAM_CREDENTIAL_NAME: str = "AI Software Factory Telegram" N8N_USER: str = "" N8N_PASSWORD: str = "" + # Runtime integration settings + BACKEND_PUBLIC_URL: str = "http://localhost:8000" + PROJECTS_ROOT: str = "" + # Telegram settings TELEGRAM_BOT_TOKEN: str = "" TELEGRAM_CHAT_ID: str = "" @@ -104,6 +110,21 @@ class Settings(BaseSettings): """Get n8n webhook URL with trimmed whitespace.""" return self.N8N_WEBHOOK_URL.strip() + @property + def n8n_api_url(self) -> str: + """Get n8n API URL with trimmed whitespace.""" + return self.N8N_API_URL.strip() + + @property + def n8n_api_key(self) -> str: + """Get n8n API key with trimmed whitespace.""" + return self.N8N_API_KEY.strip() + + @property + def n8n_telegram_credential_name(self) -> str: + """Get the preferred n8n Telegram credential name.""" + return self.N8N_TELEGRAM_CREDENTIAL_NAME.strip() or "AI Software Factory Telegram" + @property def telegram_bot_token(self) -> str: """Get Telegram bot token with trimmed whitespace.""" @@ -114,6 +135,18 @@ class Settings(BaseSettings): """Get Telegram chat ID with trimmed whitespace.""" return self.TELEGRAM_CHAT_ID.strip() + @property + def backend_public_url(self) -> str: + """Get backend public URL with trimmed whitespace.""" + return self.BACKEND_PUBLIC_URL.strip().rstrip("/") + + @property + def projects_root(self) -> Path: + """Get the root directory for generated project artifacts.""" + if self.PROJECTS_ROOT.strip(): + return Path(self.PROJECTS_ROOT).expanduser().resolve() + return Path(__file__).resolve().parent.parent / "test-project" + @property def postgres_host(self) -> str: """Get PostgreSQL host.""" diff --git a/ai_software_factory/dashboard_ui.py b/ai_software_factory/dashboard_ui.py index bfb2586..529fb17 100644 --- a/ai_software_factory/dashboard_ui.py +++ b/ai_software_factory/dashboard_ui.py @@ -1,200 +1,265 @@ -"""NiceGUI dashboard for AI Software Factory with real-time database data.""" +"""NiceGUI dashboard backed by real database state.""" + +from __future__ import annotations + +from contextlib import closing from nicegui import ui -from database import get_db, get_engine, init_db, get_db_sync -from models import ProjectHistory, ProjectLog, AuditTrail, UserAction, SystemLog, AgentAction -from datetime import datetime -import logging -logger = logging.getLogger(__name__) +try: + from .agents.database_manager import DatabaseManager + from .agents.n8n_setup import N8NSetupAgent + from .config import settings + from .database import get_db_sync, init_db +except ImportError: + from agents.database_manager import DatabaseManager + from agents.n8n_setup import N8NSetupAgent + from config import settings + from database import get_db_sync, init_db + + +def _resolve_n8n_api_url() -> str: + """Resolve the configured n8n API base URL.""" + if settings.n8n_api_url: + return settings.n8n_api_url + if settings.n8n_webhook_url: + return settings.n8n_webhook_url.split('/webhook', 1)[0].rstrip('/') + return '' + + +def _load_dashboard_snapshot() -> dict: + """Load dashboard data from the database.""" + db = get_db_sync() + if db is None: + return {'error': 'Database session could not be created'} + + with closing(db): + manager = DatabaseManager(db) + try: + return manager.get_dashboard_snapshot(limit=8) + except Exception as exc: + return {'error': f'Database error: {exc}'} def create_dashboard(): - """Create and configure the NiceGUI dashboard with real-time data from database.""" - - # Get database session directly for NiceGUI (not a FastAPI dependency) - db_session = get_db_sync() - - if db_session is None: - ui.label('Database session could not be created. Check configuration and restart the server.') - return - - try: - # Wrap database queries to handle missing tables gracefully - try: - # Fetch current project - current_project = db_session.query(ProjectHistory).order_by(ProjectHistory.created_at.desc()).first() - - # Fetch recent audit trail entries - recent_audits = db_session.query(AuditTrail).order_by(AuditTrail.created_at.desc()).limit(10).all() - - # Fetch recent project history entries - recent_projects = db_session.query(ProjectHistory).order_by(ProjectHistory.created_at.desc()).limit(5).all() - - # Fetch recent system logs - recent_logs = db_session.query(SystemLog).order_by(SystemLog.created_at.desc()).limit(5).all() - except Exception as e: - # Handle missing tables or other database errors - ui.label(f'Database error: {str(e)}. Please run POST /init-db or ensure the database is initialized.') + """Create the main NiceGUI dashboard.""" + ui.add_head_html( + """ + + """ + ) + + async def setup_n8n_workflow_action() -> None: + api_url = _resolve_n8n_api_url() + if not api_url: + ui.notify('Configure N8N_API_URL or N8N_WEBHOOK_URL first', color='negative') return - - # Create main card - with ui.card().classes('w-full max-w-6xl mx-auto').props('elevated').style('max-width: 1200px; margin: 0 auto;') as main_card: - # Header section - with ui.row().classes('items-center gap-4 mb-6').style('padding: 20px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border-radius: 12px; color: white;') as header_row: - title = ui.label('AI Software Factory').style('font-size: 28px; font-weight: bold; margin: 0;') - subtitle = ui.label('Real-time Dashboard & Audit Trail Display').style('font-size: 14px; opacity: 0.9; margin-top: 5px;') - - # Stats grid - with ui.grid(columns=4).props('gutter=1').style('margin-top: 15px;') as stats_grid: - # Current Project - with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card1: - ui.label('Current Project').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') - project_name = current_project.project_name if current_project else 'No active project' - ui.label(project_name).style('font-size: 20px; font-weight: bold; margin-top: 5px;') - - # Active Projects count - with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card2: - ui.label('Active Projects').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') - active_count = len(recent_projects) - ui.label(str(active_count)).style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #00ff88;') - - # Code Generated (calculated from history entries) - with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card3: - ui.label('Code Generated').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') - # Count .py files from history - code_count = sum(1 for p in recent_projects if 'Generated' in p.message) - code_size = sum(p.progress for p in recent_projects) if recent_projects else 0 - ui.label(f'{code_count} files ({code_size}% total)').style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #ffd93d;') - - # Status - with ui.column().classes('text-center').style('background: rgba(255, 255, 255, 0.1); padding: 15px; border-radius: 8px;') as card4: - ui.label('Status').style('font-size: 12px; text-transform: uppercase; opacity: 0.8;') - status = current_project.status if current_project else 'No active project' - ui.label(status).style('font-size: 20px; font-weight: bold; margin-top: 5px; color: #00d4ff;') - - # Separator - ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') - - # Current Status Panel - with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as status_panel: - ui.label('📊 Current Status').style('font-size: 18px; font-weight: bold; color: #4fc3f7; margin-bottom: 10px;') - - with ui.row().classes('items-center gap-4').style('margin-top: 10px;') as progress_row: - if current_project: - ui.label('Progress:').style('color: #bdbdbd;') - ui.label(str(current_project.progress) + '%').style('color: #4fc3f7; font-weight: bold;') - ui.label('').style('color: #bdbdbd;') - else: - ui.label('No active project').style('color: #bdbdbd;') - - if current_project: - ui.label(current_project.message).style('color: #888; margin-top: 8px; font-size: 13px;') - ui.label('Last update: ' + current_project.updated_at.strftime('%H:%M:%S')).style('color: #bdbdbd; font-size: 12px; margin-top: 5px;') - else: - ui.label('Waiting for a new project...').style('color: #888; margin-top: 8px; font-size: 13px;') - - # Separator - ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') - - # Active Projects Section - with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as projects_section: - ui.label('📁 Active Projects').style('font-size: 18px; font-weight: bold; color: #81c784; margin-bottom: 10px;') - - with ui.row().style('gap: 10px;') as projects_list: - for i, project in enumerate(recent_projects[:3], 1): - with ui.card().props('elevated rounded').style('background: rgba(0, 255, 136, 0.15); border: 1px solid rgba(0, 255, 136, 0.4);') as project_item: - ui.label(str(i + len(recent_projects)) + '. ' + project.project_name).style('font-size: 16px; font-weight: bold; color: white;') - ui.label('• Agent: Orchestrator').style('font-size: 12px; color: #bdbdbd;') - ui.label('• Status: ' + project.status).style('font-size: 11px; color: #81c784; margin-top: 3px;') - if not recent_projects: - ui.label('No active projects yet.').style('font-size: 14px; color: #bdbdbd;') - - # Separator - ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') - - # Audit Trail Section - with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px; margin-bottom: 15px;') as audit_section: - ui.label('📜 Audit Trail').style('font-size: 18px; font-weight: bold; color: #ffe082; margin-bottom: 10px;') - - with ui.data_table( - headers=['Timestamp', 'Component', 'Action', 'Level'], - columns=[ - {'name': 'Timestamp', 'field': 'created_at', 'width': '180px'}, - {'name': 'Component', 'field': 'component', 'width': '150px'}, - {'name': 'Action', 'field': 'action', 'width': '250px'}, - {'name': 'Level', 'field': 'log_level', 'width': '100px'}, - ], - row_height=36, - ) as table: - # Populate table with audit trail data - audit_rows = [] - for audit in recent_audits: - status = 'Success' if audit.log_level.upper() in ['INFO', 'SUCCESS'] else audit.log_level.upper() - audit_rows.append({ - 'created_at': audit.created_at.strftime('%Y-%m-%d %H:%M:%S'), - 'component': audit.component or 'System', - 'action': audit.action or audit.message[:50], - 'log_level': status[:15], - }) - table.rows = audit_rows - - if not recent_audits: - ui.label('No audit trail entries yet.').style('font-size: 12px; color: #bdbdbd;') - - # Separator - ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') - - # System Logs Section - with ui.column().style('background: rgba(255, 255, 255, 0.08); padding: 20px; border-radius: 12px;') as logs_section: - ui.label('⚙️ System Logs').style('font-size: 18px; font-weight: bold; color: #ff8a80; margin-bottom: 10px;') - - with ui.data_table( - headers=['Component', 'Level', 'Message'], - columns=[ - {'name': 'Component', 'field': 'component', 'width': '150px'}, - {'name': 'Level', 'field': 'log_level', 'width': '100px'}, - {'name': 'Message', 'field': 'log_message', 'width': '450px'}, - ], - row_height=32, - ) as logs_table: - logs_table.rows = [ - { - 'component': log.component, - 'log_level': log.log_level, - 'log_message': log.log_message[:50] + '...' if len(log.log_message) > 50 else log.log_message - } - for log in recent_logs - ] - - if not recent_logs: - ui.label('No system logs yet.').style('font-size: 12px; color: #bdbdbd;') - - # Separator - ui.separator(style='margin: 15px 0; color: rgba(255, 255, 255, 0.3);') - - # API Endpoints Section - with ui.expansion_group('🔗 Available API Endpoints', default_open=True).props('dense') as api_section: - with ui.column().style('font-size: 12px; color: #78909c;') as endpoint_list: - endpoints = [ - ['/ (root)', 'Dashboard'], - ['/generate', 'Generate new software (POST)'], - ['/health', 'Health check'], - ['/projects', 'List all projects'], - ['/status/{project_id}', 'Get project status'], - ['/audit/projects', 'Get project audit data'], - ['/audit/logs', 'Get system logs'], - ['/audit/trail', 'Get audit trail'], - ['/audit/actions', 'Get user actions'], - ['/audit/history', 'Get project history'], - ['/audit/prompts', 'Get prompts'], - ['/audit/changes', 'Get code changes'], - ['/init-db', 'Initialize database (POST)'], - ] - for endpoint, desc in endpoints: - ui.label(f'• {endpoint:<30} {desc}') - finally: - db_session.close() + + agent = N8NSetupAgent(api_url=api_url, webhook_token=settings.n8n_api_key) + result = await agent.setup( + webhook_path='telegram', + backend_url=f'{settings.backend_public_url}/generate', + force_update=True, + ) + + db = get_db_sync() + if db is not None: + with closing(db): + DatabaseManager(db).log_system_event( + component='n8n', + level='INFO' if result.get('status') == 'success' else 'ERROR', + message=result.get('message', str(result)), + ) + + ui.notify(result.get('message', 'n8n setup finished'), color='positive' if result.get('status') == 'success' else 'negative') + dashboard_body.refresh() + + def init_db_action() -> None: + result = init_db() + ui.notify(result.get('message', 'Database initialized'), color='positive' if result.get('status') == 'success' else 'negative') + dashboard_body.refresh() + + @ui.refreshable + def dashboard_body() -> None: + snapshot = _load_dashboard_snapshot() + if snapshot.get('error'): + with ui.card().classes('factory-panel w-full max-w-4xl mx-auto q-pa-xl'): + ui.label('Dashboard unavailable').style('font-size: 1.5rem; font-weight: 700; color: #5c2d1f;') + ui.label(snapshot['error']).classes('factory-muted') + ui.button('Initialize Database', on_click=init_db_action).props('unelevated') + return + + summary = snapshot['summary'] + projects = snapshot['projects'] + correlations = snapshot['correlations'] + system_logs = snapshot['system_logs'] + + with ui.column().classes('factory-shell w-full gap-4 q-pa-lg'): + with ui.card().classes('factory-panel w-full q-pa-lg'): + with ui.row().classes('items-center justify-between w-full'): + with ui.column().classes('gap-1'): + ui.label('AI Software Factory').style('font-size: 2.3rem; font-weight: 800; color: #302116;') + ui.label('Operational dashboard with project audit, prompt traces, and n8n controls.').classes('factory-muted') + with ui.row().classes('items-center gap-2'): + ui.button('Refresh', on_click=dashboard_body.refresh).props('outline') + ui.button('Initialize DB', on_click=init_db_action).props('unelevated color=dark') + ui.button('Provision n8n Workflow', on_click=setup_n8n_workflow_action).props('unelevated color=accent') + + with ui.grid(columns=4).classes('w-full gap-4'): + metrics = [ + ('Projects', summary['total_projects'], 'Tracked generation requests'), + ('Completed', summary['completed_projects'], 'Finished project runs'), + ('Prompts', summary['prompt_events'], 'Recorded originating prompts'), + ('Code Changes', summary['code_changes'], 'Audited generated file writes'), + ] + for title, value, subtitle in metrics: + with ui.card().classes('factory-kpi'): + ui.label(title).style('font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; opacity: 0.8;') + ui.label(str(value)).style('font-size: 2.1rem; font-weight: 800; margin-top: 6px;') + ui.label(subtitle).style('font-size: 0.9rem; opacity: 0.78; margin-top: 8px;') + + tabs = ui.tabs().classes('w-full') + overview_tab = ui.tab('Overview') + projects_tab = ui.tab('Projects') + trace_tab = ui.tab('Prompt Trace') + system_tab = ui.tab('System') + + with ui.tab_panels(tabs, value=overview_tab).classes('w-full'): + with ui.tab_panel(overview_tab): + with ui.grid(columns=2).classes('w-full gap-4'): + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('Project Pipeline').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + if projects: + for project_bundle in projects[:4]: + project = project_bundle['project'] + with ui.column().classes('gap-1 q-mt-md'): + with ui.row().classes('justify-between items-center'): + ui.label(project['project_name']).style('font-weight: 700; color: #2f241d;') + ui.label(project['status']).classes('factory-chip') + ui.linear_progress(value=(project['progress'] or 0) / 100, show_value=False).classes('w-full') + ui.label(project['message'] or 'No status message').classes('factory-muted') + else: + ui.label('No projects in the database yet.').classes('factory-muted') + + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('n8n and Runtime').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + rows = [ + ('Backend URL', settings.backend_public_url), + ('Project Root', str(settings.projects_root)), + ('n8n API URL', _resolve_n8n_api_url() or 'Not configured'), + ('Running Projects', str(summary['running_projects'])), + ('Errored Projects', str(summary['error_projects'])), + ] + for label, value in rows: + with ui.row().classes('justify-between w-full q-mt-sm'): + ui.label(label).classes('factory-muted') + ui.label(value).style('font-weight: 600; color: #3a281a;') + + with ui.tab_panel(projects_tab): + if not projects: + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('No project data available yet.').classes('factory-muted') + for project_bundle in projects: + project = project_bundle['project'] + with ui.expansion(f"{project['project_name']} · {project['status']}", icon='folder').classes('factory-panel w-full q-mb-md'): + with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): + with ui.card().classes('q-pa-md'): + ui.label('Prompt').style('font-weight: 700; color: #3a281a;') + prompts = project_bundle.get('prompts', []) + if prompts: + prompt = prompts[0] + ui.markdown(f"**Requested features:** {', '.join(prompt['features']) or 'None'}") + ui.markdown(f"**Tech stack:** {', '.join(prompt['tech_stack']) or 'None'}") + ui.element('div').classes('factory-code').set_text(prompt['prompt_text']) + else: + ui.label('No prompt recorded.').classes('factory-muted') + + with ui.card().classes('q-pa-md'): + ui.label('Generated Changes').style('font-weight: 700; color: #3a281a;') + changes = project_bundle.get('code_changes', []) + if changes: + for change in changes: + with ui.row().classes('justify-between items-start w-full q-mt-sm'): + ui.label(change['file_path'] or 'unknown file').style('font-weight: 600; color: #2f241d;') + ui.label(change['action_type']).classes('factory-chip') + ui.label(change['diff_summary'] or change['details']).classes('factory-muted') + else: + ui.label('No code changes recorded.').classes('factory-muted') + + with ui.grid(columns=2).classes('w-full gap-4 q-pa-md'): + with ui.card().classes('q-pa-md'): + ui.label('Recent Logs').style('font-weight: 700; color: #3a281a;') + logs = project_bundle.get('logs', [])[:6] + if logs: + for log in logs: + ui.markdown(f"- {log['timestamp'] or 'n/a'} · {log['level']} · {log['message']}") + else: + ui.label('No project logs yet.').classes('factory-muted') + + with ui.card().classes('q-pa-md'): + ui.label('Audit Trail').style('font-weight: 700; color: #3a281a;') + audits = project_bundle.get('audit_trail', [])[:6] + if audits: + for audit in audits: + ui.markdown(f"- {audit['timestamp'] or 'n/a'} · {audit['action']} · {audit['details']}") + else: + ui.label('No audit events yet.').classes('factory-muted') + + with ui.tab_panel(trace_tab): + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('Prompt to Code Correlation').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + ui.label('Each prompt entry is linked to the generated files recorded after that prompt for the same project.').classes('factory-muted') + if correlations: + for correlation in correlations: + with ui.card().classes('q-pa-md q-mt-md'): + ui.label(correlation['project_id']).style('font-size: 1rem; font-weight: 700; color: #2f241d;') + ui.element('div').classes('factory-code q-mt-sm').set_text(correlation['prompt_text']) + if correlation['changes']: + for change in correlation['changes']: + ui.markdown( + f"- **{change['file_path'] or 'unknown'}** · {change['change_type']} · {change['diff_summary'] or change['details']}" + ) + else: + ui.label('No code changes correlated to this prompt yet.').classes('factory-muted') + else: + ui.label('No prompt traces recorded yet.').classes('factory-muted') + + with ui.tab_panel(system_tab): + with ui.grid(columns=2).classes('w-full gap-4'): + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('System Logs').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + if system_logs: + for log in system_logs: + ui.markdown(f"- {log['timestamp'] or 'n/a'} · **{log['component']}** · {log['level']} · {log['message']}") + else: + ui.label('No system logs yet.').classes('factory-muted') + + with ui.card().classes('factory-panel q-pa-lg'): + ui.label('Important Endpoints').style('font-size: 1.25rem; font-weight: 700; color: #3a281a;') + endpoints = [ + '/health', + '/generate', + '/projects', + '/audit/projects', + '/audit/prompts', + '/audit/changes', + '/audit/correlations', + '/n8n/health', + '/n8n/setup', + ] + for endpoint in endpoints: + ui.label(endpoint).classes('factory-code q-mt-sm') + + dashboard_body() + ui.timer(10.0, dashboard_body.refresh) def run_app(port=None, reload=False, browser=True, storage_secret=None): @@ -202,7 +267,6 @@ def run_app(port=None, reload=False, browser=True, storage_secret=None): ui.run(title='AI Software Factory Dashboard', port=port, reload=reload, browser=browser, storage_secret=storage_secret) -# Create and run the app if __name__ in {'__main__', '__console__'}: create_dashboard() run_app() \ No newline at end of file diff --git a/ai_software_factory/database.py b/ai_software_factory/database.py index ed3977e..8dcf2be 100644 --- a/ai_software_factory/database.py +++ b/ai_software_factory/database.py @@ -1,16 +1,28 @@ """Database connection and session management.""" -from sqlalchemy import create_engine, text -from sqlalchemy.orm import sessionmaker, Session -from config import settings -from models import Base +from collections.abc import Generator +from pathlib import Path + +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, event, text +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session, sessionmaker + +try: + from .config import settings + from .models import Base +except ImportError: + from config import settings + from models import Base -def get_engine() -> create_engine: +def get_engine() -> Engine: """Create and return SQLAlchemy engine with connection pooling.""" # Use SQLite for tests, PostgreSQL for production if settings.USE_SQLITE: db_path = settings.SQLITE_DB_PATH or "/tmp/ai_software_factory_test.db" + Path(db_path).expanduser().resolve().parent.mkdir(parents=True, exist_ok=True) db_url = f"sqlite:///{db_path}" # SQLite-specific configuration - no pooling for SQLite engine = create_engine( @@ -47,37 +59,27 @@ def get_engine() -> create_engine: return engine -def get_session() -> Session: - """Create and return database session factory.""" +def get_session() -> Generator[Session, None, None]: + """Yield a managed database session.""" engine = get_engine() SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - - def session_factory() -> Session: - session = SessionLocal() - try: - yield session - session.commit() - except Exception: - session.rollback() - raise - finally: - session.close() - - return session_factory - -def get_db() -> Session: - """Dependency for FastAPI routes that need database access.""" - engine = get_engine() - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - session = SessionLocal() try: yield session + session.commit() + except Exception: + session.rollback() + raise finally: session.close() +def get_db() -> Generator[Session, None, None]: + """Dependency for FastAPI routes that need database access.""" + yield from get_session() + + def get_db_sync() -> Session: """Get a database session directly (for non-FastAPI/NiceGUI usage).""" engine = get_engine() @@ -92,15 +94,38 @@ def get_db_session() -> Session: return session +def get_alembic_config(database_url: str | None = None) -> Config: + """Return an Alembic config bound to the active database URL.""" + package_root = Path(__file__).resolve().parent + alembic_ini = package_root / "alembic.ini" + config = Config(str(alembic_ini)) + config.set_main_option("script_location", str(package_root / "alembic")) + config.set_main_option("sqlalchemy.url", database_url or (settings.database_url if not settings.USE_SQLITE else f"sqlite:///{settings.SQLITE_DB_PATH or '/tmp/ai_software_factory_test.db'}")) + return config + + +def run_migrations(database_url: str | None = None) -> dict: + """Apply Alembic migrations to the configured database.""" + try: + config = get_alembic_config(database_url) + command.upgrade(config, "head") + return {"status": "success", "message": "Database migrations applied."} + except Exception as exc: + return {"status": "error", "message": str(exc)} + + def init_db() -> dict: """Initialize database tables and database if needed.""" if settings.USE_SQLITE: - # SQLite - auto-creates file and tables + result = run_migrations() + if result["status"] == "success": + print("SQLite database migrations applied successfully.") + return {"status": "success", "message": "SQLite database initialized via migrations."} engine = get_engine() try: Base.metadata.create_all(bind=engine) print("SQLite database tables created successfully.") - return {'status': 'success', 'message': 'SQLite database initialized.'} + return {"status": "success", "message": "SQLite database initialized with metadata fallback."} except Exception as e: print(f"Error initializing SQLite database: {str(e)}") return {'status': 'error', 'message': f'Error: {str(e)}'} @@ -138,11 +163,15 @@ def init_db() -> dict: with engine.connect() as conn: # Just create tables in postgres database for now print(f"Using existing 'postgres' database.") - - # Create tables - Base.metadata.create_all(bind=engine) - print(f"PostgreSQL database '{db_name}' tables created successfully.") - return {'status': 'success', 'message': f'PostgreSQL database "{db_name}" initialized.'} + + migration_result = run_migrations(db_url) + if migration_result["status"] == "success": + print(f"PostgreSQL database '{db_name}' migrations applied successfully.") + return {'status': 'success', 'message': f'PostgreSQL database "{db_name}" initialized via migrations.'} + + Base.metadata.create_all(bind=engine) + print(f"PostgreSQL database '{db_name}' tables created successfully.") + return {'status': 'success', 'message': f'PostgreSQL database "{db_name}" initialized with metadata fallback.'} except Exception as e: print(f"Error initializing PostgreSQL database: {str(e)}") @@ -176,27 +205,4 @@ def drop_db() -> dict: def create_migration_script() -> str: """Generate a migration script for database schema changes.""" - return '''-- Migration script for AI Software Factory database --- Generated automatically - review before applying - --- Add new columns to existing tables if needed --- This is a placeholder for future migrations - --- Example: Add audit_trail_index for better query performance -CREATE INDEX IF NOT EXISTS idx_audit_trail_timestamp ON audit_trail(timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_trail_action ON audit_trail(action); -CREATE INDEX IF NOT EXISTS idx_audit_trail_project ON audit_trail(project_id); - --- Example: Add user_actions_index for better query performance -CREATE INDEX IF NOT EXISTS idx_user_actions_timestamp ON user_actions(timestamp); -CREATE INDEX IF NOT EXISTS idx_user_actions_actor ON user_actions(actor_type, actor_name); -CREATE INDEX IF NOT EXISTS idx_user_actions_history ON user_actions(history_id); - --- Example: Add project_logs_index for better query performance -CREATE INDEX IF NOT EXISTS idx_project_logs_timestamp ON project_logs(timestamp); -CREATE INDEX IF NOT EXISTS idx_project_logs_level ON project_logs(log_level); - --- Example: Add system_logs_index for better query performance -CREATE INDEX IF NOT EXISTS idx_system_logs_timestamp ON system_logs(timestamp); -CREATE INDEX IF NOT EXISTS idx_system_logs_component ON system_logs(component); -''' \ No newline at end of file + return """See ai_software_factory/alembic/versions for managed schema migrations.""" \ No newline at end of file diff --git a/ai_software_factory/frontend.py b/ai_software_factory/frontend.py index 349d8ed..0a9abda 100644 --- a/ai_software_factory/frontend.py +++ b/ai_software_factory/frontend.py @@ -7,7 +7,11 @@ The dashboard shown is from dashboard_ui.py with real-time database data. from fastapi import FastAPI from nicegui import app, ui -from dashboard_ui import create_dashboard + +try: + from .dashboard_ui import create_dashboard +except ImportError: + from dashboard_ui import create_dashboard def init(fastapi_app: FastAPI, storage_secret: str = 'Secr2t!') -> None: diff --git a/ai_software_factory/main.py b/ai_software_factory/main.py index 350f624..a9c8ed0 100644 --- a/ai_software_factory/main.py +++ b/ai_software_factory/main.py @@ -11,24 +11,341 @@ The NiceGUI frontend provides: 3. Audit trail display """ -import frontend -from fastapi import FastAPI -from database import init_db +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Annotated +from uuid import uuid4 + +from fastapi import Depends, FastAPI, HTTPException, Query +from pydantic import BaseModel, Field +from sqlalchemy.orm import Session + +try: + from . import __version__, frontend + from . import database as database_module + from .agents.database_manager import DatabaseManager + from .agents.orchestrator import AgentOrchestrator + from .agents.n8n_setup import N8NSetupAgent + from .agents.ui_manager import UIManager + from .models import ProjectHistory, ProjectLog, SystemLog +except ImportError: + import frontend + import database as database_module + from agents.database_manager import DatabaseManager + from agents.orchestrator import AgentOrchestrator + from agents.n8n_setup import N8NSetupAgent + from agents.ui_manager import UIManager + from models import ProjectHistory, ProjectLog, SystemLog + + __version__ = "0.0.1" app = FastAPI() +DbSession = Annotated[Session, Depends(database_module.get_db)] +PROJECT_ID_PATTERN = re.compile(r"[^a-z0-9]+") + + +class SoftwareRequest(BaseModel): + """Request body for software generation.""" + + name: str = Field(min_length=1, max_length=255) + description: str = Field(min_length=1, max_length=255) + features: list[str] = Field(default_factory=list) + tech_stack: list[str] = Field(default_factory=list) + + +class N8NSetupRequest(BaseModel): + """Request body for n8n workflow provisioning.""" + + api_url: str | None = None + api_key: str | None = None + webhook_path: str = "telegram" + backend_url: str | None = None + force_update: bool = False + + +def _build_project_id(name: str) -> str: + """Create a stable project id from the requested name.""" + slug = PROJECT_ID_PATTERN.sub("-", name.strip().lower()).strip("-") or "project" + return f"{slug}-{uuid4().hex[:8]}" + + +def _serialize_project(history: ProjectHistory) -> dict: + """Serialize a project history row for API responses.""" + return { + "history_id": history.id, + "project_id": history.project_id, + "name": history.project_name, + "description": history.description, + "status": history.status, + "progress": history.progress, + "message": history.message, + "current_step": history.current_step, + "error_message": history.error_message, + "created_at": history.created_at.isoformat() if history.created_at else None, + "updated_at": history.updated_at.isoformat() if history.updated_at else None, + "completed_at": history.completed_at.isoformat() if history.completed_at else None, + } + + +def _serialize_project_log(log: ProjectLog) -> dict: + """Serialize a project log row.""" + return { + "id": log.id, + "history_id": log.history_id, + "level": log.log_level, + "message": log.log_message, + "timestamp": log.timestamp.isoformat() if log.timestamp else None, + } + + +def _serialize_system_log(log: SystemLog) -> dict: + """Serialize a system log row.""" + return { + "id": log.id, + "component": log.component, + "level": log.log_level, + "message": log.log_message, + "user_agent": log.user_agent, + "ip_address": log.ip_address, + "timestamp": log.created_at.isoformat() if log.created_at else None, + } + + +def _serialize_audit_item(item: dict) -> dict: + """Return audit-shaped dictionaries unchanged for API output.""" + return item + + +def _compose_prompt_text(request: SoftwareRequest) -> str: + """Render the originating software request into a stable prompt string.""" + features = ", ".join(request.features) if request.features else "None" + tech_stack = ", ".join(request.tech_stack) if request.tech_stack else "None" + return ( + f"Name: {request.name}\n" + f"Description: {request.description}\n" + f"Features: {features}\n" + f"Tech Stack: {tech_stack}" + ) + + +def _project_root(project_id: str) -> Path: + """Resolve the filesystem location for a generated project.""" + return database_module.settings.projects_root / project_id + + +def _resolve_n8n_api_url(explicit_url: str | None = None) -> str: + """Resolve the effective n8n API URL from explicit input or settings.""" + if explicit_url and explicit_url.strip(): + return explicit_url.strip() + if database_module.settings.n8n_api_url: + return database_module.settings.n8n_api_url + webhook_url = database_module.settings.n8n_webhook_url + if webhook_url: + return webhook_url.split("/webhook", 1)[0].rstrip("/") + return "" + @app.get('/') def read_root(): - """Root endpoint that returns welcome message.""" - return {'Hello': 'World'} + """Root endpoint that returns service metadata.""" + return { + 'service': 'AI Software Factory', + 'version': __version__, + 'endpoints': [ + '/', + '/health', + '/generate', + '/projects', + '/status/{project_id}', + '/audit/projects', + '/audit/logs', + '/audit/system/logs', + '/audit/prompts', + '/audit/changes', + '/audit/lineage', + '/audit/correlations', + '/n8n/health', + '/n8n/setup', + ], + } + + +@app.get('/health') +def health_check(): + """Health check endpoint.""" + return { + 'status': 'healthy', + 'database': 'sqlite' if database_module.settings.USE_SQLITE else 'postgresql', + } + + +@app.post('/generate') +async def generate_software(request: SoftwareRequest, db: DbSession): + """Create and record a software-generation request.""" + database_module.init_db() + + project_id = _build_project_id(request.name) + prompt_text = _compose_prompt_text(request) + orchestrator = AgentOrchestrator( + project_id=project_id, + project_name=request.name, + description=request.description, + features=request.features, + tech_stack=request.tech_stack, + db=db, + prompt_text=prompt_text, + ) + result = await orchestrator.run() + + manager = DatabaseManager(db) + manager.log_system_event( + component='api', + level='INFO' if result['status'] == 'completed' else 'ERROR', + message=f"Generated project {project_id} with {len(result.get('changed_files', []))} artifact(s)", + ) + + history = manager.get_project_by_id(project_id) + project_logs = manager.get_project_logs(history.id) + response_data = _serialize_project(history) + response_data['logs'] = [_serialize_project_log(log) for log in project_logs] + response_data['ui_data'] = result.get('ui_data') + response_data['features'] = request.features + response_data['tech_stack'] = request.tech_stack + response_data['project_root'] = result.get('project_root', str(_project_root(project_id))) + response_data['changed_files'] = result.get('changed_files', []) + + return {'status': result['status'], 'data': response_data} + + +@app.get('/projects') +def list_projects(db: DbSession): + """List recorded projects.""" + manager = DatabaseManager(db) + projects = manager.get_all_projects() + return {'projects': [_serialize_project(project) for project in projects]} + + +@app.get('/status/{project_id}') +def get_project_status(project_id: str, db: DbSession): + """Get the current status for a single project.""" + manager = DatabaseManager(db) + history = manager.get_project_by_id(project_id) + if history is None: + raise HTTPException(status_code=404, detail='Project not found') + return _serialize_project(history) + + +@app.get('/audit/projects') +def get_audit_projects(db: DbSession): + """Return projects together with their related logs and audit data.""" + manager = DatabaseManager(db) + projects = [] + for history in manager.get_all_projects(): + project_data = _serialize_project(history) + audit_data = manager.get_project_audit_data(history.project_id) + project_data['logs'] = audit_data['logs'] + project_data['actions'] = audit_data['actions'] + project_data['audit_trail'] = audit_data['audit_trail'] + projects.append(project_data) + return {'projects': projects} + + +@app.get('/audit/prompts') +def get_prompt_audit(db: DbSession, project_id: str | None = Query(default=None)): + """Return stored prompt submissions.""" + manager = DatabaseManager(db) + return {'prompts': [_serialize_audit_item(item) for item in manager.get_prompt_events(project_id=project_id)]} + + +@app.get('/audit/changes') +def get_code_change_audit(db: DbSession, project_id: str | None = Query(default=None)): + """Return recorded code changes.""" + manager = DatabaseManager(db) + return {'changes': [_serialize_audit_item(item) for item in manager.get_code_changes(project_id=project_id)]} + + +@app.get('/audit/lineage') +def get_prompt_change_lineage(db: DbSession, project_id: str | None = Query(default=None)): + """Return explicit prompt-to-code lineage rows.""" + manager = DatabaseManager(db) + return {'lineage': manager.get_prompt_change_links(project_id=project_id)} + + +@app.get('/audit/correlations') +def get_prompt_change_correlations(db: DbSession, project_id: str | None = Query(default=None)): + """Return prompt-to-change correlations for generated projects.""" + manager = DatabaseManager(db) + return {'correlations': manager.get_prompt_change_correlations(project_id=project_id)} + + +@app.get('/audit/logs') +def get_audit_logs(db: DbSession): + """Return all project logs ordered newest first.""" + logs = db.query(ProjectLog).order_by(ProjectLog.id.desc()).all() + return {'logs': [_serialize_project_log(log) for log in logs]} + + +@app.get('/audit/system/logs') +def get_system_audit_logs( + db: DbSession, + component: str | None = Query(default=None), +): + """Return system logs with optional component filtering.""" + query = db.query(SystemLog).order_by(SystemLog.id.desc()) + if component: + query = query.filter(SystemLog.component == component) + return {'logs': [_serialize_system_log(log) for log in query.all()]} + + +@app.get('/n8n/health') +async def get_n8n_health(): + """Check whether the configured n8n instance is reachable.""" + api_url = _resolve_n8n_api_url() + if not api_url: + return {'status': 'error', 'message': 'N8N_API_URL or N8N_WEBHOOK_URL is not configured'} + agent = N8NSetupAgent(api_url=api_url, webhook_token=database_module.settings.n8n_api_key) + result = await agent.health_check() + return {'status': 'ok' if not result.get('error') else 'error', 'data': result} + + +@app.post('/n8n/setup') +async def setup_n8n_workflow(request: N8NSetupRequest, db: DbSession): + """Create or update the n8n Telegram workflow.""" + api_url = _resolve_n8n_api_url(request.api_url) + if not api_url: + raise HTTPException(status_code=400, detail='n8n API URL is not configured') + + agent = N8NSetupAgent( + api_url=api_url, + webhook_token=(request.api_key or database_module.settings.n8n_api_key), + ) + result = await agent.setup( + webhook_path=request.webhook_path, + backend_url=request.backend_url or f"{database_module.settings.backend_public_url}/generate", + force_update=request.force_update, + telegram_bot_token=database_module.settings.telegram_bot_token, + telegram_credential_name=database_module.settings.n8n_telegram_credential_name, + ) + + manager = DatabaseManager(db) + log_level = 'INFO' if result.get('status') != 'error' else 'ERROR' + manager.log_system_event( + component='n8n', + level=log_level, + message=result.get('message', json.dumps(result)), + ) + return result @app.post('/init-db') def initialize_database(): """Initialize database tables (POST endpoint for NiceGUI to call before dashboard).""" try: - init_db() + database_module.init_db() return {'message': 'Database tables created successfully', 'status': 'success'} except Exception as e: return {'message': f'Error initializing database: {str(e)}', 'status': 'error'} diff --git a/ai_software_factory/models.py b/ai_software_factory/models.py index 98bf938..efdc5bf 100644 --- a/ai_software_factory/models.py +++ b/ai_software_factory/models.py @@ -10,7 +10,10 @@ from sqlalchemy import ( ) from sqlalchemy.orm import relationship, declarative_base -from config import settings +try: + from .config import settings +except ImportError: + from config import settings Base = declarative_base() logger = logging.getLogger(__name__) @@ -52,6 +55,7 @@ class ProjectHistory(Base): ui_snapshots = relationship("UISnapshot", back_populates="project_history", cascade="all, delete-orphan") pull_requests = relationship("PullRequest", back_populates="project_history", cascade="all, delete-orphan") pull_request_data = relationship("PullRequestData", back_populates="project_history", cascade="all, delete-orphan") + prompt_code_links = relationship("PromptCodeLink", back_populates="project_history", cascade="all, delete-orphan") class ProjectLog(Base): @@ -145,6 +149,22 @@ class AuditTrail(Base): metadata_json = Column(JSON, nullable=True) +class PromptCodeLink(Base): + """Explicit lineage between a prompt event and a resulting code change.""" + __tablename__ = "prompt_code_links" + + id = Column(Integer, primary_key=True) + history_id = Column(Integer, ForeignKey("project_history.id"), nullable=False) + project_id = Column(String(255), nullable=False) + prompt_audit_id = Column(Integer, nullable=False) + code_change_audit_id = Column(Integer, nullable=False) + file_path = Column(String(500), nullable=True) + change_type = Column(String(50), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + project_history = relationship("ProjectHistory", back_populates="prompt_code_links") + + class UserAction(Base): """User action audit entries.""" __tablename__ = "user_actions" diff --git a/ai_software_factory/requirements.txt b/ai_software_factory/requirements.txt index 7fec392..6c42271 100644 --- a/ai_software_factory/requirements.txt +++ b/ai_software_factory/requirements.txt @@ -15,4 +15,7 @@ isort==5.13.2 flake8==6.1.0 mypy==1.7.1 httpx==0.25.2 -nicegui==3.9.0 \ No newline at end of file +nicegui==3.9.0 +aiohttp>=3.9.0 +pytest-asyncio>=0.23.0 +alembic>=1.14.0 \ No newline at end of file diff --git a/sqlite.db b/sqlite.db new file mode 100644 index 0000000..78bbdd5 Binary files /dev/null and b/sqlite.db differ