From 7180031d1fefde4bbe0707f521e123db69cc228b Mon Sep 17 00:00:00 2001 From: Simon Diesenreiter Date: Fri, 10 Apr 2026 19:37:44 +0200 Subject: [PATCH] feat(factory): implement db-backed dashboard and workflow automation refs NOISSUE --- .../agents/database_manager.py | 364 ++++++++++++- ai_software_factory/agents/git_manager.py | 16 +- ai_software_factory/agents/n8n_setup.py | 491 +++++++++++------- ai_software_factory/agents/orchestrator.py | 220 +++++--- ai_software_factory/agents/ui_manager.py | 10 +- ai_software_factory/alembic.ini | 36 ++ ai_software_factory/alembic/env.py | 50 ++ ai_software_factory/alembic/script.py.mako | 17 + .../versions/20260410_01_initial_schema.py | 164 ++++++ ai_software_factory/config.py | 33 ++ ai_software_factory/dashboard_ui.py | 446 +++++++++------- ai_software_factory/database.py | 120 +++-- ai_software_factory/frontend.py | 6 +- ai_software_factory/main.py | 329 +++++++++++- ai_software_factory/models.py | 22 +- ai_software_factory/requirements.txt | 5 +- sqlite.db | Bin 0 -> 151552 bytes 17 files changed, 1794 insertions(+), 535 deletions(-) create mode 100644 ai_software_factory/alembic.ini create mode 100644 ai_software_factory/alembic/env.py create mode 100644 ai_software_factory/alembic/script.py.mako create mode 100644 ai_software_factory/alembic/versions/20260410_01_initial_schema.py create mode 100644 sqlite.db 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 0000000000000000000000000000000000000000..78bbdd5b5104eadf774195e20dbb13b2cbb10590 GIT binary patch literal 151552 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